Compare commits

..
78 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5 53fb713f74 Release 1.18.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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:32:39 +02:00
kantarciandClaude Opus 5 c8a8fa7460 Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog
Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:32:35 +02:00
kantarciandClaude Fable 5.1 66ff68abf8 Document verified 1.17.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 Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:16:56 +02:00
kantarciandClaude Fable 5.1 d07154eec1 Release 1.17.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:15:20 +02:00
kantarciandClaude Fable 5.1 7eb462ac7b Redirect the other www spelling to SITE_ORIGIN, hold roles to the role help and fix the audit findings
SITE_ORIGIN alone decides whether the site is www or not: CanonicalHostSubscriber
sends the other spelling there with 301, cms:site:origin writes the setting, the
system screen shows it. The roles now match the role help (editor: drafts,
publisher: live content, admin: design, settings, users, system). The installer no
longer reopens on an unreachable database, the session cookie follows ADMIN_PATH,
percent-encoded credentials no longer break the container, consent only accepts
same-site decisions, JSON-LD is HTML-safe, own CSS stays in its block, plus the
editor, updater, download and public-output findings from the full review
(ADR 099, ADR 100).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 20:15:20 +02:00
kantarciandClaude Fable 5.1 45040b2ca6 Document verified 1.16.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-15 15:20:44 +02:00
kantarciandClaude Fable 5.1 937344e642 Release 1.16.1
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-15 15:19:17 +02:00
kantarciandClaude Fable 5.1 232e6fe6dc Let a single column sit in a section and give buttons an alignment
A column may now live directly in a section, not only in a row - the way
to centre or narrow a button or a short text without a one-column row.
The align design token applies to buttons; because a button is
inline-flex, auto margins never centred it, so site.css turns an aligned
button into a fit-content block per device. The Drews seed uses the
token for its centred buttons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 14:49:37 +02:00
kantarciandClaude Fable 5.1 533e181590 Rebuild maler-drews.com as a fourth installation and fix two public-page gaps
Ihr Malerservice Drews (Gernsbach) as its own installation: seed script with
theme, header, footer, eight pages, 31 posts in two categories, contact form,
consent-gated map and videos, redirects; the wording lives in a generated
content file. Reset script, launch entry on port 8093, Cormorant Garamond
served locally with its OFL text.

Two CMS fixes found while building: wide and full sections lost against the
theme's .kant .shell max-width, and the scroll-top link sat outside every
landmark (axe "region"). Lighthouse on six pages: accessibility, best
practices, SEO and agentic browsing 100, performance 98-100; axe zero
violations on nine pages at 1440 and 390 px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 11:11:42 +02:00
kantarciandClaude Fable 5.1 5c2dabdd5d Document verified 1.16.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 Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:40:41 +02:00
kantarciandClaude Fable 5.1 32ee28888e Drag the border between two columns to set their widths in 1.16.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
Umut, with a card of an icon next to a text as the example: "bei Rows die
Breite ändern, am besten per Drag and Drop im Editor". Rows only ever had
equal columns - both the classic `columns` token and the free `gridColumns`
produce `repeat(n, 1fr)`.

A row now carries the free style `columnWidths`, a list of shares such as
`2 1` or `66.7 33.3`, per device and without a new document schema.
`StyleCompiler::columnTracks()` turns it into `grid-template-columns`,
pads or cuts the shares to the number of columns, and gives a device whose
layout stacks the columns a single track, because the desktop rule carries
an id and would otherwise outrank the class that stacks a phone.

In the editor the border between two side-by-side columns of the selected
row is a handle: dragging restyles the row inline while the pointer moves
and writes one undo step on release; the arrow keys nudge by five points.
The design panel shows the same value as the text field "Spaltenbreiten".
The Playwright editor check drags the handle with the real mouse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:39:06 +02:00
kantarciandClaude Fable 5.1 f6350de636 Document verified 1.15.1 release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:18:34 +02:00
kantarciandClaude Fable 5.1 d05dba3042 Let a form send from its own address in 1.15.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Umut asked where a contact form's mail is sent from: it was the recipient
address, `From` equal to `To`. That breaks as soon as the recipient is a
mailbox at another provider, because most hosts only deliver mail that
claims an address of their own domain, and the provider's SPF/DMARC then
treats it as forged.

Each form now has an optional `sender` setting, validated like the
recipient; empty keeps the old behaviour, so stored forms need no
migration. Delivery uses `sender ?: recipient`; the reply-to field is
untouched. The administration shows the field under the recipient with a
note on why it matters. The pilot launch config moves to port 8091 because
8087 was held by another session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 10:16:15 +02:00
kantarciandClaude Fable 5.1 4d1f51801c Document verified 1.15.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 Fable 5.1 <noreply@anthropic.com>
2026-09-15 08:01:42 +02:00
kantarciandClaude Fable 5.1 160c394c3f Rework forms, statistics, design and the sidebar foot in 1.15.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
Forms are an editor now: one card per field with add and remove, the
technical name derived from the label, options only for a choice, and a
new "Aussehen" section with a button label and four colours (field border
and background, button and button text) that travel to the page as one
rule per form inside the hashed style block. A live preview draws the
form with the site's default button and names the text contrast.

Statistics open with six figures (views against the same span before,
average per day, busiest day, pages, referred views, missing addresses),
a chart with grid lines and a title per bar, a weekday chart and ranked
lists with share bars. The design screen shows the palette as colour
cards with seven ready-made palettes, picks the base colours from the
palette, previews every font in itself and the typography as a sample.
The sidebar ends in an account card with role and a logout icon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:59:30 +02:00
kantarciandClaude Opus 5 a4ee04533c Let a header that paints its own surface show through while sticky
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
A section in the header set to #ffffff00 for the scrolled page still looked
white: the sticky header element paints its own white surface and a shadow
line underneath, so a transparent section showed that surface instead of the
page, and the line could be neither recoloured nor removed.

When a top-level header section defines a background of its own - normal,
hover or scrolled - the header element now gets `has-own-surface` and paints
nothing. A header without its own design keeps the white surface, so it does
not turn see-through over the content. A text colour alone is not a surface,
and a boxed column is not the whole header.

The check is a Twig function rather than a controller variable because four
paths render the header: page, archive, editor preview and area preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:15:31 +02:00
kantarciandClaude Fable 5.1 fb7cfa37d1 Document verified 1.14.2 release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:14:46 +02:00
kantarciandClaude Fable 5.1 0aa1ff6d3e Line up the consent notice footer in 1.14.2
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
"Einzeln wählen" sits on the left and the legal links on the right, on one
baseline; opened, the disclosure takes the full width and the links move
below it, right-aligned. `.consent-bar p` outranked `.consent-links` and
took the right margin away, so the links selector now carries the element.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 07:04:08 +02:00
kantarciandClaude Fable 5.1 65b11563a8 Document verified 1.14.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-15 06:59:12 +02:00
kantarciandClaude Fable 5.1 0bdcc7f90e Make the consent notice styleable and keep the back-to-top button reachable in 1.14.1
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 back-to-top button was hidden as long as the consent question was open,
on every device - and on a phone the question tends to stay open. Instead
of `display:none` it now anchors itself 12px above the bar wherever the bar
covers its corner: below 800px for the centred card, at every width for the
full-width bar and the corner card. Browsers without anchor positioning keep
hiding it there. The bar renders before the button so it can be an anchor,
and the reserved bottom padding grows to 320px below 620px, where the bar
with its default text is up to 299px tall.

The notice itself gets a face. Settings → Einwilligung now carries layout
(card, bar, corner), shape (soft, round, square - one setting for the box and
its buttons), a heading and four colours, each as #hex or @palette key.
`Theme::cssColor()` resolves both, replacing the copy inside the back-to-top
code; `Consent::styles()` writes the values as `--kant-consent-*` into the
hashed style block. The base layout changes with it: an accent stripe and
shield icon, equal-width buttons, a disclosure with a chevron, underlined
legal links, a two-column bar on wide screens. Declining stays an outlined
button of the same size next to accepting. The archive and the consent page
carry the same variables - the archive had no palette at all until now.

In the editor, rows with an own value are labelled in orange rather than
bold with a dot; the device chips sit 6px apart with the focus ring inside
the gap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 06:57:38 +02:00
kantarciandClaude Opus 5 860bed9199 Drop the draft notice and give the Moldovan legal pages proper margins
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 legal pages put their width limit on the section itself, which shrank the
section and its container along with it: the text sat 32 px from the window
edge while the header's brand stands at 100 px. The section now stays full
width and only the content column is limited, so title and text share the
header's edge on every device.

Both pages open with a pale band - label, title, lead - in the colour of the
offers section, followed by one row per point: heading on the left, text on
the right, separated by fine rules, stacked on a phone.

The footer no longer carries "Gestaltungsentwurf · Beispieltexte &
illustratives KI-Bild".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:40:29 +02:00
kantarciandClaude Opus 5 1973bd10ef Document verified 1.14.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-15 06:08:04 +02:00
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
kantarciandClaude Fable 5.1 042aef9a32 Fix the slider pause label and three contrast findings in 1.12.1
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
Lighthouse and axe on the Elektro Bernhard site: the slider pause button
had visible text "Pause" but an accessible name that did not contain it
(WCAG 2.5.3); the name now starts with the visible text. On the site the
brand names and the footer credit link lacked contrast and the services
page jumped from H1 to H3. After the fixes: axe zero violations on six
pages, Lighthouse 100 everywhere except mobile home performance 99.
Measurements documented in docs/QUALITY.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 22:04:53 +02:00
kantarciandClaude Fable 5.1 a19b6cc1c9 Document verified 1.12.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 Fable 5.1 <noreply@anthropic.com>
2026-09-14 21:24:33 +02:00
kantarciandClaude Fable 5.1 fd4cb75548 Add social profiles as a block and rebuild Elektro Bernhard in 1.12.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Social profiles are maintained once under settings (one HTTPS address per
network, SocialProfiles::NETWORKS) and shown by the new `social` block
(document schema v23) wherever it stands: size, shape, layout, labels,
new tab and an optional subset of networks on the block; colour, hover,
spacing and alignment through the free block styles. The addresses also
feed sameAs in the structured data. Icons are own outline drawings in
the style of the existing icon set. ADR 093.

Elektro Bernhard is rebuilt with a dark hero over the team photo, a
transparent header that turns white on scroll, Lato, cards with shadows
and hover lift, a review slider with six real Google reviews, a dark
solar band, page headers on every subpage and staggered entrance
effects. Social profiles sit in the top bar and the footer. The site is
a second local reference installation next to the Kant-IT pilot
(var/elektro-bernhard.db, launch config on port 8088); the seed script
rebuilds it from scratch.

Checked: PHP suite 492 tests (only the documented old failure remains),
33 JS tests, Twig lint, PHP style, full editor browser check with axe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 19:29:08 +02:00
kantarciandClaude Fable 5.1 30e4b3eb2e Document verified 1.11.4 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 19:13:26 +02:00
kantarciandClaude Fable 5.1 cef710d5db Lay the slider controls over the picture like the Kant-IT hero in 1.11.4
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Round white arrows left and right, dots bottom right with the active one as
an accent bar, a small pause button and no counter - the design of the hero
slider on kant-it.de. Slider and controls share a positioned shell; arrows
hide on phones. New style property for the active dot colour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 19:11:48 +02:00
kantarciandClaude Fable 5.1 434e89f779 Document verified 1.11.3 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 18:47:34 +02:00
kantarciandClaude Fable 5.1 6dc30a43fe Let the slider controls take their colours from the slider in 1.11.3
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
Dots, arrows, counter and button background are style properties of the
slider ("Slider-Steuerung"), written as custom properties onto the slider and
onto the control bar the script places after it, so the script-free markers
and the scripted controls share the same colours.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:46:36 +02:00
kantarciandClaude Fable 5.1 9095aa5d2c Document verified 1.11.2 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 18:02:53 +02:00
kantarciandClaude Fable 5.1 769b0fae2d Keep the slider moving in Safari and draw real dots in 1.11.2
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
Safari cancels a smooth scroll on a snapping container and snaps back to
the slide it came from, so arrows and autoplay stuttered on the iPhone. The
slider switches snapping off for the ride and restores it on arrival. The
control dots are drawn as dots with the active one filled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:01:53 +02:00
kantarciandClaude Fable 5.1 9c8462d46d Document verified 1.11.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 17:45:54 +02:00
kantarciandClaude Fable 5.1 3785d3dfef Give the header scrolled colours and equal card heights in 1.11.1
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
Cards, quotes and call-to-action bands side by side in a row are equal in
height again: the stacking margin meant for a column no longer applies inside
a row. The header can carry a second set of colours for the scrolled page,
the Flatsome transparent header: a "scrolled" state next to "hover", limited
to colours, shadow and opacity, marked by a small script only where it is
used, plus a setting that lets the header float over the first section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:44:53 +02:00
kantarciandClaude Fable 5.1 b634f486e5 Point the motion check at a block that keeps its entrance
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 Kant-IT home page now animates blocks instead of whole sections, so the
check waits for the closing call to action rather than the contact section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 17:23:43 +02:00
kantarciandClaude Fable 5.1 2299184401 Document verified 1.11.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 Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:42:54 +02:00
kantarciandClaude Fable 5.1 a1f8b905cd Rebuild the editor around one sidebar and play motion by default in 1.11.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 editor follows the Flatsome UX Builder: dark top bar with the device
switch, one sidebar with tabs for elements, structure and page, an element
view with breadcrumbs, property tabs and actions, element cards, a context
menu in the preview and keyboard shortcuts that also work inside the preview.

Entrance and hover effects play for every visitor unless the website chooses
to respect the browser's reduced-motion preference (setting "motion", class
"motion-respect" on <html>). Waiting blocks stay hidden until they scroll into
view and are shown at once when the reader jumps past them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 16:41:04 +02:00
kantarci ebc16777d6 docs: record verified 1.10.2 update recovery and release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 15:38:24 +02:00
kantarci 7c1c86b864 fix: preserve running kernel cache during archive updates
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
2026-09-14 15:35:52 +02:00
kantarci c82a37643b Document verified 1.10.1 release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 15:23:48 +02:00
kantarci cff468167b Use standard form buttons and resume entrance effects in 1.10.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 15:22:42 +02:00
kantarci f182f9e125 Document verified 1.10.0 release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 14:48:26 +02:00
kantarci 37c2942d1d Add device visibility and responsive navigation for 1.10.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
2026-09-14 14:46:55 +02:00
kantarci eea6747d9e Document published 1.9.0 release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 13:48:57 +02:00
kantarci 3391fea58b Add visual button presets, jump targets and menu controls
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 13:47:49 +02:00
kantarci bb4e422af8 Document verified image alignment release 1.8.4
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 13:19:58 +02:00
kantarci 81276ddd1a Fix image centering in normal and responsive layouts
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
2026-09-14 13:12:59 +02:00
kantarci d300eae241 docs: record verified 1.8.3 release artifacts
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 12:54:51 +02:00
kantarci a3029966cb fix: expose accordion contents and add clear insert actions (1.8.3)
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
2026-09-14 12:53:16 +02:00
kantarci a108e7d143 docs: record verified 1.8.2 release artifacts
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
2026-09-14 12:43:55 +02:00
kantarci 55dab4bc1e feat: visual palette picker with accurate color previews (1.8.2)
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
2026-09-14 12:42:21 +02:00
kantarci c1b152b376 docs: record verified 1.8.1 release artifacts
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 12:31:55 +02:00
kantarci 8f475d1374 fix: remove automatically appended footer legal links (1.8.1)
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
2026-09-14 12:30:34 +02:00
kantarci ab86312e87 docs: record verified 1.8.0 release artifacts
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
2026-09-14 12:19:48 +02:00
kantarci 16da844934 feat: assign custom 404 pages with the website theme (1.8.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
2026-09-14 12:18:33 +02:00
kantarci a3c89bb69d docs: record verified 1.7.4 release artifacts
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
2026-09-14 11:57:48 +02:00
kantarci 25fd388f80 fix: reliably reveal image link fields on selection (1.7.4)
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
2026-09-14 11:56:40 +02:00
kantarci e18c881db8 docs: record verified 1.7.3 release artifacts
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:50:19 +02:00
kantarci 6d21c452bd feat: add new-tab links and removable media previews (1.7.3)
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
2026-09-14 11:48:44 +02:00
kantarci b8991ddd5c Dokumentiert das geprüfte Release 1.7.2
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:21:59 +02:00
kantarci 0920e3cb83 Ergänzt Prozentregler für die responsive Bildbreite
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
2026-09-14 11:20:18 +02:00
kantarci d78a58f714 Dokumentiert das geprüfte Release 1.7.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 11:11:16 +02:00
kantarci 41d87f62a4 Ergänzt AJAX-Livesuche für Seiten, Beiträge und Mediathek
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
2026-09-14 11:09:24 +02:00
kantarci 11b60fc5d8 Dokumentiert die geprüfte Veröffentlichung von 1.7.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
2026-09-14 10:52:28 +02:00
kantarci 495b26896e Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
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
2026-09-14 10:49:54 +02:00
kantarci b164c4edfe Dokumentiert die gepruefte Veroeffentlichung von 1.6.2
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
2026-09-14 09:24:04 +02:00
kantarci 6a044e59e9 Fassung 1.6.2: sichtbare leere Layouts und direktes Befuellen im Editor
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
2026-09-14 09:22:23 +02:00
kantarci 149e1fe520 Dokumentiert die gepruefte Veroeffentlichung von 1.6.1
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
2026-09-14 09:00:06 +02:00
kantarci b783d9d1e5 Fassung 1.6.1: flexible Editorflaeche und passende Blockeigenschaften
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
2026-09-14 08:57:42 +02:00
kantarciandClaude Opus 5 05ea703655 Fassung 1.6.0: der Aufgabenplan wird veroeffentlicht
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
Wiederkehrende Aufgaben ohne Cronjob (ADR 073): Sicherung, Statistik kuerzen,
geplante Veroeffentlichung, Updatepruefung und Aufraeumen laufen als Plan in
der Tabelle scheduled_task - eine Zeile je Aufgabe mit Faelligkeit, Sperre und
letztem Ergebnis. Auf geteiltem Hosting gibt es oft keinen Cronjob; der Plan
haengt stattdessen an Anfragen und an einem Takt aus dem geoeffneten Backend.

Diese Arbeit entstand in einer parallel laufenden Sitzung und lag bis hierher
unveroeffentlicht im Arbeitsverzeichnis: die Releases 1.5.0 bis 1.5.5 wurden
bewusst aus dem jeweiligen Tag gebaut und enthielten sie deshalb nicht.

Vor der Veroeffentlichung geprueft, nicht bloss eingepackt:

- phpunit 452/452, npm test 28/28, npm run test:editor vollstaendig.
- Die Migration 20260914000100 legt nur die Tabelle scheduled_task an und nimmt
  sie beim Zurueckrollen wieder weg. Sie fasst keine bestehenden Daten an.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr.

Dabei zwei Fehler behoben:

- config/services.yaml verwendet !tagged_iterator. Das ist gueltiges Symfony,
  aber "lint:yaml config" kennt eigene Tags nur mit --parse-tags und gab 1
  zurueck - der CI-Lauf waere daran gescheitert. Flag ergaenzt.
- AdminHeadersSubscriber.php (aus 1.5.3) entsprach nicht dem Stil von
  php-cs-fixer; composer lint gab 8 zurueck. Behoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:30:13 +02:00
kantarciandClaude Opus 5 d1ac9d760b Fassung 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
gesamte linke Navigation - Marke oben, Fusszeile unten, dazwischen nichts.

Die Navigation steckt in einem <details>: auf dem Telefon ein Klappmenue, am
Schreibtisch die stehende Spalte. Am Schreibtisch wurde der Schalter mit
display:none ausgeblendet - aber das Element trug kein open, und ein
geschlossenes details versteckt seinen Inhalt selbst. Schalter weg und Inhalt
weg.

Behoben mit ::details-content, das den Inhalt am Schreibtisch zurueckholt,
waehrend der Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht,
faellt ueber @supports die ganze Gruppe weg: dann steht der Schalter da und die
Liste ist einen Klick entfernt statt gar nicht da.

Der Code dafuer lag bereits im Arbeitsverzeichnis - eine parallel laufende
Sitzung hatte ihn geschrieben, aber nicht committet, und damit war er in keinem
Release. Uebernommen ist hier nur diese Korrektur, nicht die uebrige Arbeit
jener Sitzung.

Wie das durchrutschen konnte: beim vorigen Bericht wurde die Navigation fuer in
Ordnung erklaert, nachdem eine Nachbildung der Regel in einer leeren Seite
geprueft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte
Datei nicht - der Befund war wertlos. Jetzt prueft der Browsertest die echte
Verwaltungsseite, auf beiden Groessen. Gegenprobe gemacht: gegen die
ausgelieferte admin.css schlaegt er fehl.

Geprueft: npm run test:editor vollstaendig, npm test 28/28, phpunit 452/452.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:17:56 +02:00
kantarciandClaude Opus 5 7c3c8671a7 Fassung 1.5.4: der Werkzeugkasten bekommt Bilder und eine Suche
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
Die Elementwahl war eine Liste aus Schriftzeichen - "▣" fuer Sektion, "☰" fuer
Zeile. Jetzt traegt jeder der 34 Eintraege eine Strichzeichnung, die zeigt, was
er baut: die Sektion ein gefuelltes Band, "Zeile, 3 Spalten" tatsaechlich drei
Kaesten, das Bild einen Rahmen mit Bergzug.

Zeichnungen statt Bilddateien. Flatsome liefert je Element ein PNG mit.
Gezeichnet ist hier besser: library-previews.js ist ein paar hundert Byte statt
eines Ordners voller Bitmaps, die Formen nehmen die Farbe der Flaeche an, auf
der sie liegen - gesperrte Eintraege werden damit von selbst blass - und sie
bleiben auf jedem Bildschirm scharf. Die Zeilenvorschau wird gerechnet: zwei,
drei oder vier Kaesten aus derselben Funktion.

Suche. Ein Feld ueber der Liste filtert ueber alle Gruppen, gesucht wird ueber
Namen, internen Typ und Gruppennamen - wer "slider" eingibt, muss nicht wissen,
dass er unter "Gruppen" steht. Gruppen ohne Treffer verschwinden, darunter
steht wie viele von wie vielen uebrig sind.

Dabei bemerkt: es gab zwei Suchfelder namens "Element suchen" auf einem
Bildschirm, fuer eine Sprachausgabe nicht unterscheidbar. Sie heissen jetzt
nach dem, was sie durchsuchen.

Geprueft: der Browsertest zaehlt die Vorschaubilder gegen die Eintraege und
sucht nach Name, Gruppenname und etwas Nichtvorhandenem. npm test 28/28,
phpunit 452/452, npm run test:editor vollstaendig ohne axe-Verstoss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:08:31 +02:00
kantarciandClaude Opus 5 9726af9254 Fassung 1.5.3: der Editor blieb nach dem Update schwarz
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
Nach 1.5.2 meldete der Editor im Browser "The requested module
'./editor-model.js' does not provide an export named 'dropList'", und der
Werkzeugkasten war leer.

Ursache war nicht der Code, sondern das Zwischenspeichern. public/.htaccess
setzt fuer jede .js-Datei Cache-Control: immutable, max-age=31536000 - ein
Jahr. Die Fassungskennung haengt aber nur an den Einstiegsdateien, die die
Vorlage schreibt. Die fuenf Module, die der Editor ueber import './...js'
laedt, tragen keine: der Browser holte das neue editor.js und behielt das alte
editor-model.js. Ein neues canvas-overlay.js traf damit auf ein Modell ohne
dropList.

Beide gemeldeten Symptome hatten diese eine Wurzel. Seit 1.5.1 baut das Skript
den Werkzeugkasten auf, und editor.js importiert canvas-overlay.js in Zeile 5;
bricht der Import, laeuft keine Zeile danach und die Elementliste bleibt leer.
Das gemeldete fehlende Menue war der Werkzeugkasten, nicht die
Verwaltungsnavigation - die wurde geprueft und ist in Chrome 152 in Ordnung.

Behebung: eine Importkarte in base.html.twig haengt die Fassung an die
aufgeloeste Adresse jedes Moduls, ohne dass eine Importzeile sie kennen muss.
Das Zwischenspeichern ueber ein Jahr bleibt - die Leistung war nie das
Problem, nur die fehlende Kennung.

Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe.
Unter script-src 'self' wurde sie wortlos verworfen. AdminHeadersSubscriber
erzeugt deshalb je Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie
und legt ihn in die Anfrage; csp_nonce() gibt ihn der Karte mit. Der Nonce
erlaubt genau dieses eine Skript, 'self' gilt unveraendert fuer alles andere.

Geprueft: der Browsertest verlangt an jeder angeforderten .js-Adresse eine
Fassungskennung (vorher fehlten fuenf, jetzt tragen alle acht), dazu ein Test,
der jeden Modulimport gegen die Importkarte haelt - Gegenprobe gemacht.
npm test 28/28, phpunit 452/452, npm run test:editor vollstaendig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 08:00:52 +02:00
226 changed files with 23204 additions and 2026 deletions

No files matched your search

+70
View File
@@ -18,6 +18,76 @@
"bin/dev-router.php"
],
"port": 8080
},
{
"name": "kantsites-pilot",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
],
"port": 8091
},
{
"name": "elektro-bernhard",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/elektro-bernhard.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/elektro-bernhard-media'; $env:SITE_ORIGIN='http://127.0.0.1:8088'; & php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8088 -t public bin/dev-router.php"
],
"port": 8088
},
{
"name": "tagespflege-moldovan",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$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
},
{
"name": "maler-drews",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/maler-drews.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/maler-drews-media'; $env:SITE_ORIGIN='http://127.0.0.1:8093'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8093 -t public bin/dev-router.php"
],
"port": 8093
},
{
"name": "immobilien-deeg",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8094'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8094 -t public bin/dev-router.php"
],
"port": 8094
},
{
"name": "immobilien-deeg-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8095'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8095 -t public bin/dev-router.php"
],
"port": 8095
}
]
}
-9
View File
@@ -1,9 +0,0 @@
APP_ENV=dev
APP_DEBUG=1
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
SITE_ORIGIN=http://127.0.0.1:8080
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
# Die Adresse der Verwaltung. Aenderbar mit `php bin/console cms:admin:path <neu>`;
# der Befehl schreibt .env.local und prueft, dass die Adresse frei ist.
ADMIN_PATH=admin
+7 -4
View File
@@ -1,14 +1,17 @@
# Vorlage für eine Installation. Der Installer schreibt daraus die echte .env und erzeugt das
# Geheimnis selbst; von Hand: diese Datei nach .env kopieren, Werte eintragen und
# `php bin/setup.php` ausführen, das APP_SECRET nach .env.local schreibt.
# Vorlage: Der Installer erstellt daraus die Konfiguration .env.
# .env gehört der Installation und wird bei Updates erhalten, nicht in Git gespeichert.
# Installer, Setup und Admin-Pfad-Befehl schreiben ausschließlich in .env.
# Ältere .env.local-Einstellungen werden einmalig mit Sicherung zusammengeführt.
APP_ENV=prod
APP_DEBUG=0
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
# Die oeffentliche Adresse der Website, mit oder ohne www - so, wie sie in Sitemap, Feed und
# Canonical stehen soll. Die andere Schreibweise leitet die Anwendung dorthin um.
SITE_ORIGIN=https://example.de
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
MAILER_DSN=sendmail://default
# Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.local.
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.
#ADMIN_PATH=admin
+6 -1
View File
@@ -24,7 +24,7 @@ jobs:
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
- run: php bin/console cms:seed-demo
- run: php bin/console cms:seed-demo
- run: php bin/console lint:twig templates && php bin/console lint:yaml config && php bin/console lint:container
- run: php bin/console lint:twig templates && php bin/console lint:yaml config --parse-tags && php bin/console lint:container
- run: composer build
browser:
runs-on: ubuntu-latest
@@ -83,3 +83,8 @@ jobs:
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
- run: php bin/console cms:seed-demo
- run: php bin/console cms:seed-demo
# Vorher hoerte der Job hier auf und sagte nichts ueber Sicherung und Betrieb auf MySQL.
- run: php bin/console cms:health
- run: php bin/console cms:backup --keep=2
- run: php bin/console cms:backup --list
- run: php vendor/bin/phpunit
+3
View File
@@ -3,6 +3,9 @@
/var/
/node_modules/
/.env.local
/.env
/.env.lock
/.env.*.tmp
/.env.*.local
/phpunit.cache/
/reports/
+257 -10
View File
@@ -1,9 +1,157 @@
# KantSites
Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite lokal, MySQL/MariaDB als Hostingziel. Drei gespeicherte Beispielseiten werden ohne JavaScript als HTML ausgeliefert. Anmeldung, geschützte Bearbeitung und getrennte Veröffentlichung sind implementiert; weitere Layoutwerkzeuge folgen.
Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Bedienprinzip des Flatsome UX Builders. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite oder MySQL/MariaDB. Öffentliche Seiten kommen als fertiges HTML ohne Pflicht-JavaScript, mit Sitemap, Feed, strukturierten Daten und `llms.txt`. Webinstaller, Releasearchive mit Update aus der Verwaltung, Sicherung, Aufgabenplan, Formulare, Statistik und Einwilligung sind eingebaut. Fünf Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews, DEEG Immobilien), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven.
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`.
## Adresse mit oder ohne www, Rollen wie versprochen, Gesamtprüfung (ab 1.17.0)
**Eine Adresse.** `SITE_ORIGIN` in `.env` entscheidet, ob die Website mit oder ohne `www` heißt; die andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um (auch die Verwaltung), ein fremder Host bleibt erreichbar. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt die Einstellung, der Bildschirm „System“ zeigt sie und warnt, wenn die Verwaltung gerade unter einer anderen Adresse läuft. Der www-Block in `public/.htaccess` ist weg.
**Rollen.** Redakteure bearbeiten Entwürfe, laden Medien hoch und sehen die Statistik. Veröffentlicher dürfen zusätzlich veröffentlichen, Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite pflegen. Administratoren allein ändern Design, Unternehmensdaten (samt Einwilligung und Spamschutz), Benutzer, System und eigene Skripte. Vorher genügte für Design, Einstellungen, Menüs, Bereiche und Formulare die Rolle Redakteur – entgegen der Rollenhilfe. Die Navigation zeigt nur, was die Rolle öffnen darf.
**Behoben.** Nach `cms:admin:path` war keine Anmeldung möglich (Sitzungscookie blieb auf `/admin`). Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete. Ein MySQL-Passwort mit Sonderzeichen ließ den Container abbrechen (`resolve:`), ebenso ein SMTP-Passwort. Die Einwilligung nahm Entscheidungen von fremden Websites an und leitete auf `/\fremd.example` weiter. `</script>` in Titel oder FAQ zerriss die strukturierten Daten. Eigenes CSS konnte mit einem Komma den Block verlassen. Eine alte Fassung konnte ein Skript an der Administratorregel vorbei zurückholen. Im Editor ließen sich Menü, Beitragsliste und Slider in älteren Dokumenten nicht speichern (Schemafassung), „Gestaltung einfügen“ und „Als Vorlage speichern“ waren fälschlich aktiv, „Ausrücken“ und Baum-Drop umgingen die Elternregeln, ein Bild auf ein Akkordeon warf einen Fehler, abgelehnte Einfügungen hinterließen leere Verlaufsschritte. Dazu: Schatten bei Überfahren/Scrollen, Tabs und Bildbetrachter in Kopf, Fuß und Sektionen, Sitemap-`lastmod`, Feed-Zeitzone, Vollbild in eingebetteten Videos, Update-Downloads nur über HTTPS-Weiterleitungen, Composer- und PHP-Aufruf aus dem Web-Update unter php-fpm, unerwartete Fehler im Updater nehmen den Rückweg, eine geplante Veröffentlichung blockiert die anderen nicht mehr, keine Unterseite unter einer Seite im Papierkorb.
## Spaltenbreiten einer Zeile durch Ziehen (ab 1.16.0)
Wähle eine Zeile oder eine ihrer Spalten: auf jeder Grenze zwischen zwei Spalten liegt ein Griff. Ziehen macht die eine Spalte breiter und die andere schmaler, Loslassen ist ein Schritt für „Rückgängig“; die Pfeiltasten verschieben um fünf Prozentpunkte. Der Wert steht auch im Design-Bedienfeld der Zeile unter **Layout → Spaltenbreiten**, etwa `2 1` oder `25 50 25`, und gilt je Gerät. Wo die Spalten gestapelt sind, etwa auf dem Handy, gibt es keinen Griff und die Spalten bleiben untereinander.
## Rückstände der Gesamtprüfung (unveröffentlicht)
Archivseiten (`/beitraege`, Kategorien, Schlagwörter) sind jetzt gewöhnliche Seiten mit Kopf, Fuß, Sektionen, eigenem CSS und Nach-oben-Knopf; blätternde Beitragslisten tragen Canonical mit Seitenzahl und `rel=prev/next`. Formulare, Beitragslisten und Brotkrumen funktionieren auch in Kopf, Fuß und verknüpften Sektionen, eigene Skripte von Bereichen laufen. Eine zweimal verknüpfte Sektion bekommt beim zweiten Mal eigene Kennungen. Tablet und Telefon trennen sich überall bei 767 px. Dunkle Paletten färben Formularfelder, Fehlermeldungen, Skip-Link und Aufruf-Buttons mit; es gibt Druckstile. **Betrieb:** Sicherung, Wiederherstellung, Archiv-Update und Rückweg laufen auch auf MySQL/MariaDB (`mysqldump` und `mysql` müssen aufrufbar sein), Updates sperren sich gegenseitig (`var/update.lock`), das Archiv wird direkt in eine Datei geladen, `public/.htaccess` behält beim Archiv-Update eingeschaltete HTTPS- und HSTS-Zeilen, und nach dem Update werden `var/update/` (bis auf zwei) und alte Caches aufgeräumt. Der Editor zeigt in der Vorschau eigenes CSS, Bereichsstile und Formulare; frisch eingefügte Kontaktdaten, Formulare, verknüpfte Sektionen und externe Inhalte sind gültig, bis man sie füllt.
## Plugins (unveröffentlicht)
Erweiterungen liegen als Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional Stylesheet und Skript unter `assets/`. Unter **Plugins** (Administrator) werden sie ein- und ausgeschaltet; ihre Bausteine erscheinen dann im Editor unter „Erweiterungen“, mit einem Eigenschaftenpanel aus dem Schema des Manifests. Der Kern prüft die Eigenschaften beim Speichern und beim Rendern, liefert Stylesheet und Skript nur auf Seiten mit dem Baustein aus und lässt die Bausteine eines ausgeschalteten Plugins unangetastet in den Seiten stehen. Ein Plugin kann außerdem Container-Bausteine (mit Liste erlaubter Kinder), Einstellungen für den Betreiber (Formular auf dem Bildschirm „Plugins“), eigene Adressen wie `/vergleich/daten` (Twig-Vorlage, Antwort als html, json, text oder xml), Seitenvorlagen für den Editor und ein Designpaket mitbringen („Designpaket übernehmen“ ersetzt Palette, Schriften, Grundwerte und Buttons; die Inhalte bleiben). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert sind **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen) und das Designpaket **Nachtblau** (`plugins/nachtblau`). Wie man ein Plugin schreibt, steht in `docs/PLUGINS.md`. Migrationen `20260915000100` und `20260916000100` legen die Tabelle `plugin` mit Einstellungen an.
## Formulare in Schritten, mit Bedingungen und Zusammenfassung (unveröffentlicht)
Unter **Formulare** gibt es drei neue Feldtypen – **Zahl** (mit Unter-, Obergrenze und Einheit wie m²), **Auswahl (Optionsfelder)** und **Mehrfachauswahl** – und den Typ **Neuer Schritt**: seine Beschriftung ist der Titel des Abschnitts, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann von einer früheren Auswahl abhängen („Nur zeigen, wenn das Feld `immobilienart` einen dieser Werte hat: Wohnung, Haus“); der Server verlangt und speichert es dann nur, wenn die Bedingung zutrifft. Bei Optionsfeldern und Mehrfachauswahl lässt sich je Option ein **Bild aus der Mediathek** wählen; sobald eine Option eines hat, zeigt die Website die Auswahl als anklickbare Bildkarten statt als Kästchen. Unter Zustellung schaltet „Zusammenfassung vor dem Absenden zeigen“ einen letzten Schritt mit allen Angaben und „Ändern“ je Schritt ein. Auf der Website zeigt ein kleines Skript einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“; ohne JavaScript steht das Formular als Ganzes mit Abschnitten auf der Seite. Das Skript kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat. Beispiel: der Preisfinder der DEEG-Website (`scripts/seed-immobilien-deeg.php`, Formular `bewertung`).
## Eigene Absenderadresse je Formular (ab 1.15.1)
Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt.
## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0)
**Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift.
**Die Statistik** beginnt mit Kennzahlen: Aufrufe mit Vergleich zum gleich langen Zeitraum davor, Schnitt je Tag, stärkster Tag, aufgerufene Seiten, Aufrufe über Verweise, nicht gefundene Adressen. Der Verlauf hat Rasterlinien, jeder Balken nennt beim Überfahren Tag und Zahl, der stärkste Tag ist hervorgehoben; neu ist ein Wochentagsdiagramm im Schnitt. Die Ranglisten tragen Rang und Anteilsbalken.
**Im Design** ist die Palette eine Reihe von Farbkarten, deren Fläche der Farbwähler selbst ist. Sieben fertige Paletten (Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht) lassen sich per Klick übernehmen; eigene Farben bleiben stehen. Text-, Seiten- und Linkfarbe wählst du aus der Palette oder trägst einen eigenen Wert ein. Jede Schrift zeigt eine Probe in sich selbst, die Schnitte sind Chips, und eine Typografie-Vorschau zeigt Schrift, Größe, Zeilenhöhe, Skala, Farben und Unterstreichung live.
**Die Seitenleiste** endet in einer Kontokarte mit Avatar, Name, Rolle und Abmeldesymbol, darunter die Fassung mit dem Update-Hinweis. Auf Tablets bleiben Konto und Abmelden rechts im Bild, nur die Liste wischt.
## Fußzeile des Einwilligungshinweises (ab 1.14.2)
„Einzeln wählen“ steht links, Datenschutz und Impressum rechts auf einer Linie; aufgeklappt nimmt die Einzelwahl die volle Breite, die Links rutschen rechtsbündig darunter.
## Einwilligungshinweis gestalten, Nach-oben-Knopf auch bei offener Frage (ab 1.14.1)
**Der Einwilligungshinweis lässt sich gestalten**, unter **Einstellungen → Einwilligung → Aussehen des Hinweises**: Anordnung (Karte unten in der Mitte, Leiste über die volle Breite, kleine Karte rechts unten), Form (abgerundet, rund, eckig – gilt für Fläche und Knöpfe zusammen), eigene Überschrift und vier Farben (Fläche, Schrift, Knopf „Alle akzeptieren“, Schrift auf dem Knopf), jeweils als `#hex` oder `@schlüssel` aus der Palette. Leer heißt Papier, Tinte und Akzent aus dem Design. Die Fläche trägt einen schmalen Streifen und ein Symbol in der Knopffarbe; „Nur notwendige“ bleibt ein gleich großer Rahmenknopf daneben. Dieselben Farben gelten auf `/einwilligung` und in den Beitragsarchiven.
**Der Nach-oben-Knopf** war ausgeblendet, solange die Einwilligungsfrage offen stand – auf dem Telefon bleibt sie das oft, und der Knopf kam dort nie. Jetzt setzt er sich auf die Oberkante der Leiste, wo sie die Ecke abdeckt; Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Der reservierte Abstand unten reicht auf schmalen Bildschirmen wieder für die ganze Leiste.
**Im Designbereich** sind belegte Eigenschaften jetzt in Orange beschriftet, nicht nur mit Punkt und Fettdruck; die Gerätechips D/T/M stehen weiter auseinander, und der Fokusring bleibt in der Lücke.
## 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.
## Slider-Steuerung auf dem Bild (ab 1.11.4)
Die Steuerung liegt jetzt auf dem Slider, wie beim Hero-Slider von kant-it.de: runde weiße Pfeile links und rechts in der Mitte, Punkte unten rechts mit dem aktiven Punkt als Strich in der Akzentfarbe, ein kleiner Pause-Knopf daneben, kein Zähler mehr (der bleibt für Vorlesesoftware). Auf Bildschirmen bis 600 px verschwinden die Pfeile, gewischt wird mit dem Finger. Unter **Design → Slider-Steuerung** gibt es zusätzlich „Aktiver Punkt“.
## Farben der Slider-Steuerung (ab 1.11.3)
Slider auswählen → **Design → Slider-Steuerung**: Farbe der Punkte, Farbe von Pfeilen und Zähler sowie Hintergrund der Knöpfe, je Gerät und mit Palettenfarben. Ohne eigene Werte sind die Punkte halbtransparent weiß und die Pfeile weiß mit Akzentfarbe. Die Farben gelten auch für die skriptfreien Punkte, die der Browser selbst zeichnet.
## Slider auf dem iPhone (ab 1.11.2)
Safari bricht sanftes Weiterblättern in einem einrastenden Slider ab und springt zur vorigen Folie zurück; die Automatik und die Pfeile stotterten deshalb auf dem iPhone. Während der Fahrt ist das Einrasten jetzt ausgeschaltet und wird bei Ankunft auf der Folie wieder eingeschaltet. Die Punkte unter dem Slider sind echte Punkte, der aktive gefüllt, bei unveränderter Trefffläche von 44 px.
## Kopfbereich beim Scrollen und gleich hohe Karten (ab 1.11.1)
Der Kopfbereich kann für die gescrollte Seite eigene Farben tragen, wie der transparente Header in Flatsome: Kopfbereich im Editor öffnen, die Sektion wählen, **Design → Hintergrund**. Dort steht unter „Hintergrundfarbe“ die Zeile **„Hintergrundfarbe beim Scrollen“**; dasselbe gibt es für Textfarbe, Rahmenfarbe, Schattenfarbe und Deckkraft. Der normale Wert gilt am Seitenanfang, der Scroll-Wert sobald die Seite mehr als 40 px bewegt wurde, mit weichem Übergang. Für einen durchsichtigen Kopf am Anfang `#ffffff00` als normale Hintergrundfarbe eintragen. Damit der Kopf über der ersten Sektion liegt statt darüber zu stehen, unter **Einstellungen** „Kopfbereich über dem Inhalt schweben lassen“ einschalten; zusammen mit „oben halten“ bleibt er beim Scrollen sichtbar. Ohne JavaScript behält der Kopf seine normalen Farben.
Karten, Kundenstimmen und Aufruf-Bänder nebeneinander in einer Zeile sind wieder gleich hoch. Der Abstand, den gestapelte Karten in einer Spalte untereinander brauchen, hatte in der Zeile die zweite bis letzte Karte um 18 px niedriger gemacht.
## Editor mit einer Seitenleiste und Bewegung wie Flatsome (ab 1.11.0)
Der Editor folgt jetzt dem Aufbau des Flatsome UX Builders: eine dunkle Kopfzeile mit Gerätewahl in der Mitte, Rückgängig/Wiederholen, Bewegung abspielen, Speichern und Veröffentlichen; links **eine** Seitenleiste mit den Reitern **Elemente**, **Struktur** und **Seite**; rechts die Vorschau. Ein Klick auf ein Element in der Vorschau öffnet seine Optionen in der Seitenleiste: Name, Pfad (Seite › Sektion › Zeile), die Reiter Inhalt/Design/Erweitert, unten Verschieben, Duplizieren, Löschen. **‹** wählt das übergeordnete Element, **+** fügt in einen Container ein oder direkt hinter das Element, **✕** oder Escape hebt die Auswahl auf. Rechtsklick in der Vorschau öffnet ein Kontextmenü mit denselben Aktionen. Entf löscht das gewählte Element, sofern kein Feld den Fokus hat.
Die Elemente stehen als Kartenraster mit Symbol und Namen; was an der gewählten Stelle nicht passt, ist ausgegraut und sagt beim Überfahren warum. In der Struktur wählt ein Klick nur aus, **Bearbeiten** oder Doppelklick öffnet das Element. Die Sichtbarkeit je Gerät steht jetzt am Ende von Inhalt und Erweitert. Die verstellbaren Panelbreiten entfallen.
**Bewegung:** Einblendungen, Hover-Bewegungen und Slider-Automatik spielen standardmäßig bei jedem Besucher, wie bei Flatsome und den meisten Websites. Manche Browser melden „Bewegung reduzieren“, ohne dass jemand es eingestellt hat; bisher blieb dann jede Einblendung aus. Unter **Einstellungen → Einblendungen und Hover-Bewegungen** lässt sich „Browservorgabe beachten“ wählen; dann trägt `<html>` die Klasse `motion-respect`, und Skript wie CSS halten sich wieder an die Vorgabe. Einblendungen verstecken einen Block, bis er ins Bild scrollt, und blenden ihn dann von unsichtbar ein; wer per Sprungmarke oder schnellem Scrollen an einem Block vorbeikommt, sieht ihn sofort. Ohne JavaScript ist alles sichtbar.
Gezielt prüfen (isolierte Kopien; PHP mit SQLite3-Erweiterung):
```powershell
$env:PHP_BINARY = 'C:\php84\php.exe'
node scripts/motion-check.mjs
node scripts/editor-check.mjs
npm.cmd test
```
## Formularbutton und Bewegungseffekte (ab 1.10.1)
„Absenden“ übernimmt den Website-Standardbutton aus Design/Typografie: Form, Darstellung, Größe, Schrift und normale/Hover-Farben. Bestehende Formulare aktualisieren sich ohne erneutes Speichern; der Button hat seine natürliche Breite statt automatisch die gesamte Formularbreite. Formularfelder, Versand und Validierung bleiben unverändert.
Einblendungen starten nach dem Wechsel von reduzierter zu erlaubter Bewegung wieder ohne Neuladen. Bereits gezeigte Elemente werden nicht erneut animiert. Im Editor zeigt eine Meldung, wenn der Browser Bewegung reduziert. Eine weiterhin aktive Browservorgabe wird beachtet; dieses Update überschreibt keine Besucherpräferenzen.
## Sichtbarkeit und Hamburger-Menüs (ab 1.10.0)
Element auswählen → **Sichtbarkeit**: auf allen Geräten, nur Desktop/Tablet/Mobil, auf einem Gerät ausblenden oder überall ausblenden. Die Auswahl steht in Inhalt, Design und Erweitert. Ausgeblendete Elemente bleiben links in der Struktur auswählbar; dort steht auch ihre Sichtbarkeit. „Auf allen Geräten“ setzt die Gerätebeschränkung zurück. Eigene Layoutwerte bleiben erhalten. Ausgeblendete Elternbereiche verbergen auch ihre Inhalte.
Waagerechte Navigation wird automatisch auf Tablet und Mobil zum Hamburger, auch auf großen iPads mit Touchbedienung ohne Maus. Unter **Inhalt → Automatisch als Hamburger** lässt sich stattdessen „Nur Mobil“ oder „Nie“ wählen. Vertikale Menüs bleiben vertikal und die Menüart „Burger“ bleibt immer aufklappbar. Bereits getrennt angelegte Desktop- und Mobilmenüs lassen sich über **Sichtbarkeit** passend zuordnen. Das Menü funktioniert ohne JavaScript und lässt sich mit Enter oder Leertaste öffnen und schließen.
Sichtbarkeitsgrenzen: Desktop ab 1025 px, Tablet 768–1024 px, Mobil bis 767 px. Undo/Redo, Speichern und Veröffentlichung gelten wie gewohnt. Neue Optionen verwenden Schema 22; Rückweg siehe ADR 088.
Gezielt prüfen (isolierte Kopie der lokalen Kant-IT-Daten; PHP mit SQLite3-Erweiterung):
```powershell
$env:PHP_BINARY = 'C:\php84\php.exe'
npm.cmd run test:responsive-navigation
php vendor/bin/phpunit tests/ResponsiveVisibilityTest.php
npm.cmd test
```
Bericht und Screenshots: `reports/responsive-navigation/`. Produktionsbuild: `composer build`.
## Buttons, Sprungziele und Menüs (ab 1.9.0)
Unter Design/Typografie konfiguriert „Standardbutton“ Form, Darstellung, Größe, Schrift und Farben für die Website. Acht Vorlagen zeigen das Ergebnis direkt, einschließlich Hover-Vorschau. Einzelne Buttons können im Editor davon abweichen, auch je Gerät: eckig, abgerundet, Pille, Rahmen, dezent oder Textlink. Eigene freie Designwerte können diese Vorgaben übersteuern.
Sektionen, Zeilen und Spalten haben im Inhaltsbereich das Feld „Sprungziel“. Dort beispielsweise `kontakt` eintragen und einen Link auf `#kontakt` setzen. Ziele sind eindeutig; die internen Block-IDs bleiben erhalten. Duplikate erhalten kein kopiertes Sprungziel.
Menüeinträge am Punktgriff ziehen oder per Pfeiltasten verschieben und einrücken. Optional ein Symbol vor oder nach dem Text auswählen. Änderungen anschließend mit „Menü speichern“ übernehmen. Neben der Fassung links erscheint für Administratoren ein Link, sobald die bestehende tägliche Updateprüfung eine neuere Version gefunden hat. Der Aufgaben-Worker muss dafür laufen; die Sidebar selbst führt keine Netzwerkanfrage aus.
Gezielte Entwicklungsprüfungen: `npm run test:buttons` und `npm run test:navigation-ux` verwenden eine isolierte Kant-IT-Kopie. Responsive Button-CSS nach Änderungen mit `npm run build:buttons` erzeugen.
## Lokal starten
Voraussetzungen: PHP ab 8.2 mit Composer 2, PDO SQLite, Ctype, Iconv, Session, XML und GD mit WebP-Unterstützung. EXIF wird für die automatische Ausrichtung von JPEG-Fotos empfohlen. Für Tests zusätzlich die von `composer install` geprüften Erweiterungen, insbesondere DOM, XMLWriter und Mbstring. Für Hosting eine aktuell gepflegte PHP-Patchversion verwenden. Node 24 wird für Browserprüfungen und das Generieren der Design-CSS gebraucht, nicht im Hostingbetrieb.
@@ -18,12 +166,28 @@ php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0
Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage.
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
`bin/setup.php` erzeugt ein zufälliges Secret und `var/`. Die Konfiguration steht ausschließlich in der nicht versionierten `.env`; vorhandene Einstellungen und Secrets bleiben erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
## Eigene 404-Seite (ab 1.8.0)
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website-Design. Entwürfe bleiben privat; Umbenennen erhält die Zuordnung, nach dem Löschen greift wieder der Standard. Das Entfernen der Auswahl setzt die Zuordnung zurück und erhält den Seiteninhalt. Die Zuordnung benötigt Veröffentlichungsrechte. Unbekannte URLs bleiben unverändert und antworten mit HTTP 404; die Fehlerseite wird nicht in Sitemap/llms.txt aufgenommen. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:404`, Berichte und Screenshots unter `reports/not-found/`. Datenvertrag und Rückweg: ADR 082.
## Anmelden und bearbeiten
**Scroll-Einblendungen prüfen:** Block auswählen → Design → Bewegung und Effekte → Einblendung beim Erscheinen → „Von links“ oder „Von rechts“. Anschließend speichern und veröffentlichen. Im Editor startet „Bewegung testen“ die Vorschauwiedergabe. Öffentliche Seiten animieren beim ersten Erscheinen; die Systemoption „Bewegung reduzieren“ unterdrückt Animationen. `node scripts/motion-check.mjs` prüft Scroll-Auslösung, beide Richtungen, Bewegungsreduktion und Ausgabe ohne JavaScript mit einer isolierten Kant-IT-Kopie. PHP mit SQLite3-Unterstützung über `PHP_BINARY` wählen, etwa in PowerShell `$env:PHP_BINARY='C:\php84\php.exe'`. Ergebnisse stehen unter `reports/motion/`.
**Bildausrichtung (ab 1.8.4):** Bild auswählen → Design → Layout → **Eigene Ausrichtung quer: Mitte**. Zentriert den Bildblock und auch kleinere Bilder innerhalb des Rahmens. Funktioniert mit Prozentbreite, maximaler Breite und Bildlinks; Anfang/Ende und Gerätewerte werden ebenfalls berücksichtigt. In nebeneinander angeordneten Flex-Gruppen bleibt die Querachse vertikal. Prüfen: `npm run test:image-alignment` auf einer isolierten Kant-IT-Kopie; Screenshots unter `reports/image-alignment/`.
**Akkordeon-Inhalte (ab 1.8.3):** Einträge bleiben im Editor geöffnet. Im Eintrag auf **Inhalt hinzufügen** klicken und links Text, Bild oder ein anderes Element auswählen; alternativ direkt in die sichtbare Inhaltsfläche ziehen. **Eintrag hinzufügen** steht unter dem Akkordeon und in dessen Inhaltsoptionen. Die Eintragsübersicht rechts und die Struktur links zeigen die Titel. **Beim Laden geöffnet** bestimmt ausschließlich den Zustand der veröffentlichten Seite. Prüfen: `npm run test:accordion`, Screenshots unter `reports/accordion/`.
**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`.
**Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position.
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
```powershell
@@ -34,15 +198,29 @@ Das Passwort wird verdeckt zweimal abgefragt (14–72 Bytes). Bestehende Benutze
Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzufügen/auswählen, in der Mitte die Vorschau, rechts Eigenschaften. Die Auswahl ist zwischen Strukturbaum und Vorschau synchron. Sektion, Zeile, Spalte, Überschrift, Text, Bild, Button und Galerie sind vorhanden. Bilder können aus der Mediathek oder über vorhandene lokale/HTTPS-Adressen eingebunden werden.
**Bild prozentual verkleinern (ab 1.7.2):** Bild auswählen → Inhalt → **Bildbreite (%)**. Regler oder Zahlenfeld setzen 1–100 % der verfügbaren Breite in Sektion/Spalte. Desktop, Tablet und Mobil können eigene Werte erhalten; kleinere Geräte übernehmen zunächst den Wert vom größeren Gerät. „Breite zurücksetzen“ entfernt den eigenen Wert und stellt die Vererbung beziehungsweise automatische Breite wieder her. Originalbreite/-höhe beschreiben weiterhin die Datei. Der Regler bearbeitet denselben Wert wie Design → Größe → Breite; eine dort gesetzte maximale Breite bleibt wirksam. Prüfung mit kopierten Kant-IT-Daten: `npm run test:image-size`, Bericht und Screenshots unter `reports/image-size/`.
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern und den Serverstand bewusst neu laden. Auch Netzwerk-/Sitzungsfehler lassen den lokalen Stand erhalten. Die Vorschau behält bei ungültigen Eingaben die letzte gültige Ausgabe.
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 767 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durch Skalierung an den verfügbaren Platz an. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst bei mehr Platz mit. Tablet/Mobil verwenden 768/390 px; bei weniger Platz wird die Vorschau passend skaliert. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
### Platz und Blockgestaltung im Editor (1.6.1)
**Leere Layouts ab 1.6.2:** Sektionen und Spalten zeigen eine umrahmte Einfügefläche. In einer leeren Sektion direkt **1–4 Spalten** wählen; in einer Spalte **Inhalt hinzufügen** drücken und links das gewünschte Element auswählen. Alternativ ein Element auf die Fläche ziehen: die Markierung nennt das genaue Ziel, am Vorschaurand wird mitgescrollt. Neue Container werden in den sichtbaren Ausschnitt gebracht. Diese Hilfen erscheinen nur im Editor, verschwinden nach dem Befüllen und kehren bei Undo zurück.
**Neue Sektion auf Seitenebene** steht dauerhaft oben links und fügt nach dem aktuellen Seitenabschnitt ein, unabhängig von der ausgewählten Verschachtelung oder einem gesetzten Plus-Einfügepunkt. Auch „Sektion“ im Werkzeugkasten verwendet ohne expliziten Einfügepunkt die Seitenebene. Verschachtelte Sektionen sind weiterhin über die Plus-Einfügepunkte oder Drag-and-drop möglich.
Die Trennlinien links und rechts der Vorschau lassen sich ziehen. Mit Tab fokussieren und mit den Pfeiltasten ändern; Umschalt vergrößert den Schritt, Home/End wählen die Grenzen, Doppelklick setzt die jeweilige Breite zurück. Die Breiten werden im Browser behalten und bei schmalen Fenstern an den verfügbaren Platz angepasst. Auf dem Telefon stehen die Panels unter der Vorschau.
Text und Überschriften nutzen die Containerbreite. Die Sektion wählt unter **Inhalt → Inhaltsbreite** zwischen Websitebreite und randloser Ausgabe. **Design → Größe → Maximale Breite** begrenzt ein einzelnes Element ausdrücklich, etwa auf `60ch`; „Ohne Begrenzung“ hebt einen eigenen Grenzwert auf. Der Feinschliff zeigt passende Optionen für Text, Bild oder Container zuerst. **Alle Eigenschaften anzeigen** macht die übrigen Optionen zugänglich. Bereits gespeicherte Werte bleiben sichtbar.
## Produktionsmodus lokal prüfen
@@ -61,7 +239,7 @@ Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersic
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
Unter **Design → Effekte** Schatten, Hover-Anheben/Bildzoom und Einblendung beim Laden wählen. Effekte gelten für alle Geräte; bei aktivierter Bewegungsreduktion werden Animationen abgeschaltet. Die öffentliche Seite benötigt dafür kein JavaScript. Lightbox, Slideshow, Video/PDF-Upload und Bildzuschnitt sind noch nicht enthalten.
Unter **Design → Bewegung und Effekte** Schatten, Hover-Effekte und **Einblendung beim Erscheinen** wählen. Die gewählte Richtung, Zoom, Unschärfe oder Überblendung startet beim Eintreten in den Bildschirm. Das kleine Bewegungsmodul lädt nur auf Seiten mit Einblendungen; ohne JavaScript bleiben die Inhalte sichtbar. Slider haben Pfeile, optionale Punkte sowie bei aktivierter Automatik Pause/Wiedergabe. Manuelle Bedienung pausiert die Automatik, Wiedergabe setzt sie fort. Bewegungsreduktion schaltet Einblendungen und Automatik aus; manuelles Blättern bleibt möglich. Im Editor startet **Bewegung testen** diese Vorschau, **Bewegung stoppen** beendet sie. Die Vorschau-Sandbox bleibt ohne Skriptfreigabe.
Medien liegen standardmäßig in `var/media`; mit `MEDIA_DIRECTORY` lässt sich ein absoluter Ordner außerhalb von `public` konfigurieren. Er muss für PHP beschreibbar sein. Auf dem Hosting `upload_max_filesize=8M`, `post_max_size=10M` und ausreichend Arbeitsspeicher (lokal 256M) einstellen, soweit der Tarif dies erlaubt. Datenbank und Medienordner gemeinsam sichern. Archivieren erhält Bild-URLs; es ist keine Dateilöschung.
@@ -90,7 +268,9 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
**Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist.
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env.local`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
@@ -104,8 +284,31 @@ Die Formate v1/v2/v3 und erlaubten Blöcke stehen in `src/Content/DocumentValida
Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-draft PAGE_ID PFAD_ZUR_DATEI ERWARTETE_VERSION` speichern. Der Befehl gibt die neue Revisions-ID aus. Die redaktionelle Bearbeitung und Veröffentlichung erfolgen jetzt über den geschützten Editor.
## Wiederkehrende Aufgaben
Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln.
Vier Auslöser, in aufsteigender Verlässlichkeit:
1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an.
2. **Geöffnete Verwaltung** – das Backend fragt jede Minute nach, solange der Reiter sichtbar ist. Damit läuft der Plan auch auf einer Website ohne Besucher. Standardmäßig an.
3. **URL-Cronjob** – wenn der Hoster nur das Aufrufen einer Adresse anbietet: die Adresse steht auf dem Bildschirm „Aufgaben“ (`/cms-cron/<Schlüssel>`, aus `APP_SECRET` abgeleitet), zum Beispiel alle fünf Minuten.
4. **Konsole** – `php bin/console cms:tasks:run`, für einen echten Cronjob jede Minute:
```
* * * * * cd /pfad/zur/installation && php bin/console cms:tasks:run
```
`cms:tasks` zeigt denselben Plan wie die Verwaltung (`--log` zusätzlich das Protokoll), `cms:tasks:run --task=backup` startet eine bestimmte Aufgabe sofort, `cms:tasks:run --loop=60 --max-time=3600` läuft als Dauerläufer unter einem Prozessverwalter. Die alten Einzelbefehle `cms:backup`, `cms:stats:prune` und `cms:publish-due` gibt es weiterhin; wer sie in einen Cronjob einträgt, schaltet die entsprechende Aufgabe im Plan besser ab, damit nicht beides läuft.
**Die Grenze gehört dazu:** ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst beim nächsten Aufruf, vielleicht um 07:15 Uhr. Wer Minutengenauigkeit braucht, richtet Weg 3 oder 4 ein und schaltet den Besuchs-Auslöser ab.
## PHP-Webhosting
**Archiv-Updates prüfen:** `node scripts/archive-update-check.mjs <altes-release.zip> <neues-release.zip>` testet im Produktionsmodus den vollständigen HTTP-Updateablauf mit isolierter Kopie von `var/kantit.db`. PHP mit SQLite3 über `PHP_BINARY` wählen. Downloadtransport lokal, Originaldatenbank unverändert; Protokolle unter `var/archive-check-*`. Ein drittes Argument `500` prüft ausdrücklich den bekannten Altfehler samt Wiederaufnahme. Um den nächsten Updateaufruf des korrigierten 1.10.2-Pakets ohne Folgerelease zu testen, dasselbe Paket zweimal angeben und ausschließlich für diesen Test `UPDATE_SOURCE_VERSION=1.10.1` setzen; geändert wird nur die Versionsdatei der Wegwerfinstallation.
**500 direkt nach einem Archiv-Update aus 1.10.1 oder älter:** `/admin/system` direkt öffnen und angebotene Migrationen abschließen. Der alte Updater kann nach erfolgreichem Dateitausch seinen eigenen Cache löschen und beim Antworten abbrechen. Ab 1.10.2 behalten laufende Anfragen ihren Cache; neue Releases erhalten ein eigenes Cacheverzeichnis. Details und Grenzen stehen in ADR 090. Nicht erneut installieren oder die Datenbank ersetzen.
Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
- Gepflegte PHP-Version ab 8.2, PDO MySQL und die Composer-Plattformanforderungen.
@@ -114,8 +317,52 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
- Apache mit Rewrite-Unterstützung für `public/.htaccess` oder entsprechende FastCGI/Nginx-Konfiguration.
- Schreibzugriff für PHP auf `var/`, HTTPS und eine Möglichkeit, Migrationen/Cache-Kommandos kontrolliert auszuführen.
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsvariablen dürfen sie gezielt überschreiben: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Skripte, kein `var/` und kein `.env`; als Vorlage für die eigene Konfiguration liegt `.env.example` bei. Auf dem Hosting wird es entpackt, `.env.local` angelegt, migriert und der Cache gebaut. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` gehören danach in einen täglichen Cronjob – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
## Verwaltung ab 1.7.0
**Seiten** und **Beiträge** sind getrennte Menüpunkte. Suche, Status einschließlich Papierkorb und 20/50 Einträge begrenzen jede Ergebnisliste bereits in der Datenbank. Vor/Zurück erhält die Filter; übergeordnete Seiten lassen sich unabhängig von der aktuellen Ergebnisseite suchen.
Ab **1.7.1** aktualisieren sich Seiten, Beiträge und Mediathek beim Tippen per AJAX, ohne Enter oder Neuladen. Status, Anzahl und Blättern reagieren ebenfalls direkt. Der Cursor bleibt im Suchfeld; Zurück/Vor im Browser stellt die Filter wieder her. Die Mediathek sucht in Titel und Alternativtext über alle Ergebnisseiten. Menüs, Seitenauswahl und Editor-Suchfelder reagieren ebenfalls beim Tippen. Bei Ladefehlern bleiben die bisherigen Treffer sichtbar und „Erneut suchen“ wiederholt die Anfrage. Ohne JavaScript bleibt das Suchformular mit „Anwenden“ benutzbar.
Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden.
**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite.
### Konfigurationswechsel von älteren Fassungen
Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env` zusammengeführt. Die bisher wirksamen Werte aus `.env.local` bleiben dabei erhalten, insbesondere Datenbank, Medienordner und Secret. Beide Originale liegen anschließend unter `var/backups/environment-<Zeitpunkt>-<Kennung>/`; erst nach erfolgreichem Schreiben wird `.env.local` entfernt. Danach gelten Änderungen direkt in `.env`. System zeigt die tatsächlich genutzte Datenbank und die Quelle `.env` oder Server-Umgebung an.
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
### Zweite lokale Referenzwebsite: Elektro Bernhard
Neben dem Kant-IT-Piloten liegt eine vollständige Kundenwebsite in einer eigenen Installation: Datenbank `var/elektro-bernhard.db`, Medien in `var/elektro-bernhard-media`. Beide werden über Umgebungsvariablen gewählt, die Anwendung selbst bleibt unverändert:
```
DATABASE_URL="sqlite:///<Projektpfad>/var/elektro-bernhard.db" MEDIA_DIRECTORY="<Projektpfad>/var/elektro-bernhard-media" php -S 127.0.0.1:8088 -t public bin/dev-router.php
```
In der App-Vorschau erledigt das die Startkonfiguration `elektro-bernhard` in `.claude/launch.json` (Port 8088).
Der Bestand wird nicht von Hand gepflegt, sondern aufgebaut: `scripts/seed-elektro-bernhard.php <Ordner mit den Originalbildern>` legt Gestaltung, Medien, Formular, Kopf- und Fußbereich, elf Seiten, vier Menüs, Einstellungen, Weiterleitungen und ein Administratorkonto an. Das Skript weigert sich, wenn die Datenbank schon Seiten enthält oder `DATABASE_URL` nicht auf diese Installation zeigt. `sh scripts/reset-elektro-bernhard.sh <Bilderordner>` wirft die Installation weg, migriert neu und baut sie wieder auf; das Passwort des Kontos steht am Ende der Ausgabe. Vorlage ist das statische Paket `Elektro-Bernhard-Website`; die drei Stellen, an denen der Nachbau bewusst abweicht, stehen in `docs/STATUS.md`.
### Dritte lokale Referenzwebsite: Tagespflege Moldovan
Der Onepager „Tagespflege Jacqueline Moldovan“ liegt in `var/tagespflege-moldovan.db` mit Medien in `var/tagespflege-moldovan-media`; die Startkonfiguration `tagespflege-moldovan` in `.claude/launch.json` nutzt Port 8089. Der Inhalt entsteht ausschließlich über `scripts/seed-tagespflege-moldovan.php <Ordner mit gemeinsam.webp, jacqueline-moldovan.png und marke.png>`; `sh scripts/reset-tagespflege-moldovan.sh [Ordner]` (Standard `var/tagespflege-moldovan-assets`) wirft die Installation weg, migriert neu und baut sie wieder auf. Vorlage war das gelieferte Paket `Tagespflege_Moldovan_Website.zip` (09.09.2026); die bewussten Abweichungen stehen im Kopf des Skripts.
### Vierte lokale Referenzwebsite: Ihr Malerservice Drews
Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `var/maler-drews.db` mit Medien in `var/maler-drews-media`; die Startkonfiguration `maler-drews` in `.claude/launch.json` nutzt Port 8093. `scripts/seed-maler-drews.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, acht Seiten, 31 Beiträge in zwei Kategorien, Menüs, Formular, Einstellungen und Weiterleitungen auf; der Wortlaut der Beiträge, des Impressums und der Datenschutzerklärung steht in `scripts/seed-maler-drews-inhalte.php`. `sh scripts/reset-maler-drews.sh [Bilderordner]` (Standard `var/maler-drews-assets`, Zuordnung in `manifest.json`) wirft die Installation weg und baut sie neu auf; die Schriften Lato 400/700 und Cormorant Garamond 700 holt es nur, wenn die Cormorant-Dateien in `public/assets/fonts` fehlen, weil `cms:fonts:fetch` die geteilte `fonts.css` sonst nur für dieses Design neu schreibt (Kant-IT und Elektro Bernhard brauchen Lato 900). Für den Abruf liegt `var/cacert.pem` (curl.se) bereit, weil das PHP 8.4 auf diesem Rechner keine Stammzertifikate kennt. Die bewussten Abweichungen vom Original stehen im Kopf des Skripts.
### Fünfte lokale Referenzwebsite: DEEG Immobilien & Hausverwaltung
Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
+1 -1
View File
@@ -1 +1 @@
1.5.2
1.18.0
+8 -8
View File
@@ -1,13 +1,13 @@
<?php
// Only creates local state; never replaces an existing environment file.
require dirname(__DIR__).'/vendor/autoload.php';
use App\Ops\Environment;
$root = dirname(__DIR__);
if (!is_dir($root.'/var')) {
mkdir($root.'/var', 0775, true);
}
if (!file_exists($root.'/.env.local')) {
file_put_contents($root.'/.env.local', 'APP_SECRET='.bin2hex(random_bytes(32)).PHP_EOL);
echo "Created .env.local with a local secret.\n";
} else {
echo "Existing .env.local preserved.\n";
mkdir($root.'/var', 0o775, true);
}
Environment::prepare($root);
Environment::ensureSecret($root);
echo ".env ist eingerichtet; bestehende Einstellungen und das Secret bleiben erhalten.\n";
+15 -16
View File
@@ -1,29 +1,28 @@
<?php
use App\Ops\Environment;
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
// PHP web hosts may omit E from variables_order. Honor real process configuration.
foreach (['APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', 'MEDIA_DIRECTORY'] as $name) {
$root = dirname(__DIR__);
Environment::prepare($root);
// Server variables are explicit deployment overrides; other .env.* files are never loaded.
$keys = array_keys((new Dotenv())->parse((string) file_get_contents($root.'/.env')));
foreach (array_unique([...$keys, 'APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', 'MEDIA_DIRECTORY', 'ADMIN_PATH', 'FONT_DIRECTORY', 'ASSET_DIRECTORY']) as $name) {
$value = getenv($name, true);
if (false !== $value && !isset($_SERVER[$name]) && !isset($_ENV[$name])) {
$_SERVER[$name] = $_ENV[$name] = $value;
}
}
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
// A freshly unpacked installation has no secret, and without one nothing boots - not even the
// installer that would explain why. So the first request writes one into .env.local and never
// touches it again. If the project directory is not writable, the request still works, but only
// this one: the installer's system check says so in a sentence instead of leaving a white page.
if (!isset($_SERVER['APP_SECRET']) || '' === $_SERVER['APP_SECRET']) {
$secret = bin2hex(random_bytes(32));
$file = dirname(__DIR__).'/.env.local';
$existing = is_file($file) ? (string) file_get_contents($file) : '';
if (false === @file_put_contents($file, $existing.(str_ends_with($existing, "\n") || '' === $existing ? '' : "\n").'APP_SECRET="'.$secret.'"'."\n")) {
$secret .= '-nicht-gespeichert';
} else {
@chmod($file, 0o600);
(new Dotenv())->load($root.'/.env');
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $_SERVER['APP_ENV'] ?? 'prod';
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? '0';
if ('' === ($_SERVER['APP_SECRET'] ?? '')) {
try {
$secret = Environment::ensureSecret($root);
} catch (RuntimeException) {
// Keep the installer reachable: its system check names the missing permission.
$secret = bin2hex(random_bytes(32)).'-nicht-gespeichert';
}
$_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'] = $secret;
}
+6 -3
View File
@@ -8,7 +8,9 @@ framework:
cookie_httponly: true
cookie_samesite: lax
cookie_secure: auto
cookie_path: /admin
# Der Pfad folgt der Adresse der Verwaltung: hiesse sie anders und das Cookie weiter
# /admin, schickte der Browser es nie mit, und keine Anmeldung kaeme je an.
cookie_path: '/%kant.admin_path%'
name: KANTSESSION
router:
utf8: true
@@ -19,6 +21,7 @@ framework:
log: true
mailer:
# Default to the host's sendmail, which is what ordinary PHP hosting offers. Override with
# MAILER_DSN in .env.local for SMTP. Delivery failures never lose a submission: it is
# MAILER_DSN in .env for SMTP. Delivery failures never lose a submission: it is
# stored first and stays visible as undelivered in the administration.
dsn: '%env(resolve:MAILER_DSN)%'
# Ohne `resolve:`: ein SMTP-Passwort mit Prozentzeichen ist kein Parametername.
dsn: '%env(MAILER_DSN)%'
+3
View File
@@ -1,5 +1,8 @@
twig:
default_path: '%kernel.project_dir%/templates'
# Die Vorlagen der Plugins (@plugins/<name>/...) registriert App\Twig\PluginExtension zur
# Laufzeit - nur wenn der Ordner plugins/ existiert; ein fester Pfad hier liesse Twig auf einer
# Installation ohne Plugins abbrechen.
strict_variables: true
globals:
# Reading the decision needs the current request, so the templates ask the gate directly
+59 -6
View File
@@ -15,25 +15,38 @@ services:
_defaults:
autowire: true
autoconfigure: true
# Jede Aufgabe traegt sich selbst in den Plan ein: eine Klasse, die App\Ops\Tasks\Task
# implementiert, bekommt das Etikett und liegt beim naechsten Abgleich in der Tabelle. Eine
# neue Aufgabe ist damit eine Datei und keine Liste, die man an drei Stellen nachfuehrt.
_instanceof:
App\Ops\Tasks\Task:
tags: ['kant.task']
App\:
resource: '../src/'
exclude: '../src/{Kernel.php,Domain/}'
Doctrine\DBAL\Connection:
factory: ['App\Infrastructure\ConnectionFactory', 'create']
arguments: ['%env(resolve:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
App\Controller\PageController:
arguments:
$secret: '%kernel.secret%'
$siteOrigin: '%env(SITE_ORIGIN)%'
# Mit oder ohne www entscheidet SITE_ORIGIN; die andere Schreibweise leitet dorthin um.
App\EventSubscriber\CanonicalHostSubscriber:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
App\Content\MediaLibrary:
arguments:
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
App\EventSubscriber\AdminHeadersSubscriber:
arguments:
$adminPath: '%kant.admin_path%'
App\EventSubscriber\PublicNotFoundSubscriber:
arguments:
$adminPath: '%kant.admin_path%'
App\Content\Statistics:
arguments:
$adminPath: '%kant.admin_path%'
@@ -57,18 +70,26 @@ services:
App\Controller\SystemController:
arguments:
$adminPath: '%kant.admin_path%'
$databaseUrl: '%env(DATABASE_URL)%'
$siteOrigin: '%env(SITE_ORIGIN)%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
$projectDir: '%kernel.project_dir%'
App\Command\AdminPathCommand:
arguments:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
$environment: '%kernel.environment%'
App\Command\SiteOriginCommand:
arguments:
$projectDir: '%kernel.project_dir%'
$siteOrigin: '%env(SITE_ORIGIN)%'
App\Ops\ArchiveUpdate:
arguments:
$root: '%kernel.project_dir%'
App\Ops\Updater:
arguments:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(resolve:DATABASE_URL)%'
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
App\EventSubscriber\InstallSubscriber:
arguments:
$projectDir: '%kernel.project_dir%'
@@ -79,14 +100,46 @@ services:
App\Ops\Installation:
arguments:
$root: '%kernel.project_dir%'
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
$secret: '%kernel.secret%'
App\Ops\Backup:
arguments:
$root: '%kernel.project_dir%'
$databaseUrl: '%env(resolve:DATABASE_URL)%'
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
# jedes Mal mitwaechst.
App\Ops\Scheduler:
arguments:
$tasks: !tagged_iterator kant.task
$projectDir: '%kernel.project_dir%'
App\Ops\Tasks\CleanupTask:
arguments:
$projectDir: '%kernel.project_dir%'
App\EventSubscriber\TaskWorkerSubscriber:
arguments:
$projectDir: '%kernel.project_dir%'
$adminPath: '%kant.admin_path%'
App\Controller\TaskController:
arguments:
$secret: '%kernel.secret%'
App\Controller\CronController:
arguments:
$secret: '%kernel.secret%'
App\Infrastructure\QueryCounter: ~
# `%env(projectdir:...)%`: ersetzt nur %kernel.project_dir%. Symfonys `resolve:` laese ein
# prozentkodiertes MySQL-Passwort (`p%40ss`) als Parameter und brauche den Container.
App\Infrastructure\ProjectDirEnvProcessor:
arguments:
$projectDir: '%kernel.project_dir%'
App\EventSubscriber\QueryHeaderSubscriber:
arguments:
$environment: '%kernel.environment%'
App\Plugin\PluginRegistry:
arguments:
$projectDir: '%kernel.project_dir%'
App\Twig\PluginExtension:
arguments:
$loader: '@twig.loader.native_filesystem'
+62
View File
@@ -0,0 +1,62 @@
# Arbeitsplan: Installation, Verwaltung und Bewegung
Auftrag vom 14.09.2026. Ausgangspunkt: Release 1.6.2 plus bereits vorhandene lokale Änderungen an der Systemdiagnose. Diese bleiben erhalten und werden an den neuen Konfigurationsvertrag angepasst. Vorhandener Patch liegt lokal unter `var/work-before-usability/`.
## 1. Fehler reproduzieren und Daten schützen
- [x] Bereichsfehler auf den Seitenzugriff in der gemeinsamen Vorlage zurückführen und Öffnen/Vorschau/Speichern im Browser prüfen.
- [x] Kant-IT-Datenbank für die Prüfung isoliert kopieren; echte Inhalte und Zugangsdaten nicht verändern.
- [x] Slider und Einblendungen auf der Kant-IT-Startseite mit Positionen und Zeitverlauf prüfen.
## 2. Eine eindeutige Installationskonfiguration
- [x] `.env` ist die Konfigurationsdatei der Installation. Installer, Setup und Admin-Pfad-Befehl schreiben dort; keine neue `.env.local`.
- [x] Bestehende wirksame Einstellungen aus älteren Installationen einmalig und mit Sicherung in `.env` zusammenführen. Datenbankverbindung und Secret erhalten; danach keine versteckte Überschreibung.
- [x] Updates erhalten `.env`; echte Server-Umgebungsvariablen bleiben für Hosting/Testbetrieb möglich. Anzeige im Systembereich muss die tatsächliche Quelle nennen, ohne Passwörter auszugeben.
- [x] Neuinstallation, bestehende Installation, wiederholtes Booten, Updateerhalt und Fehler beim Schreiben prüfen.
## 3. Bereiche zuverlässig bearbeiten
- [x] Gemeinsamen Editor ohne Zugriff auf nicht vorhandene Seitenvariablen rendern; benannte Routen auch bei geändertem Admin-Pfad verwenden.
- [x] Kopf, Fuß und verknüpfte Sektion öffnen, Inhalt ändern, Vorschau prüfen, speichern und neu laden; Versionskonflikte erhalten.
- [x] Sofort wirksames Speichern bei Bereichen in der Oberfläche korrekt benennen.
## 4. Seiten und Beiträge übersichtlich verwalten
- [x] Eigene Navigation und Ansichten für Seiten und Beiträge; passende Anlageformulare.
- [x] Datenbankseitige Pagination, Suche, Statusfilter und Ergebniszahl; keine vollständigen Dokumentlisten für jede Bildschirmseite laden.
- [x] Filter und Seitenzahl beim Blättern erhalten; leere Suche und ungültige Seitenzahlen behandeln. Papierkorb ebenfalls begrenzen.
- [x] Übergeordnete Seiten bleiben unabhängig von der aktuellen Ergebnisseite wählbar.
## 5. Menüeditor mit verständlichem Aufbau
- [x] Seiten/Beiträge nach Titel oder Adresse suchen und mit eindeutiger Vorschau auswählen.
- [x] Inhalt auswählen → zum Menü hinzufügen → Reihenfolge/Unterpunkte ändern → speichern. Eigene Links getrennt anbieten.
- [x] Bestehende Ziele und Hierarchien erhalten, Umordnen auch mit Tastatur/Buttons; Speicherfehler und ungespeicherte Änderungen sichtbar behandeln.
## 6. Formulare, Animationen und Slider
- [x] Meldung nach dem Absenden als ausreichend breites mehrzeiliges Feld anbieten.
- [x] Einblendungen sichtbar beim Eintreten in den Bildschirm starten; Effektauswahl tatsächlich unterscheiden. Inhalte ohne JavaScript lesbar lassen und Bewegungsreduktion respektieren.
- [x] Slider mit sichtbarer Bedienung, funktionierendem Wechsel und nachvollziehbarer Pause/Automatik. Auf der realen Kant-IT-Seite über mehrere Wechsel prüfen.
- [x] Bewegung in der Editorvorschau gezielt prüfbar machen, ohne die Sandbox für Inhaltsskripte zu öffnen.
## 7. Abnahme und Übergabe
- [x] Relevante PHP-/JavaScript-Tests und vollständige Browserabläufe, einschließlich Tastatur, Speicherung, Fehlerzuständen und unterschiedlichen Bildschirmbreiten.
- [x] Änderungen der öffentlichen Ausgabe auf Kant-IT-Inhalten visuell und mit den passenden Qualitätsprüfungen kontrollieren; keine ungeprüften Scores behaupten.
- [x] Tatsächliche Ergebnisse, Grenzen und Migration dokumentieren; Versionsnummer vor Veröffentlichung mit dem Repository abgleichen.
Jeder Abschnitt wird erst als erledigt markiert, wenn seine Prüfung bestanden ist. Der Plan ersetzt keine Umsetzung; Befunde und Änderungen werden darin nachgeführt.
## Ergebnis der Abnahme
Umgesetzt als 1.7.0, ohne neues Datenbank- oder Dokumentformat. Begründungen und Konfigurationsmigration: ADR 076–078. Die ursprünglichen lokalen Änderungen an Systemdiagnose/STATUS wurden integriert, die veraltete lokale Versionsnummer 1.4.7 mit der bestehenden Releasefolge abgeglichen.
Geprüft: PHP-Vollsuite 461/461, JavaScript 30/30, alle 43 Twig-Dateien und Produktionscontainer. Komplette Editorprüfung einschließlich unterschiedlicher Einblendungen und Bewegungsreduktion. Kant-IT-Browserabläufe mit 4 Seiten/29 Beiträgen, Menüfehlern und Versionskonflikt, mehrzeiliger Formularmeldung, Kopf/Fuß und neu angelegter Sektion samt echtem Textinhalt. Desktop, 1024 und 390 px; axe ohne Verstöße. Öffentliche Inhalte und Slider bleiben ohne JavaScript erreichbar.
Kant-IT-Startseite, Produktionsmodus, Lighthouse 13.4.1: jeweils drei Läufe mobil/desktop, Leistungsmedian jeweils 99; Barrierefreiheit, Best Practices, SEO und Agentic Browsing jeweils 100. Lokale Labormessung, keine Felddaten/CWV-Abnahme für Kundenseiten. Messungen: `reports/admin-quality/lighthouse/`; Browserbericht und Bilder im übergeordneten Ordner. Der zusätzliche Updater-Test verändert die Datenbank vor einem Migrationsfehler, prüft echte Wiederherstellung und einen erhaltenen Wartungszustand, falls die Sicherung fehlt.
Veröffentlichungsnachweis und bekannte Grenzen stehen im aktuellen Abschnitt von STATUS.md.
**Veröffentlicht:** `495b268`, Tag `v1.7.0`, Release mit ZIP/Prüfsumme/Installer. Quellabgleich und anonyme Downloads geprüft; frischer Release-Installer antwortet mit HTTP 200 und erzeugt ausschließlich `.env`.
+380 -6
View File
@@ -1,6 +1,349 @@
# Architekturentscheidungen
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 104 – Die Rückstände der Gesamtprüfung abgearbeitet: Archiv als Seite, Bereiche vollständig, Palette bis in die Fehlerfarbe, Betrieb auf MySQL (16.09.2026)
Umut: „Bitte umsetzen“ – die Liste aus ADR 100, die bis dahin nur dokumentiert war.
**Archivseiten sind Seiten.** `/beitraege`, `/kategorie/…` und `/schlagwort/…` bauen ein Dokument aus Bausteinen (Kopfabschnitt mit Überschrift und Zahl, ein Abschnitt mit blätternder Beitragsliste) und geben es an `renderPage()` – dieselbe Aufbaukette wie jede Seite: Kopf und Fuß mit ihren Stilen, verknüpfte Sektionen, eigenes CSS, Schriften, Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Skripte, Richtlinie. Die eigene Vorlage `archive.html.twig` ist weg. Ein Archiv hat den Typ `archive`; Brotkrumen führen „Beiträge → Kategorie“, und das steht als `BreadcrumbList` in den strukturierten Daten.
**Blättern ist eine Folge von Seiten.** Hat eine Seite eine blätternde Beitragsliste, nennt die Canonical ab Seite 2 die Seitenzahl (`?seite=n`), und `<link rel="prev">`/`<link rel="next">` verbinden die Folge. Vorher hielt eine Suchmaschine Seite 3 für ein Duplikat von Seite 1.
**Bereiche sind vollständig.** Formulare, Beitragslisten und Brotkrumen in Kopf, Fuß und verknüpften Sektionen werden jetzt wie im Inhalt aufgelöst; die Meldungen eines abgewiesenen Formulars finden ihren Block über die Formularadresse statt über die Block-ID, wo auch immer er steht. Eigene Skripte eines Bereichs laufen als `/assets/bereich/<id>.js` – vorher wurden sie gespeichert, geprüft und nie ausgeführt.
**Mehrfach verknüpfte Sektionen ohne doppelte IDs.** `PartialRepository::expandRepeatedLinks()` zählt in Renderreihenfolge (Kopf, Inhalt, Fuß, verschachtelt bis zur Grenze) und gibt der zweiten und jeder weiteren Verknüpfung eine Kopie mit angehängten Kennungen (`id-2`) und ohne Sprungziele; ihre Stile werden mitkompiliert. Auch die Editorvorschau macht das.
**Eine Grenze zwischen Tablet und Telefon: 767 px.** Design-Tokens, Website-Stile, Slider und Galerie sprangen bei 650, der Stilcompiler und die Sichtbarkeit bei 767 – zwischen 651 und 767 galt die Hälfte. Jetzt gilt überall 1024/767, wie es der Editor schon immer sagte.
**Die Palette reicht bis in die Fehlerfarbe.** `color-scheme`, Skip-Link, Aufruf-Button, die Fehlerfarben der Formulare und der Text auf dunklen Abschnitten kommen aus Variablen; `Theme::css()` leitet aus der Leuchtdichte des Papiers ab, ob das Schema dunkel ist und welches Rot dort steht (`--k-scheme`, `--k-error`, `--k-error-bg`, `--k-error-line`). Buttons dunkeln beim Überfahren in die Tinte statt in Schwarz. Neu: Druckstile – Menü, Einwilligung, Nach-oben und Formularknöpfe bleiben weg, Verweise zeigen ihre Adresse.
**Betrieb.** Sicherung und Wiederherstellung auf MySQL/MariaDB über `mysqldump --single-transaction --result-file` und `mysql --execute=source`; damit laufen Archiv-Update und Rückweg auch dort. Der Git-Updater sichert über `Backup` (VACUUM INTO bzw. Dump) statt mit einer rohen Dateikopie und spielt beim Rückweg dieselbe Sicherung ein. Eine Sperre (`var/update.lock`, flock) hält den zweiten Klick und den Archiv-Weg neben dem Git-Weg fern. Das Archiv wird direkt in eine Datei geladen (`Download::toFile()`), nicht mehr als Zeichenkette in den Speicher. `public/.htaccess` wird zusammengeführt: eingeschaltete HTTPS- und HSTS-Zeilen bleiben eingeschaltet. Nach einem gelungenen Update räumt `finish()` `var/update/` (bis auf die zwei jüngsten) und die Zwischenspeicher früherer Fassungen auf. Der MySQL-Job der CI prüft jetzt Gesundheit, Sicherung und die Tests.
**Editor.** Die Vorschau zeigt eigenes CSS, Bereichs- und Sektionsstile und Formulare mit ihren Farben – „sieht in der Vorschau anders aus“ ist keine Ausrede mehr. Kontaktdaten, Formular, verknüpfte Sektion und externer Inhalt sind direkt nach dem Einfügen gültig: leer heißt „noch nichts gewählt“, der Editor sagt es, die Website zeigt nichts. Der Server gibt nach dem Speichern die bereinigte Fassung zurück, und der Editor übernimmt sie als gespeicherten Stand (`EditorHistory.adopt()`), statt sie erst nach dem Neuladen zu zeigen.
**Kleines.** `legal.label` ist auf 60 Zeichen begrenzt, die verrutschten Docblocks in `PageController` stehen an ihren Methoden.
**Folgen.** `tests/BacklogTest.php` hält alles fest. Nicht gebaut: eine Wiederherstellung auf MySQL ohne `mysql`-Client (dann liegt der Dump zum Einspielen von Hand bereit), Druckstile für Beiträge mit Bildkopf.
## ADR 103 – Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen und Designpakete für Plugins – und warum es keine PHP-Hooks gibt (16.09.2026)
Umut: „Dann mach H.“ Der erste Teil (ADR 102) brachte Manifest, Schalter und geprüfte Bausteine. Offen waren Container, Einstellungen, eigene Routen, Tabellen, Hooks und Templates.
**Grundentscheidung: ein Plugin bleibt Manifest plus Twig.** Es bringt keinen PHP-Code mit. Das ist kein Zufall, sondern die Antwort auf zwei Punkte des Entwicklungsplans zugleich: Ein Plugin ohne PHP kann nicht als „sandboxed“ bezeichnet werden müssen, weil es gar nichts ausführt, was der Kern nicht ohnehin ausführt (Twig mit `strict_variables`, ohne Zugriff auf Datenbank, Anfrage oder Dateisystem). Und ein Update des Kerns kann ein solches Plugin nicht brechen, solange die vier Übergaben stehen – `props`, `settings`, `children`, `block`. Damit fallen zwei Wünsche des Plans bewusst weg: **Hooks** in Speichern und Veröffentlichen und **eigene Tabellen**. Beides braucht Code, der im Kern läuft. Wer das braucht, baut einen Kernbaustein (ADR 049) oder wartet auf einen dritten Teil mit einer echten Erweiterungsschnittstelle in PHP – dann mit der Ehrlichkeit, dass er Installationscode ist.
**Container.** `"container": true` am Baustein, optional `"children": [Typen]`. Der Kern rendert die Kinder selbst – mit demselben Kontextbündel wie jeden Kernbaustein – und reicht sie der Vorlage fertig als `children`. So kommt eine Plugin-Vorlage nie an Formulare, Beitragslisten oder die Navigation. Beim Schreiben prüft `DocumentValidator`, dass ein Nicht-Container keine Kinder trägt und ein Container nur die erlaubten; der Editor spiegelt die Regel in `canContain`, also im Werkzeugkasten und beim Ziehen.
**Einstellungen.** `"settings": {Schlüssel: Schema}` mit denselben sieben Arten wie Eigenschaften. Der Bildschirm „Plugins“ zeigt für jedes aktive Plugin ein Formular daraus; gespeichert wird in der neuen Spalte `plugin.settings` (Migration `20260916000100`), geprüft gegen das Schema wie eine Eigenschaft – aus einem Formular kommen Zahlen und Schalter als Text, das wird umgewandelt. Vorlagen und Adressen bekommen die Werte als `settings`, fehlende Schlüssel mit ihrer Vorgabe.
**Eigene Adressen.** `"routes": {name: {path, template, type}}`. Der Pfad hat die Form einer Seite (Slugs, bis zu vier Ebenen) und darf nichts belegen, was der Kern beantwortet; die Antwortart ist eine aus html, json, text, xml. `PageController::page()` fragt die Registry, wenn es zu einem Pfad keine veröffentlichte Seite gibt – eine Seite geht also immer vor, die Adresse kommt vor Weiterleitung und 404. Die Vorlage bekommt `settings`, die Abfrageparameter (nur saubere Namen, gekürzt) und die Route; die Antwort trägt dieselben Schutzkopfzeilen wie jede Seite, kein Skript, bei allem außer html `noindex`. Ausgeschaltet: 404.
**Seitenvorlagen.** `"presets": {name: {label, blocks}}` – fertige Bausteine, die der Editor als Ganzes einfügt, hinter den eingebauten Vorlagen mit demselben Knopf. Jedes Einfügen vergibt frische Kennungen; geprüft wird beim Speichern wie jedes Dokument. Ein Plugin kann so seinen Baustein gleich im passenden Abschnitt anbieten.
**Designpakete.** `"theme": {colors, fonts, base, buttons}` im Format des Designbereichs. „Designpaket übernehmen“ auf dem Bildschirm „Plugins“ ruft `Theme::save()` – dieselbe Prüfung wie beim Speichern unter Design, dieselben Fehler. Inhalte bleiben unangetastet; genau das ist die Abnahme des Plans („Templatewechsel erhält die Inhalte“), und der Test wechselt auf einer Seite mit Plugin-Baustein das Paket und vergleicht das Dokument davor und danach. Ein Plugin darf nur aus einem Designpaket bestehen (`plugins/nachtblau`); übernehmen geht nur eingeschaltet.
**Referenz.** `plugins/vergleich` 1.1.0 hat jetzt Einstellungen (Standardbeschriftungen, Griff), einen Container „Vergleichsgalerie“ nur für Bildvergleiche, die Vorlage „Vorher-Nachher-Abschnitt“ und die Adresse `/vergleich/daten` (JSON mit den Einstellungen). `plugins/nachtblau` ist das Designpaket.
**Folgen.** `docs/PLUGINS.md` beschreibt alles. Nicht gebaut und nicht geplant, solange Plugins ohne PHP bleiben: Hooks, Tabellen, Ressourcen in der Verwaltung, eigene Verwaltungsbildschirme.
## ADR 102 – Das Pluginsystem, erste Fassung: Manifest, Schalter, geprüfte Bausteine (15.09.2026)
Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit.“ Bis hierher gab es nur den Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wurde aufbewahrt, aber nie gerendert. Jetzt gibt es die Laufzeit dazu – Arbeitspaket H, erster Teil.
**Ein Plugin ist ein Ordner.** `plugins/<name>/plugin.json` beschreibt Titel, Fassung, kleinste KantSites-Fassung und die Bausteine: je Baustein Beschriftung, Symbol, Twig-Vorlage, optional ein Stylesheet und ein Skript unter `assets/`, und ein Schema der Eigenschaften mit sieben Arten (Text, mehrzeilig, Zahl mit Grenzen, an/aus, Auswahl, Adresse, Bild aus der Mediathek). Der Typ des Bausteins ist `<plugin>.<baustein>` – dieselbe Form, die der Erhaltungsvertrag seit Schema v4 kennt. `PluginRegistry` liest die Manifeste bei jedem Aufruf (ein Dutzend Dateien, kein Cache nötig) und meldet ein kaputtes Manifest mit Grund statt es zu übergehen; ein solches Plugin lässt sich nicht einschalten.
**Der Schalter liegt in der Datenbank.** Migration `20260915000100` legt `plugin` an (Name, aktiv, Fassung beim Einschalten, Zeitpunkt). Ob ein Plugin läuft, entscheidet die Installation, nicht der Code – und nicht eine Datei, die ein Update überschreiben könnte. Der Bildschirm „Plugins“ (Administrator) zeigt jeden Ordner mit seinen Bausteinen und schaltet ein und aus; ein ausgeschaltetes Plugin lässt seine Bausteine unangetastet in den Dokumenten (Erhaltungsvertrag wie bisher).
**Der Kern prüft, die Vorlage vertraut.** Beim Schreiben eines Dokuments prüft `DocumentValidator` die Eigenschaften eines Plugin-Bausteins gegen das Schema – so streng wie bei einem Kernbaustein, aber nur, wenn das Plugin aktiv ist. Beim *Lesen* nicht (`validate(..., writing: true)` nur an den Schreibstellen): ein Plugin, das nach dem Speichern eingeschaltet oder geändert wurde, darf eine gespeicherte Seite nicht unlesbar machen. Dass unpassende Werte trotzdem nie in die Ausgabe gelangen, sichert der Renderer: `kant_plugin_props(block)` prüft beim Rendern erneut und liefert bei einem Fehler null – dann steht ein Kommentar statt des Bausteins. Die Vorlage bekommt genau vier Dinge: die geprüften `props`, `block` (für die `id`), `designClasses` und `editing`, dazu `kant_plugin_media()` für Bilder. Keine Datenbank, keine Einstellungen, keine Anfrage. Unbekannte Eigenschaften in einem gespeicherten Dokument werden ignoriert, damit ein Plugin eine Eigenschaft streichen kann.
**Dateien nur dort, wo der Baustein steht.** `PageController` sammelt aus allen Dokumenten der Seite die Plugin-Bausteine und verlinkt ihre Stylesheets und Skripte (`/plugin-assets/<name>/assets/...?v=<Fassung>`); ein Skript schaltet die Richtlinie auf `script-src 'self'`, sonst bleibt sie bei `'none'`. `PluginAssetController` liefert nur Dateien aktiver Plugins unter `assets/` mit erlaubter Endung, mit Typ, `nosniff` und einem Jahr Haltbarkeit; Manifest und Vorlagen sind nicht erreichbar. Plugins liegen bewusst außerhalb von `public/`. In der Editorvorschau wird das Stylesheet geladen, das Skript nicht – wie bei eigenem Code.
**Der Editor kennt die Bausteine aus dem Dokumentaufruf.** Der Editor-Endpunkt liefert mit dem Dokument die Definitionen der aktiven Plugins; `registerPlugins()` trägt Beschriftung, Symbol und Vorgaben ein und gibt ihnen die Gruppe „Erweiterungen“ im Werkzeugkasten. Das Eigenschaftenpanel entsteht aus dem Schema mit derselben Feldfabrik wie für Kernbausteine; ein Bild geht über die Mediathek. Ein Baustein, dessen Plugin fehlt, bleibt der bekannte Platzhalter. Design-Tokens (Abstände, Ausrichtung, Ecken) gelten jetzt auch für Plugin-Bausteine – die Klassen sind allgemein, die Prüfung lässt namensraumhaltige Typen durch.
**Kein Sandkasten, und das steht so da.** Eine Plugin-Vorlage läuft mit den Rechten der Anwendung. Der Entwicklungsplan verlangt, das nicht anders zu nennen; der Bildschirm „Plugins“ sagt es dem Betreiber.
**Referenzplugin „Bildvergleich“** (`plugins/vergleich`): zwei Bilder übereinander, ein Regler dazwischen – Beschriftungen, Startposition, Ecken als Eigenschaften, ein Stylesheet, ein Skript, das nur `--pos` über das CSSOM setzt (Inline-Stile bleiben durch die Richtlinie verboten). Ohne Skript steht der Vergleich bei der Startposition, das native Bereichsfeld bleibt bedienbar.
**Updates.** Das Releasearchiv enthält `plugins/`; das Archiv-Update übernimmt den Ordner Datei für Datei wie `public/`, sodass mitgelieferte Plugins aktualisiert werden und ein selbst installiertes stehen bleibt.
**Nicht in dieser Fassung:** Container-Bausteine, eigene Routen, Einstellungen, Tabellen, Hooks in Speichern und Veröffentlichen, Ressourcen in der Verwaltung, Templates (der zweite Teil von H). Anleitung für Entwickler: `docs/PLUGINS.md`.
## ADR 101 – Formulare mit Schritten und Bedingungen: der Preisfinder ist ein Formular, kein Plugin (15.09.2026)
Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so ein ausgebautes Form“ – den Preisfinder der laufenden Website: erst die Objektart, dann Eckdaten je Objektart, dann Kontakt, zum Schluss eine Zusammenfassung. Und er fragte, ob das ein eigenes Plugin braucht.
**Warum kein Plugin.** Ein Plugin ist in diesem CMS bis heute nur der Erhaltungsvertrag aus ADR 011: ein Baustein mit Namensraum wird gespeichert, im Editor als Platzhalter gezeigt und öffentlich als Kommentar ausgegeben. Registrierung, Laufzeit, Eigenschaftenpanel und Ressourcenladen sind Arbeitspaket H und nicht gebaut. Ein Preisfinder als Plugin hätte zuerst das ganze Pluginsystem gebraucht – und wäre dann eine Hülle um genau das, was der Formularbaukasten schon kann: Felder prüfen, speichern, verschicken, anzeigen. Wie bei der Beitragsseite (ADR 049) ist die Antwort ein Ausbau des Kerns. Ein Formular, das in Schritten fragt und Felder je nach Antwort zeigt, braucht jede Kundenwebsite.
**Was dazukam.** Drei Feldtypen: Zahl (mit Unter-, Obergrenze und Einheit), Auswahl als Optionsfelder und Mehrfachauswahl. Ein Feld vom Typ „Neuer Schritt“ eröffnet einen Abschnitt: seine Beschriftung ist der Titel, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann eine Bedingung tragen (`showIf: {field, values}`): es gilt nur, wenn ein *früheres* Auswahlfeld einen der Werte hat. Und eine Einstellung „Zusammenfassung vor dem Absenden“.
**Der Server entscheidet, nicht das Skript.** `FormRepository::check()` geht die Felder in Reihenfolge durch und merkt sich die gewählten Optionen; ein Feld hinter einer Bedingung, die nicht zutrifft, wird weder verlangt noch geprüft noch gespeichert – auch wenn der Browser einen Wert mitschickt. Ein Schritt hinter einer Bedingung nimmt seine Felder mit. Beim Speichern der Definition wird jede Bedingung geprüft: das Feld muss davor stehen, ein Auswahlfeld sein, und jeder Wert eine seiner Optionen – sonst könnte die Bedingung nie zutreffen, und ein Pflichtfeld dahinter wäre unerreichbar oder immer offen. Optionen dürfen kein Komma enthalten, weil die Verwaltung daran trennt und die Mehrfachauswahl damit gespeichert wird.
**Ohne Skript vollständig.** Die Vorlage rendert Schritte als `fieldset` mit `legend`, Optionsfelder und Mehrfachauswahl als Gruppen, Zahlen als `type="number"` mit Grenzen; Bedingungen stehen als Datenattribute am Feld. Ohne JavaScript steht das ganze Formular auf der Seite, jeder Schritt ein Abschnitt, alles absendbar – der Server prüft ohnehin selbst. `form-steps.js` kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat (`FormRepository::needsScript()`), und die Richtlinie erlaubt es dann; es zeigt einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“, prüft den aktuellen Schritt mit der Browserprüfung, deaktiviert Felder hinter nicht zutreffenden Bedingungen (deaktiviert, nicht nur versteckt – sie werden nicht gesendet und nicht geprüft) und baut als letzten Schritt die Zusammenfassung aus den geltenden Feldern mit „Ändern“ je Schritt. Nach einer Ablehnung durch den Server öffnet es den ersten Schritt mit einem Fehler. Eine Pflicht-Mehrfachauswahl trägt `required` nicht am Kästchen – das hieße „alle ankreuzen“ – sondern als `data-required` an der Gruppe; Skript und Server prüfen sie dort.
**Bildauswahl.** Umut zeigte den Solar-Rechner von Elektro Bernhard: „Wieviele Personen leben im Haushalt“ als vier Bildkarten, die gewählte mit Rahmen. Optionsfelder und Mehrfachauswahl tragen dafür je Option ein Bild aus der Mediathek (`images: {Option: Kennung}`); sobald eine Option eines hat, rendert die Vorlage die Auswahl als Karten mit Bild und Text. Das native Kästchen bleibt in der Karte – sichtbar oben links, mit Fokusring auf der Karte –, damit Tastatur und Vorlesesoftware dieselbe Auswahl bedienen wie die Maus. Die Seite holt die kleinsten Fassungen aller Optionsbilder eines Formulars mit einer Abfrage (`PageController::optionImages()`); ein gelöschtes Bild lässt die Karte mit Text stehen. In der Verwaltung wählt man das Bild je Option aus einer Liste der Mediathek; ohne Skript ist es eine Zeile „Option = Kennung“.
**Rückweg.** Bestehende Definitionen bleiben gültig: `present()` ergänzt fehlende Schlüssel (`min`, `max`, `unit`, `images`, `showIf`, `summary`), es gibt keine Migration. Ein Formular ohne Schritte und Bedingungen rendert wie zuvor und lädt kein Skript. Die Höchstzahl der Felder steigt von 25 auf 40; ein Preisfinder mit vier Objektarten braucht sie.
**Folgen.** Die Editorvorschau zeigt Formulare weiterhin nicht (bekannter Rückstand). Der Preisfinder der DEEG-Website ist damit ein gewöhnliches Formular `bewertung` mit drei Schritten, 20 Feldern und Zusammenfassung, angelegt im Aufbauskript und in der Verwaltung ohne Skript bearbeitbar. Nicht gebaut: Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, ein Schritt-für-Schritt-Ablauf ohne JavaScript.
## ADR 100 – Gesamtprüfung: Rollen wie versprochen, Installer bleibt zu, Einwilligung nur von hier (15.09.2026)
**Anlass:** Umut wollte eine umfangreiche Prüfung – Fehler, Durchgängigkeit, Sauberkeit – und ausdrücklich die Frage, ob jemand ohne Administrator etwas verändern kann. Sechs getrennte Durchsichten (Backend, Betrieb, Editor, öffentliche Ausgabe, Dokumentation, Autorisierung jeder Route) lieferten rund sechzig Befunde; die mit Wirkung auf Sicherheit oder Bedienbarkeit sind umgesetzt, der Rest steht in STATUS als Rückstand.
**Rollen.** Die Rollenhilfe versprach „Design, Einstellungen: Administrator“ und „Redakteur: nur Entwürfe“, der Code verlangte für Design, Einstellungen, Bereiche, Menüs und Formulare aber nur `ROLE_EDITOR` – ein Redakteur konnte Kopf und Fuß live umschreiben, die Empfängeradresse der Formulare ändern, die Website per `robotsExtra` aus dem Index nehmen. Entscheidung: der Code folgt der Hilfe. **Redakteur** = Entwürfe von Seiten und Beiträgen, Medien, Statistik, eigenes Konto. **Veröffentlicher** = dazu veröffentlichen, Bereiche (Speichern ist dort Veröffentlichen), Menüs, Formulare, Weiterleitungen, 404-Seite, Einstellungen lesen. **Administrator** = dazu Design, Unternehmensdaten samt Einwilligung und Spamschutz, Benutzer, System, Aufgaben, eigene Skripte, endgültiges Löschen. Die Navigation zeigt je Rolle nur, was sie öffnen darf; die Unternehmensdaten sind für Veröffentlicher ein Hinweis statt eines Formulars. `tests/EditorSecurityTest.php` hält die Grenze mit einer Tabelle „Rolle, Route, 403“ fest, dazu die Navigation eines Redakteurs und den Weg ohne Anmeldung.
**Installer.** `Installation::isInstalled()` las jede Ausnahme als „nicht installiert“ – auch eine tote Datenbankverbindung. Bei einer Wartung des Hosters oder einem gedrehten Passwort stand der Installer offen, und jeder Besucher hätte eine eigene Datenbank samt Administratorkonto eintragen können. Jetzt zählt die Marke `var/installed` immer, eine fehlende Tabelle heißt „leer“, eine fehlende Verbindung heißt „gestört“, sobald eine Datenbank eingetragen ist. `tests/InstallationTest.php` prüft alle vier Lagen.
**Einwilligung.** `/einwilligung` hat keine Sitzung und deshalb kein CSRF-Token; ein fremdes Formular konnte jedem Besucher „alles akzeptiert“ ins Cookie schreiben. Die Route liest jetzt `Sec-Fetch-Site`, ersatzweise `Origin`/`Referer`, und merkt sich nur Entscheidungen von dieser Website; ohne Herkunftsangabe (alte Browser) bleibt sie beantwortbar. Der Rücksprung akzeptiert nur Pfade, die weder mit `//` noch mit `/\` beginnen – Browser lesen beide als protokollrelativ.
**Ausgabe.** JSON-LD wird mit `JSON_HEX_TAG|JSON_HEX_AMP` kodiert: ein `</script>` in Titel oder FAQ-Antwort hätte den Datenblock beendet und den Rest des Kopfes als Markup gerendert. Eigenes CSS erlaubt im verschachtelten Selektor kein Komma mehr (`&,body{…}` wurde zu `#id,body{…}`). Eine Fassung wiederherstellen prüft Skripte wie das Speichern. Das Archiv escaped alte Klartext-Bereiche wie die Seiten. Tabs und Bildbetrachter in Kopf, Fuß oder verknüpfter Sektion bekommen Skript und Richtlinie.
**Betrieb.** `%env(resolve:…)%` las prozentkodierte Zugangsdaten (`p%40ss`) als Parameter – der Container brach nach dem Installer bei jeder Anfrage ab. Ein eigener Prozessor `projectdir:` ersetzt nur `%kernel.project_dir%` (`src/Infrastructure/ProjectDirEnvProcessor.php`); `MAILER_DSN` kommt ohne Prozessor aus. Das Sitzungscookie folgt der Adresse der Verwaltung (`cookie_path: '/%kant.admin_path%'`) – nach `cms:admin:path` war vorher keine Anmeldung möglich. Downloads bleiben auch über Weiterleitungen bei HTTPS. Der Befehlsläufer gibt Composer und Git ein Zuhause (`HOME`, `COMPOSER_HOME`, `APPDATA`) und sucht die PHP-Kommandozeile, weil `PHP_BINARY` unter php-fpm der Server selbst ist. Ein unerwarteter Fehler im Updater nimmt den Rückweg statt den Wartungszustand stehen zu lassen.
**Editor.** `upgradeForType` hob das Schema auf die Fassung, in der ein Typ auftauchte, nicht auf die, die seine heutigen Eigenschaften brauchen – Menü (v16), Beitragsliste (v18) und Slider (v17) waren in älteren Dokumenten sofort ungültig. `controls()` gab nach jedem Speichern alle Knöpfe des Bedienfelds frei, auch die, die das Design-Bedienfeld bewusst gesperrt hatte („Gestaltung einfügen“ ohne Zwischenablage löschte die Gestaltung, „Als Vorlage speichern“ ohne Gestaltung löschte eine Vorlage gleichen Namens); jetzt merkt sich die Sperre, was sie geschlossen hat. „Ausrücken“ und ein Zug neben ein Element der obersten Ebene folgen denselben Regeln wie jeder andere Zug. Einfügen prüft das Ziel, bevor es das Schema hebt, sonst blieb bei einer Ablehnung ein leerer Verlaufsschritt. Ein Bild auf ein Akkordeon meldet sich statt zu werfen. Klartext unter v12 wird beim Öffnen nicht als HTML gelesen.
**Bewusst nicht in diesem Schritt:** Archivseiten auf dieselbe Aufbaukette wie Seiten (eigene Stile von Kopf/Fuß, Schriften, Sektionen), Formulare und Beitragslisten in Bereichen, doppelte Block-IDs bei mehrfach verknüpften Sektionen, die zwei Breakpoints (650/767 px), feste Farbwerte in dunklen Paletten, MySQL-Sicherung, `.htaccess` als Betreiberdatei beim Archiv-Update, Wachstum der Release-Caches, Druckstile, Canonical blätternder Listen – Umfang und Risiko rechtfertigen je einen eigenen Schritt (Rückstand in STATUS).
## ADR 099 – Mit oder ohne www entscheidet SITE_ORIGIN, nicht der Webserver (15.09.2026)
**Anlass:** Umut: „URL sollte aus env kommen, www oder ohne, die Seite sollte dann darauf reagieren.“ Bis dahin stand die Umleitung auskommentiert in `public/.htaccess` und musste von Hand zur Einstellung `SITE_ORIGIN` passen (LIVEGANG, Punkt 3). Zwei Stellen für eine Entscheidung laufen auseinander – und dann nennt jede Seite in Canonical, Sitemap und Feed eine Adresse, die weiterleitet.
**Entscheidung:** `SITE_ORIGIN` in `.env` ist die einzige Quelle. `CanonicalHostSubscriber` (`kernel.request`, Priorität 48, also vor Wartungszustand und Installer) vergleicht den Host der Anfrage mit dem von `SITE_ORIGIN` und schickt die jeweils andere Schreibweise – `example.de` ↔ `www.example.de` – mit 301 auf die eingetragene, mit Pfad und Abfrage, Schema und Port aus `SITE_ORIGIN`. Das gilt auch für die Verwaltung: ein Host, ein Sitzungscookie. Die Antwort trägt `Cache-Control: public, max-age=3600`, damit ein Browser die Umleitung behält, ohne dass eine Korrektur der Einstellung ein Jahr lang unsichtbar bliebe. Der www-Block in `.htaccess` ist weg; die Datei erklärt, wer es jetzt macht. `php bin/console cms:site:origin <adresse>` prüft (Schema, Host, kein Pfad) und schreibt die Einstellung, wie `cms:admin:path` es für die Verwaltung tut. Der Bildschirm „System“ zeigt die eingetragene Adresse, ihre Quelle und einen Hinweis, wenn die Verwaltung gerade unter einem anderen Host läuft.
**Bewusst nicht:** ein fremder Host wird nicht umgeleitet. Die Vorschauadresse des Hosters, eine IP-Adresse, `localhost`, eine Subdomain – alles bleibt erreichbar, auch wenn `SITE_ORIGIN` etwas anderes nennt. Sonst sperrte eine falsch eingetragene Adresse jeden aus, samt der Verwaltung, die man bräuchte, um es zu bemerken. Nur lesende Anfragen (GET, HEAD) werden umgeleitet: ein 301 macht aus einem POST ein GET und verwirft die Eingabe. Und HTTPS erzwingt die Anwendung nicht: hinter einem Proxy sieht PHP oft nur `http`, ohne konfigurierte vertrauenswürdige Proxys wäre das eine Schleife. Die drei Zeilen dafür bleiben in `.htaccess`, wo der Webserver die Verbindung wirklich kennt.
**Folgen:** `tests/CanonicalHostTest.php` (Paar, Fremdhost, Schema, Port, Methoden, Priorität), `tests/SiteOriginCommandTest.php`, je ein Fall in `CmsTest` (durch den Kernel) und `SystemScreenTest` (Anzeige und Hinweis). Die Startkonfigurationen der lokalen Installationen setzen `SITE_ORIGIN` auf ihre `127.0.0.1:<port>`-Adresse und sind davon nicht betroffen.
## ADR 098 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
**Anlass:** Umut: „bei Rows die Breite ändern – am besten per Drag and Drop im Editor.“ Bis dahin gab es nur gleich breite Spalten: die klassische Vorgabe `columns` (one/two/three/four) und die freie Eigenschaft `gridColumns` erzeugten beide `repeat(n, minmax(0, 1fr))`. Ein Symbol neben einem Text – die Karte aus seinem Beispiel – brauchte eine schmale und eine breite Spalte.
**Entscheidung:** Eine neue freie Stileigenschaft `columnWidths` am **Zeilenblock**, Typ `fractions`: bis zu zwölf Zahlen mit Leerzeichen, `2 1` oder `66.7 33.3`, je Gerät wie jede andere freie Eigenschaft (`StyleSchema::FRACTIONS`, Prüfung in `StyleCompiler::scalar()`, null ist ausgeschlossen). Die Anteile liegen an der Zeile und nicht an den Spalten, weil eine Grenze immer zwei Spalten zugleich verändert und die Reihenfolge der Kinder die Zuordnung ist. Kein neues Dokumentschema: die Eigenschaft fällt unter die freien Stile ab v10.
**CSS.** `StyleCompiler::columnTracks()` schreibt je Gerät `#id{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}`. Zwei Feinheiten: Fehlende Anteile (eine später eingefügte Spalte) bekommen den Durchschnitt, überzählige fallen weg, also passt die Regel immer zur Kinderzahl. Und die Desktop-Regel trägt eine ID und würde die Klasse `.d-mobile-columns-one` schlagen, mit der ein Handy die Spalten stapelt – deshalb bekommt ein Gerät, dessen wirksame Aufteilung `one` (klassisch) oder `gridColumns` 1 ist, unter seiner Medienabfrage `minmax(0,1fr)`. Die Abfragen sind die des Stilcompilers (1024/767 px), nicht die 650 px der klassischen Klassen; zwischen 651 und 767 px stapelt eine Zeile mit Anteilen also, wo sie ohne Anteile noch zwei Spalten hätte. Das ist gewollt: Querformat-Handys sind Handys.
**Editor.** `canvas-overlay.js` zeichnet für die gewählte Zeile – oder die Zeile um die gewählte Spalte – einen Griff auf jede Grenze zwischen zwei nebeneinander liegenden Spalten (`.ov-resize`, ein Knopf mit Beschriftung „Breite zwischen Spalte 1 und 2 ändern“). Ausgangswert ist die **gemessene** Breite der Spalten, nicht der gespeicherte Wert, damit der Griff auch bei geerbten oder klassischen Aufteilungen dort anfängt, wo die Spalten wirklich stehen. Während des Ziehens wird die Zeile im Vorschaudokument inline umgestellt (`grid-template-columns`), erst beim Loslassen entsteht **ein** Verlaufsschritt (`onResize` → `setStyle(row, device, 'columnWidths')`); die Vorschau lädt danach vom Server neu. Pfeiltasten verschieben um fünf Punkte und behalten den Fokus auf dem Griff über die Neuzeichnung hinweg. Kein Griff, wo die Spalten gestapelt sind, und keiner während ein Element gezogen wird. Im Design-Bedienfeld steht derselbe Wert als Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen), für alle, die lieber Zahlen tippen.
**Verworfen:** Anteile je Spalte (`span` aus zwölf) wie in Flatsome – zwei Werte je Grenze zu pflegen und eine Summe, die 12 ergeben muss, sind für ein Drag-Ergebnis unnötig streng; Anteile summieren sich beliebig. Eine eigene Klasse je Verhältnis (`1-3`, `2-3`) – deckt genau die Fälle ab, die jemand vorhergesehen hat, und das Ziehen erzeugt jeden anderen.
## ADR 097 – Der Einwilligungshinweis bekommt ein Gesicht, und der Nach-oben-Knopf weicht ihm aus (15.09.2026)
**Anlass.** Umut gefiel das Banner nicht: nicht gestaltbar, ein kantiger Kasten in Papierfarbe. Und der Nach-oben-Knopf „funktionierte auf dem Handy nicht“ – er war ausgeblendet, solange die Frage offen stand, und auf dem Telefon bleibt sie das oft.
**Gestaltung aus den Einstellungen, nicht aus dem Editor.** Der Hinweis ist kein Baustein einer Seite, sondern eine Frage der Website; seine Gestaltung gehört deshalb neben seinen Text unter Einstellungen → Einwilligung: Anordnung (`karte`, `leiste`, `ecke`), Form (`weich`, `rund`, `eckig`), Überschrift, vier Farben (Fläche, Schrift, Knopf, Schrift auf dem Knopf). Die Farben nehmen `#hex` oder `@schlüssel` aus der Palette wie der Nach-oben-Knopf; `Theme::cssColor()` löst beides für beide auf. Ausgegeben wird das als `:root{--kant-consent-…}` im gehashten Stylesblock (`Consent::styles()`), die Anordnung als Klasse. Die Form setzt zwei Radien zugleich – Fläche und Knöpfe gehören zusammen; zwei Regler dafür wären zwei, die man gegeneinander verstellt. Leer heißt weiterhin Papier, Tinte und Akzent des Themes.
**Was gleich bleibt, mit Absicht.** „Nur notwendige“ bleibt ein Rahmenknopf in der Schriftfarbe, genauso groß wie „Alle akzeptieren“, in derselben Zeile – die Farbe des Knopfes ist einstellbar, die Gleichwertigkeit der Antworten nicht. Kein Skript, keine Cookie-Wand.
**Archiv und Einwilligungsseite.** Beide trugen bisher keine Palette im Kopf. Sie bekommen jetzt Theme- und Hinweisvariablen als gehashten Stylesblock; die Einwilligungsseite erweitert ihre Richtlinie um genau diesen einen Hash.
**Der Nach-oben-Knopf.** Statt `display:none` bei offener Frage setzt er sich per Ankerpositionierung auf die Oberkante der Leiste (`anchor-name`, `bottom:calc(anchor(top) + 12px)`), wo sie die Ecke abdeckt: bis 799 px bei der Karte, auf jeder Breite bei Leiste und Ecke (`:has()`). Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Damit die Leiste als Anker taugt, steht sie in `page.html.twig` vor dem Knopf. Der reservierte Abstand unten wächst auf 320 px bis 620 px, weil die Leiste dort mit Standardtext bis 290 px hoch ist.
## ADR 096 – 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)
Ein Link zu Instagram im Fuß, derselbe im Kopf, ein dritter in der Kontaktsektion – bisher wären das drei von Hand gesetzte Symbol-Links, und eine geänderte Adresse hätte drei Stellen zu finden. Deshalb gehören die Profile zur Website, nicht zur Seite: `SiteSettings` führt unter `social` je Netzwerk eine HTTPS-Adresse (`SocialProfiles::NETWORKS`, feste Liste, weil jedes Netzwerk ein gezeichnetes Symbol braucht), gepflegt unter Einstellungen → Unternehmensdaten. Der Baustein `social` (v23) trägt nur die Darstellung: Größe aus `ICON_SIZES` als Klasse, Form (Symbol, runde oder eckige Fläche), Anordnung, Beschriftung an oder aus, optional eine Teilmenge der Netzwerke und `newTab`. Farbe, Abstand und Ausrichtung kommen aus den freien Blockstilen wie überall. Die Vorlage liest die Profile über das Twig-Global `siteSettings`, darum braucht die Rekursion in `block.html.twig` keine neue Durchreichvariable. Ohne eingetragene Profile gibt der Block auf der öffentlichen Seite nichts aus und zeigt im Editor einen Hinweis, wo sie einzutragen sind. Die Adressen stehen zugleich in `sameAs` der strukturierten Daten – dieselbe Aussage, ohne zweite Pflege. Symbole sind eigene Umrisszeichnungen im Strich des vorhandenen Symbolsatzes, keine Markenbilder und keine fremde Datei. Kein Datenbankwechsel: `social` ist ein Schlüssel im Einstellungs-JSON und fällt auf eine leere Liste zurück.
## ADR 092 – Der Kopfbereich kennt einen gescrollten Zustand (14.09.2026)
Der transparente Header aus Flatsome ist zwei Dinge: ein Kopf, der über der ersten Sektion liegt, und Farben, die sich beim Scrollen ändern. Beides bleibt getrennt. Das Schweben ist eine Website-Einstellung (`headerOverlay` → `.site-header.is-overlay`, absolut, mit „oben halten“ fixiert), weil es das Layout jeder Seite betrifft. Die Farben sind Gestaltung des Kopfbereichs: neben `hover` gibt es je Gerät einen Block `scrolled`, beschränkt auf Farben, Schatten und Deckkraft (`StyleSchema::scrollable()`) – ein Kopf, der beim Scrollen wächst, würde die Seite unter dem Finger verschieben. Der Compiler gibt `html.is-scrolled #id{…}` plus einen Übergang aus; ein Skript von zehn Zeilen setzt die Klasse ab 40 px und wird nur geladen, wenn der Kopfbereich solche Werte hat, damit Seiten ohne sie ihre strenge Skriptrichtlinie behalten. Ohne Skript gilt der normale Zustand, eine lesbare Seite. Das Design-Panel zeigt die Zeilen „beim Scrollen“ nur im Kopfbereich, weil sie nur dort etwas bewirken; die Editor-Vorschau setzt die Klasse beim Scrollen selbst, da sie kein Skript ausführt. Kein Schemawechsel: `scrolled` ist ein optionaler Schlüssel, alte Dokumente bleiben gültig.
**Kartenhöhen.** Die Stapelabstände `.block-iconcard + .block-iconcard` (und Zitat/CTA) galten auch im Raster einer Zeile; dort liegt der Rand im Rasterfeld und macht jede Karte außer der ersten um 18 px niedriger. Die Abstände bleiben für Spalten, in `.block-row` werden sie auf null gesetzt – das Raster hat sein eigenes `gap`.
## ADR 091 – Eine Seitenleiste wie im Flatsome UX Builder; Bewegung spielt, bis die Website etwas anderes sagt (14.09.2026)
**Editor.** Drei Panels nebeneinander – Werkzeugkasten und Struktur links, Vorschau, Eigenschaften rechts – zeigten immer alles und damit nichts; Umut fand sich nicht zurecht, im Flatsome UX Builder schon. Der Editor übernimmt dessen Rhythmus: eine Seitenleiste, die genau eine Sache zeigt. Reiter für Elemente, Struktur und Seite; ein gewähltes Element öffnet seine eigene Ansicht mit Name, Pfad, den drei Eigenschaftenreitern und den Aktionen am Fuß und gibt sie beim Abwählen wieder frei. Die Reiter bleiben dabei erreichbar, damit Bibliothek und Baum einen Klick entfernt sind. Die Struktur wählt beim Klick nur aus und öffnet erst über „Bearbeiten“; sonst verschwände der Baum bei jedem Schritt. Rechtsklick in der Vorschau öffnet ein Kontextmenü im Editor-Dokument, dessen Position einmal aus Rahmen und Maßstab umgerechnet wird. Tastenkürzel gelten auch, wenn der Fokus in der Vorschau liegt: derselbe Handler hängt am Fenster und an jedem Vorschau-Dokument. Die Auswahl scrollt nur das Vorschau-Fenster, nicht per `scrollIntoView` – das zog das Editor-Dokument mit und schob die Kopfzeile aus dem Bild. Die verstellbaren Panelbreiten entfallen; eine feste Leiste ist der einfachere Vertrag. Bestehende IDs (`#library`, `#structure`, `#properties`, `#preview`, `#save-status` …) bleiben, die Browserprüfung wechselt die Ansicht über Helfer statt über versteckte Elemente zu klicken.
**Bewegung.** Bisher entschied allein die Browservorgabe `prefers-reduced-motion`, und die meldete Umuts Chrome als aktiv, ohne dass er sie gesetzt hatte; andere Websites bewegen sich bei ihm, weil sie die Vorgabe ignorieren. Die Entscheidung gehört der Website: Einstellung `motion` mit `always` (Standard, Flatsome-Verhalten) und `respect`. Der Server druckt bei `respect` die Klasse `motion-respect` auf `<html>`; `motion.js` und `slider.js` fragen `Klasse && Vorgabe`, das CSS schreibt die Hover-Fahrten unbedingt und nimmt sie in einem Überschreibblock `@media (prefers-reduced-motion: reduce) { html.motion-respect … { transform: none; transition: none } }` zurück – Farben und Schatten bleiben, nur die Fahrt fällt weg. Einblendungen verstecken einen wartenden Block per Inline-`opacity:0` (nur mit Skript, nur wenn Bewegung erlaubt ist) und blenden ihn mit `fill: backwards` von unsichtbar ein; ein `scroll`-Handler zeigt sofort, was oberhalb des Bildes liegt, weil ein Sprung per Sprungmarke oder Wiederherstellung sonst Blöcke dauerhaft unsichtbar ließe. Beim Abschalten (Präferenzwechsel unter `respect`, Editor-Stopp) werden alle versteckten Blöcke wieder eingeblendet. Barrierefreiheit: Einblendungen dauern unter einer Sekunde, blinken nicht und lassen sich per Einstellung an die Vorgabe binden; Lighthouse misst diese Vorgabe nicht. Kein Schema- und kein Datenbankwechsel: `motion` fällt bei fehlendem Wert auf `always` zurück.
## ADR 090 – Releasecache bleibt für laufende Updateanfragen erhalten (14.09.2026)
Der echte HTTP-Updatepfad von 1.10.0 nach 1.10.1 reproduziert Umuts 500 auf `/admin/system/archiv`: Die Dateien sind bereits erfolgreich ersetzt, dann entfernt `ArchiveUpdate` den gesamten `var/cache`. Symfony lädt beim Abschluss weitere Dienste aus dem kompilierten Container nach und findet deren PHP-Dateien nicht mehr. Eine frische Installation und isolierte Updater-Rückgabewerte decken diesen Fehler nicht ab.
Der Kernel verwendet deshalb `var/cache/<Umgebung>/release-<SHA256-Präfix von VERSION>`. Er merkt sich diesen Pfad pro Kernelinstanz, damit das Überschreiben von VERSION während der Anfrage ihren Cachepfad nicht ändert. Der Archiv-Updater löscht keinen Cache mehr; die folgende Anfrage startet mit dem eigenen Container- und Twigcache der neuen Version. Alte Cacheverzeichnisse bleiben absichtlich erhalten. Sie können bei gestoppter Anwendung entfernt werden, nicht während eines laufenden Updates. Keine Datenmigration; ein Downgrade verwendet wieder den zu seiner Fassung gehörenden Cache und kann ihn bei Bedarf über die Konsole erneuern.
**Übergang:** Ein bereits installierter Updater bis einschließlich 1.10.1 führt noch seinen alten Löschcode aus, auch wenn er 1.10.2 installiert. Dieser Updateaufruf kann einmalig weiterhin mit 500 enden. `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen; die Marke in `var/update/offen.json` erhält den Fortschritt. Die Reparatur verhindert den Abbruch bei nachfolgenden Updates aus dem korrigierten Stand. Kein Erfolg der Produktionsmigrationen wird ohne Prüfung behauptet.
**Regression:** `scripts/archive-update-check.mjs` installiert zwei vollständige Releasearchive in einem eigenen Verzeichnis und prüft den echten HTTP-POST, Wartungszustand, Weiterleitung, erhaltene Sitzung, Systemseite, Migrationsabschluss, Sicherung und öffentliche Ausgabe. Nur der Downloadtransport wird auf das lokale Zielarchiv umgestellt. `UPDATE_SOURCE_VERSION` erlaubt eine ausdrücklich synthetische vorherige Versionsnummer auf dem korrigierten Paket, um dessen nächsten Updateaufruf vor einem weiteren Release zu prüfen. Die Original-Pilotdatenbank bleibt unverändert.
## ADR 089 – Formularaktionen nutzen den Website-Standardbutton (14.09.2026)
Der öffentliche Absenden-Button erhält dieselbe Klasse `cms-button` wie normale Buttonblöcke. Die bisherigen formularspezifischen Farb- und Hover-Regeln entfallen; die bereits vorhandenen Theme-Variablen bestimmen Form, Darstellung, Größe, Schrift und Farben. `justify-self:start` verhindert das implizite Strecken durch das Formular-Grid. Es werden keine Designwerte in Formulardefinitionen kopiert: spätere Änderungen am Website-Standard wirken auch auf vorhandene Formulare. Nativer Submit-Typ, Beschriftung, Fokus, Validierung und Versand bleiben erhalten. Keine Daten-/Schemamigration; ein Code-Rückweg stellt das frühere Sonderdesign wieder her.
## ADR 088 – Gerätesichtbarkeit und automatische Hamburger-Navigation (14.09.2026)
`layout.visibility` enthält optional eine feste Auswahl: `all`, `desktop`, `tablet`, `mobile`, `hide-desktop`, `hide-tablet`, `hide-mobile`, `hidden`. Fehlend bedeutet überall sichtbar. Die Bedienidee wurde anhand der lokalen Flatsome-Dateien `inc/builder/shortcodes/commons/visibility.php` und `template-parts/header/header-main.php` geprüft; kein Fremdcode oder Asset wurde übernommen. Die Sichtbarkeit steht in allen Eigenschaftsansichten direkt am Element und wird im Strukturbaum benannt. Ausgeblendete Elemente bleiben dort auswählbar. Änderungen verwenden dieselbe History, Entwurfsspeicherung und Veröffentlichung wie andere Blockwerte.
Vorschau und Öffentlichkeit verwenden dieselben CSS-Klassen. `display:none!important` entfernt ausgeblendete Elemente aus Layout, Tastaturreihenfolge und Accessibility Tree, ohne ihre eigenen Flex-/Grid-/Display-Werte umzuschreiben. Die Grenzen folgen dem bestehenden freien StyleCompiler: Mobil bis 767 px, Tablet 768–1024 px, Desktop ab 1025 px. Die Sichtbarkeit eines Elternbereichs gilt damit auch für seine Kinder. Wrapper von Logos, Tablinks und Sliderfolien erhalten dieselbe Auswahl; Tabs und Slider überspringen ausgeblendete Einträge bei der Bedienung. Sichtbarkeit entfernt Inhalte nicht aus dem HTML und ist keine Zugriffskontrolle.
Waagerechte Menüblöcke wechseln standardmäßig bis 1024 px sowie bei `hover:none` und `pointer:coarse` zum Hamburger. Das umfasst auch große iPads ohne Maus bei mehr als 1024 CSS-Pixeln; es gibt keine User-Agent-Erkennung. `props.collapse` erlaubt `tablet` (Standard), `mobile` (nur bis 767 px) und `never`. Bewusst vertikale Menüs bleiben vertikal, explizite Burger bleiben aufklappbar. Die Automatik wirkt auch auf bisherige waagerechte Menüs ohne neue Speicherung. Bereits separat angelegte Menüblöcke bleiben eigenständige Elemente und können mit der neuen Sichtbarkeit zugeordnet werden.
Die aufgeklappte Navigation verwendet native `details`/`summary` und benötigt kein öffentliches JavaScript. Der gemeinsame Menübaum wird für die breite und die aufklappbare Darstellung ausgegeben; CSS zeigt genau eine Variante, die andere fehlt auch in der Fokusreihenfolge. Das kostet etwas zusätzliches HTML, vermeidet aber eine skriptabhängige Navigation. Der Summary-Name bleibt „Menü“, der Browser vermittelt den Öffnungszustand. Im Editor wird der Öffnungszustand nur in der Vorschau umgeschaltet und nicht gespeichert.
**Migration:** Schema 22 wird beim ersten Setzen einer neuen Sichtbarkeits- oder Hamburger-Option verwendet. Bestehende Dokumente bleiben unverändert lesbar; keine SQL-Migration. Der Validator weist unbekannte Werte und neue Optionen in alten Schemata ab.
**Rückweg:** Vor einem Downgrade sichern. Schema-22-Dokumente exportieren, rekursiv `layout.visibility` und bei Menüs `props.collapse` entfernen, `schemaVersion` auf 21 setzen, mit dem alten Validator prüfen und importieren; alternativ die vorherige Sicherung wiederherstellen. Damit entfallen die neuen Gerätebeschränkungen und die automatische Umschaltung. Layout, Inhalt und stabile IDs bleiben erhalten.
## ADR 087 – Button-System, benannte Sprungziele und Menübedienung (14.09.2026)
Globale Button-Vorgaben liegen optional unter `theme.buttons`; Vorschau und öffentliche Ausgabe verwenden dasselbe kleine CSS-System mit Geräteklassen. Lokale Varianten stehen getrennt von Inhalt und freien Styles in `layout.button.<Gerät>`. Vorlagen ändern Form/Darstellung, erhalten Text, Link und Abstände und entfernen kollidierende freie Werte nur für die betroffene Einstellung am aktiven Gerät. Verbleibende eigene Styles können Varianten übersteuern. Die Bedienidee orientiert sich an der lokal bereitgestellten Flatsome-Button-Konfiguration (`inc/builder/shortcodes/button.php`); Implementierung und Assets wurden nicht übernommen.
Benannte `anchor`-Werte sind nur an Sektion, Zeile und Spalte erlaubt und werden gegen alle Block-IDs, andere Ziele und reservierte IDs geprüft. Ein zusätzliches Zielelement erhält den Namen; die stabile Block-ID bleibt erhalten. Duplizieren entfernt benannte Ziele aus der Kopie. Native Fragmentlinks funktionieren ohne öffentliches JavaScript.
Menüeinträge speichern optionale `icon`- und `iconPosition`-Werte aus der bestehenden SVG-Allowlist. Drag-and-drop verwendet denselben Baum und Undo-/Speicherweg wie Tastaturbefehle; Unterpunkte reisen mit dem Hauptpunkt. Die Versionsanzeige liest nur das vorhandene Ergebnis des täglichen Update-Workers. Dadurch entstehen beim Öffnen von Adminseiten keine zusätzlichen externen Anfragen.
**Migration:** Validator und Editor unterstützen Schema 21 zusätzlich zu bisherigen Fassungen. Erst eine neue Button-Variante oder ein Sprungziel hebt das bearbeitete Dokument auf 21; vorhandene Dokumente werden nicht pauschal umgeschrieben. Globale Buttons und Menü-Icons sind optionale Konfigurationsfelder, keine SQL-Migration.
**Rückweg:** Vor einem Downgrade eine Sicherung erstellen bzw. die Sicherung vor 1.9.0 wiederherstellen. Alternativ Schema-21-Dokumente exportieren, `anchor` und `layout.button` rekursiv entfernen und die Fassung auf 20 setzen, anschließend mit dem alten Validator prüfen/importieren. Alte Software kennt die neuen Konfigurationsfelder nicht; globale Button-Vorgaben/Icons gegebenenfalls aus der Sicherung entfernen, sie können beim alten Speichern verloren gehen. Sprunglinks auf entfernte Ziele müssen angepasst werden.
## ADR 086 – Bildausrichtung berücksichtigt Rahmen, Bild und Layoutachse (14.09.2026)
`align-self` allein hat in normalen Inhaltsbereichen keine Wirkung. Ein prozentual schmaler Bildrahmen blieb links; bei voller Rahmenbreite konnte zusätzlich das kleinere Bild im Rahmen links stehen. Der Browserfall mit einem 50-Prozent-Bild in einer gepolsterten Spalte reproduzierte die Abweichung trotz ausgewählter Mitte.
Der gemeinsame StyleCompiler ergänzt bei Bildern mit eigener Ausrichtung horizontale Außenabstände am Rahmen und am enthaltenen Bild sowie `justify-self` für Raster. Verlinkte und vergrößerbare Bilder werden über dasselbe innere Bild erreicht. In horizontalen Flex-Gruppen bleiben die Außenabstände am Rahmen erhalten: Dort wirkt die vorhandene Einstellung auf der vertikalen Querachse und soll die horizontale Verteilung nicht verändern. Sektionen, Akkordeon-Einträge und Slider haben innere Inhaltswrapper, deren Blockfluss berücksichtigt wird.
Bild- und Elterneinstellungen werden je Gerät geerbt. Änderungen der Flexrichtung werden deshalb auch bei unverändertem Bildwert wirksam. Auto, Strecken und Grundlinie entfernen die zusätzliche horizontale Bildausrichtung und stellen die jeweiligen selbst gesetzten Außenabstände wieder her. Originalmaße, Bildquellen, Bildbreite, Beschriftungen und Links werden nicht umgeschrieben. Beliebiges benutzerdefiniertes CSS kann diese Layoutannahmen weiterhin überschreiben.
**Migration und Rückweg:** Keine Änderung am Dokument- oder Datenbankschema. Bestehende gespeicherte Ausrichtungswerte werden beim Rendern korrigiert, ohne erneutes Speichern. Vorschau und öffentliche Ausgabe verwenden denselben Compiler; kein zusätzliches öffentliches JavaScript. Ein Rückweg auf 1.8.3 erhält alle Daten, zeigt jedoch dessen bisherige Ausrichtung.
## ADR 085 – Akkordeon-Inhalte sind im Editor direkt zugänglich (14.09.2026)
Die bisherige Vorschau verhinderte Standard-Klickaktionen einschließlich des Öffnens von `<details>`. Einträge mit `props.open: false` blieben deshalb geschlossen; für leere Akkordeon-Einträge fehlten zusätzlich die Einfügehilfen. Ein Browserfall reproduzierte den geschlossenen Zustand nach Anklicken des Titels.
`preview-containers.js` öffnet Akkordeon-Einträge ausschließlich im Vorschau-DOM. Leere Einträge zeigen eine Einfügefläche, gefüllte Einträge eine Schaltfläche zum Anhängen weiterer Inhalte. Eine eigene Aktion am Akkordeon legt Einträge an, auch wenn es vollständig leer ist. Die rechte Inhaltsansicht listet Einträge mit ihren Titeln und bietet dieselben Aktionen. Neu eingefügte Akkordeons wählen ihren ersten Eintrag und den Inhaltsbereich; Struktur und Struktursuche berücksichtigen Titel.
Die Modellauswahl eines Akkordeons erlaubt direkt nur neue Einträge. Texte und andere Inhalte erfordern die Auswahl des Eintrags, damit sie nicht unbemerkt außerhalb des Akkordeons landen. Der gesonderte Befehl für neue Sektionen auf Seitenebene bleibt erhalten. Explizite Einfügepunkte und Drag-and-drop verwenden weiterhin die bestehenden Eltern-/Kindregeln; „Inhalt hinzufügen“ hängt innerhalb des angesprochenen Eintrags an.
**Migration und Rückweg:** Kein Schemawechsel und keine Datenmigration. Öffnungszustand, Kinder, IDs und Reihenfolge bleiben normale Blockwerte. Vorschauhilfen werden nie gespeichert; die öffentliche Ausgabe und deren native Tastatur-/Mausbedienung bleiben unverändert. Älterer Code kann die Daten weiterhin lesen, zeigt jedoch wieder die bisherige eingeschränkte Vorschau. Alle Inhaltsänderungen laufen über den vorhandenen Undo-/Speicher-/Veröffentlichungsweg.
## ADR 084 – Visuelle Palettenauswahl im Editor (14.09.2026)
Alle Farbfelder des Designbereichs verwenden `color-control.js`: kompakte Vorschau mit Farbnamen und aufgelöstem Hexwert, aufklappbare Farbkacheln, nativer Farbwähler und validierte Hexeingabe. `@key` wird nur für die Darstellung aufgelöst; die gespeicherte Palettenreferenz bleibt erhalten. Gerätespezifische Vererbung und Hover-Werte größerer Geräte sind sichtbar. Ohne zuordenbaren Farbwert zeigt das Feld ein neutrales Schachbrett und „Übernehmen“; es erfindet keine schwarze Farbe. Achtstellige Hexwerte behalten ihren Alphakanal auch beim Ändern des Farbtons. Dreistellige Hexeingaben werden vor dem Speichern in sechsstellige Werte umgewandelt.
Palettenwahl und bestätigte Texteingaben sind einzelne Undo-Schritte. Eine Bewegung im nativen Wähler aktualisiert die Vorschau mit einem gemeinsamen History-Schlüssel und erhält den DOM-Knoten bis zum Abschluss. Danach werden Gerätemarkierungen, Reset und Kontrasthinweis aktualisiert. Farbauswahl per Tastatur, Escape, Fokuswiederherstellung und lokale Fehlermeldung sind Teil desselben Bedienelements. Die Farbkacheln verwenden beschriftete Buttons mit Auswahlzustand; lange Paletten bleiben innerhalb des Panels scrollbar. Keine zusätzliche Bibliothek und kein öffentliches Skript. Das neue Modul steht in der versionierten Importmap.
**Migration:** Keine Änderung an Datenbank oder Dokumentformat. Bereits erlaubte sechs-/achtstellige Hexwerte und Palettenreferenzen werden unverändert gespeichert. Ein Rückweg auf 1.8.1 zeigt dieselben Werte im früheren Farbfeld. Verwaltungsformulare außerhalb des visuellen Editors behalten ihre bisherigen Felder.
## ADR 083 – Footer-Links werden redaktionell platziert (14.09.2026)
Auf ausdrücklichen Wunsch von Umut entfällt die automatisch angehängte Rechtszeile vollständig. Seiten, Beiträge, Archive, Fehlerseiten und Editorvorschauen geben ausschließlich die selbst platzierten Footer-/Inhaltslinks aus. Die frühere Erkennung des speziellen `legal`-Blocks konnte normale Text- oder Menülinks nicht berücksichtigen; dadurch erschien unter einem vollständig gestalteten Footer ein zusätzlicher heller Streifen mit doppelten Links. Die Template-Ausgabe, ihre Erkennung im Controller und ungenutztes CSS sind entfernt.
Normale Links, Menüs und bewusst eingefügte „Rechtliches“-Blöcke bleiben erhalten. Der optionale Block nutzt weiterhin die konfigurierten Seitenadressen und den Link zur Einwilligung. Einwilligungsdialog, Einstellungen und der Endpunkt `/einwilligung` behalten ihre Funktion. Keine Inhalte werden umgeschrieben, keine neue Schema- oder Datenbankmigration. Bei einem Rückweg auf ältere Versionen erscheint deren automatische Leiste wieder.
Diese Entscheidung ersetzt die automatische Verlinkung aus ADR 033 und die bedingte Zusatzleiste aus ADR 062; Position und Gestaltung der Links liegen beim Autor.
## ADR 082 – Zuweisbare 404-Seite im gemeinsamen Seitenrenderer (14.09.2026)
`website.settings.notFoundPageId` verweist auf eine normale Seite über deren stabile ID. Eine eigene Einstellung mit Livesuche weist sie zu oder entfernt die Zuordnung; sie benötigt Veröffentlichungsrechte und CSRF-Schutz. Der erwartete bisherige Wert schützt gegen ein veraltetes Formular. Die Änderung liest die aktuellen Einstellungen innerhalb einer Transaktion, unter MySQL/MariaDB mit Zeilensperre, und schreibt nur die geänderte Zuordnung in den erhaltenen JSON-Inhalt. Das normale Unternehmensformular erhält diesen Wert unabhängig von seinen eigenen Feldern. Startseite und 404-Seite dürfen nicht dieselbe Seite sein.
Ohne verwendbare veröffentlichte Zuordnung wird ein v10-Blockdokument im Speicher erzeugt. Öffentliche GETs legen keine Seiten an. „404-Vorlage anlegen“ erstellt eine bearbeitbare Kopie als Entwurf und weist sie zu; bis zur Veröffentlichung bleibt die Standardausgabe sichtbar. Vorhandene Adressen einschließlich Papierkorb werden erhalten; bei belegtem `/404` wird `/404-2` usw. gewählt. Wiederholtes Anlegen überschreibt keine Seite. Die erzeugte Vorlage erhält `noindex`. Erstellen und Zuweisen laufen in einer gemeinsamen Datenbanktransaktion.
Normale Seiten, zugewiesene Fehlerseiten und der Standard verwenden denselben Renderer mit Theme-CSS, Schriften, Standard-Header/-Footer, Menüs, verknüpften Sektionen und Einwilligungsanzeige. Auf der Fehlerausgabe haben die Standardbereiche Vorrang vor eventuellen Bereichsabweichungen der ausgewählten Seite. Nur veröffentlichte Revisionen sind öffentlich sichtbar; Umbenennen erhält die Zuordnung, Löschen/nicht veröffentlichte Seiten führen auf den Standard zurück. Die Originaladresse bleibt stehen, die Antwort trägt HTTP 404 und `noindex, follow` in HTML und Header, ohne Canonical oder strukturierte Daten einer normalen Seite. Auch die eigene Adresse der zugewiesenen Seite antwortet mit 404. Sitemap und llms.txt schließen die zugewiesene ID zusätzlich aus, unabhängig von deren normalen Metadaten.
Ein schmaler Exception-Subscriber übernimmt öffentliche HTML-GET/HEAD-Routingfehler außerhalb der Slug-Grammatik, etwa alte `.html`-Adressen oder tiefere Pfade. Fehler bestehender Controller, Verwaltung, Medien/Assets, Formulare, Installation, AJAX sowie maschinenlesbare Ressourcen bleiben ihren bisherigen Endpunkten überlassen. Normale Seiten- und Archivfehler rufen den Fehlerrenderer direkt auf. 301/410 werden vorher behandelt; 410 nutzt einen eigenen Standardtext und behält seinen Status.
**Migration und Rückweg:** Keine Datenbankmigration und kein neues Dokumentschema. Fehlender/null Settings-Inhalt erhält beim Lesen den leeren Standardwert. Die Einstellung wird erst beim Zuweisen gespeichert. Die Vorlage nutzt bestehende v10-Blöcke. Älterer Code ignoriert die neue Zuordnung und verwendet wieder seine bisherige 404-Ausgabe; die angelegte Seite und ihre Revisionen bleiben normaler Inhalt. Vor einem Rückweg die Website-Einstellungen sichern, da ältere Einstellungsformulare unbekannte JSON-Schlüssel entfernen können. Kein automatisches Veröffentlichen oder Überschreiben vorhandener Seiten.
## ADR 081 – Linkziel und sichtbare Medienauswahl (14.09.2026)
Schema v20 ergänzt optionales `props.newTab: boolean` an Button, Bild, Symbolkarte, Aufruf-Band und Symbol. Fehlend/false öffnet wie bisher im aktuellen Tab; true erzeugt im gemeinsamen Renderer `target="_blank" rel="noopener"`. Derselbe Wert ist als optionales Feld in Menüeinträgen verfügbar; die bestehende Menüversion schützt weiterhin vor veralteten Schreibzugriffen. Der Rich-Text-Filter erlaubt ausschließlich `_blank` als Ziel und setzt `noopener` selbst. Andere Targets und fremde Attribute werden entfernt. Textlinks bleiben Teil des bisherigen HTML-Vertrags. Bei Bildern entscheidet die gewählte Klickaktion; eine hinterlegte Adresse bleibt bei „Nichts tun“ gespeichert, erzeugt aber keinen Link mehr.
Bilder, Galerie-/Logobilder und Hintergründe zeigen kleine Vorschaubilder mit Lade-/Fehlerzustand. Für Hintergrund-IDs liefert eine authentifizierte einzelne Medienabfrage die tatsächlich vorhandene Bildvariante; auch kleine Originale und archivierte, weiterhin verwendete Dateien funktionieren. Die Bildauswahl verwendet denselben Vorschaucache. Späte Antworten ersetzen keine neuere Auswahl. Entfernen eines Inhaltsbildes löscht dessen Block aus dem Entwurf, ein Galerieeintrag bleibt eine einzelne Änderung; beide sind rückgängig zu machen. Die Mediendatei bleibt erhalten. Bildaustausch ersetzt Quelle und Bildmetadaten, erhält Link, Bildunterschrift, Block-ID und Gestaltung.
Ein Hintergrund wird mit `style.<device>.backgroundImage: "none"` ausdrücklich ausgeblendet. Das ist von Zurücksetzen zu unterscheiden: Zurücksetzen entfernt den eigenen Wert und stellt Vererbung her. Der Compiler übernimmt Bild, Verlauf und Überlagerung schrittweise Desktop→Tablet→Mobil; dadurch erscheint ein auf Tablet entferntes Bild nicht auf Mobil wieder. Geerbte Verläufe bleiben beim Entfernen des Bildes erhalten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Erzeugung. Die neuen Bedienelemente gehören nur zum Editor; kein zusätzliches öffentliches JavaScript.
**Migration:** Dokumente v1–19 bleiben lesbar und werden nicht beim Laden umgeschrieben. Das Editor-Modell hebt die Version erst beim Setzen eines neuen Block-Linkwerts oder eines ausdrücklichen Hintergrund-„none“ auf v20 an, einschließlich der bisherigen Schema-Upgrades. Die Validierung verlangt für diese Werte v20. Keine Datenbankmigration. Rückweg auf ältere Software: vor Bearbeitung mit v20 angelegte Revision/Export oder die Sicherung vor dem Update wiederherstellen; ältere Validatoren können v20 nicht speichern. Menüs benötigen ebenfalls die Sicherung, falls neue Tab-Einstellungen auf älterem Code erhalten bleiben sollen. Kein stilles Herabsetzen der Dokumentversion.
**Korrektur 1.7.4:** Native Inhalts-Auswahlfelder verarbeiten `input` und `change`, identische aufeinanderfolgende Werte werden nur einmal übernommen. Jede Auswahl ist ein eigener Undo-Schritt. Die Bild-Klickaktion ersetzt beim Wechsel nicht mehr den ganzen Feinschliff: Linkziel und Tab-Häkchen werden in einer bestehenden Gruppe ein-/ausgeblendet. Das erhält Dropdown, Fokus und eingegebene Adresse. Ein Test mit ausschließlich `change` konnte das fehlende URL-Feld vor der Korrektur reproduzieren; native Auswahl und Speicherung werden zusätzlich geprüft. Keine Änderung des Datenvertrags.
## ADR 080 – Prozentuale Bildbreite direkt bei den Bildoptionen (14.09.2026)
Der Inhaltsbereich eines Bildes erhält oben einen Prozentregler und ein Zahlenfeld für 1–100 % der verfügbaren Containerbreite. Beide schreiben ausschließlich `style.<device>.width` über die bestehende `setStyle`-Transaktion. Die generische Breite unter Design und die direkte Bildsteuerung sind dadurch immer derselbe Wert. Originalmaße, Quelle, Varianten, Alternativtext und Bildunterschrift bleiben unverändert. Vorhandene maximale Breiten werden erhalten und im Hinweis genannt; Werte in anderen Einheiten bleiben sichtbar, bis bewusst eine Prozentbreite gesetzt wird. Keine neue Style-Eigenschaft, keine neue Schema- oder Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen JavaScript.
Die Steuerung verwendet das aktive Vorschaugerät und zeigt die Herkunft des Werts. Zurücksetzen entfernt nur die Breite dieses Geräts und stellt die bestehende Desktop→Tablet→Mobil-Vererbung wieder her. Eingaben aktualisieren die Vorschau, ohne das Feld zu ersetzen. Eine Ziehbewegung ist ein Undo-Schritt; Zahlenwerte außerhalb des Bereichs werden mit verständlicher Meldung abgelehnt und nicht gespeichert. Tastatur, Undo/Redo, Speichern/Laden und prozentuale Ausgabe ohne JavaScript werden am realen TeamViewer-Bild aus einer isolierten Kopie der Pilotdaten geprüft.
## ADR 079 – Livesuche mit gemeinsamen HTML-Ergebnissen (14.09.2026)
Seiten, Beiträge und Mediathek verwenden dieselben authentifizierten GET-Routen für vollständige Ansichten und AJAX. Der Header `X-Requested-With: XMLHttpRequest` fordert JSON mit einem serverseitig gerenderten Ergebnisfragment, Gesamtzahl und normalisierten Filtern an. Twig bleibt die einzige Darstellung für Listen, Berechtigungen, CSRF-Felder, leere Ergebnisse und Pagination. Suchformular sowie Anlegen/Hochladen bleiben außerhalb des austauschbaren Bereichs; vorhandene Formulare funktionieren weiter ohne JavaScript. Keine Schema- oder Inhaltsmigration.
Das Verwaltungsmodul wartet beim Tippen 220 ms und ersetzt nur die Ergebnisliste. Eine neue Eingabe bricht die alte Anfrage sofort ab; eine zusätzliche Sequenzprüfung verwirft verspätete Antworten. IME-Eingaben werden erst nach der Komposition gesucht. Beginnt während einer ausstehenden Suche eine Bearbeitung in der Liste, wird die Suche angehalten, damit die Antwort keine Eingabe entfernt. Nach dem Austausch werden Seitenauswahlen initialisiert und die Rückkehrfilter aller Formulare aktualisiert. Fehler erhalten die bisherigen Ergebnisse und bieten Wiederholen an. Ergebniszahl und Ladezustand sind für Screenreader zugänglich; Tastaturfokus bleibt beim Tippen erhalten.
URLs enthalten den Suchzustand. Tippen ersetzt den aktuellen Historieneintrag, Filterwahl und Blättern legen einen neuen an; `popstate` lädt passende Ergebnisse und stellt die Felder wieder her. Ohne JavaScript arbeitet das GET-Formular mit Submit und regulären Links. Das Modul gehört ausschließlich zum Admin-Paket.
Die Mediathek filtert Titel und Alternativtext vor COUNT und LIMIT in der Datenbank, inklusive Archivfilter und wörtlicher Behandlung von `%`, `_` und `!`. SQLite erhält eine Unicode-fähige LOWER-Funktion über PDO/mb_strtolower, weil die eingebaute Funktion nur ASCII verarbeitet und etwa „über“ nicht zu „Über“ passte. MySQL verwendet weiterhin seine native Funktion. Die Korrektur gilt auch für Inhalts- und Menüsuche; bestehende Daten und Indizes werden nicht verändert.
## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
Ergänzung zur Laufzeit: Eine Änderung von reduzierter zu erlaubter Bewegung nimmt die Beobachtung noch nicht gezeigter Elemente wieder auf, auch wenn das Dokument schon mit reduzierter Bewegung gestartet ist. Die Aufräumfunktion entfernt denselben Präferenzlistener. Im Editor zeigt eine laufend aktualisierte Statusmeldung die tatsächlich gemeldete Browserpräferenz. Das CMS setzt diese weder aus einer Windows-Einstellung noch aus erfolgreichen Testprofilen voraus; es überschreibt sie nicht. Die Browsermeldung und ihre Ursache sind getrennt zu diagnostizieren.
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
Slider behalten Scroll-Snap als Grundlage. Ein kleines Modul ergänzt sichtbare Pfeile, Zähler, optionale Punkte sowie Pause/Wiedergabe bei Autoplay. Manuelle Bedienung stoppt auch einen bereits geplanten Timer. Automatik wartet bei Hover, außerhalb des Bildschirms oder in unsichtbaren Tabs; Bewegungsreduktion deaktiviert sie. Ohne Skript bleibt der Inhalt scrollbar. Im Editor startet der Benutzer diese Module ausdrücklich aus dem Elternfenster; die iframe-Sandbox bleibt `allow-same-origin` ohne `allow-scripts`. Neue Vorschauen räumen Observer, Timer und Ereignisse der alten Instanz auf.
## ADR 077 – Begrenzte Inhaltslisten und Menüaufbau mit Suche (14.09.2026)
Seiten und Beiträge erhalten getrennte Routen und Navigation. Die Datenbank führt Typ, Suche, Status, COUNT und LIMIT/OFFSET aus; nur 20 oder 50 Dokumente werden pro Ansicht dekodiert. Stabile Sortierung verhindert Überschneidungen bei identischen Zeitstempeln. Papierkorb und leere Suchergebnisse folgen demselben Vertrag. Keine Schemaänderung. Unterlagen mit Titeln und Pfaden werden höchstens in Gruppen von 20 als authentifizierte Suchvorschläge geliefert. Eine separate ID-Abfrage hält bereits ausgewählte Eltern unabhängig von der Ergebnisseite sichtbar.
Der Menüeditor bearbeitet einen Menüplatz je Ansicht. Suche und eigene Links stehen links, Reihenfolge und Felder rechts. Hinzufügen, Haupt-/Unterpunkte, Umordnen und Entfernen sind per Buttons/Tastatur bedienbar und rückgängig zu machen. Haupteinträge ziehen ihre Kinder mit; beim Entfernen bleiben deren Links als Hauptpunkte erhalten. Speichern verwendet weiterhin die validierte Menüstruktur und erwartete Version, liefert bei JavaScript JSON und behält bei 422/409 oder Verbindungsfehlern die Eingaben. Keine neuen Datenformate.
Der Fehler 500 bei Bereichen entstand durch einen unbedingten Zugriff auf `page.slug` in der gemeinsamen Editorvorlage. Bereiche haben keine Seite. Routen und öffentliche Links richten sich jetzt nach dem bearbeiteten Objekt; Speichern eines Bereichs ist sichtbar als sofort wirksame Änderung bezeichnet. Der API-Pfad wird aus benannten Routen abgeleitet, auch nach Verlegung der Verwaltung.
## ADR 076 – Eine Installationsdatei und sichtbare Datenbankquelle (14.09.2026)
`.env` ist die einzige automatisch geladene Konfigurationsdatei. Sie gehört zur Installation und wird nicht mehr von Git versioniert. Installer, Setup und Admin-Pfad-Befehl schreiben atomar und unter einer Dateisperre in diese Datei, ohne bestehende fremde Schlüssel zu verwerfen. Ein Marker unterscheidet eine vom Installer bestätigte Datenbank von der bloßen Vorlage. Echte Prozess-/Servervariablen bleiben bewusst höher priorisiert.
Beim ersten Boot mit einer alten `.env.local` bleiben deren bisher wirksame Werte erhalten: beide Originale sichern, zusammengeführte `.env` schreiben, erst danach `.env.local` entfernen. Sicherungen liegen außerhalb des Webroots unter `var/backups/environment-*`. Secret und Datenbank werden nicht neu erzeugt oder gewechselt. Ein Schreibfehler bricht die Migration ab, statt still auf Standarddaten zu wechseln. Andere Symfony-Overlays werden nicht mehr implizit geladen; eine manuelle Nutzung benötigt vor dem Wechsel die Übernahme in `.env`/Servervariablen. Rückweg: Code und beide Konfigurationssicherungen gemeinsam wiederherstellen.
Der Git-Updater sichert Konfigurationsinhalte vor dem Checkout und stellt sie vor jedem neuen PHP-Prozess wieder her, auch beim Rollback. Das behandelt ausdrücklich ältere Repositorys mit getrackter `.env`. Der Archiv-Updater nimmt diese Dateien weiterhin vom Dateiaustausch aus. Zusätzlich wurde ein vorhandener Rückwegfehler behoben: Der Git-Updater speicherte nur einen Beschreibungstext und leitete daraus einen ungültigen Datenbank-Sicherungspfad ab. Der tatsächliche Sicherungsordner wird jetzt getrennt geführt. Ein fehlgeschlagenes Zurückschreiben lässt den Wartungszustand aktiv; der Regressionstest verändert die Datenbank vor dem simulierten Migrationsfehler und prüft ihre Wiederherstellung. Bereits installierter alter Git-Updater-Code kann diesen neuen Schutz beim erstmaligen Wechsel noch nicht ausführen; daher diesen Übergang per Archiv-Update durchführen oder die Konfiguration vor dem manuellen Checkout sichern und anschließend wiederherstellen (UPDATE.md).
Die bereits begonnene Systemdiagnose bleibt erhalten: tatsächliche Datenbankart, SQLite-Pfad/Größe/Schreibrecht beziehungsweise maskierte Serveradresse sowie die tatsächliche Quelle. Die Quelle stammt aus den vom Dotenv-Lader gesetzten Variablen; sie wird nicht aus der bloßen Existenz einer Datei geraten. Passwort und Secret werden nicht angezeigt.
## ADR 075 – Leere Container sind Arbeitsflächen (14.09.2026)
Leere Spalten hatten keine Höhe; leere Sektionen waren zwar durch Abstände vorhanden, aber weder als Container erkennbar noch mit einem direkten Einfügeweg versehen. Der lokale Flatsome-3.20.9-Quelltext verwendet für solche Zustände eine eigene sichtbare Inhaltsfläche mit Mindesthöhe und mittigem Einfügewerkzeug (konkrete Fundstellen in `docs/FLATSOME-ABGLEICH.md`).
`preview-containers.js` ergänzt deshalb erst im vollständig geparsten Vorschau-DOM sichtbare Hilfsflächen für leere Sektionen, Zeilen und Spalten. Eine leere Sektion bietet außerdem Zeilenlayouts mit 1–4 Spalten. Die Hilfen sind echte Buttons mit Tastaturbedienung und eindeutigen Namen, etwa „Spalte 2 von 2: Inhalt hinzufügen“. Sie setzen einen genauen Einfügepunkt oder führen dieselbe Modelltransaktion wie der Werkzeugkasten aus. Abbrechen oder eine andere Auswahl hebt den Einfügepunkt auf.
Die Editor-CSS gibt den Flächen mindestens 130 Layoutpixel Höhe. Der öffentliche Renderer und seine CSS bleiben unverändert; die Mindesthöhe ist eine Bearbeitungshilfe und wird nicht als Designwert gespeichert. Nach dem ersten Inhalt verschwindet die Hilfe automatisch, Undo stellt sie wieder her. Kein Schemawechsel und keine Datenmigration; bestehende leere Container erhalten die Hilfe allein beim Öffnen. `preview.css` und das neue Modul erhalten die Versionskennung 1.6.2, damit ein bestehender Browsercache die Funktion nicht verdeckt.
Neu eingefügte Elemente werden nach dem Rendern sichtbar gescrollt. Die Vorschau gilt erst mit geparstem DOM und geladenen Hilfsstilen als bereit. Die Drop-Geometrie wird beim Scrollen und beim Loslassen neu ermittelt, Zielcontainer werden benannt, und nebeneinanderstehende Spalten werden horizontal eingeordnet; mobil gestapelte Spalten vertikal. Ein Ziehen nahe dem Vorschaurand scrollt den Frame, bis der Zeiger den Rand verlässt oder das Ziehen endet. Inhalt und Undo bleiben von bloßem Scrollen unberührt.
Der Offen-/Geschlossen-Zustand von Feinschliff-Gruppen wird direkt aus ihren bisherigen DOM-Knoten übernommen. Ein verzögertes `toggle`-Ereignis allein reicht nicht: Ein schneller Neuaufbau nach einer Wertänderung kann den Knoten entfernen, bevor das Ereignis ankommt, und würde die gerade geöffnete Gruppe wieder schließen.
## ADR 074 – Editorfläche, Seitenebene und kontextbezogene Gestaltung (14.09.2026)
Textblöcke sowie H1/H2 hatten im Basis-CSS feste Höchstbreiten von 690/900/760 px. Sie folgen jetzt der Breite ihres Containers; ausdrücklich gespeicherte `maxWidth`-Werte bleiben wirksam. Sektionsbreiten und Theme-Container bleiben bewusste Layoutentscheidungen. Das verändert gegebenenfalls den Zeilenumbruch bestehender Seiten. Vorschau und öffentliche Ausgabe verwenden dieselbe CSS-Quelle. Kein Dokument wird dafür umgeschrieben, keine Schema- oder Datenbankmigration ist erforderlich. Rückweg: vorheriges Basis-CSS oder eine explizite maximale Breite am betreffenden Block.
Ein Klick auf „Sektion“ erzeugt eine neue Sektion nach dem obersten Block der aktuellen Auswahl. Die dauerhaft sichtbare Aktion „Neue Sektion auf Seitenebene“ verwirft zuvor gesetzte Einfügepunkte. Bewusst gesetzte Plus-Einfügepunkte und Drag-and-drop dürfen weiterhin verschachteln. Die Einfügeauskunft und die Modellaktion verwenden denselben Zielermittler; Änderungen bleiben einzelne Undo-Schritte.
Die Seitenleisten sind unabhängig zwischen 240/280 und 560 px verstellbar, soweit wenigstens 280 px für die Vorschau bleiben. Ziehen, Pfeiltasten, Home/End und Doppelklick sind unterstützt. Präferenzen liegen lokal im Browser, außerhalb von Inhalt, Revisionen und Undo. Schmalere Fenster reduzieren die sichtbaren Breiten vorübergehend; bis 850 px folgen die Panels der Vorschau, bis 560 px stehen sie untereinander. Die Vorschau hat keinen zusätzlichen Außenabstand; Desktop wächst oberhalb von 1240 px mit dem verfügbaren Platz, Tablet/Mobil behalten ihre definierten Layoutbreiten.
Der Feinschliff filtert nach Blocktyp und ordnet passende Gruppen zuerst: Bildausschnitt bei Bildern, Typografie bei Text, Layout bei Containern. „Alle Eigenschaften anzeigen“ öffnet den vollständigen Vertrag. Bereits gespeicherte Eigenschaften bleiben auch außerhalb des üblichen Blockprofils auffindbar; Validierung und gespeichertes Schema ändern sich nicht. Beim Auswahlwechsel beginnt das Panel oben. `auto` beziehungsweise `none` sind für die passenden Größenfelder explizit auswählbar. VERSION 1.6.1 erneuert die Cachekennung aller Editor-Module.
## ADR 001 – PHP-Webhosting, Symfony 7.4
@@ -195,7 +538,7 @@ Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe,
## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026)
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Absender (seit 1.15.1 wählbar, leer heißt Empfänger – manche Hoster stellen nur zu, was von einer Adresse der eigenen Domain kommt), Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
**Kein CSRF-Token und keine Sitzung auf dem öffentlichen Weg.** Symfonys CSRF-Schutz braucht eine Sitzung, und eine Sitzung bedeutet ein Cookie auf jeder öffentlichen Seite; das vermeidet das Projekt bewusst. Die Route ist anonym und ändert nichts, was einem Besucher gehört: eine gefälschte Anfrage könnte höchstens eine Nachricht senden, und genau dagegen wirkt der Spamschutz. Die Verwaltung der Formulardefinitionen bleibt dagegen sitzungsgebunden und CSRF-geschützt.
@@ -243,7 +586,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 096.
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.
@@ -281,7 +624,7 @@ Migration `20260912000900` ergänzt `page.publish_at`, `page.deleted_at` und `re
`revision.seq` behebt einen echten Fehler, den erst ein Test gezeigt hat: `created_at` hat Sekundengenauigkeit, und mehrere Speichervorgänge fallen in dieselbe Sekunde. Die Reihenfolge im Verlauf war dadurch **nicht eindeutig** – sie richtete sich nach der zufälligen Revisions-ID. Jetzt trägt jede Fassung einen je Seite streng aufsteigenden Zähler; die Migration füllt ihn aus den vorhandenen Zeitstempeln.
**Geplante Veröffentlichung** setzt `publish_at`. Ein Cron-Aufruf von `cms:publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt. Ohne Cron bleibt die Seite ein Entwurf – die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
**Geplante Veröffentlichung** setzt `publish_at`. Die Aufgabe `publish-due` veröffentlicht, was fällig ist, und löscht den Zeitpunkt (ADR 073); ohne einen Auslöser – Besuch, geöffnetes Backend, URL-Cron oder Konsole – bleibt die Seite ein Entwurf, die sichere Richtung. Zeitpunkte sind UTC; das steht im Formular, weil ein `datetime-local`-Feld sonst die Zeitzone des Browsers unterstellt. Eine Seite im Papierkorb wird nie durch den Zeitplan veröffentlicht.
**Eine frei wählbare Startseite ist nicht umgesetzt.** Der Pfad `home` gilt an elf Stellen als Wurzel der Website – öffentliche Routen, Canonical, Sitemap, `llms.txt`, Menüauflösung. Das zu einer Einstellung zu machen ist ein eigener Schritt; bis dahin behält die Startseite ihre Adresse, statt dass die Hälfte des Systems der Änderung folgt und die andere nicht.
@@ -438,7 +781,38 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
**Der Preis steht auf dem Bildschirm.** Aufrufe statt Besucher, keine Verweildauer, keine Absprungrate, kein Klickweg, kein Land. Das ist keine Lücke, sondern die Folge der Entscheidung: all das braucht Wiedererkennung. Wer es braucht, braucht eine Einwilligung – die Gruppe `statistics` dafür gibt es schon, sie wäre dann ein eigener Baustein.
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides; im Normalbetrieb erledigt das die Aufgabe `stats-prune` des Plans (ADR 073).
## ADR 073 – Wiederkehrende Aufgaben ohne Cronjob: die Anfrage ist der Auslöser (14.09.2026)
Bis hierher stand in vier Dokumenten derselbe Satz: „gehört in einen Cronjob". Das ist richtig – und auf dem Hosting, für das dieses CMS gebaut ist, oft nicht einlösbar. Kein SSH, kein Supervisor, manchmal nicht einmal ein Cron-Eintrag im Kundenmenü. Die Folge war nicht „läuft seltener", sondern **läuft nie**: keine Sicherung, keine geplante Veröffentlichung, eine Statistiktabelle, die nur wächst.
**Die Entscheidung: Was ein Hosting immer hat, sind Anfragen.** Also arbeitet eine Anfrage die Aufgaben ab – in `kernel.terminate`, nach der ausgelieferten Antwort. Unter PHP-FPM ruft Symfony dort `fastcgi_finish_request()` auf: der Browser ist fertig, während hier noch gearbeitet wird. Das ist derselbe Weg, den WordPress (`wp-cron`) und Shopware gehen, und aus demselben Grund.
Vier Auslöser, eine Mechanik:
| Auslöser | Wer stößt an | Grenzen je Durchlauf |
| --- | --- | --- |
| Besuch | jeder Seitenaufruf | 2 Aufgaben, 20 s |
| Verwaltung | das geöffnete Backend, im Minutentakt | 3 Aufgaben, 45 s |
| URL-Cron | der Hoster ruft `/cms-cron/<Schlüssel>` | 20 Aufgaben, 240 s |
| Konsole | `cms:tasks:run`, auch mit `--loop` | frei |
**Was das nicht kann, steht auf dem Bildschirm und nicht im Kleingedruckten:** Ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr. Für Sitemap, Aufräumen, Updateprüfung und Sicherung ist das brauchbar; für etwas Minutengenaues ist es das nicht, und dann ist einer der beiden unteren Wege die Antwort. Genau dieser Satz steht im Bildschirm „Aufgaben", weil die Alternative ist, dass jemand später an der falschen Stelle sucht.
Drei Eigenschaften entscheiden darüber, ob das im Betrieb trägt:
**1. Der Normalfall kostet keine Datenbankabfrage.** In `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht, dazu die zwei Schalter. Liegt der Zeitpunkt in der Zukunft, ist der Abonnent nach einem `file_get_contents` fertig. Fehlt die Datei – frische Installation, geleertes `var/`, kein Schreibrecht –, wird die Datenbank gefragt und die Datei neu geschrieben: der teure Weg ist der Ausnahmefall, nicht der Normalfall, und ein schreibgeschütztes `var/` macht das System langsamer, aber nicht falsch.
**2. Zwei Besucher führen eine Aufgabe nicht zweimal aus.** Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE … WHERE next_run_at <= :jetzt AND (locked_until IS NULL OR locked_until <= :jetzt)`. Das ist auf MySQL wie auf SQLite atomar; wer keine Zeile verändert hat, war zu spät und geht weiter. Deshalb ist der Plan eine **Tabelle** und keine Datei: eine Dateisperre ist auf geteiltem Hosting nicht zuverlässig, ein bedingtes UPDATE ist es. Die Sperre verfällt nach 15 Minuten von selbst, denn ein Prozess, den das Hosting mitten im Lauf abschießt, kann sie nicht mehr aufräumen.
**3. Eine Aufgabe darf niemals die Website mitreißen.** Der Abonnent fängt alles. Ein Fehlschlag wird zum sichtbaren Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden) – sonst fängt eine Aufgabe, die nicht laufen *kann*, bei jedem Seitenaufruf neu an. Fehlt die Tabelle, weil Dateien hochgeladen und Migrationen noch nicht ausgeführt wurden, passiert schlicht nichts.
**Die Sicherung läuft mit, obwohl sie die schwerste Aufgabe ist.** Sie kopiert Datenbank und Medien, und unter Apache mit mod_php belegt sie dabei einen Prozess, weil es dort kein `fastcgi_finish_request()` gibt – die Seite steht längst im Browser, der Prozess bleibt. Der Handel ist trotzdem eindeutig: eine tägliche Sicherung auf einem Hosting ohne Cron ist mehr wert als gar keine. Wer einen echten Cronjob hat, schaltet den Besuchs-Auslöser ab; der Bildschirm sagt auch das.
**Der Schlüssel des URL-Cronjobs steht in keiner Datenbank.** Er wird aus `APP_SECRET` abgeleitet (`hash_hmac`, 40 Zeichen), ist damit auf jeder Installation ein anderer, überlebt jedes Update und wechselt genau dann, wenn das Geheimnis wechselt. Ein falscher Schlüssel bekommt 404 und nicht 403: wer probiert, soll nicht erfahren, dass es die Stelle gibt.
**Eine neue Aufgabe ist eine Datei.** Wer `App\Ops\Tasks\Task` implementiert, bekommt über `_instanceof` ein Etikett, liegt beim nächsten Abgleich in der Tabelle und steht im Bildschirm – ohne eine Liste, die an drei Stellen nachzuführen wäre. Verschwindet die Klasse, verschwindet die Zeile. Mitgeliefert sind fünf: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich).
## ADR 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
@@ -856,7 +1230,7 @@ Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funkt
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local` (seit 1.7.0: `.env`, siehe ADR zur Umgebungsdatei), nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
**Das Ende ist das erste Konto, und es ist ein Zustand der Datenbank, keine Datei.** Eine Datei, die den Installer sperrt, ist eine Datei, die jemand löschen kann – und dann steht auf einer laufenden Website wieder ein Formular, das ein Administratorkonto anlegt. Gibt es ein Konto, antwortet jede Adresse unter `/install` mit 404.
+3 -1
View File
@@ -86,6 +86,8 @@ Abnahme: Die Startseite des Piloten ist ohne Code nachbaubar. Keine neue öffent
### D – Formulare — **erledigt am 12.09.2026, ohne Dateiuploads**
**Ergänzt am 15.09.2026 (ADR 101):** Feldtypen Zahl, Optionsfelder und Mehrfachauswahl, Schritte, Bedingungen auf frühere Auswahlfelder und eine Zusammenfassung vor dem Absenden – für den Preisfinder der DEEG-Website, als Ausbau des Kerns statt als Plugin. Dateiuploads bleiben offen.
*Braucht: nichts; das größte eigenständige Paket.*
Umgesetzt: Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, serverseitige Validierung mit Meldung je Feld, Einwilligungszeitpunkt, E-Mail-Versand mit Antwortadresse, gespeicherte Einsendungen in der Verwaltung und Spamschutz aus Honeypot, signiertem Zeitstempel und Rate-Limit. **Offen geblieben:** Dateiuploads (eigener nicht öffentlicher Ablageort und Typprüfung, Begründung in ADR 017), Löschen und Umbenennen von Formularen, Export und Aufbewahrungsfrist für Einsendungen. Der tatsächliche Versand ist ungeprüft und gehört zu Paket J.
@@ -139,7 +141,7 @@ Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge:
Abnahme: Kopf- und Fußbereich sind im Editor gestaltbar und öffentlich sichtbar. Eine verknüpfte Sektion auf zwei Seiten ändert sich an beiden Stellen gleichzeitig. Ein Textelement lässt sich formatiert und als HTML bearbeiten, und keine Auszeichnung außerhalb der Positivliste übersteht das Speichern. Eigenes CSS wirkt, eigenes Skript wirkt nur für Administratoren gesetzt und nur auf der veröffentlichten Seite. Die Verwaltung ist ohne Anleitung bedienbar.
### H – Plugins und Templates
### H – Plugins und Templates — **erledigt am 16.09.2026** (ADR 102 und 103: Manifest, Schalter, geprüfte Bausteine, Dateien je Seite, Editoranbindung, Container, Einstellungen, eigene Adressen, Seitenvorlagen, Designpakete; Referenzplugins „Bildvergleich“ und „Nachtblau“. Bewusst nicht: PHP-Hooks und eigene Tabellen – Plugins bleiben Manifest plus Twig, ADR 103)
*Braucht: den Erhaltungsvertrag (erledigt).*
+14
View File
@@ -1,5 +1,19 @@
# Abgleich mit dem Flatsome UX Builder
## Bedienkorrektur für leere Layouts (14.09.2026, KantSites 1.6.2)
Erneuter Quelltextabgleich mit dem von Umut genannten Ordner `C:\Users\UmutKantarci\Downloads\flatsome`, Theme-Version **3.20.9** laut `style.css`. Keine laufende WordPress-Installation dieses Pakets gestartet; die folgenden Aussagen stammen aus seinen tatsächlichen Builder-Dateien.
| Beobachtung im Flatsome-Code | Umsetzung in KantSites |
| --- | --- |
| `assets/css/builder/core/content.css`: leeres `content` mit mindestens 90 px Höhe, getöntem Hintergrund und gepunktetem Rahmen | Vorschau-only-Flächen für leere Sektionen, Zeilen und Spalten mit realer Mindesthöhe, Rahmen und Typbezeichnung |
| `tools-addable.is-empty` und `assets/js/builder/core/content.js`: Plus in der Mitte, eigener Einfüge-/Drop-Zustand für leere Container | „Inhalt hinzufügen“ im Zielbereich; Spalten werden nummeriert; der Einfügepunkt wird konkret markiert und benannt |
| `inc/builder/shortcodes/row.php`: fertige Zeilen mit einer, zwei, drei und vier Spalten | Diese vier gleichmäßigen Layouts direkt in der leeren Sektion anbieten, einschließlich passender mobiler Stapelung |
Zusätzlich: neue Container werden nach dem Rendern in den sichtbaren Ausschnitt gebracht, ein Auswahlwechsel löst alte Einfügepunkte, und beim Ziehen scrollt die Vorschau an den Rändern mit. Die Ablageanzeige nennt den Zielcontainer; beim Verschieben von Spalten entscheidet die horizontale Position. Editorhilfen entstehen ausschließlich im Vorschau-DOM, nicht im gespeicherten Dokument oder in der öffentlichen Ausgabe.
Der ältere Gesamtabgleich unten bleibt eine historische Bestandsaufnahme. Diese Änderung übernimmt konkrete Bedienprinzipien für den Aufbau, keine vollständige Flatsome-Oberfläche und keine WordPress-/Shortcode-Abhängigkeit.
Stand: 14.09.2026, KantSites 1.5.0 (Dokumentschema v19).
Quelle des Vergleichs: Flatsome-Theme im Download-Ordner, `inc/builder/shortcodes/` (69 Definitionen)
und `inc/shortcodes/`. Ziel laut Auftrag: alle Funktionen übernehmen und verbessern, die
+3 -3
View File
@@ -21,16 +21,16 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. Die Entscheidung steht an einer Stelle: `SITE_ORIGIN` in `.env` (`php bin/console cms:site:origin https://www.kant-it.de`). Die jeweils andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um; in `.htaccess` gibt es dafür keine Regel mehr. Der Bildschirm „System“ zeigt die eingetragene Adresse und warnt, wenn die Verwaltung gerade unter einer anderen läuft.
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
5. **Cronjobs.** Täglich `php bin/console cms:backup` und `php bin/console cms:stats:prune`. Ohne den ersten gibt es keine Sicherung außer der von Hand angelegten.
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
## Optional: die Verwaltung umbenennen
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
## Optional: Spamschutz mit Cloudflare
+183
View File
@@ -0,0 +1,183 @@
# Plugins schreiben
Ein Plugin bringt eigene Bausteine, Vorlagen, Adressen und Designpakete in den Editor und auf die
Website. Es ist ein Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional
Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins**
(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`.
**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen.
## Der Ordner
```
plugins/
vergleich/ ← Name des Plugins: Kleinbuchstaben und Ziffern, mit Buchstabe beginnend
plugin.json ← das Manifest
blocks/bildvergleich.html.twig
blocks/galerie.html.twig
routes/daten.json.twig
assets/bildvergleich.css ← nur unter assets/ liegt, was die Website ausliefert
assets/bildvergleich.js
```
Die mitgelieferten Plugins sind die Vorlagen zum Abschreiben: `plugins/vergleich` (Bausteine,
Container, Einstellungen, Adresse, Seitenvorlage) und `plugins/nachtblau` (nur ein Designpaket).
## Das Manifest
```json
{
"name": "vergleich",
"title": "Bildvergleich",
"description": "Vorher und nachher in einem Bild.",
"version": "1.1.0",
"requires": "1.17.0",
"settings": {
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher" }
},
"blocks": {
"bildvergleich": {
"label": "Bildvergleich",
"icon": "⇔",
"template": "blocks/bildvergleich.html.twig",
"css": "assets/bildvergleich.css",
"script": "assets/bildvergleich.js",
"props": {
"before": { "kind": "media", "label": "Bild vorher" },
"position": { "kind": "number", "label": "Startposition (%)", "min": 5, "max": 95, "default": 50 }
}
},
"galerie": {
"label": "Vergleichsgalerie",
"template": "blocks/galerie.html.twig",
"container": true,
"children": ["vergleich.bildvergleich"],
"props": { "columns": { "kind": "select", "label": "Spalten", "choices": { "2": "Zwei", "3": "Drei" } } }
}
},
"presets": {
"abschnitt": { "label": "Vorher-Nachher-Abschnitt", "blocks": [ { "id": "s", "type": "section", "children": [ ] } ] }
},
"routes": {
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
},
"theme": { "colors": [ ], "fonts": [ ], "base": { }, "buttons": { } }
}
```
- `name` muss dem Ordnernamen entsprechen. Der Typ eines Bausteins ist `name.baustein`, hier
`vergleich.bildvergleich`; so heißt er im Dokument und in der Ausgabe.
- `version` in der Form `1.2.3`. Sie hängt an den Adressen der Dateien (`?v=1.1.0`), damit der
Browser nach einem Update die neue Fassung holt.
- `requires` (optional): die kleinste KantSites-Fassung. Passt sie nicht, erscheint das Plugin in
der Verwaltung mit dem Grund und lässt sich nicht einschalten.
- Ein Manifest braucht mindestens einen Baustein, eine Adresse, eine Vorlage oder ein
Designpaket. Fehlt eine genannte Datei, ist das Manifest ungültig.
### Eigenschaften und Einstellungen (Schema)
Eigenschaften (`props` eines Bausteins) und Einstellungen (`settings` des Plugins) haben dasselbe
Schema: eine Art (`kind`), eine Beschriftung (`label`), optional `help` und `default`.
| Art | Feld | Prüfung | Weitere Schlüssel |
|---|---|---|---|
| `text` | einzeilig | Text bis `maxLength` (Standard 200) | `maxLength` |
| `textarea` | mehrzeilig | Text bis `maxLength` (Standard 2000) | `maxLength` |
| `number` | Zahl | zwischen `min` und `max` | `min`, `max` |
| `boolean` | Kästchen | an oder aus | – |
| `select` | Liste | einer der Werte | `choices`: `{"wert": "Beschriftung"}` |
| `url` | einzeilig | lokaler Pfad, `#sprungmarke`, `https://`, `mailto:`, `tel:` | `maxLength` |
| `media` | Bild aus der Mediathek | Kennung eines Bildes oder leer | – |
Höchstens 30 Eigenschaften je Baustein. Unbekannte Eigenschaften in einem gespeicherten Dokument
werden ignoriert (so kann ein Plugin eine Eigenschaft streichen), bekannte müssen passen, sonst
lässt sich die Seite nicht speichern. Einstellungen bearbeitet der Administrator auf dem Bildschirm
„Plugins“; sie werden genauso geprüft.
### Container
`"container": true` macht einen Baustein zum Behälter. `"children"` (optional) zählt auf, was
hinein darf – Kernbausteine wie `text` oder Bausteine des Plugins; ohne die Liste darf alles hinein.
Der Editor zeigt die Regel im Werkzeugkasten, der Server prüft sie beim Speichern.
### Seitenvorlagen (`presets`)
Fertige Bausteine, die der Editor als Ganzes einfügt – hinter den eingebauten Vorlagen mit demselben
Knopf. `blocks` ist eine Liste von Bausteinen wie in einem Dokument (höchstens 64 KiB); Kennungen
vergibt der Editor bei jedem Einfügen neu, du kannst also feste schreiben.
### Eigene Adressen (`routes`)
`path` hat die Form einer Seite (`/name` oder `/name/unter/seite`, Slugs) und darf nichts belegen,
was der Kern beantwortet (`/admin`, `/media`, `/assets`, `/formular`, `/beitraege` …). `type` ist
`html`, `json`, `text` oder `xml`. Eine veröffentlichte Seite mit derselben Adresse geht vor.
Ausgeschaltet antwortet die Adresse mit 404; alles außer `html` trägt `noindex`.
### Designpaket (`theme`)
`colors`, `fonts`, `base` und optional `buttons` im Format des Designbereichs (siehe die
Aufbauskripte unter `scripts/` oder `plugins/nachtblau`). „Designpaket übernehmen“ ersetzt die
Palette der Website – dieselbe Prüfung wie beim Speichern unter Design, die Inhalte bleiben.
## Die Vorlagen
### Bausteine
Die Vorlage eines Bausteins bekommt genau sechs Dinge:
- `props` – die geprüften Eigenschaften, jede vorhanden, fehlende mit ihrem Vorgabewert
- `settings` – die Einstellungen des Plugins, jede vorhanden
- `children` – bei einem Container die fertig gerenderten Kinder (leer sonst); ausgeben mit
`{{ children }}`
- `block` – der Baustein selbst; `block.id` gehört als `id` an das Wurzelelement, sonst greifen
eigenes CSS und die Editorauswahl nicht
- `designClasses` – Klassen aus Sichtbarkeit, Design-Tokens und Einblendungen, an die `class`
des Wurzelelements anhängen
- `editing` – `true` in der Editorvorschau, für Hinweise wie „bitte ein Bild wählen“
Dazu eine Funktion: `kant_plugin_media(kennung)` liefert ein Bild der Mediathek als
`{id, title, alt, variants: [{src, width, height}, …]}` (kleinste Fassung zuerst) oder `null`.
```twig
{% set bild = props.before ? kant_plugin_media(props.before) : null %}
<div id="{{ block.id }}" class="block-bildvergleich{{ designClasses }}" data-position="{{ props.position }}">
{% if bild %}<img src="{{ (bild.variants|last).src }}" alt="{{ props.alt ?: settings.labelBefore }}">{% endif %}
{{ children }}
</div>
```
### Adressen
Die Vorlage einer Adresse bekommt `settings`, `query` (die Abfrageparameter mit sauberen Namen,
gekürzt) und `route`. Für `json` und `xml` schreibt sie die Antwort selbst, etwa mit `json_encode`.
Was keine Vorlage bekommt: Datenbank, Einstellungen der Website, Anfrage. Sie läuft mit den
Rechten der Anwendung – ein Sandkasten ist das nicht, und so wird es auch nicht genannt.
## Stylesheet und Skript
Beide werden nur auf Seiten geladen, auf denen der Baustein vorkommt, über
`/plugin-assets/<name>/assets/...`. Die Content-Security-Policy erlaubt Skripte vom eigenen
Ursprung, sobald die Seite eines braucht; Inline-Stile und Inline-Skripte bleiben verboten. Ein
Skript setzt Werte deshalb über das CSSOM (`element.style.setProperty(...)`), nie über ein
`style`-Attribut in der Vorlage. In der Editorvorschau wird das Stylesheet geladen, das Skript
nicht – die Vorlage muss auch ohne Skript ein brauchbares Bild abgeben.
## Was der Kern übernimmt
- Speichern, Veröffentlichen, Fassungen, Undo/Redo – der Baustein ist ein gewöhnlicher Block.
- Eigenes CSS je Block, Design-Tokens (Abstände, Ausrichtung, Ecken), Sichtbarkeit je Gerät,
Einblendungen – alles über `designClasses` und die `id`.
- Der Editor: Eintrag im Werkzeugkasten unter „Erweiterungen“, Eigenschaftenpanel aus dem
Schema, Container-Regeln, Vorlagen, Vorschau über den Server.
- Der Erhaltungsvertrag: ist das Plugin aus oder fehlt es, bleibt der Baustein im Dokument, der
Editor zeigt einen Platzhalter, die Website einen HTML-Kommentar. Nichts geht verloren.
## Updates
Mitgelieferte Plugins liegen im Releasearchiv und werden beim Archiv-Update Datei für Datei
aktualisiert. Ein selbst installiertes Plugin bleibt dabei stehen, weil sein Ordner im Archiv
nicht vorkommt. Beim Git-Update ist ein fremder Ordner unter `plugins/` nicht von Git verwaltet;
`git checkout` lässt ihn in Ruhe. Die Einstellungen liegen in der Datenbank und überleben beides.
+37 -1
View File
@@ -1,5 +1,41 @@
# Qualitätsbericht – erster ausführbarer Prototyp
## Ergänzung: DEEG Immobilien & Hausverwaltung gemessen, 15.09.2026 (1.17.0)
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der fünften Referenzwebsite „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (`var/immobilien-deeg.db`, Startkonfiguration `immobilien-deeg-prod`, Port 8095), drei Adressen: `/`, `/impressum`, `/datenschutz`. Ergebnis: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**; Startseite LCP 1,8 s mobil und 0,5 s Desktop (das Startfoto als `img` im Hero), CLS 0, TBT 0 ms. Ladebudget der Startseite 157 KB mit 6 Anfragen und 11 Datenbankabfragen. Berichte unter `reports/lighthouse-immobilien-deeg/`. Voraussetzung für die 100 bei Barrierefreiheit: Blau mit weißer Schrift als `#0076b6` statt Logo-Blau `#0081c7`, kleine blaue Schrift `#006aa3`, Copyright-Zeile mit eigener Farbe. Nach dem Umbau der Bewertungsanfrage zum dreistufigen Preisfinder (ADR 101, zweites Skript `form-steps.js` auf der Startseite) erneut gemessen: `/` mobil und Desktop weiterhin in allen fünf Kategorien 100, Ladebudget 161 KB mit 7 Anfragen.
## Ergänzung: Ihr Malerservice Drews gemessen, 15.09.2026 (1.16.0 + zwei unveröffentlichte Korrekturen)
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der vierten Referenzwebsite „Ihr Malerservice Drews“ (`var/maler-drews.db`, Port 8092), sechs Adressen: `/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`. Ergebnis: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 – Startseite 99 mobil und 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, alle übrigen 100. Der Rest zur 100 ist überall das Foto hinter dem Seitenkopf als CSS-Hintergrund (LCP mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte unter `reports/lighthouse-maler-drews/`.
Drei Messrunden führten dorthin. Die erste zeigte Barrierefreiheit 91–93: das Gold `#c6a36b` der Vorlage erreicht auf Weiß 2,4:1 (Kleinzeilen, Datum, Links, weiße Buttonschrift auf Gold), die Beitragslisten sprangen von H1 auf H3, der Copyright-Link hob sich nur durch Farbe ab, und der Nach-oben-Knopf lag außerhalb jeder Landmarke (axe „region“, auf jeder Seite). Behoben durch `@gold-text` (`#8a6a2f`, 5,0:1) für Text auf hellem Grund, dunkle Buttonschrift (6:1), eine unsichtbare H2 vor den Listen, Unterstreichung, und im CMS ein `nav` um den Knopf. Die zweite Runde ließ noch den aktiven Menüpunkt (Gold auf Weiß) und graue Formularbeschriftungen (`#999`, 2,8:1) übrig. Für die Leistung: nur das erste Hintergrundbild einer Seite wird vorgeladen – der Slider der Startseite liegt eine Ebene tiefer, deshalb trägt der äußere Abschnitt dasselbe Foto; jede im Design genannte Schriftstärke wird vorgeladen, deshalb Lato nur 400/700 (ohne das 300 der Vorlage, 23 KB weniger vor dem größten Bild); Kacheln und Galeriebilder bekamen feste Breiten, weil ohne sie eine 270 px breite Kachel die 2400er Datei lud (108 KB Ersparnis auf der Startseite laut `image-delivery`).
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px, nach dem Durchscrollen und zwei Sekunden Wartezeit) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); die erste Runde hatte 58.
Maschinenlesbar: `llms.txt` (8 Seiten, 31 Beiträge mit Beschreibung), `robots.txt`, `sitemap.xml` (41 Adressen), `feed.xml`, `LocalBusiness` mit Adresse, Telefon, Öffnungszeiten Mo–Do/Fr, Einsatzgebiet und Instagram als `sameAs`. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, ein echtes Touchgerät, das tatsächliche Hosting.
## Ergänzung: Elektro Bernhard gemessen, 14.09.2026 (1.12.1)
Lighthouse **13** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der Referenzwebsite Elektro Bernhard (`var/elektro-bernhard.db`, Port 8090), vier Adressen: `/`, `/leistungen`, `/leistungen/notdienst`, `/kontakt`. Ergebnis nach den Korrekturen: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung überall 100 bis auf die mobile Startseite mit **99** (LCP 2,1 s durch das Hero-Foto als Hintergrund unter Mobildrosselung; CLS 0, TBT 30 ms). Berichte unter `reports/lighthouse-elektro-bernhard-2/`; der erste Lauf davor (`reports/lighthouse-elektro-bernhard/`) zeigte Barrierefreiheit 95–97.
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px) auf sechs Seiten: **null Verstöße**. Vorher gab es drei echte Befunde, alle behoben: Markennamen `#9aa0a6` auf Weiß (2,6:1 → `#6b7178`, 4,7:1), der rote Link „Kant-IT Solutions“ im dunklen Fuß (3,0:1 → hell), und auf der Leistungsübersicht sprang die Gliederung von H1 auf H3 (jetzt eine H2 vor den Karten). Ein vierter Befund lag im CMS: der Pause-Knopf des Sliders hatte den sichtbaren Text „Pause“, aber den zugänglichen Namen „Automatische Wiedergabe pausieren“ (WCAG 2.5.3); der Name beginnt jetzt mit dem sichtbaren Text. Achtung beim Messen: unmittelbar nach dem Laden meldet axe Kontrastverstöße an Elementen, die gerade eingeblendet werden (halb transparente Zwischenzustände); erst nach dem Durchscrollen und zwei Sekunden Wartezeit sind die Befunde belastbar.
Maschinenlesbar: `llms.txt`, `robots.txt`, `sitemap.xml` werden ausgeliefert, `LocalBusiness` mit drei `sameAs`-Profilen auf der Startseite, `BreadcrumbList` auf Unterseiten, `og:image` auf allen Inhaltsseiten. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, das tatsächliche Hosting.
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
Vergleich mit lokalen Flatsome-3.20.9-Quelldateien, keine laufende WordPress-Referenz geprüft. Kein neuer Lighthouse-Lauf, da der öffentliche Renderer und dessen CSS unverändert sind; die Messung von 1.6.1 ist weiterhin als solche zu lesen. Kein neuer Screenreader-, Firefox-, Safari-, Touchgeräte- oder Felddatennachweis.
## Ergänzung: Flexible Editorfläche und Inhaltsbreiten, 14.09.2026 (1.6.1)
`npm.cmd test`: 30 Modelltests; PHP 8.4.22: 452 Tests / 3476 Assertions. Der vollständige isolierte Playwright-Lauf einschließlich axe, Speichern/Neuladen, Veröffentlichung, Konfliktbehandlung und neuer Panel-/Sektionsprüfungen besteht. Bildschirmbreiten 390/850/1100/1920 px ohne horizontalen Überlauf geprüft. Screenshotprüfung für Desktop, Bildinspektor und Telefon; kein neuer Screenreader-Test.
`PHP_BINARY=C:\php84\php.exe`, `npm.cmd run test:editor -- --audit`: Lighthouse **13.4.1**, lokaler Symfony-Produktionsmodus mit isolierter Datenbank und den vom Editortest erstellten Inhalten/Bildern. Je **drei** Läufe im Mobil- und Desktopprofil aus `scripts/audit.mjs`. Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing in jedem Lauf und damit in beiden Medianen **100**. Vollständige Geräte-/Drosselungs- und Umgebungseinstellungen sowie Ergebnisse stehen in `reports/lighthouse-editor/summary.json` und den sechs Einzelberichten; funktionale Ergebnisse in `reports/editor/result.json`.
Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der festen Text-/Überschriftenbreiten. Sie ist keine Abnahme aller Pilot- oder Kundenseiten. Felddaten zu LCP/INP/CLS wurden nicht erhoben; INP wird aus diesem Ladelauf nicht abgeleitet. GEO/Auffindbarkeit ist durch den Agentic-Browsing-Score nicht nachgewiesen.
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag.
@@ -233,7 +269,7 @@
- **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 096).
- **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.
+859 -1
View File
@@ -1,6 +1,730 @@
# Projektstatus
Stand: 14.09.2026, Fassung **1.5.2**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
## Fassung 1.18.0 veröffentlicht (16.09.2026)
**Anlass:** Umut: „Bau eine neue Version bitte, Commit, Push und Release.“ Der Stand aus DEEG-Nachbau, Formularschritten, Pluginsystem und den Rückständen der Gesamtpruefung geht als 1.18.0 heraus.
**Vorher gefunden und behoben:** Der komplette `phpunit`-Lauf in einem Prozess starb reproduzierbar nach 80 Sekunden mit „Maximum execution time exceeded“, an wechselnden Stellen. Ursache war nicht ein Test, sondern `TaskWorkerSubscriber`: `set_time_limit()` **setzt** die Grenze, es verlängert sie nicht. Auf einem Prozess ohne Grenze – Konsole, Testlauf, Hoster mit `max_execution_time = 0` – machte `set_time_limit($budget + 60)` aus „unbegrenzt“ achtzig Sekunden, und der Prozess starb später irgendwo weit weg vom Aufgabenplan. Derselbe Fall stand im Takt der Verwaltung (`TaskController::tick()`, 120 Sekunden). Beide Stellen heben die Grenze jetzt nur an und senken sie nie. Einzeln liefen alle Testdateien immer durch, deshalb war das bisher nie aufgefallen.
**Geprüft:** neuer Test in `tests/SchedulerTest.php` („der Worker kürzt einen Prozess ohne Zeitgrenze nicht“): faellige Aufgabe läuft, `max_execution_time` bleibt 0. `phpunit` 572 Tests, 4768 Zusicherungen in **einem** Prozess grün (vorher Abbruch), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer. Danach Commit, Tag `v1.18.0`, Push nach `origin` und Releasearchiv.
## Rückstände der Gesamtprüfung abgearbeitet (16.09.2026)
**Anlass:** Umut: „Bitte umsetzen“ zur Liste aus ADR 100. ADR 104 beschreibt jeden Punkt; hier die Kurzfassung.
- **Archivseiten** laufen durch `renderPage()` (eigenes Dokument aus Kopfabschnitt und blätternder Beitragsliste): Kopf/Fuß mit Stilen, Sektionen, eigenes CSS, Bündel, Nach-oben, Einwilligung, Skripte. `templates/archive.html.twig` gelöscht.
- **Formulare, Beitragslisten, Brotkrumen in Bereichen und Sektionen** werden aufgelöst; Meldungen eines abgewiesenen Formulars finden den Block über die Formularadresse. Skripte in Bereichen laufen als `/assets/bereich/<id>.js`.
- **Doppelte Block-IDs** bei mehrfach verknüpfter Sektion: `PartialRepository::expandRepeatedLinks()` erzeugt Kopien mit eigenen Kennungen (Website und Editorvorschau).
- **Breakpoints** überall 1024/767 (design.css neu gebaut, site.css, enhancements.css, preview.css, editor.css, Editortext).
- **Farben**: `color-scheme`, Skip-Link, Aufruf-Button, Fehlerfarben, `.tone-ink`-Text aus der Palette; `Theme::css()` setzt `--k-scheme` und die Fehlerfarben nach Leuchtdichte des Papiers; Formularbutton dunkelt in die Tinte. Druckstile neu.
- **Betrieb**: Sicherung/Wiederherstellung auf MySQL (mysqldump/mysql), Git-Updater sichert und rollt über `Backup`, Sperre `var/update.lock` für beide Wege, Archiv als Datei geladen, `.htaccess` zusammengeführt (HTTPS/HSTS bleiben an), `var/update/` und alte Caches nach dem Update aufgeräumt, CI-MySQL-Job mit `cms:health`, `cms:backup` und `phpunit`.
- **SEO**: Canonical mit `?seite=n` und `rel=prev/next` bei blätternden Beitragslisten; Druckstile.
- **Editor**: Vorschau mit eigenem CSS, Bereichs-/Sektionsstilen und Formularen; Kontaktdaten, Formular, verknüpfte Sektion und Embed direkt nach dem Einfügen gültig (leer = nichts gewählt); bereinigter Rich Text sofort im Editor (`EditorHistory.adopt()`).
- **Kleines**: `legal.label` begrenzt, Docblocks in `PageController` gerückt.
**Geprüft:** neue `tests/BacklogTest.php` (7 Tests, 55 Zusicherungen: Archiv mit Fuß, Sektion, Nach-oben, Canonical/prev/next, Kategorie mit BreadcrumbList; blätternde Liste auf einer Seite; Formular im Fuß samt 422-Wiederanzeige; Sektion zweimal verknüpft; leere Bausteine; dunkle Palette; Sperre und .htaccess-Zusammenführung). Bestehende Tests angepasst: leerer Kontaktblock ist gültig, Schrittfolge des Updaters mit „Sperre“, `ReleaseOnDisk` kennt `toFile()`. Komplette Kette grün: `phpunit` 571 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen geänderten Dateien, `npm run test:editor` bestanden (Vorschau mit Formularen und eigenem CSS), DEEG `npm run test:browser` bestanden (Startseite 168 KB, 11 Anfragen – die vier Optionsbilder des Preisfinders kamen dazu), Lighthouse `/` Desktop überall 100, mobil Leistung 99 (die vier Piktogramme laden mit; Rest 100). Nicht committet.
## Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen, Designpakete (16.09.2026)
**Anlass:** Umut: „Dann mach H.“ Zweiter Teil des Pluginsystems (ADR 103). Grundentscheidung: ein Plugin bleibt Manifest plus Twig, ohne PHP – deshalb keine Hooks und keine eigenen Tabellen, dafür alles, was sich ohne Installationscode sicher übergeben lässt.
**Gebaut.** `PluginRegistry` liest jetzt auch `settings` (Schema wie Eigenschaften, gespeichert in der neuen Spalte `plugin.settings`, Migration `20260916000100`), `container`/`children` am Baustein, `routes` (Pfad wie eine Seite, Vorlage, Antwortart html/json/text/xml, Kernadressen gesperrt), `presets` (Bausteinlisten bis 64 KiB) und `theme` (Palette, Schriften, Grundwerte, Buttons). `block.html.twig` rendert die Kinder eines Containers selbst und reicht sie der Vorlage als `children`, dazu `settings`; `DocumentValidator` weist Kinder in Nicht-Containern und nicht erlaubte Kinder ab. `PageController::page()` beantwortet eine Plugin-Adresse, wenn keine Seite den Pfad hat (vor Weiterleitung und 404), mit Einstellungen und gesäuberten Abfrageparametern, Schutzkopfzeilen, ohne Skript, außer html mit `noindex`. Editor: `registerPlugins(list, presets)` kennt Container und erlaubte Kinder (`canContain`), `insertPreset('plugin:…')` fügt Vorlagen mit frischen Kennungen ein, der Werkzeugkasten zeigt sie hinter den eingebauten. Bildschirm „Plugins“: Einstellungsformular je aktivem Plugin, Adressen verlinkt, „Designpaket übernehmen“ (`Theme::save()`, Inhalte bleiben), beide Wege in der Rollenmatrix. Referenz: `plugins/vergleich` 1.1.0 (Einstellungen, Vergleichsgalerie als Container nur für Bildvergleiche, Vorlage „Vorher-Nachher-Abschnitt“, Adresse `/vergleich/daten`), neu `plugins/nachtblau` als reines Designpaket. Alle fünf lokalen Installationen migriert. `docs/PLUGINS.md` beschreibt das vollständige Manifest.
**Geprüft:** `tests/PluginTest.php` jetzt 8 Tests: Einstellungen (Vorgaben, Umwandlung aus dem Formular, Abweisung, Wirkung in Vorlage und Adresse, 404 bei ausgeschaltetem Plugin), Container (Kinder gerendert, Stylesheet einmal, fremdes Kind und Kinder im Nicht-Container abgewiesen, Editor-Definitionen und Vorlagen), Designpaket (übernommen, Buttons, Dokument unverändert, nur eingeschaltet, Fehler ohne Paket). `editor-model.test.mjs` prüft registrierte Container, Vorgaben, `canContain` und Vorlagen mit frischen Kennungen (34 grün). `EditorSecurityTest` mit den neuen Routen grün. `npm run test:editor` um Einstellungen speichern, die Adresse `/vergleich/daten`, die Plugin-Vorlage im Werkzeugkasten und den Container erweitert (Bildvergleich landet in der Galerie, ein Text daneben) und bestanden; ein Lauf davor blieb beim Neuladen des Editors bei „Wird geladen“ hängen, der nächste lief ohne Änderung durch – der Check protokolliert jetzt jede Konsolenfehlermeldung, damit so etwas beim nächsten Mal einen Grund hat. Komplette Kette grün: `phpunit` 565 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien. Nicht committet.
## Pluginsystem, erste Fassung – Manifest, Schalter, geprüfte Bausteine, Referenzplugin „Bildvergleich“ (15.09.2026)
**Anlass:** Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit. … Dann Plugin so wie du es gesagt hast, hört sich gut an.“ Arbeitspaket H, erster Teil (ADR 102).
**Gebaut.** `src/Plugin/PluginRegistry.php` liest `plugins/<name>/plugin.json` (Titel, Fassung, `requires`, Bausteine mit Beschriftung, Symbol, Vorlage, Stylesheet, Skript und Eigenschaftenschema mit sieben Arten), meldet kaputte Manifeste mit Grund und hält den Schalter in der neuen Tabelle `plugin` (Migration `20260915000100`, in `TestSchema`). `DocumentValidator` prüft die Eigenschaften eines Plugin-Bausteins beim Schreiben gegen das Schema (`validate(..., writing: true)` an allen Schreibstellen), beim Lesen nicht – ein später eingeschaltetes Plugin macht keine Seite unlesbar. `templates/blocks/block.html.twig` rendert Bausteine aktiver Plugins über `@plugins/...` mit den erneut geprüften `props` (`src/Twig/PluginExtension.php`: `kant_plugin_block`, `kant_plugin_props`, `kant_plugin_media`); passt etwas nicht, bleibt der Kommentar. `PluginAssetController` liefert nur Dateien aktiver Plugins unter `assets/` mit erlaubter Endung; `PageController` verlinkt sie je Seite und öffnet die Richtlinie für ein Skript nur dann. Design-Tokens gelten jetzt auch für Plugin-Typen. Editor: der Dokumentaufruf liefert die Definitionen (`plugins`), `registerPlugins()` in `editor-model.js` trägt sie ein (Gruppe „Erweiterungen“, Vorgaben, Schema v4), `renderPluginFields()` in `editor.js` baut das Panel aus dem Schema, ein Bild über die Mediathek; die Vorschau lädt das Stylesheet, kein Skript. Verwaltung: Bildschirm „Plugins“ (Administrator) mit Ein/Aus, CSRF, in Navigation und Rollenmatrix. Release/Update: `plugins/` im Archiv und beim Archiv-Update Datei für Datei übernommen. Entwicklerdoku `docs/PLUGINS.md`.
**Referenzplugin `plugins/vergleich`:** Baustein `vergleich.bildvergleich` – zwei Bilder aus der Mediathek, ein Regler dazwischen (natives Bereichsfeld, Skript setzt nur `--pos` über das CSSOM), Beschriftungen, Startposition, Ecken. Ohne Skript steht der Vergleich bei der Startposition.
**Geprüft:** `tests/PluginTest.php` (5 Tests, 50 Zusicherungen): Manifest lesen und schalten (Schalter überlebt eine neue Registry), drei kaputte Manifeste mit Grund und ohne Schalter, Eigenschaftenprüfung nur bei aktivem Plugin und nur beim Schreiben, Rendering mit Stylesheet/Skript/CSP gegen Kommentar bei ausgeschaltetem Plugin, Asset-Route (Typ, `immutable`, 404 für Manifest, Vorlagen, `..` und ausgeschaltete Plugins), Design-Tokens und eigenes CSS am Plugin-Baustein. `EditorSecurityTest` mit den Plugin-Routen grün (38 Tests), `npm test` 33 grün, Twig-Lint aller Vorlagen einschließlich `plugins/`. Die vier lokalen Installationen sind migriert. `npm run test:editor` um ein Plugin-Szenario erweitert und bestanden: Bildschirm „Plugins“ mit axe (die Überschriftenfolge dort war zuerst falsch, h3 unter h1), Einschalten, Baustein aus dem Werkzeugkasten, Panel aus dem Schema, zwei Bilder über die Mediathek, Vorschau mit Stylesheet und ohne Skript, Speichern und Veröffentlichen, Website mit Skript und `script-src 'self'`, Ausschalten lässt den Baustein im Dokument und zeigt den Kommentar (`reports/editor/plugins.png`, `plugin-block.png`). Danach die komplette Kette: `phpunit` zuerst mit drei Befunden – der feste Twig-Pfad `paths` in `twig.yaml` brach jede Installation ohne Ordner `plugins/` (Installer-Tests), und die Vollständigkeitsprüfung des Archiv-Updaters kannte `plugins/` nicht. Jetzt registriert `PluginExtension` den Namensraum `@plugins` zur Laufzeit nur, wenn der Ordner existiert, und der Test führt `plugins/` als Datei-für-Datei-Ordner wie `public/`. Danach `phpunit` vollständig grün (562 Tests, 4664 Zusicherungen, PHP 8.4), Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien.
**Nicht gebaut:** Container-Bausteine, eigene Routen und Einstellungen, Tabellen, Hooks, Ressourcen in der Verwaltung, Templates. Kein Sandkasten – Plugin-Vorlagen laufen mit den Rechten der Anwendung, so steht es auf dem Bildschirm. Nicht committet.
## Formulare mit Schritten, Bedingungen und Zusammenfassung – der DEEG-Preisfinder (15.09.2026)
**Anlass:** Umut: „Plugins funktionieren noch nicht? Was fehlt denn? Ich hätte gerne kein Kontaktformular weiteres, sondern so ein ausgebautes Form.“ Dann: „Okay, dann erweitere den Kern.“ Gemeint ist der Preisfinder der laufenden DEEG-Website: Objektart wählen, Eckdaten je Objektart, Kontakt, Zusammenfassung.
**Kern (ADR 101):** Drei neue Feldtypen – Zahl (Unter-/Obergrenze, Einheit), Auswahl als Optionsfelder, Mehrfachauswahl. Ein Feld „Neuer Schritt“ eröffnet einen Abschnitt (Beschriftung = Titel, Hilfetext = Einleitung). Jedes Feld und jeder Schritt kann eine Bedingung tragen: gilt nur, wenn ein früheres Auswahlfeld einen der genannten Werte hat. Einstellung „Zusammenfassung vor dem Absenden“. Höchstens 40 statt 25 Felder. Der Server prüft die Bedingungen selbst (`FormRepository::check()`): ein Feld, das nicht galt, wird weder verlangt noch gespeichert, auch wenn der Browser einen Wert schickt; beim Speichern der Definition muss jede Bedingung auf ein früheres Auswahlfeld mit passenden Optionen zeigen. Bestehende Formulare bleiben ohne Migration gültig (`present()` ergänzt die Schlüssel).
**Öffentlich:** `templates/blocks/form.html.twig` rendert Schritte als `fieldset`/`legend`, Gruppen für Options- und Mehrfachauswahl, `type="number"` mit Grenzen und Einheit, Bedingungen als `data-show-if`/`data-show-values`. Ohne JavaScript steht das Formular vollständig auf der Seite. Neu `public/assets/form-steps.js` (nur auf Seiten, deren Formular es braucht, `FormRepository::needsScript()`; die CSP erlaubt es dann): Fortschritt, „Zurück“/„Weiter“ mit Browserprüfung je Schritt, Felder hinter nicht zutreffenden Bedingungen deaktiviert statt nur versteckt, Zusammenfassung als letzter Schritt mit „Ändern“, nach einer Serverablehnung der erste Schritt mit Fehler. `site.css`: Stile für Schritte, Fortschritt, Gruppen, Zahl mit Einheit, Zusammenfassung; `.block-form [hidden]` schlägt jetzt `display:grid`/`inline-flex`. E-Mail: Schritte als Zwischenüberschrift, Felder, die nicht galten, bleiben weg.
**Bildauswahl (Nachtrag):** Umut meinte mit „Upload“ eine Bildauswahl wie im Solar-Rechner von Elektro Bernhard (Bildkarten statt Kästchen). Optionsfelder und Mehrfachauswahl tragen jetzt je Option ein Bild aus der Mediathek; sobald eine Option eines hat, wird die Auswahl auf der Website zu anklickbaren Karten mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. Im DEEG-Preisfinder hat die Objektart vier Piktogramme (`var/immobilien-deeg-assets/option-*.png`, mit GD gezeichnete Platzhalter, in der Mediathek gegen Fotos tauschbar). Test `testAChoiceWithPicturesBecomesClickableCards`, dazu zwei abgelehnte Zuordnungen.
**Verwaltung:** Feldkarte mit Untergrenze/Obergrenze/Einheit (bei Zahl), Optionen bei Liste/Optionsfeldern/Mehrfachauswahl, „Bild je Option“ als Liste der Mediathek, Bedingung („Nur zeigen, wenn das Feld … einen dieser Werte hat“); Zustellung mit Schalter „Zusammenfassung vor dem Absenden zeigen“; Vorschau zeigt Schritte, Optionsfelder, Kästchen, Einheit und Bedingungen. Alles ohne Skript bedienbar.
**DEEG:** Das Formular `bewertung` im Aufbauskript ist jetzt der Preisfinder: Schritt 1 Objektart (Wohnung, Haus, Mehrfamilienhaus, Grundstück) und Anlass (Verkaufen, Vermieten); Schritt 2 Eckdaten je Objektart – Wohnfläche, Grundstücksfläche, Zimmer, Wohneinheiten, Baujahr, Zustand, Einbauküche, Aufzug, Parkmöglichkeit, Bebaubarkeit, jeweils hinter Bedingungen; Schritt 3 Lage und Kontakt; dann die Zusammenfassung. Die Feldliste folgt dem Preisfinder von immobiliendeeg.de (Wohnung-Seite, 15.09.2026). Das Kontaktformular bleibt, wie es war.
**Geprüft:** neue `tests/FormStepsTest.php` (5 Tests, 75 Zusicherungen): Normalisierung beim Speichern, zehn abgelehnte Definitionen mit Begründung, Bedingungen in `check()` (Grundstück ohne Wohnfläche, Wohnung mit Pflicht, Zahl mit Komma, Grenzen, fremde Optionen), Rendering samt Skript und CSP (und ein einfaches Formular weiter ohne Skript, `script-src 'none'`), POST mit Speicherung nur der geltenden Felder und 422-Wiederanzeige mit erhaltenen Kreuzen. Im Browser (Port 8094): drei Schritte durchgespielt, Felder je Objektart, Zusammenfassung mit allen Werten, Absenden mit Erfolgsmeldung, Einsendung in `form_submission` mit leeren Feldern für alles, was nicht galt; Grundstück zeigt nur zwei Eckdatenfelder, „Weiter“ hält bei leerem Pflichtfeld. Komplette Kette: `phpunit` 556 grün (PHP 8.4), `npm test` 33 grün, `npm run test:editor` vollständig bestanden (darunter die axe-Prüfung der neuen Formularverwaltung), Twig-Lint ohne Befund, php-cs-fixer auf den geänderten Dateien (die vier Befunde in `install.php` und den drei älteren Aufbauskripten sind vorbestehend). DEEG nach dem Umbau: `npm run test:browser` bestanden (Startseite jetzt 2 Skripte – Einblendungen und Formular –, 161 KB), Lighthouse `/` mobil und Desktop weiterhin überall 100.
**Browsercheck angepasst:** `scripts/browser-check.mjs` erlaubte bisher höchstens ein Skript je Seite. Das war schon vor dem Formularskript zu eng – Slider, Reiter, Einblendungen, Bildbetrachter und Nach-oben sind je eine eigene Datei –, fiel aber erst jetzt auf, weil keine Referenzseite zwei davon lud. Neu: höchstens drei Skripte, alle aus `/assets/` des eigenen Ursprungs, und weiterhin keins, wenn die Richtlinie keins erlaubt.
**Nicht gebaut:** Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, Schritt-für-Schritt ohne JavaScript, Formulare in der Editorvorschau. Nicht committet.
## DEEG Immobilien & Hausverwaltung nachgebaut, Anfrage als Formular (15.09.2026)
**Anlass:** Umut lieferte `Immobilien-DEEG-Website.zip` (Onepager für die Deeg Hausverwaltung und Immobilien GmbH, Freudenstadt) und wollte ihn „nach dem Prinzip wie die anderen Seiten mit Kontaktformular“ aufbauen; dazu die Frage, ob die Bewertungsanfrage ein eigenes Plugin braucht.
**Antwort auf die Plugin-Frage:** Nein. Ein Plugin ist in diesem CMS ein Baustein mit Namensraum, den der Kern aufbewahrt, aber nicht rendert (ADR zum Erhaltungsvertrag, ADR 049); eine Laufzeit dafür gibt es erst mit Phase 4. Die Anfrage ist deshalb das Formular `bewertung` (Auswahl Wohnung/Haus/Mehrfamilienhaus/Grundstück – die vier Objektarten des Preisfinders der laufenden Website –, Straße und Ort, Name, E-Mail, Telefon, Hinweise, Einwilligung), der Kontakt das Formular `kontakt` (mit Auswahl Verkauf/Vermietung/Verwaltung). Beide verschicken serverseitig und liegen unter Formulare in der Verwaltung; die Vorlage verlinkte dafür noch auf www.immobiliendeeg.de.
**Nachbau (`scripts/seed-immobilien-deeg.php`, `scripts/reset-immobilien-deeg.sh`).** Fünfte Installation `var/immobilien-deeg.db`, `var/immobilien-deeg-media`, Bildquellen `var/immobilien-deeg-assets` (Logo, zwei Architekturfotos aus dem Paket), Startkonfigurationen `immobilien-deeg` (Port 8094) und `immobilien-deeg-prod` (Port 8095, Produktionsmodus zum Messen). Startseite mit Hero (Foto, Schild, Bildzeile, blauer Aufruf zur Bewertung), Leistungsleiste, drei Leistungskarten mit Akkordeon, blauem Bewertungsabschnitt mit Formularkarte, Über uns mit „SEIT 1994“, Angebotsband (Link auf www.immobiliendeeg.de/immobilien/, die Immobilien-Datenbank ist nicht Teil des Pakets), Kontakt mit Telefon, E-Mail, Adresse, Bürozeiten und Formularkarte; Kopf mit heller Leiste, Logo, Menü und blauem Knopf „Immobilie bewerten“; Fuß mit Logo, Rechtszeile und Agenturlink. Impressum mit Register und Vertretung vom Impressum der laufenden Website (HRB 430747, Amtsgericht Stuttgart, Furkan Kurt); USt-ID, Aufsicht nach § 34c GewO und Bildlizenz des übernommenen Fotos als „NOCH ZU PRÜFEN“. Datenschutz als eigene Fassung ohne Cookies und Fremddienste. Schrift Arial wie die Vorlage, nichts zu holen.
**Für 100 geändert, nicht für die Optik:** Blau als Fläche mit weißer Schrift trägt `#0076b6` (4,9:1 statt 4,2:1 beim Logo-Blau), kleine blaue Schrift auf Weiß `#006aa3`; das Logo-Blau `#0081c7` bleibt für Linien und große Überschriften. Der Button-Baustein hängt immer „↗“ an, die Vorlage kommt ausdrücklich ohne Pfeile aus – am Button `& span{display:none}`. Die Copyright-Zeile trägt ihre Farbe selbst, weil der Fuß kleinen Text sonst auf 3,8:1 aufhellt.
**Browsercheck angepasst (`scripts/browser-check.mjs`):** Vor Überlauf- und axe-Prüfung spielt das Skript alle laufenden Einblendungen zu Ende (`document.getAnimations().forEach(a => a.finish())`). Vorher hing das Ergebnis vom Zeitpunkt ab: mitten in einer Einblendung rechnete axe den Kontrast gegen die halb durchsichtige Farbe (drei Scheinbefunde am Hero), und ein von rechts hereingleitender Block zählte als horizontaler Überlauf.
**Geprüft:** `npm run test:links` gegen Port 8094: 3 Seiten, 14 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur, ohne JavaScript bestanden, keine redaktionellen Befunde; Ladebudget der Startseite 157 KB, 6 Anfragen, 11 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8095 für `/`, `/impressum`, `/datenschutz`: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100** (Startseite LCP 1,8 s mobil / 0,5 s Desktop, CLS 0, TBT 0 ms); Berichte in `reports/lighthouse-immobilien-deeg/`. Kontaktformular einmal abgeschickt: Erfolgsmeldung, Eingang in `form_submission`. Screenshots in 1440 und 390 px gegen die Vorlage verglichen. php-cs-fixer auf dem Skript ohne Befund.
**Nicht geprüft:** der Editor auf den DEEG-Dokumenten im Browser, E-Mail-Zustellung (lokal `sendmail://default`, der Eingang bleibt trotzdem gespeichert), Firefox/Safari, ein echtes Touchgerät, das Hosting. Nicht committet.
## 1.17.0 veröffentlicht (15.09.2026)
Tag `v1.17.0` (Commit `d07154e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.17.0 – Adresse mit oder ohne www aus `SITE_ORIGIN`, Rollen wie die Rollenhilfe sie verspricht, Befunde der Gesamtprüfung (ADR 099, ADR 100).
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.17.0 v1.17.0`, `php scripts/build-release.php` darin): 3555 Dateien im Archiv, 4,7 MB.
**Anhänge:** `kantsites-1.17.0.zip` (SHA-256 `d421654cd6803e3a683fbbd893db22c005e96261da4f64892c4ae1bb868557f6`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.17.0.py`, erst Entwurf, dann veröffentlicht. Die Releasenotes (`var/release-notes-1.17.0.md`) nennen die Rollenänderung als ersten Punkt vor dem Update.
**Geprüft:** HTTP-Archiv-Update 1.16.1 → 1.17.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.17.0`. Die Prüfungen des Codes stehen im Eintrag darunter.
**Nächster Schritt:** auf `web.kant-it.de` und dem Drews-Hosting über System → Update aus dem Archiv einspielen (keine Migration), danach unter System die eingetragene Adresse prüfen (`SITE_ORIGIN` mit oder ohne www, wie die Website heißen soll) und die Rollen der vorhandenen Konten unter Benutzer durchsehen – Redakteure sehen Design, Einstellungen, Bereiche, Menüs und Formulare nicht mehr.
## 1.17.0 – Gesamtprüfung, Adresse mit oder ohne www, Rollen wie versprochen (15.09.2026)
**Anlass:** Umut: „Komplette Analyse – Bugs, Durchgängigkeit, ob alles sauber ist; was man besser machen kann; und: die URL soll aus env kommen, www oder ohne, die Seite soll darauf reagieren.“ Nachgeschoben: „prüfst du auch, ob man ohne Admin etwas verändern kann?“
**Prüfung.** Sechs getrennte Durchsichten: Backend (Controller, Content, Security, Twig), Betrieb (Ops, Commands, Subscriber, Config, Installer, CI), Editor (JS, Admin-Templates, Modelltests), öffentliche Ausgabe (Templates, CSS, Skripte, SEO, CSP, Einwilligung), Dokumentation und Repository (README, docs, Skripte, Ports, Versionen, git-Hygiene) sowie eine Routenmatrix aller 63 Verwaltungs- und 15 öffentlichen Routen mit Firewall, Rolle, CSRF und Testabdeckung. Vorab: `phpunit` 512 grün, `npm test` 33 grün, Twig/YAML/Container-Lint ohne Befund, php-cs-fixer eine vorbestehende Zeile in `CmsTest`. Repository-Hygiene in Ordnung (313 Dateien, keine Geheimnisse, `var/`, `reports/`, `.env` ignoriert, `VERSION` = Tag = STATUS).
**Antwort auf die Rollenfrage:** Ohne Anmeldung kann niemand Inhalte, Einstellungen oder Konten ändern – alle Verwaltungsrouten liegen hinter der Firewall, jede schreibende prüft Rolle und CSRF-Token. Zwei echte Lücken gab es trotzdem, beide behoben: (1) Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete (Wartung beim Hoster, gedrehtes Passwort) – dort hätte jeder eine eigene Datenbank samt Administratorkonto eintragen können. (2) Die Rolle Redakteur durfte Design, Einstellungen (Startseite, `robotsExtra`, Turnstile, Einwilligung), Bereiche, Menüs und Formulare samt Empfängeradresse live ändern, entgegen der Rollenhilfe. ADR 100.
**www / SITE_ORIGIN (ADR 099):** `CanonicalHostSubscriber` leitet die jeweils andere Schreibweise mit 301 auf die eingetragene Adresse um (Pfad und Abfrage bleiben, Schema und Port aus `SITE_ORIGIN`, auch für die Verwaltung); fremde Hosts, IP-Adressen und localhost bleiben erreichbar, nur GET/HEAD, kein HTTPS-Zwang. Neu `php bin/console cms:site:origin <adresse>`; der Bildschirm „System“ zeigt Adresse, Quelle und einen Hinweis bei abweichendem Host. Der www-Block in `public/.htaccess` ist weg, `.env.example` erklärt die Einstellung, LIVEGANG Punkt 3 und README angepasst.
**Behoben (Auswahl, alles mit Test):** Sitzungscookie folgt `ADMIN_PATH` (Anmeldung nach `cms:admin:path` war unmöglich); `%env(resolve:)%` gegen prozentkodierte Passwörter durch `projectdir:`-Prozessor ersetzt; Einwilligung nur von dieser Website und kein `/\`-Rücksprung; JSON-LD mit `JSON_HEX_TAG`; Komma im eigenen CSS; Skriptprüfung beim Wiederherstellen einer Fassung; keine Unterseite unter einer Seite im Papierkorb; geplante Veröffentlichung blockiert bei einem Fehler nicht die anderen; Archiv escaped alte Bereiche; Feed-Zeitzone UTC; Sitemap-`lastmod` aus der Fassung; Vollbild in Embeds; Tabs/Bildbetrachter in Bereichen und Sektionen; Schatten bei Überfahren/Scrollen erbt Form; Download nur über HTTPS-Weiterleitungen; Composer/Git-Umgebung und PHP-Kommandozeile im Befehlsläufer; Updater fängt jeden Fehler. Editor: Schemafassung für Menü/Beitragsliste/Slider, gesperrte Design-Knöpfe bleiben gesperrt, Ausrücken/Baum-Zug mit Regeln, kein leerer Verlaufsschritt bei Ablehnung, Bild auf Akkordeon meldet sich, Klartext unter v12, Zahlenfelder ab 0, Farbwähler ohne `onComplete`. Docs: `.env.local`-Reste, `motion`/`archive-update` als npm-Skripte, README-Einleitung und dritte Referenzwebsite, UPDATE.md, `install.php` erkennt `.env`, Release-Builder ohne `LICENSE`.
**Geprüft:** `phpunit` vollständig mit PHP 8.4 grün (neu: `CanonicalHostTest` 24 Fälle, `SiteOriginCommandTest`, `ProjectDirEnvProcessorTest`, Rollen-Matrix und Cookie-Pfad in `EditorSecurityTest`, Installer-Fälle in `InstallationTest`, Systembildschirm, Tabs im Fuß, Sitemap-`lastmod`); `npm test` 33 grün (Fassungstabelle angepasst); Twig/YAML/Container-Lint ohne Befund; php-cs-fixer ohne Befund; `cms:health` gegen die Entwicklungsdatenbank mit dem neuen Prozessor `OK`; `npm run test:editor` (Playwright + axe, `PHP_BINARY=C:/php84/php.exe`) vollständig bestanden – ein erster Lauf scheiterte an einer seit 1.16.1 veralteten Zusicherung des Skripts („Spalte in der Sektion weiterhin gesperrt“), die jetzt die erlaubte Spalte erwartet. Nicht geprüft: Firefox/Safari, ein echtes Touchgerät, das Hosting, ein echter MySQL-Server.
**Rückstand (aus der Prüfung, nicht umgesetzt):** Archivseiten auf die Aufbaukette der Seiten heben (Kopf/Fuß-Stile, Schriften, Sektionen, Nach-oben); Formulare, Brotkrumen und Beitragslisten in Bereichen/Sektionen sind dort tot; doppelte Block-IDs bei mehrfach verknüpfter Sektion; Breakpoints 650 vs. 767 px vereinheitlichen; feste Farbwerte (`.tone-ink`, Skip-Link, CTA-Button, Fehlerrot, `color-scheme`) bei dunklen Paletten; MySQL ohne Sicherung, Archiv-Update und Rollback; Archiv-Update überschreibt `.htaccess` (HTTPS-Schalter); Release-Caches wachsen; Rollback-Kopie des Git-Updaters ohne Sperre; Archiv komplett im Speicher; Canonical/`rel=prev/next` blätternder Beitragslisten; Druckstile; Editor-Vorschau ohne eigenes CSS/Bereichsstile/Formulare; Skripte in Bereichen laufen nie; vier Blocktypen direkt nach dem Einfügen ungültig (Kontakt, Formular, Sektion, Embed); Rich-Text-Normalisierung erst nach Neuladen sichtbar; `legal.label` unvalidiert; verrutschte Docblocks; CI-`mysql`-Job ohne Tests; Ports 8090/8092 in QUALITY/STATUS sind Messserver, nicht die Startkonfigurationen.
**Veröffentlicht als 1.17.0** (kein neues Dokumentschema, keine Migration; bestehende Redakteurskonten verlieren Design, Einstellungen, Bereiche, Menüs und Formulare – siehe Eintrag oben). **Nächster Schritt:** `SITE_ORIGIN` auf `web.kant-it.de` und dem Drews-Hosting prüfen und die HTTPS-Zeilen in `.htaccess` einschalten.
## 1.16.1 veröffentlicht (15.09.2026)
Tag `v1.16.1` (Commit `937344e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.1 – einzelne Spalte in einer Sektion, Ausrichtung für Buttons, breite und volle Abschnitte gegen die Design-Inhaltsbreite, Nach-oben-Knopf in einer Landmarke, Cormorant Garamond lokal.
**Anlass:** Die neu aufgebaute Drews-Datenbank trägt das Token „Ausrichtung“ an Buttons; unter dem auf dem Hosting laufenden 1.16.0 lehnte der Validator diese Dokumente beim Rendern ab (500 auf jeder öffentlichen Seite, Verwaltung lief). Das Release bringt Code und Datenbank wieder zusammen.
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.1 v1.16.1`, `php scripts/build-release.php` darin): 3552 Dateien im Archiv, 4,7 MB.
**Anhänge:** `kantsites-1.16.1.zip` (SHA-256 `2922027abb648839b89962351aa3dbeaa9e354dd985c94a7a2efddabb86d6731`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.1.py`, erst Entwurf, dann veröffentlicht.
**Geprüft:** HTTP-Archiv-Update 1.16.0 → 1.16.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.1`. Die Prüfungen des Codes stehen in den beiden Einträgen darunter.
**Nächster Schritt:** auf dem Drews-Hosting über System → Update aus dem Archiv einspielen (keine Migration), danach `var/cache/prod` prüfen; die kopierte Datenbank kann bleiben.
## Einzelne Spalte in einer Sektion, Buttons ausrichten (15.09.2026)
**Anlass:** Umut konnte im Editor keine Spalte direkt in eine Sektion legen (ausgegraut) – so zentriert er einen Button –, und der Button „Alle Mitteilungen“ auf der Drews-Startseite stand links, ohne eine Option dafür.
**Editor (`editor-model.js`):** `REQUIRE.column` ist jetzt `['row', 'section']`. Eine einzelne Spalte darf direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente), in einer Spalte oder lose im Dokument weiter nicht. Test angepasst.
**Buttons (`design-tokens.json`, `site.css`, `design.css` neu gebaut):** Das Design-Token „Ausrichtung“ gilt jetzt auch für Buttons. Weil ein Button `inline-flex` ist, zentriert ihn `margin:auto` nicht; `site.css` macht ihn bei `align-center`/`-right` zum Block mit eigener Breite (`display:flex;width:fit-content;margin-inline:auto`, dazu `justify-self` für Raster), je Gerät an den Breakpoints von `design.css` (1024/650 px). Das Drews-Aufbauskript nutzt das Token für alle mittigen Buttons statt der wirkungslosen auto-Ränder.
**Geprüft:** `npm test` 33 grün (darunter der geänderte Einfügetest), PHP-Tests zu Design-Tokens 11 grün; Drews-Startseite nach Neuaufbau: „Alle Mitteilungen“ und „Alle Projekte ansehen“ mittig, Aufruf-Buttons mittig, auf 1440 und 390 px. Unveröffentlicht, gehört mit den beiden Korrekturen darunter in 1.16.1.
## Ihr Malerservice Drews nachgebaut, 15.09.2026
**Anlass:** Umut wollte maler-drews.com (Flatsome-Auftritt, Malermeister Axel Drews, Gernsbach) nach dem bekannten Prinzip in eigener Installation nachbauen – exakt im Viewer, mit Lighthouse, axe und maschinenlesbaren Ausgaben auf 100.
**Vorlage ausgelesen, nicht geschätzt.** Alle 9 Seiten und 31 Beiträge der laufenden Website als HTML geholt, Struktur, eigenes CSS (`malerdrews-*`, `drews-*`), Abschnittsfarben und Inline-Werte extrahiert, Screenshots in 1440 und 390 px als Vergleich. Befund: Lato + Cormorant Garamond 700, Gold `#c6a36b`, Creme `#f7f5f2`, dunkle Bänder `#232323`/`#111`/`#0a0a0a`, Kopf 90 px weiß mit Versalmenü, Startbild als Slider mit drei Folien und goldener Welle, Kartenraster aus Bild und Verlauf, Zickzackliste der Projekte, Zeitleiste der Mitteilungen. `/about-us` und `/innenaustattung` sind auf der Vorlage Lorem-ipsum-Reste, `/little-greene` ein 404.
**Nachbau (`scripts/seed-maler-drews.php`, `scripts/seed-maler-drews-inhalte.php`, `scripts/reset-maler-drews.sh`).** Eigene Installation `var/maler-drews.db`, `var/maler-drews-media`, Bildquellen `var/maler-drews-assets` (139 Bilder, auf 2400 px verkleinert, Zuordnung in `manifest.json`), Startkonfiguration `maler-drews` auf Port 8093. Acht Seiten (Start, Aktuelles, Unsere Projekte, Kunstmalerei, Kontakt, Impressum, Datenschutz, 404), 31 Beiträge mit Bildkopf, Text, Galerie mit Bildbetrachter, Nachbarn und Aufruf, Kategorien „Aktuelles“ (5) und „Projekte“ (26); die Listen sind Beitragslisten-Bausteine (Karten auf der Startseite, Liste als Zeitleiste, Wechselseitig als Zickzack). Kopf mit Marke und Versalmenü, Fuß in vier Spalten mit Instagram, Öffnungszeiten und Rechtszeile. Formular serverseitig, Google-Karte und elf Videos als eingebettete Inhalte hinter der Einwilligungsleiste. Impressum und Datenschutz wörtlich übernommen (die Datenschutzerklärung nennt Google Analytics und reCAPTCHA, die der Nachbau nicht nutzt – vor dem Livegang zu bereinigen). Weiterleitungen für die drei toten Vorlagenadressen und die eine über 80 Zeichen lange Beitragsadresse. Bewusste Abweichungen stehen im Kopf des Skripts.
**Für 100 geändert, nicht für die Optik:** Gold auf Weiß erreicht 2,4:1 – Kleinzeilen, Datum, Links und aktive Menüpunkte auf hellem Grund tragen `@gold-text` (`#8a6a2f`, 5,0:1), Buttons dunkle statt weiße Schrift auf Gold (6:1), der Einwilligungsknopf ebenso; vor den Beitragslisten steht eine nur für Vorlesesoftware sichtbare H2; der Copyright-Link ist unterstrichen. Das Startbild wird über den äußeren Abschnitt vorgeladen (nur das erste Hintergrundbild einer Seite bekommt ein `preload`).
**Zwei Lücken im CMS behoben (`site.css`, `page.html.twig`, unveröffentlicht, gehört in 1.16.1):** Abschnitte mit Breite „breit“ oder „voll“ blieben so schmal wie alle anderen, weil `.kant .shell{max-width:var(--k-container)}` des Designs die Breitenregeln schlug – jetzt mit `.kant` davor. Der Nach-oben-Knopf stand außerhalb jeder Landmarke (axe „region“) und steckt jetzt in einem `nav`. Beim Bauen gelernt: eigenes CSS erlaubt kein `@media`, keine Anführungszeichen, keine Klammern in Selektoren (`:not()`, `nth-child()`), keine Kommaselektoren, und eine leere `&{…}`-Regel geht verloren – Eigenschaften des Elements ohne `&{}` schreiben.
**Geprüft:** Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus (`APP_ENV=prod`, Port 8092) auf sechs Adressen (`/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`): **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 (Startseite 99 mobil / 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, sonst 100; LCP ist jeweils das Foto hinter dem Seitenkopf, mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte in `reports/lighthouse-maler-drews/`. Drei Messrunden davor zeigten Barrierefreiheit 91–93 (Gold-Kontrast, Überschriftenfolge, Link nur durch Farbe), dann 96–100 (aktiver Menüpunkt, Formularbeschriftungen), jetzt 100. axe (WCAG 2.0/2.1 A+AA und Best Practice, Playwright, 1440 und 390 px, nach dem Durchscrollen) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); vorher 58, davon 36 Kontrast, 18 „region“ am Nach-oben-Knopf, 2 Überschriftenfolge, 2 Link im Text. Maschinenlesbar: `llms.txt` mit allen Seiten und Beiträgen, `sitemap.xml` mit 41 Adressen, `robots.txt`, `feed.xml`, `LocalBusiness` mit Adresse, Öffnungszeiten, Einsatzgebiet und Instagram als `sameAs`. Alle 41 Adressen der Vorlage antworten mit 200 oder 301. Screenshots des Nachbaus in 1440 und 390 px gegen die der Vorlage verglichen (Startseite, Aktuelles, Projekte, Kunstmalerei, Kontakt, Beitrag, Impressum). PHP-Tests 512 grün (PHP 8.4), `npm test` 33 grün, Twig-Lint, php-cs-fixer auf den neuen Skripten.
**Nicht geprüft:** der Editor auf den Drews-Dokumenten im Browser (kein Anmelden mit Passwort in dieser Sitzung), Firefox/Safari, ein echtes Touchgerät, das Hosting. Die Videos liegen weiter auf maler-drews.com und brauchen für den Livegang einen eigenen Speicherort.
## 1.16.0 veröffentlicht (15.09.2026)
Tag `v1.16.0` (Commit `32ee288`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.0 – Spaltenbreiten einer Zeile durch Ziehen im Editor.
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.0 v1.16.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
**Anhänge:** `kantsites-1.16.0.zip` (4,7 MB, SHA-256 `8aa8d40d79dfe7bf972ca005ed92d688cf63beb93a4807c20d5dcabb9b2f9264`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.0.py`, erst Entwurf, dann veröffentlicht.
**Geprüft:** HTTP-Archiv-Update 1.15.1 → 1.16.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.0`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann im Editor eine Zeile wählen und die Spaltengrenze ziehen – etwa bei einer Karte mit Symbol links und Text rechts.
## 1.16.0 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
**Anlass:** Umut, mit der Karte „HausKlar“ (Symbol links, Text rechts) als Beispiel: „bei Rows die Breite ändern, am besten per Drag and Drop im Editor.“ Zeilen kannten bisher nur gleich breite Spalten. Entscheidung und Begründung: ADR 098.
**Umsetzung:** Neue freie Stileigenschaft `columnWidths` an der Zeile (Typ `fractions`, `StyleSchema::FRACTIONS`, z. B. `66.7 33.3`), je Gerät, ohne neues Dokumentschema. `StyleCompiler::columnTracks()` macht daraus `grid-template-columns:minmax(0,66.7fr) minmax(0,33.3fr)`, füllt fehlende Anteile mit dem Durchschnitt auf, kappt überzählige und setzt ein Gerät, dessen Aufteilung stapelt (`one` oder `gridColumns` 1), auf eine Spur – sonst schlüge die ID-Regel des Desktops die Stapel-Klasse des Handys. Im Editor (`canvas-overlay.js`) liegt auf jeder Grenze zwischen zwei nebeneinander stehenden Spalten der gewählten Zeile (oder der Zeile um die gewählte Spalte) ein Griff `.ov-resize`: Ziehen stellt die Zeile in der Vorschau live um, Loslassen schreibt einen Verlaufsschritt (`onResize` in `editor.js` → `setStyle`), Pfeiltasten verschieben um fünf Punkte und behalten den Fokus. Ausgangswert sind die gemessenen Spaltenbreiten. Im Design-Bedienfeld gibt es dazu das Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen). Kein Griff bei gestapelten Spalten und während ein Element gezogen wird.
**Geprüft:** `phpunit` vollständig mit PHP 8.4 512 von 512 grün, mit PHP 8.2 512 grün mit der vorbestehenden Warnung in `UpdaterTest`; neu `tests/ColumnWidthsTest.php` (Spuren, Stapeln je Gerät, Auffüllen und Kappen, Ablehnung ungültiger Werte). `npm test` 33 grün (Relevanz nur für Zeilen). `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden – neu darin: echter Mauszug am Griff verbreitert die erste Spalte, der Wert steht im Bedienfeld der Zeile, auf „Mobil“ gibt es keinen Griff, ein Rückgängig stellt gleiche Spalten her (`reports/editor/column-widths.png`). php-cs-fixer ohne Befund. Ein erster Lauf des Checks scheiterte an `openGroup`, das im Skript erst später definiert ist – die Gruppe wird jetzt an Ort und Stelle geöffnet.
**Nebenbei:** `.claude/launch.json` trägt in der Arbeitskopie den noch nicht committeten Eintrag `maler-drews` einer anderen Sitzung auf Port 8091; die Pilotkonfiguration ist deshalb lokal auf 8092 verschoben, die Datei bleibt aus diesem Commit heraus.
## 1.15.1 veröffentlicht (15.09.2026)
Tag `v1.15.1` (Commit `d05dba3`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.1 – eigene Absenderadresse je Formular.
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.1 v1.15.1`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
**Anhänge:** `kantsites-1.15.1.zip` (4,7 MB, SHA-256 `e15a7a69f299c16d86d372b65610b52f15b40b93904fa6402ea6991de7ed21f7`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.1.py`, erst Entwurf, dann veröffentlicht.
**Geprüft:** HTTP-Archiv-Update 1.15.0 → 1.15.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.1`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare eine Absenderadresse der eigenen Domain eintragen und eine Testanfrage senden.
## 1.15.1 – Eigene Absenderadresse je Formular (15.09.2026)
**Anlass:** Umut: „Versandadresse bei Kontaktformularen sollte man noch festlegen können – aktuell wird's über die gleiche Adresse verschickt wie der Empfänger?" Stimmte: `FormController::deliver()` setzte `From` auf die Empfängeradresse. Das geht schief, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist (z. B. eine GMX- oder Gmail-Adresse), weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt, und SPF/DMARC des fremden Anbieters die Post als Fälschung abweisen.
**Umsetzung:** Neue Formulareinstellung `sender` (freiwillig). `FormRepository::validateSettings()` prüft sie wie den Empfänger mit `FILTER_VALIDATE_EMAIL`, leer bleibt erlaubt; die Verwaltung (`forms.html.twig`, Abschnitt Zustellung, Feld „Absender“ direkt unter dem Empfänger mit Hinweis) und `FormAdminController` reichen sie durch. Beim Versand gilt `sender ?: recipient` – bestehende Formulare ohne den Schlüssel verhalten sich wie bisher, es ist keine Migration nötig. Die Antwortadresse aus dem Formularfeld bleibt `Reply-To`.
**Geprüft:** `phpunit` vollständig mit PHP 8.4 509 von 509 grün, mit PHP 8.2 509 grün mit der vorbestehenden Warnung in `UpdaterTest`; `npm test` 33 grün; `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden, darin die axe-Prüfung der Formularseite. Neu in `EditorSecurityTest`: `testAFormMaySendFromItsOwnAddress` (From = Absender, To = Empfänger, Reply-To = Feld; ungültiger Absender abgewiesen, Leerzeichen werden zu leer) und im bestehenden Absendetest die Prüfung, dass ohne Absender From = To ist – beides über `getMailerMessage()` mit dem `null://`-Transport der Tests. php-cs-fixer ohne Befund. In der Pilotkopie im Browser (`kantsites-pilot`, jetzt Port 8091, weil 8087 von einer anderen Sitzung belegt war): Feld sichtbar, `formular@kant-it.de` gespeichert und nach dem Neuladen erhalten, Meldung „Das Formular wurde gespeichert.“ Ein echter Versand über SMTP wurde nicht geprüft.
**Nächster Schritt:** Auf `web.kant-it.de` nach dem nächsten Update unter Formulare eine Absenderadresse der eigenen Domain eintragen (z. B. `formular@kant-it.de`), dann eine Testanfrage senden und im Postfach Absender und Zustellung kontrollieren.
## 1.15.0 veröffentlicht (15.09.2026)
Tag `v1.15.0` (Commit `160c394`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.0 – Formulare, Statistik, Design und Seitenleiste der Verwaltung überarbeitet, Formulare mit eigenen Farben und Vorschau.
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.0 v1.15.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
**Anhänge:** `kantsites-1.15.0.zip` (4,7 MB, SHA-256 `86ef728812db3b2f565f07dd1d8aebf8d5d41b75875695544134684b77a193ed`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.0.py`, erst Entwurf, dann veröffentlicht.
**Geprüft:** HTTP-Archiv-Update 1.14.2 → 1.15.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus, weil der saubere Checkout weder `vendor/` noch `var/kantit.db` hat); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.0`, Installeradresse 200. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare das Aussehen des Kontaktformulars wählen.
## 1.15.0 – Formulare, Statistik, Design und Seitenleiste überarbeitet (15.09.2026)
**Anlass:** Umut fand die Formularanlage unschön und wollte Felder löschen können, die Statistik „lieblos“, auf der Designseite vordefinierte Farben, eigene Farben für Link- und Seitenfarbe und eine Vorschau der Schriften, und den Fuß der Seitenleiste (Fassung, Benutzer, Abmelden) besser. Nachgeschoben: eine Vorschau des Kontaktformulars, in der Rahmen-, Hintergrund- und Buttonfarbe direkt gewählt werden.
**Formulare (`forms.html.twig`, neu `form-editor.js`).** Jedes Feld ist eine Karte mit Nummer, Beschriftung, Typ, technischem Name, Hilfetext, Pflicht-Schalter und einem Entfernen-Knopf; „Feld hinzufügen“ klont eine `<template>`-Karte. Der technische Name folgt der Beschriftung (`Größe der Fläche` → `groesse_der_flaeche`), bis jemand ihn selbst schreibt; Optionen erscheinen nur bei einer Auswahl; eine Einwilligung ist sichtbar immer Pflicht. Ohne Skript bleibt alles bedienbar: eine Karte ohne Beschriftung wird nicht gespeichert. Neu ist der Abschnitt **Aussehen**: Buttonbeschriftung, Rahmen und Hintergrund der Felder, Button- und Buttonschriftfarbe, jeweils aus der Palette oder als eigener Wert (`FormRepository::COLORS`, geprüft mit `StyleSchema::COLOR`). Daneben eine **Vorschau**, die jeder Eingabe folgt und den Absendeknopf mit dem Standardbutton der Website zeichnet (`buttonSample`). Auf der Website schreibt `FormRepository::css()` je Formular eine Regel `.block-form[data-form="…"]{…}` in den gehashten Stylesblock (`PageController::renderPage`); Felder lesen `--form-border`/`--form-bg` (`site.css`), der Button bekommt `--kb-fill`/`--kb-ink` **und** die Darstellungsvariablen neu – am Wurzelelement wären sie schon mit den alten Werten aufgelöst. Das „Neues Formular“ ist ein zugeklapptes Karten-Details am Ende.
**Statistik (`statistics.html.twig`, `Statistics::overview()`).** Sechs Kennzahlkacheln: Aufrufe mit Vergleich zum gleich langen Zeitraum davor (`previousTotal`), Schnitt je Tag, stärkster Tag mit Datum und Wochentag (`busiestDay`), aufgerufene Seiten (`pageCount`), Aufrufe über Verweise (`referred`) und nicht gefundene Adressen (`missingTotal`). Der Verlauf hat Rasterlinien, jeder Balken einen `<title>` mit Tag und Zahl, der stärkste Tag ist hervorgehoben; dazu ein neues Wochentagsdiagramm im Schnitt (`weekdays`). Die Ranglisten tragen Rang und einen Anteilsbalken (SVG-Breite, kein Inline-Stil). Der Zeitraum ist eine Leiste mit gedrücktem Knopf in der Kopfzeile; „Was diese Zahlen nicht sagen“ ist zugeklappt.
**Design (`theme.html.twig`, neu `theme-panel.js`).** Die Palette sind Farbkarten, deren Fläche der Farbwähler selbst ist, mit Hexwert, Name, Schlüssel und Entfernen. Sieben fertige Paletten (`Theme::PALETTES`: Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht; die Linkfarbe erreicht auf dem Papierton mindestens 4,5:1) ersetzen die fünf Grundschlüssel und lassen eigene Farben stehen. Schriften sind Karten mit einer Probe in der Schrift selbst (lokale `fonts.css` wird geladen, wenn vorhanden – `FontLibrary::hasStylesheet()`), Schnitte als Chips, die Auswahlen für Fließtext/Überschriften/Button folgen neuen Schriften. Text-, Seiten- und Linkfarbe nutzen das Palettenbedienelement des Editors (`createColorControl`), das bei jeder Palettenänderung neu aufgebaut wird; daneben eine Typografie-Vorschau mit Überschriftenskala, Zeilenhöhe, Farben und Unterstreichung.
**Seitenleiste (`shell.html.twig`).** Unten eine Kontokarte: Avatar, Name, Rolle (Administrator/Veröffentlicher/Redakteur), rechts ein Abmeldesymbol mit Beschriftung für Vorlesesoftware; darunter „KantSites 1.14.2“ mit dem Update-Hinweis in einer Zeile. Neue Symbole `logout`, `plus`, `remove`. Auf Tablets wischt nur noch die Liste, Konto und Abmelden bleiben rechts im Bild; dafür braucht die Rasterspalte `minmax(0, 1fr)`, sonst schob die Leiste die Seite über den Rand.
Die Formularvorschau nennt den Kontrast der Buttonschrift auf der Buttonfarbe (WCAG-Formel, grün ab 4,5:1, gelb ab 3:1, sonst rot), damit eine unlesbare Wahl auffällt, bevor sie auf der Website steht. Die `pattern`-Attribute der Schlüsselfelder maskieren den Bindestrich (`[a-z][a-z0-9\-]*`): Chrome prüft `pattern` inzwischen mit dem `v`-Flag und lehnte das alte Muster mit einem Konsolenfehler ab – die Prüfung war damit wirkungslos.
**Geprüft:** `phpunit` mit PHP 8.4 508 von 508 grün, mit PHP 8.2 508 grün mit einer vorbestehenden Warnung in `UpdaterTest` (Git-Umgebung); neu getestet: Formularfarben auf der Seite samt CSP-Hash, Ablehnung ungültiger Farben, Statistiksummen und Vorzeitraum, drei Diagramme mit Titeln. `npm run test:editor` vollständig bestanden – darin die axe-Prüfung von Formularen, Statistik und Design. `test:buttons` besteht den Designseiten-Teil (Vorlage wählen, speichern, Werte erhalten) und scheitert danach wie `test:navigation-ux` an einem Klick auf den Strukturbaum des Editors (`#structure [data-block-id=…]` „not visible“) – eine Stelle außerhalb dieser Änderung, nicht weiter untersucht. `test:admin` scheitert vor den Formularen an zwei vorbestehenden Befunden: dem Wiedergabeknopf des öffentlichen Sliders bei reduzierter Bewegung und `nested-interactive` in den Menü-Zusammenfassungen (Ziehgriff im `summary`). In der Pilotkopie im Browser: Feld hinzufügen/entfernen, Namensableitung, Farbwahl mit Vorschau und Kontrasthinweis, Speichern, Regel und Buttonfarbe auf der öffentlichen Startseite; Palette „Ozean“ übernehmen, Schrift hinzufügen, Linkfarbe wählen; Seitenleiste und Bildschirme auf 1440, 900 und 375 px. Codestil mit php-cs-fixer (verbleibende Abweichung in `tests/CmsTest.php` ist vorbestehend). Veröffentlicht als 1.15.0, siehe Eintrag oben.
## 1.14.2 veröffentlicht (15.09.2026)
Tag `v1.14.2` (Commit `0aa1ff6`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.2 – Fußzeile des Einwilligungshinweises: Einzelwahl links, Rechtslinks rechts.
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.14.2 v1.14.2`), nicht aus der Arbeitskopie: dort lagen zu diesem Zeitpunkt nicht committete Änderungen einer anderen Sitzung (`site.css`, `archive.html.twig`, `page.html.twig`, `tests/CmsTest.php`, neu `src/Twig/HeaderExtension.php`), und ein erster Build aus der Arbeitskopie hatte sie mitgenommen – der wurde verworfen. Das Archiv enthält keine vorgefertigten `assets/build/`-Bündel mehr (die Arbeitskopie hatte welche, sie entstehen beim ersten Aufruf); daher 3546 statt 3552 Dateien.
**Anhänge:** `kantsites-1.14.2.zip` (4,7 MB, SHA-256 `236dff086267591a51198b55ddfbaf2ee999db8a081ec5bb1d28752260a3db3f`), `.sha256`, `install.php`; hochgeladen mit `var/publish-release-1.14.2.py`, erst Entwurf, dann veröffentlicht.
**Geprüft:** PHP 506 Tests grün (ein erster Lauf zeigte einen Fehler, während die andere Sitzung `tests/CmsTest.php` bearbeitete; zwei weitere Läufe grün); HTTP-Archiv-Update 1.14.1 → 1.14.2 `ok`; nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.14.2`, Installeradresse 200.
## 1.14.2 – Einwilligungshinweis: Fußzeile aufgeräumt (15.09.2026)
**Anlass:** Umut fand „Einzeln wählen“ und die Rechtslinks unordentlich und schlug links/rechts vor.
**Umgesetzt (`consent-banner.html.twig`, `site.css`):** Beide stehen in einer Zeile `.consent-foot` – Einzelwahl links, Datenschutz · Impressum rechts, auf gemeinsamer Grundlinie. Aufgeklappt nimmt die Einzelwahl die volle Breite (`[open]{flex-basis:100%}`), die Links rutschen rechtsbündig darunter. Die Links stehen ohne Punkt dazwischen mit 14 px Abstand; ohne beide Adressen entfällt die Zeile. Ein Stolperstein: `.consent-bar p` schlug `.consent-links` und nahm den rechten Rand weg – der Selektor heißt jetzt `.consent-bar p.consent-links`.
**Geprüft:** vier Gestaltungsvarianten auf 1440 und 390 px, zu- und aufgeklappt (Screenshots `reports/consent-look/*-offen.png`), sechs Einwilligungstests grün. Veröffentlicht als 1.14.2, siehe Eintrag oben.
## 1.14.1 veröffentlicht (15.09.2026)
Tag `v1.14.1` (Commit `0bdcc7f`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.1. Enthält den gestaltbaren Einwilligungshinweis, den bei offener Frage erreichbaren Nach-oben-Knopf, den korrigierten Abstand unten und die orangen Beschriftungen im Editor; dazu `860bed9` (Seed-Skript Tagespflege Moldovan, nicht im Archiv).
**Anhänge:** `kantsites-1.14.1.zip` (4,7 MB, 3552 Dateien, SHA-256 `c0620a3155db894373199dbe2e286744dd7b21eed2c355e8d2a890652d1d203e`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.14.1.py`, erst Entwurf, dann veröffentlicht.
**Geprüft:** PHP 505 Tests grün; `browser-check.mjs`, `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.14.0 → 1.14.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200). Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich, `/releases/latest` nennt `v1.14.1`, Installeradresse antwortet 200.
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, Migrationen abschließen (keine neuen), dann unter Einstellungen → Einwilligung das Aussehen wählen; `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
## 1.14.1 – Einwilligungshinweis gestaltbar, Nach-oben-Knopf bei offener Frage, orange Beschriftungen (15.09.2026)
**Anlass:** Umut wollte das Banner gestaltbar (rund, Farben) und schöner, den Abstand unten korrigiert, belegte Eigenschaften im Editor orange statt nur fett mit Punkt, und Gerätechips, die sich „aktiv überlagern“.
**Einwilligungshinweis (ADR 097).** Sieben neue Einstellungen: `consentLayout` (karte/leiste/ecke), `consentShape` (weich/rund/eckig), `consentTitle`, `consentColor`, `consentInk`, `consentAccent`, `consentAccentInk`; Farben mit `StyleSchema::COLOR` geprüft, Palette über `Theme::cssColor()` (neu, ersetzt die Kopie im Nach-oben-Knopf). `Consent::styles()` schreibt `:root{--kant-consent-…}` in den gehashten Stylesblock; die Anordnung ist eine Klasse. Neues Grundlayout: Streifen und Symbol in der Knopffarbe, gleich breite Knöpfe, aufklappbares „Einzeln wählen“ mit Pfeil, unterstrichene Rechtslinks; Leiste auf breiten Bildschirmen zweispaltig. Archiv und `/einwilligung` bekommen Theme- und Hinweisvariablen als gehashten Stylesblock – das Archiv trug bisher gar keine Palette.
**Abstand unten:** 320 px bis 620 px (Leiste bis 299 px hoch), 270 px bis 899 px, 200 px darüber; die Fläche scrollt ab `100dvh − 24px` in sich. Nach-oben-Knopf hebt sich bei Leiste und Ecke auf jeder Breite (`:has()`), bei der Karte bis 799 px.
**Editor:** belegte Beschriftungen in `--used` (#9d4e0d, 5,6:1); Chips mit 6 px Lücke, Fokusring ohne Versatz, Gruppe `flex:none`. Ein Überlagern der Chips ließ sich in 1.14.0 nicht reproduzieren – am Handy (390 px) liegen die drei Chips bei 22 px Breite mit 6 px Abstand, kein `outline` gezeichnet; die Rückmeldung bezog sich wohl auf den `outline`-Ring von 1.13.0.
**Geprüft:** PHP 505 Tests grün, darunter neu `testTheConsentQuestionTakesLayoutShapeAndColoursFromTheSettings` (Standard, eigene Werte, unbekannter Palettenschlüssel fällt weg, Einwilligungsseite mit Hash, ungültige Farbe abgewiesen). Chromium: vier Varianten (Karte, Leiste, Ecke rund dunkel, Karte eckig dunkel) auf 1440 und 390 px – Klasse, Radien, Farben und Überschrift stimmen, Nach-oben-Knopf sichtbar, treffbar, ohne Überlappung; `/einwilligung` 200 mit gehashtem `style-src`. Nach-oben-Prüfung auf acht Breiten bestanden. `browser-check.mjs`, `editor-check.mjs` (Einstellungen, Einwilligung) und axe über die Editorseitenleiste bestanden. Screenshots: `reports/consent-look/`, `reports/admin-hints/`.
**Nicht geprüft:** echtes iOS-Safari; der `@supports not`-Zweig; Lighthouse.
## Nach-oben-Knopf bei offener Einwilligungsfrage (15.09.2026)
**Anlass:** Umut meldete, der Nach-oben-Knopf funktioniere auf dem Handy nicht.
**Befund.** Der Knopf selbst funktioniert auf dem Telefon: In iPhone- und Pixel-Emulation (Chromium), lokal wie auf `web.kant-it.de` (dort bereits 1.14.0 mit `scrolled.js`), erscheint er nach 500 px, ist treffbar, und Tippen springt nach oben – **sobald die Einwilligungsfrage beantwortet ist**. Vorher galt `body.consent-open .scroll-top{display:none}` auf allen Geräten. Live trägt jeder Erstbesuch `consent-open`, und auf dem Telefon bleibt die Leiste unten oft einfach stehen – der Knopf kam dort nie. Kein Fehler im Skript und nicht der alte Safari-Fehler aus 1.13.0.
**Korrektur (`site.css`, `page.html.twig`).** Der Knopf bleibt bei offener Frage sichtbar. Gemessen deckt die Leiste die rechte Ecke nur bis knapp 800 px ab (bis 620 px volle Breite, 262–285 px hoch; darüber zentriert mit höchstens 620 px). In dieser Stufe setzt sich der Knopf per Ankerpositionierung 12 px auf die Oberkante der Leiste (`anchor-name:--kant-consent`, `bottom:calc(anchor(top) + 12px)`), unabhängig davon, wie hoch ihr Text umbricht. Browser ohne Ankerpositionierung blenden ihn dort weiter aus (`@supports not`). Ab 800 px bleibt er an seinem Platz. Weil ein Anker im Dokument vor dem positionierten Element stehen muss, gibt die Vorlage die Leiste jetzt vor dem Knopf aus.
**Geprüft:** Chromium, isolierte Kopie der Pilotdatenbank, Einwilligung offen auf 360, 390, 700, 768, 799, 800 und 1440 px sowie beantwortet auf 390 px – in allen acht Fällen sichtbar, keine Überlappung mit der Leiste, treffbar, nach dem Tippen `scrollY` 0. Twig-Lint gültig. PHP-Tests 504 grün, `browser-check.mjs` und `editor-check.mjs` (einschließlich Einwilligungsabläufe) bestanden.
**Nicht geprüft:** echtes iOS-Safari – WebKit ist für Playwright auf diesem Rechner nicht installiert. Der `@supports not`-Zweig wurde nicht ausgeführt, weil Chromium Ankerpositionierung kann.
**Nebenbefund, nicht geändert:** Bis 620 px reserviert `body.consent-open` 260 px Abstand unten, die Leiste ist mit Standardtext bis 285 px hoch – auf kleinen Telefonen liegen die letzten rund 25 px der Seite unter der offenen Leiste.
**Nächster Schritt:** nicht committet und nicht veröffentlicht; live erst mit einem Release (z. B. 1.14.1).
## 1.14.0 veröffentlicht (15.09.2026)
Tag `v1.14.0` (Commit `a434e0c`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.0. Enthält die Schriften vom eigenen Server, den Hinweisstreifen, System und Update auf dem Telefon, die Gerätechips sowie Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten.
**Anhänge:** `kantsites-1.14.0.zip` (4,7 MB, SHA-256 `8360566b1eaed670b29f506262953d3b17f95893e23c7f4a940ff6d5e3f62735`), `.sha256`, `install.php` mit eingetragener Prüfsumme. Hochgeladen mit `var/publish-release-1.14.0.py` über den Git-Zugang dieses Rechners, erst als Entwurf, dann veröffentlicht.
**Geprüft:** 504 PHP-Tests grün; `browser-check.mjs` und `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.12.1 → 1.14.0 `ok`. Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich mit den lokalen Dateien, `/releases/latest` nennt `v1.14.0`, die Installeradresse `…/releases/download/v1.14.0/kantsites-1.14.0.zip` antwortet mit 200.
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, danach Migrationen abschließen und `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
## 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.
**Ergebnis:** Lighthouse-Mediane auf vier Seiten, Mobil und Desktop: alles 100, nur die mobile Startseite hat Leistung 99 (LCP 2,1 s durch das Hero-Foto). axe auf sechs Seiten in zwei Breiten: null Verstöße. Details und Messbedingungen in `docs/QUALITY.md`.
**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.
**Soziale Profile (Dokumentschema v23, ADR 093).** Unter Einstellungen → Unternehmensdaten gibt es je Netzwerk ein Adressfeld (`SocialProfiles::NETWORKS`: Facebook, Instagram, LinkedIn, YouTube, TikTok, X, Threads, Pinterest, Xing, WhatsApp, Google-Unternehmensprofil; nur HTTPS). Der neue Baustein `social` (Werkzeugkasten → Bausteine → „Soziale Profile“) zeigt sie und bestimmt Größe (die 15 Symbolstufen), Form (nur Symbol, runde oder eckige Fläche), Anordnung, Beschriftung, neuen Tab und optional eine Teilmenge der Netzwerke; Farbe, Hover-Farbe, Abstand und Ausrichtung kommen aus den freien Blockstilen. Die Vorlage liest die Profile über das Twig-Global `siteSettings`, ohne Profile gibt sie nichts aus und zeigt im Editor einen Hinweis. Die Adressen stehen automatisch in `sameAs` der strukturierten Daten. Symbole sind eigene Umrisszeichnungen im Strich des Symbolsatzes. Neue Dateien `src/Content/SocialProfiles.php`, `templates/blocks/social-icon.html.twig`; Änderungen an Validator, Einstellungen, Controller, Vorlagen, `site.css`, Editor (Modell, Eigenschaften, Bibliothekssymbol).
**Zur Frage nach dem transparenten Menü:** Nein, im ersten Nachbau war der Kopf weiß. Jetzt nutzt die Website den Mechanismus aus 1.11.1: `headerOverlay` und `stickyHeader` an, Kopfsektion mit `#ffffff00` als Hintergrund und Weiß mit Schatten `scrolled`. Das Logo (schwarze Schrift, transparenter Grund) sitzt auf einer weißen Fläche, damit es über dem dunklen Foto lesbar bleibt.
**Elektro Bernhard neu gestaltet** (`scripts/seed-elektro-bernhard.php`, vollständig neu geschrieben): dunkler Hero mit dem Teamfoto als Hintergrund und Überlagerung, Lato aus dem lokalen Schriftordner, Versal-Kleinzeilen in Rot, Leistungskarten mit Symbolfläche, Schatten und Hover-Lift, Foto mit starkem Schatten neben Text und vier Stärken, Kundenstimmen als Slider mit sechs echten Rezensionen (Punkte, Automatik 7 s), dunkles Solarband mit dunkler Karte, Akkordeon in weichen Kästen, Formular als weiße Karte, jede Unterseite mit dunklem Seitenkopf unter dem schwebenden Kopfbereich, Handlungsaufruf als dunkle Karte, Einblendungen mit gestaffelten Verzögerungen auf allen Abschnitten. Soziale Profile in der Kopfleiste (16 px, nur Symbol, auf dem Telefon ausgeblendet) und im Fuß (20 px, runde Fläche, Hover rot). „Nach oben“-Knopf in Rot. Keine Änderung am CMS-Code außer dem Baustein.
**Geprüft:** PHP-Suite 492 Tests, davon vier neue für die Profile (Darstellung, Teilmenge, Beschriftung, neuer Tab, sameAs, Abweisung falscher Werte, HTTP-Adresse); einziger Fehlschlag ist der bekannte Altfehler `testAnyPublishedPageCanBecomeTheRoot…`. 33 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung (Fixer angewendet). Editor-Browserprüfung vollständig erfolgreich einschließlich axe. In der App-Vorschau: Startseite von oben bis unten, Kopf transparent und nach Scrollen weiß, Leistungsseite, Einstellungen mit den elf Profilfeldern, Fußbereich im Editor mit geöffnetem Eigenschaftenfeld des Bausteins (Größe, Form, Anordnung, Beschriftung, Tab, Netzwerke, Sichtbarkeit); Mobilansicht 375 px mit Hamburger, Kopfleiste einzeilig. Alle Adressen 200, `/gibtsnicht` 404, `sameAs` mit drei Profilen.
**Veröffentlicht:** Commit `fd4cb75` und Tag `v1.12.0` auf `origin`; [Release 1.12.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.12.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.12.0`), die drei Anhänge anonym von der Releaseadresse geladen und bytegenau mit den gebauten Dateien verglichen; SHA-256 `5cc745f6…d5195e`. Während dieser Arbeit hatte eine parallele Sitzung 1.11.3 und 1.11.4 veröffentlicht und `VERSION` zurückgesetzt; 1.12.0 folgt darauf, weil ein neuer Baustein und ein neues Schema kein Patch sind.
## Tagespflege Moldovan – getrennte Installation vorbereitet (14.09.2026, Nachbau offen)
Auftrag: ZIP-Vorlage exakt nachbauen, eigene Datenbank und Medien; ausdrücklich keine CMS-Codeänderung, Inhalte und Gestaltung ausschließlich über die vorhandene Editoroberfläche bearbeitbar aufbauen.
Vorlage gefunden unter `C:/Users/UmutKantarci/AppData/Local/Temp/codex-file-preview-oZxj9Z/Tagespflege_Moldovan_Website.zip`, sicher nach `var/tagespflege-moldovan-source` entpackt. Enthält einen Onepager, CSS/JS, zwei Fotos und `marke.svg`; die unbenutzte Datei `Schmetterling_Moldovan.png` ist leer. Formular bereitet nur eine Mailto-Nachricht vor, Rechtstexte fehlen in der Vorlage.
Angelegt: `var/tagespflege-moldovan.db`, alle 15 Migrationen erfolgreich; separates `var/tagespflege-moldovan-media`; Startkonfiguration `tagespflege-moldovan` auf Port 8089. Über die Installationsoberfläche Website-Grundgerüst angelegt. Bestehende `.env` nach den Installeränderungen bytegleich aus der vorher angelegten Sicherung wiederhergestellt; die neue Installation verwendet Prozessvariablen. Keine Änderungen am CMS-Code durch diese Aufgabe.
Noch offen: Erstes Administratorkonto im geöffneten Browser unter `http://127.0.0.1:8089/install/konto` fertigstellen und anmelden. Automatische Freigabeprüfung wies zuvor den kombinierten Shellbefehl für Kontoanlage und Serverstart ab (keine genauere Begründung); der verworfene Setuphelfer wurde entfernt und nicht ausgeführt. Server separat erfolgreich gestartet. Danach den eigentlichen Nachbau vollständig im Editor durchführen, Medien hochladen, Desktop/Mobil vergleichen und Speichern/Neuladen prüfen. **Noch kein Vorlagennachbau und keine visuelle Abnahme.**
## 1.11.4 – Slider-Steuerung auf dem Bild wie bei kant-it.de (14.09.2026)
**Anlass:** Umut wollte nicht die Knopfleiste unter dem Slider, sondern das Design des Hero-Sliders auf www.kant-it.de (eigenes Plugin, kein Flatsome-Slider): runde weiße Pfeile auf dem Bild, Punkte unten rechts mit aktivem Strich in der Akzentfarbe, keine Zählerleiste, Pfeile auf dem Handy aus.
**Umgesetzt:** `slider.js` legt Slider und Steuerung in eine `.slider-shell` (beim Aufräumen wieder entpackt) und benennt Pfeile und Pause (`slider-prev/next/play`, `data-playing`); `site.css` zeichnet die Steuerung als Ebene über dem Bild: Pfeile 50 px rund mit Chevron, Punkte 9 px (aktiv 28 px Strich), Pause 32 px unten rechts, Zähler nur noch für Vorlesesoftware, Pfeile unter 600 px ausgeblendet. Neue Gestaltungseigenschaft „Aktiver Punkt“ (`sliderDotActiveColor`, `--slider-dot-active`). Der Compiler-Selektor `#id + .slider-controls` gilt in der Shell weiterhin.
**Geprüft:** Playwright auf der Pilotkopie bei 1600 und 390 px: Pfeile mittig links/rechts (Desktop) bzw. ausgeblendet (Mobil), Punkte und Pause unten rechts innerhalb des Bildes, Screenshots `reports/editor/slider-design-*.png` visuell geprüft. Zwei gezielte PHP-Tests, 33 JavaScript-Tests, Editor-Browserprüfung vollständig erfolgreich.
**Veröffentlicht:** Commit `cef710d` und Tag `v1.11.4` auf `origin`; [Release 1.11.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.4). Weil parallel die Arbeit an „Soziale Profile“ (1.12.0) im selben Arbeitsbaum lief, wurden nur die eigenen Hunks committed und das Archiv (3544 Dateien, 4,6 MB) aus einem sauberen Worktree des Tags gebaut; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `53904dea…9a663b`.
## Elektro Bernhard: Abgleich mit dem bestehenden Auftritt, SEO nachgezogen (14.09.2026)
**Anlass:** Umut bat darum, den Nachbau gegen die laufende Website `elektro-bernhard.de` zu halten und SEO nachzuholen.
**Dabei zwei Falschangaben gefunden und korrigiert.** Das gelieferte HTML-Paket ordnete zwei Google-Rezensionen falsch zu: „Super Firma und einfach tolle Mitarbeiter“ stammt von **Redzepi Samir** (26.10.2024), nicht von Philipp Bender; Philipp Bender (26.11.2024) hat die Architekten-Rezension geschrieben. Der dritte Eintrag des Pakets war eine selbst formulierte Zusammenfassung statt der tatsächlichen Worte. Die Zuordnung ist am DOM der Live-Seite geprüft (`.wprevpro_t1_DIV_1`: erst Text, dann Name, dann Datum), nicht aus der flachen Textfassung geraten. Jetzt stehen drei Rezensionen im Originalwortlaut mit richtigem Namen und Datum; der Hinweis darunter nennt den Stand und sagt ausdrücklich, dass kein Gesamtdurchschnitt ausgewiesen wird.
**Aus dem bestehenden Auftritt nachgeholt:** die sechs Stärken („Kundenzufriedenheit“, „Qualität“, „Erfahrung“, „Flexibel“, „Professionell“, „Schnell & sauber“), die das Paket zu drei Absätzen Fließtext zusammengezogen hatte – jetzt als Symbolkarten auf der Leistungsübersicht, die dadurch von 8 auf 23 Bausteine wächst. Dazu der Handlungsaufruf „Projekt geplant? Wir sind bereit.“ als `cta`-Baustein auf der Übersicht und auf allen vier Leistungsseiten. Das Einsatzgebiet nennt jetzt auch Kuppenheim, Gaggenau, Gernsbach und Muggensturm – Orte, die der bestehende Auftritt in seinen eigenen Keywords führt.
**SEO:** `sameAs` mit den drei Profilen, die die Live-Seite selbst verlinkt (Google-Unternehmensprofil, Facebook, Instagram) – sie stehen jetzt in den strukturierten Daten. Unternehmensbeschreibung auf Leistungen, Notdienstnummer und Bürozeiten erweitert; sie ist zugleich die Zusammenfassungszeile in `llms.txt`. Vorschaubild beim Teilen gesetzt (Startseite Teamfoto, Leistungsseiten Installationsmotiv), womit `og:image` und `twitter:card: summary_large_image` ausgeliefert werden statt einer Textkarte. Meta-Beschreibungen liegen zwischen 110 und 159 Zeichen.
`llms.txt` gab es schon seit dem Aufbau (Einstellung `llmsTxt`); es listet alle zehn indexierbaren Seiten mit Beschreibung.
**Bewusst nicht per Skript gesetzt:** der Auszug (`excerpt`) je Seite. Das Feld erscheint in der Verwaltung nur bei Beiträgen; ein per SQL geschriebener Wert wäre unsichtbar und würde beim nächsten Speichern der Seitendaten stillschweigend gelöscht. `llms.txt` nimmt ersatzweise die Seitenbeschreibung, und die ist im Editor bearbeitbar.
**Offene Lücke gegenüber dem HTML-Paket:** dessen JSON-LD enthielt `openingHoursSpecification`, `areaServed`, einen `contactPoint` für den Notdienst sowie `FAQPage` und `Service`. Das CMS kennt diese Felder nicht – es liefert `LocalBusiness` (Name, Adresse, Telefon, E-Mail, `sameAs`) und `BreadcrumbList`. Das nachzurüsten ist eine Kernerweiterung (Einstellungen, `SiteSettings::organisation()`, FAQ aus Akkordeons ableiten) mit eigenem Release, nicht eine Änderung an dieser Website. Noch nicht umgesetzt, nicht beauftragt.
**Geprüft:** Alle zehn Adressen 200, `/gibtsnicht` 404, `llms.txt` und `sitemap.xml` ausgeliefert; strukturierte Daten, `og:image` und `twitter:card` im Quelltext kontrolliert; Leistungsübersicht, Leistungsseite und Kundenstimmen in der App-Vorschau angesehen; keine Konsolenfehler. Das größte Dokument (Startseite) liegt bei 117 Bausteinen und 27 KiB – Grenze sind 300 und 256 KiB.
## 1.11.3 – Farben der Slider-Steuerung (14.09.2026)
**Anlass:** Umut will die Punktfarben am Slider einstellen; auf dem dunklen Hero waren die Standardfarben (dunkle Punkte, weiße Knöpfe) nicht anpassbar.
**Umgesetzt:** Drei Gestaltungseigenschaften in der neuen Gruppe „Slider-Steuerung“ (`sliderDotColor`, `sliderControlColor`, `sliderControlBackground`) mit dem Ziel `controls`: der Compiler schreibt sie als `--slider-dot`, `--slider-ink`, `--slider-bg` auf den Slider und auf die vom Skript dahinter eingefügte Steuerleiste (`#id,#id + .slider-controls{…}`); `site.css` liest die Variablen für Punkte, Pfeile, Zähler, Knopfhintergrund und die skriptfreien `::scroll-marker`. Die Gruppe erscheint nur an Slidern (`relevantProperty`).
**Geprüft:** Neuer Test `testSliderControlsTakeTheirColoursFromTheSliderStyle` (Ausgabe mit Hex- und Palettenfarbe). Playwright im Editor der Pilotkopie: Gruppe am Hero-Slider vorhanden, Punktfarbe `#ff0000` gesetzt, Regel in der Vorschau angekommen, Punkt bei „Bewegung abspielen“ rot gezeichnet. 33 JavaScript-Tests, Editor-Browserprüfung vollständig erfolgreich.
**Veröffentlicht:** Commit `6dc30a4` und Tag `v1.11.3` auf `origin`; [Release 1.11.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.3) mit ZIP (3548 Dateien), SHA-256-Datei und `install.php` als neuestes stabiles Release; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `a9536289…f45a24`. Der Commit enthält auch den parallel entstandenen Statuseintrag zur Elektro-Bernhard-Referenzwebsite; deren Skripte sind noch nicht committed.
## Zweite Referenzwebsite: Elektro Bernhard in eigener Datenbank (14.09.2026)
**Anlass:** Umut hat das fertige statische Paket `Elektro-Bernhard-Website` (HTML/CSS/JS, erstellt 12.09.2026) geliefert mit dem Auftrag, es vollständig im CMS nachzubauen – eigene Datenbank, eigener Medienordner, im Editor bearbeitbar.
**Umgesetzt:** Neue Installation neben dem Kant-IT-Piloten, nach demselben Muster: Datenbank `var/elektro-bernhard.db` (alle 15 Migrationen), Medien in `var/elektro-bernhard-media`, Startkonfiguration `elektro-bernhard` auf Port 8088 in `.claude/launch.json`. Aufgebaut wird sie von `scripts/seed-elektro-bernhard.php` (Neuaufbau mit `scripts/reset-elektro-bernhard.sh`), damit der Bestand reproduzierbar ist und nicht von Hand zusammengeklickt werden muss.
Inhalt: elf Seiten (Startseite, Leistungsübersicht mit vier Leistungsseiten, Kontakt, Solarenergie, Impressum, Datenschutz, 404), Kopf- und Fußbereich als gemeinsame Bereiche, vier Menüs (Haupt, Notdienst, Fuß, Rechtliches), Kontaktformular mit sechs Feldern, drei Bilder aus dem Original in der Mediathek, Palette und Schrift der Vorlage im Design, drei Weiterleitungen. Alles besteht aus vorhandenen Bausteinen – Abschnitt, Zeile, Spalte, Überschrift, Text, Bild, Symbolkarte, Kennzahl, Zitat, Akkordeon, Kontakt, Formular, Brotkrumen, Menü, Rechtszeile, Trennlinie. Kein neuer Bausteintyp, keine Änderung am CMS-Code.
**Drei Stellen weichen bewusst ab, weil das Schema sie so verlangt:**
- `/leistungen/…` braucht im CMS eine übergeordnete Seite; die Vorlage hatte nur den Anker `/#service`. Es gibt deshalb zusätzlich eine Leistungsübersicht unter `/leistungen`, und `/service` leitet dorthin.
- Eine Weiterleitung zeigt auf einen Pfad, nicht auf ein Sprungziel: `/ueber-uns` landet auf der Startseite, die den Abschnitt trägt, statt auf `/#ueber-uns`.
- Ein Button verlinkt nur lokale Pfade, Sprungziele und HTTPS. Die Telefonnummern der Vorlage sind deshalb Kontakt-Bausteine (mit `tel:`-Verweis), gestaltet wie der zweite Button der Vorlage.
Für drei Feinheiten, die die Gestaltungsfelder nicht erreichen (Symbolfarbe in der Karte, Sternfarbe im Zitat, Pluszeichen im Akkordeon), steht je zwei Zeilen unter „Eigenes CSS“ am Baustein – im Editor sichtbar und änderbar.
**Geprüft:** Alle elf Adressen antworten mit 200, `/gibtsnicht` mit der eigenen 404-Seite, die drei Weiterleitungen mit 301 auf das erwartete Ziel; `sitemap.xml`, `robots.txt` und `llms.txt` werden ausgeliefert. In der App-Vorschau Startseite von oben bis unten mit der Vorlage verglichen (Hero, Leistungen, Über uns mit Kennzahlen, Marken, Kundenstimmen, Solar, Fragen, Kontakt, Notdienststreifen, Fuß), dazu Leistungsseite und Impressum; auf 375 px Kopfleiste ohne Adresse und Hamburger-Menü mit den vier Originaleinträgen. Im Editor ist die Startseite geladen, der Strukturbaum zeigt den vollständigen Baum, ein Textbaustein lässt sich auswählen und über das Eigenschaftenfeld bearbeiten. Keine Konsolenfehler auf der öffentlichen Seite.
**Nicht geprüft:** Kein Formularversand (keine Mailzustellung lokal), keine Lighthouse-Messung, kein Livegang. Die Rechtstexte stammen aus der Vorlage; im Datenschutz ist der Abschnitt zum Formular auf den Serverversand des CMS umgeschrieben und als „NOCH ZU PRÜFEN“ markiert, weil Hoster und Aufbewahrungsdauer offen sind.
## 1.11.2 – Slider stottert nicht mehr auf dem iPhone (14.09.2026)
**Anlass:** Umuts iPhone-Screenshot des Hero-Sliders („das buggt“). Safari bricht `scrollTo({behavior:'smooth'})` in einem Container mit `scroll-snap-type: x mandatory` ab und rastet auf die vorige Folie zurück; Pfeile und Automatik ruckeln.
**Umgesetzt:** `slider.js` schaltet das Einrasten für die Fahrt aus und stellt es bei Ankunft am Zielwert wieder her (spätestens nach zwei Sekunden, weil ein Hintergrund-Tab sanftes Scrollen pausiert); bei reduzierter Bewegung weiter sofortiges Springen. Die Punkte der Steuerung sind gezeichnete Punkte (aktiv gefüllt) statt eines Kastens um den Text, Trefffläche bleibt 44 px.
**Geprüft:** Playwright auf der Pilotkopie bei 375 und 1600 px: „Nächste Folie“ landet exakt auf dem Folienanfang (359 bzw. 666 px), Einrasten danach wieder `x mandatory`, Automatik blättert nach 7 s selbst weiter; 33 JavaScript-Tests. Der Safari-Fall selbst ist hier nicht ausführbar; die Maßnahme ist die dokumentierte Umgehung dieses WebKit-Verhaltens. Editor-Browserprüfung vollständig erfolgreich.
**Veröffentlicht:** Commit `769b0fa` und Tag `v1.11.2` auf `origin`; [Release 1.11.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.2) mit ZIP (3549 Dateien), SHA-256-Datei und `install.php` als neuestes stabiles Release; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `548e1bf0…ba4a07`.
## 1.11.1 – Kopfbereich mit Scroll-Farben, gleich hohe Karten (14.09.2026)
**Anlass:** Umut sah auf der Startseite ungleich hohe Karten und wünschte sich für den Kopfbereich einstellbare Transparenz-Effekte beim Scrollen, wie in Flatsome.
**Umgesetzt:** (1) `.block-iconcard + .block-iconcard` und die verwandten Stapelabstände gelten in `.block-row` nicht mehr; die Karten in einer Zeile sind gleich hoch. (2) Neuer Zustand `scrolled` neben `hover` in der Gestaltung: `StyleSchema::scrollable()` (Hintergrund-, Text-, Rahmen-, Schattenfarbe, Deckkraft), Prüfung im `StyleCompiler`, Ausgabe als `html.is-scrolled #id{…}` mit Übergang; Design-Panel zeigt „… beim Scrollen“ nur im Kopfbereich; `scrolled.js` markiert die Seite ab 40 px, wird nur geladen, wenn der Kopfbereich Scroll-Werte hat (Seiten und Archive, CSP entsprechend); die Editor-Vorschau setzt die Klasse selbst. Neue Einstellung „Kopfbereich über dem Inhalt schweben lassen“ (`headerOverlay`, Klasse `is-overlay`, absolut bzw. fixiert mit „oben halten“). ADR 092.
**Geprüft:** Neuer Test `testAHeaderCanChangeItsColoursOnceThePageHasScrolled` (Ausgabe, Skript, CSP, Ablehnung unzulässiger Scroll-Werte); PHP-Suite 485 Tests grün bis auf den bekannten Altfehler. In der App-Vorschau: sechs Leistungskarten 227 px, Kundenstimmen 232 px, „Warum“-Karten 191 px, gestapelte Karten behalten 18 px. Scroll-Farbe im Kopfbereich-Editor über das Farbfeld gesetzt, in der Vorschau als Regel angekommen und gespeichert. 33 JavaScript-Tests, Twig-Lint aller 49 Vorlagen, PHP-Stilprüfung. Editor-Browserprüfung vollständig erfolgreich, einschließlich aller axe-Prüfungen.
**Veröffentlicht:** Commit `3785d3d` und Tag `v1.11.1` auf `origin`; [Release 1.11.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.1) mit ZIP (3547 Dateien, 4,7 MB), SHA-256-Datei und `install.php` als neuestes stabiles Release angelegt. Anhänge anonym heruntergeladen und bytegenau mit dem lokalen Build verglichen; SHA-256 `970a12bc…6ff306`.
## Kant-IT-Startseite: Einblendungen nach Flatsome-Muster in `var/kantit.db` (14.09.2026)
**Umgesetzt:** Auf Umuts Wunsch das Animationsmuster der alten Flatsome-Website (www.kant-it.de: fadeInLeft/Right im Hero, bei „Warum“ und Kontakt, gestaffeltes fadeInUp auf Zahlen, Karten und Fußzeilenspalten) in der lokalen Kant-IT-Datenbank nachgebaut: 55 Blöcke der Startseite umgestellt – Sektionen ohne eigene Einblendung, Hero-Text von links, Slider-Spalte von rechts, Kennzahlen und Karten gestaffelt von unten, „Warum“-Texte und -Karten von links, Kundenstimmen von rechts, Kontaktdaten links und Formular rechts – sowie die vier Fußzeilenspalten gestaffelt von unten. Als Entwurf gespeichert und veröffentlicht (Revision `155e2934…`), Sicherung `var/kantit.db.vor-bewegung-*.bak`. Umut kopiert die Datei selbst auf den Server; `web.kant-it.de` läuft bereits auf 1.11.0 (Fassung, `motion.js`, Hover und Einblendungen live geprüft).
**Geprüft:** `scripts/motion-check.mjs` (Ziel jetzt `#abschluss-cta`, weil die Sektion `#kontakt` keine eigene Einblendung mehr trägt) auf frischer Pilotkopie bei 1600/390 px erfolgreich: 45 Einblendungen gerendert (30 von unten, 11 von links, 4 von rechts), Warten außerhalb des Bildes, einmalige Wiedergabe, Richtungen, Hover, Standardmodus unter „reduce“, `motion-respect`. Keine JavaScript-Fehler in der App-Vorschau.
## 1.11.0 – Editor mit einer Seitenleiste nach Flatsome-Vorbild, Bewegung spielt standardmäßig (14.09.2026)
**Anlass:** Umut fand den Editor mit drei Panels unübersichtlich und kam mit dem Flatsome UX Builder zurecht; außerdem liefen die Einblendungen in seinem Chrome nicht, weil der Browser „Bewegung reduzieren“ meldet, obwohl er das nicht eingestellt hat – andere Websites bewegen sich bei ihm.
**Umgesetzt:** Editor-Gerüst neu (`templates/admin/editor.html.twig`, `editor.css`, `editor.js`): dunkle Kopfzeile mit Gerätewahl in der Mitte, eine Seitenleiste mit den Reitern Elemente/Struktur/Seite und einer Elementansicht (Name, Pfad, Inhalt/Design/Erweitert, Aktionen unten, ‹ zum Elternelement, + zum Einfügen, ✕ zum Schließen), Elementkarten im Raster, Kontextmenü per Rechtsklick in der Vorschau, Escape/Entf-Tasten auch bei Fokus in der Vorschau, „Bearbeiten“ neben dem gewählten Strukturknoten. Panelbreiten und der Werkzeugkasten-Umschalter entfallen. Sichtbarkeit am Ende von Inhalt und Erweitert. Bewegung: Website-Einstellung `motion` (`always` Standard, `respect`), Klasse `motion-respect` auf `<html>`, `motion.js` versteckt wartende Blöcke und blendet sie von unsichtbar ein, zeigt übersprungene Blöcke sofort; Hover-CSS ohne Medienschranke, Überschreibblock nur für `motion-respect`; `slider.js` folgt derselben Regel. ADR 091.
**Geprüft:** `scripts/motion-check.mjs` auf isolierter Pilotkopie (1600/390 px) erfolgreich: Warten außerhalb des Bildes, Start beim Scrollen, einmalige Wiedergabe, Richtungen, gespeicherter Hover, Standard spielt unter emuliertem „reduce“, `motion-respect` unterdrückt und nimmt wieder auf, Editorhinweis nur bei `motion-respect`. In der App-Vorschau mit realen Kant-IT-Daten: Auswahl, Design-Reiter, Kontextmenü, +-Einfügen (Zeile → nur Spalte erlaubt, Spalte → Überschrift), Entf, Strg+Z, Tablet-Ansicht, öffentliche Startseite mit 14 Einblendungen (7 warten, alle sichtbar nach Sprung ans Seitenende), keine JavaScript-Fehler. 33 JavaScript-Tests, Twig-Lint der geänderten Vorlagen, angepasster CSS-Test in `CmsTest`. `scripts/editor-check.mjs` auf die eine Seitenleiste umgestellt (Reiter-Helfer, Bearbeiten im Baum, Flatsome-Modus in der Sandbox) und vollständig erfolgreich, einschließlich aller axe-Prüfungen (Desktop- und Mobil-Editor, Mediathek, Verwaltungsseiten), Entwurf/Veröffentlichung, Konflikt und Einwilligung.
**Bekannt / Grenzen:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt bereits ohne diese Änderungen fehl (Menülink `Start` fehlt) – nicht Teil dieses Schritts. Kein neuer Lighthouse-Lauf. Die Ursache der Browsermeldung in Umuts Chrome bleibt offen; die Website hängt nicht mehr davon ab.
**Veröffentlicht:** Commit `a1f8b90` und annotierter Tag `v1.11.0` auf `origin`; [Release 1.11.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.0) mit ZIP (3549 Dateien, 4,7 MB), SHA-256-Datei und `install.php` als neuestes stabiles Release über die API angelegt. Alle drei Anhänge anonym unter `releases/download/v1.11.0/` heruntergeladen und bytegenau mit dem lokalen Build verglichen; SHA-256 `49338e77…63ca56` stimmt mit der Datei und dem Installer überein. Die lokale Startkonfiguration `.claude/launch.json` für die Pilotkopie ist nicht committed.
## 1.10.2 – Fehler 500 beim Abschluss des Archiv-Updateaufrufs (14.09.2026)
**Reproduziert und korrigiert:** Echter Produktionsmodus mit den vollständigen Paketen 1.10.0 → 1.10.1: Update-POST HTTP 500 nach erfolgreichem Dateitausch, fehlende PHP-Dienste im gerade gelöschten Containercache. Systemseite anschließend HTTP 200, Migrationsabschluss HTTP 302, öffentliche Seite danach HTTP 200. Ursache und Übergang: ADR 090. Der Kernel hält nun seinen Cachepfad pro Anfrage fest und verwendet für jede Releasefassung ein eigenes Verzeichnis. Der Archiv-Updater entfernt keine Dateien, die die laufende Anfrage noch benötigt.
**Geprüft:** 28 PHP-Tests / 347 Assertions für Archiv- und Git-Updater, Cachepfad, Systemverwaltung und Installation erfolgreich. Wiederverwendbarer Paket-HTTP-Test reproduziert 1.10.0 → 1.10.1 mit 500 sowie den erfolgreichen Wiederaufnahmeweg (`var/archive-check-f00fc15412`). Mit dem echten Zielpaket 1.10.2: Übergang aus 1.10.1 weiterhin einmalig POST 500, danach System 200, Abschluss 302, öffentlich 200 (`var/archive-check-316de9113a`). Korrigierter Paket-Updater mit ausschließlich synthetischer vorheriger Versionsnummer: POST 302, System 200, Abschluss 302, öffentlich 200, keine fehlenden Cachedienste (`var/archive-check-8637d9310e`). Sitzung, Wartungszustand, Sicherung, Versionswechsel und unveränderte Originaldatenbank geprüft. Produktionscontainer, Cache-Warmup, PHP-Stil und JavaScript-Syntax erfolgreich.
**Veröffentlicht:** Commit `7c1c86b`, Tag `v1.10.2`, [Release 1.10.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.10.2) auf origin und als neuestes stabiles Release geprüft. Paket aus dem exakten Commit, 3543 Archivdateien und 221 Quelldateien bytegenau geprüft; nur Produktionsabhängigkeiten. Frischer Installer HTTP 200, Container und Cache-Warmup auch im Release erfolgreich. ZIP, Prüfsumme und Installer anonym heruntergeladen und mit den lokalen Artefakten verglichen. ZIP-SHA-256: `733f591c143e9d98d4044f37dabce93086542e4db2512b1d4f051617d8835711`.
**Übergabe / Grenzen:** Der bereits installierte alte Updater kann beim Einspielen dieser Reparatur einmalig weiter 500 ausgeben; anschließend `/admin/system` direkt öffnen und die angebotenen Migrationen abschließen. Genau dieser Weg ist mit dem Releasepaket geprüft. Kein Zugriff auf Produktionsprotokolle und keine Produktionsänderung durch diese Diagnose; Startseite und Anmeldung bei der Prüfung HTTP 200. Kein neuer Lighthouse- oder visueller Gesamtlauf. Die während der Arbeit extern geänderte `.claude/launch.json` bleibt unberührt und uncommitted.
## 1.10.1 – Standardbutton im Formular und zuverlässiges Wiederaufnehmen von Einblendungen (14.09.2026)
**Umgesetzt:** „Absenden“ verwendet den konfigurierten Website-Standardbutton, einschließlich Form, Darstellung, Größe, Schrift sowie normalen und Hover-Farben. Harte Formularfarben entfernt und natürliche Buttonbreite hergestellt. Bestehende Formulare übernehmen Theme-Änderungen ohne Inhaltsmigration (ADR 089). Enthalten sind außerdem die unten dokumentierte Observer-Korrektur und die dynamische Bewegungsmeldung im Editor.
**Prüfungen:** Formularabweichung vor der Korrektur mit realen berechneten CSS-Werten reproduziert (`var/form-button-before.log`). 10 gezielte PHP-Tests / 76 Assertions für Formulare, Validierung, Versand, Spam-Schutz und Menüformular erfolgreich; eine alte Menüerwartung auf die seit 1.10.0 getrennten responsiven Darstellungen angepasst. 33 JavaScript-Tests, alle 49 Twig-Dateien, Produktionscontainer und PHP-Stilprüfung erfolgreich. Bewegungstest auf isolierter Pilotkopie bei 1600/390 px einschließlich Präferenzwechsel und Editorhinweis erneut erfolgreich. Kein neuer Lighthouse-Gesamtlauf oder Test des normalen Chrome-Benutzerprofils.
**Browser:** Drei Button-Prüfgruppen erfolgreich auf isolierter Kant-IT-Kopie. Globale Vererbung des Formularbuttons bei 1600/900/390 px ohne JavaScript mit berechneten CSS-Werten gegen einen gewöhnlichen Standardbutton verglichen; Hover, Tastaturfokus, natürliche Breite sowie spätere Umstellung von Rahmen/Pille auf gefüllt/abgerundet/groß mit eigenen Farben geprüft. Bestehende Button-Tests für Undo/Redo, Gerätevarianten und Veröffentlichung ebenfalls erfolgreich. Screenshots unter `reports/buttons/` visuell geprüft.
**Veröffentlicht:** Commit `cff4681` und Tag `v1.10.1` auf `origin`; [Release 1.10.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.10.1) als neuestes stabiles Release öffentlich geprüft. ZIP, SHA-256-Datei und Installer anonym heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3543 Archivdateien, 221 Paketquelldateien verglichen, ausschließlich Produktionsabhängigkeiten. Frischer Installer HTTP 200, `.env` vorhanden und keine `.env.local`; Produktionscontainer und Cache-Warmup im Release erfolgreich. ZIP-SHA-256: `24733774d14fe1c640681d01e62d2cef2b6a337b24b63af416965ba63cf4654c`.
**Nächster Schritt / Grenzen:** Über die Systemverwaltung aktualisieren und den Editor neu laden. Die Browservorgabe `prefers-reduced-motion` bleibt wirksam; ein dauerhaftes `true` wird durch dieses Release nicht überschrieben. Keine Kundeninhalte oder Systemeinstellungen verändert; auf `web.kant-it.de` hier kein Update installiert.
## Einblendungen nach Freigabe wieder aufnehmen – lokal korrigiert (14.09.2026)
**Neue Evidenz:** Umuts Screenshot zeigt eingeschaltete Windows-Animationseffekte. Auch die lokale Win32-Abfrage `SPI_GETCLIENTAREAANIMATION` ergibt `true`; kein Chrome-Prozess mit `--force-prefers-reduced-motion` gefunden. Die frühere Empfehlung, Windows-Animationen einzuschalten, ist damit nicht als Lösung belegt. Warum Umuts Chrome-Profil dennoch `prefers-reduced-motion: reduce` meldet, bleibt offen; eine Chrome-DevTools-Medienemulation ist eine mögliche, nicht verifizierte Ursache.
**CMS-Fehler reproduziert und behoben:** Der Observer in `motion.js` startete nach dem Wechsel von reduzierter zu erlaubter Bewegung nicht erneut. Der neue Browserfall scheiterte vor der Änderung (`var/motion-resume-before.log`). Jetzt werden noch nicht gezeigte Elemente wieder beobachtet; bereits gezeigte Elemente laufen weiterhin nur einmal, laufende Animationen werden bei Reduktion abgebrochen. Keine Änderung an der grundsätzlichen Beachtung der Browserpräferenz. Im Editor erscheint eine dynamische Statusmeldung, wenn der Browser Einblendungen, Hover-Bewegungen und Slider-Automatik unterdrückt.
**Geprüft:** Erweiterte `scripts/motion-check.mjs` auf isolierter Pilotkopie bei 1600/390 px erfolgreich: Start unter Reduktion und anschließende Freigabe ohne Reload, nachträgliches Sperren/Freigeben, keine Wiederholung bereits gezeigter Elemente, Hover aus/an, alle bisherigen Scroll-/Richtungsfälle, lesbare Ausgabe ohne JavaScript. Editorhinweis erscheint/verschwindet bei Präferenzänderung, Screenshot `reports/motion/editor-motion-status.png` visuell geprüft. 33 JavaScript-Tests, Twig-Lint für den Editor und Git-Diff-Prüfung erfolgreich; Originaldatenbank unverändert.
**Direkte Produktionsgegenprobe:** Mit installiertem Chrome in zwei getrennten Testkontexten auf `web.kant-it.de`: bei emuliertem `reduce` keine Einblendung und Hover-Transform `none`; bei `no-preference` Kontaktsektion mit `translateY(28px)` und App-Karte beim Hover um −4 px verschoben. Keine JavaScript-Seitenfehler; Bericht `reports/motion/production-proof.json`. Während der Prüfung war an `leistung-app` inzwischen `fx-entrance-none` gespeichert; keine Produktionsinhalte verändert. Dieser Vergleich zeigt die Wirkung der Präferenz, ersetzt jedoch keine Prüfung des normalen Benutzerprofils.
**Offen / nächster Schritt:** Die lokale Observer-Korrektur erklärt keinen dauerhaft wahren Browserwert und behebt bei weiter aktivem `reduce` nicht Umuts gesamten Produktionsbefund. Chrome-Rendering-Emulation prüfen; Windows-Einstellungen nicht erneut als Ursache behaupten. Lokale Änderungen noch nicht committed oder veröffentlicht, keine neue Datenmigration, kein neuer Lighthouse-Gesamtlauf.
## Produktionsdiagnose: Bewegung in Chrome unterdrückt (14.09.2026)
**Bestätigte Ursache:** Umut meldet für `https://web.kant-it.de/` in seinem Chrome `matchMedia('(prefers-reduced-motion: reduce)').matches === true`. Damit deaktivieren die vorhandenen CSS-/JavaScript-Regeln Einblendungen, Hover-Bewegungen und Slider-Automatik. Die öffentliche Karte `leistung-app` trägt tatsächlich `fx-hover-lift fx-entrance-left fx-delay-short`; die frühere Diagnose allein anhand der lokalen Pilotdaten war für Produktion unzureichend.
**Geprüft:** Produktions-HTML und Effektdateien HTTP 200; `motion.js?v=1.10.0` entspricht dem lokalen Modul, ausgeliefertes CSS enthält die Hover-Regel samt Bewegungsschranke. Im Codex-Browser ebenfalls reduzierte Bewegung und deaktivierte Slider-Automatik gemessen, keine JavaScript-Warnungen/-Fehler. Direkter Zugriff auf Umuts Chrome wurde von der Browsersteuerung wegen nicht sicher ermittelbarer URL gestoppt; den Wert aus seinem Browser hat Umut selbst bestätigt.
**Testverbesserung:** `scripts/motion-check.mjs` prüft zusätzlich den gespeicherten Hover-Effekt anhand der berechneten Verschiebung von −4 px und protokolliert die Bewegungsvorgabe separat ohne Medienemulation (`reducedMotion: null`). Optional installiertes Chrome über `MOTION_BROWSER_CHANNEL=chrome`; dieser Lauf auf einer isolierten Pilotkopie war bei 1600/390 px erfolgreich. Ein frisches Testprofil ersetzt weiterhin keine Messung im Benutzerprofil.
**Einordnung:** Der später bereitgestellte Screenshot und die Systemabfrage zeigen, dass Windows-Animationen bereits eingeschaltet sind (siehe neuerer Eintrag oben). Die Herkunft der Browsermeldung bleibt ungeklärt; keine erfolgreiche Wiedergabe im normalen Chrome-Profil bestätigt. Keine Systemoption, Produktionsdatei oder gespeicherter Inhalt geändert; kein neues Release.
## Prüfung der Scroll-Einblendungen mit Kant-IT-Daten (14.09.2026)
**Befund:** Die Startseite in `var/kantit.db` enthält in Entwurf und Veröffentlichung 14 Einblendungen, ausschließlich `rise` und `fade`. Keine seitlichen Effekte an den Startseitenelementen gespeichert. `rise` bewegt den Block 28 Pixel bei voller Deckkraft; überwiegend sind ganze Sektionen animiert. Der bestehende Renderer lädt `motion.js`, und der Observer löst beim Scrollen aus. Im Editor erfordert die Wiedergabe „Bewegung testen“; reduzierte Systembewegung unterdrückt sie.
**Geprüft:** `PHP_BINARY=C:\php84\php.exe node scripts/motion-check.mjs` (PowerShell: zuerst `$env:PHP_BINARY='C:\php84\php.exe'`) erfolgreich auf isolierter Pilotkopie im Produktionsmodus, Chromium mit Desktop 1600 und Mobil 390. Kontaktsektion wartet außerhalb des Bildschirms, startet nach Scrollen und läuft nur einmal. Beide seitlichen Optionen auf demselben Element temporär im Browser geprüft, inklusive tatsächlich berechneter Transformationsrichtung. Reduzierte Bewegung und sichtbarer Inhalt ohne JavaScript erfolgreich; keine JavaScript-Seitenfehler. Screenshots visuell geprüft, Bericht unter `reports/motion/result.json`. SHA-256 der Originaldatenbank vor/nach identisch.
**Ergebnis / nächster Schritt:** Kein Ausfall der Scroll-Laufzeit reproduziert; Anwendung und gespeicherte Inhalte unverändert. Für seitliche Bewegung die gewünschten Blöcke unter Design → Bewegung und Effekte → Einblendung beim Erscheinen auf „Von links“/„Von rechts“ stellen, speichern und veröffentlichen. Ob Umuts Browser aktuell Bewegung reduziert, wurde nicht geprüft. Keine neue Lighthouse- oder vollständige Barrierefreiheitsprüfung.
## 1.10.0 – Gerätesichtbarkeit und automatische Hamburger-Menüs (14.09.2026)
**Erledigt:** Eigene Sichtbarkeitsauswahl direkt am Element in Inhalt/Design/Erweitert: alle Geräte, nur Desktop/Tablet/Mobil, auf einem Gerät ausblenden oder überall ausblenden. Strukturbaum zeigt die Auswahl und erhält den Zugriff auf ausgeblendete Elemente. Waagerechte Menüs werden standardmäßig auf Tablet/Mobil und großen Touchgeräten ohne Maus zum Hamburger. Pro Menü sind „Nur Mobil“ und „Nie“ möglich. Vertikale und ausdrücklich gewählte Burger-Menüs behalten ihre Menüart. Native Menübedienung ohne öffentliches JavaScript; Öffnen auch in der Editorvorschau möglich. Tabs und Slider berücksichtigen ausgeblendete Einträge. Datenvertrag und Rückweg: ADR 088, Schema 22, keine SQL-Migration.
**Geprüft:** `PHP_BINARY=C:\php84\php.exe npm run test:responsive-navigation` (in PowerShell mit `$env:PHP_BINARY` setzen) erfolgreich mit vier Browser-Prüfgruppen auf isolierter Kant-IT-Kopie: Tastatur, Undo/Redo, Rücksetzen, Strukturzugriff, Vorschau, Speichern/Laden/Veröffentlichung; alle acht Sichtbarkeitsoptionen bei 1600/1025/1024/768/767/390 px; geerbte Elternsichtbarkeit, erhaltene Flex-/Gridwerte; alle Menüvarianten ohne JavaScript einschließlich Tastatur und Linknavigation; 1366-px-Touchprofil; Tabs/Slider und gezielte WCAG-Audits für Navigation, Tabs und Slidersteuerung auf Desktop/Mobil. Screenshots unter `reports/responsive-navigation/` visuell geprüft. Kein Testkonto oder Inhalt in den Originaldaten verändert. Der erste Start mit PHP 8.2 im PATH war wegen dessen fehlender SQLite3-Erweiterung nicht möglich; der dokumentierte PHP-8.4-Lauf ist erfolgreich.
**Weitere Prüfungen:** 33 JavaScript-Tests; 2 neue PHP-Tests mit 30 Assertions; 50 gezielte bestehende PHP-Tests zu Menüs, Dokument-Roundtrip und ungültigen Dokumenten mit 86 Assertions. PHP-Formatierung, alle 49 Twig-Dateien, Produktionscontainer und Cache-Warmup erfolgreich. Bestehende HTML-Erwartungen berücksichtigen jetzt beide responsiven Menüvarianten; der Browser prüft, dass jeweils nur eine zugänglich ist. Ein im Browser gefundener CSS-Fehler bei ausgeblendeten Slider-Punkten wurde korrigiert.
**Release-Prüfung 1.10.0:** 33 JavaScript-Tests und 139 gezielte PHP-Tests / 234 Assertions erfolgreich. Eine alte HTML-Erwartung im Test für gemeinsame Header/Footer an die vorhandene Menübeschriftung angepasst. Responsive-Navigationsprüfung mit vier Gruppen und Scroll-Prüfung bei 1600/390 px auf isolierten Pilotkopien erneut erfolgreich. Alle 49 Twig-Dateien, Produktionscontainer und PHP-Stilprüfung der drei betroffenen PHP-Dateien ohne Befund.
**Veröffentlicht:** Commit `37c2942` und Tag `v1.10.0` auf `origin`; [Release 1.10.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.10.0) als neuestes stabiles Release öffentlich geprüft. ZIP, SHA-256-Datei und Installer anonym heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3543 Archivdateien, 221 Paketquelldateien verglichen, ausschließlich Produktionsabhängigkeiten. Frische Installationsseite HTTP 200, `.env` vorhanden und keine `.env.local`; Produktionscontainer und Cache-Warmup im Release erfolgreich. ZIP-SHA-256: `beee5723eb27a2de58b4fc32c66a521a1469d7ded00046a25ddac51a34cc86ba`. Ergebnisse unter `var/release-verification-1.10.0.json`.
**Bekannte Grenzen / nächster Schritt:** Kein neuer Lighthouse-Gesamtlauf, keine neue Feldmessung, kein Test auf physischem iPad. Geräteklassen richten sich nach Viewportbreite; die Menüautomatik berücksichtigt zusätzlich Touchbedienung. Bereits separat angelegte Desktop-/Mobilmenüs können mit der neuen Sichtbarkeit zugeordnet werden. Nach dem Release über die Systemverwaltung aktualisieren und die gewünschten Sichtbarkeiten im echten Header festlegen. Kundenhostings werden hier nicht verändert.
## 1.9.0 – Button-Vorlagen, Sprungziele und bessere Menüs (14.09.2026)
**Erledigt:** Website-Standardbutton unter Typografie mit acht visuellen Vorlagen, normaler und Hover-Vorschau, Form, Darstellung, Größe, Schrift und Farben. Einzelne Buttons erhalten Gerätevarianten und Rücksetzen über den bestehenden Undo-/Speicherweg. Ein vorhandener Fehler beim Speichern leerer Palettenzeilen wurde behoben. Sprungziele wie `#kontakt` lassen sich an Sektionen, Zeilen und Spalten vergeben; interne IDs bleiben stabil und doppelte Ziele werden abgewiesen. Menüs haben sichtbare Drag-and-drop-Griffe, Tastaturbedienung, ordentliche SVG-Pfeile sowie optionale Icons vor/nach dem Text. Neben der Fassung erscheint ein Update-Link aus dem Ergebnis der bestehenden täglichen Prüfung.
**Gezielt geprüft:** Je drei Browser-Prüfgruppen für Buttons und Navigation auf isolierter Kant-IT-Kopie erfolgreich: Vorlagen und globale Speicherung, Gerätevarianten, Undo/Redo, Speichern/Laden, Veröffentlichung, Sprunglinks, Menü-Drag-and-drop/Tastatur, Icons und Update-Hinweis. Öffentliche Ausgabe ohne JavaScript geprüft; Screenshots unter `reports/buttons/` und `reports/navigation-ux/` visuell kontrolliert. Originaldaten unverändert. Twig-Lint und Produktionscontainer erfolgreich. Im gezielten PHP-Lauf zu Theme/Menüs bestanden 29 von 30 Tests unmittelbar; eine alte HTML-Erwartung wurde an die neue Icon-Beschriftung angepasst und der betroffene Test erfolgreich wiederholt (8 Assertions). PHP-Formatierung abgeschlossen. Auf Wunsch keine Gesamtprüfung und keine neue Lighthouse-Messung.
**Veröffentlicht:** Commit `3391fea` und Tag `v1.9.0` auf `origin`; [Release 1.9.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.9.0) ist das neueste stabile Release. ZIP, SHA-256-Datei und Installer öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3543 Archivdateien, 221 Paketquelldateien verglichen, ausschließlich Produktionsabhängigkeiten. Frische Installationsseite HTTP 200, `.env` vorhanden und keine `.env.local`. ZIP-SHA-256: `f48f135015f1ab2fb49a6f0a862d1ef414d20a8ca61a70d43e85c9b0e100bf62`.
**Grenzen / nächster Schritt:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen. Update-Hinweis setzt den vorhandenen Aufgaben-Worker voraus und erscheint nach dessen Prüfung. Freie CSS-/Designwerte können Button-Vorlagen übersteuern. Schema 21 ist beim Rückweg zu berücksichtigen (ADR 087); keine Datenbankmigration. Kundenhostings wurden hier nicht verändert.
## 1.8.4 – Bildausrichtung sichtbar korrigiert (14.09.2026)
**Behoben:** „Eigene Ausrichtung quer: Mitte“ zentriert Bilder jetzt auch in normalen Sektionen und Spalten. Zuvor blieb das Bild trotz `align-self:center` links; der Browserfall mit 50-Prozent-Breite zeigte 181 Pixel Abweichung von der Mitte (`var/image-alignment-before.log`, `reports/image-alignment/before.png`). Der gemeinsame Compiler richtet Rahmen und enthaltenes Bild aus, einschließlich kleiner Logos, Prozent-/Maximalbreiten und Bildlinks. Anfang/Ende, Gerätevererbung und Rücksetzen sind berücksichtigt; horizontale Flex-Gruppen behalten ihre vertikale Querachse. Keine Datenmigration, keine Änderung an gespeicherten Bilddaten, kein zusätzliches öffentliches Skript (ADR 086).
**Browser geprüft:** `npm run test:image-alignment` mit drei Prüfgruppen auf isolierter Kant-IT-Kopie erfolgreich: echte Layout-Auswahl, Tastatur, Anfang/Mitte/Ende, Undo/Redo, gepolsterte Inhaltsbereiche, kleine Originalbilder, Prozent-/Maximalbreiten, Link/Vergrößerung, Grid, beide Flexachsen, responsive Elterneinstellungen und Rücksetzen auf Auto mit wiederhergestelltem Außenabstand. Speichern/Laden erhält Maße und Medienmetadaten. Öffentliche Ausgabe ohne JavaScript auf Desktop 1600, Tablet 900 und Mobil 390 geprüft. Screenshots unter `reports/image-alignment/` visuell geprüft; Originaldaten unverändert.
**Zusätzlich geprüft:** Bestehendes `npm run test:image-size` mit drei Prüfgruppen erfolgreich, einschließlich Größenregler, Gerätewerten, Undo/Redo, Speichern/Laden, Veröffentlichung und Desktop-/Mobil-Barrierefreiheit der Bedienelemente. 32 JavaScript-Tests und Produktionscontainer erfolgreich; PHP-Stilprüfung der drei betroffenen Dateien ohne Befund. Alle 481 PHP-Tests / 3865 Assertions erfolgreich, verteilt auf sämtliche 15 Testklassen mit jeweils eigenem Prozess (`php -d max_execution_time=0 vendor/bin/phpunit tests/<Klasse>Test.php`). Ergebnis: `var/phpunit-1.8.4-result.json`, Einzellogs unter `var/phpunit-1.8.4-classes/`. Der Aufgaben-Worker setzt im gemeinsamen Prozess erneut ein 80-Sekunden-Limit; deshalb wurde die Suite pro Klasse ausgeführt. Ein vorhandener CRLF/LF-abhängiger Quelltextvergleich der Layout-Gruppen ist jetzt zeilenumbruchunabhängig. Kein Anwendungscode wurde für die Testlaufzeit geändert.
**Veröffentlicht:** Commit `81276dd` und Tag `v1.8.4` auf `origin`; [Release 1.8.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.4) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 213 Paketquelldateien gegen Commit/Vorlage verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4e7b429381cadcc5d9ed857b03917c3abab0197c37fb47e570d03bf50d69eea4`.
**Nächster Schritt / Grenzen:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert. Keine neue Lighthouse-Messung oder vollständige Editor-Gesamtprüfung für die gezielte CSS-Compiler-Korrektur; eigene CSS-Regeln können weiterhin Layoutvorgaben überschreiben.
## 1.8.3 – Inhalte in Akkordeons sichtbar bearbeiten (14.09.2026)
**Behoben:** Geschlossene Akkordeon-Einträge ließen sich in der Editorvorschau nicht öffnen, weil die Klickbehandlung die native Aktion verhinderte. Der Fehler wurde vor der Korrektur im Browser reproduziert (`var/accordion-before.log`, `reports/accordion/before.png`). Einträge sind im Editor jetzt geöffnet, leere Inhaltsbereiche sichtbar und als Ablageziel nutzbar. „Inhalt hinzufügen“ im Eintrag und rechts im Feinschliff hängt neue Elemente an. „Eintrag hinzufügen“ steht an der Gruppe und in deren Inhaltsansicht; auch ein vollständig leeres Akkordeon ist wieder befüllbar.
**Bedienung:** Rechte Eintragsübersicht, Struktur und Struktursuche zeigen Eintragstitel. Ein neues Akkordeon wählt den ersten Eintrag samt Inhaltsoptionen. Bei Auswahl der Gruppe werden Texte nicht mehr unbemerkt außerhalb des Akkordeons eingefügt. Die gespeicherte Einstellung „Beim Laden geöffnet“ gilt weiterhin für Besucher; Öffnen im Editor verändert weder Daten noch Speicherstatus. Keine Migration und kein geänderter öffentlicher Renderer (ADR 085).
**Geprüft:** `npm run test:accordion` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: sichtbarer Inhalt trotz geschlossenem Besucherzustand, Einfügen über Tastatur/Vorschau/Feinschliff, Textbearbeitung, Anhängen und Drag-and-drop von Buttons in Einträge, Undo/Redo, weitere/leere/verschachtelte Akkordeons, Suche nach Titeln, Speichern/Laden und Veröffentlichung. Veröffentlichte Einträge behalten ihre Öffnungswerte und lassen sich ohne JavaScript per Maus/Enter bedienen; Vorschauhilfen fehlen im öffentlichen HTML und in gespeicherten Daten. Desktop 1600/Mobil 390 ohne axe-Verstöße im Editor oder horizontalen Überlauf; Screenshots visuell geprüft (`reports/accordion/`). Originaldaten unverändert.
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Medien, Einwilligung und Desktop-/Mobil-Barrierefreiheit. 32 JavaScript-Tests erfolgreich, darunter ergänzte Einfüge-/Undo-Prüfungen. 16 gezielte PHP-Tests / 33 Assertions zu V6-Blockvalidierung, Speichern und Ausgabe ohne JavaScript erfolgreich. Produktionscontainer gültig und Git-Diff ohne Whitespacefehler. Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Editoränderung.
**Veröffentlicht:** Commit `a302996` und Tag `v1.8.3` auf `origin`; [Release 1.8.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `7a3625609dc55f00354ee4b853c2816f20467cd04d0aa9acde7d117c69bd897a`.
**Nächster Schritt:** Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
## 1.8.2 – Visuelle Farbauswahl im Editor (14.09.2026)
**Umgesetzt:** Kompakte Farbvorschau mit Palettenname und aufgelöstem Hexwert statt schwarzem Feld bei `@deep` und einer zusätzlichen Dropdown-Liste. Aufklappbare Website-Farben als beschriftete Farbkacheln mit Auswahlzustand, eigener Farbwähler und Hexeingabe mit Enter/Übernehmen. Transparente Farben auf Schachbrett; Alphakanal bleibt beim Ändern des Farbtons erhalten. Geerbte Geräte- und Hover-Farben erkennbar, einzelne Werte zurücksetzbar. Text-, Hintergrund-, Hover-, Verlaufs-, Rahmen- und Schattenfarben nutzen dasselbe Bedienelement. Keine Änderung am Datenvertrag oder öffentlichen Paket (ADR 084).
**Geprüft:** `npm run test:colors` mit fünf Prüfgruppen auf isolierter Kant-IT-Kopie: Token-Vorschau, Auswahl/Kanvas, Tastatur/Tab/Escape/Fokus, Undo/Redo, Geräte-/Hover-Vererbung und Reset, stabiler nativer Farbwähler mit einem Undo-Schritt, reine change-Ereignisse, ungültige Eingaben, Kurzhex, achtstellige Hexwerte, Speichern/Laden und Veröffentlichung mit erhaltener Palettenreferenz. Desktop 1600/Mobil 390: geöffnete Farbauswahl und Editor ohne axe-Verstöße oder horizontalen Überlauf; Screenshots `reports/color-picker/` visuell geprüft. Originaldaten unverändert.
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Gestaltung, Vorlagen, Speichern/Veröffentlichen, Konflikten, Mediathek, Einwilligung sowie Desktop-/Mobil-Barrierefreiheit. Der bisherige Test zur automatisch angehängten Einwilligungszeile wurde an die bereits veröffentlichte Änderung aus 1.8.1 angepasst. 32 JavaScript-Tests einschließlich versionierter Modulimporte bestanden. Geänderte Twig-Basis und Produktionscontainer gültig; Git-Diff ohne Whitespacefehler.
**Veröffentlicht:** Commit `55dab4b` und Tag `v1.8.2` auf `origin`; [Release 1.8.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3535 Archivdateien, 212 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f6d2a6ff99f52f3a0939160e26403aca13bb9fb9d7042f38255dd958057ae8f5`.
**Grenzen / nächster Schritt:** Keine erneute PHP-Vollsuite oder Lighthouse-Messung für die Änderung am Editor. Verwaltungsformulare außerhalb des Editors behalten ihre bisherigen Farbfelder. Über die Systemverwaltung aktualisieren und den Editor neu öffnen; Kundenhostings wurden hier nicht verändert.
## 1.8.1 – Keine automatische Rechtszeile unter dem Footer (14.09.2026)
**Behoben:** Das CMS hängt Datenschutz, Impressum und Einwilligungslink nicht mehr automatisch an den Footer an. Entfernt aus Seiten-/Archiv-/Fehlervorlagen und damit auch aus Seiten- und Bereichsvorschauen. Normale Textlinks, Menüs und ausdrücklich eingefügte „Rechtliches“-Bausteine bleiben erhalten; der Editorhinweis beschreibt die freie Platzierung. Keine Inhaltsänderung oder Migration (ADR 083).
**Geprüft:** PHP-Vollsuite 477 Tests / 3815 Assertions und 32 JavaScript-Tests erfolgreich. Vorhandene Tests an die redaktionelle Platzierung angepasst; freiwilliger Rechtsblock und Einwilligungsablauf weiterhin geprüft. 47 Twig-Dateien und Produktionscontainer gültig, PHP-Stilprüfung der drei betroffenen PHP-Dateien ohne Änderungen, Git-Diff ohne Whitespacefehler.
**Browser:** Isolierte Kant-IT-Kopie mit bestehenden Rechtsblöcken sowie einer Variante mit normalen Footer-Textlinks wie im Screenshot. Acht Prüfgruppen erfolgreich: Startseite, Beitragsarchiv und 404 auf Desktop 1600/Mobil 390 sowie Seiten- und Footer-Editor. Eigene Links erhalten, keine Zusatzleiste, kein horizontaler Überlauf. Screenshots unter `reports/footer-links/` visuell geprüft. Gegenprobe mit dem unveränderten Release 1.8.0 reproduziert die doppelte Leiste auf allen sechs öffentlichen Ansichten (`reports/footer-links-before/`). Originaldaten bleiben unverändert.
**Bekannte Prüfbefunde:** Die Variante mit gewöhnlichen, unformatierten Textlinks meldet Kontrastbefunde an diesen Links; bei sichtbarem Nach-oben-Knopf zusätzlich einen Landmark-Befund. Das Archiv meldet außerdem Überschriftenreihenfolge und doppelte Landmark-Benennung. Alle Befunde betreffen dieselben Elemente wie in der Gegenprobe mit 1.8.0. Keine fehlerfreie Gesamt-Barrierefreiheit oder neuen Lighthouse-Werte behauptet. Keine Wiederholung der gesamten Editor-Bedienprüfung für diese Template-Entfernung.
**Veröffentlicht:** Commit `8f475d1` und Tag `v1.8.1` auf `origin`; [Release 1.8.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `fc5d7386d1d9b099e1506f16ab7937526014a85a96840c510e7ca4e2703206c3`. Über die Systemverwaltung aktualisieren; Kundenhostings wurden hier nicht verändert.
## 1.8.0 – Eigene 404-Seite mit Website-Design (14.09.2026)
**Umgesetzt:** Einstellungen → 404-Seite mit Livesuche, bestehende Seite zuweisen, Auswahl zurücksetzen, direktem Bearbeiten und öffentlicher 404-Vorschau. „404-Vorlage anlegen“ erstellt und verknüpft einen bearbeitbaren Entwurf, ohne vorhandene Seiten zu überschreiben. Bis zur Veröffentlichung erscheint eine automatische Standard-404. Normaler Seitenrenderer übernimmt Standard-Header/-Footer, Theme, Schriften, Menüs und verknüpfte Sektionen. Die ID-Verknüpfung übersteht Umbenennen; Löschen führt auf den Standard zurück. Nur veröffentlichte Revisionen werden öffentlich ausgeliefert.
**Antwortverhalten:** Echte 404-Antwort an der ursprünglichen URL, auch bei ungewöhnlichen/tiefen HTML-Adressen und beim direkten Aufruf der zugewiesenen Seite. Kein Canonical und keine strukturierten Daten einer normalen Seite; `noindex, follow` in HTML und Header. Ausschluss der zugewiesenen ID aus Sitemap und llms.txt. 301/410, Verwaltungs-/Medien-/Formularendpunkte und maschinenlesbare Ressourcen behalten ihre eigene Behandlung. Zuweisung mit Veröffentlichungsrechten, CSRF und Schutz gegen veraltete Formulare. Keine Datenbankmigration; optionaler Settings-Schlüssel und vorhandenes Dokumentschema v10 (ADR 082).
**Geprüft:** PHP-Vollsuite 477 Tests / 3817 Assertions erfolgreich, einschließlich neuer 404-Fälle zu Entwurf/Veröffentlichung, Umbenennen, Löschen/Fallback, bestehenden `/404`-Seiten, Schreibkonflikten, Berechtigungen, CSRF, HEAD, Bereichen, 301/410, Sitemap und llms.txt. 32 JavaScript-Tests erfolgreich. `npm run test:404` mit isolierter Kant-IT-Kopie: Vorlage anlegen, im Editor bearbeiten, veröffentlichen, tatsächliche Ausgabe, Livesuche/Tastaturwahl, Zuordnung nach Neuladen, Rücksetzen und veraltetes zweites Formular. Desktop 1600 und Mobil 390 ohne Überlauf/axe-Verstöße; öffentliche Startseite weiterhin 200 mit Canonical und ohne axe-Befund. Funktioniert auch ohne JavaScript; Header/Footer/Schrift mit dem Originalpiloten abgeglichen, Screenshots visuell geprüft (`reports/not-found/`). Originaldaten bleiben unverändert.
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Speichern/Laden, Veröffentlichung, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit. Alle 47 Twig-Dateien und der Produktionscontainer gültig; PHP-Stilprüfung über 133 Dateien ohne Befund.
**Veröffentlicht:** Commit `16da844` und Tag `v1.8.0` auf `origin`; [Release 1.8.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.8.0) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3534 Archivdateien, 211 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `4c90a0256ad827c40ae09db85fbe72a9315f41b181c26f9983d710b646bc64b4`.
**Grenzen / nächster Schritt:** Keine neuen Lighthouse-Scores für eine absichtlich nicht indexierbare 404-Seite. Keine neuen Firefox-/Safari-/Screenreader- oder MySQL-Läufe; lokale Datenbankprüfungen verwenden SQLite. Nach Veröffentlichung über die Systemverwaltung aktualisieren; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
## 1.7.4 – URL-Feld bei Bild-Klickaktion zuverlässig einblenden (14.09.2026)
**Behoben:** „Zu einer Adresse führen“ blendet Linkziel und Tab-Häkchen unmittelbar unter dem Dropdown ein, auch bei Bildern ohne frühere Verlinkung. Inhalts-Auswahlfelder reagieren auf `input` und `change`, ohne doppelte Änderungen. Das Dropdown bleibt beim Umschalten im DOM; Linkfelder werden nur ein-/ausgeblendet, eingegebene Adressen bleiben erhalten. Jede Auswahl bleibt einzeln rückgängig zu machen. Keine neue Schema-/Datenbankmigration, kein geänderter öffentlicher Renderer oder öffentliches Paket. Cachekennung 1.7.4.
**Geprüft:** Der ergänzte Browserfall mit ausschließlich `change` scheiterte vor dem Fix am fehlenden URL-Feld (`var/image-link-before.log`). Danach `npm run test:link-media` mit sechs Prüfgruppen bestanden: erstmals verlinktes Bild, natives Dropdown mit Mausöffnung/Tastaturwahl, Tastaturwechsel, stabiler DOM-Knoten/Fokus, Nur-change-Ereignis, URL-Erhalt, Vergrößern/Nichts tun, Undo/Redo, Speichern/Laden und veröffentlichte Zieladresse. Bestehende Bild-, Hintergrund-, Textlink- und Menüprüfungen ebenfalls erfolgreich. Desktop-/Mobil-Editor und öffentliche Seite ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). 32 JavaScript-Tests bestanden. Schreibtests ausschließlich in isolierten Kant-IT-Kopien.
**Zusätzlich geprüft:** Vollständiges `npm run test:editor` erfolgreich, einschließlich Inhalts-/Design-Auswahl, Undo, Speichern, Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`).
**Veröffentlicht:** Commit `25fd388` und Tag `v1.7.4` auf `origin`; [Release 1.7.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.4) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` öffentlich heruntergeladen und bytegenau verglichen. Paket aus dem exakten Commit: 3532 Archivdateien, 209 versionierte Quelldateien gegen den Commit verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `f41ff10ad403a5dccd48e8d2805079dc3ebdfa2966fd548c4c1b0abe6d26a617`.
**Grenzen / nächster Schritt:** Kein erneuter PHP- oder Lighthouse-Lauf für diese reine Editor-Korrektur. Kundenhostings werden nicht verändert; nach Einspielen des Updates den Editor neu öffnen, damit die versionierten Skripte geladen werden.
## 1.7.3 – Links in neuen Tabs und Bildvorschauen (14.09.2026)
**Umgesetzt:** „In neuem Tab öffnen“ bei Buttons, verlinkten Bildern, Symbolen, Symbolkarten, Aufruf-Bändern, Textlinks und Menüeinträgen. Serverseitig validierte Ausgabe mit `target="_blank" rel="noopener"`; normale Links bleiben im selben Tab. Textlinks lassen sich mit vorhandenem Ziel/Häkchen wieder bearbeiten. „Nichts tun“ am Bild deaktiviert auch eine früher hinterlegte Verlinkung. Inhalts-, Galerie-/Logo- und Hintergrundbilder zeigen Vorschauen mit Lade-/Fehlerzustand und Entfernen-Funktion. Entfernen ist rückgängig zu machen und erhält die Datei in der Mediathek. Bildaustausch erhält Link, Bildunterschrift und Gestaltung. Geerbte Hintergrundbilder sind sichtbar und je Gerät entfernbar; kleinere Geräte übernehmen die Entfernung, sofern dort kein eigener Wert besteht. Verläufe bleiben erhalten. Tastaturfokus bleibt beim Umschalten der Klickaktion und nach dem Entfernen eines Hintergrunds bedienbar.
**Datenvertrag:** Dokument-Schema v20 nur beim Verwenden neuer Block-Linkwerte oder ausdrücklicher Hintergrundentfernung. Vorhandene Dokumente bleiben lesbar, keine Datenbankmigration. Migration und Rückweg: ADR 081. Medienvorschauen verwenden eine authentifizierte Einzelabfrage und tatsächlich vorhandene kleine Varianten, auch bei archivierten verwendeten Bildern. Kein zusätzliches öffentliches JavaScript.
**Geprüft:** 338 PHP-Tests (`CmsTest|EditorSecurityTest`) mit 2873 Assertions; 32 JavaScript-Tests; 47 Twig-Vorlagen gültig; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:link-media` auf einer isolierten Kant-IT-Kopie: Vorschauen, fehlendes Bild, Entfernen/Undo bei Bild/Galerie/Hintergrund, Vererbung/Reset, Links erstellen/ändern/deaktivieren, Menü-Undo, Speichern/Laden und Veröffentlichung. Neuer Tab tatsächlich geöffnet, Ursprungstab erhalten und `window.opener` null. Veröffentlichter Hintergrund ohne JavaScript bei 1600/900/390 px geprüft. Öffentliche Seite sowie Desktop-/Mobil-Editor ohne axe-Verstöße; Screenshots visuell geprüft (`reports/link-media/`). Vollständiges `npm run test:editor` ebenfalls erfolgreich, einschließlich Mediathek, Entwurf/Veröffentlichung/Konflikten und Desktop-/Mobil-Barrierefreiheit. Originaldaten bleiben unverändert; Schreibtests ausschließlich in temporären Kopien.
**Veröffentlicht:** Commit `6d21c45` und Tag `v1.7.3` auf `origin`; [Release 1.7.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.3) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3532 Archivdateien, 209 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `acf6a4ebea555fe7613935372e1a7922aca4e2c72646175a93d349359787e315`.
**Grenzen und nächster Schritt:** Keine erneute Lighthouse-Messung für diese Link-/Mediensteuerung; öffentliche Ausgabe und Barrierefreiheit direkt geprüft. Keine neuen Firefox-/Safari-/Screenreader-Prüfungen. Nach Veröffentlichung kann das Update über die Systemverwaltung eingespielt werden; Kundenhostings werden in diesem Arbeitsschritt nicht verändert.
## 1.7.2 – Bildbreite direkt in Prozent einstellen (14.09.2026)
**Umgesetzt:** Beim ausgewählten Bild steht im Reiter Inhalt oben „Bildbreite (%)“ mit Regler, Zahlenfeld und Zurücksetzen. 1–100 % beziehen sich auf die verfügbare Breite der Sektion/Spalte. Desktop, Tablet und Mobil verwenden die vorhandene Vererbung; Herkunft und bestehende maximale Breiten sind sichtbar. Beide Bildsteuerungen bearbeiten denselben gespeicherten Breitenwert wie Design → Größe. Originalmaße, Medienvarianten, Alt-Text und Bildunterschrift bleiben erhalten. Ungültige Zahlen verändern den gespeicherten Wert nicht, eine Ziehbewegung bleibt ein Undo-Schritt. Der veraltete Hinweis auf eine erst künftig verfügbare Mediathek wurde berichtigt. Keine neue Schema-/Datenbankmigration, keine Änderung am öffentlichen Renderer oder öffentlichen Paket.
**Geprüft:** `npm run test:image-size` auf einer isolierten Kant-IT-Kopie mit dem TeamViewer-Logo: Regler mit echter Mausbewegung und Tastatur, Zahlenfeld, Fokus, ungültiger Wert, Undo/Redo, responsive Vererbung/Reset, Speichern/Laden, unveränderte Bilddaten und Abgleich mit Design → Größe. Veröffentlichte Ausgabe ohne JavaScript bei 1600/900/390 px: 60/75/30 % und unverzerrtes Seitenverhältnis gemessen. Neue Steuerung auf Desktop/Mobil ohne Überlauf und axe-Verstöße, Screenshots visuell geprüft (`reports/image-size/`). Vollständige Editor-Browserprüfung erfolgreich, einschließlich Speichern/Veröffentlichen, Konflikten, Mediathek und Desktop-/Mobil-Barrierefreiheit (`reports/editor/`). 30 JavaScript-Tests und 35 PHP-Tests zu Styles/Bildern mit 67 Assertions erfolgreich. Alle Schreibtests in eigenen Datenbankkopien.
**Veröffentlicht:** Commit `0920e3c` und Tag `v1.7.2` auf `origin`; [Release 1.7.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.2) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen. Aus dem exakten Commit gebaut: 3531 Archivdateien, 208 versionierte Quelldateien bytegenau verglichen, nur Produktionsabhängigkeiten. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env`, keine `.env.local`) erfolgreich. ZIP-SHA-256: `e9983669871815e46c8a675bf59eb3632f37f015c0026ccb072a93119def5ac8`.
**Grenzen und nächster Schritt:** Das Update kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. Da nur die Editorsteuerung vorhandener Style-Werte geändert wurde, keine erneuten öffentlichen Lighthouse-Messungen; die Prozentdarstellung wurde im gemeinsamen Renderer direkt überprüft.
## 1.7.1 – Livesuche in der Verwaltung (14.09.2026)
**Umgesetzt:** Seiten und Beiträge suchen beim Tippen per AJAX; Status, 20/50 Einträge und Pagination aktualisieren nur die Ergebnisliste. URL, Browser-Zurück/Vor, Eingabefokus und Rückkehrfilter beim Speichern bleiben zusammenhängend. Die Mediathek durchsucht Titel und Alternativtext über alle Seiten, kombiniert mit Archivfilter. Bestehende Suchen in Menüs, übergeordneten Seiten, Editor-Werkzeugkasten/Struktur/Eigenschaften und Bildauswahl reagieren bereits beim Tippen. Kein Enter nötig; ohne JavaScript bleibt „Anwenden“ erhalten. Alte Antworten werden verworfen, Fehler erhalten Treffer und Eingaben, Wiederholen ist direkt möglich. Eine während der Suche begonnene Bearbeitung hält den Austausch an. SQLite-Suche erkennt Groß-/Kleinschreibung auch bei Umlauten. Keine Datenmigration und kein zusätzliches öffentliches JavaScript.
**Geprüft:** PHP 8.4.22: `EditorSecurityTest|CmsTest` 332 Tests / 2832 Assertions; nach der abschließenden Korrektur zur Suchanfrage „0“ die fünf Livesuche-/Medienfälle erneut mit 111 Assertions. 30 JavaScript-Tests; 24 Admin-Twig-Vorlagen; Produktionscontainer; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:search`: sechs Browser-Prüfgruppen mit kopierten Kant-IT-Daten, darunter schnelle/überholte Anfragen, Fehler/Wiederholen, abgelaufene Sitzung, IME, Pagination/Historie, Seitenauswahl nach AJAX, Bearbeitung während laufender Suche, Medien und Betrieb ohne JavaScript. Desktop 1440 und Mobil 390: kein horizontaler Überlauf, axe ohne Verstöße; Screenshots visuell geprüft. `test:admin -- --admin-only` bestätigt zusätzlich Menüs, Formulare, Bereiche, Speichern/Laden sowie die Vorschau mit aktivierter Bewegung; Tablet/Mobil ohne axe-Befunde. Berichte: `reports/live-search/`, `reports/admin-quality/`; Testkonten und Änderungen nur in isolierten Datenbankkopien.
**Veröffentlicht:** Commit `41d87f6` und Tag `v1.7.1` auf `origin`; [Release 1.7.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.1) ist das neueste stabile Release. ZIP, SHA-256-Datei und `install.php` hochgeladen und anschließend ohne Anmeldung heruntergeladen/bytegenau geprüft. Paket aus dem exakten Commit: 3531 Dateien, davon 208 versionierte Quelldateien verglichen; Produktionsabhängigkeiten ohne Entwicklungspakete, `.env` aus Vorlage, keine `.env.local`. Archivintegrität, Installer-Prüfsumme, Produktionscontainer/Cache und frische Installationsseite mit HTTP 200 geprüft. ZIP-SHA-256: `08846e3b8b700e66075160956a9e1963dc5d4f11a350dae175a0ff9511dd2ab1`.
**Grenzen und nächster Schritt:** Das Update ist veröffentlicht und kann über die Systemverwaltung eingespielt werden; Kundenhostings wurden nicht verändert. MySQL wurde in diesem Schritt nicht ausgeführt; die lokale Prüfung verwendet SQLite. Öffentliche Lighthouse-Messungen wurden für diese Verwaltungsänderung nicht wiederholt; Nachweise aus 1.7.0 stehen unten.
## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026)
**Veröffentlicht:** Commit `495b268` und annotierter Tag `v1.7.0` sind auf `origin` hochgeladen. [Release 1.7.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.0) enthält ZIP, SHA-256-Datei und `install.php`. Gebaut aus dem exakten Commit: 3527 Archivdateien, 204 versionierte Quelldateien bytegenau verglichen, Produktionsabhängigkeiten ohne Entwicklungspakete. Archivintegrität, Prüfsumme im Installer, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env` vorhanden, keine `.env.local`) geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; API meldet 1.7.0 als neuestes stabiles Release. ZIP-SHA-256: `b708f7dffad74e1def31325533b646557963b0f3e702c46bd85ee109fc22a192`. Das Update ist bereitgestellt, nicht auf Kundenhostings eingespielt.
**Umgesetzt:** `.env` ist die einzige automatisch geladene Installationsdatei und aus Git entfernt. Setup, Webinstaller und Admin-Pfad-Befehl schreiben dort. Alte `.env.local` wird einmalig mit Sicherung beider Originale zusammengeführt; wirksame Datenbank und Secret bleiben erhalten. System zeigt die tatsächliche Datenbank samt Quelle. Der Git-Updater erhält Konfiguration vor dem neuen PHP-Prozess und beim Rückweg; ein entdeckter falscher Datenbank-Sicherungspfad wurde ebenfalls korrigiert. Ein fehlgeschlagenes Zurückschreiben lässt die Website im Wartungszustand. Vorgehen beim erstmaligen Wechsel von altem Git-Updater-Code und bei FTP: UPDATE.md. Keine Schema-/Datenbankmigration.
**Verwaltung:** Fehler 500 beim Öffnen von Bereichen behoben. Seiten/Beiträge mit eigener Navigation, datenbankseitiger Pagination (20/50), Suche, Status, begrenztem Papierkorb und Filtererhalt. Elternauswahl unabhängig von der Ergebnisseite. Menüeditor mit Suchauswahl per Tastatur, eigenen Links, klarer Reihenfolge, Haupt-/Unterpunkten und Rückgängig; Validierungs-/Verbindungs-/Versionsfehler erhalten Eingaben. Formularmeldung mehrzeilig über die verfügbare Breite; Felddefinitionen auf schmalen Bildschirmen als beschriftete Gruppen. Mobil zugänglicher Kontolink.
**Bewegung:** Richtungen und Effekte werden beim Erscheinen tatsächlich unterschieden. Slider haben Pfeile, Zähler, optionale Punkte und Pause/Wiedergabe. Ein bereits geplanter Wechsel wird bei manueller Bedienung abgebrochen. Automatik wartet bei Hover, außerhalb des Bildschirms und in unsichtbaren Tabs; reduzierte Bewegung stoppt sie. Bewegung im Editor ausdrücklich testbar, iframe-Sandbox weiterhin ohne Skriptfreigabe. Kein JavaScript für die Lesbarkeit der Inhalte erforderlich.
**Geprüft:** PHP 8.4.22 Vollsuite 461 Tests/3577 Assertions bestanden; anschließend 10/10 Tests des finalen Updaters mit 206 Assertions, einschließlich echter Datenbankänderung vor dem Fehler und ausgefallenem Restore. `npm.cmd test` 30/30. Alle 43 Twig-Dateien und Produktionscontainer gültig. Vollständiger `npm.cmd run test:editor` bestanden, einschließlich neuer Links-/Rechtseinblendung, reduzierter Bewegung, Drag-and-drop, Undo/Redo, Speicher-/Veröffentlichungs-/Konfliktabläufen und sämtlichen axe-Prüfungen. `node scripts/admin-quality-check.mjs --audit` bestanden: isolierte Kant-IT-Kopie mit 4 Seiten/29 Beiträgen; Pagination ohne Überschneidungen, leere Suche, Seitengrößen und Grenzwerte; Menüsuche per Tastatur, Hierarchie/Undo/Speicherung sowie 422/409 ohne Eingabeverlust; breite Formularmeldung nach Neuladen; echte gemeinsame Sektion mit Text; Kopf/Fuß ändern und neu laden; mehrfaches Slider-Autoplay, manuelle Pause, Fortsetzen, reduzierte Bewegung und bedienbare Sandbox-Vorschau. Desktop/1024/390 px ohne Seitenüberlauf oder axe-Verstoß. Zusätzliche Prüfung ohne JavaScript und visuelle Screenshots bestanden. Kunden-/Pilotdatenbank bleibt eine nur gelesene Quelle; Testkonten und Änderungen liegen ausschließlich in temporären Kopien, deren Hintergrundaufgaben abgeschaltet sind.
**Öffentliche Messung:** Kant-IT-Startseite mit Originalmedien, Produktionsmodus, Lighthouse **13.4.1**, je drei Läufe: Leistung mobil **99/99/99**, Desktop **99/99/97**, Median jeweils **99**. Barrierefreiheit, Best Practices, SEO und Agentic Browsing in allen sechs Läufen **100**. Lokaler PHP-Server mit Kompression/Cachekopfzeilen über `bin/dev-router.php`; keine Garantie für Hosting oder beliebige Kundeninhalte. Berichte: `reports/admin-quality/lighthouse/summary.json`, einzelne Läufe daneben; Editor: `reports/editor/result.json`; Verwaltungsabläufe: `reports/admin-quality/result.json`. Screenshots der Verwaltung, der mobilen Formulardefinition und des Piloten visuell geprüft.
**Grenzen / nächster Schritt:** Kein neuer Firefox-/Safari-/Screenreader- oder echter Touchgerätetest, keine CWV-Felddaten. Der Pilot enthält vereinzelt bereits gespeicherte HTML-Entities als Klartext. Die übrigen offenen Entwicklungsplan-Pakete bleiben bestehen. Installations- und Bedienkorrekturen nach Veröffentlichung über den vorhandenen Archiv-Updater auf der gewünschten Installation übernehmen. Arbeitsplan: ARBEITSPLAN-ADMIN-QUALITAET.md; Entscheidungen: ADR 076–078.
## 1.6.2 – Sichtbare leere Sektionen und Spalten (14.09.2026)
**Veröffentlicht:** Commit `6a044e5` und annotierter Tag `v1.6.2` sind auf `origin` hochgeladen. [Release 1.6.2](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.2) enthält ZIP, SHA-256-Datei und `install.php`. Aus dem exakten Commit mit Produktionsabhängigkeiten gebaut: 3521 Archivdateien, 199 Quelldateien gegen den Commit verglichen, Archivintegrität und Produktionscontainer geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und gegen die lokalen Dateien geprüft; 1.6.2 ist laut API das neueste stabile Release. ZIP-SHA-256: `6af093d752fafa76c720c203e1e3ced03e37ace3481a405b7d6eed378255e449`. Das Update ist bereitgestellt, nicht auf Kundenwebsites installiert.
**Erledigt:** Leere Sektionen, Zeilen und Spalten besitzen in der Vorschau sichtbare, beschriftete Arbeitsflächen. „Inhalt hinzufügen“ setzt einen genauen Einfügepunkt, öffnet den Werkzeugkasten und fokussiert die Suche. Leere Sektionen bieten unmittelbar 1–4 Spalten; neue Elemente werden sichtbar gescrollt. Beim Ziehen sind Container und Ablageziel erkennbar, die Vorschau scrollt am Rand mit. Spalten werden auf Desktop horizontal, bei mobiler Stapelung vertikal eingeordnet. Geöffnete Feinschliff-Gruppen bleiben auch bei schnellen Änderungen erhalten. Cachekennung 1.6.2; keine Schema- oder Datenbankmigration. Die Hilfen werden ausschließlich im Editor-DOM erzeugt und erscheinen weder in gespeicherten Dokumenten noch auf öffentlichen Seiten. Entscheidungen: ADR 075.
**Referenz:** Die tatsächlichen Builder-Dateien des bereitgestellten Flatsome 3.20.9 wurden erneut gelesen. Übernommen sind sichtbare Leerflächen, direkte Plus-Aktionen und schnelle Spaltenlayouts. Konkrete Fundstellen und Umfang stehen in `docs/FLATSOME-ABGLEICH.md`; keine laufende WordPress-Referenzinstallation geprüft.
**Geprüft:** `npm.cmd test` 30/30; PHP 8.4.22 `vendor/bin/phpunit --filter EditorSecurityTest` 27/27 mit 312 Assertions; JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig. Der vollständige isolierte `npm.cmd run test:editor` besteht einschließlich axe-Prüfungen. Neue Abdeckung: sichtbare leere Sektion, Tastaturwahl des Zweispaltenlayouts, Einfügen in genau die zweite Spalte, Ziehen in die erste Spalte mit Zielmarkierung und automatischem Scrollen, Undo/Redo, leere Layouts nach Speichern/Neuladen, Spaltenumsortierung Desktop/Mobil und Veröffentlichung ohne Editorhilfen. Desktop-, Mobil- und Drag-Ansichten anhand der Screenshots visuell geprüft. Bericht: `reports/editor/result.json`; Bilder: `empty-section.png`, `empty-layout-desktop.png`, `empty-layout-mobile.png`, `empty-layout-drag.png` im selben Ordner. Keine Änderungen an Kundendaten durch die isolierten Tests.
**Bekannte Grenzen:** Der öffentliche Renderer ist unverändert; Lighthouse wurde für diese Fassung nicht erneut gemessen. Die Messung zu 1.6.1 bleibt unten dokumentiert. Kein neuer Screenreader-, Firefox-, Safari- oder echter Touchgerätetest. **Nächster Schritt:** Die neuen Aufbauabläufe mit Umuts tatsächlichen Seiten nutzen und anschließend die restlichen Arbeitspakete des Entwicklungsplans fortsetzen.
## 1.6.1 – Editorbreite, neue Seitenabschnitte und passende Eigenschaften (14.09.2026)
**Veröffentlicht am 14.09.2026:** Commit `b783d9d` und annotierter Tag `v1.6.1` sind auf `origin` (`https://git.kant-it.de/kantarci/KantSites.git`) hochgeladen. Das stabile [Release 1.6.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.6.1) enthält ZIP, SHA-256-Datei und `install.php`. Das Archiv wurde aus dem exakten Commit mit Produktionsabhängigkeiten gebaut (3520 Dateien); Archivintegrität, Übereinstimmung mit dem Commit und Produktionscontainer wurden geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; die API liefert 1.6.1 als neuestes stabiles Release. Archiv-SHA-256: `a6e7da2bb14ede43dd2d94960b3b4d67cc285d9c717b560f796a91cbcde4dd26`. Dieser Schritt veröffentlicht das Update, installiert es aber nicht auf Kundenwebsites.
**Erledigt:** Die festen 690/900/760-px-Grenzen für Text/H1/H2 sind entfernt. Elemente nutzen ihren Container, eigene Maximalbreiten bleiben möglich. Dauerhaft sichtbare Aktion **Neue Sektion auf Seitenebene**; auch der normale Sektionsklick erzeugt ohne expliziten Einfügepunkt einen übergeordneten Seitenabschnitt. Linke und rechte Sidebar sind per Ziehen und Tastatur verstellbar und merken ihre Breite im Browser. Die Vorschau nutzt ihre Fläche ohne Außenränder; die Elementbibliothek läuft nicht mehr horizontal über. Auf kleinen Fenstern passen sich die Panels an, auf dem Telefon stehen sie untereinander.
**Feinschliff:** Bild-, Text- und Containeroptionen werden passend gefiltert und sortiert. Gespeicherte Werte bleiben sichtbar; die gesamte Eigenschaftenauswahl ist über eine Schaltfläche erreichbar. Die Auswahl eines anderen Blocks setzt die Panelrolleiste nach oben. Geöffnete Eigenschaftengruppen bleiben beim Ändern eines Werts geöffnet. Automatische Größen und „Ohne Begrenzung“ sind explizit wählbar. Cachekennung auf 1.6.1 angehoben; keine Datenbankmigration, keine Kundeninhalte durch Tests geändert. Begründung und Rückweg: ADR 074.
**Geprüft:** `npm.cmd test`: 30/30; PHP 8.4.22 `vendor/bin/phpunit`: 452/452, 3476 Assertions. JavaScript-Syntax und alle 19 Admin-Twig-Dateien gültig; Produktionscache erfolgreich aufgewärmt, `git diff --check` sauber. `npm.cmd run test:editor -- --audit` vollständig bestanden: Seitenebenen/Undo/Redo, Panelbreiten per Maus/Tastatur und nach Neuladen, 390/850/1100/1920-px-Fenster, kontextbezogene Eigenschaften und bewusst gesetzte Textbreiten; auch Speichern, Neuladen, Veröffentlichen und Konfliktbehandlung. Sämtliche enthaltenen axe-Prüfungen ohne Verstoß. Desktop-, Bild- und Telefonansicht anhand der Browser-Screenshots visuell geprüft. Lighthouse **13.4.1**, isolierte öffentliche Editortestseite im Produktionsmodus: alle fünf Kategorien **100**, jeweils drei Läufe mobil und Desktop, auch jeder Einzellauf 100. Kein Nachweis für beliebige Kundeninhalte oder Feldwerte. Berichte: `reports/editor/result.json`, `reports/lighthouse-editor/summary.json`; Ansichten: `reports/editor/flexible-workspace.png`, `context-image.png`, `editor-phone.png`.
**Bekannte Grenzen:** Die geänderten Standardbreiten können den Zeilenumbruch bestehender Seiten verändern. Sektions- und ausdrücklich gespeicherte Elementbreiten bleiben wirksam. Kein neuer Screenreader- oder Felddatennachweis. **Nächster Schritt:** Umuts tatsächliche Editorabläufe mit realen Seiten durchgehen, danach den verbliebenen Ausbau gemäß Entwicklungsplan fortsetzen.
## Vorheriger Stand und bisherige Arbeitspakete
Stand: 14.09.2026, Fassung **1.6.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
@@ -14,6 +738,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
**Im Nachbau von kant-it.de gefunden und behoben:** vier tote Verweise im Hero-Slider – die Adressen waren im Aufbauskript von Hand geschrieben, die Beiträge bekamen längere. Sie werden jetzt aus derselben Regel abgeleitet, mit der die Beiträge entstehen, und ein zweiter Lauf des Skripts aktualisiert vorhandene Seiten, statt sie zu überspringen. Eine Prüfung aller internen Verweise auf 404 gibt es im CMS noch nicht; gefunden wurden sie mit einem einmaligen Durchlauf im Browser.
**Wiederkehrende Aufgaben ohne Cronjob (14.09.2026, ADR 073):** Bis hierher stand in vier Dokumenten „gehört in einen Cronjob“ – auf geteiltem Hosting oft nicht einlösbar, und die Folge war nicht „läuft seltener“, sondern **läuft nie**. Jetzt gibt es einen Aufgabenplan mit vier Auslösern: **jeder Seitenaufruf** (nach der ausgelieferten Antwort, in `kernel.terminate`), das **geöffnete Backend** (im Minutentakt, nur solange der Reiter sichtbar ist), ein **URL-Cronjob** unter `/cms-cron/<Schlüssel>` für Hoster, die nur eine Adresse aufrufen können, und die **Konsole** (`cms:tasks:run`, auch als Dauerläufer mit `--loop`). Fünf Aufgaben sind angeschlossen: geplante Veröffentlichung (5 Minuten), Statistik verdichten, Sicherung, Updateprüfung und Aufräumen (je täglich). Der Bildschirm **Aufgaben** zeigt Plan, letztes Ergebnis, Dauer, Auslöser und Protokoll, schaltet jede Aufgabe einzeln und ändert ihren Abstand. **Die Einschränkung steht auf diesem Bildschirm:** ohne Besuch und ohne geöffnetes Backend passiert nichts – eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst um 07:15 Uhr.
## Erledigt
- `AGENTS.md` und `ENTWICKLUNGSPLAN.md` unverändert aus Downloads im Projektstamm abgelegt; SHA-256 jeweils mit dem Original identisch.
@@ -36,11 +762,13 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
- **Aufgabenplan ohne Cronjob** (ADR 073, Migration `20260914000100`): eine Zeile je Aufgabe mit Fälligkeit, Sperre und letztem Ergebnis. Wer sie ausführen darf, entscheidet ein bedingtes `UPDATE` – auf MySQL wie auf SQLite atomar, damit zwei gleichzeitige Besucher eine Aufgabe nicht zweimal starten; die Sperre verfällt nach 15 Minuten von selbst. Der Normalfall kostet **keine Datenbankabfrage**: in `var/scheduler.json` steht, wann überhaupt das nächste Mal etwas ansteht. Ein Fehlschlag ist ein sichtbarer Zustand mit wachsendem Abstand bis zum nächsten Versuch (Verdopplung, gedeckelt bei sechs Stunden), und eine scheiternde Aufgabe reißt nie eine Seite mit. Neue Aufgaben sind eine Datei: wer `App\Ops\Tasks\Task` implementiert, steht beim nächsten Abgleich im Plan. Befehle: `cms:tasks`, `cms:tasks:run` (`--task`, `--limit`, `--budget`, `--loop`, `--max-time`).
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
- **System nennt die tatsächlich benutzte Datenbank und deren Quelle** (1.7.0, ADR 076): Art, Dateipfad, Größe, Schreibrecht und `.env` beziehungsweise Server-Umgebung. Die bereits lokal begonnene Diagnose wurde in den neuen Konfigurationsvertrag übernommen. Verdeckte Datei-Overlays entfallen; die bisher wirksamen Werte werden einmalig gesichert und in `.env` zusammengeführt.
- **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an.
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
@@ -141,6 +869,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
- **Aufgabenplan geprüft** (14.09.2026): 12 Tests auf die Mechanik (nur Fälliges läuft; eine gesperrte Aufgabe wird übergangen, eine abgelaufene Sperre übernommen; ein Fehlschlag wird festgehalten und der nächste Versuch rückt weiter weg; eine abgeschaltete Aufgabe läuft nicht, von Hand gestartet doch; eine Anfrage arbeitet nicht alles auf einmal ab; eine fehlende Tabelle ist kein Fehler) und 5 über HTTP (der Bildschirm nennt jede Aufgabe und die Adresse des URL-Cronjobs; anonym führt niemand etwas aus, eine Redakteurin bekommt 403, ohne gültiges Formular passiert nichts; der Takt des Backends antwortet mit JSON; ein falscher Cron-Schlüssel bekommt 404). Von Hand auf der Entwicklungsinstallation **alle vier Auslöser ausgeführt**: alle fünf Aufgaben über die Konsole (Sicherung 36 ms, Updateprüfung 318 ms gegen das echte Repository), ein Seitenaufruf hat eine fällige Aufgabe abgearbeitet (Auslöser `besuch` im Protokoll), das geöffnete Backend hat getaktet (`POST /admin/aufgaben/takt` → 200), und der URL-Cronjob hat mit dem richtigen Schlüssel Text geliefert und mit einem falschen 404. Der Bildschirm wurde im Browser bedient (Abstand und Schalter gespeichert und wieder zurückgesetzt); dabei fiel eine Inline-Stilangabe auf, die die CSP der Verwaltung blockiert hat – sie ist jetzt eine Klasse.
- Ladeverhalten geprüft: drei Tests – 30 Fassungen ergeben zwei Seiten mit richtiger Reihenfolge und richtigem Rest, eine Seite jenseits des Endes zeigt die letzte statt einer leeren; das erste Bild trägt `loading="eager"` und `fetchpriority="high"`, das zweite `lazy`; und die Startseite bleibt unter zwölf Abfragen. Dazu das Ladebudget in der Browserprüfung.
- **Wiederherstellung nachgewiesen**, nicht nur gebaut – das war der älteste offene Punkt aus Arbeitspaket I. Acht Tests gegen eine wegwerfbare Installation, darunter der eigentliche: Sicherung anlegen, Seite und Bilddatei löschen, zurückspielen, beides ist wieder da. Dazu von Hand auf der Entwicklungsinstallation: Testseite angelegt (5 Seiten), gesichert, gelöscht (4), zurückgespielt (5, Seite mit ihrer Adresse wieder da), Testseite entfernt (4). Ebenfalls geprüft: eine manipulierte Sicherung wird abgewiesen, eine aus einer neueren Fassung nur mit `--force`, Rotation behält die neuesten, und eine nach der Sicherung hochgeladene Datei überlebt die Wiederherstellung.
- Seitenbereiche geprüft: Standard, anderer Bereich, „keiner“ (dann steht weder `site-header` noch `site-footer` im HTML, die Seite selbst aber schon), ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück, eine veraltete Fassungsnummer und eine ungültige Adresse werden abgewiesen. Menüarten: drei angenommen, eine erfundene abgewiesen, Burger als `details` – und die Seite behält `script-src 'none'`. Seitenbreite: die Regel in `site.css` nimmt den Theme-Wert, und ein größerer Wert erscheint als `--k-container` im Seiten-Stylesheet.
@@ -199,6 +928,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
- **Aus Umuts erweitertem Auftrag noch offen** (Paket G2 im Ausbauplan): die Neugestaltung der Verwaltung.
- Eigenes CSS kennt keine Gerätevarianten – dafür sind die Vorschaugrößen im Designbereich da. Eigenes Skript hat keine Fassungsverwaltung außer der Seitenhistorie und keine Prüfung auf Syntaxfehler; ein fehlerhaftes Skript schreibt in die Browserkonsole und lässt die Seite sonst unangetastet.
@@ -369,6 +1099,134 @@ als Platzhaltertext, nicht als eigene Kennzeichnung. Noch offen aus Teil 3: Vors
Suche im Werkzeugkasten, Vorlagen je Element mit Bild, und das Ziehen der Spaltenbreite in der
Vorschau.
## 1.5.3: der Editor blieb nach dem Update schwarz
**Der Fehler.** Nach 1.5.2 meldete der Editor im Browser
`The requested module './editor-model.js' does not provide an export named 'dropList'`, und der
Werkzeugkasten war leer.
Ursache war nicht der Code, sondern das Zwischenspeichern. `public/.htaccess` setzt für **jede**
`.js`-Datei `Cache-Control: public, max-age=31536000, immutable` – ein Jahr. Die Fassungskennung
`?v=` hängt aber nur an den Einstiegsdateien, die die Vorlage schreibt. Die fünf Module, die der
Editor über `import './…​.js'` lädt, tragen keine: der Browser holte das neue `editor.js` und
behielt das alte `editor-model.js`. Ein neues `canvas-overlay.js` traf damit auf ein Modell ohne
`dropList`.
Beide gemeldeten Symptome hatten diese eine Wurzel: seit 1.5.1 baut das Skript den Werkzeugkasten
auf (`buildLibrary()`), und `editor.js` importiert `canvas-overlay.js` in Zeile 5. Bricht der
Import, läuft keine Zeile danach – und die Elementliste bleibt leer. Das gemeldete „Menü fehlt“
war der Werkzeugkasten, nicht die Verwaltungsnavigation. Die wurde geprüft: in Chrome 152 stehen
`::details-content`, ausgeblendeter Schalter und sichtbare Liste korrekt.
**Die Behebung.** Eine Importkarte in `base.html.twig` hängt die Fassung an die aufgelöste
Adresse jedes Moduls, ohne dass eine einzige Importzeile sie kennen muss. Das behält das
Zwischenspeichern über ein Jahr – die Leistung war nie das Problem, nur die fehlende Kennung.
Eine Importkarte muss eingebettet sein; die Spezifikation kennt keine externe. Unter
`script-src 'self'` wurde sie wortlos verworfen. `AdminHeadersSubscriber` erzeugt deshalb je
Verwaltungsseite einen Nonce, nennt ihn in der Richtlinie und legt ihn in die Anfrage; die
Twig-Funktion `csp_nonce()` gibt ihn der Karte mit. Der Nonce erlaubt genau dieses eine Skript –
`'self'` gilt unverändert für alles andere.
**Geprüft.** Der Browsertest schreibt jede angeforderte `.js`-Adresse mit und verlangt an jeder
eine Fassungskennung; vor der Behebung nennt er die fünf fehlenden beim Namen, danach trägt sie
alle acht. Dazu ein Test, der jeden `./…​.js`-Import in `public/assets` gegen die Importkarte
hält – ein neues Modul, das dort fehlt, fällt damit im Test auf und nicht erst beim nächsten
Update beim Kunden. Gegenprobe gemacht: mit einem entfernten Eintrag schlägt er fehl.
`npm test` 28 von 28, `php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig.
**Bekannte Grenze.** Ein Modulfehler legt den Editor weiterhin vollständig lahm, und der
Werkzeugkasten ist seit 1.5.1 nichts ohne sein Skript. Für einen Editor ist das vertretbar – die
öffentliche Website läuft ohne JavaScript –, aber die Fehleranzeige dafür ist noch die
Browserkonsole und kein Satz in der Oberfläche.
## Arbeitspaket H, Teil 3b: der Werkzeugkasten bekommt Bilder und eine Suche
**Erledigt.** Die Elementwahl war eine Liste aus Schriftzeichen – „▣“ für Sektion, „☰“ für Zeile.
Jetzt trägt jeder der 34 Einträge eine Strichzeichnung, die zeigt, was er baut: die Sektion ein
gefülltes Band, „Zeile, 3 Spalten“ tatsächlich drei Kästen, das Bild einen Rahmen mit Bergzug.
**Zeichnungen statt Bilddateien.** Flatsome liefert je Element ein PNG mit. Gezeichnet ist hier
besser: `library-previews.js` ist ein paar hundert Byte statt eines Ordners voller Bitmaps, die
Formen nehmen die Farbe der Fläche an, auf der sie liegen – gesperrte Einträge werden damit von
selbst blass – und sie bleiben auf jedem Bildschirm scharf. Die Zeilenvorschau wird gerechnet,
nicht gezeichnet: zwei, drei oder vier Kästen aus derselben Funktion.
**Suche.** Ein Feld über der Liste filtert über alle Gruppen. Gesucht wird über den Namen, den
internen Typ **und** den Gruppennamen – wer „slider“ eingibt, muss nicht wissen, dass er unter
„Gruppen“ einsortiert ist. Gruppen ohne Treffer verschwinden, damit die Liste keine Lücken
behält, und darunter steht, wie viele von wie vielen übrig sind.
**Dabei bemerkt:** Es gab damit zwei Suchfelder mit dem Namen „Element suchen“ auf einem
Bildschirm – für eine Sprachausgabe nicht unterscheidbar. Sie heißen jetzt nach dem, was sie
durchsuchen: „Element zum Einfügen suchen“ im Werkzeugkasten, „Element auf der Seite suchen“ im
Strukturbaum.
**Geprüft.** Der Browsertest zählt die Vorschaubilder gegen die Einträge (alle 34 haben eines),
sucht nach „galerie“ (ein Eintrag, eine Gruppe), nach „aufbau“ (fünf Einträge über den
Gruppennamen), nach etwas Nichtvorhandenem und wieder nach nichts. `npm test` 28 von 28,
`php vendor/bin/phpunit` 452 von 452, `npm run test:editor` vollständig ohne axe-Verstoß.
**Noch offen aus Teil 3.** Vorlagen je Element mit Vorschaubild. Und das Ziehen der
Spaltenbreite: das geht heute nicht ohne einen Zwischenschritt, denn eine Zeile ist ein
CSS-Raster mit **gleich breiten** Spalten (`repeat(n, minmax(0,1fr))`); ein `span` je Spalte
gibt es nicht. Dafür braucht es erst ein Zwölf-Spalten-Modell im Designtoken-Manifest, und das
berührt die öffentliche CSS und damit die Messwerte aus AGENTS.md. Das ist ein eigener Schritt,
kein Nebenbei.
## 1.5.5: die Verwaltungsnavigation war am Schreibtisch unerreichbar
**Der Fehler.** In der ausgelieferten Fassung fehlte in der Verwaltung am Schreibtisch die
gesamte linke Navigation – Marke oben, Fußzeile unten, dazwischen nichts. Kein Weg zu Seiten,
Mediathek, Einstellungen außer über die Adresszeile.
Die Navigation steckt in einem `<details>`: auf dem Telefon ein Klappmenü, am Schreibtisch die
stehende Spalte. Am Schreibtisch wurde der Schalter mit `.shell-menu > summary { display: none }`
ausgeblendet – aber das Element trug kein `open`, und ein geschlossenes `details` versteckt seinen
Inhalt selbst. Schalter weg **und** Inhalt weg.
**Die Behebung.** `::details-content` holt den Inhalt am Schreibtisch zurück, während der
Schalter ausgeblendet bleibt. Kennt ein Browser die Regel nicht, fällt über `@supports` die ganze
Gruppe weg: dann steht der Schalter da und die Liste ist einen Klick entfernt – statt gar nicht
da. Auf dem Telefon bleibt es beim Klappmenü.
Der Code dafür lag bereits im Arbeitsverzeichnis: eine parallel laufende Sitzung hatte ihn
geschrieben, aber nicht committet, und damit war er in keinem Release. Übernommen ist hier nur
diese Korrektur, nicht die übrige Arbeit jener Sitzung.
**Wie das durchrutschen konnte – und was dagegen getan ist.** Beim vorigen Bericht wurde die
Navigation für in Ordnung erklärt, nachdem eine **Nachbildung** der Regel in einer leeren Seite
geprüft worden war. Die Nachbildung enthielt die Korrektur, die ausgelieferte Datei nicht; der
Befund war damit wertlos. Jetzt prüft der Browsertest die **echte** Verwaltungsseite: am
Schreibtisch kein Schalter, Navigation sichtbar, der Eintrag „Seiten“ mit messbarer Fläche; auf
390 px Breite ein Schalter, Liste zunächst zu, ein Klick öffnet sie. Gegenprobe gemacht – gegen
die ausgelieferte `admin.css` schlägt der Test fehl.
**Geprüft.** `npm run test:editor` vollständig, `npm test` 28 von 28,
`php vendor/bin/phpunit` 452 von 452.
## 1.6.0: der Aufgabenplan wird veröffentlicht
Diese Fassung bringt den **Aufgabenplan ohne Cronjob** (ADR 073, oben beschrieben) in ein
Release. Er entstand in einer parallel laufenden Sitzung und lag bis hierher unveröffentlicht im
Arbeitsverzeichnis – die Releases 1.5.0 bis 1.5.5 wurden bewusst aus dem jeweiligen Tag gebaut
und enthielten ihn deshalb nicht.
**Vor der Veröffentlichung geprüft, nicht bloß eingepackt:**
- `php vendor/bin/phpunit` 452 von 452, `npm test` 28 von 28, `npm run test:editor` vollständig.
- Die Migration `20260914000100` legt **nur** die Tabelle `scheduled_task` an und nimmt sie beim
Zurückrollen wieder weg. Sie fasst keine bestehenden Daten an – das ist die risikoarme Sorte.
- Seit einer Stunde keine Schreibzugriffe der anderen Sitzung mehr; der Stand ist ruhig.
**Dabei zwei Fehler behoben:**
- `config/services.yaml` verwendet `!tagged_iterator`. Das ist gültiges Symfony, aber
`php bin/console lint:yaml config` kennt eigene Tags nur mit `--parse-tags` und gab 1 zurück –
der CI-Lauf in `.github/workflows/ci.yml` wäre daran gescheitert. Das Flag ist ergänzt.
- `src/EventSubscriber/AdminHeadersSubscriber.php` (aus 1.5.3) entsprach nicht dem Stil von
`php-cs-fixer`; `composer lint` gab 8 zurück. Behoben, jetzt 0 von 128 Dateien zu ändern.
## Nächster konkreter Schritt
Arbeitspaket H, Teil 3b: Vorschaubilder und Suche im Werkzeugkasten, Vorlagen je Element mit
+13 -1
View File
@@ -124,4 +124,16 @@ var/releases/install.php → Release-Anhang
- **Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte.** Sonst würde der Rückweg Inhalte verlieren, die während des Updates entstanden sind.
- **Der Rauchtest ist `cms:health`** und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt.
- **Noch offen:** MySQL wird gesichert, indem `mysqldump` aufgerufen wird – das ist **nicht** umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden.
- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt.
- **Inzwischen umgesetzt:** der Installer (`/install`, ADR 041) und der Weg ohne Git über das Releasearchiv (`scripts/install.php`, System → Update aus dem Archiv). Offen bleibt allein die MySQL-Sicherung.
## Übergang auf 1.7.0: `.env` gehört zur Installation
Der Archiv-Updater erhält die Konfiguration auch bei diesem Übergang. Der erste Start führt eine bestehende `.env.local` mit `.env` zusammen und sichert beide Originale in `var/backups/environment-*`. Danach ist allein `.env` maßgeblich; tatsächliche Servervariablen bleiben möglich. Datenbank, Medienordner und Secret bleiben die bisher wirksamen Werte. Siehe ADR 076 und README.
In alten Git-Installationen war `.env` noch versioniert. Beim erstmaligen Wechsel kann ein alter Git-Updater diese neue Schutzlogik noch nicht besitzen. Nutze dafür den Archiv-Updater. Bei einem manuellen Git-Checkout zuerst `.env` und gegebenenfalls `.env.local` außerhalb des Checkout-Verzeichnisses sichern, beide nach dem Checkout wiederherstellen und erst danach Setup/Migration/Cache starten. Ab 1.7.0 erhält der Git-Updater die Dateien selbst vor dem ersten neuen PHP-Prozess. `.env` nicht erneut ins Repository aufnehmen.
Bei FTP-Updates die `.env` aus dem neuen Archiv überspringen. Die Vorlage im Archiv ist ausschließlich für Erstinstallationen bestimmt. Ein Rückweg benötigt den alten Code plus die beiden gesicherten Konfigurationsdateien.
## MySQL und MariaDB (seit 16.09.2026, ADR 104)
Sicherung und Wiederherstellung brauchen auf einer MySQL-Installation die Clients `mysqldump` und `mysql` im Pfad des PHP-Prozesses (auf geteiltem Hosting beim Anbieter erfragen; ein voller Pfad lässt sich nicht eintragen, aber `mysqldump.phar`/`mysql.phar` im Projektstamm würden gefunden). Die Sicherung schreibt `datenbank.sql` mit `--single-transaction`; die Wiederherstellung spielt sie mit `mysql --execute=source` ein. Fehlt der Client zum Einspielen, sagt die Wiederherstellung das und nennt den Pfad des Dumps. Der Archiv-Updater und der Git-Updater sichern vorher über denselben Weg und spielen beim Rückweg dieselbe Sicherung ein. Beide Wege sperren sich gegenseitig über `var/update.lock`; ein zweiter Aufruf bekommt die Meldung „Ein anderes Update läuft gerade“.
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260914000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Der Aufgabenplan: eine Zeile je wiederkehrender Aufgabe, mit Faelligkeit, Sperre und letztem Ergebnis.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Warum eine Tabelle und keine Datei: die Sperre. Zwei Besucher koennen im selben
// Moment dieselbe Aufgabe faellig sehen, und nur einer darf sie ausfuehren. Ein
// bedingtes UPDATE ist auf MySQL wie auf SQLite atomar; zwei Prozesse, die eine Datei
// sperren, sind es auf geteiltem Hosting nicht zuverlaessig.
//
// Die Tabelle bleibt klein - eine Zeile je registrierter Aufgabe, heute fuenf. Deshalb
// steht hier kein Index: ein voller Durchlauf ueber fuenf Zeilen ist billiger als ein
// Index, den jede Aenderung mitpflegen muss.
$tasks = $schema->createTable('scheduled_task');
// Der technische Name aus dem Code, zum Beispiel `publish-due`. Er ist der Schluessel:
// verschwindet eine Aufgabe aus dem Code, verschwindet ihre Zeile beim naechsten Abgleich.
$tasks->addColumn('name', 'string', ['length' => 64]);
// Der Abstand zwischen zwei Laeufen. Kommt als Vorgabe aus dem Code und ist in der
// Verwaltung aenderbar, weil eine taegliche Sicherung auf der einen Installation
// richtig und auf der naechsten zu selten ist.
$tasks->addColumn('interval_seconds', 'integer', ['default' => 3600]);
$tasks->addColumn('enabled', 'boolean', ['default' => true]);
// Alle Zeitpunkte in UTC, als Zeichenkette verglichen - wie ueberall sonst im Projekt.
$tasks->addColumn('next_run_at', 'datetime_immutable');
$tasks->addColumn('last_run_at', 'datetime_immutable', ['notnull' => false]);
// Bis wann diese Aufgabe als "laeuft gerade" gilt. Danach darf sie ein anderer
// Durchlauf uebernehmen: ein Prozess, den das Hosting nach 30 Sekunden abgeschossen
// hat, kann seine Sperre nicht mehr selbst aufraeumen.
$tasks->addColumn('locked_until', 'datetime_immutable', ['notnull' => false]);
$tasks->addColumn('last_status', 'string', ['length' => 16, 'notnull' => false]);
$tasks->addColumn('last_message', 'string', ['length' => 500, 'default' => '']);
$tasks->addColumn('last_duration_ms', 'integer', ['default' => 0]);
// Wodurch der letzte Lauf ausgeloest wurde: besuch, verwaltung, cron, konsole, hand.
// Steht hier, weil die erste Frage bei einer Stoerung immer dieselbe ist: laeuft der
// Ausloeser ueberhaupt, den der Betreiber eingerichtet zu haben glaubt?
$tasks->addColumn('last_trigger', 'string', ['length' => 16, 'default' => '']);
// Aufeinanderfolgende Fehlschlaege. Sie verlaengern den Abstand bis zum naechsten
// Versuch, damit eine Aufgabe, die nicht laufen kann, nicht bei jedem Seitenaufruf
// wieder anfaengt.
$tasks->addColumn('failures', 'integer', ['default' => 0]);
$tasks->setPrimaryKey(['name']);
}
public function down(Schema $schema): void
{
// Faellt die Tabelle, faellt nur der Plan: Zeitpunkte und letzte Meldungen. Kein Inhalt,
// kein Konto, keine Datei. Beim naechsten Start legt der Abgleich sie neu an.
$schema->dropTable('scheduled_task');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260915000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Plugins: eine Zeile je Erweiterung mit dem Schalter, ob sie aktiv ist.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Was ein Plugin kann, steht in seinem Manifest im Ordner plugins/. Hier steht nur, ob der
// Betreiber es eingeschaltet hat - eine Entscheidung der Installation, nicht des Codes,
// und deshalb in der Datenbank statt in einer Datei, die ein Update ueberschreiben koennte.
$plugin = $schema->createTable('plugin');
$plugin->addColumn('name', 'string', ['length' => 40]);
$plugin->addColumn('active', 'boolean', ['default' => false]);
$plugin->addColumn('activated_at', 'datetime_immutable', ['notnull' => false]);
// Die Fassung, mit der es zuletzt eingeschaltet wurde - damit die Verwaltung sagen kann,
// dass ein Plugin seit dem Einschalten aktualisiert wurde.
$plugin->addColumn('version', 'string', ['length' => 32, 'default' => '']);
$plugin->setPrimaryKey(['name']);
}
public function down(Schema $schema): void
{
$schema->dropTable('plugin');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260916000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Plugins: die Einstellungen je Erweiterung, wie sie der Betreiber in der Verwaltung setzt.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Was ein Plugin einstellbar macht, steht in seinem Manifest; was der Betreiber gewaehlt
// hat, hier - als JSON, weil jedes Plugin andere Schluessel hat. Eine Spalte, kein
// Tabellenneubau: auf SQLite ist ADD COLUMN eine Zeile im Katalog.
$schema->getTable('plugin')->addColumn('settings', 'text', ['default' => '{}']);
}
public function down(Schema $schema): void
{
$schema->getTable('plugin')->dropColumn('settings');
}
}
+15 -1
View File
@@ -3,12 +3,26 @@
"private": true,
"type": "module",
"scripts": {
"build:buttons": "node scripts/build-buttons.mjs",
"test:buttons": "node scripts/button-check.mjs",
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
"build:design": "node scripts/build-design.mjs",
"test": "node --test tests/*.test.mjs",
"test:editor": "node scripts/editor-check.mjs",
"test:browser": "node scripts/browser-check.mjs",
"test:links": "node scripts/link-check.mjs",
"audit": "node scripts/audit.mjs"
"audit": "node scripts/audit.mjs",
"test:admin": "node scripts/admin-quality-check.mjs",
"test:search": "node scripts/live-search-check.mjs",
"test:image-size": "node scripts/image-size-check.mjs",
"test:image-alignment": "node scripts/image-alignment-check.mjs",
"test:link-media": "node scripts/link-media-check.mjs",
"test:404": "node scripts/not-found-check.mjs",
"test:colors": "node scripts/color-picker-check.mjs",
"test:accordion": "node scripts/accordion-check.mjs",
"test:motion": "node scripts/motion-check.mjs",
"test:archive-update": "node scripts/archive-update-check.mjs"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
@@ -0,0 +1,23 @@
/* Bildvergleich. Die Position kommt als --pos (Prozent) vom Skript; ohne Skript gilt die Mitte. */
.block-bildvergleich{--pos:50%;margin:0}
.bildvergleich-buehne{position:relative;margin:0;overflow:hidden;user-select:none;-webkit-user-select:none}
.bildvergleich-rund .bildvergleich-buehne{border-radius:12px}
.bildvergleich-buehne img{display:block;width:100%;height:auto;border-radius:0}
.bildvergleich-vorher{position:absolute;inset:0;height:100%;object-fit:cover;clip-path:inset(0 calc(100% - var(--pos)) 0 0)}
.bildvergleich-linie{position:absolute;top:0;bottom:0;left:var(--pos);width:2px;margin-left:-1px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25);pointer-events:none}
.bildvergleich-linie::after{content:'⇔';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px;border-radius:50%;background:#fff;color:#182d2b;display:grid;place-items:center;font-size:20px;box-shadow:0 4px 14px rgba(0,0,0,.3)}
.bildvergleich-etikett{position:absolute;top:14px;padding:6px 10px;border-radius:6px;background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;pointer-events:none}
.bildvergleich-etikett-vorher{left:14px}
.bildvergleich-etikett-nachher{right:14px}
.bildvergleich-regler{position:absolute;inset:0;display:block;cursor:ew-resize}
.bildvergleich-regler input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:ew-resize}
.bildvergleich-regler input:focus-visible{opacity:1;appearance:none;-webkit-appearance:none;background:transparent;outline:3px solid #fff;outline-offset:-6px}
.block-bildvergleich .block-unavailable{margin:0}
/* Griff als Punkt statt Pfeile (Plugin-Einstellung). */
.bildvergleich-griff-punkt .bildvergleich-linie::after{content:'';width:22px;height:22px}
/* Die Vergleichsgalerie: ein Raster aus Bildvergleichen. */
.block-vergleichsgalerie{margin:0}
.vergleichsgalerie-titel{margin:0 0 18px}
.vergleichsgalerie-raster{display:grid;gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}
.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:repeat(3,minmax(0,1fr))}
@media(max-width:767px){.vergleichsgalerie-raster,.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:1fr}}
+14
View File
@@ -0,0 +1,14 @@
/**
* Bildvergleich: der Regler setzt --pos auf der Bühne. Ohne dieses Skript steht der Vergleich
* bei der gespeicherten Startposition; Tastatur und Vorlesesoftware bedienen das native
* Bereichsfeld in beiden Fällen.
*/
(() => {
for (const block of document.querySelectorAll('[data-bildvergleich]')) {
const range = block.querySelector('input[type="range"]');
if (!range) continue;
const apply = () => block.style.setProperty('--pos', `${range.value}%`);
range.addEventListener('input', apply);
apply();
}
})();
@@ -0,0 +1,24 @@
{# Bildvergleich: das Nachher-Bild liegt unten, das Vorher-Bild darüber und wird per clip-path
auf den linken Teil beschnitten. Der Regler ist ein natives Bereichsfeld – ohne Skript bleibt
er stehen, beide Bilder sind trotzdem zu sehen. Die Vorlage bekommt nur, was der Kern geprüft
hat: `props` nach dem Schema, `settings` des Plugins, `block.id`, `designClasses`. #}
{% set vorher = props.before ? kant_plugin_media(props.before) : null %}
{% set nachher = props.after ? kant_plugin_media(props.after) : null %}
{% set labelVorher = props.labelBefore ?: settings.labelBefore|default('Vorher') %}
{% set labelNachher = props.labelAfter ?: settings.labelAfter|default('Nachher') %}
<div id="{{ block.id }}" class="block-bildvergleich{{ props.rounded ? ' bildvergleich-rund' : '' }} bildvergleich-griff-{{ settings.handle|default('pfeile') }}{{ designClasses }}" data-bildvergleich data-position="{{ props.position }}">
{% if vorher and nachher %}
{% set gross = nachher.variants|last %}
<figure class="bildvergleich-buehne">
<img class="bildvergleich-nachher" src="{{ gross.src }}" srcset="{% for v in nachher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ gross.width }}" height="{{ gross.height }}" alt="{{ props.alt ?: nachher.alt }}" loading="lazy" decoding="async">
{% set grossVorher = vorher.variants|last %}
<img class="bildvergleich-vorher" src="{{ grossVorher.src }}" srcset="{% for v in vorher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ grossVorher.width }}" height="{{ grossVorher.height }}" alt="" loading="lazy" decoding="async">
<span class="bildvergleich-etikett bildvergleich-etikett-vorher" aria-hidden="true">{{ labelVorher }}</span>
<span class="bildvergleich-etikett bildvergleich-etikett-nachher" aria-hidden="true">{{ labelNachher }}</span>
<span class="bildvergleich-linie" aria-hidden="true"></span>
<label class="bildvergleich-regler"><span class="visually-hidden">{{ labelVorher }} und {{ labelNachher }} vergleichen: Regler verschieben</span><input type="range" min="0" max="100" value="{{ props.position }}" step="1"></label>
</figure>
{% else %}
<p class="block-unavailable">{{ editing ? 'Bildvergleich: bitte unter Inhalt ein Bild für vorher und eines für nachher wählen.' : '' }}</p>
{% endif %}
</div>
@@ -0,0 +1,10 @@
{# Die Vergleichsgalerie: ein Container. Seine Kinder kommen fertig gerendert als `children`
herein - der Kern hat sie mit demselben Kontext gerendert wie jeden anderen Baustein. #}
<div id="{{ block.id }}" class="block-vergleichsgalerie vergleichsgalerie-{{ props.columns }}{{ designClasses }}">
{% if props.title %}<h3 class="vergleichsgalerie-titel">{{ props.title }}</h3>{% endif %}
{% if children|trim %}
<div class="vergleichsgalerie-raster">{{ children }}</div>
{% elseif editing %}
<p class="block-unavailable">Vergleichsgalerie: Bildvergleiche hier hineinziehen.</p>
{% endif %}
</div>
+63
View File
@@ -0,0 +1,63 @@
{
"name": "vergleich",
"title": "Bildvergleich",
"description": "Vorher und nachher in einem Bild: ein Regler schiebt zwischen zwei Aufnahmen – für Renovierungen, Malerarbeiten, Sanierungen.",
"version": "1.1.0",
"requires": "1.17.0",
"settings": {
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher", "maxLength": 40 },
"labelAfter": { "kind": "text", "label": "Standardbeschriftung nachher", "default": "Nachher", "maxLength": 40 },
"handle": { "kind": "select", "label": "Griff des Reglers", "choices": { "pfeile": "Pfeile", "punkt": "Punkt" }, "default": "pfeile" }
},
"blocks": {
"bildvergleich": {
"label": "Bildvergleich",
"description": "Zwei Bilder übereinander, ein Regler dazwischen.",
"icon": "⇔",
"template": "blocks/bildvergleich.html.twig",
"css": "assets/bildvergleich.css",
"script": "assets/bildvergleich.js",
"props": {
"before": { "kind": "media", "label": "Bild vorher" },
"after": { "kind": "media", "label": "Bild nachher" },
"labelBefore": { "kind": "text", "label": "Beschriftung vorher", "help": "Leer: die Standardbeschriftung aus den Plugin-Einstellungen.", "maxLength": 40 },
"labelAfter": { "kind": "text", "label": "Beschriftung nachher", "maxLength": 40 },
"alt": { "kind": "text", "label": "Was die Bilder zeigen", "help": "Ein Satz für Vorlesesoftware, zum Beispiel „Fassade vor und nach dem Anstrich“.", "maxLength": 200 },
"position": { "kind": "number", "label": "Startposition des Reglers (%)", "min": 5, "max": 95, "default": 50 },
"rounded": { "kind": "boolean", "label": "Abgerundete Ecken", "default": true }
}
},
"galerie": {
"label": "Vergleichsgalerie",
"description": "Mehrere Bildvergleiche nebeneinander im Raster.",
"icon": "⊞",
"template": "blocks/galerie.html.twig",
"css": "assets/bildvergleich.css",
"container": true,
"children": ["vergleich.bildvergleich"],
"props": {
"columns": { "kind": "select", "label": "Spalten am Schreibtisch", "choices": { "2": "Zwei", "3": "Drei" }, "default": "2" },
"title": { "kind": "text", "label": "Überschrift der Galerie", "maxLength": 120 }
}
}
},
"presets": {
"abschnitt": {
"label": "Vorher-Nachher-Abschnitt",
"description": "Eine Sektion mit Überschrift, kurzem Text und einem Bildvergleich.",
"blocks": [
{
"id": "vergleich-abschnitt", "type": "section", "layout": { "tone": "paper", "width": "normal" },
"children": [
{ "id": "vergleich-titel", "type": "heading", "props": { "text": "Vorher und nachher", "level": 2 } },
{ "id": "vergleich-text", "type": "text", "props": { "text": "Ziehen Sie den Regler und sehen Sie den Unterschied." } },
{ "id": "vergleich-bild", "type": "vergleich.bildvergleich", "props": { "before": "", "after": "", "labelBefore": "", "labelAfter": "", "alt": "", "position": 50, "rounded": true } }
]
}
]
}
},
"routes": {
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
}
}
+3
View File
@@ -0,0 +1,3 @@
{# Eine eigene Adresse des Plugins: die Einstellungen als JSON - fuer eine App oder ein Skript,
das die Beschriftungen kennen will. Die Vorlage bekommt `settings`, `query` und `route`. #}
{{ {plugin: 'vergleich', labels: {before: settings.labelBefore, after: settings.labelAfter}, handle: settings.handle, echo: query}|json_encode(constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_UNESCAPED_SLASHES'))|raw }}
+3 -7
View File
@@ -14,13 +14,9 @@ RewriteRule ^ index.php [L]
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Eine Domain, eine Adresse. Ohne diese Umleitung ist dieselbe Seite unter www und ohne www
# erreichbar - zwei Adressen fuer denselben Inhalt, und Suchmaschinen muessen raten, welche gilt.
# Die Richtung muss zu SITE_ORIGIN passen; hier die Fassung "immer mit www".
#RewriteCond %{HTTP_HOST} !^www\. [NC]
#RewriteCond %{HTTP_HOST} !^localhost [NC]
#RewriteCond %{HTTP_HOST} !^127\.0\.0\.1 [NC]
#RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der
# Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin.
# Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen.
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
+331 -68
View File
@@ -147,31 +147,61 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
.shell-user { display: flex; align-items: center; gap: 10px; padding: 8px 10px; font-size: 12px }
.shell-user strong { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
/* Der Fuss der Leiste: Kontokarte, Abmeldesymbol, Fassung. Eine Einheit statt dreier Zeilen. */
.shell-foot { margin-top: auto; padding-top: 14px; border-top: 1px solid #2a3d35; display: grid; gap: 8px }
.shell-account {
display: flex;
align-items: center;
gap: 4px;
padding: 5px;
border-radius: 13px;
background: #ffffff0b;
border: 1px solid #ffffff10;
}
.shell-user {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 5px 7px;
border-radius: 9px;
text-decoration: none;
color: var(--shell-ink);
}
.shell-user:hover, .shell-user[aria-current=page] { background: #ffffff12 }
.shell-user[aria-current=page] .shell-avatar { box-shadow: 0 0 0 2px var(--mint) }
.shell-user-text { display: grid; min-width: 0; line-height: 1.3 }
.shell-user-text strong { font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.shell-user-text span { font-size: 11px; color: var(--shell-muted) }
.shell-avatar {
display: grid;
place-items: center;
width: 28px; height: 28px;
width: 32px; height: 32px;
border-radius: 50%;
background: #2b4a3b;
color: var(--mint);
font-size: 12px;
background: var(--mint);
color: var(--shell);
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.shell-foot form { margin: 0 }
.shell-foot button {
width: 100%;
justify-content: flex-start;
.shell-logout {
display: grid;
place-items: center;
width: 38px; height: 38px;
padding: 0;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--shell-muted);
font-size: 12px;
font-weight: 500;
min-height: 34px;
padding: 6px 10px;
}
.shell-foot button:hover { background: #21362d; color: var(--shell-ink) }
.shell-logout .nav-icon { color: inherit }
.shell-logout:hover { background: #ffffff14; color: #fff }
.shell-logout:focus-visible { outline-color: var(--mint); outline-offset: -2px }
.shell-version { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 0 8px 2px; font-size: 11px; color: var(--shell-muted) }
.shell-version a { text-decoration: none }
.shell-version > a:hover { color: var(--shell-ink); text-decoration: underline }
.brand {
display: inline-flex;
@@ -211,13 +241,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 +286,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 }
@@ -428,7 +487,7 @@ details.card > summary:hover { background: #fafbf8 }
.table .check-cell input { width: 18px; min-height: 18px }
.table-new input, .table-new select { background: #fafbf8 }
.table form { margin: 0 }
/* Column widths per screen: the two tables carry different things. */
/* Column widths per screen: the menu table carries text, links and switches. */
.menu-table td:first-child input { width: 82px }
.menu-table td:nth-child(2) select { width: 148px }
.menu-table td:nth-child(3) input { min-width: 170px }
@@ -436,27 +495,23 @@ details.card > summary:hover { background: #fafbf8 }
.menu-table td:nth-child(5) select { min-width: 190px }
.menu-table td:nth-child(6) input { width: 130px }
.menu-table td:nth-child(7) input { min-width: 190px }
.field-table td:first-child input { width: 140px }
.field-table td:nth-child(2) select { width: 150px }
.field-table td:nth-child(3) input { min-width: 170px }
.field-table td:nth-child(5) input { min-width: 180px }
.field-table td:nth-child(6) input { min-width: 170px }
.theme-table td input { width: 190px }
.theme-table td select { width: 150px }
.theme-table td input[type=color] { width: 52px }
.theme-table .weights-cell { white-space: nowrap }
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
.tasks-table td input[type=number] { width: 92px }
.tasks-table td .field-help { margin-top: 4px }
.tasks-table .row-actions, .tasks-table td:last-child { white-space: nowrap }
/* Headings inside a card separate its parts without a second border. */
.card-body .section-head { margin: 28px 0 12px }
.card-body .section-head:first-child { margin-top: 14px }
.card-body > .primary, .card-body > .secondary { margin-top: 24px }
/* A screen made of several equal sections (menu slots, form definitions). */
/* A screen made of several equal sections (menu slots). */
.stack-section { display: grid; gap: 10px }
.menu-form > label:first-of-type, .form-def > label:first-of-type { margin-top: 16px }
.menu-form > label:first-of-type { margin-top: 16px }
.menu-form > input[name=name] { max-width: 420px }
.menu-form .table, .form-def .table { margin-top: 22px }
.menu-form .primary, .form-def .primary { margin-top: 20px }
.menu-form .table { margin-top: 22px }
.menu-form .primary { margin-top: 20px }
.empty {
padding: 34px;
@@ -474,10 +529,6 @@ details.card > summary:hover { background: #fafbf8 }
.contrast-ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
.contrast-warn { background: #fff6e2; color: #6f4e18; border: 1px solid #e6cf9c }
.contrast-bad { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
.theme-form .theme-swatch { width: 52px; min-height: 38px; padding: 2px; cursor: pointer }
.theme-form .weights-cell { white-space: nowrap }
.theme-form .weights-cell label { display: inline-flex; align-items: center; gap: 4px; margin: 0 9px 0 0; font-size: 11px; font-weight: 400 }
.theme-form .weights-cell input { width: 14px; min-height: 14px }
/* ---- submissions ----------------------------------------------------------------------- */
@@ -513,25 +564,33 @@ details.card > summary:hover { background: #fafbf8 }
/* ---- narrow screens: the sidebar becomes a strip above the content --------------------- */
@media (max-width: 1000px) {
.admin-shell { grid-template-columns: 1fr }
/* minmax(0, 1fr), nicht 1fr: sonst nimmt die Spalte die Mindestbreite der Leiste an und
schiebt die ganze Seite ueber den rechten Rand hinaus. */
.admin-shell { grid-template-columns: minmax(0, 1fr) }
.shell-nav {
position: static;
height: auto;
min-width: 0;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
overflow: visible;
gap: 6px;
padding: 11px 14px;
}
/* Nur die Liste wischt; Konto und Abmelden bleiben rechts im Bild. */
.shell-menu { flex: 1 1 auto; min-width: 0; overflow-x: auto }
.shell-foot { flex: none }
.shell-nav .brand { margin: 0 10px 0 0; font-size: 0 }
.shell-nav .brand-icon { margin: 0; font-size: 15px }
.nav-label { display: none }
.nav-list { display: flex; gap: 4px }
.nav-item { white-space: nowrap; padding: 8px 12px }
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 14px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center }
.shell-user strong { display: none }
.shell-foot button { width: auto }
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
.shell-account { padding: 3px; gap: 2px }
.shell-user { padding: 3px }
.shell-user-text { display: none }
.shell-version { padding: 0 }
.shell-top { padding: 22px 20px 18px }
.shell-body { padding: 20px 20px 48px }
}
@@ -545,6 +604,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. */
@@ -552,40 +623,70 @@ details.card > summary:hover { background: #fafbf8 }
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
.field-set .check-label { margin-top: 14px }
/* ---- statistics: charts drawn by the server, styled by class ---------------------------- */
/* ---- statistics: tiles, charts drawn by the server, ranked lists ------------------------ */
.range-switch { display: flex; gap: 8px; flex-wrap: wrap }
.range-switch a { font-size: 12px; padding: 8px 14px; min-height: 36px }
.chart { display: block; width: 100%; height: 180px; overflow: visible }
/* Der Zeitraum als eine Leiste mit einem gedrueckten Knopf, nicht vier lose Schaltflaechen. */
.range-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper) }
.range-switch a { display: inline-flex; align-items: center; min-height: 32px; padding: 6px 13px; border-radius: 7px; font-size: 12px; font-weight: 600; text-decoration: none; color: var(--muted) }
.range-switch a:hover { color: var(--ink); background: #eef2ea }
.range-switch a[aria-current=page] { background: var(--shell); color: #fff }
/* Die Kennzahlen: eine grosse dunkle Kachel fuer die Summe, kleine helle fuer alles, was sie einordnet. */
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); gap: 12px }
.stat-tile { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 16px 18px; background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
.stat-tile-main { grid-column: span 2; background: var(--shell); border-color: var(--shell); color: var(--shell-ink) }
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted) }
.stat-value { font-size: 28px; font-weight: 650; letter-spacing: -.8px; line-height: 1.1; font-variant-numeric: tabular-nums }
.stat-note { font-size: 12px; line-height: 1.45; color: var(--muted); overflow-wrap: anywhere }
.stat-tile-main .stat-label, .stat-tile-main .stat-note { color: var(--shell-muted) }
.stat-tile-main .stat-value { font-size: 40px; color: #fff }
.stat-tile-main .stat-up { color: #bfe3a5 }
.stat-tile-main .stat-down { color: #f3b9a8 }
.chart { display: block; width: 100%; height: 190px; overflow: visible }
.chart-hours, .chart-weekdays { height: 150px }
.chart-bar { fill: var(--accent) }
.chart-bar:hover { fill: #1c4735 }
.chart-axis { stroke: var(--line); stroke-width: 1 }
.chart-legend { display: flex; justify-content: space-between; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
.chart-bar-peak { fill: var(--ring) }
.chart-grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 3 }
.chart-axis { stroke: #c3cec7; stroke-width: 1 }
.chart-legend { display: flex; justify-content: space-between; gap: 8px; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
.chart-legend-even span { flex: 1; text-align: center }
.chart-table { margin-top: 18px }
.chart-table > summary { cursor: pointer; font-size: 12px; font-weight: 600; padding: 6px 0 }
.table-wrap { max-height: 320px; overflow: auto; margin-top: 10px }
.stats-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px }
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px; counter-reset: rank }
.stats-columns-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)) }
/* Rang, Name, Anteil am Spitzenwert als Balken, Zahl. */
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px }
.stats-list li {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
display: grid;
grid-template-columns: 22px minmax(0, 1fr) 72px auto;
align-items: center;
gap: 10px;
padding: 7px 0;
border-top: 1px solid #eef1ed;
font-size: 13px;
}
.stats-list li:first-child { border-top: 0 }
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
.stats-rank { font-size: 11px; font-weight: 700; color: var(--muted); text-align: center }
.stats-list span:not(.stats-rank), .stats-list a { overflow-wrap: anywhere; min-width: 0 }
.stats-share { display: block; width: 100%; height: 6px; overflow: visible }
.stats-share-bar { fill: var(--accent); opacity: .5 }
.stats-list li:first-child .stats-share-bar { opacity: 1 }
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right }
@media (max-width: 620px) {
.stat-tile-main { grid-column: 1 / -1 }
.stats-list li { grid-template-columns: 22px minmax(0, 1fr) auto }
.stats-share { display: none }
}
/* ---- 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;
@@ -600,7 +701,6 @@ details.card > summary:hover { background: #fafbf8 }
.check-bad { color: var(--danger) }
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
.log-list code { font-size: 11px; overflow-wrap: anywhere }
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
@@ -665,26 +765,189 @@ details.card > summary:hover { background: #fafbf8 }
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
.shell-menu > summary { display: none }
/* Der Schalter traegt seine Gestalt hier, nicht erst im Telefonabschnitt: am Schreibtisch
wird er ausgeblendet, aber nur dort, wo der Inhalt sicher sichtbar ist. */
.shell-menu > summary {
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
min-height: 44px; padding: 0 14px; border-radius: 10px;
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
}
.shell-menu > summary::-webkit-details-marker { display: none }
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
.shell-menu[open] > summary { margin-bottom: 8px }
.shell-menu-icon { font-size: 17px }
/* Am Schreibtisch steht die Liste dauerhaft da. Ein geschlossenes details-Element versteckt
seinen Inhalt aber von sich aus - der Schalter allein auszublenden loescht die Navigation.
Erst ::details-content holt sie zurueck. Kennt ein Browser die Regel nicht, faellt die
ganze Gruppe weg und der Schalter bleibt stehen: die Liste ist dann einen Klick entfernt
statt gar nicht da. */
@supports selector(::details-content) {
.shell-menu > summary { display: none }
.shell-menu::details-content { content-visibility: visible; block-size: auto }
}
@media (max-width: 760px) {
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
.shell-menu { flex: 1 1 100%; order: 3 }
.shell-menu > summary {
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
min-height: 44px; padding: 0 14px; border-radius: 10px;
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
}
.shell-menu > summary::-webkit-details-marker { display: none }
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
.shell-menu-icon { font-size: 17px }
.shell-menu[open] > summary { margin-bottom: 8px }
.shell-menu > summary { display: flex }
.shell-menu nav { display: block }
.shell-menu:not([open]) nav { display: none }
.shell-menu:not([open])::details-content { content-visibility: hidden; block-size: 0 }
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
.nav-list { display: grid; gap: 2px }
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
.shell-version { display: none }
.shell-user strong { display: none }
}
/* Search and forms share usable widths throughout administration. */
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
.selected-media-preview{margin:12px 0;padding:10px;border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.selected-media-preview img{display:block;width:100%;height:140px;object-fit:contain;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px}.selected-media-preview figcaption{font-size:12px;line-height:1.5;overflow-wrap:anywhere;margin-top:8px;color:var(--muted)}
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
.picker-results{position:absolute;z-index:30;top:100%;left:0;right:0;min-width:220px;max-height:300px;overflow-y:auto;background:white;border:1px solid #a7b9af;border-radius:8px;box-shadow:0 10px 30px #18382b24;padding:5px}
.picker-results[hidden]{display:none}.picker-results button{display:flex;flex-direction:column;align-items:start;width:100%;padding:10px;text-align:left;color:#163d30;background:white;border:0;border-radius:5px}
.picker-results button:hover,.picker-results button[aria-selected=true]{background:#e8f2e9}.picker-results span{font-size:12px;color:#496458;overflow-wrap:anywhere}.picker-status:empty{display:none}
@media(max-width:650px){.list-toolbar>div,.list-toolbar .list-search{min-width:0;flex-basis:100%}.picker-results{min-width:0}.picker-input button{min-width:44px;min-height:44px}}
/* One menu at a time: content picker on the left, ordered structure on the right. */
.menu-slots{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 24px}.menu-slots a{padding:10px 16px;border:1px solid var(--line);border-radius:8px;text-decoration:none;background:white}.menu-slots a[aria-current=page]{background:var(--shell);color:white;border-color:var(--shell)}
.menu-workspace{display:grid;grid-template-columns:minmax(260px,320px) minmax(0,1fr);gap:24px;align-items:start}.menu-add{display:grid;gap:16px;position:sticky;top:20px}.menu-add .card{margin:0}.menu-add .content-picker{margin-bottom:16px}.menu-structure{min-width:0}.menu-items{list-style:none;padding:0;margin:24px 0;display:grid;gap:12px}.menu-entry{border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.menu-entry.menu-child{margin-left:32px;border-left:3px solid #8eaa99}.menu-entry summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px;cursor:pointer}.menu-entry summary::after{content:'⌄';font-size:18px}.menu-entry-title{overflow-wrap:anywhere;flex:1}.menu-entry-fields{padding:0 16px 16px}.menu-entry-fields .content-picker{margin-top:14px}.menu-entry-actions{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;border-top:1px solid var(--line);background:#f7f9f6;border-radius:0 0 9px 9px}.menu-entry-actions button{min-height:36px}.menu-entry-actions .danger-text{margin-left:auto}.menu-save{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding-top:16px;border-top:1px solid var(--line)}.menu-save [role=status]{font-size:13px;color:var(--muted)}
@media(max-width:1100px){.menu-workspace{grid-template-columns:minmax(220px,280px) minmax(0,1fr)}}
@media(max-width:850px){.menu-workspace{grid-template-columns:1fr}.menu-add{position:static;grid-template-columns:1fr 1fr}.menu-add>p{grid-column:1/-1}}
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
.form-def{min-width:0;margin:0}
.version-update{display:inline-block;padding:4px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
.menu-entry summary::after{content:none}.menu-entry summary{list-style:none;gap:10px;padding:10px 14px}.menu-entry summary::-webkit-details-marker{display:none}.menu-drag-handle{display:grid;place-items:center;width:36px;min-width:36px;height:40px;padding:0;border:0;background:transparent;color:#576e60;cursor:grab;touch-action:none}.menu-drag-handle:active{cursor:grabbing}.menu-chevron{flex:none;color:#52665a;transition:transform .15s}.menu-entry details[open] .menu-chevron{transform:rotate(180deg)}.menu-entry-kind{font-size:11px;border:1px solid #dae4dc;border-radius:5px;padding:3px 6px;color:#506659;background:#f7f9f6;white-space:nowrap}.menu-entry-title{display:flex;align-items:center;gap:8px;min-width:0}.menu-entry-title svg{width:19px;height:19px;flex:none}.menu-dragging{opacity:.6;box-shadow:0 4px 16px #203b3426;border-color:#2c6b4f}.menu-icon-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}@media(max-width:560px){.menu-entry summary{padding:8px}.menu-entry-kind{display:none}.menu-icon-fields{grid-template-columns:1fr}}
/* ---- the form editor: a card per field, the look, and a preview beside it -------------- */
.form-screens { display: grid; gap: 20px }
.form-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 20px 36px; padding-top: 2px }
.form-editor-main { min-width: 0 }
.form-part { margin: 26px 0 0; font-size: 11px; font-weight: 700; letter-spacing: .9px; text-transform: uppercase; color: var(--muted) }
.form-editor-main > .form-part:first-child, .form-editor-preview .form-part, .type-preview-column .form-part { margin-top: 14px }
.form-fields-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 28px }
.form-fields-head .form-part { margin: 0 }
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
.field-card { position: relative; padding: 2px 16px 16px; border: 1px solid var(--line); border-radius: 11px; background: #fafbf8 }
.field-card-new { border-style: dashed; background: var(--paper) }
.field-card-head { display: grid; grid-template-columns: 26px minmax(0, 1fr) 190px 38px; gap: 0 12px; align-items: end }
.field-card-index { display: grid; place-items: center; width: 26px; height: 26px; margin-bottom: 8px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
.field-card label { margin-top: 12px }
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 38px }
.field-card-body .field-help { margin-top: 5px }
.field-card-flags { align-self: end; padding-bottom: 11px }
.field-card-flags .check-label { margin: 12px 0 0 }
.field-remove { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 2px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted) }
.field-remove .nav-icon { width: 17px; height: 17px; color: inherit }
.field-remove:hover { background: #fff0eb; border-color: #e2b2a7; color: var(--danger) }
/* Die Vorschau bleibt beim Blaettern durch lange Feldlisten im Bild. */
.form-editor-preview { min-width: 0; position: sticky; top: 16px; align-self: start }
/* Die Vorschau uebernimmt Text- und Seitenfarbe der Website per Skript; die Felder lesen dieselben
Variablen wie auf der Website selbst. */
.form-preview { display: grid; gap: 16px; margin-top: 12px; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; font-size: 14px; line-height: 1.5 }
.form-preview-field { display: grid; gap: 6px }
.form-preview-label { font-weight: 600 }
.form-preview-input { display: block; min-height: 40px; padding: 9px 12px; border: 1px solid var(--form-border, currentColor); border-radius: 8px; background: var(--form-bg, #fff); color: inherit }
.form-preview-select { display: flex; align-items: center; justify-content: space-between }
.form-preview-select::after { content: '⌄'; font-size: 16px; line-height: 1 }
.form-preview-area { min-height: 92px }
.form-preview-check { display: flex; align-items: flex-start; gap: 9px }
.form-preview-box { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--form-border, currentColor); border-radius: 4px; background: var(--form-bg, #fff) }
.form-preview-help { font-size: 12px }
.form-preview-step { padding-top: 6px; border-top: 1px solid var(--line); display: grid; gap: 2px }
.form-preview-step-title { font-weight: 700; font-size: 15px }
.form-preview-radio { border-radius: 50% }
.form-preview-number { display: flex; align-items: center; justify-content: flex-end; opacity: .7 }
.form-preview-conditional { padding-left: 10px; border-left: 2px dashed var(--line) }
.field-card-number { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 12px }
.field-card-condition { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px }
.field-card-condition .field-help { grid-column: 1 / -1 }
.field-card-images { grid-column: 1 / -1 }
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px }
.field-image-picker select { margin: 0 }
.form-preview-empty { margin: 0; font-size: 13px }
.form-preview + .contrast-note { margin-top: 10px }
.form-preview .cms-button { justify-self: start; pointer-events: none }
.form-editor-foot { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
.form-editor-foot .muted { font-size: 12px }
.form-look .color-control { margin-top: 0 }
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
@media (max-width: 700px) {
.field-card { padding-top: 8px }
.field-card-head { grid-template-columns: 26px minmax(0, 1fr) }
.field-card-title, .field-card-type { grid-column: 2 }
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
.field-card-body { padding-left: 0 }
}
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
.theme-form > .card { margin: 0 }
.palette-presets { margin-top: 14px }
.palette-presets-label { display: block; margin-bottom: 8px; font-size: 12px; font-weight: 600 }
.palette-preset-row { display: flex; flex-wrap: wrap; gap: 8px }
.palette-preset { display: grid; gap: 6px; justify-items: center; padding: 8px 10px 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); font-size: 11px; font-weight: 600 }
.palette-preset svg { display: block; width: 90px; height: 24px; border-radius: 6px; overflow: hidden; box-shadow: inset 0 0 0 1px #203b3426 }
.palette-preset:hover { border-color: var(--accent); background: #f5f8f1 }
.palette-presets .field-help { margin-top: 10px }
.swatch-list { list-style: none; margin: 18px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px }
.swatch-card { position: relative; display: grid; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
.swatch-card-new { border-style: dashed }
/* Der Farbwaehler ist die Flaeche selbst: nichts liegt dazwischen. */
.swatch-well { position: relative; display: grid; margin: 0; cursor: pointer }
.swatch-input { display: block; width: 100%; height: 76px; min-height: 0; padding: 0; border: 0; border-radius: 0; background: none; cursor: pointer }
.swatch-input::-webkit-color-swatch-wrapper { padding: 0 }
.swatch-input::-webkit-color-swatch { border: 0; border-radius: 0 }
.swatch-input::-moz-color-swatch { border: 0 }
.swatch-hex { position: absolute; left: 10px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: #ffffffe6; color: var(--ink); font-size: 11px; box-shadow: 0 1px 2px #0000001f }
.swatch-fields { display: grid; padding: 0 12px 12px }
.swatch-fields label { margin: 10px 0 4px; font-size: 11px }
.swatch-fields input { min-height: 36px; padding: 6px 10px; font-size: 13px }
.swatch-card .field-remove { position: absolute; top: 6px; right: 6px; width: 32px; height: 32px; background: #ffffffe6 }
.list-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px }
.list-actions .field-help { flex: 1 1 300px; margin: 0 }
.font-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px }
.font-card { position: relative; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
.font-card-new { border-style: dashed }
/* Die Probe steht in der Schrift selbst - das Skript setzt die Familie, die Datei liegt auf dem Server. */
.font-sample { display: flex; align-items: center; gap: 18px; min-height: 88px; padding: 18px 60px 18px 22px; background: #fafbf8; border-bottom: 1px solid var(--line) }
.font-sample-big { flex: none; font-size: 44px; line-height: 1; font-weight: 500 }
.font-sample-line { display: grid; gap: 6px; min-width: 0 }
.font-sample-text { font-size: 19px; line-height: 1.3; overflow-wrap: anywhere }
.font-sample-weights { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted) }
.font-weight-100 { font-weight: 100 } .font-weight-200 { font-weight: 200 } .font-weight-300 { font-weight: 300 }
.font-weight-400 { font-weight: 400 } .font-weight-500 { font-weight: 500 } .font-weight-600 { font-weight: 600 }
.font-weight-700 { font-weight: 700 } .font-weight-800 { font-weight: 800 } .font-weight-900 { font-weight: 900 }
.font-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 16px; padding: 0 22px 18px }
.font-fields label { margin-top: 12px }
.font-weights { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 0; padding: 0; border: 0 }
.font-weights legend { float: left; margin-right: 8px; padding: 0; font-size: 12px; font-weight: 600 }
.chip { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 5px 10px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); font-size: 12px; font-weight: 500; cursor: pointer }
.chip input { width: 14px; min-height: 14px; margin: 0 }
.chip:has(input:checked) { border-color: var(--accent); background: #edf4e8; color: #22553e }
.font-card .field-remove { position: absolute; top: 10px; right: 10px }
.type-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: 16px 36px }
.type-fields { min-width: 0 }
.type-colors .color-control { margin-top: 0 }
.type-preview-column { min-width: 0 }
.type-preview { margin-top: 12px; padding: 26px 26px 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; overflow: hidden }
.type-preview p { margin: 0 }
.type-preview .type-preview-h1 { margin-bottom: 10px; font-size: 34px; font-weight: 700; line-height: 1.15; letter-spacing: -.5px }
.type-preview .type-preview-h2 { margin-bottom: 14px; font-size: 26px; font-weight: 600; line-height: 1.2 }
.type-preview-p { font-size: 17px; line-height: 1.6 }
.type-preview-link { text-decoration: underline; text-underline-offset: 3px }
.theme-save { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
.theme-save .muted { font-size: 12px }
.theme-form .button-defaults { margin: 0; background: var(--paper) }
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
+1
View File
@@ -0,0 +1 @@
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}
+95
View File
@@ -0,0 +1,95 @@
import { devices, effectiveStyle, hoverStyle, locate, upgradeDocument, setStyle } from './editor-model.js';
export function effectiveButton(block, device, defaults) {
const result = { ...defaults };
for (const name of Object.keys(devices)) {
Object.assign(result, block.layout?.button?.[name]);
if (name === device) break;
}
return result;
}
/** Change appearance without touching copy, URL, typography, sizing or layout placement. */
export function setButtonDesign(document, id, device, values) {
const block = locate(document.blocks, id)?.block;
if (block?.type !== 'button' || !Object.hasOwn(devices, device)) return;
if (Object.values(values).some(value => value !== '')) upgradeDocument(document, 21);
const target = ((block.layout ??= {}).button ??= {});
target[device] ??= {};
for (const [key, value] of Object.entries(values)) {
if (value === '') delete target[device][key]; else target[device][key] = value;
// A chosen shape/appearance must visibly take effect even on an older hand-styled button.
// Other groups (font, width, padding, margins, position) remain untouched. Undo is atomic.
const reset = key === 'shape' ? ['borderRadius'] : key === 'appearance' ? ['backgroundColor', 'backgroundImage', 'gradientFrom', 'gradientTo', 'color', 'borderColor', 'borderWidth', 'borderStyle'] : [];
for (const property of reset) {
setStyle(document, id, device, property, '');
setStyle(document, id, device, property, '', { hover: true });
}
}
if (!Object.keys(target[device]).length) delete target[device];
if (!Object.keys(target).length) delete block.layout.button;
}
const element = (tag, className, text) => {
const node = document.createElement(tag); if (className) node.className = className;
if (text !== undefined) node.textContent = text; return node;
};
const color = (value, schema) => value?.startsWith('@') ? schema.colors.find(row => row.key === value.slice(1))?.value || '#203b34' : value;
export function previewVariables(node, values, schema) {
const set = (key, value) => node.style.setProperty(`--kb-${key}`, String(value));
set('fill', color(values.backgroundColor, schema)); set('ink', color(values.color, schema));
set('hover-fill', color(values.hoverBackgroundColor, schema)); set('hover-ink', color(values.hoverColor, schema));
const font = schema.fonts.find(font => font.key === values.fontFamily);
set('font', font ? `${font.family ? `"${font.family}",` : ''}${font.fallback}` : 'inherit');
set('font-size', `${values.fontSize}px`); set('weight', values.fontWeight);
set('tracking', `${values.letterSpacing}px`); set('transform', values.textTransform);
}
export function buttonSample(text, values, schema, hover = false) {
const sample = element('span', `cms-button b-desktop-shape-${values.shape} b-desktop-appearance-${values.appearance} b-desktop-size-${values.size}${hover ? ' is-hover' : ''}`, text || 'Button');
previewVariables(sample, values, schema);
return sample;
}
export function renderButtonControls(panel, { block, device, schema, apply }) {
const group = element('section', 'button-designer'); group.setAttribute('aria-label', 'Button-Gestaltung');
group.append(element('h3', null, 'Button-Gestaltung'));
const current = effectiveButton(block, device, schema.buttons);
const previews = element('div', 'button-state-previews');
for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) {
const state = element('div', 'button-state'); state.append(element('span', 'field-help', label));
const sample = buttonSample(block.props.text, current, schema, hover);
// Preview the block's own free styling as well as its variant and site defaults.
for (const [key, definition] of Object.entries(schema.properties)) {
if (!definition.css || definition.target || definition.sides || definition.corners || ['position','display','width','height','minWidth','maxWidth','minHeight','maxHeight'].includes(key)) continue;
let value = effectiveStyle(block, device, key).value;
if (hover) for (const name of Object.keys(devices)) { value = hoverStyle(block, name, key) ?? value; if (name === device) break; }
if (value !== undefined && value !== '') sample.style.setProperty(definition.css, definition.type === 'color' ? color(value, schema) : String(value));
}
state.append(sample); previews.append(state);
}
group.append(previews);
const cards = element('div', 'button-preset-grid'); cards.setAttribute('role', 'group'); cards.setAttribute('aria-label', 'Button-Vorlagen');
for (const [index, preset] of schema.buttonPresets.entries()) {
const choice = element('button', 'button-preset'); choice.type = 'button'; choice.id = `button-preset-${index}`;
choice.setAttribute('aria-label', `Button-Vorlage ${preset.name}`);
choice.setAttribute('aria-pressed', String(current.shape === preset.shape && current.appearance === preset.appearance));
const sample = buttonSample('Button', { ...schema.buttons, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true');
choice.append(sample, element('span', 'button-preset-label', preset.name));
choice.addEventListener('click', () => apply({ shape: preset.shape, appearance: preset.appearance }, choice.id)); cards.append(choice);
}
group.append(cards);
for (const [key, options] of Object.entries(schema.buttonOptions)) {
const id = `button-${key}`, label = element('label', null, { shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe' }[key]); label.htmlFor = id;
const select = element('select'); select.id = id;
select.append(new Option(`Übernehmen: ${options[current[key]]}`, ''));
for (const [value, name] of Object.entries(options)) select.append(new Option(name, value));
select.value = block.layout?.button?.[device]?.[key] ?? '';
select.addEventListener('change', () => apply({ [key]: select.value }, id)); group.append(label, select);
}
const reset = element('button', 'secondary full-width', device === 'desktop' ? 'Website-Standard übernehmen' : 'Größeres Gerät übernehmen'); reset.type = 'button'; reset.id = 'button-reset';
reset.addEventListener('click', () => apply({ shape: '', appearance: '', size: '' }, reset.id));
group.append(reset, element('p', 'field-help', `${devices[device]}: Form und Darstellung übernehmen die Website-Vorgaben, solange kein eigener Wert gesetzt ist. Vorlagen ersetzen eigene Füllung und Rahmen; Schrift, Breite und Abstände bleiben erhalten.`));
panel.append(group);
}
+28
View File
@@ -0,0 +1,28 @@
/* Shared by the public renderer and the actual button samples in the editor. */
.cms-button{display:inline-flex;align-items:center;justify-content:center;gap:.65em;max-width:100%;min-height:44px;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));border:2px solid var(--b-border,var(--kb-border,transparent));border-radius:var(--b-radius,var(--kb-radius,8px));background:var(--b-bg,var(--kb-bg,var(--kb-fill,var(--ink,#203b34))));color:var(--b-fg,var(--kb-fg,var(--kb-ink,var(--paper,#f6f7f4))));font-family:var(--kb-font,inherit);font-size:var(--kb-font-size,14px);font-weight:var(--kb-weight,600);letter-spacing:var(--kb-tracking,0);text-transform:var(--kb-transform,none);line-height:1.6;text-align:center;text-decoration:none;overflow-wrap:anywhere;box-sizing:border-box}
.cms-button:hover,.cms-button:focus-visible,.cms-button.is-hover{background:var(--b-hover-bg,var(--kb-hover-bg,var(--kb-hover-fill,var(--ink,#203b34))));color:var(--b-hover-fg,var(--kb-hover-fg,var(--kb-hover-ink,var(--paper,#f6f7f4))));text-decoration:none}
.cms-button:focus-visible{outline:3px solid currentColor;outline-offset:4px}
.cms-button svg{width:1.15em;height:1.15em;flex:none}
.cms-button:where(.b-desktop-shape-square){--b-radius:0px}.cms-button:where(.b-desktop-shape-rounded){--b-radius:8px}.cms-button:where(.b-desktop-shape-pill){--b-radius:999px}
.cms-button:where(.b-desktop-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-desktop-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-desktop-size-large){--b-py:19px;--b-px:30px}
.cms-button:where(.b-desktop-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
.cms-button:where(.b-desktop-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
.cms-button:where(.b-desktop-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
.cms-button:where(.b-desktop-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
/* Responsive variants */
@media(max-width:1024px){
.cms-button:where(.b-tablet-shape-square){--b-radius:0px}.cms-button:where(.b-tablet-shape-rounded){--b-radius:8px}.cms-button:where(.b-tablet-shape-pill){--b-radius:999px}
.cms-button:where(.b-tablet-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-tablet-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-tablet-size-large){--b-py:19px;--b-px:30px}
.cms-button:where(.b-tablet-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
.cms-button:where(.b-tablet-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
.cms-button:where(.b-tablet-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
.cms-button:where(.b-tablet-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
}
@media(max-width:767px){
.cms-button:where(.b-mobile-shape-square){--b-radius:0px}.cms-button:where(.b-mobile-shape-rounded){--b-radius:8px}.cms-button:where(.b-mobile-shape-pill){--b-radius:999px}
.cms-button:where(.b-mobile-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-mobile-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-mobile-size-large){--b-py:19px;--b-px:30px}
.cms-button:where(.b-mobile-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
.cms-button:where(.b-mobile-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
.cms-button:where(.b-mobile-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
.cms-button:where(.b-mobile-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
}
+166 -11
View File
@@ -4,9 +4,9 @@ import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
// The layer above the preview.
//
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
// whole document. Anything drawn inside it would therefore be wiped on the next keystroke and
// would also end up in the markup we are supposed to be showing faithfully. So the outline, the
// element toolbar, the insert points and the drop marker all live in the editor document, in an
// whole document. The selection tools therefore live outside it and get rebound after a refresh.
// Empty-container hit areas are rebuilt separately inside the frame by preview-containers.js.
// The outline, element toolbar, insert points and drop marker live in the editor document, in an
// absolutely positioned layer that sits exactly on top of the frame.
//
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
@@ -35,7 +35,7 @@ const TOOLS = [
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
];
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop, onResize = () => {} }) {
const layer = document.createElement('div');
layer.className = 'canvas-overlay';
const hover = element('div', 'ov-box ov-hover');
@@ -50,10 +50,11 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
toolbar.setAttribute('role', 'toolbar');
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
const drop = element('div', 'ov-drop');
const dropLabel = element('div', 'ov-drop-label');
const insertBefore = insertButton('before', 'Element davor einfügen');
const insertAfter = insertButton('after', 'Element dahinter einfügen');
toolbar.append(selectName);
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop);
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop, dropLabel);
stage.append(layer);
for (const tool of TOOLS) {
@@ -204,9 +205,131 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
// now is it settled whether there is one.
positionToolbar(selectedBox, Boolean(sibling));
placeResizers();
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
if (dragging) paintDrop();
if (dragging) {
dropPlan = dragPoint ? planDrop(dragPoint.x, dragPoint.y, dragging.block) : null;
paintDrop();
}
}
// -------------------------------------------------------------------------------------------
// Column widths. Umut: "am besten per Drag and Drop im Editor".
//
// The border between two columns of the selected row is a handle. Dragging it moves share from
// one neighbour to the other; the row is restyled inline while the pointer moves, and the
// result is written to the document once, when the pointer is released. The arrow keys do the
// same in steps of five, so the widths are reachable without a mouse.
// -------------------------------------------------------------------------------------------
const resizers = [];
let focusResizer = null;
/** The row whose borders can be dragged: the selected row, or the row around the selected column. */
function resizableRow() {
const item = getSelected() ? locate(getDocument().blocks, getSelected()) : null;
if (!item) return null;
if (item.block.type === 'row') return item.block;
return item.parent?.type === 'row' ? item.parent : null;
}
/** The measured widths of the columns as shares of their sum - the truth a drag starts from. */
function sharesOf(rowId) {
const kids = childElements(rowId);
const total = kids.reduce((sum, kid) => sum + kid.box.width, 0);
return total > 0 ? kids.map(kid => kid.box.width / total * 100) : null;
}
/** Moves `delta` points of share from the right neighbour to the left one; neither may vanish. */
function shift(shares, index, delta) {
const pair = shares[index] + shares[index + 1];
const least = Math.min(10, pair / 2);
const left = Math.max(least, Math.min(pair - least, shares[index] + delta));
const next = shares.slice();
next[index] = left;
next[index + 1] = pair - left;
return next;
}
const tenth = share => Math.round(share * 10) / 10;
const formatShares = shares => shares.map(share => String(tenth(share))).join(' ');
const tracksOf = shares => shares.map(share => `minmax(0,${tenth(share)}fr)`).join(' ');
function placeResizers() {
const row = dragging ? null : resizableRow();
const kids = row ? childElements(row.id) : [];
// Stacked columns - a phone, or a single-column layout - have no border to drag.
const sideBySide = kids.length > 1 && kids.every((kid, index) => index === 0 || kid.box.left >= kids[index - 1].box.right - 1);
const wanted = sideBySide ? kids.length - 1 : 0;
while (resizers.length > wanted) resizers.pop().remove();
while (resizers.length < wanted) resizers.push(resizeButton());
const width = 14 / getScale();
resizers.forEach((button, index) => {
const left = kids[index].box, right = kids[index + 1].box;
button.dataset.row = row.id;
button.dataset.index = String(index);
const label = `Breite zwischen Spalte ${index + 1} und ${index + 2} ändern`;
button.setAttribute('aria-label', label);
button.title = `${label}: ziehen oder Pfeiltasten`;
button.style.left = `${(left.right + right.left) / 2 - width / 2}px`;
button.style.top = `${Math.min(left.top, right.top)}px`;
button.style.width = `${width}px`;
button.style.height = `${Math.max(left.bottom, right.bottom) - Math.min(left.top, right.top)}px`;
if (focusResizer && focusResizer.row === row.id && focusResizer.index === index) { button.focus(); focusResizer = null; }
});
}
function resizeButton() {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ov-resize';
button.addEventListener('pointerdown', startResize);
button.addEventListener('keydown', event => {
const step = event.key === 'ArrowLeft' ? -5 : event.key === 'ArrowRight' ? 5 : 0;
if (!step) return;
event.preventDefault();
const rowId = button.dataset.row, index = Number(button.dataset.index);
const shares = sharesOf(rowId);
if (!shares) return;
focusResizer = { row: rowId, index };
onResize(rowId, formatShares(shift(shares, index, step)));
});
layer.append(button);
return button;
}
function startResize(event) {
if (event.button !== 0) return;
const button = event.currentTarget;
const rowId = button.dataset.row, index = Number(button.dataset.index);
const start = sharesOf(rowId);
const node = doc()?.getElementById(rowId);
if (!start || !node) return;
event.preventDefault();
button.setPointerCapture(event.pointerId);
layer.classList.add('is-resizing');
// Pointer pixels are editor pixels; the shares live in the frame's scale, on the summed
// width of the columns without the gaps between them.
const content = childElements(rowId).reduce((sum, kid) => sum + kid.box.width, 0);
const startX = event.clientX;
let current = start;
const move = moved => {
current = shift(start, index, (moved.clientX - startX) / getScale() / content * 100);
node.style.gridTemplateColumns = tracksOf(current);
sync();
};
const finish = () => {
button.removeEventListener('pointermove', move);
button.removeEventListener('pointerup', finish);
button.removeEventListener('pointercancel', finish);
layer.classList.remove('is-resizing');
if (formatShares(current) !== formatShares(start)) onResize(rowId, formatShares(current));
else sync();
};
button.addEventListener('pointermove', move);
button.addEventListener('pointerup', finish);
button.addEventListener('pointercancel', finish);
}
// -------------------------------------------------------------------------------------------
@@ -259,8 +382,9 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
const parentId = item?.parent?.id ?? null;
if (id && !ownBranch && dropList(document_, parentId, block)) {
const box = rectOf(id);
const after = box ? y > box.top + box.height / 2 : false;
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: after ? 'bottom' : 'top' } : null };
const horizontal = item.parent?.type === 'row' && indexInside(parentId, x, y).sideBySide;
const after = box ? (horizontal ? x > box.left + box.width / 2 : y > box.top + box.height / 2) : false;
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: horizontal ? (after ? 'right' : 'left') : (after ? 'bottom' : 'top') } : null };
}
if (!node) break;
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
@@ -276,11 +400,19 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
function paintDrop() {
const line = dropPlan?.line;
if (!line?.box) { drop.hidden = true; return; }
if (!line?.box) { drop.hidden = true; dropLabel.hidden = true; return; }
drop.hidden = false;
const scale = getScale();
const thickness = 3 / scale;
const { box, edge } = line;
const target = dropPlan.parentId ? doc()?.getElementById(dropPlan.parentId) : null;
const label = target?.dataset.editorLabel || (dropPlan.parentId ? blockLabel(locate(getDocument().blocks, dropPlan.parentId)?.block) : 'Seite');
dropLabel.textContent = `${edge === 'inside' ? 'Einfügen in' : 'Einfügen ·'} ${label}`;
dropLabel.hidden = false;
dropLabel.style.left = `${Math.max(0, box.left + 8)}px`;
dropLabel.style.top = `${Math.max(2, box.top + 8)}px`;
counterScale(dropLabel, 'left', 'top');
drop.style.marginLeft = drop.style.marginTop = '0';
drop.classList.toggle('ov-drop-inside', edge === 'inside');
if (edge === 'inside') {
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
@@ -317,15 +449,20 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
document_.addEventListener('dragover', event => {
if (!dragging) return;
event.preventDefault();
event.dataTransfer.dropEffect = dragging.kind === 'move' ? 'move' : 'copy';
dragPoint = { x: event.clientX, y: event.clientY };
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
event.dataTransfer.dropEffect = dropPlan ? (dragging.kind === 'move' ? 'move' : 'copy') : 'none';
paintDrop();
if (!scrollFrame) scrollFrame = requestAnimationFrame(scrollDrag);
});
document_.addEventListener('dragleave', event => {
if (!event.relatedTarget) { dragPoint = null; dropPlan = null; paintDrop(); }
});
document_.addEventListener('drop', event => {
if (!dragging) return;
event.preventDefault();
// Both are read before the clean-up clears them.
const payload = dragging, plan = dropPlan;
const payload = dragging, plan = planDrop(event.clientX, event.clientY, dragging.block);
endDrag();
if (plan) onDrop(payload, plan);
});
@@ -335,17 +472,35 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
function startDrag(payload) {
dragging = payload;
layer.classList.add('is-dragging');
doc()?.documentElement.classList.add('editor-dragging');
hovered = null;
sync();
}
function endDrag() {
dragging = null;
dragPoint = null;
cancelAnimationFrame(scrollFrame); scrollFrame = null;
dropPlan = null;
drop.hidden = true;
dropLabel.hidden = true;
layer.classList.remove('is-dragging');
doc()?.documentElement.classList.remove('editor-dragging');
sync();
}
let dragPoint = null, scrollFrame = null;
function scrollDrag() {
scrollFrame = null;
if (!dragging || !dragPoint) return;
const edge = 60 / getScale();
const amount = dragPoint.y < edge ? -14 : dragPoint.y > frame.clientHeight - edge ? 14 : 0;
if (amount) {
doc()?.defaultView?.scrollBy(0, amount / getScale());
sync();
scrollFrame = requestAnimationFrame(scrollDrag);
}
}
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
}
+28
View File
@@ -0,0 +1,28 @@
.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer}
.color-trigger:hover{background:#f5f7f3}
.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px}
.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit}
.color-description{display:grid;gap:3px;flex:1;min-width:0}
.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere}
.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c}
.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)}
.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8}
.color-choices[hidden]{display:none}
.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258}
.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin}
.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer}
.color-option:hover{background:#eef3ea;border-color:#718575}
.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8}
.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426}
.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere}
.color-custom{display:flex;align-items:center;gap:6px}
.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff}
.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px}
.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer}
.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0}
.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer}
.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none}
+125
View File
@@ -0,0 +1,125 @@
/** A compact palette picker. Palette references stay references when saved. */
let sequence = 0;
const node = (tag, className, text) => {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
};
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete = () => {} }) {
const wrap = node('div', 'color-control');
const trigger = node('button', 'color-trigger');
trigger.type = 'button'; trigger.id = id;
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-controls', `${id}-choices`);
const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true');
const ink = node('span', 'color-sample-ink'); sample.append(ink);
const description = node('span', 'color-description');
const name = node('span', 'color-name'), detail = node('span', 'color-detail');
description.append(name, detail);
const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true');
trigger.append(sample, description, arrow);
const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true;
const heading = node('p', 'color-heading', 'Website-Farben');
const palette = node('div', 'color-palette');
palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`);
const buttons = [];
let current = value, editing = false, mergeKey = `color-${++sequence}`;
const entryFor = raw => colors.find(color => `@${color.key}` === raw);
const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || '';
const restoreFocus = () => document.getElementById(id)?.focus();
const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); };
for (const color of colors) {
const button = node('button', 'color-option'); button.type = 'button';
button.dataset.color = `@${color.key}`;
button.title = `${color.name} · ${color.value}`;
button.setAttribute('aria-label', button.title);
const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value;
swatch.setAttribute('aria-hidden', 'true');
button.append(swatch, node('span', 'color-option-name', color.name));
button.addEventListener('click', () => choose(`@${color.key}`));
buttons.push(button); palette.append(button);
}
if (colors.length) choices.append(heading, palette);
const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`;
const custom = node('div', 'color-custom');
const picker = node('input', 'color-native'); picker.type = 'color';
picker.setAttribute('aria-label', `Eigene Farbe für ${label}`);
const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`;
text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB';
text.setAttribute('aria-label', `Farbwert für ${label}`);
const accept = node('button', 'color-accept', '✓'); accept.type = 'button';
accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen';
custom.append(picker, text, accept);
const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true;
error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id);
const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button';
clear.addEventListener('click', () => choose(''));
choices.append(customLabel, custom, error, clear);
wrap.append(trigger, choices);
function sync() {
const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown);
ink.style.backgroundColor = hex || 'transparent';
name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen');
detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · ');
trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`);
picker.value = hex ? hex.slice(0, 7) : '#ffffff';
text.value = hex || current || '';
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown));
clear.disabled = current === '';
}
const finish = () => {
if (!editing) return;
editing = false;
onComplete();
};
trigger.addEventListener('click', () => {
if (!choices.hidden) {
choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
} else {
choices.hidden = false; trigger.setAttribute('aria-expanded', 'true');
}
});
// A native picker emits many input events during one gesture. Keep its DOM and one undo entry.
picker.addEventListener('input', () => {
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
editing = true; sync(); onChange(current, mergeKey);
});
picker.addEventListener('change', () => {
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
sync(); onChange(current, editing ? mergeKey : undefined);
if (editing) finish();
restoreFocus();
});
const applyText = () => {
let raw = text.value.trim();
if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join('');
if (raw && !hexPattern.test(raw) && !entryFor(raw)) {
text.setAttribute('aria-invalid', 'true'); error.hidden = false;
error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.';
text.focus(); return;
}
choose(raw);
};
text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; });
text.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); applyText(); }
});
accept.addEventListener('click', applyText);
wrap.addEventListener('keydown', event => {
if (event.key !== 'Escape' || choices.hidden) return;
event.preventDefault(); event.stopPropagation(); choices.hidden = true;
trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
});
// Commit after leaving the native picker even on browsers that omit its change event.
wrap.addEventListener('focusout', () => setTimeout(() => {
if (wrap.isConnected && !wrap.contains(document.activeElement)) finish();
}, 0));
sync();
return wrap;
}
+103 -42
View File
@@ -5,8 +5,9 @@
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
* appear here without touching this file.
*/
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
import { openMedia } from './media.js';
import { devices, effectiveStyle, hoverStyle, scrolledStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
import { openMedia, createMediaPreview } from './media.js';
import { createColorControl } from './color-control.js';
export const GROUPS = {
text: 'Typografie',
@@ -17,6 +18,8 @@ export const GROUPS = {
image: 'Bild',
layout: 'Layout',
effect: 'Wirkung',
menu: 'Menü-Knopf',
slider: 'Slider-Steuerung',
};
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
@@ -103,7 +106,7 @@ function element(tag, className, text) {
return node;
}
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory }) {
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory, getAreaKind = () => 'page' }) {
/**
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
*
@@ -122,6 +125,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
const linkedSides = new Map();
// Lives as long as the editor session, so a look can travel between blocks and pages.
let clipboard = null;
let showAll = false, lastBlockId = null;
const openGroups = new Map();
const renderedGroups = new Map();
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
const applyStyle = (doc, id, style) => {
@@ -145,7 +151,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
/** Does this device carry an own value for this property - as a plain value or per side? */
const hasOwn = (block, deviceName, key, hover) => {
const bucket = hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
const bucket = hover === 'scrolled' ? block?.style?.[deviceName]?.scrolled : hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
const value = bucket?.[key];
if (value === undefined) return false;
// A per-side property stores an object; an empty one is a leftover, not a value.
@@ -201,33 +207,16 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
return wrap;
};
const colorControl = (id, value, apply) => {
const wrap = element('div', 'color-control');
const picker = document.createElement('input');
picker.type = 'color';
picker.id = id;
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
const text = document.createElement('input');
text.type = 'text';
text.className = 'color-text';
text.value = value ?? '';
text.placeholder = '#1a2b3c';
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
const palette = document.createElement('select');
palette.className = 'color-palette';
palette.setAttribute('aria-label', 'Farbe aus der Palette');
palette.append(new Option('Palette …', ''));
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
palette.value = (value ?? '').startsWith('@') ? value : '';
const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
onComplete: () => {
const focusedId = document.activeElement?.id;
onChange();
if (focusedId) document.getElementById(focusedId)?.focus();
},
});
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
text.addEventListener('change', () => apply(text.value.trim()));
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
wrap.append(picker, text, palette);
return wrap;
};
const lengthControl = (id, value, apply, placeholder) => {
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
const wrap = element('div', 'length-control');
const parts = splitLength(value);
const number = document.createElement('input');
@@ -245,9 +234,37 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
unit.addEventListener('change', () => { if (number.value !== '') push(); });
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
wrap.append(number, unit);
if (keywords.length) {
const mode = document.createElement('select');
mode.className = 'length-mode';
mode.setAttribute('aria-label', `${schema.properties[id.replace('style-', '')]?.label ?? 'Länge'} Vorgabe`);
mode.append(new Option('Eigener Zahlenwert / geerbt', ''));
for (const keyword of keywords) mode.append(new Option(keyword === 'none' ? 'Ohne Begrenzung' : 'Automatisch', keyword));
mode.value = ['auto', 'none'].includes(value) ? value : '';
number.hidden = unit.hidden = mode.value !== '';
mode.addEventListener('change', () => apply(mode.value));
wrap.prepend(mode);
}
return wrap;
};
/**
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
*/
const fractionsControl = (id, value, apply) => {
const input = document.createElement('input');
input.type = 'text';
input.id = id;
input.value = value ?? '';
input.placeholder = 'gleich breit';
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
input.setAttribute('inputmode', 'decimal');
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
return input;
};
const numberControl = (id, value, property, apply) => {
const wrap = element('div', 'number-control');
const slider = document.createElement('input');
@@ -430,15 +447,18 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
const mediaControl = (key, value, apply) => {
const wrap = element('div', 'media-control');
const status = element('p', 'media-chosen', value ? `Bild gewählt (${String(value).slice(0, 8)}…)` : 'Kein Bild gewählt');
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
const hasImage = Boolean(value && value !== 'none');
const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
choose.type = 'button';
choose.id = `style-${key}`; choose.setAttribute('aria-label', choose.textContent);
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
wrap.append(status, choose);
if (value) {
wrap.append(preview.element, choose);
if (hasImage) {
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
remove.type = 'button';
remove.addEventListener('click', () => apply(''));
remove.title = 'Hintergrundbild für dieses Gerät entfernen. Die Datei bleibt in der Mediathek.';
remove.addEventListener('click', () => { apply('none'); document.getElementById(choose.id)?.focus(); });
wrap.append(remove);
}
return wrap;
@@ -446,14 +466,22 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
/** Renders the whole panel for one block into the given container. */
return function render(panel) {
// Read the previous nodes directly; a queued toggle event can be lost during a redraw.
for (const [key, details] of renderedGroups) openGroups.set(key, details.open);
renderedGroups.clear();
const block = getBlock();
const deviceName = getDevice();
if (!block) return;
if (lastBlockId !== block.id) { showAll = false; lastBlockId = block.id; }
const scope = element('p', 'design-scope');
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
panel.append(scope);
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
const all = element('button', 'secondary design-context-toggle', showAll ? 'Passende Eigenschaften anzeigen' : 'Alle Eigenschaften anzeigen');
all.type = 'button'; all.setAttribute('aria-pressed', String(showAll));
all.addEventListener('click', () => { showAll = !showAll; onChange(); panel.querySelector('.design-context-toggle')?.focus(); });
panel.append(all);
// A filter over the properties. With more than ninety of them across eight collapsed groups,
// "where is the border thickness" is a fair question - so it can be typed instead of hunted.
@@ -484,11 +512,16 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
});
panel.append(searchLabel, search, found);
for (const [group, groupLabel] of Object.entries(GROUPS)) {
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
for (const group of orderedGroups) {
const groupLabel = GROUPS[group];
const properties = Object.entries(schema.properties).filter(([key, p]) => p.group === group && (showAll || relevantProperty(block, key, p)));
if (properties.length === 0) continue;
const details = element('details', 'design-group');
if (group === 'text' || group === 'spacing') details.open = true;
const groupKey = `${block.type}:${group}`;
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
renderedGroups.set(groupKey, details);
details.append(element('summary', null, groupLabel));
const body = element('div', 'design-group-body');
@@ -509,12 +542,13 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
body.append(sideHead, sideControl(key, block, deviceName, property));
continue;
}
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || '');
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
else continue;
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
@@ -522,9 +556,14 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
if (schema.hoverable.includes(key)) {
const hoverId = `${id}-hover`;
const hoverValue = hoverStyle(block, deviceName, key);
let hoverInherited = current.value || '';
for (const name of Object.keys(devices)) {
if (name === deviceName) break;
hoverInherited = hoverStyle(block, name, key) ?? hoverInherited;
}
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
const hoverControl = property.type === 'color'
? colorControl(hoverId, hoverValue ?? '', hoverApply)
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
: property.type === 'number'
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
@@ -535,6 +574,27 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
}));
}
// The scrolled page - only in the header, the one area that changes with scrolling.
if (getAreaKind() === 'header' && (schema.scrollable ?? []).includes(key)) {
const scrolledId = `${id}-scrolled`;
const scrolledValue = scrolledStyle(block, deviceName, key);
let scrolledInherited = current.value || '';
for (const name of Object.keys(devices)) {
if (name === deviceName) break;
scrolledInherited = scrolledStyle(block, name, key) ?? scrolledInherited;
}
const scrolledApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { scrolled: true }), mergeKey, Boolean(mergeKey));
const scrolledControl = property.type === 'color'
? colorControl(scrolledId, scrolledValue ?? '', scrolledApply, `${property.label} beim Scrollen`, scrolledInherited)
: numberControl(scrolledId, scrolledValue ?? '', property, scrolledApply);
body.append(row(scrolledId, `${property.label} beim Scrollen`, scrolledControl, {
own: scrolledValue !== undefined,
inherited: 'Gilt, sobald die Seite um mehr als 40 px gescrollt ist. Vorher gilt der normale Wert - für einen transparenten Kopf dort #ffffff00 eintragen.',
onReset: () => scrolledApply(''),
chips: deviceChips(block, key, `${property.label} beim Scrollen`, 'scrolled'),
}));
}
}
if (group === 'text') {
const notice = contrastNotice(block, deviceName);
@@ -591,6 +651,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
};
function describe(property, value) {
if (property.type === 'media') return value === 'none' ? 'Kein Bild' : 'Bild aus der Mediathek';
if (property.type === 'choice') return property.values[value] ?? value;
return String(value);
}
+2 -1
View File
@@ -10,7 +10,8 @@
"iconcard",
"stat",
"quote",
"cta"
"cta",
"button"
],
"values": {
"left": [
+1 -1
View File
@@ -89,7 +89,7 @@
.kant .d-tablet-radius-small{border-radius:8px}
.kant .d-tablet-radius-large{border-radius:24px}
}
@media(max-width:650px){
@media(max-width:767px){
.kant .d-mobile-align-left{text-align:left;margin-inline:0}
.kant .d-mobile-align-center{text-align:center;margin-inline:auto}
.kant .d-mobile-align-right{text-align:right;margin-inline:auto 0}
+148 -23
View File
@@ -1,5 +1,5 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭' };
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
// document, stay selectable and movable, but the editor never edits or fills them.
/**
@@ -10,7 +10,7 @@ export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H',
export const CATEGORIES = [
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'logos', 'progress'] },
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
];
@@ -30,10 +30,35 @@ export function whyNot(type) {
}
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
/**
* Die Bausteine der aktiven Plugins, vom Server mit dem Dokument geliefert. Sie bekommen
* Beschriftung, Symbol, Vorgaben und eine eigene Gruppe im Werkzeugkasten; was das Dokument
* enthält, aber hier nicht steht, bleibt ein Baustein einer fehlenden Erweiterung.
*/
export const PLUGIN_BLOCKS = {};
/** Die Seitenvorlagen der aktiven Plugins: id => {label, blocks}. */
export const PLUGIN_PRESETS = {};
export function registerPlugins(list, presets = []) {
for (const key of Object.keys(PLUGIN_BLOCKS)) { delete PLUGIN_BLOCKS[key]; delete labels[key]; delete icons[key]; }
for (const key of Object.keys(PLUGIN_PRESETS)) delete PLUGIN_PRESETS[key];
for (const preset of presets || []) if (typeof preset?.id === 'string' && Array.isArray(preset.blocks)) PLUGIN_PRESETS[preset.id] = preset;
const types = [];
for (const definition of list || []) {
if (!PLUGIN_TYPE.test(definition?.type || '')) continue;
PLUGIN_BLOCKS[definition.type] = definition;
labels[definition.type] = definition.label;
icons[definition.type] = definition.icon || '⬡';
types.push(definition.type);
}
const group = CATEGORIES.find(category => category.name === 'Erweiterungen');
if (group) group.types = types;
else if (types.length) CATEGORIES.push({ name: 'Erweiterungen', hint: 'Bausteine der eingeschalteten Plugins.', types });
}
export const pluginDefinition = block => PLUGIN_BLOCKS[block?.type] ?? null;
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
export const blockIcon = block => icons[block?.type] ?? '❔';
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type) || PLUGIN_BLOCKS[block?.type]?.container === true;
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
// Containers that accept only one kind of child, mirrored by the server side validator.
// ---------------------------------------------------------------------------------------------
@@ -55,8 +80,12 @@ export const isContainer = block => ['section', 'row', 'column', 'gallery', 'log
/** Containers that take one kind of child and nothing else. */
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
/** Blocks that exist only inside one of these parents. */
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
/**
* Blocks that exist only inside one of these parents. A column belongs to a row - and, as a
* single one, directly to a section: that is how a button or a short text gets centred and
* narrowed without a one-column row around it. Umut asked for exactly that.
*/
export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'], tabitem: ['tabs'] };
/**
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
* hold one - but it is not a thing a document starts with. At the top level a page begins with
@@ -64,8 +93,10 @@ export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem:
*/
export const ROOT_NEEDS_CONTAINER = ['row'];
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type))
// Ein Plugin-Container nimmt auf, was sein Manifest erlaubt; ein Plugin ohne Definition nichts.
const allowedIn = parent => PLUGIN_BLOCKS[parent?.type] ? (PLUGIN_BLOCKS[parent.type].children ?? undefined) : ALLOW[parent?.type];
export const canContain = (parent, block) => isContainer(parent) && (!isPluginBlock(parent) || PLUGIN_BLOCKS[parent.type]?.container === true)
&& (allowedIn(parent) === undefined || allowedIn(parent).includes(block?.type))
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
export const clone = value => structuredClone(value);
@@ -82,6 +113,11 @@ export function locate(blocks, id, parent = null) {
export function createBlock(type) {
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
if (PLUGIN_BLOCKS[type]) {
block.props = Object.fromEntries(Object.entries(PLUGIN_BLOCKS[type].props || {}).map(([key, schema]) => [key, schema.default ?? '']));
if (PLUGIN_BLOCKS[type].container) block.children = [];
return block;
}
if (isContainer(block)) block.children = [];
if (type === 'accordion') block.children = [createBlock('accordionitem')];
// A single tab is not a tab group, and the script needs at least two to enhance anything.
@@ -107,6 +143,8 @@ export function createBlock(type) {
if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 };
if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' };
if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true };
// Welche Profile es gibt, steht in den Einstellungen; der Block bringt nur die Darstellung mit.
if (type === 'social') block.props = { size: 24, shape: 'plain', layout: 'horizontal', labels: false, networks: [], newTab: true };
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 };
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
return block;
@@ -115,16 +153,23 @@ export function createBlock(type) {
/** Raises the document schema to the version that knows this block type. */
export function upgradeForType(document, type) {
if (type === 'gallery') upgradeDocument(document, 3);
if (type === 'postlist') upgradeDocument(document, 5);
if (PLUGIN_BLOCKS[type]) upgradeDocument(document, 4);
// Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den
// Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer
// Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern.
if (type === 'postlist') upgradeDocument(document, 18);
if (type === 'postnav') upgradeDocument(document, 18);
if (type === 'legal') upgradeDocument(document, 19);
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
if (type === 'form') upgradeDocument(document, 8);
if (type === 'breadcrumb') upgradeDocument(document, 9);
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
if (type === 'menu') upgradeDocument(document, 16);
if (type === 'partialref') upgradeDocument(document, 11);
if (type === 'embed') upgradeDocument(document, 14);
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
if (type === 'icon' || type === 'progress') upgradeDocument(document, 15);
if (type === 'slider') upgradeDocument(document, 17);
if (type === 'social') upgradeDocument(document, 23);
}
/**
@@ -141,8 +186,15 @@ export function upgradeForType(document, type) {
* @return {{parentId: string|null, index: number}|null}
*/
export function insertionTarget(document, selected, type) {
// A section starts a page area. Explicit insertion points and drag/drop can still nest it.
if (type === 'section') {
const top = blockPath(document.blocks, selected)[0];
return { parentId: null, index: top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length };
}
const probe = { type };
let item = locate(document.blocks, selected);
// Content belongs to a chosen entry. Do not silently place it outside the accordion.
if (item?.block.type === 'accordion' && type !== 'accordionitem') return null;
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
while (item) {
const parentId = item.parent?.id ?? null;
@@ -164,6 +216,7 @@ export function duplicateBlock(document, selected) {
const copy = clone(target.block);
const rekey = block => {
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
delete block.anchor;
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
(block.children || []).forEach(rekey);
};
@@ -178,7 +231,9 @@ export function moveBlock(document, selected, targetId, inside = false) {
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
if (inside && !isContainer(target.block)) return false;
if (inside && !canContain(target.block, source.block)) return false;
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
// Neben ein Element der obersten Ebene darf nur, was dort auch stehen darf - eine Spalte oder
// ein Akkordeoneintrag nicht.
if (!inside && !dropList(document, target.parent?.id ?? null, source.block)) return false;
source.siblings.splice(source.index, 1);
const destination = locate(document.blocks, targetId);
if (inside) (destination.block.children ??= []).push(source.block);
@@ -209,10 +264,18 @@ function place(document, block, parentId, index) {
return block.id;
}
/** Inserts a new block at an exact position instead of behind the selection. */
/**
* Inserts a new block at an exact position instead of behind the selection.
*
* Erst pruefen, ob das Ziel den Baustein nimmt, dann die Fassung anheben: sonst bliebe bei einer
* Ablehnung nur die Schemaaenderung uebrig - ein Verlaufsschritt, ein "ungespeichert", und nichts
* zu sehen.
*/
export function insertBlockAt(document, type, parentId, index) {
const block = createBlock(type);
if (!dropList(document, parentId, block)) return null;
upgradeForType(document, type);
return place(document, createBlock(type), parentId, index);
return place(document, block, parentId, index);
}
/**
@@ -247,7 +310,21 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
// nirgends. Eine Zahl, an einer Stelle.
export const SCHEMA_VERSION = 19;
export const SCHEMA_VERSION = 23;
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
export function setVisibility(document, id, value) {
if (!Object.hasOwn(VISIBILITY, value)) throw new Error('Unbekannte Sichtbarkeit.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
if (value === 'all') {
if (block.layout) delete block.layout.visibility;
} else {
upgradeDocument(document, 22);
(block.layout ??= {}).visibility = value;
}
}
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
export function upgradeDocument(document, version = 2) {
@@ -304,12 +381,19 @@ export function mediaProps(media) {
export function insertMedia(document, selected, items) {
const target = locate(document.blocks, selected)?.block;
upgradeDocument(document, 3);
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
if (target?.type === 'image') {
upgradeDocument(document, 3);
target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' };
return target.id;
}
let parent = target;
if (target?.type !== 'gallery') {
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
// Ein Akkordeon nimmt nur Eintraege: dann gibt es keine Galerie und nichts zu tun.
const id = insertBlock(document, 'gallery', selected);
if (id === null) return null;
parent = locate(document.blocks, id).block;
}
upgradeDocument(document, 3);
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
return parent.id;
}
@@ -370,21 +454,34 @@ function buildRow(columns) {
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
export function insertRowAt(document, columns, parentId, index) {
const row = buildRow(columns);
if (!dropList(document, parentId, row)) return null;
upgradeDocument(document);
return place(document, buildRow(columns), parentId, index);
return place(document, row, parentId, index);
}
export function insertRow(document, columns, selected) {
const row = buildRow(columns);
upgradeDocument(document);
// Same placement rule as a single element, so the row does not land somewhere else than the
// toolbox promised.
const target = insertionTarget(document, selected, 'row');
return target ? place(document, row, target.parentId, target.index) : null;
if (!target) return null;
upgradeDocument(document);
return place(document, buildRow(columns), target.parentId, target.index);
}
/** Frische Kennungen für eine Vorlage aus einem Plugin - jedes Einfügen ist ein eigener Baustein. */
const freshIds = blocks => blocks.map(block => ({ ...structuredClone(block), id: `b_${crypto.randomUUID().replaceAll('-', '')}`, ...(Array.isArray(block.children) ? { children: freshIds(block.children) } : {}) }));
export function insertPreset(document, name, selected) {
if (name === 'hero') return insertHero(document, selected);
if (PLUGIN_PRESETS[name]) {
const blocks = freshIds(PLUGIN_PRESETS[name].blocks);
const top = blockPath(document.blocks, selected)[0];
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
document.blocks.splice(index, 0, ...blocks);
upgradeDocument(document, SCHEMA_VERSION);
return blocks[0].id;
}
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
const section = createBlock('section');
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
@@ -418,6 +515,14 @@ export class EditorHistory {
this.mergeKey = null;
this.saved = JSON.stringify(document);
}
/** Uebernimmt die vom Server bereinigte Fassung als aktuellen Stand, der Verlauf bleibt. */
adopt(document) {
this.past.push(this.document);
if (this.past.length > 100) this.past.shift();
this.document = clone(document);
this.future = [];
this.mergeKey = null;
}
change(mutator, mergeKey = null) {
const next = clone(this.document);
const result = mutator(next);
@@ -485,17 +590,24 @@ function prune(block) {
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
* device and finally to the theme. Called inside a history transaction like every other change.
*/
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
export function scrolledStyle(block, device, key) {
return block?.style?.[device]?.scrolled?.[key];
}
export function setStyle(document, id, device, key, value, { side = null, hover = false, scrolled = false } = {}) {
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
const empty = value === '' || value === null || value === undefined;
if (!empty) upgradeDocument(document, 10);
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
block.style ??= {};
block.style[device] ??= {};
let target = block.style[device];
if (hover) { target.hover ??= {}; target = target.hover; }
// The scrolled state of the header: same shape as hover, applied once the page has moved.
if (scrolled) { target.scrolled ??= {}; target = target.scrolled; }
if (side !== null) {
if (empty) { if (target[key]) delete target[key][side]; }
@@ -533,3 +645,16 @@ export function copyStyle(document, fromId, toId) {
target.style = structuredClone(source.style);
return true;
}
/** UI relevance only. Existing values remain editable, including on older documents. */
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 (key === 'columnWidths') return block.type === 'row';
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
return true;
}
+197 -167
View File
@@ -1,12 +1,18 @@
/* The editor and the media library. Only the editor and the media screen load this; the
management screens get by with admin.css alone. Rules moved out of admin.css unchanged. */
management screens get by with admin.css alone.
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)}
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.media-launch{margin-top:12px;white-space:normal}
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
.media-launch{margin-top:14px;white-space:normal}
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
.media-heading h2{margin:0}
@@ -29,121 +35,170 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
#media-status{font-size:12px;margin-block:15px}
.media-dialog> .panel-hint{margin-top:20px}
.media-details>p{font-size:12px}
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
.icon-button,.block-actions button,.viewport-controls button{display:inline-flex;align-items:center;justify-content:center;gap:12px;border:1px solid var(--line);border-radius:7px;padding:11px 15px;text-decoration:none;font-size:12px;font-weight:600;background:white;color:var(--ink);white-space:nowrap;min-height:40px}
.editor-message{padding:14px 18px;border-radius:7px;line-height:1.6;font-size:13px}
.editor-body{overflow:hidden}
#editor{height:100dvh;display:flex;flex-direction:column}
.editor-header{display:flex;align-items:center;gap:20px;min-height:76px;padding:13px 20px;border-bottom:1px solid var(--line);background:white;z-index:1}
.back-link{display:flex;gap:9px;text-decoration:none;font-size:12px;padding-right:20px;border-right:1px solid var(--line)}
.editor-page-name{min-width:0;flex:1;display:grid;gap:5px}
.editor-page-name strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.editor-page-name>span{font-size:10px;color:var(--muted)}
.history-actions,.save-actions{display:flex;align-items:center;gap:8px}
.icon-button{font-size:21px;min-width:36px;padding:3px 9px}
.save-actions>span{font-size:10px;color:var(--muted);max-width:130px;margin-right:8px}
.editor-layout{display:grid;grid-template-columns:258px minmax(200px,1fr) 280px;flex:1;min-height:0}
.left-panel,.right-panel{background:white;overflow:auto}
.left-panel{border-right:1px solid var(--line)}
.right-panel{border-left:1px solid var(--line)}
.panel-section{padding:23px 18px;border-bottom:1px solid var(--line)}
.panel-section .eyebrow{margin-bottom:10px}
.panel-section h2{font-size:14px;margin-bottom:12px}
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
.panel-title{display:flex;justify-content:space-between;align-items:center}
.panel-title h2{margin-bottom:0}
.panel-title+.panel-hint{margin-top:10px}
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
/* ---- shell ------------------------------------------------------------------------------- */
html:has(>.editor-body){overflow:hidden;height:100%}
.editor-body{overflow:hidden;height:100dvh}
#editor{height:100dvh;display:flex;flex-direction:column;background:#e3e7e1}
.editor-header{display:flex;align-items:center;gap:12px;height:52px;padding:0 12px;background:var(--ink);color:#fff;flex-shrink:0;z-index:5}
.back-link{display:flex;gap:7px;align-items:center;color:#cfd9d3;text-decoration:none;font-size:12px;font-weight:600;padding:8px 14px 8px 4px;border-right:1px solid #ffffff22;white-space:nowrap}
.back-link:hover{color:#fff}
.editor-page-name{min-width:0;width:230px;display:grid;gap:1px}
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0;color:#fff}
.editor-page-name>span{font-size:11px;color:#9fb1a7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.icon-button{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;min-height:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:inherit;text-decoration:none;font-size:14px;line-height:1}
.icon-button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.editor-header .icon-button{color:#d5ded8}
.editor-header .icon-button:hover:not(:disabled){background:#ffffff1a;color:#fff}
.editor-header .icon-button[aria-pressed=true]{background:#fff;color:var(--ink)}
.viewport-controls{display:flex;gap:2px;margin:0 auto;padding:3px;background:#ffffff14;border-radius:9px}
.viewport-controls button{display:inline-flex;align-items:center;gap:7px;border:0;background:transparent;color:#c9d4cd;padding:0 11px;min-height:30px;border-radius:6px;font-size:12px;font-weight:600;white-space:nowrap}
.viewport-controls button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.viewport-controls button:hover:not(:disabled){color:#fff}
.editor-header .viewport-controls button[aria-pressed=true]{background:#fff;color:var(--ink)}
.history-actions{display:flex;gap:2px}
.save-actions{display:flex;align-items:center;gap:8px}
.save-actions>span{font-size:11px;color:#9fb1a7;max-width:150px;text-align:right;line-height:1.3}
.editor-header .secondary{background:transparent;color:#fff;border:1px solid #ffffff4d;font-size:12px;padding:0 13px;min-height:34px;white-space:nowrap}
.editor-header .secondary:hover:not(:disabled){background:#ffffff1a;border-color:#ffffff99}
.editor-header .primary{font-size:12px;padding:0 15px;min-height:34px;white-space:nowrap}
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 16px;font-size:12px;line-height:1.6}
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
/* The single row is capped at the space left under the header, so the sidebar and the canvas
scroll inside themselves instead of stretching the workspace. */
.editor-layout{display:grid;grid-template-columns:330px minmax(0,1fr);grid-template-rows:minmax(0,1fr);flex:1;min-height:0}
/* ---- sidebar ----------------------------------------------------------------------------- */
.sidebar{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:#fff;border-right:1px solid var(--line)}
.sidebar-head{flex:none;border-bottom:1px solid var(--line);background:#fff}
.sidebar-nav{display:flex}
.sidebar-nav [role=tab]{flex:1;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.2px;padding:0 6px;min-height:46px;border-radius:0}
.sidebar-nav [role=tab]:hover:not(:disabled){color:var(--ink);background:#f7f9f5}
.sidebar-nav [aria-selected=true]{color:var(--ink);border-bottom-color:var(--accent)}
.sidebar-element{display:flex;align-items:center;gap:4px;padding:7px 8px 7px 6px;border-top:1px solid var(--line);background:#f7f9f5}
.sidebar-element .icon-button{color:var(--ink);flex:none}
.sidebar-element .icon-button:hover:not(:disabled){background:#eef2ea}
.sidebar-element-name{flex:1;min-width:0;padding:0 4px}
.sidebar-element-name h2{margin:0;font-size:14px;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}
.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:1px;min-height:0;line-height:1.3}
.breadcrumbs:empty{display:none}
.crumb{border:0;background:none;padding:0;min-height:0;font-size:10.5px;color:var(--muted);cursor:pointer;white-space:nowrap}
.crumb:hover{color:var(--accent);text-decoration:underline}
.crumb+.crumb::before{content:'›';margin:0 4px;color:#b7c2bb}
.property-tabs{display:flex;gap:4px;padding:0 8px 8px}
.property-tabs button{flex:1;min-width:0;border:1px solid var(--line);background:#f5f7f2;color:var(--ink);border-radius:7px;padding:0 6px;min-height:32px;font-size:12px;font-weight:600}
.property-tabs button:hover:not(:disabled){border-color:#b9c6bd}
.property-tabs [aria-pressed=true]{background:var(--ink);color:#fff;border-color:var(--ink)}
.sidebar-body{flex:1;min-height:0;overflow:auto;overflow-x:hidden;padding:14px 14px 28px;container-type:inline-size}
.sidebar-view>:first-child{margin-top:0}
.sidebar-foot{flex:none;display:flex;gap:6px;align-items:center;padding:10px 12px;border-top:1px solid var(--line);background:#fff}
.sidebar-foot .icon-button{border-color:var(--line);color:var(--ink)}
.sidebar-foot .icon-button:hover:not(:disabled){background:#eef2ea}
.sidebar-foot .secondary{flex:1;font-size:12px;padding:0 8px;min-height:34px;white-space:nowrap}
.sidebar-foot .danger-text{color:#943a2e}
.sidebar-note{flex:none;font-size:11px;line-height:1.5;color:#873e24;background:#fff4ec;border-top:1px solid #e6c3b2;margin:0;padding:10px 14px}
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:12px 0}
.sidebar label{font-size:12px;margin:16px 0 6px}
.sidebar input,.sidebar select,.sidebar textarea{font-size:12px;border-radius:7px;min-width:0;max-width:100%}
.sidebar textarea{min-height:120px}
.sidebar select{padding-inline:7px;text-overflow:ellipsis}
.sidebar .field-help{font-size:11px;line-height:1.5}
.sidebar .full-width{width:100%}
.sidebar .secondary,.sidebar .primary{font-size:12px;min-height:36px}
/* ---- element library --------------------------------------------------------------------- */
#library-search,#tree-search{font-size:12px;min-height:38px;padding:8px 11px;margin:0 0 4px}
#library-found{margin:0 0 8px;min-height:0}
#library-found:empty{display:none}
.library-group+.library-group{margin-top:18px}
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:16px 0 8px}
.library-group:first-child .library-group-title{margin-top:8px}
.library-group-hint{font-size:11px;line-height:1.5;color:var(--muted);margin:-4px 0 8px}
.library-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
.library-buttons button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;min-height:74px;padding:10px 5px 9px;border:1px solid var(--line);border-radius:9px;background:#fbfcfa;color:var(--ink);font-size:11px;line-height:1.2;text-align:center;overflow-wrap:anywhere;min-width:0}
.library-preview{display:grid;place-items:center;width:40px;height:27px;color:var(--accent)}
.library-preview svg{width:40px;height:27px}
.library-name{min-width:0}
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff;box-shadow:0 2px 10px #1b2a2414}
.library-buttons button:focus-visible{outline:2px solid #b55b12;outline-offset:1px}
.library-buttons button:disabled{opacity:.35;cursor:not-allowed}
.library-buttons button:disabled .library-preview{color:var(--muted)}
#library button[draggable=true]{cursor:grab}
#library button[draggable=true]:active{cursor:grabbing}
@container(max-width:260px){.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}}
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:8px;padding:9px 11px;font-size:11.5px;line-height:1.5;margin:0 0 12px}
.insert-hint .text-button{margin-left:4px}
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
.preset-library{display:grid;gap:6px}
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fbfcfa;color:var(--ink);font-size:12px}
.preset-library button:hover:not(:disabled){border-color:var(--accent);background:#fff}
.preset-library span{font-size:16px;color:var(--accent)}
/* ---- structure --------------------------------------------------------------------------- */
.tree-tools{display:flex;flex-wrap:wrap;gap:2px;margin:4px 0 8px}
.tree-tools .text-button{font-size:11px;padding:4px 7px;min-height:26px}
.structure-list{list-style:none;margin:0;padding:0}
.structure-list .structure-list{padding-left:13px;border-left:1px solid #e5e9e3;margin-left:8px}
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:5px;padding:9px 7px;font-size:11px;min-height:34px}
.structure-list .structure-list{padding-left:12px;border-left:1px solid #e5e9e3;margin-left:9px}
.structure-list li{position:relative}
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:6px;padding:8px 7px 8px 24px;font-size:12px;min-height:34px}
.tree-item span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tree-item:hover{background:#f3f6ef}
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600}
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600;padding-right:84px}
.tree-icon{font-size:12px;min-width:15px}
.tree-visibility{margin-left:auto;max-width:95px;font-size:10px;font-weight:400;line-height:1.3;color:#42584b;white-space:normal}
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px;min-height:0;padding:0}
.tree-edit{position:absolute;top:5px;right:4px;z-index:1;min-height:0;padding:4px 8px;border:1px solid #c5d89a;border-radius:6px;background:#fff;color:var(--accent);font-size:11px;font-weight:700}
.tree-edit:hover{background:var(--accent);color:#fff}
.drop-target{outline:2px dashed #315b49}
.block-actions{display:flex;flex-wrap:wrap;gap:5px;border-top:1px solid var(--line);padding-top:13px;margin-top:16px}
.block-actions button{font-size:10px;padding:7px 9px;min-height:33px}
.block-actions .danger-text{color:#943a2e}
.canvas-panel{min-width:0;display:flex;flex-direction:column;background:#e9ede7;overflow:hidden}
.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 20px;background:#f6f8f2;border-bottom:1px solid var(--line);min-height:59px}
.canvas-label{font-size:9px;font-weight:700;letter-spacing:1.2px;color:#57655d}
.viewport-controls{display:flex;gap:3px;background:#e8eee1;border-radius:7px;padding:3px}
.viewport-controls button{padding:5px 9px;font-size:10px;min-height:28px;border:0;background:transparent}
.viewport-controls [aria-pressed=true]{background:white;box-shadow:0 1px 3px #00000012}
.canvas-toolbar>a{text-decoration:none;font-size:18px}
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:22px;display:flex;justify-content:center;align-items:flex-start}
#preview{background:white;border:0;box-shadow:0 3px 20px #203b3410;min-height:600px;height:100%;flex-shrink:0}
.viewport-desktop{width:100%}
.viewport-tablet{width:768px;max-width:100%}
.viewport-mobile{width:390px;max-width:100%}
.preview-status{font-size:10px;padding:9px 20px;color:#52614f;margin:0;min-height:30px;background:#f3f5ed}
.right-panel label{font-size:11px}
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px}
.right-panel textarea{min-height:150px}
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:20px}
/* ---- canvas ------------------------------------------------------------------------------ */
.canvas-panel{min-width:0;min-height:0;display:flex;flex-direction:column;background:#e3e7e1;overflow:hidden}
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:16px}
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
#preview-stage{position:relative;margin:0 auto;background:#fff;box-shadow:0 8px 30px #1b2a2418;border-radius:6px;overflow:hidden}
#preview{position:absolute;top:0;left:0;border:0;background:#fff;transform-origin:top left;max-width:none;min-height:0}
.preview-status{flex:none;font-size:11px;padding:6px 14px;color:#52614f;margin:0;background:#f5f7f2;border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.resizing-panels iframe{pointer-events:none}
/* ---- context menu ------------------------------------------------------------------------ */
.context-menu{position:fixed;z-index:60;min-width:210px;padding:5px;background:#fff;border:1px solid var(--line);border-radius:9px;box-shadow:0 12px 40px #1b2a2433;display:grid}
.context-menu button{border:0;background:transparent;text-align:left;padding:8px 11px;min-height:0;border-radius:6px;font-size:12px;color:var(--ink);white-space:nowrap}
.context-menu button:hover,.context-menu button:focus-visible{background:#eef2ea;outline:0}
.context-menu .danger-text{color:#943a2e}
.context-menu .danger-text:hover{background:#fbeae6}
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 20px;font-size:12px}
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
@media(max-width:1150px){.editor-layout{grid-template-columns:215px minmax(200px,1fr) 240px}.editor-header{gap:12px;padding-inline:14px}.save-actions>span{display:none}.panel-section{padding:18px 14px}.canvas-scroll{padding:12px}.canvas-label{display:none}}
@media(max-width:850px){.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}.editor-header{flex-wrap:wrap}.editor-page-name{min-width:180px}.save-actions{width:100%;justify-content:flex-end}.save-actions>span{display:block;margin-right:auto}.editor-layout{display:flex;flex-wrap:wrap}.canvas-panel{order:0;width:100%;height:680px}.left-panel{order:1;width:45%;max-height:650px}.right-panel{order:2;width:55%;max-height:650px}.canvas-label{display:block}.editor-header .back-link{padding-right:12px}.editor-message{font-size:11px}.editor-layout .panel-hint{font-size:10px}}
.left-panel{display:flex;flex-direction:column;overflow:hidden}
.left-panel>.panel-section:first-child{max-height:45%;overflow:auto;flex-shrink:0}
.structure-section{display:flex;flex-direction:column;flex:1;min-height:0}
.structure-section #structure{overflow:auto;flex:1;min-height:90px;padding:3px}
.structure-section .block-actions{flex-shrink:0;background:white}
.structure-section .panel-hint{margin:8px 0}
.search-label{font-size:11px;margin:12px 0 6px}
#tree-search{font-size:11px;padding:9px;min-height:36px}
.tree-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}
.tree-tools .text-button{font-size:10px;padding:5px}
.structure-list li{position:relative}
.tree-item{padding-left:23px}
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px}
.preset-heading{font-size:12px;margin:22px 0 8px}
.preset-library{display:grid;gap:6px}
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
.preset-library button:hover:not(:disabled){border-color:var(--accent)}
.preset-library span{font-size:18px}
.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}
.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}
.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}
/* ---- properties -------------------------------------------------------------------------- */
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:0 0 8px;padding:9px 10px;border-left:3px solid var(--accent);background:#f1f5e7;border-radius:0 7px 7px 0}
.design-reset{margin-top:24px;white-space:normal;width:100%}
.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}
.breadcrumbs .text-button{font-size:10px;white-space:normal}
.breadcrumbs button+button:before{content:'›';color:var(--muted)}
.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}
.canvas-scroll{display:block}
.canvas-scroll #preview-stage{position:relative;margin:0 auto}
#preview{position:absolute;top:0;left:0;transform-origin:top left;max-width:none;min-height:0}
.right-panel select{padding-inline:7px;text-overflow:ellipsis}
.right-panel .panel-section{min-width:0}
@media(max-width:850px){.left-panel,.right-panel{height:650px}.left-panel>.panel-section:first-child{max-height:40%}.structure-section{padding:12px 7px}.tree-item{font-size:10px;gap:3px}.tree-icon{min-width:10px}.structure-list .structure-list{padding-left:5px;margin-left:4px}.block-actions button{padding:6px;white-space:normal}.property-tabs button{padding:9px 4px;font-size:11px}.canvas-scroll{padding:12px}.right-panel label{line-height:1.5}.tree-tools .text-button{white-space:normal}}
.design-group{border:1px solid var(--line);border-radius:9px;margin-top:10px;background:#fff}
.design-group>summary{cursor:pointer;padding:12px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
.design-group>summary::after{content:'+';font-size:17px}
.design-group{border:1px solid var(--line);border-radius:10px;margin-top:10px;background:#fff}
.design-group>summary{cursor:pointer;padding:11px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
.design-group>summary::-webkit-details-marker{display:none}
.design-group>summary::after{content:'+';font-size:17px;color:var(--muted)}
.design-group[open]>summary{border-bottom:1px solid var(--line)}
.design-group[open]>summary::after{content:'–'}
.design-group-body{padding:6px 14px 16px}
.design-group-body{padding:4px 14px 16px}
.design-group-body>label:first-child{margin-top:10px}
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
.design-row{margin-top:14px}
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
.design-row-head label{margin:0;font-size:11px}
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
/* 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:var(--used);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}
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
.color-control .color-text{font-size:12px;min-height:38px}
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
.length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px}
.length-control input,.length-control select{font-size:12px;min-height:38px}
.length-control.is-inherited input{color:var(--muted)}
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
.length-mode{grid-column:1/-1}
.number-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:8px;align-items:center}
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
.number-control input[type=number]{font-size:12px;min-height:38px}
.side-wrap{margin-bottom:10px}
@@ -160,25 +215,31 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.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.
/* 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}
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;flex:none;gap:6px;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}
/* Der Fokusring bleibt innerhalb der Luecke zwischen zwei Chips - nichts liegt uebereinander. */
.device-chip:focus-visible{outline-width:2px;outline-offset:0}
.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. */
.scrubbable{cursor:ew-resize}
.scrubbable:focus{cursor:text}
.design-actions{display:grid;gap:8px;margin-top:22px}
.design-actions button{width:100%}
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
#design-search{font-size:12px;min-height:36px;padding:9px}
.search-label{font-size:11px;margin:12px 0 6px}
.media-control{display:grid;gap:8px}
.media-chosen{margin:0;font-size:11px;color:var(--muted)}
.design-reuse{display:grid;gap:8px;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)}
@@ -191,8 +252,10 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.rt-button:hover{background:#eef2e7;border-color:var(--line)}
.rt-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
.rt-link{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
.rt-link input{font-size:11px;min-height:30px}
.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}
.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
@@ -200,27 +263,9 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.rt-source{min-height:150px;border:0;border-radius:0;font-family:ui-monospace,monospace;font-size:12px;line-height:1.6}
.own-code{font-family:ui-monospace,monospace;font-size:12px;line-height:1.6;min-height:110px}
.own-code[readonly]{background:#f4f5f1;color:var(--muted)}
/* Bringing the editor chrome in line with the new administration: same radii, same restraint,
and a few sizes that were simply too small to read comfortably. The layout is unchanged. */
.editor-header{min-height:70px}
.back-link{font-size:12px;font-weight:600;gap:7px;align-items:center}
.editor-page-name h1{font-size:14px}
.panel-section h2{font-size:13px;letter-spacing:0}
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
.tree-item{font-size:12px;border-radius:6px}
.right-panel label{font-size:12px}
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
.property-tabs{padding:3px;border-radius:9px}
.property-tabs button{border-radius:7px;font-size:12px}
.viewport-controls{border-radius:8px}
.viewport-controls button{border-radius:6px;font-size:11px}
.design-group{border-radius:10px}
.design-scope{border-radius:0 7px 7px 0}
#preview{box-shadow:0 6px 28px #1b2a2414}
.canvas-panel{background:#e7ebe5}
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
.icon-button{border-radius:8px}
#design-search{font-size:12px;min-height:36px;padding:9px}
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
/* --------------------------------------------------------------------------------------------
The working layer above the preview.
@@ -234,8 +279,7 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.ov-box{position:absolute;pointer-events:none}
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
.ov-label{background:#5c6b62;font-weight:600}
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:#5c6b62;color:#fff;font:600 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
@@ -246,37 +290,23 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
/* The border between two columns of the selected row: a grab area with a short bar in it. */
.ov-resize{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:0;background:transparent;cursor:col-resize;pointer-events:auto;touch-action:none}
.ov-resize::before{content:"";width:4px;height:40px;max-height:60%;border-radius:2px;background:var(--ring,#b55b12);box-shadow:0 0 0 1px #fff8;opacity:.55;transition:opacity .12s}
.ov-resize:hover::before,.ov-resize:focus-visible::before,.canvas-overlay.is-resizing .ov-resize::before{opacity:1}
.ov-resize:focus-visible{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
.canvas-overlay.is-resizing .ov-hover,.canvas-overlay.is-resizing .ov-toolbar,.canvas-overlay.is-resizing .ov-insert{display:none}
/* While something is being dragged the outlines would only add noise. */
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
#library button[draggable=true]{cursor:grab}
#library button[draggable=true]:active{cursor:grabbing}
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
.insert-hint .text-button{margin-left:4px}
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
fade on the insert points is a transition and is switched off with the rest. */
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert,.canvas-overlay.is-dragging .ov-resize{display:none}
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
/* --------------------------------------------------------------------------------------------
The toolbox, grouped the way a page is built.
The first group is the scaffolding, the rest goes inside it. What does not fit where the next
element would land is disabled and carries the reason in its title, so the rule is learned
rather than merely enforced.
-------------------------------------------------------------------------------------------- */
.library-group + .library-group{margin-top:16px}
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:0 0 7px}
.library-group-hint{font-size:10px;line-height:1.5;color:var(--muted);margin:-3px 0 8px}
.library-buttons{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.library-buttons button{display:flex;align-items:center;gap:7px;text-align:left;min-height:38px;padding:7px 9px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px;line-height:1.25}
.library-buttons button>span:first-child{flex:none;width:14px;text-align:center;font-size:13px;color:var(--accent)}
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff}
.library-buttons button:disabled{opacity:.4;background:transparent;cursor:not-allowed}
.library-buttons button:disabled>span:first-child{color:var(--muted)}
/* A row is offered by column count, so its glyph shows the count - and needs the room for it,
instead of the fixed single-glyph width the other entries share. */
.library-buttons button>span.library-row-glyph{width:auto;letter-spacing:0;font-size:10px;color:var(--accent)}
.library-buttons button[data-row]{grid-column:span 2}
@media(max-width:1150px){.library-buttons{grid-template-columns:1fr}.library-buttons button[data-row]{grid-column:span 1}}
/* ---- narrow screens ---------------------------------------------------------------------- */
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{display:none}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
@media(max-width:850px){
.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}
.editor-header{flex-wrap:wrap;height:auto;padding:8px 10px;gap:8px}.editor-page-name{flex:1;width:auto}.viewport-controls{order:6;margin:0}.save-actions{margin-left:auto}
.editor-layout{display:flex;flex-direction:column}
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
}
+630 -81
View File
File diff suppressed because it is too large. Load diff
+17 -69
View File
@@ -1,26 +1,11 @@
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
@media(max-width:767px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
/* Entrance animations and hover effects, added 12.09.2026.
Every animation stays inside the reduced-motion guard: whoever asked their system for less
movement gets the finished state, not a softened animation. They play on load, because a public
page runs no script and there is nothing to observe a scroll position with - that is a
limitation, not an oversight, and it is written in the editor. */
@media (prefers-reduced-motion: no-preference) {
.fx-entrance-sink { animation: kant-sink .55s ease-out backwards }
.fx-entrance-left { animation: kant-left .55s ease-out backwards }
.fx-entrance-right { animation: kant-right .55s ease-out backwards }
.fx-entrance-zoom { animation: kant-zoom .55s ease-out backwards }
.fx-entrance-flip { animation: kant-flip .7s ease-out backwards }
.fx-entrance-blur { animation: kant-unblur .6s ease-out backwards }
.fx-delay-short { animation-delay: .15s }
.fx-delay-medium { animation-delay: .3s }
.fx-delay-long { animation-delay: .6s }
/* Hover effects remain CSS; entrance effects are observed by motion.js. They play for everyone
unless the website chooses to respect the browser preference - see the override at the end. */
.fx-hover-sink { transition: transform .2s ease }
.fx-hover-sink:hover { transform: translateY(3px) }
.fx-hover-glow { transition: box-shadow .2s ease }
@@ -29,21 +14,13 @@
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
.fx-hover-grow-border { transition: box-shadow .2s ease }
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
}
@keyframes kant-sink { from { opacity: 0; transform: translateY(-18px) } to { opacity: 1; transform: none } }
@keyframes kant-left { from { opacity: 0; transform: translateX(-24px) } to { opacity: 1; transform: none } }
@keyframes kant-right { from { opacity: 0; transform: translateX(24px) } to { opacity: 1; transform: none } }
@keyframes kant-zoom { from { opacity: 0; transform: scale(.92) } to { opacity: 1; transform: none } }
@keyframes kant-flip { from { opacity: 0; transform: perspective(800px) rotateX(-12deg) } to { opacity: 1; transform: none } }
@keyframes kant-unblur { from { opacity: 0; filter: blur(8px) } to { opacity: 1; filter: none } }
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
/* Beitragskarten und KnÃpfe, ergänzt am 13.09.2026.
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
@media (prefers-reduced-motion: no-preference) {
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
@@ -65,44 +42,15 @@
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
.button:hover { transform: translateY(-2px) }
}
/* Einblenden beim Hineinscrollen, ergänzt am 13.09.2026.
Bisher liefen die Einblendungen beim Laden der Seite - was unterhalb der Falz stand, war
fertig animiert, bevor es jemand gesehen hat. Mit einer Scroll-Zeitachse läuft dieselbe
Animation genau dann, wenn das Element in den Blick kommt, und zwar ohne eine Zeile
JavaScript: `animation-timeline: view()` bindet den Ablauf an die Sichtbarkeit.
Drei Bedingungen, alle bewusst:
- Hinter der Bewegungsschranke: wer weniger Bewegung eingestellt hat, bekommt keine.
- Hinter `@supports`: Browser ohne Scroll-Zeitachse (heute Firefox und Safari) behalten die
bisherige Fassung - die Animation läuft beim Laden. Niemand sieht deshalb weniger Inhalt.
- `animation-range` endet früh (`cover 26%`): die Bewegung ist vorbei, bevor das Element in
der Mitte des Bildschirms steht. Eine Einblendung, die beim Lesen noch läuft, stört. */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
/* Eigene Kurven für den Scrollweg: **nur Bewegung, keine Deckkraft**.
Eine Scroll-Zeitachse ist umkehrbar - ein Abschnitt außerhalb des Blicks steht wieder am
Anfang. Alles unterhalb der Falz wäre also dauerhaft blass, und blasser Text ist nicht
weniger Effekt, sondern schlechterer Kontrast: #3d4d5c bei 85 Prozent ergibt 3,86:1
statt 7,1:1 - axe meldet das zu Recht. Der Weg trägt den Effekt allein. */
@keyframes kant-scroll-rise { from { transform: translateY(20px) } to { transform: none } }
@keyframes kant-scroll-fade { from { transform: translateY(10px) } to { transform: none } }
.fx-entrance-fade, .fx-entrance-rise, .fx-entrance-sink, .fx-entrance-left,
.fx-entrance-right, .fx-entrance-zoom, .fx-entrance-flip, .fx-entrance-blur {
animation-name: kant-scroll-rise;
animation-timeline: view();
animation-range: entry 10% cover 28%;
animation-duration: 1ms; /* Die Zeitachse bestimmt den Ablauf, nicht die Uhr. */
animation-fill-mode: both;
}
.fx-entrance-fade { animation-name: kant-scroll-fade }
/* Die Verzögerungsstufen sind auf einer Scroll-Zeitachse eine Verschiebung im Weg,
nicht in der Zeit - sonst würden sie gar nicht wirken. */
.fx-delay-short { animation-range: entry 16% cover 32% }
.fx-delay-medium { animation-range: entry 22% cover 36% }
.fx-delay-long { animation-range: entry 28% cover 40% }
}
/* Die Website kann die Browservorgabe „Bewegung reduzieren" beachten (Einstellungen → Bewegung).
Dann trägt <html> die Klasse motion-respect, und die Hover-Fahrten fallen weg - Farben und
Schatten bleiben, nur nichts bewegt sich mehr. Ohne die Klasse spielt alles, wie bei Flatsome. */
@media (prefers-reduced-motion: reduce) {
html.motion-respect :is(.fx-hover-lift, .fx-hover-zoom img, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-image img, .post-nav-link, .button) { transition: none }
html.motion-respect :is(.fx-hover-lift, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-nav-link, .button):hover,
html.motion-respect :is(.fx-hover-lift, .post-card):focus-within,
html.motion-respect .post-nav-link:focus-visible,
html.motion-respect .fx-hover-zoom:hover img, html.motion-respect .fx-hover-zoom:focus-within img,
html.motion-respect .post-card:hover .post-image img, html.motion-respect .post-card:focus-within .post-image img { transform: none }
}
+4
View File
@@ -19,6 +19,10 @@ Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dat
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
die Verwendung des reservierten Namens für eine geänderte Fassung.
**Cormorant Garamond** (Überschriften der Referenzwebsite „Ihr Malerservice Drews") steht ebenfalls
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
@@ -0,0 +1,93 @@
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+4
View File
@@ -10,3 +10,7 @@
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
+213
View File
@@ -0,0 +1,213 @@
/*
* Der Formulareditor in der Verwaltung.
*
* Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne
* Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript
* macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der
* Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen -
* und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website.
*/
import { createColorControl } from './color-control.js';
import { buttonSample } from './button-design.js';
const root = document.querySelector('[data-form-schema]');
if (root) {
const schema = JSON.parse(root.dataset.formSchema);
const bilder = JSON.parse(root.dataset.formImages || '[]');
const defaults = {
fieldBorderColor: schema.base.color,
fieldBackgroundColor: '#ffffff',
buttonColor: schema.buttons.backgroundColor,
buttonInk: schema.buttons.color,
};
const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || '';
// Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz).
const darker = value => {
const rgb = hex(value); if (!rgb) return value;
return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join('');
};
const luminance = rgb => {
const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255)
.map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (back, front) => {
if (!back || !front) return null;
const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a);
return (light + 0.05) / (dark + 0.05);
};
const slug = text => text.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32);
const el = (tag, className, text) => {
const node = document.createElement(tag); node.className = className;
if (text !== undefined) node.textContent = text; return node;
};
for (const form of root.querySelectorAll('[data-form-editor]')) {
const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]');
const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]');
const buttonLabel = form.querySelector('[data-form-button-label]');
const colorInputs = {};
const cards = () => [...list.querySelectorAll('[data-field]')];
let next = cards().length;
const renderPreview = () => {
preview.replaceChildren();
preview.style.color = hex(schema.base.color) || '#203b34';
preview.style.background = hex(schema.base.backgroundColor) || '#ffffff';
preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor');
preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff');
let shown = 0;
for (const card of cards()) {
const label = card.querySelector('[data-field-label]').value.trim();
if (!label) continue;
shown++;
const type = card.querySelector('[data-field-type]').value;
const help = card.querySelector('[data-field-help]').value.trim();
if (type === 'step') {
// Ein Schritt ist eine Zwischenüberschrift in der Vorschau, mit seiner Einleitung.
const step = el('div', 'form-preview-step');
step.append(el('span', 'form-preview-step-title', label));
if (help) step.append(el('span', 'form-preview-help', help));
preview.append(step);
continue;
}
const required = type === 'consent' || card.querySelector('[data-field-required]').checked;
const showIf = card.querySelector('[data-field-showif]').value.trim();
const field = el('div', `form-preview-field${showIf ? ' form-preview-conditional' : ''}`);
const options = () => card.querySelector('[data-field-options-input]').value.split(',').map(option => option.trim()).filter(Boolean);
if (type === 'consent') {
const check = el('span', 'form-preview-check');
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
field.append(check);
} else if (type === 'radio' || type === 'checkboxes') {
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
for (const option of options()) {
const check = el('span', 'form-preview-check');
check.append(el('span', `form-preview-box${type === 'radio' ? ' form-preview-radio' : ''}`), document.createTextNode(option));
field.append(check);
}
} else {
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
const unit = type === 'number' ? card.querySelector('[data-field-unit]').value.trim() : '';
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}${type === 'number' ? ' form-preview-number' : ''}`, type === 'select' ? '— bitte wählen —' : unit));
}
if (help) field.append(el('span', 'form-preview-help', help));
if (showIf) field.append(el('span', 'form-preview-help', `Nur wenn ${showIf} = ${card.querySelector('[data-field-showvalues]').value.split(',').map(v => v.trim()).filter(Boolean).join(' oder ') || '…'}`));
preview.append(field);
}
if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.'));
const values = { ...schema.buttons };
const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value;
if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); }
if (ink) { values.color = ink; values.hoverColor = ink; }
preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema));
// Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text
// fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht.
const ratio = contrast(hex(values.backgroundColor), hex(values.color));
note.hidden = ratio === null;
if (ratio !== null) {
note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`;
note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`;
}
};
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
preview.after(note);
// Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das
// gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein.
const renderImagePickers = card => {
const area = card.querySelector('[data-field-images-input]');
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
let box = card.querySelector('[data-image-pickers]');
if (!box) {
box = el('div', 'field-image-pickers'); box.dataset.imagePickers = '';
area.hidden = true; area.after(box);
}
if (box.dataset.signature === options.join('|')) return;
const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim())));
box.dataset.signature = options.join('|');
box.replaceChildren();
for (const option of options) {
const row = el('label', 'field-image-picker');
row.append(el('span', 'field-image-picker-option', option));
const select = document.createElement('select');
select.append(new Option('Kein Bild', ''));
for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id));
select.dataset.option = option;
select.addEventListener('change', () => {
const map = {};
for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value;
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
renderPreview();
});
row.append(select); box.append(row);
}
};
const refresh = () => {
cards().forEach((card, index) => {
card.querySelector('[data-field-index]').textContent = String(index + 1);
const type = card.querySelector('[data-field-type]').value;
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
card.querySelector('[data-field-number]').hidden = type !== 'number';
const remove = card.querySelector('[data-remove-field]');
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
const required = card.querySelector('[data-field-required]');
// Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken.
if (type === 'consent') { required.checked = true; required.disabled = true; }
else if (type === 'step') { required.checked = false; required.disabled = true; }
else required.disabled = false;
});
renderPreview();
};
// Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript
// kommt jedes weitere über den Knopf.
const empty = cards().filter(card => card.classList.contains('field-card-new'));
if (cards().length > empty.length) empty.forEach(card => card.remove());
add.hidden = false;
add.addEventListener('click', () => {
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
const card = list.lastElementChild; card.classList.remove('field-card-new');
refresh(); card.querySelector('[data-field-label]').focus();
});
list.addEventListener('click', event => {
const button = event.target.closest('[data-remove-field]');
if (!button) return;
button.closest('[data-field]').remove(); refresh(); add.focus();
});
list.addEventListener('input', event => {
const card = event.target.closest('[data-field]');
if (event.target.matches('[data-field-label]')) {
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
const name = card.querySelector('[data-field-name]');
if (!name.dataset.touched) name.value = slug(event.target.value);
}
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
refresh();
});
list.addEventListener('change', refresh);
for (const card of cards()) {
const name = card.querySelector('[data-field-name]');
if (name.value) name.dataset.touched = '1';
}
for (const input of form.querySelectorAll('[data-form-color]')) {
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
const id = input.id; input.type = 'hidden'; input.id += '-value';
colorInputs[key] = input;
const control = createColorControl({
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
});
control.querySelector('.color-inherit').textContent = 'Wie die Website';
input.after(control);
}
buttonLabel.addEventListener('input', renderPreview);
refresh();
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Formulare mit Schritten, Bedingungen und Zusammenfassung.
*
* Ohne dieses Skript steht das Formular vollständig da: jeder Schritt ein Abschnitt, jedes Feld
* sichtbar, der Server prüft beim Absenden, welche Felder galten. Das Skript ändert nur die
* Darstellung: ein Schritt nach dem anderen, Felder hinter einer Bedingung nur bei passender
* Antwort, und vor dem Absenden eine Zusammenfassung der Eingaben. Es wird nur auf Seiten
* geladen, deren Formular eines davon braucht.
*/
(() => {
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
const setup = form => {
const rules = [...form.querySelectorAll('[data-show-if]')];
const steps = [...form.querySelectorAll('[data-step]')];
const submit = form.querySelector('.cms-button[type="submit"]');
const wantsSummary = form.hasAttribute('data-summary') && steps.length > 0 && submit;
if (!rules.length && !steps.length) return;
// Die gewählten Werte eines Feldes - nur, solange es gilt. Ein Feld in einem ausgeblendeten
// Schritt zählt nicht, auch wenn dort noch etwas angekreuzt ist.
const valuesOf = name => [...form.querySelectorAll(`[name="${name}"], [name="${name}[]"]`)]
.filter(input => !input.closest('[data-inactive]'))
.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : true)
.map(input => input.value)
.filter(value => value !== '');
// Bedingungen in Dokumentreihenfolge: ein Feld hängt nur von früheren Feldern ab, also steht
// deren Zustand schon fest, wenn es an der Reihe ist.
const apply = () => {
for (const rule of rules) {
const wanted = rule.dataset.showValues.split('|');
const have = valuesOf(rule.dataset.showIf);
rule.toggleAttribute('data-inactive', !wanted.some(value => have.includes(value)));
}
// Was nicht gilt, wird nicht geprüft und nicht gesendet: deaktiviert, nicht nur versteckt.
for (const input of form.querySelectorAll('input, select, textarea')) {
if (input.type === 'hidden') continue;
input.disabled = input.closest('[data-inactive]') !== null;
}
};
form.addEventListener('change', () => { apply(); if (steps.length) refreshNav(); });
apply();
if (!steps.length) return;
// Die Zusammenfassung ist ein eigener, letzter Schritt - erst hier steht der Absendeknopf.
let summary = null;
if (wantsSummary) {
summary = el('div', 'form-step form-summary');
summary.setAttribute('data-step', '');
const title = el('p', 'form-step-title', 'Zusammenfassung');
title.tabIndex = -1;
summary.append(title, el('p', 'form-step-intro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'));
summary.append(el('div', 'form-summary-body'));
steps[steps.length - 1].after(summary);
steps.push(summary);
}
const progress = el('ol', 'form-progress');
progress.setAttribute('aria-label', 'Schritte des Formulars');
steps.forEach((step, index) => {
const item = el('li', 'form-progress-step');
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
progress.append(item);
});
form.insertBefore(progress, steps[0]);
const nav = el('div', 'form-step-nav');
const back = el('button', 'button cms-button form-step-back', 'Zurück');
back.type = 'button';
const next = el('button', 'button cms-button form-step-next', 'Weiter');
next.type = 'button';
nav.append(back, next);
(submit || form).before(nav);
if (submit) nav.append(submit);
const visible = () => steps.map((step, index) => step.hasAttribute('data-inactive') ? -1 : index).filter(index => index >= 0);
let current = 0;
const labelOf = field => field.dataset.label || '';
const valueOf = field => {
const inputs = [...field.querySelectorAll('input, select, textarea')].filter(input => !input.disabled);
const picked = inputs.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : input.value.trim() !== '');
if (!picked.length) return '';
const unit = field.querySelector('.form-unit')?.textContent || '';
return picked.map(input => {
if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return 'Ja';
if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value;
return input.value + (unit ? ` ${unit}` : '');
}).join(', ');
};
const renderSummary = () => {
const body = summary.querySelector('.form-summary-body');
body.replaceChildren();
steps.forEach((step, index) => {
if (step === summary || step.hasAttribute('data-inactive')) return;
const block = el('section', 'form-summary-step');
const head = el('div', 'form-summary-head');
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `Schritt ${index + 1}`));
const change = el('button', 'form-summary-change', 'Ändern');
change.type = 'button';
change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`);
change.addEventListener('click', () => show(index));
head.append(change);
block.append(head);
const list = el('dl', 'form-summary-list');
for (const field of step.querySelectorAll('[data-field]')) {
if (field.closest('[data-inactive]')) continue;
const value = valueOf(field);
list.append(el('dt', '', labelOf(field)), el('dd', value ? '' : 'form-summary-empty', value || '—'));
}
block.append(list);
body.append(block);
});
};
const refreshNav = () => {
const order = visible();
const position = order.indexOf(current);
const last = position === order.length - 1;
back.hidden = position <= 0;
next.hidden = last;
if (submit) submit.hidden = !last;
progress.querySelectorAll('li').forEach((item, index) => {
item.hidden = steps[index].hasAttribute('data-inactive');
item.classList.toggle('is-done', order.indexOf(index) < position);
if (index === current) item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current');
});
};
const show = (index, focus = true) => {
current = index;
steps.forEach((step, i) => { step.hidden = i !== index; });
if (summary && steps[index] === summary) renderSummary();
refreshNav();
if (focus) {
steps[index].querySelector('.form-step-title')?.focus({ preventScroll: true });
if (progress.getBoundingClientRect().top < 0) progress.scrollIntoView({ block: 'start' });
}
};
// Erst der Schritt selbst prüft seine Felder: der Browser meldet das erste ungültige Feld,
// eine Pflicht-Mehrfachauswahl ohne Kreuz meldet die Gruppe.
const valid = step => {
for (const group of step.querySelectorAll('[data-required]')) {
if (group.closest('[data-inactive]')) continue;
const note = group.querySelector('.field-error[data-live]');
if (!group.querySelector('input:checked')) {
if (!note) group.append(Object.assign(el('p', 'field-error', 'Bitte wähle mindestens eine Option.'), { dataset: { live: '' } }));
else note.hidden = false;
group.classList.add('has-error');
group.querySelector('input')?.focus();
return false;
}
if (note) note.remove();
group.classList.remove('has-error');
}
for (const input of step.querySelectorAll('input, select, textarea')) {
if (input.disabled || input.checkValidity()) continue;
input.reportValidity();
return false;
}
return true;
};
next.addEventListener('click', () => {
if (!valid(steps[current])) return;
const order = visible();
const following = order[order.indexOf(current) + 1];
if (following !== undefined) show(following);
});
back.addEventListener('click', () => {
const order = visible();
const previous = order[order.indexOf(current) - 1];
if (previous !== undefined) show(previous);
});
// Enter in einem Feld geht zum nächsten Schritt, statt das Formular vorzeitig abzuschicken.
form.addEventListener('keydown', event => {
if (event.key !== 'Enter' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'BUTTON') return;
if (!next.hidden) { event.preventDefault(); next.click(); }
});
// Nach einer Ablehnung durch den Server: der erste Schritt mit einem Fehler, sonst der erste.
const failed = steps.findIndex(step => step.querySelector('.has-error'));
const order = visible();
show(failed >= 0 ? failed : (order[0] ?? 0), false);
form.classList.add('form-steps-ready');
};
document.querySelectorAll('form.block-form').forEach(setup);
})();
+77
View File
@@ -0,0 +1,77 @@
/**
* Line art for the toolbox.
*
* Flatsome ships a PNG per element. Drawings are better here: they are a few hundred bytes
* instead of a folder of bitmaps, they take the colour of the surface they sit on, and they stay
* sharp on a screen of any density. Every shape lives in the same 24x16 field so the entries
* line up in the grid.
*
* Each value is the inside of an `<svg>`; `stroke` and `fill` come from the wrapper, so a shape
* only says what it is, not what colour it has.
*/
/** A filled bar, the building block most of these are made of. */
const bar = (x, y, width, height = 2) => `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${Math.min(height, 2) / 2}" fill="currentColor" stroke="none"/>`;
/** Several bars under each other, as a paragraph of text. */
const lines = (...widths) => widths.map((width, index) => bar(3, 4 + index * 3.4, width, 1.7)).join('');
export const PREVIEWS = {
// Aufbau
section: '<rect x="1" y="3" width="22" height="10" rx="1.5" fill="currentColor" opacity=".14" stroke="none"/><rect x="1" y="3" width="22" height="10" rx="1.5"/>',
row: '<rect x="1" y="3" width="22" height="10" rx="1.5"/><path d="M12 3.6v8.8"/>',
column: '<rect x="8" y="1.5" width="8" height="13" rx="1.5"/>',
// Inhalt
heading: `${bar(3, 3.6, 18, 3)}${bar(3, 9, 11, 2)}`,
text: lines(18, 16, 11),
image: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><circle cx="7.5" cy="6.5" r="1.4" fill="currentColor" stroke="none"/><path d="M3.2 12l5-4.4 3.6 3.1 3-2.6 5 3.9"/>',
button: '<rect x="4" y="4.5" width="16" height="7" rx="2"/>',
icon: '<path d="M12 2.6l2.7 5.6 6 .8-4.4 4.1 1.1 5.8" transform="scale(.86) translate(2 .4)"/><path d="M12 2.6l-2.7 5.6-6 .8 4.4 4.1-1.1 5.8" transform="scale(.86) translate(2 .4)"/>',
divider: '<path d="M2 8h20"/>',
spacer: '<path d="M3 3h18M3 13h18"/><path d="M12 5.6v4.8M10.4 6.8L12 5.2l1.6 1.6M10.4 9.2L12 10.8l1.6-1.6"/>',
// Bausteine
iconcard: `<rect x="3" y="2.5" width="4.5" height="4.5" rx="1.2"/>${bar(3, 9, 12, 1.9)}${bar(3, 12, 8, 1.5)}`,
stat: `${bar(3, 3, 9, 5)}${bar(3, 10.5, 14, 1.7)}`,
quote: `<path d="M3.4 7.6c0-2 1.3-3.2 2.9-3.4M8.4 7.6c0-2 1.3-3.2 2.9-3.4"/><path d="M3.4 7.6h2.9v3.2H3.4zM8.4 7.6h2.9v3.2H8.4z"/>${bar(14, 5.6, 7, 1.6)}${bar(14, 9, 5, 1.6)}`,
cta: `${bar(2.5, 4, 12, 2.4)}<rect x="2.5" y="8.6" width="9" height="5" rx="1.6"/><rect x="14.5" y="8.6" width="7" height="5" rx="1.6" fill="currentColor" stroke="none"/>`,
contact: '<rect x="2.5" y="3.5" width="19" height="9" rx="1.5"/><path d="M2.9 4.4l9.1 5 9.1-5"/>',
logos: [2.5, 8, 13.5, 19].map(x => `<rect x="${x}" y="5.5" width="4.5" height="5" rx="1.2"/>`).join(''),
progress: '<rect x="2" y="6" width="20" height="4" rx="2"/><rect x="2" y="6" width="12" height="4" rx="2" fill="currentColor" stroke="none"/>',
social: [4.5, 12, 19.5].map(x => `<circle cx="${x}" cy="8" r="3"/>`).join('') + '<path d="M3.6 8h1.8M11.1 8h1.8M18.6 8h1.8"/>',
// Gruppen
gallery: [2.5, 12.5].flatMap(x => [2.5, 8.5].map(y => `<rect x="${x}" y="${y}" width="9" height="5" rx="1.2"/>`)).join(''),
accordion: '<rect x="2.5" y="2" width="19" height="4" rx="1.2"/><path d="M18 3.6l1.3 1.3 1.3-1.3"/><rect x="2.5" y="7.5" width="19" height="3" rx="1.2"/><rect x="2.5" y="12" width="19" height="3" rx="1.2"/>',
accordionitem: '<rect x="2.5" y="5.5" width="19" height="5" rx="1.2"/><path d="M17.6 7.4l1.3 1.3 1.3-1.3"/>',
tabs: '<rect x="2.5" y="2" width="6" height="3.5" rx="1" fill="currentColor" stroke="none"/><rect x="9.5" y="2" width="6" height="3.5" rx="1"/><rect x="2.5" y="6.5" width="19" height="7.5" rx="1.2"/>',
tabitem: '<rect x="7" y="2.5" width="10" height="4" rx="1" fill="currentColor" stroke="none"/><rect x="2.5" y="7.5" width="19" height="6" rx="1.2"/>',
slider: '<rect x="5.5" y="3" width="13" height="10" rx="1.5"/><path d="M3.4 6.6L1.6 8l1.8 1.4M20.6 6.6L22.4 8l-1.8 1.4"/>',
// Seite und Daten
postlist: '<rect x="2.5" y="2.5" width="7" height="5" rx="1.2"/><rect x="2.5" y="8.5" width="7" height="5" rx="1.2"/>'
+ `${bar(11.5, 3.2, 10, 1.6)}${bar(11.5, 6, 7, 1.4)}${bar(11.5, 9.2, 10, 1.6)}${bar(11.5, 12, 7, 1.4)}`,
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
embed: '<rect x="2" y="2.5" width="20" height="11" rx="1.5"/><path d="M10.2 5.6l4.6 2.4-4.6 2.4z" fill="currentColor" stroke="none"/>',
};
/** The picture for one element type, ready to drop into the markup. */
export function preview(type) {
const shape = PREVIEWS[type];
if (!shape) return '';
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${shape}</svg>`;
}
/** A row of columns, for the entries that add a row with a column count. */
export function rowPreview(columns) {
const gap = 1.4;
const inner = (22 - gap * (columns - 1)) / columns;
const cells = Array.from({ length: columns }, (_, index) =>
`<rect x="${(1 + index * (inner + gap)).toFixed(2)}" y="3" width="${inner.toFixed(2)}" height="10" rx="1.2"/>`).join('');
return `<svg viewBox="0 0 24 16" width="34" height="23" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linejoin="round" aria-hidden="true" focusable="false">${cells}</svg>`;
}
+94
View File
@@ -0,0 +1,94 @@
import { setupPicker } from './page-picker.js';
const form = document.querySelector('[data-live-search]');
if (form) {
const results = document.querySelector('#list-results');
const status = document.querySelector('#search-status');
const error = document.querySelector('#search-error');
const search = form.querySelector('input[type="search"]');
const fields = [...form.querySelectorAll('input[name],select[name]')];
let timer, controller, sequence = 0, composing = false;
const cancel = () => { clearTimeout(timer); controller?.abort(); sequence++; results.removeAttribute('aria-busy'); };
const updateReset = () => {
form.querySelector('[data-search-reset]').hidden = !fields.some(field => field.value !== (field.dataset.searchDefault || ''));
};
const urlFor = parameters => {
const url = new URL(form.action);
for (const [name, value] of Object.entries(parameters)) {
const fallback = fields.find(field => field.name === name)?.dataset.searchDefault || (name === 'seite' ? '1' : '');
if (String(value) !== fallback) url.searchParams.set(name, String(value));
}
return url;
};
const currentUrl = () => urlFor({ ...Object.fromEntries(new FormData(form)), q: search.value.trim(), seite: 1 });
const restoreFilters = url => {
for (const field of fields) field.value = url.searchParams.get(field.name) ?? field.dataset.searchDefault ?? '';
updateReset();
};
async function load(url, navigation = 'replace', focusResults = false) {
cancel(); const request = sequence;
controller = new AbortController(); const signal = controller.signal;
results.setAttribute('aria-busy', 'true'); error.hidden = true;
status.textContent = 'Ergebnisse werden gesucht …';
updateReset();
try {
const response = await fetch(url, { signal, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
if (response.redirected || response.status === 401 || response.status === 403) throw new Error('Deine Sitzung ist abgelaufen. Bitte erneut anmelden und die Suche wiederholen.');
if (!response.ok) throw new Error('Die Suche konnte nicht geladen werden. Die bisherigen Ergebnisse bleiben sichtbar.');
const data = await response.json();
if (signal.aborted || request !== sequence) return;
if (typeof data.html !== 'string' || !data.parameters || !Number.isInteger(data.total)) throw new Error('Die Suchantwort konnte nicht verarbeitet werden.');
const parsed = new DOMParser().parseFromString(data.html, 'text/html');
results.replaceChildren(...parsed.body.childNodes);
results.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
// Keep create/upload forms outside the replaced list on the same filtered screen.
const context = { _list_query: data.parameters.q, _list_status: data.parameters.status, _list_limit: data.parameters.limit, _list_page: data.parameters.seite, q: data.parameters.q, archiv: data.parameters.archiv, seite: data.parameters.seite };
for (const [name, value] of Object.entries(context)) if (value !== undefined) {
document.querySelectorAll(`form:not([data-live-search]) input[type="hidden"][name="${name}"]`).forEach(input => { input.value = value; });
}
const canonical = urlFor(data.parameters);
if (navigation !== 'none' && canonical.href !== location.href) history[navigation === 'push' ? 'pushState' : 'replaceState'](null, '', canonical);
status.textContent = `${data.total} Treffer${data.parameters.q ? ` für „${data.parameters.q}“` : ''}.`;
if (focusResults) { results.focus({ preventScroll: true }); results.scrollIntoView({ block: 'start' }); }
} catch (failure) {
if (signal.aborted || request !== sequence) return;
error.querySelector('[role="alert"]').textContent = failure instanceof TypeError ? 'Verbindung unterbrochen. Die bisherigen Ergebnisse bleiben sichtbar.' : failure.message;
error.hidden = false; status.textContent = 'Suche fehlgeschlagen.';
} finally {
if (request === sequence) results.removeAttribute('aria-busy');
}
}
const queue = () => {
cancel(); updateReset();
if (composing) return;
error.hidden = true; status.textContent = 'Ergebnisse werden gesucht …'; results.setAttribute('aria-busy', 'true');
timer = setTimeout(() => load(currentUrl()), search.value ? 220 : 0);
};
search.addEventListener('compositionstart', () => { composing = true; cancel(); });
search.addEventListener('compositionend', () => { composing = false; queue(); });
search.addEventListener('input', queue);
form.addEventListener('change', event => { if (event.target.matches('select')) load(currentUrl(), 'push'); });
form.addEventListener('submit', event => { event.preventDefault(); if (!composing) load(currentUrl()); });
form.querySelector('[data-search-reset]').addEventListener('click', event => {
event.preventDefault(); const url = new URL(form.action); restoreFilters(url); load(url, 'push'); search.focus();
});
results.addEventListener('click', event => {
const link = event.target.closest('.pager a');
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
event.preventDefault(); load(new URL(link.href), 'push', true);
});
// A delayed response must not overwrite a form the user has just started editing.
results.addEventListener('input', () => {
if (results.getAttribute('aria-busy') === 'true') { cancel(); status.textContent = 'Suche angehalten, während du einen Eintrag bearbeitest.'; }
});
window.addEventListener('popstate', () => { const url = new URL(location.href); restoreFilters(url); load(url, 'none'); });
window.addEventListener('pagehide', cancel);
document.querySelector('#search-retry').addEventListener('click', () => load(currentUrl()));
form.classList.add('live-search-ready');
status.textContent = 'Die Suche aktualisiert sich beim Tippen.';
search.setAttribute('aria-controls', results.id);
updateReset();
}
+42 -1
View File
@@ -1,5 +1,45 @@
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
const previews = new Map();
async function previewItem(id) {
if (!previews.has(id)) previews.set(id, fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media/' + id, { credentials: 'same-origin' }).then(async response => {
if (!response.ok || response.redirected) throw new Error('Bild nicht erreichbar');
return (await response.json()).item;
}).catch(error => { previews.delete(id); throw error; }));
return previews.get(id);
}
/** A preview of the current selection, including small originals without a 480 px variant. */
export function createMediaPreview(initial = {}) {
const wrap = element('figure', '', 'selected-media-preview');
const image = element('img'); image.alt = ''; image.decoding = 'async'; image.referrerPolicy = 'no-referrer'; image.hidden = true;
const caption = element('figcaption'); caption.setAttribute('aria-live', 'polite');
wrap.append(image, caption); let sequence = 0;
const update = async ({ src = '', mediaId = '', label = '' } = {}) => {
const request = ++sequence; image.hidden = true; image.removeAttribute('src');
caption.textContent = src || mediaId ? 'Bildvorschau wird geladen …' : 'Kein Bild ausgewählt';
if (!src && !mediaId) return;
try {
if (!src && /^[a-f0-9]{32}$/.test(mediaId)) {
const item = await previewItem(mediaId);
if (request !== sequence) return;
src = item.variants[0]?.src || ''; label ||= item.title;
}
const url = new URL(src, location.origin);
if (!(src.startsWith('/') && !src.startsWith('//') && url.origin === location.origin) && url.protocol !== 'https:') throw new Error();
if (!src) throw new Error();
const description = label || decodeURIComponent(url.pathname.split('/').pop()) || 'Ausgewähltes Bild';
image.onload = () => { if (request === sequence) { image.hidden = false; caption.textContent = description; } };
image.onerror = () => { if (request === sequence) { image.hidden = true; caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.'; } };
image.src = url.href;
} catch {
if (request === sequence) caption.textContent = 'Bildvorschau nicht verfügbar. Bildadresse prüfen oder ein anderes Bild wählen.';
}
};
update(initial);
return { element: wrap, update };
}
export function openMedia({ multiple = false, onChoose } = {}) {
const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
@@ -20,10 +60,11 @@ export function openMedia({ multiple = false, onChoose } = {}) {
$('#media-insert').disabled = state || !selected.size;
};
async function api(path = '', body) {
const response = await fetch('/admin/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
const response = await fetch(document.querySelector('meta[name="admin-base"]').content + '/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
for (const item of data.items || (data.item ? [data.item] : [])) previews.set(item.id, Promise.resolve(item));
return data;
}
function render() {
+221
View File
@@ -0,0 +1,221 @@
import { setupPicker } from './page-picker.js';
const form = document.querySelector('#menu-editor');
const list = form.querySelector('#menu-items');
const status = form.querySelector('#menu-status');
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', 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 = '';
handle.title = 'Ziehen · Pfeiltasten ändern Reihenfolge und Ebene';
handle.innerHTML = '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" fill="currentColor"><circle cx="8" cy="5" r="1.6"/><circle cx="16" cy="5" r="1.6"/><circle cx="8" cy="12" r="1.6"/><circle cx="16" cy="12" r="1.6"/><circle cx="8" cy="19" r="1.6"/><circle cx="16" cy="19" r="1.6"/></svg>';
const chevron = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); chevron.setAttribute('class','menu-chevron'); chevron.setAttribute('viewBox','0 0 24 24'); chevron.setAttribute('width','20'); chevron.setAttribute('height','20'); chevron.setAttribute('aria-hidden','true');
chevron.innerHTML = '<path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/>';
return { handle, chevron };
};
const rows = () => [...list.children].map(row => {
const data = { level: Number(row.dataset.level) };
row.querySelectorAll('[data-key]').forEach(input => { data[input.dataset.key] = input.type === 'checkbox' ? (input.checked ? '1' : '0') : input.value; });
data.level = Number(data.level); data.pageId = row.querySelector('.picker-value')?.value ?? '';
data.pageDisplay = row.querySelector('[role="combobox"]')?.value ?? '';
return data;
});
const state = () => ({ name: form.elements.name.value, rows: rows() });
const key = value => JSON.stringify(value);
let last = state(), saved = key(last);
const dirty = () => key(state()) !== saved;
function refresh() {
[...list.children].forEach((row, index) => {
row.classList.toggle('menu-child', row.dataset.level === '2');
row.querySelectorAll('[data-key]').forEach(input => { input.name = `items[${index}][${input.dataset.key}]`; });
row.querySelector('[data-key="position"]').value = index;
row.querySelector('[data-key="level"]').value = row.dataset.level;
const picker = row.querySelector('.picker-value'); if (picker) picker.name = `items[${index}][pageId]`;
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
row.querySelector('.menu-entry-title').textContent = name;
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true);
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
row.querySelector('[data-menu-action="up"]').disabled = index === 0;
row.querySelector('[data-menu-action="down"]').disabled = index === list.children.length - 1;
row.querySelector('[data-menu-action="indent"]').disabled = index === 0 || row.dataset.level === '2' || list.children[index + 1]?.dataset.level === '2';
row.querySelector('[data-menu-action="outdent"]').disabled = row.dataset.level === '1';
row.querySelector('[role="group"]').setAttribute('aria-label', `${name} verschieben oder entfernen`);
row.querySelectorAll('button').forEach(button => { button.disabled ||= busy; });
row.querySelector('[data-menu-action="up"]').setAttribute('aria-label', `${name} nach oben`);
row.querySelector('[data-menu-action="down"]').setAttribute('aria-label', `${name} nach unten`);
});
form.querySelector('#menu-count').textContent = `${list.children.length} Einträge`;
form.querySelector('#menu-empty').hidden = Boolean(list.children.length);
form.querySelector('#menu-undo').disabled = !history.length || busy;
status.textContent = busy ? 'Wird gespeichert …' : dirty() ? 'Ungespeicherte Änderungen' : 'Gespeicherter Stand';
}
function createRow(data) {
const id = `new-${serial++}`;
const row = el('li', 'menu-entry'); row.dataset.level = String(data.level ?? 1);
const field = (key, value, label, type = 'text') => {
const input = el('input'); input.type = type; input.dataset.key = key; input.value = value ?? '';
input.id = `menu-${key}-${id}`;
if (type !== 'hidden') {
const caption = el('label', type === 'checkbox' ? 'check-label' : '', label); caption.htmlFor = input.id;
if (type === 'checkbox') { caption.prepend(input); body.append(caption); } else body.append(caption, input);
}
else row.append(input);
return input;
};
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
const details = el('details'); const summary = el('summary');
const { handle, chevron } = chrome();
summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details);
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
if (data.type === 'page') {
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
picker.querySelector('label').htmlFor = input.id; picker.querySelector('label').textContent = 'Verknüpfter Inhalt';
const options = picker.querySelector('[role="listbox"]'); options.id = `${input.id}-results`; options.replaceChildren(); options.hidden = true;
input.setAttribute('aria-controls', options.id); input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant');
picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = '';
body.append(picker); setupPicker(picker);
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select);
}
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
}
row.append(actions); return row;
}
function restore(value) {
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
}
function commit(before) {
refresh(); const after = state();
if (key(before) !== key(after)) { history.push(before); if (history.length > 50) history.shift(); }
last = after; refresh();
}
function add(data) {
if (list.children.length >= 60) { error.textContent = 'Ein Menü fasst höchstens 60 Einträge.'; error.hidden = false; return; }
const before = state(); const row = createRow(data); list.append(row); commit(before);
row.scrollIntoView({ block: 'nearest' }); row.querySelector('summary').focus();
}
const addPicker = form.querySelector('.menu-add .content-picker');
addPicker.querySelector('.picker-value').addEventListener('change', () => {
const id = addPicker.querySelector('.picker-value').value;
pendingPage = id ? { id, display: addPicker.querySelector('[role="combobox"]').value } : null;
form.querySelector('#menu-add-page').disabled = !pendingPage;
});
form.querySelector('#menu-add-page').addEventListener('click', () => {
if (pendingPage) add({ type: 'page', label: pendingPage.display.replace(/ \(\/.*\)$/, ''), pageId: pendingPage.id, pageDisplay: pendingPage.display, level: 1 });
});
form.querySelector('#menu-add-url').addEventListener('click', () => {
const name = form.querySelector('#link-label'), url = form.querySelector('#link-url');
if (!name.value.trim() || !url.value.trim()) { error.hidden = false; error.textContent = 'Bitte Beschriftung und Adresse des Links ausfüllen.'; (!name.value.trim() ? name : url).focus(); return; }
const newTab = form.querySelector('#link-new-tab');
error.hidden = true; add({ type: 'url', label: name.value.trim(), url: url.value.trim(), newTab: newTab.checked, level: 1 }); name.value = url.value = ''; newTab.checked = false;
});
form.addEventListener('input', event => { if (event.target.closest('.menu-structure')) refresh(); });
form.addEventListener('change', event => { if (event.target.closest('.menu-structure')) commit(last); });
list.addEventListener('click', event => {
const button = event.target.closest('[data-menu-action]'); if (!button || busy) return;
const row = button.closest('.menu-entry'), before = state(), action = button.dataset.menuAction;
const entries = [...list.children], index = entries.indexOf(row);
const bundle = [row];
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
if (action === 'remove') {
row.remove();
// Removing a heading never deletes its links: promote its children visibly.
bundle.slice(1).forEach(child => { child.dataset.level = '1'; });
}
if (action === 'indent') row.dataset.level = '2';
if (action === 'outdent') row.dataset.level = '1';
if (action === 'up' && index > 0) {
let target = index - 1;
if (row.dataset.level === '1') while (target > 0 && entries[target].dataset.level === '2') target--;
bundle.forEach(item => list.insertBefore(item, entries[target]));
}
if (action === 'down') {
let target = index + bundle.length;
if (entries[target]) {
target++;
if (row.dataset.level === '1') while (entries[target]?.dataset.level === '2') target++;
bundle.forEach(item => list.insertBefore(item, entries[target] || null));
}
}
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
commit(before); if (row.isConnected) row.querySelector('summary').focus();
});
form.querySelector('#menu-undo').addEventListener('click', () => { const previous = history.pop(); if (previous) restore(previous); });
// Pointer capture supports mouse, pen and touch. A parent travels with its children.
let drag = null;
list.addEventListener('click', event => { if (event.target.closest('[data-menu-drag]')) { event.preventDefault(); event.stopPropagation(); } });
list.addEventListener('keydown', event => {
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy) return;
const action = { ArrowUp: 'up', ArrowDown: 'down', ArrowRight: 'indent', ArrowLeft: 'outdent' }[event.key];
if (action) { event.preventDefault(); const row = handle.closest('.menu-entry'); row.querySelector(`[data-menu-action="${action}"]`).click(); handle.focus(); }
});
list.addEventListener('pointerdown', event => {
const handle = event.target.closest('[data-menu-drag]'); if (!handle || busy || event.button !== 0) return;
event.preventDefault(); handle.focus(); handle.setPointerCapture(event.pointerId);
const row = handle.closest('.menu-entry'), entries = [...list.children], index = entries.indexOf(row), bundle = [row];
if (row.dataset.level === '1') for (let i = index + 1; entries[i]?.dataset.level === '2'; i++) bundle.push(entries[i]);
drag = { handle, row, bundle, before: state(), x: event.clientX, y: event.clientY, pointer: event.pointerId, active: false, level: row.dataset.level };
});
list.addEventListener('pointermove', event => {
if (!drag || event.pointerId !== drag.pointer) return;
if (!drag.active && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 6) return;
drag.active = true; drag.bundle.forEach(row => row.classList.add('menu-dragging'));
const others = [...list.children].filter(row => !drag.bundle.includes(row));
let target = others.find(row => event.clientY < row.getBoundingClientRect().top + row.getBoundingClientRect().height / 2) || null;
// A main entry never splits the children of another main entry.
if ((drag.bundle.length > 1 || drag.level === '1') && target?.dataset.level === '2') {
let index = others.indexOf(target); while (index > 0 && others[index].dataset.level === '2') index--; target = others[index];
}
drag.bundle.forEach(row => list.insertBefore(row, target));
if (drag.bundle.length === 1) drag.row.dataset.level = event.clientX - drag.x > 36 ? '2' : event.clientX - drag.x < -36 ? '1' : drag.level;
if (list.firstElementChild) list.firstElementChild.dataset.level = '1';
refresh();
if (event.clientY > window.innerHeight - 65) window.scrollBy(0, 18); else if (event.clientY < 65) window.scrollBy(0, -18);
});
function endDrag(cancel = false) {
if (!drag) return;
const previous = drag; drag = null;
previous.bundle.forEach(row => row.classList.remove('menu-dragging'));
if (previous.handle.hasPointerCapture(previous.pointer)) previous.handle.releasePointerCapture(previous.pointer);
if (cancel) restore(previous.before); else if (previous.active) { commit(previous.before); previous.handle.focus(); status.textContent = 'Reihenfolge geändert · noch nicht gespeichert'; }
}
list.addEventListener('pointerup', () => endDrag()); list.addEventListener('pointercancel', () => endDrag(true));
document.addEventListener('keydown', event => { if (event.key === 'Escape' && drag) { event.preventDefault(); endDrag(true); } });
window.addEventListener('beforeunload', event => { if (dirty()) { event.preventDefault(); event.returnValue = ''; } });
form.noValidate = true;
form.addEventListener('submit', async event => {
event.preventDefault(); if (busy) return;
const invalid = form.querySelector(':invalid');
if (invalid) { const details = invalid.closest('details'); if (details) details.open = true; invalid.reportValidity(); return; }
refresh(); const body = new FormData(form); const snapshot = key(state());
busy = true; error.hidden = true; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = true; }); refresh();
try {
const response = await fetch(form.action, { method: 'POST', body, headers: { Accept: 'application/json' }, credentials: 'same-origin' });
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Die Änderungen bleiben hier erhalten. Bitte erneut anmelden.');
const result = await response.json(); if (!response.ok) throw new Error(result.error || 'Das Menü konnte nicht gespeichert werden.');
form.elements.expectedVersion.value = result.version; saved = snapshot;
} catch (failure) { error.textContent = failure.message || 'Verbindung unterbrochen. Die Änderungen bleiben hier erhalten.'; error.hidden = false; }
finally { busy = false; form.querySelectorAll('input,select,button').forEach(node => { node.disabled = false; }); form.querySelector('#menu-add-page').disabled = !pendingPage; refresh(); }
});
refresh(); last = state(); saved = key(last);
+96
View File
@@ -0,0 +1,96 @@
/**
* Entrance effects.
*
* Visible content is the baseline: without this script every block simply stands on the page.
* With it, a block that carries an entrance class waits out of sight until it scrolls into
* view, then comes in - the way the Flatsome animations behave.
*
* Whether the browser preference "reduce motion" counts is the website's decision, made under
* Einstellungen and delivered as a class on <html>. Without `motion-respect` the effects play
* for everyone; many systems report the preference without anyone having chosen it, and a site
* whose animations never show is not what the person building it asked for.
*/
export function initMotion(doc = document) {
const view = doc.defaultView;
const html = doc.documentElement;
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
const reduced = () => html.classList.contains('motion-respect') && preference.matches;
const animations = new Set();
const seen = new WeakSet();
// Blocks hidden while they wait. Kept so they can be shown again the moment the effects are
// switched off - a block must never stay invisible because of a preference.
const hidden = new Set();
const start = {
fade: { opacity: 0 },
rise: { opacity: 0, transform: 'translateY(40px)' },
sink: { opacity: 0, transform: 'translateY(-40px)' },
left: { opacity: 0, transform: 'translateX(-56px)' },
right: { opacity: 0, transform: 'translateX(56px)' },
zoom: { opacity: 0, transform: 'scale(.88)' },
flip: { opacity: 0, transform: 'perspective(800px) rotateX(-18deg)' },
blur: { opacity: 0, filter: 'blur(8px)' },
};
const nameOf = node => Object.keys(start).find(name => node.classList.contains(`fx-entrance-${name}`));
const nodes = [...doc.querySelectorAll('[class*="fx-entrance-"]')].filter(nameOf);
const hide = node => { if (!seen.has(node) && !hidden.has(node)) { hidden.add(node); node.style.opacity = '0'; } };
const unhide = node => { if (hidden.delete(node)) node.style.opacity = ''; };
const show = node => {
if (seen.has(node) || reduced()) return;
seen.add(node);
unhide(node);
const name = nameOf(node);
const delay = node.classList.contains('fx-delay-long') ? 600 : node.classList.contains('fx-delay-medium') ? 300 : node.classList.contains('fx-delay-short') ? 150 : 0;
// The block's own transform and filter, if any, are the end state - the entrance is
// composed in front of them rather than replacing them.
const style = view.getComputedStyle(node);
const finish = { opacity: style.opacity, transform: style.transform, filter: style.filter };
const first = { ...start[name] };
if (first.transform && finish.transform !== 'none') first.transform += ' ' + finish.transform;
if (first.filter && finish.filter !== 'none') first.filter += ' ' + finish.filter;
// `backwards` holds the start state through the delay, so a delayed block does not flash
// up and vanish before its turn.
const animation = node.animate([first, finish], { duration: 750, delay, easing: 'cubic-bezier(.2,.7,.2,1)', fill: 'backwards' });
animations.add(animation);
animation.finished.then(() => animations.delete(animation), () => animations.delete(animation));
};
/** A block the reader has already scrolled past: shown at once, no entrance for nobody. */
const passed = node => { seen.add(node); unhide(node); observer.unobserve(node); };
const observer = new view.IntersectionObserver(entries => {
for (const entry of entries) {
if (entry.isIntersecting) { show(entry.target); observer.unobserve(entry.target); }
else if (entry.boundingClientRect.bottom < 0) passed(entry.target);
else hide(entry.target);
}
}, { threshold: 0, rootMargin: '0px 0px -40px 0px' });
// A jump - an anchor link, a fast scroll, a restored position - can carry the reader past a
// block without it ever intersecting. Nothing that is above the viewport may stay hidden.
let sweeping = false;
const sweep = () => {
sweeping = false;
for (const node of [...hidden]) if (node.getBoundingClientRect().bottom < 0) passed(node);
};
const onScroll = () => { if (!sweeping) { sweeping = true; view.requestAnimationFrame(sweep); } };
const syncPreference = () => {
if (reduced()) {
observer.disconnect();
animations.forEach(animation => animation.cancel());
[...hidden].forEach(unhide);
} else {
// Reduction may have been active at startup or enabled during this visit.
// Resume observing unseen content when movement is allowed again.
nodes.forEach(node => { if (!seen.has(node)) observer.observe(node); });
}
};
preference.addEventListener('change', syncPreference);
view.addEventListener('scroll', onScroll, { passive: true });
syncPreference();
return () => {
observer.disconnect();
animations.forEach(animation => animation.cancel());
[...hidden].forEach(unhide);
preference.removeEventListener('change', syncPreference);
view.removeEventListener('scroll', onScroll);
};
}
if (!document.querySelector('#editor')) initMotion();
+67
View File
@@ -0,0 +1,67 @@
export function setupPicker(picker, onSelect = () => {}) {
if (picker.dataset.ready) return;
picker.dataset.ready = 'true';
const input = picker.querySelector('[role="combobox"]');
const value = picker.querySelector('.picker-value');
const results = picker.querySelector('[role="listbox"]');
const status = picker.querySelector('.picker-status');
let controller, timer, current = -1, items = [];
const close = () => {
results.hidden = true; input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant'); current = -1;
};
const select = item => {
value.value = item?.id ?? '';
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
};
const activate = index => {
current = Math.max(0, Math.min(items.length - 1, index));
[...results.children].forEach((node, i) => node.setAttribute('aria-selected', String(i === current)));
const option = results.children[current];
if (option) { input.setAttribute('aria-activedescendant', option.id); option.scrollIntoView({ block: 'nearest' }); }
};
const search = async query => {
controller?.abort(); controller = new AbortController();
const signal = controller.signal;
const url = new URL(picker.dataset.source, location.href);
url.searchParams.set('q', query); url.searchParams.set('type', picker.dataset.type || '');
url.searchParams.set('parents', picker.dataset.parents || '0');
status.textContent = 'Wird gesucht …';
try {
const response = await fetch(url, { signal, credentials: 'same-origin' });
if (!response.ok || response.redirected) throw new Error();
const data = await response.json();
if (signal.aborted) return;
items = data.items; current = -1; results.replaceChildren();
items.forEach((item, index) => {
const option = document.createElement('button'); option.type = 'button'; option.tabIndex = -1;
option.id = `${input.id}-option-${index}`; option.setAttribute('role', 'option'); option.setAttribute('aria-selected', 'false');
const title = document.createElement('strong'); title.textContent = item.title;
const detail = document.createElement('span'); detail.textContent = `/${item.path === 'home' ? '' : item.path} · ${item.type === 'post' ? 'Beitrag' : 'Seite'}${item.published ? '' : ' · Entwurf'}`;
option.append(title, detail); option.addEventListener('mousedown', event => event.preventDefault());
option.addEventListener('click', () => { select(item); input.focus({ preventScroll: true }); close(); }); results.append(option);
});
results.hidden = !items.length; input.setAttribute('aria-expanded', String(Boolean(items.length)));
status.textContent = items.length ? `${items.length} Treffer${items.length === 20 ? ' · Suche eingrenzen für weitere Ergebnisse' : ''}.` : 'Keine passenden Inhalte gefunden.';
} catch (error) {
if (error.name !== 'AbortError') { close(); status.textContent = 'Inhalte konnten nicht geladen werden. Bitte erneut suchen oder neu anmelden.'; }
}
};
input.addEventListener('focus', () => search(''));
input.addEventListener('input', () => {
value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
value.dispatchEvent(new Event('change', { bubbles: true }));
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
});
input.addEventListener('keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); controller?.abort(); clearTimeout(timer); close(); }
if (['ArrowDown', 'ArrowUp'].includes(event.key) && !results.hidden) { event.preventDefault(); activate(current + (event.key === 'ArrowDown' ? 1 : -1)); }
if (event.key === 'Enter' && current >= 0 && !results.hidden) { event.preventDefault(); select(items[current]); }
});
picker.addEventListener('focusout', event => { if (!picker.contains(event.relatedTarget)) { controller?.abort(); clearTimeout(timer); close(); } });
picker.querySelector('.picker-clear').addEventListener('click', () => { controller?.abort(); clearTimeout(timer); select(null); input.focus(); });
}
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
+56
View File
@@ -0,0 +1,56 @@
import { blockLabel } from './editor-model.js';
/** Editing aids are created only in the preview DOM, never in a saved block or public HTML. */
export function decorateContainers(preview, content) {
const visit = (blocks, parent = null) => {
blocks.forEach((block, index) => {
const node = preview.getElementById(block.id);
// Editing must expose the body without changing the visitor's saved open/closed state.
if (node && block.type === 'accordionitem') node.open = true;
if (node && ['section', 'row', 'column', 'accordion', 'accordionitem'].includes(block.type)) {
const label = block.type === 'column' && parent?.type === 'row'
? `Spalte ${index + 1} von ${blocks.length}` : block.type === 'accordionitem'
? `Eintrag „${block.props?.title || 'Ohne Titel'}“` : blockLabel(block);
node.classList.add('editor-container');
node.dataset.editorLabel = label;
if (!block.children?.length || ['accordion', 'accordionitem'].includes(block.type)) {
const compact = Boolean(block.children?.length);
if (!compact) node.classList.add('editor-empty-container');
const host = block.type === 'section' ? node.querySelector(':scope > .section-inner')
: block.type === 'accordionitem' ? node.querySelector(':scope > .accordion-body') : node;
if (!host) return;
const empty = preview.createElement('div');
empty.className = compact ? 'editor-empty editor-add-content' : 'editor-empty';
const name = preview.createElement('span');
name.className = 'editor-empty-name'; name.textContent = label;
const hint = preview.createElement('span');
hint.className = 'editor-empty-hint';
hint.textContent = block.type === 'row' ? 'Spalte hierher ziehen oder hinzufügen'
: block.type === 'accordion' ? 'Eintrag hinzufügen und darin Inhalte gestalten' : 'Element hierher ziehen oder hinzufügen';
const actions = preview.createElement('div'); actions.className = 'editor-empty-actions';
const add = (text, attribute, value, accessibleName) => {
const button = preview.createElement('button');
button.type = 'button'; button.textContent = text;
button.dataset.editorTarget = block.id;
button.setAttribute(attribute, value);
button.setAttribute('aria-label', accessibleName);
actions.append(button);
};
add(block.type === 'row' ? '+ Spalte hinzufügen' : '+ Inhalt hinzufügen', 'data-editor-insert', '', `${label}: ${block.type === 'row' ? 'Spalte' : 'Inhalt'} hinzufügen`);
if (block.type === 'accordion') {
actions.replaceChildren();
add('+ Eintrag hinzufügen', 'data-editor-add-entry', '', 'Akkordeon: Eintrag hinzufügen');
}
if (block.type === 'section') {
for (const count of [1, 2, 3, 4]) add(`${count} ${count === 1 ? 'Spalte' : 'Spalten'}`, 'data-editor-row', String(count), `${count} ${count === 1 ? 'Spalte' : 'Spalten'} in dieser Sektion anlegen`);
}
if (!compact) empty.append(name, hint);
empty.append(actions);
host.append(empty);
}
}
visit(block.children ?? [], block);
});
};
visit(content.blocks);
}
+16
View File
@@ -7,3 +7,19 @@
/* Inline editing. Only ever active in the editor preview, never on a published page. */
[contenteditable].editor-inline{outline:3px solid #b55b12;outline-offset:4px;border-radius:3px;cursor:text;background:#fffdf7}
/* Empty layout areas must have a real hit area, including when sibling columns have content. */
.editor-empty-container{min-height:130px!important}
.editor-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;min-height:130px;width:100%;padding:22px 14px;border:2px dashed #93aa9a;border-radius:7px;background:#f3f8f4;color:#264f3d;text-align:center;font:14px/1.5 system-ui,sans-serif;box-sizing:border-box;grid-column:1/-1}
.editor-empty-name{font-size:12px;font-weight:700;letter-spacing:.04em}
.editor-empty-hint{font-size:13px;color:#496351}
.editor-empty-actions{display:flex;justify-content:center;flex-wrap:wrap;gap:8px}
.editor-empty button{appearance:none;font:600 13px/1.3 system-ui,sans-serif;color:#264f3d;background:white;border:1px solid #94ad9d;border-radius:6px;min-height:40px;padding:9px 13px;cursor:pointer;letter-spacing:normal;text-transform:none;box-shadow:none;margin:0}
.editor-empty button:hover,.editor-empty button:focus-visible{background:#deeee2;border-color:#32644a;outline:2px solid #32644a;outline-offset:2px}
.editor-empty button[data-editor-insert]{background:#2c634a;color:white;border-color:#2c634a}
.editor-container:hover:not(.editor-selected),.editor-dragging .editor-container{outline:1px dashed #6b927a;outline-offset:-1px}
.editor-container.editor-insert-target{outline:3px solid #b55b12!important;outline-offset:-3px}
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
+28 -8
View File
@@ -39,7 +39,7 @@ function button(label, title, onClick, className = 'rt-button') {
* Builds the control. `apply` is called with the markup whenever it changes; the caller decides
* what to do with it (history entry, preview refresh).
*/
export function createRichText(value, apply, { label = 'Text' } = {}) {
export function createRichText(value, apply, { label = 'Text', plain = false } = {}) {
const wrap = document.createElement('div');
wrap.className = 'rich-text';
@@ -55,13 +55,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
surface.setAttribute('role', 'textbox');
surface.setAttribute('aria-multiline', 'true');
surface.setAttribute('aria-label', label);
surface.innerHTML = value ?? '';
// Unter Schema v12 ist der Text Klartext: "Preis <b>5 Euro</b>" ist dann eine Zeichenfolge, kein
// Markup, und darf beim Oeffnen nicht als HTML gelesen werden.
if (plain) surface.textContent = value ?? '';
else surface.innerHTML = value ?? '';
const source = document.createElement('textarea');
source.className = 'rt-source';
source.hidden = true;
source.setAttribute('aria-label', `${label} als HTML`);
source.value = value ?? '';
source.value = plain ? surface.innerHTML : (value ?? '');
const push = markup => { source.value = markup; apply(markup); };
@@ -83,7 +86,10 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
linkInput.type = 'text';
linkInput.placeholder = '/kontakt, #abschnitt, https://…, mailto:…';
linkInput.setAttribute('aria-label', 'Linkziel');
let savedRange = null;
const newTabLabel = document.createElement('label'); newTabLabel.className = 'check-label';
const newTab = document.createElement('input'); newTab.type = 'checkbox';
newTabLabel.append(newTab, document.createTextNode(' In neuem Tab öffnen'));
let savedRange = null, currentLink = null;
const confirmLink = () => {
const target = linkInput.value.trim();
linkRow.hidden = true;
@@ -93,8 +99,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
selection.removeAllRanges();
selection.addRange(savedRange);
}
if (target) document.execCommand('createLink', false, target);
else document.execCommand('unlink');
if (target) {
if (currentLink && surface.contains(currentLink)) currentLink.setAttribute('href', target);
else document.execCommand('createLink', false, target);
const range = window.getSelection().rangeCount ? window.getSelection().getRangeAt(0) : null;
for (const anchor of surface.querySelectorAll('a')) {
if (anchor !== currentLink && !(anchor.getAttribute('href') === target && range?.intersectsNode(anchor))) continue;
if (newTab.checked) { anchor.setAttribute('target', '_blank'); anchor.setAttribute('rel', 'noopener'); }
else { anchor.removeAttribute('target'); anchor.removeAttribute('rel'); }
}
} else document.execCommand('unlink');
push(surface.innerHTML);
linkInput.value = '';
};
@@ -102,14 +116,20 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
});
linkRow.append(linkInput, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
const selection = window.getSelection();
savedRange = selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
savedRange = selection.rangeCount > 0 && surface.contains(selection.getRangeAt(0).commonAncestorContainer) ? selection.getRangeAt(0).cloneRange() : null;
const node = savedRange?.startContainer;
currentLink = (node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement)?.closest('a');
if (currentLink && (!surface.contains(currentLink) || !currentLink.contains(savedRange.endContainer))) currentLink = null;
linkInput.value = currentLink?.getAttribute('href') || '';
newTab.checked = currentLink?.getAttribute('target') === '_blank';
linkRow.hidden = false;
linkInput.focus();
}));
linkRow.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); linkRow.hidden = true; surface.focus(); } });
const toggle = button('HTML', 'Zwischen Formatierung und HTML wechseln', () => {
const toSource = source.hidden;
+34
View File
@@ -0,0 +1,34 @@
/**
* Marks the page as scrolled.
*
* A header can carry a second set of colours for the scrolled page - transparent over the hero,
* solid once the reader has moved on, the way the Flatsome transparent header works. The
* stylesheet holds both states; this only says which one applies. Without the script the header
* keeps its normal colours, which is a readable page, not a broken one.
*/
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;
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);
root.classList.remove('is-scrolled', 'kant-top-armed', 'kant-top-shown');
};
}
if (!document.querySelector('#editor')) initScrolled();
+224 -47
View File
@@ -5,9 +5,13 @@ h1,h2,h3,h4,h5,h6,p,a{overflow-wrap:break-word}
h1,h2,h3,h4,h5,h6{hyphens:manual}
p{hyphens:auto}
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
:root{color-scheme:var(--k-scheme,light);--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;--error:var(--k-error,#8b2f22);--error-bg:var(--k-error-bg,#fff0eb);--error-line:var(--k-error-line,#e2b2a7);font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}
/* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe
und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */
.site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none}
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:var(--paper);padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:767px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
@@ -23,7 +27,7 @@ p{hyphens:auto}
/* Posts */
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
.post-more::after{content:'→';transition:transform .2s}
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
@@ -41,7 +45,11 @@ p{hyphens:auto}
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
.block-icon{flex-shrink:0;display:block}
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
letzte Karte um genau diesen Betrag niedriger als die erste, weil ein Rand im Rasterfeld liegt. */
.block-row > .block-iconcard + .block-iconcard,.block-row > .block-quote + .block-quote,.block-row > .block-cta + .block-cta,.block-row > .block-iconcard + .block-quote,.block-row > .block-quote + .block-iconcard{margin-top:0}
.kant .block-iconcard h3{margin:0;font-size:19px;line-height:1.3}
.block-iconcard h3 a{text-decoration:none}
.block-iconcard h3 a:hover{text-decoration:underline}
.block-iconcard p{margin:0;line-height:1.65}
@@ -56,7 +64,7 @@ p{hyphens:auto}
.quote-rating{margin:0;letter-spacing:3px}
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
.kant .block-form button[type=submit]{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-form button[type=submit]:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
.kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
.block-cta p{margin:0;line-height:1.6}
.block-contact{display:grid;gap:14px;font-style:normal}
@@ -87,22 +95,74 @@ p{hyphens:auto}
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
.block-form{display:grid;gap:20px;max-width:640px}
.block-form>.cms-button{justify-self:start}
/* Ausrichtung eines Buttons (Design-Token „align"): ein inline-flex-Element zentriert sich nicht
über margin:auto, darum hier als Block mit eigener Breite - in Spalten, Zeilen und Rastern. */
.kant .cms-button.d-desktop-align-left{justify-self:start;margin-inline:0}
.kant .cms-button.d-desktop-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}
.kant .cms-button.d-desktop-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}
@media(max-width:1024px){.kant .cms-button.d-tablet-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-tablet-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-tablet-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
@media(max-width:767px){.kant .cms-button.d-mobile-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-mobile-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-mobile-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
.form-field{display:grid;gap:7px}
.form-field label{font-weight:600;font-size:14px}
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid currentColor;border-radius:8px;background:#fff;color:inherit;font:inherit;min-height:46px}
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid var(--form-border,currentColor);border-radius:8px;background:var(--form-bg,#fff);color:inherit;font:inherit;min-height:46px}
.form-field textarea{min-height:140px;line-height:1.6;resize:vertical}
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:#8b2f22}
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:var(--error)}
.field-error{margin:0;font-size:13px;font-weight:600;color:var(--error)}
.field-note{margin:0;font-size:13px;opacity:.8}
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600}
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:var(--error-bg);color:var(--error);border:1px solid var(--error-line);font-weight:600}
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:color-mix(in srgb,var(--accent) 10%,var(--paper));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--paper));font-weight:600}
.form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px}
.check-row{display:flex;align-items:flex-start;gap:11px}
.check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px}
.check-row label{font-weight:400;line-height:1.55}
.required-mark{color:#8b2f22}
.required-mark{color:var(--error)}
/* Off screen rather than display:none so a filled value still proves automation. */
.form-trap{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
/* Schritte, Bedingungen, Zusammenfassung. Ohne Skript: Abschnitte untereinander, alles sichtbar. */
.form-step{display:grid;gap:20px;margin:0;padding:0;border:0;min-width:0}
/* Ein Schritt und der Absendeknopf sind Raster bzw. inline-flex - das hidden-Attribut muss trotzdem gelten. */
.block-form [hidden]{display:none!important}
.form-step-title{padding:0;margin:0;font-weight:600;font-size:18px;line-height:1.3}
.form-step-title:focus{outline:none}
.form-step-intro{margin:-8px 0 0;font-size:14px;opacity:.85}
.form-choice{display:grid;gap:10px;margin:0;padding:0;border:0;min-width:0}
.form-choice legend{padding:0;margin-bottom:2px;font-weight:600;font-size:14px}
.form-choice .check-row{align-items:center}
.form-choice .check-row input{margin-top:0}
/* Bildauswahl: jede Option eine Karte, das Kästchen bleibt für Tastatur und Vorlesesoftware. */
.form-choice-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:12px}
.form-choice-card{position:relative}
.form-choice-card input{position:absolute;top:10px;left:10px;width:20px;height:20px;margin:0;z-index:1}
.form-choice-card label{display:grid;gap:8px;padding:8px;border:2px solid var(--form-border,currentColor);border-radius:10px;background:var(--form-bg,#fff);cursor:pointer;font-weight:600;font-size:14px;text-align:center;transition:border-color .2s,box-shadow .2s}
.form-choice-card label:hover{border-color:var(--kb-fill,var(--accent))}
.form-choice-card input:checked+label{border-color:var(--kb-fill,var(--accent));box-shadow:0 0 0 2px var(--kb-fill,var(--accent))}
.form-choice-card input:focus-visible+label{outline:3px solid var(--kb-fill,var(--accent));outline-offset:3px}
.form-choice-picture{display:block;aspect-ratio:4/3;border-radius:6px;overflow:hidden;background:color-mix(in srgb,currentColor 6%,transparent)}
.form-choice-picture img{display:block;width:100%;height:100%;object-fit:cover}
.form-choice-text{display:block;padding:0 4px 4px}
.form-number{display:flex;align-items:center;gap:10px}
.form-number input{flex:1 1 auto;min-width:0}
.form-unit{flex:none;font-size:14px;opacity:.8}
[data-inactive]{display:none!important}
.form-progress{display:flex;flex-wrap:wrap;gap:8px 18px;list-style:none;margin:0;padding:0;font-size:13px}
.form-progress-step{display:flex;align-items:center;gap:8px;opacity:.55}
.form-progress-step[aria-current]{opacity:1;font-weight:600}
.form-progress-step.is-done{opacity:.85}
.form-progress-number{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;border:1px solid currentColor;font-size:12px}
.form-progress-step[aria-current] .form-progress-number,.form-progress-step.is-done .form-progress-number{background:var(--kb-fill,var(--accent));color:var(--kb-ink,#fff);border-color:transparent}
.form-step-nav{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
.form-step-nav .form-step-back{--kb-fill:transparent;--kb-ink:currentColor;--kb-hover-fill:transparent;--kb-hover-ink:currentColor;border:1px solid currentColor;background:transparent;color:inherit}
.form-summary-body{display:grid;gap:18px}
.form-summary-step{padding:16px 18px;border:1px solid var(--form-border,currentColor);border-radius:8px}
.form-summary-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:8px}
.form-summary-title{margin:0;font-size:15px;font-weight:600}
.form-summary-change{border:0;padding:0;background:none;color:inherit;font:inherit;font-size:14px;text-decoration:underline;cursor:pointer}
.form-summary-list{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 16px;margin:0;font-size:14px}
.form-summary-list dt{font-weight:600}
.form-summary-list dd{margin:0;overflow-wrap:anywhere}
.form-summary-empty{opacity:.6}
@media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}}
.block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px}
.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6}
@@ -113,35 +173,60 @@ p{hyphens:auto}
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
later, so the dark tone wins where it has to. */
.tone-ink .block-column .block-text{color:#dae3da}
.tone-ink .block-column .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
stays readable while the decision is open, because a wall would make "accept" the only way
forward. Accepting and refusing are the same button in the same row. */
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
.consent-bar p{margin:0 0 14px}
forward. Accepting and refusing are the same button in the same row.
Aussehen aus den Einstellungen: `--kant-consent-*` im Kopf der Seite (Consent::styles), die
Anordnung als Klasse. Ohne Einstellung gilt Papier, Tinte und Akzent des Themes. Ein Wert wie
`--k-consent` aus der Palette bleibt als Zwischenstufe erhalten. */
.consent-bar{position:fixed;inset-inline:16px;bottom:16px;z-index:50;max-width:640px;max-height:calc(100dvh - 24px);overflow:auto;margin-inline:auto;background:var(--kant-consent-bg,var(--paper));color:var(--kant-consent-ink,var(--ink));border:1px solid color-mix(in srgb,currentColor 12%,transparent);border-radius:var(--kant-consent-radius,18px);padding:22px 24px 20px;box-shadow:0 24px 60px color-mix(in srgb,var(--ink) 22%,transparent);font-size:14px;line-height:1.6;overscroll-behavior:contain}
/* Ein schmaler Streifen in der Akzentfarbe oben: die Fläche gehört sichtbar zur Website. */
.consent-bar::before{content:'';position:absolute;inset:0 0 auto 0;height:4px;background:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
.kant .consent-bar h2{display:flex;align-items:center;gap:10px;font-size:17px;line-height:1.3;margin:0 0 8px}
.consent-icon{flex:none;color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
.consent-bar p{margin:0 0 16px;color:color-mix(in srgb,currentColor 82%,transparent)}
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
.consent-actions .consent-button:last-child{background:var(--paper);color:var(--ink);border-color:var(--line)}
.consent-button{display:inline-flex;align-items:center;justify-content:center;flex:1 1 150px;min-height:46px;padding:11px 18px;border-radius:var(--kant-consent-button-radius,10px);border:1.5px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));background:var(--kant-consent-accent,var(--k-consent,var(--accent)));color:var(--kant-consent-accent-ink,#fff);font:inherit;font-weight:600;cursor:pointer;transition:filter .12s,background-color .12s}
.consent-decline,.consent-choice{background:transparent;color:inherit;border-color:color-mix(in srgb,currentColor 28%,transparent)}
.consent-button:hover{filter:brightness(.94)}
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
.consent-details{margin-top:14px}
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
.consent-decline:hover,.consent-choice:hover{filter:none;background:color-mix(in srgb,currentColor 7%,transparent)}
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));outline-offset:2px}
/* Fusszeile der Frage: Einzelwahl links, Rechtslinks rechts, auf einer Linie. Aufgeklappt
nimmt die Einzelwahl die volle Breite und die Links rutschen rechtsbuendig darunter. */
.consent-foot{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:8px 20px;margin-top:14px}
.consent-details{flex:0 1 auto;min-width:0}
.consent-details[open]{flex-basis:100%}
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:6px 0;list-style:none;display:inline-flex;align-items:center;gap:6px}
.consent-details>summary::-webkit-details-marker{display:none}
.consent-details>summary::after{content:'';width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:translateY(-2px) rotate(45deg);transition:transform .15s}
.consent-details[open]>summary::after{transform:translateY(2px) rotate(-135deg)}
.consent-details>summary:hover{text-decoration:underline}
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
.consent-choice{margin-top:14px;background:var(--paper);color:var(--ink);border-color:var(--line)}
.consent-links{margin:14px 0 0;font-size:12px}
.consent-links a{color:var(--accent)}
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0;accent-color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
.consent-choice{margin-top:14px;flex:none;width:auto}
/* Eigener Selektor mit Element, sonst gewinnt `.consent-bar p` und nimmt den Rand rechts weg. */
.consent-bar p.consent-links{margin:0 0 0 auto;display:flex;gap:14px;font-size:12px;color:color-mix(in srgb,currentColor 72%,transparent)}
.consent-links a{color:inherit;text-decoration:underline;text-underline-offset:2px}
.consent-links a:hover{color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
/* Leiste: über die ganze Breite am unteren Rand, auf breiten Bildschirmen Text links und Knöpfe
rechts. Ecke: eine kleine Karte rechts unten, die den Inhalt kaum verdeckt. */
.consent-layout-leiste{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding-inline:max(24px,calc((100% - 1180px) / 2))}
@media (min-width:900px){.consent-layout-leiste{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 40px;align-items:center}.consent-layout-leiste .consent-body p{margin-bottom:0}.consent-layout-leiste .consent-form{width:360px;max-width:100%}}
.consent-layout-ecke{inset-inline:auto 16px;max-width:400px;margin:0}
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
otherwise it covers the last element, and an embed's own consent button sat right underneath.
Found by the browser test, which could not click it. */
body.consent-open{padding-bottom:260px}
Found by the browser test, which could not click it. Auf schmalen Bildschirmen ist die Leiste
mit Standardtext bis 290 px hoch; 320 px lassen ihr Luft, ohne einen Bildschirm zu verschenken. */
body.consent-open{padding-bottom:320px}
@media (min-width:621px){body.consent-open{padding-bottom:270px}}
@media (min-width:900px){body.consent-open{padding-bottom:200px}}
.consent-page main{max-width:680px;margin-inline:auto;padding:40px 20px}
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;margin-top:24px}
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;max-height:none;margin:24px 0 0;display:block}
@media (max-width:620px){.consent-bar,.consent-layout-ecke{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding:18px 18px 16px}.consent-layout-leiste{padding-inline:18px}}
/* An embedded third party, and what stands in its place until it may load. */
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 8%,var(--paper))}
@@ -162,12 +247,6 @@ body.consent-open{padding-bottom:260px}
.site-footer .block-column{border:0;border-radius:0;padding:0}
.site-header .block-text,
.site-footer .block-text{margin:0}
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
.legal-bar a{color:var(--accent);text-decoration:none}
.legal-bar a:hover{text-decoration:underline}
/* An image takes the rounding of its figure. Without this a radius on an image block rounded the
figure and left the picture square - which looked like the setting did nothing. */
.block-image img, .block-gallery img, .block-logos img { border-radius: inherit }
@@ -175,6 +254,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}
@@ -197,7 +279,36 @@ body.consent-open{padding-bottom:260px}
.slider-snap>.slider-slide{scroll-snap-align:start}
.slider-slide{min-width:0}
.block-slider:focus-visible{outline:3px solid var(--accent);outline-offset:6px}/* Mit Punkten braucht es die Rollleiste nicht mehr - sie stand quer unter dem Bild. */
.slider-dots{scroll-marker-group:after;scroll-behavior:smooth;position:relative;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots::-webkit-scrollbar{display:none}.slider-dots>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,currentColor 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--accent)}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}
.slider-dots{position:relative}
@supports selector(::scroll-marker){.slider-dots:not(.slider-controlled){scroll-marker-group:after;scroll-behavior:smooth;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots:not(.slider-controlled)::-webkit-scrollbar{display:none}.slider-dots:not(.slider-controlled)>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,var(--slider-dot,currentColor) 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--slider-dot,var(--accent))}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}}
.slider-controlled{scrollbar-width:none;scrollbar-gutter:auto}.slider-controlled::-webkit-scrollbar{display:none}
/* Die Steuerung liegt auf dem Bild, wie beim Hero-Slider von kant-it.de: runde Pfeile links und
rechts, Punkte unten rechts (der aktive als Strich in der Akzentfarbe), Pause klein unten links,
kein Zähler. Farben kommen aus der Gestaltung des Sliders (--slider-*). */
.slider-shell{position:relative}
.slider-controls{position:absolute;inset:0;margin:0;padding:0;pointer-events:none;font:14px/1 system-ui,sans-serif}
.slider-controls button{pointer-events:auto;cursor:pointer;font:inherit;padding:0;border:0;background:transparent;color:inherit}
.slider-controls button:focus-visible{outline:3px solid var(--slider-dot-active,var(--accent));outline-offset:3px}
.slider-controls .slider-prev,.slider-controls .slider-next{position:absolute;top:50%;transform:translateY(-50%);width:50px;height:50px;border-radius:50%;background:var(--slider-bg,rgba(255,255,255,.9));color:var(--slider-ink,var(--accent));display:flex;align-items:center;justify-content:center;font-size:0;box-shadow:0 6px 18px rgba(0,0,0,.18);transition:background-color .2s,color .2s,transform .2s}
.slider-prev{left:24px}.slider-next{right:24px}
.slider-prev::before,.slider-next::before{content:'';width:12px;height:12px;border-left:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);margin-left:5px}
.slider-next::before{transform:rotate(-135deg);margin-left:-5px}
.slider-controls .slider-prev:hover,.slider-controls .slider-next:hover{background:var(--slider-dot-active,var(--accent));color:#fff}
.slider-prev:disabled,.slider-next:disabled{opacity:.4;cursor:default}
.slider-position{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
.slider-control-dots{position:absolute;right:70px;bottom:20px;display:flex;gap:3px}
.slider-control-dots button{display:flex;align-items:center;justify-content:center;width:24px;height:24px;font-size:0;transition:width .25s}
.slider-control-dots button::before{content:'';width:9px;height:9px;border-radius:99px;background:var(--slider-dot,rgba(255,255,255,.55));transition:width .25s,background-color .25s}
.slider-control-dots button[aria-pressed=true]{width:40px}
.slider-control-dots button[aria-pressed=true]::before{width:28px;background:var(--slider-dot-active,var(--accent))}
.slider-control-dots button:hover::before{background:var(--slider-dot-active,var(--accent))}
.slider-controls .slider-play{position:absolute;right:24px;bottom:16px;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.35);color:#fff;font-size:0;display:flex;align-items:center;justify-content:center}
.slider-play::before{content:'';width:0;height:0;border-left:9px solid currentColor;border-top:6px solid transparent;border-bottom:6px solid transparent;margin-left:2px}
.slider-play[data-playing=true]::before{width:8px;height:11px;border:0;border-left:3px solid currentColor;border-right:3px solid currentColor;margin-left:0}
.slider-play:disabled{opacity:.4;cursor:default}
.slider-controls[hidden]{display:none}
@media(max-width:767px){.slider-controls .slider-prev,.slider-controls .slider-next{display:none}.slider-control-dots{right:54px;bottom:14px}.slider-controls .slider-play{right:14px;bottom:12px}}
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
.slide-desktop-5{grid-auto-columns:calc(20% - 16px)}.slide-desktop-6{grid-auto-columns:calc(16.666% - 17px)}
@@ -206,7 +317,7 @@ body.consent-open{padding-bottom:260px}
.slide-tablet-3{grid-auto-columns:calc(33.333% - 14px)}.slide-tablet-4{grid-auto-columns:calc(25% - 15px)}
.slide-tablet-5{grid-auto-columns:calc(20% - 16px)}.slide-tablet-6{grid-auto-columns:calc(16.666% - 17px)}
}
@media (max-width:650px){
@media (max-width:767px){
.slide-mobile-1{grid-auto-columns:100%}.slide-mobile-2{grid-auto-columns:calc(50% - 10px)}
.slide-mobile-3{grid-auto-columns:calc(33.333% - 14px)}.slide-mobile-4{grid-auto-columns:calc(25% - 15px)}
.slide-mobile-5{grid-auto-columns:calc(20% - 16px)}.slide-mobile-6{grid-auto-columns:calc(16.666% - 17px)}
@@ -316,9 +427,11 @@ body.consent-open{padding-bottom:260px}
/* Section widths. "wide" only widens the content, "full" removes the container entirely: that is
what a picture or a colour field from edge to edge needs. A full width section drops its own
padding as well, otherwise "edge to edge" would still have 32 pixels of air on each side. */
.width-wide>.section-inner{width:min(1600px,calc(100% - 64px))}
.width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
@media (max-width:650px){.width-wide>.section-inner{width:calc(100% - 36px)}}
/* Mit ".kant" davor, weil das Design ".kant .shell{max-width:var(--k-container)}" schreibt: ohne
den Zusatz gewann diese Regel und ein breiter oder voller Abschnitt blieb so schmal wie alle. */
.kant .width-wide>.section-inner{width:min(1600px,calc(100% - 64px));max-width:none}
.kant .width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
@media (max-width:767px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}}
/* A section that is a flex container centres its content box; the inner container must then be
allowed to grow, or a full height section would show a strip in the middle. */
@@ -333,6 +446,38 @@ body.consent-open{padding-bottom:260px}
.menu-burger[open]>summary{border-radius:8px 8px 0 0}
.menu-burger-body{padding:14px 0 4px}
.menu-burger-body .menu-list{flex-direction:column;align-items:flex-start;gap:8px}
/* Only one copy participates in layout, focus order and the accessibility tree. */
.menu-collapse-tablet>.menu-burger,.menu-collapse-mobile>.menu-burger{display:none}
@media (max-width:1024px),(hover:none) and (pointer:coarse){
.menu-collapse-tablet>.menu-burger{display:block}
.menu-collapse-tablet>.menu-list{display:none}
}
@media (max-width:767px){
.menu-collapse-mobile>.menu-burger{display:block}
.menu-collapse-mobile>.menu-list{display:none}
}
.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}
/* Gibt der Kopfbereich seine Flaeche selbst vor, malt der klebende Kopf keine zweite dahinter:
sonst zeigte eine durchsichtige Sektion Weiss statt Seite, und die Schattenlinie liesse sich
weder faerben noch abschalten. Linie und Schatten kommen dann aus Rahmen und Wirkung. */
.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}
.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}
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
@@ -343,14 +488,26 @@ body.consent-open{padding-bottom:260px}
.scroll-top:hover{filter:brightness(1.08)}
.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}}
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
body.consent-open .scroll-top{display:none}
@supports (animation-timeline: scroll()){
@media (prefers-reduced-motion: no-preference){
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
}
/* Solange die Einwilligungsfrage offen ist, bleibt der Knopf da. Frueher verschwand er ganz -
und auf dem Telefon bleibt die Frage oft einfach stehen, der Knopf kam dort also nie.
Bis 799 px deckt die Leiste die Ecke ab, in der er steht: dort setzt er sich per
Ankerpositionierung auf ihre Oberkante, wie hoch ihr Text auch umbricht. Ab 800 px steht die
Leiste zentriert mit Rand, die Ecke bleibt frei. Ein Browser ohne Ankerpositionierung blendet
ihn in der schmalen Stufe weiter aus - lieber kein Knopf als einer unter der Leiste. Der Anker
muss im Dokument vor dem Knopf stehen; page.html.twig gibt die Leiste deshalb zuerst aus. */
.consent-bar{anchor-name:--kant-consent}
@media (max-width:799px){
@supports (anchor-name:--kant-consent){body.consent-open .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
@supports not (anchor-name:--kant-consent){body.consent-open .scroll-top{display:none}}
}
/* Leiste und Ecke decken die Ecke auf jeder Breite ab - dort gilt dasselbe ohne Stufe. */
@supports (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
@supports not (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{display: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. */
@@ -374,3 +531,23 @@ body.consent-open .scroll-top{display:none}
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
.zoom-prev{left:8px}.zoom-next{right:8px}
}
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}
/* Soziale Profile: eine Symbolreihe, Farbe und Abstand vom Block, Größe als Klasse. */
.block-social{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:0;padding:0;list-style:none}.block-social.social-vertical{flex-direction:column;align-items:flex-start}.block-social a{display:inline-flex;align-items:center;gap:10px;color:inherit;text-decoration:none;line-height:1;transition:transform .2s,background-color .2s,opacity .2s}.block-social a:hover{transform:translateY(-2px)}.block-social svg{display:block;flex:none}.block-social.social-labels a{font-size:.875em;font-weight:600}.social-shape-circle a,.social-shape-square a{padding:.5em;background:color-mix(in srgb,currentColor 10%,transparent)}.social-shape-circle a{border-radius:999px}.social-shape-square a{border-radius:.35em}.social-shape-circle a:hover,.social-shape-square a:hover{background:color-mix(in srgb,currentColor 18%,transparent)}.social-labels.social-shape-circle a,.social-labels.social-shape-square a{padding-inline:.8em}.social-size-12 svg{width:12px;height:12px}.social-size-16 svg{width:16px;height:16px}.social-size-20 svg{width:20px;height:20px}.social-size-24 svg{width:24px;height:24px}.social-size-32 svg{width:32px;height:32px}.social-size-40 svg{width:40px;height:40px}.social-size-48 svg{width:48px;height:48px}.social-size-56 svg{width:56px;height:56px}.social-size-64 svg{width:64px;height:64px}.social-size-80 svg{width:80px;height:80px}.social-size-96 svg{width:96px;height:96px}.social-size-120 svg{width:120px;height:120px}.social-size-160 svg{width:160px;height:160px}.social-size-200 svg{width:200px;height:200px}.social-size-240 svg{width:240px;height:240px}
@media(prefers-reduced-motion:reduce){html.motion-respect .block-social a{transition:none}html.motion-respect .block-social a:hover{transform:none}}
/* Druck: eine Seite, die man mitnimmt. Kein Kopf mit Menü, keine Einwilligung, kein Nach-oben,
keine Formularknöpfe; Verweise zeigen ihre Adresse, Bilder bleiben auf der Seite. */
@media print{
.kant{background:#fff;color:#000}
.site-header nav,.menu-toggle,.skip-link,.scroll-top,.consent-bar,.consent-banner,.block-form .cms-button,.form-step-nav,.form-progress,.post-pager,.lightbox,.slider-controls,.slider-dots,.tabs-nav,.cf-turnstile{display:none!important}
.site-header,.site-footer{border:0}
.site-header.is-sticky,.site-header.is-overlay,.site-header.is-overlay.is-sticky{position:static;background:transparent;box-shadow:none}
a[href^="http"]::after{content:" (" attr(href) ")";font-size:.85em;color:#444}
.block-section{padding:16px 0;break-inside:avoid}
img{max-width:100%;break-inside:avoid}
.tone-ink,.tone-mint{background:transparent;color:#000}
[data-step][hidden],.block-form [hidden]{display:block!important}
}
+109 -64
View File
@@ -1,67 +1,112 @@
/**
* Lets a slider advance by itself - and stop as soon as somebody takes over.
*
* Without this script a slider is a scroll container with snap points and, where the browser can
* draw them, dots: complete, swipeable and keyboard operable. The script only adds the automatic
* step, and it is loaded exclusively on pages that carry a slider which asked for it.
*
* What it refuses to do:
* - it does not run when the system asks for reduced motion,
* - it stops for good at the first touch, click, key press or manual scroll,
* - it pauses while the pointer is inside, while something in it has focus, and while the tab is
* in the background or the slider is off screen.
*
* A carousel that keeps moving while somebody reads it is a nuisance, and one that cannot be
* stopped is a barrier.
*/
(() => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const setup = slider => {
const seconds = Number(slider.dataset.autoplay);
if (!Number.isFinite(seconds) || seconds < 3) return;
let paused = false;
let stopped = false;
let visible = true;
let ownScroll = 0;
const slides = [...slider.children];
if (slides.length < 2) return;
const step = () => {
if (stopped || paused || !visible || document.hidden) return;
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
// Measured, not computed from offsetLeft: whether a slide's offset parent is the slider or
// something further up decides the number, and getting that wrong means scrolling nowhere.
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
const next = last ? slides[0] : slides.find(slide => left(slide) > slider.scrollLeft + 2);
if (!next) return;
// Remember that the next scroll event is ours, so it does not look like a person.
ownScroll = Date.now();
slider.scrollTo({ left: last ? 0 : left(next), behavior: 'smooth' });
/** Scroll snapping is the baseline; arrows, dots and controllable playback enhance it. */
export function initSliders(doc = document) {
const view = doc.defaultView;
const cleanups = [];
doc.querySelectorAll('.block-slider').forEach(slider => {
const slides = [...slider.querySelectorAll(':scope > .slider-slide')];
if (slides.length < 2 || slider.dataset.enhanced) return;
slider.dataset.enhanced = 'true';
const events = new view.AbortController(), signal = events.signal;
const preference = view.matchMedia('(prefers-reduced-motion: reduce)');
// The browser preference counts only when the website says so (Einstellungen → Bewegung).
const reduced = () => doc.documentElement.classList.contains('motion-respect') && preference.matches;
const seconds = Number(slider.dataset.autoplay || 0);
let playing = seconds >= 3 && !reduced(), hover = false, visible = false, timer;
const controls = doc.createElement('div'); controls.className = 'slider-controls';
for (const name of slider.classList) if (name.startsWith('visibility-')) controls.classList.add(name);
controls.setAttribute('role', 'group'); controls.setAttribute('aria-label', 'Slider bedienen');
const button = (text, label, action) => {
const node = doc.createElement('button'); node.type = 'button'; node.textContent = text;
node.setAttribute('aria-label', label); node.setAttribute('aria-controls', slider.id);
node.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); action(); }, { signal });
controls.append(node); return node;
};
const stop = () => { stopped = true; clearInterval(timer); };
const timer = setInterval(step, seconds * 1000);
slider.addEventListener('pointerenter', () => { paused = true; });
slider.addEventListener('pointerleave', () => { paused = false; });
slider.addEventListener('focusin', () => { paused = true; });
slider.addEventListener('focusout', () => { paused = false; });
for (const event of ['pointerdown', 'keydown', 'touchstart', 'wheel']) {
slider.addEventListener(event, stop, { passive: true });
const left = slide => slide.getBoundingClientRect().left - slider.getBoundingClientRect().left + slider.scrollLeft;
const available = () => slides.map((slide, i) => slide.getClientRects().length ? i : -1).filter(i => i >= 0);
const active = () => {
let index = 0, distance = Infinity;
available().forEach(i => { const delta = Math.abs(left(slides[i]) - slider.scrollLeft); if (delta < distance) { distance = delta; index = i; } });
return index;
};
let settle = null;
const go = index => {
if (!available().includes(index)) return;
const target = Math.max(0, Math.min(slider.scrollWidth - slider.clientWidth, left(slides[index])));
if (reduced()) { slider.scrollTo({ left: target, behavior: 'instant' }); return; }
// Safari cancels a smooth scroll on a snapping container and snaps back to the slide it
// came from - the slider stutters instead of moving on. Snapping is switched off for the
// ride and put back once the movement has settled, on the exact start of a slide, so
// restoring it moves nothing.
clearInterval(settle);
const snap = slider.style.scrollSnapType;
slider.style.scrollSnapType = 'none';
slider.scrollTo({ left: target, behavior: 'smooth' });
// Restored on arrival, or after two seconds if the ride was interrupted - a background tab
// pauses smooth scrolling, and snapping must not come back halfway.
let ticks = 0;
settle = setInterval(() => {
if (Math.abs(slider.scrollLeft - target) < 1 || ++ticks > 25) { clearInterval(settle); settle = null; slider.style.scrollSnapType = snap; }
}, 80);
};
const step = direction => {
const visible = available(), position = visible.indexOf(active());
if (!visible.length) return;
const last = slider.scrollLeft + slider.clientWidth >= slider.scrollWidth - 2;
const first = slider.scrollLeft <= 2;
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
};
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
const dots = [];
if (slider.classList.contains('slider-dots')) {
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
slides.forEach((slide, i) => {
const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
});
}
slider.addEventListener('scroll', () => {
// A scroll that is not the one we just started belongs to a person; then we are done.
if (Date.now() - ownScroll > 1200) stop();
}, { passive: true });
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
if (play) play.className = 'slider-play';
const update = () => {
const visible = available();
const fits = visible.length < 2 || slider.scrollWidth <= slider.clientWidth + 2;
controls.hidden = fits; previous.disabled = next.disabled = fits;
const index = active(); count.textContent = `${Math.max(0, visible.indexOf(index) + 1)} / ${visible.length}`;
dots.forEach((dot, i) => { dot.hidden = !visible.includes(i); dot.setAttribute('aria-pressed', String(i === index)); });
if (play) {
play.textContent = playing ? 'Pause' : 'Wiedergabe'; play.dataset.playing = String(playing);
// Der zugängliche Name beginnt mit dem sichtbaren Text (WCAG 2.5.3): wer „Pause“ liest, kann „Pause“ sagen.
play.setAttribute('aria-label', playing ? 'Pause: automatische Wiedergabe anhalten' : 'Wiedergabe: automatisch weiterblättern');
play.disabled = reduced(); play.title = reduced() ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
}
};
const schedule = () => {
clearTimeout(timer);
if (playing && !reduced()) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
};
const pause = () => { playing = false; update(); clearTimeout(timer); };
// Slider and controls share a positioned shell, so arrows and dots can lie on the picture
// the way the Kant-IT hero slider draws them.
const shell = doc.createElement('div'); shell.className = 'slider-shell';
slider.replaceWith(shell); shell.append(slider, controls);
slider.classList.add('slider-controlled'); update();
slider.addEventListener('scroll', update, { passive: true, signal });
view.addEventListener('resize', update, { passive: true, signal });
slider.addEventListener('pointerenter', () => { hover = true; }, { signal });
slider.addEventListener('pointerleave', () => { hover = false; schedule(); }, { signal });
for (const event of ['pointerdown', 'wheel', 'touchstart', 'focusin']) slider.addEventListener(event, pause, { passive: true, signal });
slider.addEventListener('keydown', event => {
if (event.target !== slider || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault(); manual(() => step(event.key === 'ArrowRight' ? 1 : -1));
}, { signal });
const observer = new view.IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0 }); observer.observe(slider);
const resize = new view.ResizeObserver(update); resize.observe(slider);
preference.addEventListener('change', () => { if (reduced()) pause(); update(); }, { signal });
schedule();
cleanups.push(() => { clearTimeout(timer); clearInterval(settle); slider.style.scrollSnapType = ''; events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); shell.replaceWith(slider); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
});
return () => cleanups.forEach(cleanup => cleanup());
}
if ('IntersectionObserver' in window) {
new IntersectionObserver(entries => { visible = entries[0].isIntersecting; }, { threshold: 0.35 })
.observe(slider);
}
};
document.querySelectorAll('.block-slider[data-autoplay]').forEach(setup);
})();
if (!document.querySelector('#editor')) initSliders();
+11 -3
View File
@@ -25,14 +25,19 @@
panels[index].querySelector('.tabs-heading')?.classList.add('visually-hidden');
});
const available = () => links.map((link, i) => getComputedStyle(link.parentElement).display !== 'none' ? i : -1).filter(i => i >= 0);
let selected = -1;
const select = (index, moveFocus = true) => {
const visible = available();
if (!visible.includes(index)) index = visible[0] ?? -1;
selected = index;
links.forEach((link, i) => {
const current = i === index;
link.setAttribute('aria-selected', String(current));
link.tabIndex = current ? 0 : -1;
panels[i].hidden = !current;
});
if (moveFocus) links[index].focus();
if (moveFocus && index >= 0) links[index].focus();
};
list.addEventListener('click', event => {
@@ -46,15 +51,18 @@
list.addEventListener('keydown', event => {
const index = links.indexOf(event.target.closest('a'));
if (index === -1) return;
const moves = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: links.length - 1 };
const visible = available(), position = visible.indexOf(index);
if (!visible.length) return;
const moves = { ArrowRight: position + 1, ArrowLeft: position - 1, Home: 0, End: visible.length - 1 };
if (!(event.key in moves)) return;
event.preventDefault();
select((moves[event.key] + links.length) % links.length);
select(visible[(moves[event.key] + visible.length) % visible.length]);
});
// A link from elsewhere may point straight at one panel.
const deepLink = panels.findIndex(panel => panel.id === location.hash.slice(1));
select(deepLink === -1 ? 0 : deepLink, false);
window.addEventListener('resize', () => select(selected, false));
group.classList.add('tabs-ready');
};
+45
View File
@@ -0,0 +1,45 @@
/*
* Der Takt des geöffneten Backends.
*
* Auf einer Website ohne Besucher gibt es keinen Auslöser für wiederkehrende Aufgaben - außer
* dem Menschen, der gerade in der Verwaltung arbeitet. Dieses Skript klopft in seinem Namen alle
* 60 Sekunden an; der Server entscheidet, ob überhaupt etwas fällig ist.
*
* Drei Eigenschaften, die es unauffällig machen:
*
* - Es fragt nur, während der Reiter sichtbar ist. Ein vergessener Hintergrundreiter soll keine
* Sitzung endlos offen halten und keine Anfragen erzeugen, die niemand sehen will.
* - Es fragt nie zwei Mal gleichzeitig: eine laufende Anfrage blockiert die nächste.
* - Ein Fehlschlag ist kein Ereignis. Kein Hinweis, keine Meldung - beim nächsten Takt wieder.
*/
(function () {
var endpoint = document.querySelector('meta[name="kant-tick"]');
var token = document.querySelector('meta[name="kant-tick-token"]');
if (!endpoint || !token || !window.fetch) {
return;
}
var busy = false;
function tick() {
if (busy || document.visibilityState !== 'visible') {
return;
}
busy = true;
var body = new FormData();
body.append('_csrf_token', token.content);
fetch(endpoint.content, { method: 'POST', body: body, credentials: 'same-origin' })
.catch(function () {})
.then(function () {
busy = false;
});
}
// Der erste Takt kommt verzögert: beim Seitenaufbau hat der Browser Wichtigeres zu tun.
window.setTimeout(tick, 5000);
window.setInterval(tick, 60000);
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') {
tick();
}
});
})();
+39
View File
@@ -0,0 +1,39 @@
import { buttonSample } from './button-design.js';
import { createColorControl } from './color-control.js';
const root = document.querySelector('#button-defaults');
if (root) {
const schema = JSON.parse(root.dataset.schema), form = root.closest('form');
const read = () => Object.fromEntries([...root.querySelectorAll('[name^="buttons["]')].map(input => [input.name.slice(8, -1), input.value]));
const update = () => {
// Unsaved palette edits are reflected immediately in all samples.
schema.colors = [...form.querySelectorAll('[data-color-card]')].flatMap(row => {
const key = row.querySelector('[name$="[key]"]'), value = row.querySelector('[name$="[value]"]');
return key?.value && value ? [{ key: key.value, value: value.value }] : [];
});
const values = read(), previews = root.querySelector('.button-state-previews'); previews.replaceChildren();
for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) {
const state = document.createElement('div'); state.className = 'button-state';
const title = document.createElement('span'); title.className = 'field-help'; title.textContent = label;
state.append(title, buttonSample('Mehr erfahren', values, schema, hover)); previews.append(state);
}
root.querySelectorAll('[data-button-preset]').forEach((choice, index) => {
const preset = schema.buttonPresets[index]; choice.setAttribute('aria-pressed', String(preset.shape === values.shape && preset.appearance === values.appearance));
choice.querySelector('.cms-button')?.remove();
const sample = buttonSample('Button', { ...values, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true'); choice.prepend(sample);
});
};
for (const input of root.querySelectorAll('[data-button-color]')) {
const label = root.querySelector(`label[for="${input.id}"]`).textContent;
const originalId = input.id; input.type = 'hidden'; input.id += '-value';
const control = createColorControl({ id: originalId, label, value: input.value, colors: schema.colors, onChange: value => { input.value = value; update(); } });
control.querySelector('.color-inherit').hidden = true;
input.after(control);
}
root.querySelectorAll('[data-button-preset]').forEach((choice, index) => choice.addEventListener('click', () => {
const preset = schema.buttonPresets[index];
for (const key of ['shape', 'appearance']) root.querySelector(`[name="buttons[${key}]"]`).value = preset[key];
update();
}));
form.addEventListener('input', update); form.addEventListener('change', update); update();
}
+171
View File
@@ -0,0 +1,171 @@
/*
* Die Designseite: Palette, Schriften, Grundtypografie.
*
* Ohne Skript sind es Karten mit Eingaben; eine Farbe ohne Schlüssel und eine Schrift ohne
* Schlüssel werden nicht gespeichert, so lassen sie sich auch dann entfernen. Das Skript macht
* daraus die eigentliche Werkstatt: Karten hinzufügen und entfernen, fertige Paletten übernehmen,
* die Grundfarben aus der Palette wählen statt tippen, jede Schrift in sich selbst zeigen und die
* Grundtypografie als lesbare Probe.
*/
import { createColorControl } from './color-control.js';
const form = document.querySelector('[data-theme-editor]');
if (form) {
const palettes = JSON.parse(form.dataset.palettes), colorNames = JSON.parse(form.dataset.colorNames);
const colorList = form.querySelector('[data-color-list]'), colorTemplate = form.querySelector('[data-color-template]');
const fontList = form.querySelector('[data-font-list]'), fontTemplate = form.querySelector('[data-font-template]');
const colorCards = () => [...colorList.querySelectorAll('[data-color-card]')];
const fontCards = () => [...fontList.querySelectorAll('[data-font-card]')];
let nextColor = colorCards().length, nextFont = fontCards().length;
const value = (card, selector) => card.querySelector(selector).value.trim();
const palette = () => colorCards().flatMap(card => {
const key = value(card, '[data-color-key]').toLowerCase();
return key ? [{ key, name: value(card, '[data-color-name]') || key, value: card.querySelector('[data-color-value]').value }] : [];
});
const fonts = () => fontCards().flatMap(card => {
const key = value(card, '[data-font-key]').toLowerCase();
return key ? [{ key, name: value(card, '[data-font-name]') || key, family: value(card, '[data-font-family]'), fallback: card.querySelector('[data-font-fallback]').value }] : [];
});
const stack = font => font.family ? `"${font.family}", ${font.fallback}` : font.fallback;
const hex = raw => /^#[0-9a-f]{6}/i.test(raw || '') ? raw.slice(0, 7) : palette().find(color => `@${color.key}` === raw)?.value || '';
/* ---- Palette ---------------------------------------------------------------------- */
const refreshColors = () => {
colorCards().forEach((card, index) => {
const name = value(card, '[data-color-name]') || value(card, '[data-color-key]') || `Farbe ${index + 1}`;
card.querySelector('[data-color-hex]').textContent = card.querySelector('[data-color-value]').value;
card.querySelector('[data-color-value]').setAttribute('aria-label', `Farbwert von ${name}`);
const remove = card.querySelector('[data-remove-color]');
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
});
form.querySelector('[data-color-count]').textContent = String(palette().length);
};
const addColor = (values = {}) => {
colorList.insertAdjacentHTML('beforeend', colorTemplate.innerHTML.replaceAll('__n__', String(nextColor++)));
const card = colorList.lastElementChild; card.classList.remove('swatch-card-new');
for (const [selector, field] of [['[data-color-key]', 'key'], ['[data-color-name]', 'name'], ['[data-color-value]', 'value']]) {
if (values[field] !== undefined) card.querySelector(selector).value = values[field];
}
return card;
};
// Die leeren Karten braucht es nur ohne Skript: hier kommt jede Farbe über den Knopf.
const emptyColors = colorCards().filter(card => card.classList.contains('swatch-card-new'));
if (colorCards().length > emptyColors.length) emptyColors.forEach(card => card.remove());
const addColorButton = form.querySelector('[data-add-color]'); addColorButton.hidden = false;
addColorButton.addEventListener('click', () => {
const card = addColor(); refreshColors(); mountBaseColors(); card.querySelector('[data-color-name]').focus();
});
colorList.addEventListener('click', event => {
const button = event.target.closest('[data-remove-color]');
if (!button) return;
button.closest('[data-color-card]').remove(); refreshColors(); mountBaseColors(); renderType(); addColorButton.focus();
});
colorList.addEventListener('input', () => { refreshColors(); renderType(); });
colorList.addEventListener('change', () => { mountBaseColors(); renderType(); });
const presets = form.querySelector('[data-palette-presets]'), status = form.querySelector('[data-palette-status]');
presets.hidden = false;
presets.addEventListener('click', event => {
const button = event.target.closest('[data-palette-preset]');
if (!button) return;
const preset = palettes[Number(button.dataset.palettePreset)];
// Bekannte Schlüssel bekommen den neuen Wert, fehlende kommen dazu, eigene Farben bleiben.
for (const [key, color] of Object.entries(preset.colors)) {
const card = colorCards().find(candidate => value(candidate, '[data-color-key]').toLowerCase() === key);
if (card) card.querySelector('[data-color-value]').value = color;
else addColor({ key, name: colorNames[key] || key, value: color });
}
refreshColors(); mountBaseColors(); renderType();
status.textContent = `Palette „${preset.name}“ übernommen – sie gilt, sobald das Design gespeichert ist.`;
});
/* ---- Schriften -------------------------------------------------------------------- */
const applySample = card => {
const family = value(card, '[data-font-family]'), fallback = card.querySelector('[data-font-fallback]').value;
card.querySelector('[data-font-sample]').style.fontFamily = stack({ family, fallback });
const chosen = new Set([...card.querySelectorAll('[data-font-weight]')].filter(input => input.checked).map(input => input.value));
for (const weight of card.querySelectorAll('[data-font-weights] > span')) weight.hidden = !chosen.has(weight.className.replace('font-weight-', ''));
};
const syncFontSelects = () => {
const list = fonts();
for (const select of form.querySelectorAll('[data-font-select]')) {
const current = select.value, keep = select.querySelector('option[value="inherit"]');
select.replaceChildren(...(keep ? [keep] : []), ...list.map(font => new Option(font.name, font.key)));
select.value = list.some(font => font.key === current) || (keep && current === 'inherit') ? current : (keep ? 'inherit' : list[0]?.key ?? '');
}
};
const refreshFonts = () => {
fontCards().forEach((card, index) => {
applySample(card);
const name = value(card, '[data-font-name]') || value(card, '[data-font-key]') || `Schrift ${index + 1}`;
const remove = card.querySelector('[data-remove-font]');
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
});
form.querySelector('[data-font-count]').textContent = String(fonts().length);
syncFontSelects();
};
const emptyFonts = fontCards().filter(card => card.classList.contains('font-card-new'));
if (fontCards().length > emptyFonts.length) emptyFonts.forEach(card => card.remove());
const addFontButton = form.querySelector('[data-add-font]'); addFontButton.hidden = false;
addFontButton.addEventListener('click', () => {
fontList.insertAdjacentHTML('beforeend', fontTemplate.innerHTML.replaceAll('__n__', String(nextFont++)));
const card = fontList.lastElementChild; card.classList.remove('font-card-new');
refreshFonts(); card.querySelector('[data-font-name]').focus();
});
fontList.addEventListener('click', event => {
const button = event.target.closest('[data-remove-font]');
if (!button) return;
button.closest('[data-font-card]').remove(); refreshFonts(); renderType(); addFontButton.focus();
});
fontList.addEventListener('input', () => { refreshFonts(); renderType(); });
fontList.addEventListener('change', () => { refreshFonts(); renderType(); });
/* ---- Grundtypografie -------------------------------------------------------------- */
const preview = form.querySelector('[data-type-preview]');
const baseInputs = {};
const lengthPattern = /^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/;
function renderType() {
const list = fonts();
const family = key => { const font = list.find(entry => entry.key === key); return font ? stack(font) : 'inherit'; };
const raw = form.querySelector('#base-size').value.trim(), size = lengthPattern.test(raw) ? raw : '18px';
const line = form.querySelector('#base-line').value || '1.6', scale = parseFloat(form.querySelector('#base-scale').value) || 1.25;
preview.style.fontFamily = family(form.querySelector('#base-font').value);
preview.style.fontSize = size; preview.style.lineHeight = line;
const heading = family(form.querySelector('#base-heading-font').value);
for (const [selector, step] of [['[data-type-h1]', 4], ['[data-type-h2]', 3]]) {
const node = preview.querySelector(selector);
node.style.fontFamily = heading; node.style.fontSize = `calc(${size} * ${(scale ** step).toFixed(4)})`;
}
preview.style.color = hex(baseInputs.color?.value) || '#203b34';
preview.style.background = hex(baseInputs.backgroundColor?.value) || '#ffffff';
const link = preview.querySelector('[data-type-link]');
link.style.color = hex(baseInputs.linkColor?.value) || 'inherit';
link.style.textDecoration = form.querySelector('[data-base-underline]').checked ? 'underline' : 'none';
}
// Die drei Grundfarben als Palettenwahl. Die Palette kann sich unter ihnen ändern, deshalb
// werden die Bedienelemente bei jeder Änderung der Palette neu aufgebaut; der Wert bleibt.
function mountBaseColors() {
const colors = palette();
for (const input of form.querySelectorAll('[data-base-color]')) {
const key = input.name.slice(5, -1);
if (!input.dataset.controlId) {
input.dataset.controlId = input.id; input.type = 'hidden'; input.id += '-value'; baseInputs[key] = input;
}
const id = input.dataset.controlId, label = form.querySelector(`label[for="${id}"]`).textContent;
input.nextElementSibling?.classList.contains('color-control') && input.nextElementSibling.remove();
const control = createColorControl({
id, label, value: input.value, colors,
onChange: chosen => { input.value = chosen; renderType(); }, onComplete: () => {},
});
control.querySelector('.color-inherit').hidden = true;
input.after(control);
}
}
refreshColors(); refreshFonts(); mountBaseColors(); renderType();
form.addEventListener('input', renderType); form.addEventListener('change', renderType);
}
+136
View File
@@ -0,0 +1,136 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/accordion'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
data.document.blocks.unshift({ id: 'qa-accordion', type: 'accordion', children: [
{ id: 'qa-entry', type: 'accordionitem', props: { title: 'Was ist enthalten?', open: false }, children: [] },
{ id: 'qa-open', type: 'accordionitem', props: { title: 'Schon geöffnet', open: true }, children: [{ id: 'qa-existing', type: 'text', props: { text: '<p>Vorhandener Inhalt bleibt.</p>' } }] },
] }, { id: 'qa-empty-accordion', type: 'accordion', children: [] });
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
const frame = page.frameLocator('#preview');
const ready = async () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
const select = async id => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator('[data-panel="content"]').click(); };
await ready();
await frame.locator('#qa-entry > summary').click();
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
assert.equal(await page.locator('#save').isDisabled(), true, 'Revealing the editor body must not change the document');
await frame.locator('#qa-entry [data-editor-insert]').focus();
await frame.locator('#qa-entry [data-editor-insert]').press('Enter');
assert.match(await page.locator('#insert-hint').innerText(), /Was ist enthalten/);
await page.locator('#library [data-add="text"]').click();
await ready(); await frame.locator('#qa-entry .block-text').waitFor();
const textId = await frame.locator('#qa-entry .block-text').getAttribute('id');
await page.getByLabel('Text', { exact: true }).fill('Text direkt im Akkordeon.'); await ready();
assert.equal(await frame.locator('#qa-entry .block-text').innerText(), 'Text direkt im Akkordeon.');
await page.locator('#undo').click(); await page.locator('#undo').click(); await ready();
assert.equal(await frame.locator(`#${textId}`).count(), 0);
await page.locator('#redo').click(); await page.locator('#redo').click(); await ready();
assert.equal(await frame.locator(`#qa-entry #${textId}`).count(), 1);
checks.push('closed entries reveal editable bodies and empty insert areas without changing public state; text insertion, editing and undo/redo');
await select('qa-accordion');
assert.equal(await page.locator('#library [data-add="text"]').isDisabled(), true, 'Group selection must not put text outside the accordion');
await page.getByRole('button', { name: 'Eintrag 1: Was ist enthalten? bearbeiten', exact: true }).click();
await page.getByRole('button', { name: '+ Inhalt hinzufügen', exact: true }).click();
await page.locator('#library [data-add="button"]').click(); await ready();
const firstButtonId = await frame.locator('#qa-entry a.button').getAttribute('id');
assert.equal(await frame.locator('#qa-entry .accordion-body > [id]').last().getAttribute('id'), firstButtonId, 'Add content appends after existing content');
await select('qa-open');
await page.locator('#library [data-add="button"]').dragTo(frame.locator('#qa-open .accordion-body'), { targetPosition: { x: 40, y: 20 } });
await ready(); await frame.locator('#qa-open a.button').waitFor();
const draggedId = await frame.locator('#qa-open a.button').getAttribute('id');
await page.locator('#undo').click(); await ready(); assert.equal(await frame.locator(`#${draggedId}`).count(), 0);
await page.locator('#redo').click(); await ready(); assert.equal(await frame.locator(`#qa-open #${draggedId}`).count(), 1);
checks.push('entry selection in inspector, append content and drag/drop into an open body with undo/redo');
await frame.locator('#qa-accordion > .editor-add-content [data-editor-add-entry]').click();
await page.getByLabel('Titel', { exact: true }).fill('Ein neuer Eintrag'); await ready();
const newEntryId = await frame.locator('#qa-accordion > details').last().getAttribute('id');
assert.equal(await frame.locator('#qa-accordion > details').count(), 3);
assert.equal(await frame.locator(`#${newEntryId}`).evaluate(el => el.open), true);
await select('qa-empty-accordion');
await page.getByRole('button', { name: '+ Eintrag hinzufügen', exact: true }).click(); await ready();
assert.equal(await frame.locator('#qa-empty-accordion > details').count(), 1);
await page.getByLabel('Titel', { exact: true }).fill('Erster Eintrag'); await ready();
await page.locator('#tree-search').fill('Ein neuer Eintrag');
assert.equal(await page.locator(`#structure [data-block-id="${newEntryId}"]`).count(), 1);
await page.locator('#tree-search').fill('');
await select('qa-entry'); await page.locator('[data-panel="design"]').click();
await page.locator('#library [data-add="accordion"]').click(); await ready();
assert.equal(await page.locator('#properties-title').innerText(), 'Akkordeon-Eintrag');
assert.equal(await page.getByLabel('Titel', { exact: true }).inputValue(), 'Eine Frage');
await page.locator('#undo').click(); await ready();
checks.push('add entries in canvas and inspector, recover empty accordion, search by title, new accordion starts with its editable entry selected');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await ready(); await select('qa-entry');
assert.equal(await page.getByLabel('Beim Laden geöffnet', { exact: true }).isChecked(), false);
assert.equal(await frame.locator('#qa-entry').evaluate(el => el.open), true);
const saved = await (await context.request.get(api)).json();
const accordion = saved.document.blocks.find(b => b.id === 'qa-accordion');
assert.equal(accordion.children[0].props.open, false); assert.equal(accordion.children[1].props.open, true);
assert.deepEqual(accordion.children[0].children.map(b => b.id), [textId, firstButtonId]);
assert.ok(!JSON.stringify(saved.document).includes('editor-empty'));
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const plain = await browser.newContext({ javaScriptEnabled: false });
const visitor = await plain.newPage(); await visitor.goto(origin);
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
assert.equal(await visitor.locator('#qa-open').evaluate(el => el.open), true);
assert.equal(await visitor.locator('[data-editor-insert],[data-editor-add-entry],.editor-empty').count(), 0);
await visitor.locator('#qa-entry > summary').click();
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), true);
assert.equal(await visitor.locator(`#qa-entry #${textId}`).isVisible(), true);
await visitor.locator('#qa-entry > summary').press('Enter');
assert.equal(await visitor.locator('#qa-entry').evaluate(el => el.open), false);
await plain.close();
checks.push('save/reload/publication keep children and original open flags; published accordion toggles without JavaScript and contains no editing aids');
for (const width of [1600, 390]) {
await page.setViewportSize({ width, height: 1050 }); await select('qa-entry'); await ready();
await page.locator('[data-viewport="mobile"]').click(); await ready();
await frame.locator('#qa-entry').scrollIntoViewIfNeeded();
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze();
assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await page.screenshot({ path: `${report}/editor-${width}.png`, fullPage: true });
}
checks.push('desktop/mobile editor passes axe and layout checks with editable accordion preview');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Accordion checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+234
View File
@@ -0,0 +1,234 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8' }));
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: { ...process.env, DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory } });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
windowsHide: true, stdio: 'ignore', env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, SITE_ORIGIN: origin },
});
await mkdir('reports/admin-quality', { recursive: true });
let browser, page;
const checks = [];
try {
for (let i = 0; i < 60; i++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (i === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'no-preference' }); page = await context.newPage();
page.setDefaultTimeout(15000);
const errors = []; page.on('pageerror', error => errors.push(error.message));
page.on('dialog', dialog => dialog.accept());
if (process.argv.includes('--visual-only')) {
await page.goto(origin);
await page.evaluate(async () => {
const images = [...document.images]; images.forEach(image => { image.loading = 'eager'; });
await Promise.all(images.map(image => image.decode().catch(() => {}))); await document.fonts.ready;
});
await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true, animations: 'disabled' });
const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1440, height: 1000 } });
const noScript = await plain.newPage(); await noScript.goto(origin);
assert.equal(await noScript.locator('h1').isVisible(), true);
assert.equal(await noScript.locator('#hero-slider .slider-slide').count(), 4);
assert.ok(await noScript.locator('main').innerText().then(text => text.includes('Dienstleistungen') || text.includes('Full-Service')));
await noScript.screenshot({ path: 'reports/admin-quality/pilot-without-javascript.png', fullPage: true });
await plain.close();
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
console.log('Public no-JavaScript baseline and visual captures passed.');
} else {
if (!process.argv.includes('--admin-only')) {
await page.goto(origin); await page.mouse.move(1, 1);
const slider = page.locator('#hero-slider');
const geometry = () => slider.evaluate(el => ({ left: el.scrollLeft, width: el.clientWidth, total: el.scrollWidth, slides: el.children.length, columns: getComputedStyle(el).gridAutoColumns }));
const before = await geometry(); await page.waitForTimeout(8000); const after = await geometry();
console.log('Pilot slider:', JSON.stringify({ before, after }));
if (process.argv.includes('--probe')) {
console.log('Motion:', await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(el => ({ id: el.id, name: getComputedStyle(el).animationName, duration: getComputedStyle(el).animationDuration, timeline: getComputedStyle(el).animationTimeline }))));
} else {
assert.ok(after.left > before.left + 20, 'the real pilot slider advances');
}
await page.waitForTimeout(7100); const second = await geometry();
console.log('Second slider:', JSON.stringify(second), await slider.locator('xpath=following-sibling::*[1]').innerText());
assert.ok(second.left > after.left + 20, 'autoplay advances more than once');
const controls = slider.locator('xpath=following-sibling::*[1]');
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
await page.waitForTimeout(700); const manual = await geometry();
console.log('Manual slider:', JSON.stringify(manual), await controls.innerText());
assert.ok(manual.left < second.left - 20);
await page.mouse.move(1, 1); await page.waitForTimeout(7200);
console.log('Paused slider:', JSON.stringify(await geometry()), await controls.innerText());
assert.ok(Math.abs((await geometry()).left - manual.left) < 2, 'manual navigation pauses autoplay');
await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).click();
await page.mouse.move(1, 1); await page.waitForTimeout(7700);
assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation');
await page.emulateMedia({ reducedMotion: 'reduce' });
assert.equal(await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).isDisabled(), true);
const reduced = await geometry(); await page.waitForTimeout(7100);
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion');
await page.emulateMedia({ reducedMotion: 'no-preference' });
checks.push({ name: 'pilot slider: repeated autoplay, manual pause, resume, reduced motion', passed: true });
await page.screenshot({ path: 'reports/admin-quality/pilot-home.png', fullPage: true });
}
if (process.argv.includes('--audit')) {
execFileSync(process.execPath, ['scripts/audit.mjs'], { stdio: 'inherit', windowsHide: true, env: { ...process.env, CMS_URL: origin, CMS_AUDIT_DIR: 'reports/admin-quality/lighthouse' } });
}
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
for (const [name, path] of [['pages', '/admin'], ['posts', '/admin/posts'], ['menus', '/admin/menus'], ['forms', '/admin/forms'], ['areas', '/admin/partials']]) {
const response = await page.goto(origin + path); assert.equal(response.status(), 200, path);
await page.screenshot({ path: `reports/admin-quality/${name}.png`, fullPage: true });
const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
checks.push({ name, passed: true });
}
await page.goto(origin + '/admin/posts');
const rowLinks = () => page.locator('.rows article h3 a');
assert.equal(await rowLinks().count(), 20);
const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href));
await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click();
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 9);
assert.equal(await rowLinks().count(), 9);
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false);
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50');
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 29);
assert.equal(await rowLinks().count(), 29);
await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff');
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 0);
assert.equal(await rowLinks().count(), 0);
await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9);
await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4);
checks.push({ name: 'separate pages/posts, bounded pages, no overlap, size/search/clamping', passed: true });
await page.goto(origin + '/admin/menus');
const entries = page.locator('#menu-items > li'); const initialCount = await entries.count();
const search = page.getByRole('combobox', { name: 'Inhalte suchen' });
await search.fill('Impressum');
await page.getByRole('option').filter({ hasText: 'Impressum' }).first().waitFor();
await search.press('ArrowDown'); await search.press('Enter');
await page.getByRole('button', { name: 'Zum Menü hinzufügen', exact: true }).click();
assert.equal(await entries.count(), initialCount + 1);
await entries.last().getByRole('button', { name: 'Als Unterpunkt', exact: true }).click();
assert.equal(await entries.last().getAttribute('data-level'), '2');
await entries.last().getByRole('button', { name: 'Entfernen', exact: true }).click();
assert.equal(await entries.count(), initialCount);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
assert.equal(await entries.count(), initialCount + 1);
assert.equal(await entries.last().getAttribute('data-level'), '2');
const menuSave = () => Promise.all([
page.waitForResponse(response => response.url().includes('/menus/header') && response.request().method() === 'POST'),
page.getByRole('button', { name: 'Menü speichern', exact: true }).click(),
]).then(([response]) => response);
assert.equal((await menuSave()).status(), 200);
await page.reload(); assert.equal(await entries.count(), initialCount + 1);
assert.equal(await entries.last().getAttribute('data-level'), '2');
await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click();
await page.locator('#link-label').fill('QA Kontakt'); await page.locator('#link-url').fill('javascript:alert(1)');
await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click();
assert.equal((await menuSave()).status(), 422);
assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)');
assert.equal(await page.locator('#menu-error').isVisible(), true);
await entries.last().locator('summary').click();
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
assert.equal((await menuSave()).status(), 200);
const externalSave = await page.locator('#menu-editor').evaluate(async form => {
const response = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } });
return response.status;
});
assert.equal(externalSave, 200);
await page.getByLabel('Name des Menüs').fill('QA lokaler Stand');
assert.equal((await menuSave()).status(), 409);
assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand');
assert.equal(await page.locator('#menu-error').isVisible(), true);
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
await page.goto(origin + '/admin/forms');
const form = page.locator('.form-def').first(), message = form.locator('[name="success"]');
const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth }));
assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width');
await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
await form.getByRole('button', { name: 'Formular speichern', exact: true }).click();
assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
checks.push({ name: 'wide multiline form response persists', passed: true });
await page.goto(origin + '/admin/partials');
await page.getByText('Neuen Bereich anlegen', { exact: true }).click();
await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion');
await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.locator('#structure .tree-item').first().click();
await page.locator('#library [data-add="text"]').click();
await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.');
await page.getByLabel('Text', { exact: true }).press('Tab');
await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor();
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor();
await page.reload();
await page.frameLocator('#preview').getByText('Dieser Inhalt wird als gemeinsame Sektion gespeichert.', { exact: true }).waitFor();
await page.goto(origin + '/admin/partials');
const areaLinks = await rowLinks().evaluateAll(links => links.map(link => link.href));
for (const url of areaLinks) {
assert.equal((await page.goto(url)).status(), 200);
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.locator('#select-page').click();
const title = page.getByLabel('Seitentitel', { exact: true });
await title.fill((await title.inputValue()) + ' QA'); await title.press('Tab');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Bereich gespeichert' }).waitFor();
await page.reload(); await page.locator('#select-page').click();
await page.waitForFunction(() => document.querySelector('#save-status')?.textContent !== 'Wird geladen …');
assert.match(await title.inputValue(), / QA$/);
}
checks.push({ name: 'real pilot areas open, edit, preview, save and reload', passed: true });
await page.goto(origin + '/admin/pages/' + fixture.homeId);
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider');
// Measure in the outer page: Playwright's in-frame animation stability check can stall in
// a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry.
const nextPreview = preview.getByRole('button', { name: 'Nächste Folie', exact: true });
await nextPreview.waitFor(); await page.waitForTimeout(1500);
const previewBox = await nextPreview.boundingBox(); await page.waitForTimeout(150);
const settledBox = await nextPreview.boundingBox();
assert.ok(Math.abs(settledBox.y - previewBox.y) < 1 && settledBox.width > 0, 'preview control has stable visible geometry');
await page.mouse.click(settledBox.x + settledBox.width / 2, settledBox.y + settledBox.height / 2);
await page.waitForTimeout(800);
assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20));
assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin');
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
assert.equal(await preview.locator('.slider-controls').count(), 0);
checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true });
for (const width of [1024, 390]) {
await page.setViewportSize({ width, height: 844 });
for (const route of ['/admin/posts', '/admin/menus', '/admin/forms']) {
await page.goto(origin + route);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
const results = await new AxeBuilder({ page }).analyze(); assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
await page.screenshot({ path: `reports/admin-quality/${route.split('/').pop()}-${width}.png` });
}
}
checks.push({ name: 'tablet/mobile layouts fit and pass axe', passed: true });
assert.deepEqual(errors, []);
await writeFile('reports/admin-quality/result.json', JSON.stringify({ checks }, null, 2));
console.log('Pilot and administration checks passed.');
}
} catch (error) {
if (page) await page.screenshot({ path: 'reports/admin-quality/failure.png', fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+97
View File
@@ -0,0 +1,97 @@
// Exercise the real HTTP update across two complete release archives in an isolated installation.
// Usage: PHP_BINARY=php node scripts/archive-update-check.mjs old.zip new.zip [expected-status]
// UPDATE_SOURCE_VERSION changes only the disposable source VERSION to test a future upgrade
// from the fixed updater before a subsequent release exists.
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, readFile, writeFile, readdir, access } from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import path from 'node:path';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const [source, target, expected = '302'] = process.argv.slice(2);
assert.ok(source && target, 'Supply the installed and target release ZIP paths.');
const root = process.cwd().replaceAll('\\', '/');
const run = `${root}/var/archive-check-${randomBytes(5).toString('hex')}`;
await mkdir(run, { recursive: true });
const originalHash = createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
execFileSync(php, ['-r', '$z=new ZipArchive(); if(true!==$z->open($argv[1])||!$z->extractTo($argv[2]))exit(1); $z->close();', path.resolve(source), run], { windowsHide: true });
const [directory] = (await readdir(run)).filter(name => /^kantsites-/.test(name));
assert.ok(directory);
const installation = `${run}/${directory}`;
const targetVersion = execFileSync(php, ['-r', '$z=new ZipArchive(); $z->open($argv[1]); for($i=0;$i<$z->numFiles;$i++){if(preg_match("#^kantsites-[^/]+/VERSION$#",$z->getNameIndex($i)))echo trim($z->getFromIndex($i));}', path.resolve(target)], { encoding: 'utf8', windowsHide: true }).trim();
assert.match(targetVersion, /^\d+\.\d+\.\d+$/);
if (process.env.UPDATE_SOURCE_VERSION) await writeFile(`${installation}/VERSION`, `${process.env.UPDATE_SOURCE_VERSION}\n`);
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
await mkdir(`${installation}/var/media`, { recursive: true });
// Only the download transport is replaced; the real archive, checksum, backup, file swap,
// production kernel, session, redirects, migration and subsequent requests remain in use.
await writeFile(`${run}/download.php`, `<?php
class ArchiveCheckDownload extends App\\Ops\\Download {
public function get(string $url): ?string {
if(str_ends_with($url, '/releases/latest')) return json_encode(['tag_name'=>'v'.getenv('QA_TARGET_VERSION')]);
if(str_ends_with($url, '.zip.sha256')) return hash_file('sha256', getenv('QA_TARGET_ZIP'));
if(str_ends_with($url, '.zip')) return file_get_contents(getenv('QA_TARGET_ZIP'));
return null;
}
}
`);
await writeFile(`${installation}/config/services.yaml`, (await readFile(`${installation}/config/services.yaml`, 'utf8')) + '\n App\\Ops\\Download:\n class: ArchiveCheckDownload\n');
// Keep the test router outside the replaced bin/ directory (Windows locks the active script).
await writeFile(`${run}/router.php`, `<?php
$root = ${JSON.stringify(installation)};
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($uri !== '/' && !str_contains($uri, '..') && is_file($root.'/public'.$uri)) return false;
require_once $root.'/vendor/autoload.php';
require_once ${JSON.stringify(`${run}/download.php`)};
require $root.'/public/index.php';
`);
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: `${installation}/var/media`, SITE_ORIGIN: origin, QA_TARGET_ZIP: path.resolve(target), QA_TARGET_VERSION: targetVersion };
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', `${run}/router.php`], { cwd: installation, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
let log = '', browser;
server.stdout.on('data', data => log += data); server.stderr.on('data', data => log += data);
const result = { source: path.resolve(source), target: path.resolve(target), simulatedSourceVersion: process.env.UPDATE_SOURCE_VERSION ?? null };
try {
let ready = false;
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin + '/admin/login')).ok) { ready = true; break; } } catch {}
await new Promise(resolve => setTimeout(resolve, 200));
}
assert.ok(ready, 'Isolated release must boot before testing an update.');
browser = await chromium.launch(); const page = await browser.newPage();
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.locator('button[type=submit]').click();
assert.equal((await page.goto(origin + '/admin/system')).status(), 200);
const token = await page.locator('form[action="/admin/system/archiv"] [name="_csrf_token"]').first().inputValue();
const response = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: token, target: targetVersion }, maxRedirects: 0 });
result.updateStatus = response.status(); assert.equal(response.status(), Number(expected));
if (response.status() === 302) assert.equal(response.headers().location, '/admin/system');
assert.equal((await readFile(`${installation}/VERSION`, 'utf8')).trim(), targetVersion);
const updateLog = await readFile(`${installation}/var/updates.log`, 'utf8');
assert.ok(updateLog.includes('Dateien sind ersetzt')); assert.ok(!updateLog.includes('FEHLER'));
assert.equal((await page.request.get(origin)).status(), 503, 'Maintenance stays active until migrations finish.');
result.systemStatus = (await page.goto(origin + '/admin/system')).status(); assert.equal(result.systemStatus, 200);
const finish = page.locator('form').filter({ has: page.locator('input[name=finish]') });
assert.equal(await finish.count(), 1, 'The migration step remains accessible with the same session.');
const csrf = await finish.locator('[name="_csrf_token"]').inputValue();
const finished = await page.request.post(origin + '/admin/system/archiv', { form: { _csrf_token: csrf, finish: '1' }, maxRedirects: 0 });
result.finishStatus = finished.status(); assert.equal(finished.status(), 302);
result.publicStatus = (await page.request.get(origin)).status(); assert.equal(result.publicStatus, 200);
assert.equal((await page.goto(origin + '/admin/system')).status(), 200);
assert.equal(await page.locator('input[name=finish]').count(), 0);
assert.equal((await readdir(`${installation}/var/backups`)).filter(name => name.startsWith('sicherung-')).length, 1);
await access(`${installation}/.env`);
assert.equal(createHash('sha256').update(await readFile('var/kantit.db')).digest('hex'), originalHash);
if (Number(expected) === 302) assert.doesNotMatch(log, /\[critical\]|Failed to open stream|PHP Fatal error/);
result.ok = true;
} finally {
await browser?.close(); server.kill();
await writeFile(`${run}/server.log`, log); await writeFile(`${run}/result.json`, JSON.stringify(result, null, 2));
console.log(JSON.stringify({ ...result, report: run }, null, 2));
}
+12 -4
View File
@@ -80,10 +80,13 @@ try {
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
// Eine öffentliche Seite lädt kein Skript, solange sie keines braucht - und das steht in
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider mit Autolauf,
// eigener Code), darf überhaupt eines kommen, dann höchstens eines.
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider, Einblendungen,
// Formular mit Schritten, eigener Code), darf überhaupt eines kommen. Jede Fähigkeit
// bringt genau eine kleine Datei mit; drei davon sind die Grenze, und alle vom eigenen Ursprung.
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(erlaubt ? scripts <= 1 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 1 : 0}`);
assert.ok(erlaubt ? scripts <= 3 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 3 : 0}`);
const fremd = [...transferred.keys()].filter(url => (transferred.get(url).type.includes('javascript')) && !url.startsWith(origin + '/assets/'));
assert.deepEqual(fremd, [], `${path}: Skript von außerhalb /assets/`);
assert.ok(beiwerk <= 12, `${path}: ${beiwerk} Anfragen ohne Bilder, Obergrenze 12`);
assert.ok(gewicht < 1500000, `${path}: ${gewicht} Bytes übertragen, Obergrenze 1500000`);
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
@@ -113,7 +116,12 @@ try {
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
assert.ok(darfSkripte ? laufend <= 1 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 1 : 0}`);
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
// Block halb durchsichtig oder verschoben. axe rechnet den Kontrast dann gegen die
// verwaschene Farbe, und ein von der Seite hereingleitender Block zählt als Überlauf -
// beides Befunde, die kein Besucher je sieht.
await page.evaluate(() => document.getAnimations().forEach(animation => animation.finish()));
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow');
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
+5
View File
@@ -0,0 +1,5 @@
import { readFile, writeFile } from 'node:fs/promises';
const file = new URL('../public/assets/buttons.css', import.meta.url);
const source = (await readFile(file, 'utf8')).split('/* Responsive variants */')[0].trimEnd();
const rules = source.split('\n').filter(line => line.includes('b-desktop-')).join('\n');
await writeFile(file, `${source}\n/* Responsive variants */\n@media(max-width:1024px){\n${rules.replaceAll('b-desktop-', 'b-tablet-')}\n}\n@media(max-width:767px){\n${rules.replaceAll('b-desktop-', 'b-mobile-')}\n}\n`);
+1 -1
View File
@@ -7,7 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises';
*/
export function designCss(tokens) {
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `.kant .d-${device}-${key}-${value}{${css}}`)).join('\n');
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:650px){\n${rules('mobile')}\n}\n`;
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:767px){\n${rules('mobile')}\n}\n`;
}
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/build-design.mjs')) {
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
+4 -4
View File
@@ -67,8 +67,8 @@ echo "Fassung {$version}\n";
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
// dort, wo eine frische Installation ihre Tabellen anlegen will.
$include = ['bin', 'config', 'migrations', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
$count = 0;
@@ -98,7 +98,7 @@ foreach ($include as $entry) {
// denn ohne sie bootet die Anwendung nicht einmal so weit, dass der Installer erscheint. Also
// wird die Vorlage zur .env des Releases - mit Produktionswerten und ohne Geheimnis. Das
// Geheimnis erzeugt der erste Aufruf selbst, die Zugangsdaten schreibt der Installer nach
// .env.local.
// .env.
copy($root.'/.env.example', $staging.'/.env');
++$files;
echo "Quelldateien: {$files}\n";
@@ -115,7 +115,7 @@ if (!$ok) {
fwrite(STDERR, "composer install ist gescheitert:\n".$output."\n");
exit(1);
}
echo "vendor/ gebaut".($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
echo 'vendor/ gebaut'.($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
// The environment file is not part of a release: it holds the secret of one installation.
foreach (['.env.local', '.env.prod.local'] as $secret) {
+139
View File
@@ -0,0 +1,139 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/buttons'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
const buttonStyle = node => {
const style = getComputedStyle(node);
return Object.fromEntries(['backgroundColor', 'color', 'borderRadius', 'borderColor', 'borderWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'textTransform', 'lineHeight', 'textAlign'].map(key => [key, style[key]]));
};
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
data.document.blocks.unshift({ id: 'qa-buttons-section', type: 'section', children: [
{ id: 'qa-button-default', type: 'button', props: { text: 'Kontakt aufnehmen', href: '#kontakt' } },
{ id: 'qa-button', type: 'button', props: { text: 'Mehr erfahren', href: '/kontakt' }, style: { desktop: { margin: { left: '12px' }, fontSize: '17px' } } }
] });
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
await page.goto(origin + '/admin/theme');
await page.locator('#buttons-fontSize').fill('18');
await page.getByRole('button', { name: 'Standardbutton Rahmen · Pille', exact: true }).click();
assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill');
assert.equal(await page.locator('#buttons-appearance').inputValue(), 'outline');
assert.equal(await page.locator('#button-defaults .button-state .cms-button').first().evaluate(el => getComputedStyle(el).borderRadius), '999px');
await page.locator('#button-defaults').screenshot({ path: `${report}/theme.png` });
await page.getByRole('button', { name: 'Design speichern', exact: true }).click();
assert.equal(await page.locator('#buttons-shape').inputValue(), 'pill');
assert.equal(await page.locator('#buttons-fontSize').inputValue(), '18');
checks.push('global typography, visual presets, normal/hover preview and actual form save including empty palette rows');
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
await page.locator('#structure [data-block-id="qa-button"]').click();
await page.getByRole('button', { name: 'Button-Vorlage Abgerundet', exact: true }).click();
const waitVariant = async (id, radius, background) => page.waitForFunction(({id,radius,background}) => {
const doc = document.querySelector('#preview')?.contentDocument, el = doc?.getElementById(id);
if (!el) return false;
const s = doc.defaultView.getComputedStyle(el);
return s.borderRadius === radius && (background === undefined || (s.backgroundColor === 'rgba(0, 0, 0, 0)') === background);
}, { id, radius, background });
await waitVariant('qa-button', '8px', false);
await page.locator('#undo').click(); await waitVariant('qa-button', '999px', true);
await page.locator('#redo').click(); await waitVariant('qa-button', '8px', false);
await page.locator('[data-viewport="tablet"]').click();
await page.getByRole('button', { name: 'Button-Vorlage Rahmen · eckig', exact: true }).click();
await waitVariant('qa-button', '0px', true);
await page.locator('[data-viewport="mobile"]').click();
await page.getByRole('button', { name: 'Button-Vorlage Pille', exact: true }).click();
await waitVariant('qa-button', '999px', false);
await page.locator('#button-reset').click(); await waitVariant('qa-button', '0px', true);
await page.locator('#undo').click(); await waitVariant('qa-button', '999px', false);
await page.locator('[data-viewport="desktop"]').click(); await waitVariant('qa-button', '8px', false);
await page.locator('.button-designer').screenshot({ path: `${report}/editor-controls.png` });
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await page.locator('#structure [data-block-id="qa-button"]').click(); await waitVariant('qa-button', '8px', false);
const stored = (await (await context.request.get(api)).json()).document;
assert.equal(stored.schemaVersion, 21);
assert.deepEqual(stored.blocks[0].children[1].props, { text: 'Mehr erfahren', href: '/kontakt' });
assert.equal(stored.blocks[0].children[1].style.desktop.margin.left, '12px');
assert.equal(stored.blocks[0].children[1].style.desktop.fontSize, '17px');
checks.push('preset selection, device inheritance/reset, undo/redo, save/reload, preserved content and unrelated styling');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
for (const [width, radius, transparent] of [[1600, '8px', false], [900, '0px', true], [390, '999px', false]]) {
await publicPage.setViewportSize({ width, height: 900 }); await publicPage.goto(origin);
const necessaryCookies = publicPage.getByRole('button', { name: 'Nur notwendige', exact: true });
if (await necessaryCookies.count()) await necessaryCookies.click();
const values = await publicPage.locator('#qa-button').evaluate(el => { const s=getComputedStyle(el);return { radius:s.borderRadius, bg:s.backgroundColor, font:s.fontSize }; });
assert.equal(values.radius, radius); assert.equal(values.bg === 'rgba(0, 0, 0, 0)', transparent); assert.equal(values.font, '17px');
assert.equal(await publicPage.locator('#qa-button-default').evaluate(el => getComputedStyle(el).fontSize), '18px');
const submit = publicPage.locator('.block-form button[type=submit]').first();
const standard = publicPage.locator('#qa-button-default');
assert.deepEqual(await submit.evaluate(buttonStyle), await standard.evaluate(buttonStyle), 'Form submit uses global shape, appearance, size, typography and colors');
assert.ok(await submit.evaluate(node => node.getBoundingClientRect().width < node.form.getBoundingClientRect().width), 'Submit keeps natural button width');
await submit.hover(); await publicPage.waitForTimeout(350);
const hoverStyle = await submit.evaluate(buttonStyle);
await standard.hover(); await publicPage.waitForTimeout(350);
assert.deepEqual(hoverStyle, await standard.evaluate(buttonStyle), 'Submit inherits the global hover colors');
await submit.focus();
assert.equal(await submit.evaluate(node => getComputedStyle(node).outlineStyle), 'solid', 'Submit has a keyboard focus indicator');
await submit.screenshot({ path: `${report}/submit-${width}.png` });
await publicPage.locator('#qa-buttons-section').screenshot({ path: `${report}/public-${width}.png` });
}
await page.goto(origin + '/admin/theme');
await page.locator('#buttons-shape').selectOption('rounded');
await page.locator('#buttons-appearance').selectOption('solid');
await page.locator('#buttons-size').selectOption('large');
await page.locator('#buttons-fontSize').fill('20');
for (const [key, hex] of [['backgroundColor', '#124b36'], ['color', '#ffffff'], ['hoverBackgroundColor', '#092c20'], ['hoverColor', '#fff4cc']]) {
await page.locator(`#buttons-${key}`).click();
await page.locator(`#buttons-${key}-hex`).fill(hex);
await page.locator(`#buttons-${key}-hex`).press('Enter');
await page.keyboard.press('Escape');
}
await page.getByRole('button', { name: 'Design speichern', exact: true }).click();
await publicPage.goto(origin);
const updatedSubmit = publicPage.locator('.block-form button[type=submit]').first();
const updatedDefault = publicPage.locator('#qa-button-default');
assert.deepEqual(await updatedSubmit.evaluate(buttonStyle), await updatedDefault.evaluate(buttonStyle), 'A later change to solid global defaults updates existing forms');
assert.equal(await updatedSubmit.evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(18, 75, 54)');
await updatedSubmit.hover(); await publicPage.waitForTimeout(350);
const updatedHover = await updatedSubmit.evaluate(buttonStyle);
await updatedDefault.hover(); await publicPage.waitForTimeout(350);
assert.deepEqual(updatedHover, await updatedDefault.evaluate(buttonStyle), 'Custom global hover colors update existing forms');
await publicPage.setViewportSize({ width: 390, height: 1600 });
await updatedSubmit.scrollIntoViewIfNeeded();
await publicPage.locator('.block-form').first().screenshot({ path: `${report}/form-standard-button.png` });
await plain.close(); checks.push('published global defaults, form submit including hover/focus/natural width, solid and outline theme changes, and local responsive variants without JavaScript');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Button checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+147
View File
@@ -0,0 +1,147 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/color-picker'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
const schema = await (await context.request.get(origin + '/admin/api/design')).json();
const deep = schema.colors.find(c => c.key === 'deep');
const accent = schema.colors.find(c => c.key === 'accent');
assert.ok(deep && accent);
data.document.blocks.unshift({ id: 'qa-color', type: 'section', style: { desktop: {
backgroundColor: '@deep', color: '#ffffff', gradientFrom: '#11223344', gradientTo: '#55667788',
hover: { backgroundColor: '@accent' }, padding: { top: '40px', bottom: '40px' },
} }, children: [{ id: 'qa-color-text', type: 'text', props: { text: '<p>Farben direkt im Blick</p>' } }] });
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
const select = async () => {
await page.locator('#structure [data-block-id="qa-color"]').click();
await page.locator('[data-panel="design"]').click();
const group = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Hintergrund$/ }) });
if (!(await group.getAttribute('open'))) {
if (!(await group.evaluate(el => el.open))) await group.locator('summary').click();
}
};
await select();
const trigger = (key = 'backgroundColor') => page.locator(`#style-${key}`);
const choices = (key = 'backgroundColor') => page.locator(`#style-${key}-choices`);
const sample = async (hex, key = 'backgroundColor') => {
await page.waitForFunction(({ hex, key }) => document.querySelector(`#style-${key} .color-detail`)?.textContent.includes(hex), { hex, key });
assert.ok(await trigger(key).locator('.color-sample-ink').evaluate(el => el.style.backgroundColor !== '' && el.style.backgroundColor !== 'transparent'));
};
const choose = async (token, key = 'backgroundColor') => { await trigger(key).click(); await choices(key).locator(`[data-color="${token}"]`).click(); };
const frameColor = async hex => {
const rgb = `rgb(${[1,3,5].map(i => parseInt(hex.slice(i,i+2),16)).join(', ')})`;
await page.waitForFunction(rgb => {
const doc = document.querySelector('#preview').contentDocument, block = doc?.getElementById('qa-color');
return block && doc.defaultView.getComputedStyle(block).backgroundColor === rgb;
}, rgb);
};
await sample(deep.value); assert.match(await trigger().innerText(), new RegExp(deep.name));
await trigger().focus(); await page.keyboard.press('Enter');
assert.equal(await trigger().getAttribute('aria-expanded'), 'true');
await page.keyboard.press('Tab'); assert.equal(await choices().locator('[data-color]').first().evaluate(el => el === document.activeElement), true);
await page.keyboard.press('Escape'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
await choose('@accent'); await sample(accent.value); await frameColor(accent.value);
await page.locator('#undo').click(); await sample(deep.value);
await page.locator('#redo').click(); await sample(accent.value);
await page.locator('#undo').click(); await sample(deep.value);
checks.push('palette name and real token color, keyboard open/tab/escape, selection, canvas, undo and redo');
await page.locator('[data-viewport="tablet"]').click(); await sample(deep.value);
assert.match(await trigger().innerText(), /Geerbt/);
await sample(accent.value, 'backgroundColor-hover');
await choose('@accent'); await page.locator('[data-viewport="mobile"]').click(); await sample(accent.value);
await page.locator('[data-viewport="tablet"]').click();
await page.getByRole('button', { name: 'Hintergrundfarbe zurücksetzen', exact: true }).click(); await sample(deep.value);
await page.locator('[data-viewport="desktop"]').click();
checks.push('desktop/tablet/mobile inheritance including hover colors and reset');
await trigger().click();
await choices().locator('.color-native').evaluate(el => {
window.colorPickerNode = el;
el.focus();
for (const value of ['#112233', '#223344', '#334455']) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); }
});
assert.equal(await choices().locator('.color-native').evaluate(el => el === window.colorPickerNode), true);
await frameColor('#334455');
await choices().locator('.color-native').dispatchEvent('change');
await page.locator('#undo').click(); await sample(deep.value);
await trigger().click();
await choices().locator('.color-native').evaluate(el => { el.value = '#445566'; el.dispatchEvent(new Event('change', { bubbles: true })); });
await sample('#445566'); assert.equal(await trigger().evaluate(el => el === document.activeElement), true);
await page.locator('#undo').click(); await sample(deep.value);
await trigger().click();
const hex = page.locator('#style-backgroundColor-hex');
await hex.fill('ungueltig'); await hex.press('Enter'); assert.equal(await hex.getAttribute('aria-invalid'), 'true');
await sample(deep.value);
await hex.fill('#abc'); await hex.press('Enter'); await sample('#aabbcc'); await frameColor('#aabbcc');
await page.locator('#undo').click(); await sample(deep.value);
await trigger('gradientFrom').click();
await choices('gradientFrom').locator('.color-native').evaluate(el => { el.value = '#224466'; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); });
await sample('#22446644', 'gradientFrom');
await page.locator('#undo').click(); await sample('#11223344', 'gradientFrom');
checks.push('native live color changes retain DOM and form one undo step; invalid input refused; short hex expanded; gradient alpha preserved');
await choose('@accent');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await select(); await sample(accent.value);
const saved = await (await context.request.get(api)).json();
assert.equal(saved.document.blocks[0].style.desktop.backgroundColor, '@accent');
assert.equal(saved.document.blocks[0].style.desktop.gradientFrom, '#11223344');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const visitor = await context.newPage(); await visitor.goto(origin);
assert.equal(await visitor.locator('#qa-color').evaluate(el => getComputedStyle(el).backgroundColor), `rgb(${[1,3,5].map(i => parseInt(accent.value.slice(i,i+2),16)).join(', ')})`);
await visitor.close();
checks.push('save, reload and publication preserve palette references and alpha values');
for (const width of [1600, 390]) {
await page.setViewportSize({ width, height: 1050 });
if (await trigger().getAttribute('aria-expanded') === 'false') await trigger().click();
await choices().scrollIntoViewIfNeeded();
await trigger().evaluate(el => {
const panel = el.closest('.right-panel');
panel.scrollTop += el.getBoundingClientRect().top - panel.getBoundingClientRect().top - 16;
});
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze();
assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
assert.equal(await choices().evaluate(el => el.scrollWidth <= el.clientWidth), true);
await page.locator('.right-panel').screenshot({ path: `${report}/palette-${width}.png` });
}
checks.push('open palette and editor pass axe and fit desktop/mobile without horizontal overflow');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Color picker checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+434 -31
View File
@@ -37,8 +37,21 @@ try {
activePage = page;
const errors = [];
page.on('pageerror', error => { errors.push(error.message); console.error('Editor script error:', error.message); });
page.on('console', message => { if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text()); });
page.on('console', message => {
if (message.type() === 'error' && /Content Security Policy/.test(message.text())) errors.push(message.text());
// Jede Fehlermeldung der Konsole ins Protokoll: ein Editor, der bei „Wird geladen“ stehen
// bleibt, sagt sonst nichts darüber, warum.
if (message.type() === 'error') console.error('Console error:', message.text());
});
page.on('dialog', dialog => dialog.accept());
// Jede angeforderte Skriptdatei mitschreiben. `public/.htaccess` laesst .js ein Jahr lang als
// `immutable` zwischenspeichern; eine Datei ohne Fassungskennung waere damit nach einem Update
// fuer immer veraltet - genau so brach der Editor mit "does not provide an export named ...".
const skripte = [];
page.on('request', anfrage => {
const url = new URL(anfrage.url());
if (url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js')) skripte.push(url);
});
const axe = async name => {
console.log('Accessibility check:', name);
// The no-script preview sandbox cannot host axe's frame worker. Audit the identical
@@ -47,6 +60,10 @@ try {
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), [], name);
checks.push({ name, violations: 0 });
};
const tab = name => page.getByRole('tab', { name, exact: true }).click();
const treeSelect = async id => { await tab('Struktur'); await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`#structure [data-edit-block="${id}"]`).click(); };
const treeToggle = async id => { await tab('Struktur'); await page.locator(`#structure [data-toggle="${id}"]`).click(); };
const addFromLibrary = async selector => { await tab('Elemente'); await page.locator(`#library ${selector}`).click(); };
await page.goto(origin + '/admin');
assert.equal(new URL(page.url()).pathname, '/admin/login');
await axe('login');
@@ -56,15 +73,242 @@ try {
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.waitForURL(origin + '/admin');
console.log('Authenticated in isolated test installation.');
// --- Die Verwaltungsnavigation ------------------------------------------------------------
// Sie steckt in einem details-Element: auf dem Telefon ein Klappmenue, am Schreibtisch die
// stehende Spalte. Am Schreibtisch ist der Schalter ausgeblendet - und ein geschlossenes
// details versteckt seinen Inhalt von sich aus. Ohne eine Regel, die ihn zurueckholt, ist die
// Navigation dort nicht bloss zugeklappt, sondern unerreichbar. Genau so ist 1.5.4 ausgeliefert
// worden, und genau das faellt hier auf.
const navigation = page.getByRole('navigation', { name: 'Verwaltung' });
const schalter = page.locator('.shell-menu > summary');
assert.equal(await schalter.isVisible(), false, 'am Schreibtisch gibt es keinen Menueschalter');
assert.equal(await navigation.isVisible(), true, 'am Schreibtisch steht die Navigation da');
const eintrag = navigation.getByRole('link', { name: 'Seiten', exact: true });
const flaeche = await eintrag.boundingBox();
assert.ok(flaeche && flaeche.height > 0 && flaeche.width > 0, 'ihre Eintraege sind anklickbar');
// Auf dem Telefon dagegen: ein Schalter, zunaechst zu, ein Klick oeffnet.
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(await schalter.isVisible(), true, 'auf dem Telefon gibt es den Schalter');
assert.equal(await navigation.isVisible(), false, 'und die Liste ist zunaechst zu');
await schalter.click();
assert.equal(await navigation.isVisible(), true, 'ein Klick oeffnet sie');
await page.setViewportSize({ width: 1600, height: 1100 });
checks.push({ name: 'the administration navigation is reachable on both sizes', passed: true });
console.log('Administration navigation passed.');
await axe('page list');
await page.screenshot({ path: 'reports/editor/pages.png', fullPage: true });
await page.locator('a[href="/admin/pages/test-home"]').first().click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
console.log('Editor data loaded.');
// Der Editor laedt fuenf Module ueber Importe, die selbst keine Fassung tragen; die
// Importkarte in base.html.twig haengt sie an. Ohne sie kaeme das alte Modul aus dem Cache.
assert.ok(skripte.some(url => url.pathname === '/assets/editor-model.js'), 'editor-model.js wird geladen');
const ohneFassung = [...new Set(skripte.filter(url => !url.searchParams.has('v')).map(url => url.pathname))];
assert.deepEqual(ohneFassung, [], `Skripte ohne Fassungskennung: ${ohneFassung.join(', ')}`);
checks.push({ name: 'every module is requested with a version key', passed: true });
console.log(`Fassungskennung an allen ${new Set(skripte.map(u => u.pathname)).size} Skriptdateien.`);
console.log('Preview status:', await page.locator('#preview-status').innerText());
const frame = page.frameLocator('#preview');
await frame.locator('h1').waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
// One sidebar, contextual fields and a root insertion from a deeply selected element.
await page.reload();
await frame.locator('h1').waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
for (const width of [1920, 1100, 850, 390]) {
await page.setViewportSize({ width, height: 1100 });
await page.waitForTimeout(100);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true, `workspace fits ${width}`);
for (const selector of ['.sidebar', '.sidebar-body', '.library-buttons']) {
assert.equal(await page.locator(selector).first().evaluate(el => el.scrollWidth <= el.clientWidth + 1), true, `${selector} fits ${width}`);
}
}
await page.setViewportSize({ width: 1600, height: 1100 });
await frame.locator('h1').click();
const sectionCount = await frame.locator('main > .block-section').count();
await addFromLibrary('[data-add="section"]');
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
await page.locator('#undo').click();
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count, sectionCount);
await page.locator('#redo').click();
await page.waitForFunction(count => document.querySelector('#preview').contentDocument.querySelectorAll('main > .block-section').length === count + 1, sectionCount);
await page.locator('#undo').click();
await frame.locator('h1').click();
await addFromLibrary('[data-add="image"]');
await page.locator('[data-panel="design"]').click();
assert.equal(await page.locator('#style-fontSize').count(), 0);
assert.equal(await page.locator('#style-objectFit').isVisible(), true);
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.screenshot({ path: 'reports/editor/context-image.png', fullPage: true });
await page.getByRole('button', { name: 'Alle Eigenschaften anzeigen', exact: true }).click();
assert.equal(await page.locator('#style-fontSize').count(), 1);
await page.locator('#undo').click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await frame.locator('main .block-text').first().click();
await page.locator('[data-panel="design"]').click();
assert.equal(await page.locator('#style-objectFit').count(), 0);
assert.equal(await page.locator('#style-fontSize').isVisible(), true);
const textWidth = await frame.locator('main .block-text').first().evaluate(el => ({ width: el.getBoundingClientRect().width, parent: el.parentElement.clientWidth }));
assert.ok(Math.abs(textWidth.width - textWidth.parent) < 2, 'text uses its container width');
const sizeGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: /^Größe$/ }) });
await sizeGroup.locator('summary').click();
await page.locator('#style-maxWidth').locator('xpath=following-sibling::select').selectOption('ch');
await page.locator('#style-maxWidth').fill('60');
await page.locator('#style-maxWidth').blur();
await page.waitForFunction(() => {
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
return Boolean(el && el.getBoundingClientRect().width < el.parentElement.clientWidth - 100);
});
await page.getByLabel('Maximale Breite Vorgabe', { exact: true }).selectOption('none');
await page.waitForFunction(() => {
const el = document.querySelector('#preview').contentDocument.querySelector('main .block-text');
return Boolean(el && getComputedStyle(el).maxWidth === 'none');
});
await page.locator('#undo').click();
await page.locator('#undo').click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.locator('.sidebar-body').evaluate(el => el.scrollTop = 0);
await page.screenshot({ path: 'reports/editor/flexible-workspace.png', fullPage: true });
await page.locator('[data-panel="content"]').click();
checks.push({ name: 'one sidebar, root sections, contextual image/text properties and full text width', passed: true });
// A layout starts empty: it must be visible, reachable and editable before it has any text.
const previewReady = () => page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await frame.locator('h1').click();
await addFromLibrary('[data-add="section"]');
await previewReady();
const emptySection = frame.locator('main > .editor-empty-container');
await emptySection.waitFor();
const newSectionId = await emptySection.getAttribute('id');
assert.ok((await emptySection.boundingBox()).height > 100, 'new empty section has a visible hit area');
await page.screenshot({ path: 'reports/editor/empty-section.png', fullPage: true });
await frame.getByRole('button', { name: '2 Spalten in dieser Sektion anlegen', exact: true }).press('Enter');
await previewReady();
const emptyColumns = frame.locator(`#${newSectionId} .block-column`);
assert.equal(await emptyColumns.count(), 2);
for (let index = 0; index < 2; index++) assert.ok((await emptyColumns.nth(index).boundingBox()).height > 80, 'empty column has a hit area');
const secondColumnId = await emptyColumns.nth(1).getAttribute('id');
const firstColumnId = await emptyColumns.first().getAttribute('id');
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).press('Enter');
assert.equal(await page.locator('#library-search').evaluate(el => el === document.activeElement), true);
assert.match(await page.locator('#insert-hint').innerText(), /Spalte 2 von 2/);
await page.locator('#library-search').fill('Text');
await addFromLibrary('[data-add="text"]');
await frame.locator(`#${secondColumnId} .block-text`).waitFor();
await previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'unfilled sibling stays visible');
await tab('Elemente');
await page.locator('#library-search').fill('');
const dragSource = page.locator('#library [data-add="button"]');
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
await dragSource.scrollIntoViewIfNeeded();
const sourceBox = await dragSource.boundingBox();
let targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2);
await page.mouse.down();
await page.mouse.move(sourceBox.x + sourceBox.width / 2 + 10, sourceBox.y + sourceBox.height / 2, { steps: 3 });
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 });
// Chromium dispatches dragenter on the first move into a target, dragover on the next.
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
await page.locator('.ov-drop-label').filter({ hasText: 'Spalte 1 von 2' }).waitFor();
await page.screenshot({ path: 'reports/editor/empty-layout-drag.png', fullPage: true });
const beforeDragScroll = await page.locator('#preview').evaluate(el => el.contentWindow.scrollY);
const frameBox = await page.locator('#preview').boundingBox();
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8, { steps: 5 });
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height - 8);
await page.waitForFunction(top => document.querySelector('#preview').contentWindow.scrollY > top + 10, beforeDragScroll);
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
await page.mouse.move(frameBox.x + 50, frameBox.y + frameBox.height / 2);
await frame.locator(`#${firstColumnId} .editor-empty`).scrollIntoViewIfNeeded();
targetBox = await frame.locator(`#${firstColumnId} .editor-empty`).boundingBox();
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 6 });
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2);
await page.mouse.up();
await frame.locator(`#${firstColumnId} .button`).waitFor();
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 0, 'placeholders disappear after filling');
await page.locator('#undo').click(); await previewReady();
assert.equal(await frame.locator(`#${firstColumnId} .editor-empty`).count(), 1, 'undo restores the empty target');
await page.locator('#redo').click(); await previewReady();
await frame.locator(`#${firstColumnId} .button`).waitFor();
await page.locator('#undo').click(); await previewReady();
await page.locator('#undo').click(); await previewReady();
await page.screenshot({ path: 'reports/editor/empty-layout-desktop.png', fullPage: true });
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await frame.getByRole('button', { name: 'Spalte 2 von 2: Inhalt hinzufügen', exact: true }).scrollIntoViewIfNeeded();
await page.screenshot({ path: 'reports/editor/empty-layout-mobile.png', fullPage: true });
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
await page.locator('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .editor-empty`).count(), 2, 'saved empty columns survive reload');
for (const device of ['Desktop', 'Mobil']) {
await page.getByRole('button', { name: device, exact: true }).click();
const destination = frame.locator(`#${secondColumnId}`);
await destination.scrollIntoViewIfNeeded();
const box = await destination.boundingBox();
await tab('Struktur');
await page.locator(`#structure [data-block-id="${firstColumnId}"]`).dragTo(destination, {
targetPosition: device === 'Desktop' ? { x: box.width - 8, y: 8 } : { x: 8, y: box.height - 8 },
});
await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), secondColumnId, `column order follows the ${device} layout`);
await page.locator('#undo').click(); await previewReady();
assert.equal(await frame.locator(`#${newSectionId} .block-column`).first().getAttribute('id'), firstColumnId);
}
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
// Umut: "bei Rows die Breite ändern, am besten per Drag and Drop im Editor". The border
// between the two columns is a handle; dragging it to the right widens the first column.
await treeSelect(firstColumnId);
await frame.locator(`#${firstColumnId}`).scrollIntoViewIfNeeded();
const resizeHandle = page.locator('.ov-resize');
await resizeHandle.waitFor();
assert.equal(await resizeHandle.count(), 1, 'two columns have one border to drag');
const columnTracks = () => frame.locator(`#${newSectionId} .block-row`).first().evaluate(el => getComputedStyle(el).gridTemplateColumns.split(' ').map(Number.parseFloat));
const evenTracks = await columnTracks();
assert.ok(Math.abs(evenTracks[0] - evenTracks[1]) < 2, `columns start out equal: ${evenTracks}`);
const handleBox = await resizeHandle.boundingBox();
await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2);
await page.mouse.down();
await page.mouse.move(handleBox.x + handleBox.width / 2 + 120, handleBox.y + handleBox.height / 2, { steps: 8 });
await page.mouse.up();
await previewReady();
const widenedTracks = await columnTracks();
assert.ok(widenedTracks[0] > widenedTracks[1] * 1.3, `dragging the border widened the first column: ${widenedTracks}`);
await page.screenshot({ path: 'reports/editor/column-widths.png', fullPage: true });
// The same value is visible as numbers in the design panel of the row.
await treeSelect(await frame.locator(`#${newSectionId} .block-row`).first().getAttribute('id'));
await page.getByRole('button', { name: 'Design', exact: true }).click();
const layoutGroup = page.locator('.design-group').filter({ has: page.locator('summary', { hasText: 'Layout' }) }).first();
if (!(await layoutGroup.evaluate(element => element.open))) await layoutGroup.locator('summary').click();
assert.match(await page.locator('#style-columnWidths').inputValue(), /^\d+(\.\d)? \d+(\.\d)?$/, 'shares are stored per column');
// On a phone the columns stack, so there is no border to drag - and one undo restores equal columns.
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await treeSelect(firstColumnId);
await page.waitForFunction(() => document.querySelectorAll('.ov-resize').length === 0);
await page.getByRole('button', { name: 'Desktop', exact: true }).click();
await page.locator('#undo').click(); await previewReady();
const restoredTracks = await columnTracks();
assert.ok(Math.abs(restoredTracks[0] - restoredTracks[1]) < 2, `undo restores equal columns: ${restoredTracks}`);
const savedEmptyDraft = await page.evaluate(async () => (await (await fetch(document.querySelector('#editor').dataset.api)).json()).document);
assert.equal(JSON.stringify(savedEmptyDraft).includes('editor-empty'), false, 'editing aids never enter content');
const publicBefore = await (await fetch(origin)).text();
assert.equal(publicBefore.includes(newSectionId), false, 'draft remains private');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const publishedEmpty = await (await fetch(origin)).text();
assert.ok(publishedEmpty.includes(newSectionId));
assert.equal(/editor-empty|data-editor-target|preview-containers|preview\.css|Inhalt hinzufügen/.test(publishedEmpty), false, 'public output has no editor aids');
// Restore the fixture through the same editor actions; subsequent tests retain their baseline.
await treeSelect(newSectionId);
await page.locator('#delete').click();
await page.locator('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
await page.reload(); await previewReady();
checks.push({ name: 'empty sections and columns: keyboard insertion, exact drop target, undo, responsive preview, reload and public isolation', passed: true });
await frame.locator('h1').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
@@ -95,7 +339,7 @@ try {
const headingId = await inPreview('h1');
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
await page.locator('#insert-hint').waitFor({ state: 'visible' });
await page.locator('#library button[data-add="text"]').click();
await addFromLibrary('button[data-add="text"]');
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
assert.equal(await page.locator('#insert-hint').isVisible(), false);
await page.waitForFunction(id => {
@@ -107,6 +351,7 @@ try {
// Ziehen aus dem Werkzeugkasten auf die Seite.
await frame.locator('h1').waitFor();
const beforeButtons = await page.locator('#structure [data-block-id]').count();
await tab('Elemente');
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
@@ -137,24 +382,45 @@ try {
assert.match((await rowState()).title, /gehört in eine Sektion/);
assert.equal((await toolboxState('section')).disabled, false, 'Sektion geht immer');
// In einer Sektion wird die Zeile moeglich, die Spalte noch nicht. Die Sektion kommt aus dem
// Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
// In einer Sektion werden Zeile und Spalte moeglich - eine einzelne Spalte darf seit 1.16.1
// direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente). Die Sektion kommt aus
// dem Strukturbaum, nicht aus der Vorschau: dort steht auch der Kopfbereich, und der gehoert
// einem anderen Dokument.
await tab('Struktur');
await page.locator('#structure .tree-item').filter({ hasText: 'Sektion' }).first().click();
assert.equal(await page.locator('#properties-title').innerText(), 'Sektion');
assert.equal(await page.locator('#properties-title').textContent(), 'Sektion');
assert.equal((await rowState()).disabled, false, 'Zeile in der Sektion moeglich');
assert.equal((await toolboxState('column')).disabled, true, 'Spalte in der Sektion weiterhin gesperrt');
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Sektion moeglich');
// Und in der Zeile wird die Spalte moeglich.
const columnsBefore = await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count();
await page.locator('#library button[data-row="3"]').click();
assert.equal(await page.locator('#properties-title').innerText(), 'Zeile');
await addFromLibrary('button[data-row="3"]');
assert.equal(await page.locator('#properties-title').textContent(), 'Zeile');
assert.equal((await toolboxState('column')).disabled, false, 'Spalte in der Zeile moeglich');
assert.match((await toolboxState('column')).title, /Wird in Zeile eingefügt/);
// Die Zeile bringt ihre drei Spalten mit. Die Testseite hat schon welche, also zaehlt die
// Differenz.
assert.equal(await page.locator('#structure .tree-item').filter({ hasText: 'Spalte' }).count(), columnsBefore + 3);
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
// Jeder Eintrag traegt eine Zeichnung statt eines Schriftzeichens.
await tab('Elemente');
const mitBild = await page.locator('#library button .library-preview svg').count();
const knoepfe = await page.locator('#library button').count();
assert.equal(mitBild, knoepfe, `alle ${knoepfe} Eintraege haben ein Vorschaubild`);
// Die Suche greift ueber alle Gruppen und blendet leere Gruppen aus.
await page.locator('#library-search').fill('galerie');
await page.locator('#library-found').filter({ hasText: 'von' }).waitFor();
assert.equal(await page.locator('#library button:visible').count(), 1, 'genau die Galerie bleibt');
assert.equal(await page.locator('#library .library-group:visible').count(), 1, 'nur ihre Gruppe bleibt');
// Auch der Gruppenname findet: "Aufbau" gehoert zu Sektion, Zeile und Spalte.
await page.locator('#library-search').fill('aufbau');
assert.equal(await page.locator('#library button:visible').count(), 5, 'Sektion, drei Zeilen, Spalte');
await page.locator('#library-search').fill('gibtesnicht');
await page.locator('#library-found').filter({ hasText: 'Kein Element' }).waitFor();
await page.locator('#library-search').fill('');
assert.equal(await page.locator('#library button:visible').count(), knoepfe, 'leere Suche zeigt wieder alles');
checks.push({ name: 'the toolbox follows the structure', passed: true });
await page.screenshot({ path: 'reports/editor/toolbox.png' });
console.log('Context aware toolbox passed.');
@@ -202,15 +468,18 @@ try {
checks.push({ name: 'preview never shows unstyled content', passed: true });
console.log('Preview edits and history passed.');
await tab('Struktur');
await page.getByRole('button', { name: 'Einklappen', exact: true }).click();
assert.equal(await page.locator('#structure .tree-item').count(), 3);
await page.getByLabel('Element suchen', { exact: true }).fill('Meine bearbeitete');
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('Meine bearbeitete');
assert.equal(await page.locator('#structure .tree-item').count(), 2);
await page.locator('#structure [data-block-id="headline"]').focus();
await page.keyboard.press('Enter');
await page.getByLabel('Element suchen', { exact: true }).fill('');
await page.getByLabel('Element auf der Seite suchen', { exact: true }).fill('');
assert.equal(await page.locator('#structure [data-block-id="headline"]').isVisible(), true);
await page.getByRole('button', { name: 'Alle öffnen', exact: true }).click();
// Enter in the tree only selects; editing is the button next to the selected row.
await page.locator('#structure [data-edit-block="headline"]').click();
await page.getByRole('button', { name: 'Design', exact: true }).click();
const frameValue = async (selector, property, expected) => {
@@ -258,14 +527,15 @@ try {
await frameValue('#headline', 'fontSize', '72px');
// Colours: a palette reference resolves to the theme variable, a hex value is taken as it is.
await page.locator('#style-color').locator('xpath=following-sibling::select').selectOption('@link');
await page.locator('#style-color').click();
await page.locator('#style-color-choices [data-color="@link"]').click();
await frameValue('#headline', 'color', 'rgb(164, 81, 15)');
const contrast = page.locator('.contrast-note');
await contrast.waitFor();
assert.match(await contrast.textContent(), /Kontrast \d/, 'The panel reports a contrast ratio.');
// Per side spacing on the section, and a hover rule that also covers keyboard focus.
await page.locator('#structure [data-block-id="hero"]').click();
await treeSelect('hero');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await setLength('style-padding-top', 96);
await frameValue('#hero', 'paddingTop', '96px');
@@ -309,7 +579,7 @@ try {
await frameValue('#hero', 'paddingTop', '640px');
// Das Ziehfeld: seitwaerts ziehen aendert den Wert, und der ganze Zug ist ein Undo-Schritt.
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await openGroup('Typografie');
const scrub = page.locator('#style-fontSize');
await scrub.scrollIntoViewIfNeeded();
@@ -328,7 +598,7 @@ try {
// Aufraeumen: dieser Abschnitt hat den Innenabstand des Hero auf 40rem an allen Seiten
// gebracht. Das drueckt die Spaltenbreite auf null, und alles Folgende haette unsichtbare
// Inhalte gepruef. Zurueck auf den Zustand von vorher: 96 px oben, sonst nichts.
await page.locator('#structure [data-block-id="hero"]').click();
await treeSelect('hero');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#style-padding-unit').selectOption('px');
for (const seite of ['right', 'bottom', 'left']) await setLength(`style-padding-${seite}`, '');
@@ -343,9 +613,10 @@ try {
// The background group is collapsed by default, and the readable field next to the swatch is
// what a person types into.
await openGroup('Hintergrund');
const hoverColor = page.locator('#style-backgroundColor-hover').locator('xpath=following-sibling::input[contains(@class,"color-text")]');
await page.locator('#style-backgroundColor-hover').click();
const hoverColor = page.locator('#style-backgroundColor-hover-hex');
await hoverColor.fill('#112233');
await hoverColor.blur();
await hoverColor.press('Enter');
// The preview carries its stylesheet as template text, applied by the editor as a constructed
// stylesheet; the hover rule has to arrive through that path, not as a style element.
await page.waitForFunction(() => {
@@ -356,6 +627,7 @@ try {
});
checks.push({ name: 'free styles: units, inheritance, single reset, palette colour, per-side spacing, hover rule', passed: true });
await tab('Elemente');
await page.locator('[data-preset="columns"]').click();
const presetSection = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
const presetRow = `#${presetSection} .block-row`;
@@ -376,12 +648,12 @@ try {
await frame.locator(presetRow).waitFor({ state: 'detached' });
await page.getByRole('button', { name: 'Wiederholen', exact: true }).click();
await columnCount(2);
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
checks.push({ name: 'keyboard tree search and collapse, responsive inheritance/reset/undo, true viewport widths, spacing and responsive section preset', passed: true });
console.log('Responsive design, tree tools and presets passed.');
await page.locator('#library [data-add="gallery"]').click();
await addFromLibrary('[data-add="gallery"]');
const galleryId = await page.locator('#structure .tree-item[aria-pressed="true"]').getAttribute('data-block-id');
await page.getByRole('button', { name: 'Galeriebilder auswählen', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '0 Bilder' }).waitFor();
@@ -408,12 +680,12 @@ try {
await frame.locator(`#${galleryId} img`).first().scrollIntoViewIfNeeded();
await page.waitForFunction(id => document.querySelector('#preview').contentDocument?.querySelector(`#${id} img`)?.naturalWidth > 0, galleryId);
const imageId = await frame.locator(`#${galleryId} figure`).first().getAttribute('id');
await page.locator(`#structure [data-block-id="${imageId}"]`).click();
await treeSelect(imageId);
await page.getByLabel('Bildunterschrift', { exact: true }).fill('Ein Ausblick zum Durchatmen.');
await frame.getByText('Ein Ausblick zum Durchatmen.', { exact: true }).waitFor();
await page.getByRole('button', { name: 'Nach unten verschieben', exact: true }).click();
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator(`#structure [data-block-id="${galleryId}"]`).click();
await treeSelect(galleryId);
await page.getByRole('button', { name: 'Design', exact: true }).click();
await openGroup('Layout');
await page.locator('#style-gridColumns').fill('2');
@@ -430,7 +702,43 @@ try {
await openGroup('Bewegung und Effekte');
await page.getByLabel('Schatten', { exact: true }).selectOption('soft');
await page.getByLabel('Hover-Effekt', { exact: true }).selectOption('lift');
await page.getByLabel('Einblendung beim Laden', { exact: true }).selectOption('rise');
for (const [effect, transform] of [['left', 'translateX(-56px)'], ['right', 'translateX(56px)']]) {
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption(effect);
await frame.locator(`#${galleryId}.fx-entrance-${effect}`).waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.evaluate(id => {
const element = document.querySelector('#preview').contentDocument.getElementById(id);
window.observedEntrance = [];
const animate = element.animate.bind(element);
element.animate = (frames, options) => { window.observedEntrance.push(frames); return animate(frames, options); };
element.scrollIntoView();
}, galleryId);
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
await page.waitForFunction(() => window.observedEntrance.length > 0);
assert.equal(await page.evaluate(() => window.observedEntrance[0][0].transform), transform);
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
}
// Default setting: the preview plays even when the browser asks for less motion (Flatsome mode).
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => { window.observedEntrance = []; });
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
await page.waitForFunction(() => window.observedEntrance.length > 0);
assert.equal(await page.locator('#motion-status').isVisible(), false, 'no motion note while the site ignores the preference');
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
// Setting "Browservorgabe beachten", as the preview would carry it: nothing plays, the note shows.
await page.evaluate(() => { document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect'); window.observedEntrance = []; });
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.waitForTimeout(200);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.locator('#motion-status').waitFor({ state: 'visible' });
await page.getByRole('button', { name: 'Bewegung in der Vorschau abspielen', exact: true }).click();
await page.waitForTimeout(800);
assert.equal(await page.evaluate(() => window.observedEntrance.length), 0, 'reduced motion suppresses entrance playback when respected');
await page.getByRole('button', { name: 'Bewegung in der Vorschau stoppen', exact: true }).click();
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.locator('#motion-status').waitFor({ state: 'hidden' });
checks.push({ name: 'distinct left/right entrance playback, Flatsome-mode playback and respected reduced motion in the sandbox', passed: true });
await page.getByLabel('Einblendung beim Erscheinen', { exact: true }).selectOption('rise');
await frame.locator(`#${galleryId}.fx-entrance-rise`).waitFor();
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
await openGroup('Layout');
@@ -443,7 +751,7 @@ try {
await frameValue(`#${galleryId}`, 'animationName', 'none');
await frameValue(`#${galleryId}`, 'transform', 'none');
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.locator('#open-media-library').click();
await (await tab('Elemente'), page.locator('#open-media-library').click());
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await page.locator(`[data-media-id="${mediaItems[0].id}"]`).click();
await page.getByRole('button', { name: 'Archivieren', exact: true }).click();
@@ -454,25 +762,23 @@ try {
await page.getByRole('button', { name: 'Wiederherstellen', exact: true }).click();
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await page.getByRole('button', { name: 'Inhalt', exact: true }).click();
checks.push({ name: 'image upload, alt metadata, multi-select gallery, caption, sorting undo, archive/restore, responsive variants and reduced-motion effects', passed: true });
console.log('Media library, gallery and effects passed.');
await page.locator('#library [data-add="text"]').click();
await addFromLibrary('[data-add="text"]');
await page.getByLabel('Text', { exact: true }).fill('Dieser Absatz wurde im Editor eingefügt.');
await page.getByRole('button', { name: 'Duplizieren', exact: true }).click();
await page.getByLabel('Text', { exact: true }).fill('Das ist eine unabhängige Kopie.');
await page.getByRole('button', { name: 'Nach oben verschieben', exact: true }).click();
await page.getByRole('button', { name: 'Löschen', exact: true }).click();
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator('#toggle-library').click();
await page.locator(`#structure [data-toggle="${presetSection}"]`).click();
await page.locator(`#structure [data-toggle="${galleryId}"]`).click();
await treeToggle(presetSection);
await treeToggle(galleryId);
await page.getByRole('button', { name: 'Text: Dieser Absatz wurde im Editor eingefügt.', exact: true }).dragTo(page.locator('#structure [data-block-id="principles"]'));
await page.waitForFunction(() => document.querySelector('#structure [data-block-id="principles"]')?.parentElement.textContent.includes('Dieser Absatz wurde im Editor eingefügt.'));
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
await page.locator('#toggle-library').click();
await page.route('**/admin/api/pages/test-home/draft', route => route.abort(), { times: 1 });
await page.getByRole('button', { name: 'Entwurf speichern', exact: true }).click();
await page.locator('#message').filter({ hasText: 'Verbindung ist unterbrochen' }).waitFor();
@@ -538,6 +844,102 @@ try {
await page.waitForFunction(() => document.querySelector('#property-title')?.value === 'Parallel gespeicherter Titel');
checks.push({ name: 'conflict retains local edits, JSON export, explicit server reload', passed: true });
// --- Plugins: einschalten, Baustein einfuegen, Eigenschaften aus dem Schema, Vorschau ------
// Das Referenzplugin "Bildvergleich" liegt im Repository. Aus ist es zuerst; eingeschaltet
// erscheint sein Baustein im Werkzeugkasten, das Panel entsteht aus dem Manifest, und die
// Vorschau laedt sein Stylesheet - das Skript nicht, wie bei eigenem Code.
await page.goto(origin + '/admin/plugins');
await axe('plugins');
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Einschalten', exact: true }).click();
await page.locator('.notice.ok').filter({ hasText: 'eingeschaltet' }).waitFor();
await page.screenshot({ path: 'reports/editor/plugins.png', fullPage: true });
await page.goto(origin + '/admin/pages/test-home');
await tab('Struktur');
await page.locator('#structure [data-block-id="headline"]').waitFor();
await treeSelect('headline');
await addFromLibrary('[data-add="vergleich.bildvergleich"]');
await page.locator('#properties-title').filter({ hasText: 'Bildvergleich' }).waitFor();
await page.getByLabel('Beschriftung vorher', { exact: true }).fill('Alt');
await page.getByLabel('Startposition des Reglers (%)', { exact: true }).fill('30');
for (const label of ['Bild vorher', 'Bild nachher']) {
await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Bild aus Mediathek wählen', exact: true }).click();
await page.locator('#media-dialog [data-media-id]').first().click();
await page.locator('#media-insert').click();
await page.locator('.plugin-media-field').filter({ hasText: label }).getByRole('button', { name: 'Anderes Bild wählen', exact: true }).waitFor();
}
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.waitForFunction(() => {
const doc = document.querySelector('#preview').contentDocument;
return Boolean(doc.querySelector('.block-bildvergleich input[type="range"][value="30"]')) && Boolean(doc.querySelector('link[href^="/plugin-assets/vergleich/assets/bildvergleich.css"]')) && !doc.querySelector('script[src^="/plugin-assets/"]');
});
await page.screenshot({ path: 'reports/editor/plugin-block.png', fullPage: true });
await page.locator('#save').click();
await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
{
const antwort = await page.request.get(origin + '/');
const html = await antwort.text();
assert.match(html, /class="block-bildvergleich bildvergleich-rund/, 'Plugin-Baustein auf der Website');
assert.match(html, /<script src="\/plugin-assets\/vergleich\/assets\/bildvergleich\.js\?v=1\.1\.0" defer><\/script>/);
assert.match(antwort.headers()['content-security-policy'] ?? '', /script-src 'self'/);
const css = await page.request.get(origin + '/plugin-assets/vergleich/assets/bildvergleich.css');
assert.equal(css.status(), 200);
}
// Einstellungen des Plugins: aus dem Schema erzeugt, gespeichert, auf der Website sichtbar.
await page.goto(origin + '/admin/plugins');
await page.getByLabel('Standardbeschriftung vorher', { exact: true }).fill('Alt');
await page.getByLabel('Griff des Reglers', { exact: true }).selectOption('punkt');
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
await page.locator('.notice.ok').filter({ hasText: 'Einstellungen' }).waitFor();
assert.match(await (await page.request.get(origin + '/')).text(), /bildvergleich-griff-punkt/);
// Die eigene Adresse des Plugins antwortet als JSON mit den Einstellungen.
{
const daten = await page.request.get(origin + '/vergleich/daten');
assert.equal(daten.status(), 200);
assert.equal((await daten.json()).labels.before, 'Alt');
}
// Die Seitenvorlage des Plugins im Werkzeugkasten, der Container mit seiner Kinderregel.
await page.goto(origin + '/admin/pages/test-home');
await tab('Struktur');
await page.locator('#structure [data-block-id="headline"]').waitFor();
await tab('Elemente');
await page.locator('#presets [data-preset="plugin:vergleich:abschnitt"]').click();
await page.locator('#properties-title').filter({ hasText: 'Sektion' }).waitFor();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.waitForFunction(() => document.querySelector('#preview').contentDocument.querySelectorAll('.block-bildvergleich').length >= 2);
await treeSelect('headline');
await addFromLibrary('[data-add="vergleich.galerie"]');
await page.locator('#properties-title').filter({ hasText: 'Vergleichsgalerie' }).waitFor();
// Ein Bildvergleich landet in der Galerie; ein Text passt nicht hinein und wird daneben eingefuegt.
await addFromLibrary('[data-add="vergleich.bildvergleich"]');
await page.locator('#properties-title').filter({ hasText: 'Bildvergleich' }).waitFor();
await page.waitForFunction(() => document.querySelector('#preview').contentDocument.querySelector('.block-vergleichsgalerie .block-bildvergleich') !== null);
await treeSelect(await page.evaluate(() => document.querySelector('#preview').contentDocument.querySelector('.block-vergleichsgalerie').id));
await addFromLibrary('[data-add="text"]');
await page.waitForFunction(() => {
const doc = document.querySelector('#preview').contentDocument;
return doc.querySelector('.block-vergleichsgalerie .block-text') === null && doc.querySelectorAll('.block-text').length > 0;
});
await page.locator('#undo').click();
await page.locator('#undo').click(); await page.locator('#undo').click(); await page.locator('#undo').click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await page.screenshot({ path: 'reports/editor/plugin-container.png', fullPage: true });
// Aus: der Baustein bleibt im Dokument, die Website zeigt den Kommentar, der Editor den Platzhalter.
await page.goto(origin + '/admin/plugins');
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Ausschalten', exact: true }).click();
await page.locator('.notice.ok').filter({ hasText: 'ausgeschaltet' }).waitFor();
assert.match(await (await page.request.get(origin + '/')).text(), /<!-- Block [^ ]+ \(vergleich\.bildvergleich\) wird von dieser Installation nicht dargestellt\. -->/);
await page.goto(origin + '/admin/pages/test-home');
await tab('Struktur');
await page.locator('#structure [data-block-id="headline"]').waitFor();
await page.locator('#structure').getByText('Baustein vergleich.bildvergleich').first().waitFor();
await page.goto(origin + '/admin/plugins');
await page.locator('article').filter({ hasText: 'Bildvergleich' }).getByRole('button', { name: 'Einschalten', exact: true }).click();
await page.locator('.notice.ok').waitFor();
checks.push({ name: 'plugin switched on, block inserted from the toolbox, schema panel, assets on the public page, switched off keeps the block', passed: true });
// Every management screen, not just the page list: they carry tables, disclosures and now the
// media grid, and none of that was ever checked automatically before.
for (const [name, path] of [
@@ -604,7 +1006,7 @@ try {
await visitorPage.getByRole('button', { name: 'Inhalt laden und einwilligen', exact: true }).click();
await visitorPage.locator('iframe').waitFor({ state: 'attached' });
assert.equal(await visitorPage.locator('.consent-bar').count(), 0, 'A decision is a decision.');
assert.equal(await visitorPage.locator('a[href="/einwilligung"]').count(), 1, 'Revoking has to stay reachable.');
assert.equal(await visitorPage.locator('.legal-bar').count(), 0, 'Footer links are placed by the author (since 1.8.1).');
await visitorPage.screenshot({ path: 'reports/editor/consent-granted.png', fullPage: false });
// And the way back: the consent page can refuse again, which blocks the embed once more.
await visitorPage.goto(origin + '/einwilligung');
@@ -626,8 +1028,9 @@ try {
// Back into the editor. The draft differs from the published state by now, so wait for the
// structure rather than for a particular save status.
await page.goto(origin + '/admin/pages/test-home');
await tab('Struktur');
await page.locator('#structure [data-block-id="headline"]').waitFor();
await page.locator('#structure [data-block-id="headline"]').click();
await treeSelect('headline');
await page.getByRole('button', { name: 'Design', exact: true }).click();
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await axe('desktop editor');
@@ -639,7 +1042,7 @@ try {
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
await axe('mobile editor');
await page.screenshot({ path: 'reports/editor/editor-phone.png', fullPage: true });
await page.locator('#open-media-library').click();
await (await tab('Elemente'), page.locator('#open-media-library').click());
await page.locator('#media-status').filter({ hasText: '2 Bilder' }).waitFor();
await axe('mobile media library');
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
+145
View File
@@ -0,0 +1,145 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
import { mediaProps, upgradeDocument } from '../public/assets/editor-model.js';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/image-alignment'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
// Geometry of the visible image as well as its figure: centering an empty, full-width
// figure alone must not pass while the logo inside it stays at the left edge.
const geometry = figure => {
const img = figure.querySelector('img');
const parent = figure.parentElement, style = getComputedStyle(parent);
const p = parent.getBoundingClientRect(), f = figure.getBoundingClientRect(), i = img.getBoundingClientRect();
const left = p.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft);
const width = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const top = p.top + parseFloat(style.borderTopWidth) + parseFloat(style.paddingTop);
const height = parent.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
return { left: f.left - left, right: left + width - f.right, width: f.width, available: width,
center: i.left + i.width / 2 - left - width / 2, innerCenter: i.left + i.width / 2 - f.left - f.width / 2,
verticalCenter: f.top + f.height / 2 - top - height / 2, imageWidth: i.width, imageHeight: i.height,
align: getComputedStyle(figure).alignSelf };
};
const closeTo = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 2, `${message}: ${actual} vs ${expected}`);
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
upgradeDocument(data.document, 20);
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
const chosen = media.find(item => /teamviewer/i.test(item.title)); assert.ok(chosen);
const original = mediaProps(chosen); original.alt ||= chosen.title;
const small = { ...original, width: 180, height: Math.round(180 * original.height / original.width) };
const image = (id, style, props = original) => ({ id, type: 'image', props: structuredClone(props), style });
const centered = { desktop: { width: '40%', alignSelf: 'center' } };
const container = (id, style, child) => ({ id, type: 'column', style, children: [child] });
const section = { id: 'qa-alignment-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px', padding: { left: '20px', right: '20px' } } }, children: [
container('qa-flow', { desktop: { padding: { left: '18px', right: '18px' } } }, image('qa-center', { desktop: { width: '50%' } })),
image('qa-natural', { desktop: { alignSelf: 'center' } }, small),
image('qa-linked', { desktop: { width: '60%', alignSelf: 'center', margin: { left: '11px', right: '7px' } }, tablet: { alignSelf: 'flex-end' }, mobile: { alignSelf: 'auto' } }, { ...small, click: 'link', href: '/kontakt', newTab: true }),
container('qa-grid', { desktop: { display: 'grid', gridColumns: '1', height: '160px' } }, image('qa-grid-image', centered)),
container('qa-flex-column', { desktop: { display: 'flex', flexDirection: 'column', height: '160px' } }, image('qa-column-image', centered)),
container('qa-flex-row', { desktop: { display: 'flex', height: '160px' } }, image('qa-row-image', centered)),
container('qa-responsive-parent', { desktop: { display: 'flex', height: '160px' }, tablet: { flexDirection: 'column' }, mobile: { display: 'block' } }, image('qa-responsive-image', centered)),
image('qa-zoom', { desktop: { maxWidth: '300px', alignSelf: 'center' } }, { ...small, click: 'vergroessern' }),
] };
data.document.blocks.unshift(section);
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
assert.equal(created.status(), 200, await created.text());
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
const select = async () => {
await page.locator('#structure [data-block-id="qa-center"]').click(); await page.locator('[data-panel="design"]').click();
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Layout$/ }) });
if (await group.getAttribute('open') === null) await group.locator('summary').click();
};
const preview = () => page.frameLocator('#preview');
const aligned = async value => {
await page.waitForFunction(value => {
const doc = document.querySelector('#preview')?.contentDocument, figure = doc?.getElementById('qa-center');
return figure && doc.defaultView.getComputedStyle(figure).alignSelf === value;
}, value);
return preview().locator('#qa-center').evaluate(geometry);
};
await select(); closeTo((await aligned('auto')).left, 0, 'Initial left edge');
await page.locator('#style-alignSelf').selectOption('center');
closeTo((await aligned('center')).center, 0, 'Center in normal column');
await page.locator('#undo').click(); closeTo((await aligned('auto')).left, 0, 'Undo centering');
await page.locator('#redo').click(); closeTo((await aligned('center')).center, 0, 'Redo centering');
await page.locator('#style-alignSelf').focus(); await page.locator('#style-alignSelf').press('ArrowDown');
closeTo((await aligned('flex-end')).right, 0, 'Keyboard end alignment');
await page.locator('#style-alignSelf').selectOption('flex-start'); closeTo((await aligned('flex-start')).left, 0, 'Start alignment');
await page.locator('#style-alignSelf').selectOption('center'); await aligned('center');
checks.push('actual Design control: center, start, end, keyboard, undo and redo in a normal padded column');
const checkLayout = async (root, device) => {
for (const id of ['qa-center', 'qa-natural', 'qa-grid-image', 'qa-column-image', 'qa-zoom']) {
closeTo((await root.locator(`#${id}`).evaluate(geometry)).center, 0, `${device} ${id} centered`);
}
const normal = await root.locator('#qa-center').evaluate(geometry);
closeTo(normal.width / normal.available * 100, 50, 'Percentage width preserved');
const natural = await root.locator('#qa-natural').evaluate(geometry);
closeTo(natural.imageWidth, 180, 'Natural logo is not stretched');
const row = await root.locator('#qa-row-image').evaluate(geometry);
closeTo(row.left, 0, 'Flex row keeps its main-axis position'); closeTo(row.verticalCenter, 0, 'Flex row retains vertical centering');
const changing = await root.locator('#qa-responsive-image').evaluate(geometry);
closeTo(device === 'desktop' ? changing.left : changing.center, 0, 'Responsive parent layout');
const linked = await root.locator('#qa-linked').evaluate(geometry);
if (device === 'desktop') closeTo(linked.center, 0, 'Linked image centered');
if (device === 'tablet') { closeTo(linked.right, 0, 'Tablet figure at end'); const img = await root.locator('#qa-linked img').boundingBox(); const fig = await root.locator('#qa-linked').boundingBox(); closeTo(img.x + img.width, fig.x + fig.width, 'Small linked image at end'); }
if (device === 'mobile') { closeTo(linked.left, 11, 'Auto restores authored margin'); closeTo(linked.innerCenter, -(linked.width - linked.imageWidth) / 2, 'Auto clears inherited internal alignment'); }
assert.equal(await root.locator('#qa-linked a').getAttribute('target'), '_blank');
closeTo(normal.imageWidth / normal.imageHeight, original.width / original.height, 'Aspect ratio preserved');
};
for (const device of ['desktop', 'tablet', 'mobile']) {
await page.locator(`[data-viewport="${device}"]`).click(); await aligned('center');
await checkLayout(preview(), device);
}
await page.locator('[data-viewport="desktop"]').click(); await aligned('center');
await preview().locator('#qa-flow').screenshot({ path: `${report}/centered-column.png` });
await page.locator('#style-alignSelf').scrollIntoViewIfNeeded(); await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
checks.push('preview: natural, percentage and maximum widths; links/zoom; grid and flex axes; responsive parent and auto reset');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload(); await select(); closeTo((await aligned('center')).center, 0, 'Reload preserves alignment');
const stored = await (await context.request.get(api)).json();
const block = stored.document.blocks[0].children[0].children[0];
assert.deepEqual(block.props, original); assert.deepEqual(block.style, { desktop: { width: '50%', alignSelf: 'center' } });
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
for (const [width, device] of [[1600, 'desktop'], [900, 'tablet'], [390, 'mobile']]) {
await publicPage.setViewportSize({ width, height: 1000 }); await publicPage.goto(origin);
await checkLayout(publicPage, device);
await publicPage.locator('#qa-alignment-section').screenshot({ path: `${report}/published-${device}.png` });
}
await plain.close(); checks.push('save/reload, unchanged media metadata, published desktop/tablet/mobile with JavaScript disabled');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Image alignment checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+124
View File
@@ -0,0 +1,124 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
import { mediaProps } from '../public/assets/editor-model.js';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/image-size'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
data.document.blocks.unshift({ id: 'qa-image-size-section', type: 'section', layout: { width: 'full', tone: 'paper' }, style: { desktop: { maxWidth: '800px' } }, children: [{ id: 'qa-image-size', type: 'image', props: original }] });
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
assert.equal(created.status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
const select = async () => { await page.locator('#structure [data-block-id="qa-image-size"]').click(); await page.locator('[data-panel="content"]').click(); };
await select();
const exact = () => page.getByLabel('Bildbreite (%)', { exact: true });
const slider = () => page.getByRole('slider', { name: 'Bildbreite Schieberegler' });
const width = async percent => page.waitForFunction(percent => {
const doc = document.querySelector('#preview')?.contentDocument;
const figure = doc?.getElementById('qa-image-size'); if (!figure) return false;
const parent = figure.parentElement; const style = doc.defaultView.getComputedStyle(parent);
const available = parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
return Math.abs(figure.getBoundingClientRect().width / available * 100 - percent) < .1;
}, percent);
const saved = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
const changeDevice = async device => page.locator(`[data-viewport="${device}"]`).click();
assert.equal(await exact().inputValue(), '');
await exact().fill('50'); await width(50);
assert.equal(await exact().evaluate(input => input === document.activeElement), true);
await exact().blur();
await slider().focus(); await slider().press('ArrowRight'); await width(50.1);
await page.locator('#undo').click(); await width(50); await page.locator('#redo').click(); await width(50.1);
const box = await slider().boundingBox();
await page.mouse.move(box.x + box.width * .2, box.y + box.height / 2); await page.mouse.down();
await page.mouse.move(box.x + box.width * .6, box.y + box.height / 2, { steps: 8 }); await page.mouse.up();
const dragged = Number(await exact().inputValue()); assert.ok(dragged > 50.1); await width(dragged);
await page.locator('#undo').click(); await width(50.1);
await page.locator('#redo').click(); await width(dragged);
await exact().fill('0'); assert.equal(await exact().getAttribute('aria-invalid'), 'true'); await width(dragged);
await exact().fill('50'); await width(50); await exact().blur();
checks.push('number, keyboard and drag update preview; one undo per gesture; invalid values do not change the document');
await changeDevice('tablet'); assert.equal(await exact().inputValue(), '50');
assert.match(await page.locator('#image-width-state').innerText(), /Von Desktop übernommen/);
await exact().fill('75'); await width(75); await exact().blur();
await changeDevice('mobile'); assert.equal(await exact().inputValue(), '75');
await exact().fill('30'); await width(30); await exact().blur();
await page.locator('#image-width-reset').click(); await width(75);
assert.match(await page.locator('#image-width-state').innerText(), /Von Tablet übernommen/);
await page.locator('#undo').click(); await width(30);
await changeDevice('desktop'); assert.equal(await exact().inputValue(), '50'); await width(50);
await saved(); await page.reload(); await select(); assert.equal(await exact().inputValue(), '50'); await width(50);
const stored = await (await context.request.get(api)).json();
const image = stored.document.blocks[0].children[0];
assert.deepEqual(image.style, { desktop: { width: '50%' }, tablet: { width: '75%' }, mobile: { width: '30%' } });
assert.deepEqual(image.props, original, 'original dimensions, media variants, caption and alt text are unchanged');
checks.push('device inheritance, reset, undo, save/reload and unchanged image metadata');
await page.locator('[data-panel="design"]').click();
const size = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Größe$/ }) });
if (!(await size.getAttribute('open'))) await size.locator('summary').click();
assert.equal(await page.locator('#style-width').inputValue(), '50');
await page.locator('#style-width').fill('60'); await page.locator('#style-width').blur();
await page.locator('[data-panel="content"]').click(); assert.equal(await exact().inputValue(), '60'); await width(60);
await page.screenshot({ path: `${report}/editor-desktop.png`, fullPage: true });
for (const viewport of [1600, 390]) {
await page.setViewportSize({ width: viewport, height: 1050 });
await exact().scrollIntoViewIfNeeded();
const results = await new AxeBuilder({ page }).include('.image-width-control').analyze();
assert.deepEqual(results.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await page.locator('.image-width-control').evaluate(el => el.scrollWidth <= el.clientWidth), true);
await page.locator('.image-width-control').screenshot({ path: `${report}/control-${viewport}.png` });
}
await page.setViewportSize({ width: 1600, height: 1050 }); await saved();
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
for (const [viewport, percent] of [[1600, 60], [900, 75], [390, 30]]) {
await publicPage.setViewportSize({ width: viewport, height: 900 }); await publicPage.goto(origin);
const geometry = await publicPage.locator('#qa-image-size').evaluate(async figure => {
const img = figure.querySelector('img'); await img.decode();
const parent = figure.parentElement, style = getComputedStyle(parent);
return { figure: figure.getBoundingClientRect().width, available: parent.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), width: img.getBoundingClientRect().width, height: img.getBoundingClientRect().height };
});
assert.ok(Math.abs(geometry.figure / geometry.available * 100 - percent) < .1);
assert.ok(Math.abs(geometry.width / geometry.height - original.width / original.height) < .02, 'image aspect ratio is preserved');
await publicPage.locator('#qa-image-size-section').screenshot({ path: `${report}/published-${viewport}.png` });
}
await plain.close(); checks.push('shared Design width; desktop/mobile accessible controls; published responsive percentages and aspect ratio without JavaScript');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Image size checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+1 -1
View File
@@ -171,7 +171,7 @@ function kant_checks(string $target): array
$add('Adresse und Prüfsumme eingetragen', '' !== KANT_ARCHIVE_URL && 64 === strlen(KANT_ARCHIVE_SHA256), 'Diese Datei ist unfertig: sie stammt nicht aus einem Release. Lade install.php von der Releaseseite herunter.');
$free = @disk_free_space($target);
$add('Freier Platz', false === $free || $free > 80 * 1024 * 1024, 'Es sind weniger als 80 MB frei. Das Archiv braucht Platz zum Herunterladen und zum Entpacken.');
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
$add('Verzeichnis ist leer genug', !is_file($target.'/public/index.php') && !is_file($target.'/.env') && !is_file($target.'/.env.local'), 'Hier liegt schon eine Installation. Diese Datei überschreibt nichts – bei einem Update ist der Weg das Releasearchiv von Hand oder der Updater in der Verwaltung.');
return $checks;
}
+212
View File
@@ -0,0 +1,212 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
import { mediaProps } from '../public/assets/editor-model.js';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/link-media'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
const media = (await (await context.request.get(origin + '/admin/api/media')).json()).items;
const chosen = media.find(item => /teamviewer/i.test(item.title)) || media.find(item => item.variants.at(-1).width >= 800);
assert.ok(chosen); const original = mediaProps(chosen); original.alt ||= chosen.title;
data.document.blocks.unshift({ id: 'qa-link-section', type: 'section', children: [
{ id: 'qa-link-image', type: 'image', props: { ...original, href: '/impressum', click: 'link' } },
{ id: 'qa-empty-image', type: 'image', props: { ...original, click: 'keine' } },
{ id: 'qa-link-button', type: 'button', props: { text: 'QA Neuer Tab', href: '/impressum' } },
{ id: 'qa-link-text', type: 'text', props: { text: '<p>Textlink prüfen</p>' } },
{ id: 'qa-link-gallery', type: 'gallery', children: [{ id: 'qa-gallery-image', type: 'image', props: original }] },
] });
data.document.blocks.unshift({ id: 'qa-background', type: 'section', style: { desktop: { backgroundImage: chosen.id, minHeight: '200px' } }, children: [] });
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
assert.equal(created.status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
const frame = page.frameLocator('#preview');
const select = async (id, panel = 'content') => { await page.locator(`#structure [data-block-id="${id}"]`).click(); await page.locator(`[data-panel="${panel}"]`).click(); };
const thumbnail = () => page.locator('#properties > .selected-media-preview img');
const background = async hasImage => page.waitForFunction(hasImage => {
const doc = document.querySelector('#preview')?.contentDocument, block = doc?.getElementById('qa-background');
return block && doc.defaultView.getComputedStyle(block).backgroundImage.includes('url(') === hasImage;
}, hasImage);
const save = async () => { await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor(); };
await select('qa-empty-image');
const clickAction = () => page.getByLabel('Beim Klick', { exact: true });
await page.locator('#property-href').waitFor({ state: 'hidden' });
const nativeSelect = await clickAction().elementHandle();
await clickAction().click(); await page.keyboard.press('End'); await page.keyboard.press('Enter');
await page.getByLabel('Linkziel', { exact: true }).waitFor();
assert.equal(await nativeSelect.evaluate(el => el.isConnected), true, 'Keep the native dropdown mounted while changing the action');
await nativeSelect.dispose();
await page.getByLabel('Linkziel', { exact: true }).fill('/kontakt');
await clickAction().focus(); await page.keyboard.press('Home');
await page.locator('#property-href').waitFor({ state: 'hidden' });
await page.keyboard.press('End'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
await clickAction().selectOption('keine');
await clickAction().evaluate(el => { el.value = 'link'; el.dispatchEvent(new Event('change', { bubbles: true })); });
await page.getByLabel('Linkziel', { exact: true }).waitFor();
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
await page.locator('#undo').click();
assert.equal(await clickAction().inputValue(), 'keine'); await page.locator('#property-href').waitFor({ state: 'hidden' });
await page.locator('#redo').click();
assert.equal(await clickAction().inputValue(), 'link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
await clickAction().selectOption('vergroessern');
await page.locator('#property-href').waitFor({ state: 'hidden' }); await page.locator('#property-newTab').waitFor({ state: 'hidden' });
await frame.locator('#qa-empty-image a.image-zoom').waitFor();
await clickAction().selectOption('link'); await page.getByLabel('Linkziel', { exact: true }).waitFor();
await page.locator('.image-link-options').screenshot({ path: `${report}/image-link-fields.png` });
checks.push('fresh image URL appears after native dropdown and keyboard selection; URL retained; change-only event supported');
await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
assert.ok(await thumbnail().evaluate(img => img.naturalWidth > 0));
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
await frame.locator('#qa-link-image a[target="_blank"][rel="noopener"]').waitFor();
await page.getByLabel('Beim Klick', { exact: true }).selectOption('keine');
await frame.locator('#qa-link-image a').waitFor({ state: 'detached' });
await page.getByLabel('Beim Klick', { exact: true }).focus();
await page.getByLabel('Beim Klick', { exact: true }).selectOption('link');
assert.equal(await page.getByLabel('Beim Klick', { exact: true }).evaluate(el => el === document.activeElement), true);
await page.getByLabel('Linkziel', { exact: true }).waitFor();
await page.getByLabel('Bildadresse', { exact: true }).fill('/nicht-vorhanden.png');
await page.getByLabel('Bildadresse', { exact: true }).blur();
await page.locator('#properties > .selected-media-preview').getByText(/nicht verfügbar/).waitFor();
await page.locator('#undo').click(); await thumbnail().waitFor({ state: 'visible' });
await page.getByRole('button', { name: 'Bild entfernen', exact: true }).click();
await frame.locator('#qa-link-image').waitFor({ state: 'detached' });
assert.equal((await context.request.get(origin + '/admin/api/media/' + chosen.id)).status(), 200);
await page.locator('#undo').click(); await select('qa-link-image'); await thumbnail().waitFor({ state: 'visible' });
await page.locator('.right-panel').screenshot({ path: `${report}/image-inspector.png` });
await select('qa-link-gallery'); await page.locator('.selected-media-list img').waitFor({ state: 'visible' });
await page.locator('.selected-media-list').getByRole('button', { name: /entfernen$/ }).click();
await frame.locator('#qa-gallery-image').waitFor({ state: 'detached' });
await page.locator('#undo').click(); await frame.locator('#qa-gallery-image').waitFor();
checks.push('image and gallery thumbnails, missing-image feedback, remove/undo without deleting media; image link/no-action toggle');
await select('qa-background', 'design');
const group = page.locator('.design-group').filter({ has: page.locator('summary').filter({ hasText: /^Hintergrund$/ }) });
if (!(await group.getAttribute('open'))) await group.locator('summary').click();
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' });
await page.locator('[data-viewport="tablet"]').click();
await group.locator('.selected-media-preview img').waitFor({ state: 'visible' }); await background(true);
await group.getByRole('button', { name: 'Bild entfernen', exact: true }).click(); await background(false);
assert.equal(await page.locator('#style-backgroundImage').evaluate(el => el === document.activeElement), true);
await page.locator('[data-viewport="mobile"]').click(); await background(false);
await page.locator('[data-viewport="desktop"]').click(); await background(true);
await page.locator('[data-viewport="tablet"]').click();
await page.getByRole('button', { name: 'Hintergrundbild zurücksetzen', exact: true }).click(); await background(true);
await page.locator('#undo').click(); await background(false);
await page.locator('[data-viewport="desktop"]').click();
checks.push('inherited background thumbnail, remove on tablet/mobile, preserve desktop, reset and undo');
await select('qa-link-button'); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
await frame.locator('#qa-link-button[target="_blank"]').waitFor();
await select('qa-link-text');
const surface = page.locator('.rt-surface');
await surface.evaluate(el => { const range = document.createRange(); range.selectNodeContents(el.querySelector('p')); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.focus(); });
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
await page.getByLabel('Linkziel', { exact: true }).fill('/impressum');
await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
await frame.locator('#qa-link-text a[target="_blank"][rel="noopener"]').waitFor();
const editExisting = async () => {
await surface.locator('a').evaluate(el => { const range = document.createRange(); range.selectNodeContents(el); range.collapse(true); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); el.closest('[contenteditable]').focus(); });
await page.getByRole('button', { name: 'Link setzen oder entfernen' }).click();
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/impressum');
};
await editExisting(); assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
await page.getByLabel('In neuem Tab öffnen', { exact: true }).uncheck(); await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
await frame.locator('#qa-link-text a:not([target])').waitFor();
await editExisting(); await page.getByLabel('In neuem Tab öffnen', { exact: true }).check();
await page.locator('.rt-link').screenshot({ path: `${report}/text-link.png` });
const audit = await new AxeBuilder({ page }).exclude('#preview').analyze(); assert.deepEqual(audit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
await page.getByRole('button', { name: 'Linkziel übernehmen' }).click();
await save(); await page.reload(); await select('qa-link-image');
assert.equal(await page.getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
await thumbnail().waitFor({ state: 'visible' });
await select('qa-empty-image');
assert.equal(await clickAction().inputValue(), 'link');
assert.equal(await page.getByLabel('Linkziel', { exact: true }).inputValue(), '/kontakt');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
checks.push('button and rich-text target creation/edit/uncheck, safe attributes, save/reload and publication');
await page.goto(origin + '/admin/menus');
await page.getByText('Eigener Link', { exact: true }).filter({ has: page.locator('xpath=self::summary') }).click();
await page.locator('#link-label').fill('QA Menülink'); await page.locator('#link-url').fill('/impressum');
await page.locator('#link-new-tab').check(); await page.locator('#menu-add-url').click();
const entry = () => page.locator('#menu-items > li').last();
await entry().locator('summary').click();
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
await entry().getByLabel('In neuem Tab öffnen', { exact: true }).uncheck();
await page.locator('#menu-undo').click();
await entry().locator('summary').click();
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
await page.getByRole('button', { name: 'Menü speichern', exact: true }).click();
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
await page.reload(); await entry().locator('summary').click();
assert.equal(await entry().getByLabel('In neuem Tab öffnen', { exact: true }).isChecked(), true);
await entry().screenshot({ path: `${report}/menu-link.png` });
checks.push('menu creation, checkbox undo, save and reload');
const publicPage = await context.newPage(); await publicPage.goto(origin);
for (const selector of ['#qa-link-image a', '#qa-link-button', '#qa-link-text a']) {
assert.equal(await publicPage.locator(selector).getAttribute('target'), '_blank');
assert.equal(await publicPage.locator(selector).getAttribute('rel'), 'noopener');
}
assert.equal(await publicPage.getByRole('link', { name: 'QA Menülink', exact: true }).getAttribute('target'), '_blank');
assert.equal(await publicPage.locator('#qa-empty-image a').getAttribute('href'), '/kontakt');
const popupPromise = publicPage.waitForEvent('popup'); await publicPage.locator('#qa-link-button').click();
const popup = await popupPromise; await popup.waitForLoadState();
assert.equal(new URL(popup.url()).pathname, '/impressum'); assert.equal(await popup.evaluate(() => window.opener), null); await popup.close();
assert.equal(new URL(publicPage.url()).pathname, '/');
const publicAudit = await new AxeBuilder({ page: publicPage }).analyze();
assert.deepEqual(publicAudit.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
await publicPage.close();
const noScriptContext = await browser.newContext({ javaScriptEnabled: false });
const noScript = await noScriptContext.newPage();
for (const [width, hasImage] of [[1600, true], [900, false], [390, false]]) {
await noScript.setViewportSize({ width, height: 1000 }); await noScript.goto(origin);
assert.equal(await noScript.locator('#qa-background').evaluate(el => getComputedStyle(el).backgroundImage.includes('url(')), hasImage);
assert.equal(await noScript.locator('#qa-link-image a').getAttribute('target'), '_blank');
}
await noScriptContext.close();
await page.goto(`${origin}/admin/pages/${fixture.homeId}`); await select('qa-link-image');
for (const viewport of [1600, 390]) {
await page.setViewportSize({ width: viewport, height: 1050 }); await thumbnail().scrollIntoViewIfNeeded();
const result = await new AxeBuilder({ page }).exclude('#preview').analyze();
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await page.locator('.selected-media-preview').evaluate(el => el.scrollWidth <= el.clientWidth), true);
await page.locator('#properties').screenshot({ path: `${report}/image-properties-${viewport}.png` });
}
checks.push('public links really open a separate tab without opener; public page and desktop/mobile image controls pass axe; background removal persists without JavaScript at 1600/900/390 px');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Link and media checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+162
View File
@@ -0,0 +1,162 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...environment, SITE_ORIGIN: origin } });
const report = 'reports/live-search'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
page = await context.newPage(); page.setDefaultTimeout(15000);
page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const search = () => page.locator('[data-live-search] input[type="search"]');
const rows = () => page.locator('.rows article h3 a');
const settled = () => page.waitForFunction(() => /^\d+ Treffer/.test(document.querySelector('#search-status')?.textContent) && !document.querySelector('#list-results').hasAttribute('aria-busy'));
const type = async value => { await search().fill(value); await settled(); };
const count = async expected => { await settled(); assert.equal(await rows().count(), expected); };
const mark = async () => page.evaluate(() => { window.searchDocumentMarker = 'same-document'; });
const sameDocument = async () => assert.equal(await page.evaluate(() => window.searchDocumentMarker), 'same-document');
const next = () => page.locator('.pager').first().getByRole('link', { name: 'Weiter' });
await page.goto(origin + '/admin/posts'); await mark();
assert.equal(await rows().count(), 20);
await next().click(); await count(9); await sameDocument();
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
await page.goBack(); await count(20);
await page.goForward(); await count(9);
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); await count(29);
assert.equal(new URL(page.url()).searchParams.has('seite'), false);
await type('nichtvorhandener-qa-begriff'); await count(0); await sameDocument();
assert.equal(await search().evaluate(input => input === document.activeElement), true);
assert.equal(await page.locator('.create-form input[name="_list_query"]').inputValue(), 'nichtvorhandener-qa-begriff');
await type(''); await count(29);
await page.locator('[data-search-reset]').click(); await count(20);
assert.equal(await page.getByLabel('Pro Seite', { exact: true }).inputValue(), '20');
await page.getByLabel('Status', { exact: true }).selectOption('trash'); await count(0);
await page.goBack(); await count(20);
assert.equal(await page.getByLabel('Status', { exact: true }).inputValue(), 'all');
checks.push('posts: typing, focus, filter, page size, pagination, reset, back/forward and form context without navigation');
// Let an old server response arrive after the latest query has already completed.
let delayed;
const oldRequest = new Promise(resolve => { delayed = resolve; });
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'old-delayed-search', async route => {
const response = await route.fetch(); delayed();
await new Promise(resolve => setTimeout(resolve, 800));
await route.fulfill({ response }).catch(() => {});
});
await search().fill('old-delayed-search'); await oldRequest;
await type(''); await count(20); await page.waitForTimeout(1000); await count(20);
await page.unrouteAll({ behavior: 'wait' });
let requests = 0;
const countRequests = request => { if (request.headers()['x-requested-with'] === 'XMLHttpRequest') requests++; };
page.on('request', countRequests);
await search().pressSequentially('fast-query', { delay: 25 }); await count(0);
assert.equal(requests, 1, 'rapid typing is debounced');
page.off('request', countRequests);
await type(''); await count(20);
await search().dispatchEvent('compositionstart'); await search().fill('ime-query');
await page.waitForTimeout(400); assert.equal(await rows().count(), 20);
await search().dispatchEvent('compositionend'); await count(0);
await type(''); await count(20);
checks.push('stale responses ignored; rapid input debounced; IME composition waits for completed text');
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'error-query', route => route.fulfill({ status: 500, body: 'Unavailable' }));
await search().fill('error-query'); await page.locator('#search-error').waitFor({ state: 'visible' });
assert.equal(await rows().count(), 20); assert.equal(await search().inputValue(), 'error-query');
await page.unrouteAll({ behavior: 'wait' }); await page.getByRole('button', { name: 'Erneut suchen' }).click(); await count(0);
assert.equal(await page.locator('#search-error').isVisible(), false);
await type(''); await count(20);
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'expired-session', route => route.fulfill({ status: 401, body: '' }));
await search().fill('expired-session'); await page.getByRole('alert').filter({ hasText: 'erneut anmelden' }).waitFor();
assert.equal(await rows().count(), 20); await page.unrouteAll({ behavior: 'wait' });
await type(''); await count(20);
checks.push('server error and expired session preserve results and input; retry succeeds');
await page.goto(origin + '/admin'); await mark();
await type('Impressum'); await count(1); await sameDocument();
assert.match(await rows().first().innerText(), /Impressum/);
await page.screenshot({ path: `${report}/pages-search.png`, fullPage: true });
await page.reload(); assert.equal(await search().inputValue(), 'Impressum'); assert.equal(await rows().count(), 1);
await type(''); await count(4);
const entry = page.locator('.rows article').filter({ hasText: 'Impressum' }).first();
await entry.locator('summary').first().click();
await entry.getByRole('combobox', { name: 'Übergeordnete Seite (optional)' }).fill('Datenschutz');
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().waitFor();
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().click();
assert.ok(await entry.locator('.picker-value').inputValue());
let pending;
const pendingRequest = new Promise(resolve => { pending = resolve; });
await page.route(url => url.pathname === '/admin' && url.searchParams.get('q') === 'late-edit-search', async route => {
const response = await route.fetch(); pending();
await new Promise(resolve => setTimeout(resolve, 600));
await route.fulfill({ response }).catch(() => {});
});
await search().fill('late-edit-search'); await pendingRequest;
await entry.getByLabel('Titel für Suchergebnisse').fill('Ungespeicherte Bearbeitung');
await page.waitForTimeout(800);
assert.equal(await entry.getByLabel('Titel für Suchergebnisse').inputValue(), 'Ungespeicherte Bearbeitung');
assert.match(await page.locator('#search-status').innerText(), /angehalten/);
await page.unrouteAll({ behavior: 'wait' });
checks.push('pages: live title search, reloadable URLs, reinitialized parent picker after AJAX');
await page.goto(origin + '/admin/media'); await mark();
const library = await (await context.request.get(origin + '/admin/api/media')).json();
const target = library.items.filter(item => !item.archived).at(-1); assert.ok(target, 'pilot has media');
await type(target.title);
assert.ok((await page.locator('.library-body strong').allTextContents()).includes(target.title));
assert.equal(await page.locator('form[action$="/media/upload"] input[name="q"]').inputValue(), target.title);
await sameDocument();
await page.screenshot({ path: `${report}/media-search.png`, fullPage: true });
await page.getByLabel('Status', { exact: true }).selectOption('1'); await settled();
assert.equal(new URL(page.url()).searchParams.get('archiv'), '1');
await type('nichtvorhandenes-bild'); assert.equal(await page.locator('.library-tile').count(), 0);
await type(''); assert.ok(await page.locator('.library-tile').count());
const older = page.locator('.pager a[rel="next"]');
if (await older.count()) { await older.click(); await settled(); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); await sameDocument(); }
checks.push('media: global title search, archive filter, empty/reset states, pagination, upload context');
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 900 });
for (const route of ['/admin', '/admin/posts', '/admin/media']) {
await page.goto(origin + route); await type('nichtvorhandener-begriff');
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
const audit = await new AxeBuilder({ page }).analyze();
assert.deepEqual(audit.violations.map(v => ({ id: v.id, targets: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
if (width === 390) await page.screenshot({ path: `${report}/${route.split('/').pop()}-mobile.png`, fullPage: true });
}
}
const plain = await browser.newContext({ javaScriptEnabled: false, storageState: await context.storageState() });
const fallback = await plain.newPage(); await fallback.goto(origin + '/admin');
await fallback.getByLabel('Seiten durchsuchen').fill('Impressum');
await fallback.getByRole('button', { name: 'Anwenden', exact: true }).click();
assert.equal(await fallback.locator('.rows article h3 a').count(), 1);
await plain.close(); checks.push('desktop/mobile axe and overflow checks; HTML fallback without JavaScript');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Live search browser checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+188
View File
@@ -0,0 +1,188 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const hash = async () => createHash('sha256').update(await readFile('var/kantit.db')).digest('hex');
const before = await hash();
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { env, windowsHide: true, stdio: 'ignore' });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/motion'; await mkdir(report, { recursive: true });
const results = [], errors = [];
let browser;
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch(process.env.MOTION_BROWSER_CHANNEL ? { channel: process.env.MOTION_BROWSER_CHANNEL } : {});
const systemContext = await browser.newContext({ reducedMotion: null });
const systemPage = await systemContext.newPage();
await systemPage.goto(origin);
const systemMotion = await systemPage.evaluate(() => ({ reduced: matchMedia('(prefers-reduced-motion: reduce)').matches, userAgent: navigator.userAgent }));
console.log('Default browser motion preference:', JSON.stringify(systemMotion));
await systemContext.close();
for (const width of [1600, 390]) {
const context = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'no-preference' });
await context.addInitScript(() => {
window.motionCalls = [];
const animate = Element.prototype.animate;
Element.prototype.animate = function (frames, options) {
const animation = animate.call(this, frames, options);
window.motionCalls.push({ id: this.id, frames, options, scrollY, top: this.getBoundingClientRect().top });
return animation;
};
});
const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin); await page.waitForTimeout(1200);
const inventory = await page.locator('[class*="fx-entrance-"]').evaluateAll(nodes => nodes.map(node => ({ id: node.id, classes: node.className })));
assert.ok(inventory.length > 0, 'Pilot has entrance effects');
assert.equal(await page.locator('script[src*="motion.js"]').count(), 1);
const hoverTarget = page.locator('#leistung-app');
await hoverTarget.scrollIntoViewIfNeeded(); await page.waitForTimeout(800);
await hoverTarget.hover(); await page.waitForTimeout(350);
const hovered = await hoverTarget.evaluate(node => ({ transform: getComputedStyle(node).transform, matches: node.matches(':hover'), classes: node.className }));
console.log('Pilot hover:', width, JSON.stringify(hovered));
assert.equal(hovered.transform, 'matrix(1, 0, 0, 1, 0, -4)', 'Saved lift effect changes the rendered transform');
const target = page.locator('#abschluss-cta');
assert.equal(await page.evaluate(() => motionCalls.some(call => call.id === 'abschluss-cta')), false, 'Below-fold section must wait for scrolling');
assert.equal(await target.evaluate(node => node.style.opacity), '0', 'Below-fold block waits out of sight, as in Flatsome');
await target.scrollIntoViewIfNeeded();
await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'));
const calls = await page.evaluate(() => motionCalls);
assert.equal(calls.find(call => call.id === 'abschluss-cta').frames[0].transform, 'translateY(40px)');
assert.equal(calls.find(call => call.id === 'abschluss-cta').frames[0].opacity, 0, 'Entrance fades in from invisible, as in Flatsome');
await page.waitForTimeout(800);
await page.evaluate(() => scrollTo(0, 0)); await target.scrollIntoViewIfNeeded(); await page.waitForTimeout(100);
assert.equal(await page.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 1, 'Entrance runs once');
await page.screenshot({ path: `${report}/pilot-${width}.png` });
// Exercise both existing choices on the real pilot element without editing saved content.
for (const direction of ['left', 'right']) {
await page.evaluate(async direction => {
const node = document.getElementById('abschluss-cta');
node.classList.remove('fx-entrance-rise', 'fx-entrance-left', 'fx-entrance-right');
node.classList.add(`fx-entrance-${direction}`);
const { initMotion } = await import(document.querySelector('script[src*="motion.js"]').src);
window.stopMotionCheck?.(); window.motionCalls = [];
window.stopMotionCheck = initMotion();
}, direction);
await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'));
const actual = await target.evaluate(node => {
const animation = node.getAnimations()[0]; animation.pause(); animation.currentTime = 150;
return { transform: getComputedStyle(node).transform, frames: animation.effect.getKeyframes() };
});
assert.equal(actual.frames[0].transform, direction === 'left' ? 'translateX(-56px)' : 'translateX(56px)');
const offset = Number(actual.transform.split(',')[4]);
assert.ok(direction === 'left' ? offset < 0 : offset > 0, 'Computed movement follows selected direction');
await page.screenshot({ path: `${report}/${direction}-${width}.png` });
await page.evaluate(() => window.stopMotionCheck());
}
// Default setting: the website plays regardless of the browser preference (Flatsome mode).
// Umut's Chrome reports "reduce" without anyone having chosen it; the site must still move.
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.reload(); await target.scrollIntoViewIfNeeded();
await page.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 });
await page.waitForTimeout(900);
assert.equal(await target.evaluate(node => getComputedStyle(node).opacity), '1', 'Block ends fully visible');
await hoverTarget.scrollIntoViewIfNeeded(); await hoverTarget.hover(); await page.waitForTimeout(350);
assert.equal(await hoverTarget.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Default setting keeps the hover lift under a reduce preference');
await page.emulateMedia({ reducedMotion: 'no-preference' });
await context.close();
// Setting "Browservorgabe beachten": <html class="motion-respect">. Simulated by adding the
// class before any script runs, exactly where the template would print it.
const respect = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
await respect.addInitScript(() => {
window.motionCalls = [];
const animate = Element.prototype.animate;
Element.prototype.animate = function (frames, options) {
const animation = animate.call(this, frames, options);
window.motionCalls.push({ id: this.id, frames, options, scrollY, top: this.getBoundingClientRect().top });
return animation;
};
new MutationObserver((_, observer) => {
if (!document.documentElement) return;
document.documentElement.classList.add('motion-respect'); observer.disconnect();
}).observe(document, { childList: true });
});
const respectPage = await respect.newPage(); respectPage.on('pageerror', error => errors.push(error.message));
const respectTarget = respectPage.locator('#abschluss-cta'), respectHover = respectPage.locator('#leistung-app');
await respectPage.goto(origin); await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(800);
assert.equal(await respectPage.evaluate(() => motionCalls.length), 0, 'Reduced motion suppresses animations when respected');
assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1');
await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350);
assert.equal(await respectHover.evaluate(node => getComputedStyle(node).transform), 'none', 'Reduced motion suppresses hover movement when respected');
await respectTarget.scrollIntoViewIfNeeded();
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 });
await respectPage.waitForTimeout(900);
await respectPage.emulateMedia({ reducedMotion: 'reduce' });
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
await respectPage.waitForTimeout(100);
assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 1, 'Preference changes do not replay an already seen element');
await respectHover.scrollIntoViewIfNeeded(); await respectHover.hover(); await respectPage.waitForTimeout(350);
assert.equal(await respectHover.evaluate(node => getComputedStyle(node).transform), 'matrix(1, 0, 0, 1, 0, -4)', 'Hover resumes after allowing motion');
// Also cover a page that starts normally, then suppresses motion before reaching its content.
await respectPage.reload(); await respectPage.waitForTimeout(800);
await respectPage.emulateMedia({ reducedMotion: 'reduce' });
await respectTarget.scrollIntoViewIfNeeded(); await respectPage.waitForTimeout(100);
assert.equal(await respectPage.evaluate(() => motionCalls.filter(call => call.id === 'abschluss-cta').length), 0);
assert.equal(await respectTarget.evaluate(node => getComputedStyle(node).opacity), '1', 'A waiting block is shown again when motion is reduced');
await respectPage.emulateMedia({ reducedMotion: 'no-preference' });
await respectPage.waitForFunction(() => motionCalls.some(call => call.id === 'abschluss-cta'), null, { timeout: 3000 });
results.push({ width, inventory, calls, checks: ['scroll trigger', 'once only', 'hidden until visible', 'left/right computed movement', 'saved hover lift', 'default plays under reduce preference', 'motion-respect honours reduce', 'resume without reloading after initial and subsequent reduced motion', 'hover resumes after preference change'] });
await respect.close();
}
const noJs = await browser.newContext({ javaScriptEnabled: false });
const page = await noJs.newPage(); await page.goto(origin);
assert.ok(await page.locator('main').innerText());
assert.equal(await page.locator('#abschluss-cta').evaluate(node => getComputedStyle(node).opacity), '1');
await noJs.close();
const editorContext = await browser.newContext({ reducedMotion: 'reduce', viewport: { width: 1600, height: 1000 } });
const editor = await editorContext.newPage(); editor.on('pageerror', error => errors.push(error.message));
await editor.goto(origin + '/admin/login');
await editor.getByLabel('Benutzername').fill(fixture.username);
await editor.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await editor.getByRole('button', { name: 'Anmelden', exact: true }).click();
await editor.waitForURL(origin + '/admin');
await editor.goto(`${origin}/admin/pages/${fixture.homeId}`);
// With the default setting the note stays away: the site plays anyway, there is nothing to say.
await editor.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await editor.waitForTimeout(300);
assert.equal(await editor.locator('#motion-status').isVisible(), false, 'No motion note while the site ignores the preference');
await editor.close();
// The note appears once the site respects the preference - simulated in the preview frame.
// The preview frame carries the website's decision as a class on its <html>; set it there the
// way the template would, then let the preference change re-evaluate the note.
const editorRespect = await editorContext.newPage(); editorRespect.on('pageerror', error => errors.push(error.message));
await editorRespect.goto(`${origin}/admin/pages/${fixture.homeId}`);
await editorRespect.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
await editorRespect.evaluate(() => document.querySelector('#preview').contentDocument.documentElement.classList.add('motion-respect'));
// Two toggles in one breath cancel out before the change event fires; give each its moment.
await editorRespect.emulateMedia({ reducedMotion: 'no-preference' });
await editorRespect.waitForTimeout(300);
await editorRespect.emulateMedia({ reducedMotion: 'reduce' });
await editorRespect.locator('#motion-status').waitFor({ state: 'visible' });
await editorRespect.screenshot({ path: `${report}/editor-motion-status.png` });
await editorRespect.emulateMedia({ reducedMotion: 'no-preference' });
await editorRespect.locator('#motion-status').waitFor({ state: 'hidden' });
await editorRespect.emulateMedia({ reducedMotion: 'reduce' });
await editorRespect.locator('#motion-status').waitFor({ state: 'visible' });
await editorContext.close();
assert.deepEqual(errors, []);
assert.equal(await hash(), before, 'Original database unchanged');
await writeFile(`${report}/result.json`, JSON.stringify({ systemMotion, results, errors, editorMotionStatus: true, noJavaScript: true, originalUnchanged: true }, null, 2));
console.log(JSON.stringify({ passed: true, devices: results.map(result => result.width), effects: results[0].inventory, report }));
} finally {
await browser?.close(); server.kill();
}
+92
View File
@@ -0,0 +1,92 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/navigation-ux'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
data.document.blocks.push({ id: 'qa-anchor', type: 'section', children: [{ id: 'qa-anchor-title', type: 'heading', props: { text: 'Kontaktbereich', level: 2 } }] });
assert.equal((await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } })).status(), 200);
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
await page.locator('#structure [data-block-id="qa-anchor"]').click();
await page.locator('#property-anchor').fill('#kontakt-check'); await page.locator('#property-anchor').blur();
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const stored = (await (await context.request.get(api)).json()).document;
assert.equal(stored.blocks.at(-1).anchor, 'kontakt-check'); assert.equal(stored.schemaVersion, 21);
checks.push('named jump target created in editor and saved/published with stable block ID');
await page.goto(origin + '/admin/menus');
const rows = page.locator('#menu-items > li');
const originalFirst = await rows.first().locator('[data-key="label"]').inputValue();
const handle = rows.first().locator('[data-menu-drag]');
const start = await handle.boundingBox(), end = await rows.last().boundingBox();
await page.mouse.move(start.x + start.width / 2, start.y + start.height / 2); await page.mouse.down();
await page.mouse.move(start.x + start.width / 2, end.y + end.height - 2, { steps: 12 }); await page.mouse.up();
assert.equal(await rows.last().locator('[data-key="label"]').inputValue(), originalFirst);
await page.locator('#menu-undo').click(); assert.equal(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst);
await rows.first().locator('[data-menu-drag]').focus(); await page.keyboard.press('ArrowDown');
assert.notEqual(await rows.first().locator('[data-key="label"]').inputValue(), originalFirst);
await page.locator('#menu-undo').click();
await rows.first().locator('summary').click();
await rows.first().locator('[data-key="icon"]').selectOption('phone');
await rows.first().locator('[data-key="iconPosition"]').selectOption('after');
assert.equal(await rows.first().locator('.menu-entry-title > svg').count(), 1);
await page.getByText('Eigener Link', { exact: true }).first().click();
await page.locator('#link-label').fill('Zum Kontaktbereich'); await page.locator('#link-url').fill('#kontakt-check');
await page.locator('#menu-add-url').click();
await page.getByRole('button', { name: 'Menü speichern', exact: true }).click();
await page.locator('#menu-status').filter({ hasText: 'Gespeicherter Stand' }).waitFor();
await page.reload();
assert.equal(await rows.first().locator('[data-key="icon"]').inputValue(), 'phone');
assert.equal(await rows.first().locator('[data-key="iconPosition"]').inputValue(), 'after');
await rows.first().locator('summary').click();
await page.screenshot({ path: `${report}/menus.png`, fullPage: true });
checks.push('pointer drag, keyboard ordering, undo, icon placement, fragment menu link and save/reload');
const plain = await browser.newContext({ javaScriptEnabled: false }); const publicPage = await plain.newPage();
await publicPage.goto(origin);
assert.ok(await publicPage.locator('.nav-label > svg').count() > 0);
await publicPage.getByRole('link', { name: 'Zum Kontaktbereich', exact: true }).first().click();
assert.ok(publicPage.url().endsWith('#kontakt-check'));
assert.equal(await publicPage.locator('#kontakt-check').count(), 1);
assert.ok(await publicPage.evaluate(() => window.scrollY) > 0);
await plain.close();
execFileSync(php, ['-r', 'require "vendor/autoload.php"; $db=App\\Infrastructure\\ConnectionFactory::create($argv[1]); $db->executeStatement("UPDATE scheduled_task SET last_status = ?, last_message = ? WHERE name = ?", ["ok", "Fassung 99.0.0 ist verfügbar; installiert ist 1.8.4.", "update-check"]);', fixture.databaseUrl], { windowsHide: true });
await page.reload();
assert.equal(await page.getByRole('link', { name: 'Update 99.0.0 verfügbar. Aktualisierung öffnen.', exact: true }).count(), 1);
assert.match(await page.locator('.version-update').getAttribute('href'), /system\?pruefen=1$/);
await page.locator('.shell-foot').screenshot({ path: `${report}/update-hint.png` });
checks.push('public icon and native anchor navigation without JavaScript; cached update notice by version');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Navigation checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+137
View File
@@ -0,0 +1,137 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/not-found'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const visitor = await context.newPage();
visitor.on('pageerror', error => errors.push(error.message));
await visitor.goto(origin);
const shell = await visitor.evaluate(() => ({
header: document.querySelector('.site-header').textContent.replace(/\s+/g, ' ').trim(),
footer: document.querySelector('.site-footer').textContent.replace(/\s+/g, ' ').trim(),
font: getComputedStyle(document.body).fontFamily,
}));
const checkError = async (target, heading = 'Seite nicht gefunden', targetPage = visitor) => {
const response = await targetPage.goto(origin + target);
assert.equal(response.status(), 404);
assert.ok(response.headers()['x-robots-tag'].includes('noindex'));
assert.ok(response.headers()['content-security-policy'].includes("'sha256-"));
assert.equal(await targetPage.locator('h1').innerText(), heading);
assert.equal((await targetPage.locator('.site-header').textContent()).replace(/\s+/g, ' ').trim(), shell.header);
assert.equal((await targetPage.locator('.site-footer').textContent()).replace(/\s+/g, ' ').trim(), shell.footer);
assert.equal(await targetPage.evaluate(() => getComputedStyle(document.body).fontFamily), shell.font);
assert.equal(await targetPage.locator('link[rel="canonical"]').count(), 0);
assert.equal(await targetPage.locator('meta[name="robots"]').getAttribute('content'), 'noindex, follow');
};
const audit = async targetPage => {
const result = await new AxeBuilder({ page: targetPage }).analyze();
assert.deepEqual(result.violations.map(v => ({ id: v.id, nodes: v.nodes.map(n => n.target) })), []);
assert.equal(await targetPage.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
};
await checkError('/eine-unbekannte-seite');
await audit(visitor); await visitor.screenshot({ path: `${report}/default-desktop.png`, fullPage: true });
await checkError('/Alte/Seite.html'); await checkError('/eins/zwei/drei/vier/fuenf');
await visitor.setViewportSize({ width: 390, height: 900 });
await checkError('/__404-vorschau__'); await audit(visitor);
await visitor.screenshot({ path: `${report}/default-mobile.png`, fullPage: true });
checks.push('default 404, unusual/deep URLs, correct status/noindex, standard header/footer/fonts, desktop/mobile axe and layout');
await page.goto(origin + '/admin/settings#not-found');
await page.getByRole('button', { name: '404-Vorlage anlegen', exact: true }).click();
await page.waitForURL(/\/admin\/pages\/[a-f0-9]{32}$/);
const id = new URL(page.url()).pathname.split('/').pop();
const selectHeading = async () => {
await page.locator('#structure [data-block-id="error-heading"]').click();
await page.locator('[data-panel="content"]').click();
};
await selectHeading(); await page.getByLabel('Text', { exact: true }).fill('Hier bist du falsch abgebogen');
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await checkError('/ein-fehlender-pfad');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click();
await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
await checkError('/404', 'Hier bist du falsch abgebogen');
await audit(visitor); await visitor.screenshot({ path: `${report}/custom-mobile.png`, fullPage: true });
await visitor.setViewportSize({ width: 1600, height: 1050 });
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen'); await audit(visitor);
await visitor.screenshot({ path: `${report}/custom-desktop.png`, fullPage: true });
const sitemap = await (await context.request.get(origin + '/sitemap.xml')).text();
assert.ok(!sitemap.includes(origin + '/404'));
checks.push('create an assigned draft, edit in normal editor, keep draft private, publish immediately, assigned own URL remains 404 and out of sitemap');
await page.goto(origin + '/admin/settings#not-found');
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
const clear = () => page.getByRole('button', { name: '404-Seite suchen: Auswahl entfernen', exact: true });
const saveAssignment = async () => {
await page.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
await page.waitForURL(origin + '/admin/settings#not-found');
};
await clear().click(); await saveAssignment();
await page.getByText('Aktiv: Standard-404 im Website-Design.', { exact: true }).waitFor();
await checkError('/ein-fehlender-pfad');
const assignExisting = async () => {
await page.getByLabel('404-Seite suchen', { exact: true }).fill('Seite nicht gefunden');
await page.locator('#not-found-picker-results [role="option"]').first().waitFor();
await page.getByLabel('404-Seite suchen', { exact: true }).press('ArrowDown');
await page.getByLabel('404-Seite suchen', { exact: true }).press('Enter');
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
await saveAssignment();
await page.getByRole('link', { name: '404-Seite bearbeiten', exact: true }).waitFor();
};
await assignExisting(); await page.reload();
assert.equal(await page.locator('#not-found-form .picker-value').inputValue(), id);
await checkError('/ein-fehlender-pfad', 'Hier bist du falsch abgebogen');
const stale = await context.newPage(); await stale.goto(origin + '/admin/settings#not-found');
await clear().click(); await saveAssignment();
await stale.getByRole('button', { name: '404-Zuordnung speichern', exact: true }).click();
await stale.getByText('Die 404-Zuordnung wurde inzwischen geändert. Lade die Einstellungen neu.', { exact: true }).waitFor();
await checkError('/ein-fehlender-pfad'); await stale.close();
await assignExisting();
for (const width of [1600, 390]) {
await page.setViewportSize({ width, height: 1100 }); await page.locator('#not-found').scrollIntoViewIfNeeded();
await audit(page); await page.locator('#not-found').screenshot({ path: `${report}/settings-${width}.png` });
}
checks.push('live search without submitting, keyboard assignment, reload persistence, reset to default and protection against stale settings forms');
const withoutScripts = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 390, height: 900 } });
const plain = await withoutScripts.newPage();
await checkError('/Alte/Seite.html', 'Hier bist du falsch abgebogen', plain);
await plain.getByRole('link', { name: 'Zur Startseite', exact: true }).click();
await plain.waitForURL(origin + '/'); await withoutScripts.close();
const normal = await visitor.goto(origin); assert.equal(normal.status(), 200);
assert.equal(await visitor.locator('link[rel="canonical"]').getAttribute('href'), origin + '/');
await audit(visitor);
checks.push('public 404 and working home link without JavaScript; normal home remains 200 with canonical and passes axe');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Custom 404 checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Wirft die Elektro-Bernhard-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
set -e
root="C:/Django Project/KantSites"
export DATABASE_URL="sqlite:///$root/var/elektro-bernhard.db"
export MEDIA_DIRECTORY="$root/var/elektro-bernhard-media"
rm -f "$root/var/elektro-bernhard.db"
rm -rf "$root/var/elektro-bernhard-media"
php vendor/bin/doctrine-migrations migrate --no-interaction -q
php -d memory_limit=512M scripts/seed-elektro-bernhard.php "$1"
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Wirft die DEEG-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/immobilien-deeg.db"
export MEDIA_DIRECTORY="$root/var/immobilien-deeg-media"
rm -f "$root/var/immobilien-deeg.db"
rm -rf "$root/var/immobilien-deeg-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
assets="${1:-$root/var/immobilien-deeg-assets}"
# Die Vorlage nutzt Arial; es gibt keine Schrift zu holen.
"$php" -d memory_limit=512M scripts/seed-immobilien-deeg.php "$assets"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Wirft die Maler-Drews-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/maler-drews.db"
export MEDIA_DIRECTORY="$root/var/maler-drews-media"
rm -f "$root/var/maler-drews.db"
rm -rf "$root/var/maler-drews-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
assets="${1:-$root/var/maler-drews-assets}"
"$php" -d memory_limit=512M scripts/seed-maler-drews.php "$assets"
# Lato und Cormorant Garamond vom eigenen Server. Der Abruf schreibt fonts.css nur fuer dieses
# Design neu; die Datei teilen sich hier aber alle Installationen (Kant-IT und Elektro Bernhard
# brauchen Lato 900). Deshalb nur holen, wenn die Cormorant-Dateien fehlen. Das PHP 8.4 hier kennt
# keine Stammzertifikate; mit var/cacert.pem (https://curl.se/ca/cacert.pem) klappt der Abruf.
if [ -f "$root/public/assets/fonts/cormorant-garamond-700-latin.woff2" ]; then
echo "Schriften liegen bereits unter public/assets/fonts."
elif [ -f "$root/var/cacert.pem" ]; then
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
else
"$php" bin/console cms:fonts:fetch
fi
+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"
+166
View File
@@ -0,0 +1,166 @@
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
import assert from 'node:assert/strict';
import { execFileSync, spawn } from 'node:child_process';
import { mkdir, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import net from 'node:net';
const php = process.env.PHP_BINARY || 'php';
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
const env = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env });
const probe = net.createServer(); await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port; await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...env, SITE_ORIGIN: origin } });
const report = 'reports/responsive-navigation'; await mkdir(report, { recursive: true });
let browser, page;
const checks = [], errors = [];
try {
for (let attempt = 0; attempt < 60; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch {}
if (attempt === 59) throw new Error('Pilot test server did not start');
await new Promise(resolve => setTimeout(resolve, 200));
}
browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1600, height: 1050 } });
page = await context.newPage(); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message));
await page.goto(origin + '/admin/login');
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
const api = `${origin}/admin/api/pages/${fixture.homeId}`;
const data = await (await context.request.get(api)).json();
data.document.schemaVersion = 22;
const visibility = ['all', 'desktop', 'tablet', 'mobile', 'hide-desktop', 'hide-tablet', 'hide-mobile', 'hidden'];
const text = (id, title, visible = 'all') => ({ id, type: 'text', props: { text: `<p>${title}</p>` }, layout: { visibility: visible } });
const nav = (id, layout = 'horizontal', collapse) => ({ id, type: 'menu', props: { slot: 'header', layout, ...(collapse ? { collapse } : {}) } });
data.document.blocks.unshift({ id: 'qa-responsive', type: 'section', children: [
{ id: 'qa-title', type: 'heading', props: { text: 'Gerätesichtbarkeit und Navigation', level: 2 } },
nav('qa-menu'), nav('qa-mobile-menu', 'horizontal', 'mobile'), nav('qa-never-menu', 'horizontal', 'never'), nav('qa-vertical', 'vertical'), nav('qa-burger', 'burger'),
...visibility.map(value => text(`qa-${value}`, value, value)),
{ id: 'qa-parent', type: 'column', layout: { visibility: 'mobile' }, style: { desktop: { display: 'flex' }, mobile: { display: 'grid' } }, children: [text('qa-child', 'Vererbte Sichtbarkeit')] },
{ id: 'qa-tabs', type: 'tabs', children: [
{ id: 'qa-tab-desktop', type: 'tabitem', props: { title: 'Desktop-Tab' }, layout: { visibility: 'desktop' }, children: [text('qa-tab-text-d', 'Desktopinhalt')] },
{ id: 'qa-tab-mobile', type: 'tabitem', props: { title: 'Mobil-Tab' }, layout: { visibility: 'mobile' }, children: [text('qa-tab-text-m', 'Mobilinhalt')] },
{ id: 'qa-tab-all', type: 'tabitem', props: { title: 'Immer-Tab' }, children: [text('qa-tab-text-a', 'Gemeinsamer Inhalt')] },
] },
{ id: 'qa-slider', type: 'slider', props: { desktopVisible: 1, tabletVisible: 1, mobileVisible: 1, snap: true, dots: true, autoplay: 0 }, children: [text('qa-slide-d', 'Desktopfolie', 'desktop'), text('qa-slide-m', 'Mobilfolie', 'mobile'), text('qa-slide-a', 'Gemeinsame Folie')] },
] });
const created = await context.request.post(api + '/draft', { headers: { 'X-CSRF-Token': data.csrfToken }, data: { document: data.document, expectedVersion: data.version } });
assert.equal(created.status(), 200, await created.text());
await page.goto(`${origin}/admin/pages/${fixture.homeId}`);
await page.locator('#structure [data-block-id="qa-all"]').click();
await page.locator('#block-visibility').selectOption('mobile');
const previewHidden = async hidden => page.waitForFunction(hidden => {
const el = document.querySelector('#preview')?.contentDocument?.getElementById('qa-all');
return el && (el.getClientRects().length === 0) === hidden;
}, hidden);
await previewHidden(true);
await page.locator('#undo').click(); await previewHidden(false);
await page.locator('#redo').click(); await previewHidden(true);
await page.locator('#structure [data-block-id="qa-all"]').click();
await page.locator('#block-visibility').selectOption('all'); await previewHidden(false);
await page.locator('#block-visibility').focus(); await page.locator('#block-visibility').press('ArrowDown'); await page.locator('#block-visibility').press('Tab');
assert.equal(await page.locator('#block-visibility').inputValue(), 'desktop');
await page.locator('#undo').click();
await page.locator('#structure [data-block-id="qa-menu"]').click();
assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
await page.locator('#property-collapse').selectOption('never');
await page.locator('#undo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
await page.locator('#redo').click(); assert.equal(await page.locator('#property-collapse').inputValue(), 'never');
await page.locator('#property-collapse').selectOption('tablet');
await page.locator('[data-viewport="tablet"]').click();
const preview = page.frameLocator('#preview');
await preview.locator('#qa-menu > .menu-burger > summary').click();
assert.equal(await preview.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
await page.screenshot({ path: `${report}/editor-tablet.png`, fullPage: true });
await page.locator('#save').click(); await page.locator('#save-status').filter({ hasText: 'Entwurf gespeichert' }).waitFor();
await page.reload();
await page.locator('#structure [data-block-id="qa-menu"]').click();
assert.equal(await page.locator('#property-collapse').inputValue(), 'tablet');
await page.locator('#structure [data-block-id="qa-mobile"]').click();
assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile');
await page.locator('[data-panel="design"]').click(); assert.equal(await page.locator('#block-visibility').inputValue(), 'mobile');
await page.locator('#publish').click(); await page.locator('#confirm-publish').click(); await page.locator('#save-status').filter({ hasText: 'Veröffentlicht' }).waitFor();
const stored = (await (await context.request.get(api)).json()).document;
assert.equal(stored.schemaVersion, 22);
assert.deepEqual(stored.blocks[0].children.find(b => b.id === 'qa-parent').style, data.document.blocks[0].children.find(b => b.id === 'qa-parent').style);
checks.push('Editor: visibility keyboard selection, hidden block selection, reset, undo/redo, hamburger preview, save/reload/publish and preserved layout');
const plain = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 1600, height: 1050 } });
const publicPage = await plain.newPage(); await publicPage.goto(origin);
const necessary = publicPage.getByRole('button', { name: 'Nur notwendige', exact: true });
if (await necessary.isVisible()) await necessary.click();
for (const width of [1600, 1025, 1024, 768, 767, 390]) {
await publicPage.setViewportSize({ width, height: 1050 });
const device = width > 1024 ? 'desktop' : width > 767 ? 'tablet' : 'mobile';
for (const value of visibility) {
const shown = value === 'all' || value === device || (value.startsWith('hide-') && value !== `hide-${device}`);
assert.equal(await publicPage.locator(`#qa-${value}`).isVisible(), shown, `${width}: ${value}`);
}
assert.equal(await publicPage.locator('#qa-child').isVisible(), device === 'mobile');
if (device === 'mobile') assert.equal(await publicPage.locator('#qa-parent').evaluate(el => getComputedStyle(el).display), 'grid');
assert.equal(await publicPage.locator('#qa-menu > .menu-burger > summary').isVisible(), width <= 1024);
assert.equal(await publicPage.locator('#qa-menu > .menu-list').isVisible(), width > 1024);
assert.equal(await publicPage.locator('#qa-mobile-menu > .menu-burger > summary').isVisible(), width <= 767);
assert.equal(await publicPage.locator('#qa-never-menu > .menu-list').isVisible(), true);
assert.equal(await publicPage.locator('#qa-vertical > .menu-list').isVisible(), true);
assert.equal(await publicPage.locator('#qa-burger > .menu-burger > summary').isVisible(), true);
if ([1600, 1024, 390].includes(width)) { await publicPage.evaluate(() => window.scrollTo(0, 0)); await publicPage.screenshot({ path: `${report}/public-${width}.png`, fullPage: true }); }
}
const summary = publicPage.locator('#qa-menu > .menu-burger > summary');
await summary.focus(); await publicPage.keyboard.press('Enter');
assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
await publicPage.keyboard.press('Tab');
assert.ok(await publicPage.evaluate(() => document.activeElement.closest('#qa-menu > .menu-burger')));
const branch = publicPage.locator('#qa-menu .menu-burger .menu-branch > summary').first();
if (await branch.count()) { await branch.focus(); await publicPage.keyboard.press('Space'); assert.ok(await branch.evaluate(el => el.parentElement.open)); }
await summary.focus(); await publicPage.keyboard.press('Space');
assert.equal(await publicPage.locator('#qa-menu > .menu-burger').getAttribute('open'), null);
await summary.press('Enter');
const firstLink = publicPage.locator('#qa-menu .menu-burger a:visible').first();
const href = await firstLink.getAttribute('href'); await firstLink.click(); assert.equal(publicPage.url(), new URL(href, origin).href);
await plain.close();
checks.push('Public without JavaScript: six viewport boundaries, every visibility preset, parent visibility, default and manual menu variants, keyboard toggle and real navigation');
const tablet = await browser.newContext({ javaScriptEnabled: false, hasTouch: true, isMobile: true, viewport: { width: 1366, height: 1024 } });
const ipad = await tablet.newPage(); await ipad.goto(origin);
assert.equal(await ipad.locator('#qa-menu > .menu-burger > summary').isVisible(), true);
assert.equal(await ipad.locator('#qa-menu > .menu-list').isVisible(), false);
await ipad.locator('#qa-menu > .menu-burger > summary').tap();
assert.equal(await ipad.locator('#qa-menu > .menu-burger').getAttribute('open'), '');
await ipad.locator('#qa-menu').screenshot({ path: `${report}/large-tablet.png` }); await tablet.close();
checks.push('Large tablet: 1366 px touch viewport uses the hamburger and opens by touch without JavaScript');
const enhanced = await browser.newContext({ viewport: { width: 1600, height: 1050 }, bypassCSP: true });
const dynamic = await enhanced.newPage(); await dynamic.goto(origin);
await dynamic.locator('#qa-tabs.tabs-ready').waitFor();
const audit = async () => {
const result = await new AxeBuilder({ page: dynamic }).include('#qa-menu').include('#qa-tabs').include('#qa-slider + .slider-controls').withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
assert.deepEqual(result.violations.map(item => ({ id: item.id, nodes: item.nodes.map(node => node.target) })), []);
};
await audit();
assert.equal(await dynamic.locator('#qa-tab-desktop').isVisible(), true);
await dynamic.setViewportSize({ width: 390, height: 850 });
await dynamic.locator('#qa-tab-mobile').waitFor({ state: 'visible' });
await dynamic.locator('#qa-menu > .menu-burger > summary').click(); await audit();
await dynamic.locator('#qa-tab-mobile-tab').focus(); await dynamic.keyboard.press('ArrowRight');
assert.equal(await dynamic.locator('#qa-tab-all').isVisible(), true);
await dynamic.keyboard.press('ArrowRight'); assert.equal(await dynamic.locator('#qa-tab-mobile').isVisible(), true);
const controls = dynamic.locator('#qa-slider + .slider-controls');
await controls.scrollIntoViewIfNeeded();
assert.match(await controls.locator('.slider-position').innerText(), /\/ 2$/);
assert.equal(await controls.getByRole('button', { name: 'Folie 1 anzeigen' }).isVisible(), false);
await controls.getByRole('button', { name: 'Nächste Folie' }).click();
await dynamic.waitForFunction(() => document.querySelector('#qa-slider + .slider-controls .slider-position').textContent === '2 / 2');
await enhanced.close();
checks.push('Enhanced groups: tabs select a visible panel and skip hidden tabs; slider skips hidden slides and counts visible ones; desktop/mobile WCAG checks of navigation, tabs and slider controls');
assert.deepEqual(errors, []);
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
console.log('Responsive navigation checks passed:', checks.length, 'groups.');
} catch (error) {
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
throw error;
} finally { await browser?.close(); server.kill(); }
+875
View File
@@ -0,0 +1,875 @@
<?php
/**
* Baut die Website „Elektro Bernhard“ als Inhalt dieses CMS auf.
*
* Inhalt: das gelieferte HTML-Paket (12.09.2026), abgeglichen mit elektro-bernhard.de (14.09.2026).
* Gestaltung: eigener Entwurf nach dem Muster des laufenden Auftritts – dunkler Kopf über einem
* Foto, Karten mit Schatten, Einblendungen, ein Kopfbereich, der über dem Bild schwebt und beim
* Scrollen weiß wird. Alles aus gewöhnlichen Bausteinen; kein eigener Baustein, kein Sonderweg.
*
* 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-elektro-bernhard.sh).
* Erstes Argument: Ordner mit den Originalbildern (logo.webp, hero-1200.webp, electrician-900.webp).
*/
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\RedirectRepository;
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 Originalbildern 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, 'elektro-bernhard')) {
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die Elektro-Bernhard-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);
$validator = new DocumentValidator();
$pages = new PageRepository($db, $validator, $settings);
$partials = new PartialRepository($db, $validator);
$menus = new MenuRepository($db, $settings);
$forms = new FormRepository($db);
$theme = new Theme($db);
$redirects = new RedirectRepository($db);
$mail = 'info@elektro-bernhard.de';
$buero = '07221 3944909';
$notdienst = '0151 25245851';
$SCHEMA = 23;
// ---------------------------------------------------------------------------------------------
// Bausteine
// ---------------------------------------------------------------------------------------------
/** Ein Abschnitt mit dem Rhythmus der Seite: 96 px am Schreibtisch, 64 mobil. */
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null): array {
$basis = ['desktop' => ['padding' => ['top' => '96px', 'bottom' => '96px']], 'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']]];
foreach ($style as $device => $values) {
$basis[$device] = [...($basis[$device] ?? []), ...$values];
}
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $basis];
if (null !== $anchor) {
$block['anchor'] = $anchor;
}
return $block;
};
$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;
};
$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 = []): array {
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
if ([] !== $style) {
$block['style'] = $style;
}
return $block;
};
$text = static function (string $id, string $text, array $style = []): array {
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $text]];
if ([] !== $style) {
$block['style'] = $style;
}
return $block;
};
/** Einblendung und Hover-Bewegung, wie Flatsome sie jedem Element anbietet. */
$fx = static function (array $block, string $entrance = 'rise', string $delay = 'none', ?string $hover = null, ?string $shadow = null): array {
$effects = ['entrance' => $entrance, 'delay' => $delay];
if (null !== $hover) {
$effects['hover'] = $hover;
}
if (null !== $shadow) {
$effects['shadow'] = $shadow;
}
$block['layout'] = [...($block['layout'] ?? []), 'effects' => $effects];
return $block;
};
/** Die Kleinzeile über einer Überschrift: Versalien, gesperrt, in Rot. */
$eyebrow = static fn (string $id, string $inhalt, string $farbe = '@rot'): array => [
'id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt],
'style' => ['desktop' => ['color' => $farbe, 'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.16em', 'textTransform' => 'uppercase', 'lineHeight' => 1.4, 'margin' => ['bottom' => '16px']]],
];
$button = static function (string $id, string $text, string $href, array $style = [], array $variante = []): array {
$block = ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href]];
if ([] !== $style) {
$block['style'] = $style;
}
if ([] !== $variante) {
$block['layout'] = ['button' => $variante];
}
return $block;
};
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $caption = null, ?string $href = null): array {
$gross = end($eintrag['variants']);
$props = [
'src' => $gross['src'], 'alt' => $alt, 'decorative' => false, '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 !== $caption) {
$props['caption'] = $caption;
}
if (null !== $href) {
$props['href'] = $href;
$props['click'] = 'link';
}
$block = ['id' => $id, 'type' => 'image', 'props' => $props];
if ([] !== $style) {
$block['style'] = $style;
}
return $block;
};
$alleEcken = static fn (string $r): array => ['topLeft' => $r, 'topRight' => $r, 'bottomRight' => $r, 'bottomLeft' => $r];
$alleSeiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
/** Eine Karte mit Symbol auf roter Fläche – die Leistungskarte des Auftritts. */
$karte = static function (string $id, string $icon, string $titel, string $inhalt, string $href = '') use ($alleEcken, $alleSeiten): array {
return [
'id' => $id, 'type' => 'iconcard',
'props' => ['icon' => $icon, 'title' => $titel, 'text' => $inhalt, 'href' => $href],
'style' => ['desktop' => [
'backgroundColor' => '@papier', 'color' => '@tinte',
'padding' => $alleSeiten('32px'), 'borderWidth' => $alleSeiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie',
'borderRadius' => $alleEcken('16px'), 'gap' => '14px',
'hover' => ['borderColor' => '@rot'],
]],
'css' => "& svg{width:54px;height:54px;padding:12px;color:#c8102e;background:#fbe9ec;border-radius:14px}\n& h3{font-size:20px;letter-spacing:-0.01em}\n& p{color:#5d636b;font-size:15.5px}\n& h3 a:hover{color:#c8102e;text-decoration:none}",
'layout' => ['effects' => ['shadow' => 'soft', 'hover' => 'lift', 'entrance' => 'rise']],
];
};
/** Eine schlanke Zeile mit Symbol, ohne Karte – für Aufzählungen von Stärken. */
$punkt = static function (string $id, string $icon, string $titel, string $inhalt, string $farbe = '#c8102e', string $textfarbe = '#5d636b'): array {
return [
'id' => $id, 'type' => 'iconcard',
'props' => ['icon' => $icon, 'title' => $titel, 'text' => $inhalt, 'href' => ''],
'style' => ['desktop' => ['padding' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'none', 'gap' => '6px']],
'css' => "& svg{width:26px;height:26px;color:$farbe}\n& h3{font-size:17px}\n& p{color:$textfarbe;font-size:15px}",
];
};
$kontaktBlock = static function (string $id, array $props, array $style = []): array {
$block = ['id' => $id, 'type' => 'contact', 'props' => ['phone' => $props['phone'] ?? '', 'email' => $props['email'] ?? '', 'address' => $props['address'] ?? '']];
if ([] !== $style) {
$block['style'] = $style;
}
return $block;
};
/** Die sozialen Profile – gepflegt in den Einstellungen, hier nur Größe, Form und Farbe. */
$sozial = static function (string $id, int $groesse, string $form, string $farbe, array $extra = []): array {
return [
'id' => $id, 'type' => 'social',
'props' => ['size' => $groesse, 'shape' => $form, 'layout' => 'horizontal', 'labels' => false, 'networks' => [], 'newTab' => true],
'style' => ['desktop' => ['color' => $farbe, ...$extra]],
];
};
/** Der Handlungsaufruf am Ende einer Seite: dunkle Karte mit rotem Knopf. */
$cta = static function (string $id) use ($alleEcken, $alleSeiten): array {
return [
'id' => $id, 'type' => 'cta',
'props' => ['title' => 'Projekt geplant? Wir sind bereit.', 'text' => 'Kontaktieren Sie uns für eine kostenlose Beratung – wir melden uns innerhalb von 24 Stunden.', 'buttonText' => 'Jetzt Kontakt aufnehmen', 'href' => '/kontakt'],
'style' => ['desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => $alleSeiten('44px'), 'borderRadius' => $alleEcken('20px'), 'margin' => ['top' => '56px']], 'mobile' => ['padding' => $alleSeiten('28px')]],
'css' => "& h2{font-size:28px;line-height:1.2;letter-spacing:-0.02em;margin:0 0 8px}\n& p{color:#c2c6cc;margin:0}",
'layout' => ['effects' => ['entrance' => 'rise']],
];
};
// Wiederkehrende Gestaltungswerte.
$fliess = ['desktop' => ['color' => '@gedaempft', 'fontSize' => '17px', 'lineHeight' => 1.75]];
$fliessHell = ['desktop' => ['color' => '#c2c6cc', 'fontSize' => '17px', 'lineHeight' => 1.75]];
$h2Stil = ['desktop' => ['fontSize' => '44px', 'lineHeight' => 1.12, 'letterSpacing' => '-0.03em', 'fontWeight' => '900', 'margin' => ['bottom' => '18px']], 'mobile' => ['fontSize' => '32px']];
$h3Stil = ['desktop' => ['fontSize' => '21px', 'lineHeight' => 1.25, 'fontWeight' => '700', 'margin' => ['bottom' => '12px']]];
$fussTitel = ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'textTransform' => 'uppercase', 'color' => '@papier', 'margin' => ['bottom' => '18px']]];
$linkStil = ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'color' => '@rot', 'margin' => ['top' => '8px']]];
// ---------------------------------------------------------------------------------------------
// Website, Gestaltung, Medien, Formular
// ---------------------------------------------------------------------------------------------
$db->insert('website', ['id' => 'default', 'name' => 'Elektro Bernhard GmbH', 'default_locale' => 'de', 'theme' => '{}']);
$theme->save(
[
['key' => 'rot', 'name' => 'Signalrot', 'value' => '#c8102e'],
['key' => 'rot-dunkel', 'name' => 'Signalrot dunkel', 'value' => '#a70c25'],
['key' => 'rot-hell', 'name' => 'Rot, Fläche', 'value' => '#fbe9ec'],
['key' => 'tinte', 'name' => 'Tinte', 'value' => '#151719'],
['key' => 'nacht', 'name' => 'Fußbereich', 'value' => '#17191b'],
['key' => 'balken', 'name' => 'Kopfleiste', 'value' => '#181a1d'],
['key' => 'karte-dunkel', 'name' => 'Dunkle Karte', 'value' => '#24272b'],
['key' => 'gedaempft', 'name' => 'Gedämpft', 'value' => '#5d636b'],
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
['key' => 'weich', 'name' => 'Weiches Grau', 'value' => '#f4f5f6'],
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#e2e4e7'],
],
[
// Lato liegt bereits lokal unter public/assets/fonts – keine Anfrage an Google.
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Lato', 'source' => 'google', 'weights' => [400, 700, 900], 'fallback' => 'sans-serif'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lato', 'source' => 'google', 'weights' => [700, 900], 'fallback' => 'sans-serif'],
],
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '17px', 'lineHeight' => 1.7, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@rot', 'containerWidth' => '1240px', 'headingScale' => 1.25, 'linkUnderline' => false],
['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'large', 'fontFamily' => 'inherit', 'fontSize' => 13, 'fontWeight' => 700, 'letterSpacing' => 0.6, 'textTransform' => 'uppercase', 'backgroundColor' => '@rot', 'color' => '@papier', 'hoverBackgroundColor' => '@rot-dunkel', 'hoverColor' => '@papier'],
);
$medien = [];
foreach ([
'logo' => ['logo.webp', 'Elektro Bernhard GmbH – Logo'],
'team' => ['hero-1200.webp', 'Team und Firmenfahrzeuge von Elektro Bernhard'],
'schaltschrank' => ['electrician-900.webp', 'Elektrofachkraft bei der Arbeit an einem Schaltschrank'],
] 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' => 'Name / Firma', 'required' => true],
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false],
['name' => 'thema', 'type' => 'select', 'label' => 'Worum geht es?', 'required' => true, 'options' => ['Elektroinstallation', 'Smart Home', 'Beratung & Planung', 'Solarenergie', 'Sonstige Anfrage']],
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true, 'help' => 'Was planen Sie und wo soll das Projekt umgesetzt werden?'],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden zur Bearbeitung meiner Anfrage verwendet.'],
], ['recipient' => $mail, 'subject' => 'Neue Anfrage über elektro-bernhard.de', 'success' => 'Vielen Dank. Ihre Anfrage ist angekommen – wir melden uns innerhalb von 24 Stunden.', 'replyToField' => 'email'], 0);
echo "Formular angelegt: kontakt\n";
// ---------------------------------------------------------------------------------------------
// Kopfbereich: schwebt über dem dunklen Seitenkopf, wird beim Scrollen weiß
// ---------------------------------------------------------------------------------------------
$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-adresse', 'Ooser Hauptstr. 51 · 76532 Baden-Baden', ['desktop' => ['color' => '#c4c8ce', 'fontSize' => '13px', 'lineHeight' => 1.4]]),
], [], 'hide-mobile'),
$column('kopf-leiste-rechts', [
// Auf dem Telefon bleibt in der Leiste nur die Notdienstnummer; die Profile stehen im Fuß.
[...$sozial('kopf-sozial', 16, 'plain', '#c4c8ce', ['hover' => ['color' => '@papier']]), 'layout' => ['visibility' => 'hide-mobile']],
['id' => 'kopf-leiste-menue', 'type' => 'menu', 'props' => ['slot' => 'secondary', 'layout' => 'horizontal', 'collapse' => 'never'],
'style' => ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'color' => '@papier']]],
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'center', 'gap' => '22px'], 'mobile' => ['justifyContent' => 'center']]),
], ['desktop' => ['columns' => 'two', 'gap' => 'small'], 'mobile' => ['columns' => 'one']], ['desktop' => ['alignItems' => 'center', 'margin' => ['top' => '0px']]]),
], ['desktop' => ['backgroundColor' => '@balken', 'padding' => ['top' => '8px', 'bottom' => '8px'], 'borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '#ffffff14'], 'mobile' => ['padding' => ['top' => '8px', 'bottom' => '8px']]]),
$section('kopf-haupt', [
$row('kopf-haupt-zeile', [
$column('kopf-marke', [
$bild('kopf-logo', $medien['logo'], 'Elektro Bernhard GmbH – Beratung, Service, Installation, Notdienst',
['desktop' => ['maxWidth' => '250px', 'backgroundColor' => '@papier', 'padding' => ['top' => '10px', 'right' => '14px', 'bottom' => '10px', 'left' => '14px'], 'borderRadius' => $alleEcken('10px')], 'mobile' => ['maxWidth' => '200px']], null, '/'),
]),
$column('kopf-navigation', [
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet'],
'style' => ['desktop' => ['justifyContent' => 'flex-end', 'fontWeight' => '700', 'fontSize' => '15px', 'gap' => '30px']]],
]),
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'two']], ['desktop' => ['alignItems' => 'center', 'minHeight' => '92px', 'margin' => ['top' => '0px']], 'mobile' => ['minHeight' => '72px']]),
], [
// Durchsichtig am Seitenanfang, weiß mit Schatten sobald gescrollt wird.
'desktop' => ['backgroundColor' => '#ffffff00', 'color' => '@papier', 'padding' => ['top' => '6px', 'bottom' => '6px'],
'shadowY' => '10px', 'shadowBlur' => '30px', 'shadowColor' => '#00000000',
'scrolled' => ['backgroundColor' => '@papier', 'color' => '@tinte', 'shadowColor' => '#0000001f']],
'mobile' => ['padding' => ['top' => '6px', 'bottom' => '6px']],
]),
],
];
// ---------------------------------------------------------------------------------------------
// Fußbereich
// ---------------------------------------------------------------------------------------------
$fuss = [
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
'blocks' => [
$section('fuss-notdienst', [
$row('fuss-notdienst-zeile', [
$column('fuss-notdienst-text', [
$text('fuss-notdienst-titel', '24h Elektro-Notdienst', ['desktop' => ['fontSize' => '20px', 'fontWeight' => '900', 'color' => '@papier', 'lineHeight' => 1.3]]),
$text('fuss-notdienst-zusatz', 'Bei elektrischen Störungen rund um die Uhr für Baden-Baden und die Region erreichbar.', ['desktop' => ['fontSize' => '15px', 'color' => '#ffffffd9', 'margin' => ['top' => '4px']]]),
]),
$column('fuss-notdienst-nummer', [
$kontaktBlock('fuss-notdienst-telefon', ['phone' => $notdienst], ['desktop' => ['fontSize' => '30px', 'fontWeight' => '900', 'color' => '@papier', 'justifyContent' => 'flex-end', 'letterSpacing' => '-0.02em'], 'mobile' => ['fontSize' => '24px', 'justifyContent' => 'flex-start']]),
]),
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['alignItems' => 'center', 'margin' => ['top' => '0px']]]),
], ['desktop' => ['backgroundColor' => '@rot', 'color' => '@papier', 'padding' => ['top' => '30px', 'bottom' => '30px']], 'mobile' => ['padding' => ['top' => '26px', 'bottom' => '26px']]]),
$section('fuss-haupt', [
$row('fuss-haupt-zeile', [
$column('fuss-marke', [
$bild('fuss-logo', $medien['logo'], 'Elektro Bernhard GmbH', ['desktop' => ['maxWidth' => '230px', 'backgroundColor' => '@papier', 'padding' => $alleSeiten('12px'), 'borderRadius' => $alleEcken('10px')]], null, '/'),
$text('fuss-marke-text', "Elektroinstallation und Kundenservice\nin Baden-Baden und Umgebung – seit über 35 Jahren.", ['desktop' => ['color' => '#b3b8bf', 'fontSize' => '15px', 'margin' => ['top' => '20px', 'bottom' => '20px']]]),
$sozial('fuss-sozial', 20, 'circle', '@papier', ['hover' => ['color' => '@rot']]),
]),
$column('fuss-website', [
$heading('fuss-website-titel', 'Unsere Website', 3, $fussTitel),
['id' => 'fuss-website-menue', 'type' => 'menu', 'props' => ['slot' => 'footer', 'layout' => 'vertical'], 'style' => ['desktop' => ['fontSize' => '15px', 'color' => '#c4c8ce']]],
]),
$column('fuss-kontakt', [
$heading('fuss-kontakt-titel', 'Kontakt', 3, $fussTitel),
$kontaktBlock('fuss-kontakt-daten', ['phone' => $buero, 'email' => $mail, 'address' => 'Ooser Hauptstr. 51, 76532 Baden-Baden'], ['desktop' => ['fontSize' => '15px', 'color' => '#c4c8ce', 'gap' => '10px']]),
]),
$column('fuss-zeiten', [
$heading('fuss-zeiten-titel', 'Bürozeiten', 3, $fussTitel),
$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']]]),
['id' => 'fuss-trenner', 'type' => 'divider', 'style' => ['desktop' => ['borderColor' => '#33373c', 'margin' => ['top' => '56px', 'bottom' => '24px']]]],
$row('fuss-schluss', [
$column('fuss-schluss-links', [$text('fuss-copyright', '© 2026 Elektro Bernhard GmbH', ['desktop' => ['color' => '#8f959c', 'fontSize' => '13px', 'lineHeight' => 1.8]])]),
$column('fuss-schluss-rechts', [
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'], 'style' => ['desktop' => ['color' => '#b3b8bf', 'fontSize' => '13px', 'justifyContent' => 'flex-end', 'padding' => ['right' => '72px']], 'mobile' => ['justifyContent' => 'flex-start', 'padding' => ['right' => '0px']]]],
[...$text('fuss-urheber', 'Website von <a href="https://www.kant-it.de/">Kant-IT Solutions</a>', ['desktop' => ['color' => '#8f959c', 'fontSize' => '13px', 'textAlign' => 'right', 'margin' => ['top' => '6px'], 'padding' => ['right' => '72px']], 'mobile' => ['textAlign' => 'left', 'padding' => ['right' => '0px']]]), 'css' => '& a{color:#e8eaed}'],
]),
], ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
], ['desktop' => ['backgroundColor' => '@nacht', 'color' => '#b3b8bf', 'padding' => ['top' => '72px', 'bottom' => '24px']], 'mobile' => ['padding' => ['top' => '52px', 'bottom' => '24px']]]),
],
];
$partials->create('header', 'default', 'Kopfbereich', $kopf);
$partials->create('footer', 'default', 'Fußbereich', $fuss);
echo "Kopf- und Fußbereich angelegt\n";
// ---------------------------------------------------------------------------------------------
// Inhalte
// ---------------------------------------------------------------------------------------------
$leistungen = [
['elektroinstallation', 'hardware', 'Elektroinstallation', 'Wir planen und installieren die Elektrik in Neubauten und übernehmen Elektroarbeiten bei Sanierungen.'],
['smart-home', 'app', 'Smart Home', 'Beleuchtung, Heizung und Sicherheit zentral steuern. Wir beraten Sie, welche Lösung zu Ihrem Gebäude passt.'],
['notdienst', 'phone', '24h Notdienst', 'Bei elektrischen Störungen erreichen Sie unseren Notdienst unter '.$notdienst.', auch außerhalb der Bürozeiten.'],
['beratung-planung', 'design', 'Beratung & Planung', 'Sie möchten bauen oder umbauen? Wir besprechen mit Ihnen, welche Anschlüsse und Funktionen Sie benötigen.'],
];
$fragen = [
['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
// Google-Rezensionen übernommen (Zuordnung am DOM geprüft, nicht aus der Textfassung geraten).
$stimmen = [
['philipp', 'Als Architekt empfehle ich die Firma Bernhard gerne weiter. Zuverlässig, professionell und entspannt. Probleme werden zielführend gelöst und die Wünsche der Bauherren erfüllt. Solche Handwerker gibt es heutzutage leider immer seltener.', 'Philipp Bender', 'Google · 26. November 2024'],
['soeren', 'Angefangen vom ersten Planungsgespräch, die Terminierung, Durchführung und Abnahme war ALLES perfekt. Obwohl wir tendenziell eher ein kleineres Projekt hatten, wurde alles sehr professionell und auf die Stunde genau pünktlich umgesetzt.', 'Sören de Vries', 'Google · 26. Oktober 2024'],
['michael', 'Das Team hat sauber und gründlich gearbeitet und war immer sehr freundlich. Es wurden vollumfänglich Fragen beantwortet, wir wurden durch das Projekt regelrecht begleitet. Solche Handwerker findet man sehr selten.', 'Michael Pfeil', 'Google · 26. Oktober 2024'],
['dijar', 'Ein erstklassiges Unternehmen – extrem zuverlässig und absolut kompetent. Bereits am Sonntag standen die Mitarbeiter, inklusive des Geschäftsführers, persönlich vor Ort. Absolute Empfehlung!', 'Dijar Bislimi', 'Google · 26. Oktober 2024'],
['samir', 'Super Firma und einfach tolle Mitarbeiter', 'Redzepi Samir', 'Google · 26. Oktober 2024'],
['artur', 'Von der Planung bis zur Ausführung top zufrieden. Sehr empfehlenswert.', 'Artur', 'Google · 9. Mai 2024'],
];
$marken = ['Busch-Jaeger', 'JUNG', 'KNX', 'Gira', 'hager', 'Siedle'];
// Die sechs Punkte, mit denen der bestehende Auftritt unter „Über uns“ für sich wirbt.
$staerken = [
['zufriedenheit', 'star', 'Kundenzufriedenheit', 'Im Mittelpunkt steht der Kunde. Unser Ziel ist, Ihre Wünsche zu Ihrer vollsten Zufriedenheit zu erfüllen.'],
['qualitaet', 'shield', 'Qualität', 'Ausschließlich Qualitätsware aus deutschen Markenprodukten.'],
['erfahrung', 'clock', 'Erfahrung', 'Über 35 Jahre Erfahrung sprechen für sich.'],
['flexibel', 'network', 'Flexibel', 'Durch zahlreiche Partner können wir auch bei komplexen Aufgaben schnell reagieren.'],
['professionell', 'hardware', 'Professionell', 'Professionelle Werkzeuge für einen reibungslosen Ablauf auf Ihrer Baustelle.'],
['sauber', 'check', 'Schnell & sauber', 'Saubere Arbeit in vergleichbar schnellem Arbeitstempo.'],
];
$leistungstexte = [
'elektroinstallation' => ['Elektroinstallation in Baden-Baden | Elektro Bernhard', 'Elektroinstallation für Neubau und Altbau', 'Sie bauen neu oder modernisieren ein bestehendes Gebäude? Wir begleiten Ihre Elektroinstallation von der Planung bis zur Umsetzung.', [
['Neubau und Altbau', 'Die passende Elektroinstallation beginnt mit Ihren Anforderungen: Welche Räume nutzen Sie wie? Wo benötigen Sie Anschlüsse? Gemeinsam besprechen wir Ihr Vorhaben und schaffen eine Grundlage für die weitere Planung.'],
['Material und Ausführung', 'Wir setzen auf Markenprodukte, professionelles Werkzeug und sorgfältige Ausführung. Auch bei komplexeren Aufgaben greifen wir auf unser Partnernetzwerk zurück.'],
]],
'smart-home' => ['Smart Home in Baden-Baden | Elektro Bernhard', 'Smart Home für Ihr Zuhause', 'Licht, Heizung und Sicherheitsfunktionen intelligent steuern: Wir planen Smart-Home-Lösungen passend zu Ihrem Gebäude und Ihren Gewohnheiten.', [
['Was möchten Sie steuern?', 'Im Mittelpunkt steht Ihr Alltag. Wir besprechen, welche Funktionen Sie vereinfachen möchten und wie eine individuelle Steuerung dabei helfen kann.'],
['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, 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.', [
['Ihr Vorhaben', 'Erzählen Sie uns, was Sie vorhaben. Gemeinsam besprechen wir den Umfang und die Anforderungen Ihres Projekts – vom einzelnen Bereich bis zum Neu- oder Altbau.'],
['Ein Ansprechpartner für die Umsetzung', 'Beratung, Planung und Installation greifen bei uns ineinander. Wir stimmen die nächsten Schritte mit Ihnen ab und behalten Ihre Anforderungen im Blick.'],
]],
];
// ---------------------------------------------------------------------------------------------
// Wiederkehrende Abschnitte
// ---------------------------------------------------------------------------------------------
/**
* Der dunkle Seitenkopf jeder Unterseite. Er liegt unter dem schwebenden Kopfbereich, deshalb
* beginnt sein Inhalt erst 200 px weiter unten; die Brotkrumen stehen darin in Weiß.
*/
$seitenkopf = static function (string $praefix, string $label, string $h1, string $intro = '', array $zusatz = []) use ($section, $eyebrow, $heading, $text, $fx, $fliessHell): array {
$kinder = [
['id' => $praefix.'-brotkrumen', 'type' => 'breadcrumb', 'style' => ['desktop' => ['color' => '#ffffffb3', 'margin' => ['bottom' => '28px']]]],
$fx($eyebrow($praefix.'-label', $label, '#ff7d90'), 'fade'),
$fx($heading($praefix.'-h1', $h1, 1, ['desktop' => ['fontSize' => '56px', 'lineHeight' => 1.08, 'letterSpacing' => '-0.03em', 'fontWeight' => '900', 'color' => '@papier', 'maxWidth' => '820px', 'margin' => ['bottom' => '18px']], 'mobile' => ['fontSize' => '36px']]), 'rise', 'short'),
];
if ('' !== $intro) {
$kinder[] = $fx($text($praefix.'-intro', $intro, ['desktop' => [...$fliessHell['desktop'], 'fontSize' => '19px', 'maxWidth' => '680px']]), 'rise', 'medium');
}
foreach ($zusatz as $block) {
$kinder[] = $fx($block, 'rise', 'long');
}
return $section($praefix.'-kopf', $kinder, [
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '200px', 'bottom' => '80px']],
'mobile' => ['padding' => ['top' => '166px', 'bottom' => '56px']],
]);
};
/** Der schmale Textkörper einer Unterseite. */
$textkoerper = static function (string $praefix, array $absaetze, array $vorspann = [], array $schluss = []) use ($section, $heading, $text, $fliess): array {
$kinder = $vorspann;
foreach ($absaetze as $i => [$titel, $inhalt]) {
if ('' !== $titel) {
$kinder[] = $heading($praefix.'-h2-'.$i, $titel, 2, ['desktop' => ['fontSize' => '28px', 'lineHeight' => 1.25, 'letterSpacing' => '-0.02em', 'fontWeight' => '900', 'margin' => ['top' => '40px', 'bottom' => '14px']]]);
}
$kinder[] = $text($praefix.'-p-'.$i, $inhalt, $fliess);
}
foreach ($schluss as $block) {
$kinder[] = $block;
}
return $section($praefix.'-inhalt', $kinder, ['desktop' => ['maxWidth' => '860px', 'padding' => ['top' => '72px', 'bottom' => '96px']], 'mobile' => ['padding' => ['top' => '48px', 'bottom' => '64px']]]);
};
/** Der Kontaktabschnitt – auf Startseite und Kontaktseite derselbe Aufbau. */
$kontaktAbschnitt = static function (string $praefix) use ($section, $row, $column, $heading, $text, $eyebrow, $kontaktBlock, $fx, $fliess, $h2Stil, $h3Stil, $alleEcken, $alleSeiten, $buero, $mail): array {
return $section($praefix.'-kontakt', [
$row($praefix.'-kontakt-zeile', [
$column($praefix.'-kontakt-links', [
$fx($eyebrow($praefix.'-kontakt-label', 'Kontakt'), 'fade'),
$fx($heading($praefix.'-kontakt-titel', 'Wie können wir Ihnen helfen?', 2, $h2Stil), 'rise'),
$fx($text($praefix.'-kontakt-text', 'Rufen Sie uns an oder schreiben Sie uns, welche Arbeiten Sie planen. Bitte geben Sie auch den Ort an – wir melden uns innerhalb von 24 Stunden.', $fliess), 'rise', 'short'),
$fx($kontaktBlock($praefix.'-kontakt-daten', ['phone' => $buero, 'email' => $mail], ['desktop' => ['fontSize' => '28px', 'fontWeight' => '900', 'letterSpacing' => '-0.02em', 'gap' => '12px', 'margin' => ['top' => '18px', 'bottom' => '30px']], 'mobile' => ['fontSize' => '22px']]), 'rise', 'medium'),
$row($praefix.'-kontakt-details', [
$column($praefix.'-kontakt-anschrift', [
$heading($praefix.'-kontakt-anschrift-titel', 'Hier finden Sie uns', 3, ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['bottom' => '8px']]]),
$text($praefix.'-kontakt-anschrift-text', "Elektro Bernhard GmbH\nOoser Hauptstr. 51\n76532 Baden-Baden", ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@gedaempft']]),
$text($praefix.'-kontakt-route', '<a href="https://www.google.com/maps/search/?api=1&amp;query=Elektro+Bernhard+Ooser+Hauptstr.+51+Baden-Baden" target="_blank">Route auf Google Maps →</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'color' => '@rot', 'margin' => ['top' => '10px']]]),
]),
$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–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']]]),
]),
$fx($column($praefix.'-kontakt-rechts', [
$heading($praefix.'-formular-titel', 'Schreiben Sie uns', 3, $h3Stil),
$text($praefix.'-formular-hinweis', 'Für einen Notfall nutzen Sie bitte die Notdienstnummer.', ['desktop' => ['fontSize' => '14px', 'color' => '@gedaempft', 'margin' => ['bottom' => '22px']]]),
['id' => $praefix.'-formular', 'type' => 'form', 'props' => ['form' => 'kontakt']],
], ['desktop' => ['backgroundColor' => '@papier', 'padding' => $alleSeiten('40px'), 'borderRadius' => $alleEcken('20px')], 'mobile' => ['padding' => $alleSeiten('24px')]]), 'rise', 'short', null, 'strong'),
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
], ['desktop' => ['backgroundColor' => '@weich']], 'kontakt');
};
// ---------------------------------------------------------------------------------------------
// Startseite
// ---------------------------------------------------------------------------------------------
$startseite = [
'schemaVersion' => $SCHEMA,
'title' => 'Elektro Bernhard GmbH',
'description' => 'Elektroinstallation, Smart Home und 24h-Notdienst in Baden-Baden, Rastatt und Umgebung. Über 35 Jahre Erfahrung. Jetzt Elektro Bernhard kontaktieren.',
'blocks' => [
// 1 – Hero: das Teamfoto als Hintergrund, dunkel überlagert, weißer Titel.
$section('hero', [
$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 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', [
$kontaktBlock('hero-telefon', ['phone' => $buero], ['desktop' => [
'color' => '@papier', 'fontSize' => '15px', 'fontWeight' => '700', 'minHeight' => '54px', 'alignItems' => 'center', 'justifyContent' => 'center',
'padding' => ['top' => '14px', 'right' => '24px', 'bottom' => '14px', 'left' => '20px'],
'borderWidth' => $alleSeiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff66', 'borderRadius' => $alleEcken('8px'),
'hover' => ['backgroundColor' => '#ffffff1f', 'borderColor' => '@papier'],
]]),
]),
], ['desktop' => ['columns' => 'auto', 'gap' => 'small'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]), 'rise', 'long'),
$fx($row('hero-zahlen', [
['id' => 'hero-zahl-jahre', 'type' => 'stat', 'props' => ['value' => '35+', 'label' => 'Jahre Erfahrung']],
['id' => 'hero-zahl-team', 'type' => 'stat', 'props' => ['value' => '11', 'label' => 'Mitarbeiter im Team']],
['id' => 'hero-zahl-notdienst', 'type' => 'stat', 'props' => ['value' => '24h', 'label' => 'Notdienst erreichbar']],
], ['desktop' => ['columns' => 'three', 'gap' => 'medium'], 'mobile' => ['columns' => 'three', 'gap' => 'small']], ['desktop' => ['margin' => ['top' => '56px'], 'padding' => ['top' => '28px'], 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '#ffffff2e', 'color' => '@papier', 'fontSize' => '36px', 'maxWidth' => '640px'], 'mobile' => ['fontSize' => '24px', 'margin' => ['top' => '36px']]]), 'fade', 'long'),
], ['desktop' => ['maxWidth' => '780px']]),
], [
'desktop' => ['backgroundImage' => $medien['team']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0e1013', 'overlayOpacity' => 0.68,
'color' => '@papier', 'minHeight' => '92dvh', 'padding' => ['top' => '230px', 'bottom' => '100px'], 'display' => 'flex', 'alignItems' => 'center'],
'mobile' => ['padding' => ['top' => '176px', 'bottom' => '64px'], 'minHeight' => 'auto'],
]),
// 2 – Leistungen: vier Karten
$section('leistungen', [
$row('leistungen-kopf', [
$column('leistungen-kopf-titel', [
$fx($eyebrow('leistungen-label', 'Unsere Leistungen'), 'fade'),
$fx($heading('leistungen-titel', 'Elektrotechnik aus einer Hand', 2, $h2Stil), 'rise'),
]),
$column('leistungen-kopf-text', [
$fx($text('leistungen-intro', 'Wir übernehmen Beratung, Planung und Installation. Auch nach Abschluss der Arbeiten erreichen Sie uns bei Fragen oder Störungen.', ['desktop' => [...$fliess['desktop'], 'maxWidth' => '460px']]), 'rise', 'short'),
], ['desktop' => ['alignSelf' => 'flex-end']]),
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px', 'bottom' => '40px']]]),
$row('leistungen-raster', array_map(
static fn (array $l, int $i): array => [...$karte('leistung-'.$l[0], $l[1], $l[2], $l[3], '/leistungen/'.$l[0]), 'layout' => ['effects' => ['shadow' => 'soft', 'hover' => 'lift', 'entrance' => 'rise', 'delay' => ['none', 'short', 'medium', 'long'][$i]]]],
$leistungen, array_keys($leistungen),
), ['desktop' => ['columns' => 'four', 'gap' => 'medium'], 'tablet' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
], [], 'service'),
// 3 – Über uns: Foto mit Schatten, daneben Text und die Stärken
$section('unternehmen', [
$row('ueber-uns-zeile', [
$column('ueber-uns-bild-spalte', [
$fx($bild('ueber-uns-bild', $medien['schaltschrank'], 'Elektrofachkraft bei der Arbeit an einem Schaltschrank', ['desktop' => ['imageHeight' => '560px', 'objectFit' => 'cover', 'imageRadius' => $alleEcken('20px')], 'mobile' => ['imageHeight' => '340px']]), 'left', 'none', null, 'strong'),
], ['desktop' => ['alignSelf' => 'center']]),
$column('ueber-uns-text', [
$fx($eyebrow('ueber-uns-label', 'Über uns'), 'fade'),
$fx($heading('ueber-uns-titel', 'Ein Betrieb mit Tradition – und moderner Technik', 2, $h2Stil), 'rise'),
$fx($text('ueber-uns-1', 'Elektro Bernhard kommt ursprünglich aus Hügelsheim. Heute finden Sie uns in der Ooser Hauptstraße 51 in Baden-Baden. Elektroinstallation und Kundenservice gehören seit über 35 Jahren zu unserer Arbeit; 2017 wurde der Betrieb modernisiert – seitdem mit dem Schwerpunkt auf Gebäudetechnik und Smart Home.', $fliess), 'rise', 'short'),
$fx($row('staerken-raster', array_map(
static fn (array $s): array => $punkt('staerke-'.$s[0], $s[1], $s[2], $s[3]),
array_slice($staerken, 0, 4),
), ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '10px']]]), 'rise', 'medium'),
$fx($text('ueber-uns-link', '<a href="/leistungen">Mehr über unsere Arbeit →</a>', $linkStil), 'fade', 'long'),
], ['desktop' => ['alignSelf' => 'center']]),
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
], ['desktop' => ['backgroundColor' => '@weich']], 'ueber-uns'),
// 4 – Kundenstimmen als Slider
$section('stimmen', [
$row('stimmen-kopf', [
$column('stimmen-kopf-titel', [
$fx($eyebrow('stimmen-label', 'Google-Bewertungen'), 'fade'),
$fx($heading('stimmen-titel', 'Was unsere Kunden sagen', 2, $h2Stil), 'rise'),
]),
$column('stimmen-kopf-link', [
$fx($button('stimmen-link', 'Auf Google bewerten', 'https://g.page/ElektroBernhardbadenbaden', ['desktop' => ['margin' => ['top' => '0px']]], ['desktop' => ['appearance' => 'outline']]), 'fade', 'short'),
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'flex-end'], 'mobile' => ['justifyContent' => 'flex-start']]),
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px', 'bottom' => '36px']]]),
['id' => 'stimmen-slider', 'type' => 'slider', 'props' => ['desktopVisible' => 3, 'tabletVisible' => 2, 'mobileVisible' => 1, 'snap' => true, 'dots' => true, 'autoplay' => 7],
'children' => array_map(static function (array $s) use ($alleEcken, $alleSeiten): array {
return [
'id' => 'stimme-'.$s[0], 'type' => 'quote',
'props' => ['text' => $s[1], 'author' => $s[2], 'role' => $s[3], 'rating' => 5],
'style' => ['desktop' => ['backgroundColor' => '@papier', 'padding' => $alleSeiten('30px'), 'borderWidth' => $alleSeiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'borderRadius' => $alleEcken('16px'), 'fontSize' => '16px', 'height' => '100%']],
'css' => "& .quote-rating{color:#e8a300;letter-spacing:3px;font-size:15px}\n& figcaption span{display:block;color:#5d636b;font-weight:400;font-size:13px;margin-top:4px}",
'layout' => ['effects' => ['shadow' => 'soft']],
];
}, $stimmen)],
$text('stimmen-hinweis', 'Ausgewählte Google-Rezensionen aus 2024. Stand der Auswahl: 14.09.2026. Es wird kein Gesamtdurchschnitt ausgewiesen.', ['desktop' => ['fontSize' => '13px', 'color' => '@gedaempft', 'margin' => ['top' => '24px']]]),
]),
// 5 – Marken
$section('marken', [
$row('marken-zeile', array_merge(
[$column('marken-label-spalte', [$text('marken-label', 'Wir arbeiten mit bewährten Marken', ['desktop' => ['color' => '@gedaempft', 'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.12em', 'textTransform' => 'uppercase']])])],
array_map(static fn (string $name, int $i): array => [
'id' => 'marke-'.($i + 1), 'type' => 'column',
'children' => [['id' => 'marke-name-'.($i + 1), 'type' => 'text', 'props' => ['text' => $name], 'style' => ['desktop' => ['fontSize' => '24px', 'fontWeight' => '900', 'letterSpacing' => '-0.04em', 'color' => '#6b7178', 'textAlign' => 'center'], 'mobile' => ['fontSize' => '20px']]]],
], $marken, array_keys($marken)),
), [], ['desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'alignItems' => 'center', 'justifyContent' => 'space-between', 'gap' => '40px', 'margin' => ['top' => '0px']], 'mobile' => ['gap' => '22px', 'justifyContent' => 'center']]),
], ['desktop' => ['padding' => ['top' => '40px', 'bottom' => '40px'], 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@linie'], 'mobile' => ['padding' => ['top' => '32px', 'bottom' => '32px']]]),
// 6 – Solar: dunkles Band
$section('energie', [
$row('energie-zeile', [
$column('energie-text', [
$fx($eyebrow('energie-label', 'Regenerative Energie', '#ff7d90'), 'fade'),
$fx($heading('energie-titel', 'Sie planen eine Solaranlage?', 2, ['desktop' => [...$h2Stil['desktop'], 'color' => '@papier'], 'mobile' => $h2Stil['mobile']]), 'rise'),
$fx($text('energie-intro', 'Solaranlagen für Ihr Zuhause oder Geschäftsgebäude – mit der eigenständigen Elektro Bernhard Regenerative Energie GmbH.', $fliessHell), 'rise', 'short'),
$fx($button('energie-knopf', 'Mehr über Solarenergie', '/regenerative-energie', ['desktop' => ['margin' => ['top' => '12px']]]), 'rise', 'medium'),
], ['desktop' => ['alignSelf' => 'center']]),
$fx($column('energie-liste', [
$punkt('energie-punkt-1', 'check', 'Individuelle Solarplanung', 'Auslegung nach Dach, Verbrauch und Ziel.', '#ff7d90', '#c2c6cc'),
$punkt('energie-punkt-2', 'check', 'Fachgerechte Installation', 'Montage und Anschluss aus einer Hand.', '#ff7d90', '#c2c6cc'),
$punkt('energie-punkt-3', 'check', 'Angebot für Ihr Gebäude', 'Transparent und auf Ihr Vorhaben zugeschnitten.', '#ff7d90', '#c2c6cc'),
], ['desktop' => ['backgroundColor' => '@karte-dunkel', 'color' => '@papier', 'padding' => $alleSeiten('36px'), 'borderRadius' => $alleEcken('20px'), 'display' => 'flex', 'flexDirection' => 'column', 'gap' => '22px']]), 'right', 'short'),
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
], ['desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier']]),
// 7 – Häufige Fragen
$section('fragen', [
$row('fragen-zeile', [
$column('fragen-intro', [
$fx($eyebrow('fragen-label', 'FAQ'), 'fade'),
$fx($heading('fragen-titel', 'Häufige Fragen', 2, $h2Stil), 'rise'),
$fx($text('fragen-text', 'Wenn Ihre Frage hier nicht dabei ist, rufen Sie uns gerne an.', ['desktop' => [...$fliess['desktop'], 'maxWidth' => '360px']]), 'rise', 'short'),
$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', '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']]],
'css' => "& > summary{font-size:17px;padding:18px 0}\n& > summary::after{color:#c8102e}",
'children' => [['id' => 'antwort-'.($i + 1), 'type' => 'text', 'props' => ['text' => $f[1]], 'style' => ['desktop' => ['fontSize' => '15px', 'color' => '@gedaempft', 'lineHeight' => 1.75, 'padding' => ['bottom' => '14px']]]]],
];
}, $fragen, array_keys($fragen)), 'style' => ['desktop' => ['gap' => '12px']]],
]),
], ['desktop' => ['columns' => 'two', 'gap' => 'large'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
]),
// 8 – Kontakt
$kontaktAbschnitt('start'),
],
];
// ---------------------------------------------------------------------------------------------
// Seiten anlegen und veröffentlichen
// ---------------------------------------------------------------------------------------------
$erstellt = [];
$anlegen = static function (string $slug, ?string $elternId, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db, &$erstellt): string {
$id = $pages->createPage($slug, $elternId, 'page', $dokument);
// Der Dokumenttitel ist der kurze Name (Brotkrumen, Menüs); der lange steht in „Titel für die Suche“.
if ('' !== $seoTitel) {
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
}
// Das Bild beim Teilen; im Editor unter „Vorschaubild beim Teilen“.
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;
};
$anlegen('home', null, $startseite, 'Elektriker Baden-Baden | Elektro Bernhard GmbH', $medien['team']['id']);
// Die Leistungsübersicht: Pfade wie /leistungen/smart-home brauchen im CMS eine Elternseite.
$leistungenId = $anlegen('leistungen', null, [
'schemaVersion' => $SCHEMA, 'title' => 'Leistungen',
'description' => 'Elektroinstallation, Smart Home, 24h-Notdienst sowie Beratung und Planung von Elektro Bernhard in Baden-Baden.',
'blocks' => [
$seitenkopf('leistungen-seite', 'Unsere Leistungen', 'Elektrotechnik aus einer Hand', 'Wir übernehmen Beratung, Planung und Installation. Auch nach Abschluss der Arbeiten erreichen Sie uns bei Fragen oder Störungen.'),
$section('leistungen-seite-raster-abschnitt', [
$fx($heading('leistungen-seite-h2', 'Vier Leistungen, ein Ansprechpartner', 2, ['desktop' => [...$h2Stil['desktop'], 'margin' => ['bottom' => '36px']], 'mobile' => $h2Stil['mobile']]), 'rise'),
$row('leistungen-seite-raster', array_map(
static fn (array $l, int $i): array => [...$karte('leistungen-seite-'.$l[0], $l[1], $l[2], $l[3], '/leistungen/'.$l[0]), 'layout' => ['effects' => ['shadow' => 'soft', 'hover' => 'lift', 'entrance' => 'rise', 'delay' => ['none', 'short', 'medium', 'long'][$i]]]],
$leistungen, array_keys($leistungen),
), ['desktop' => ['columns' => 'two', 'gap' => 'medium'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
]),
$section('leistungen-seite-staerken', [
$fx($eyebrow('staerken-label', 'Wer wir sind'), 'fade'),
$fx($heading('staerken-titel', 'Warum Elektro Bernhard', 2, $h2Stil), 'rise'),
$fx($text('staerken-intro', 'Seit über 35 Jahren Elektroinstallation und Kundenservice – seit der Modernisierung 2017 mit dem Schwerpunkt auf moderner Gebäudetechnik.', ['desktop' => [...$fliess['desktop'], 'maxWidth' => '620px', 'margin' => ['bottom' => '30px']]]), 'rise', 'short'),
$row('staerken-alle', array_map(static fn (array $s): array => $fx($punkt('staerke-seite-'.$s[0], $s[1], $s[2], $s[3]), 'rise', 'short'), $staerken),
['desktop' => ['columns' => 'three', 'gap' => 'large'], 'tablet' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], ['desktop' => ['margin' => ['top' => '0px']]]),
$cta('leistungen-seite-cta'),
], ['desktop' => ['backgroundColor' => '@weich']]),
],
], 'Leistungen | Elektro Bernhard GmbH', $medien['schaltschrank']['id']);
foreach ($leistungen as [$slug, $icon, $name, $kurz]) {
[$titel, $h1, $intro, $absaetze] = $leistungstexte[$slug];
$knopf = 'notdienst' === $slug
? $kontaktBlock($slug.'-notdienst-kontakt', ['phone' => $notdienst], ['desktop' => ['fontSize' => '30px', 'fontWeight' => '900', 'color' => '@papier', 'margin' => ['top' => '24px']]])
: $button($slug.'-anfrage', 'Projekt anfragen', '/kontakt', ['desktop' => ['margin' => ['top' => '20px']]]);
$anlegen($slug, $leistungenId, [
'schemaVersion' => $SCHEMA, 'title' => $name, 'description' => $intro,
'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, 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'),
]),
],
], $titel, $medien['schaltschrank']['id']);
}
$anlegen('kontakt', null, [
'schemaVersion' => $SCHEMA, 'title' => 'Kontakt & Anfahrt',
'description' => 'Elektro Bernhard: Ooser Hauptstr. 51, Baden-Baden. Telefon '.$buero.'. Projekt anfragen oder 24h-Notdienst unter '.$notdienst.' erreichen.',
'blocks' => [
$seitenkopf('kontakt-seite', 'Kontakt & Anfahrt', 'Kontakt zu Elektro Bernhard', 'Schreiben Sie uns eine Nachricht oder rufen Sie direkt an – wir melden uns innerhalb von 24 Stunden.'),
$kontaktAbschnitt('seite'),
],
], 'Kontakt & Anfahrt | Elektro Bernhard Baden-Baden', $medien['team']['id']);
$anlegen('regenerative-energie', null, [
'schemaVersion' => $SCHEMA, 'title' => 'Solarenergie',
'description' => 'Solaranlagen für Wohnhäuser und Geschäftsgebäude: Planung, Installation und individuelles Angebot der Elektro Bernhard Regenerative Energie GmbH.',
'blocks' => [
$seitenkopf('solar', 'Elektro Bernhard Regenerative Energie GmbH', 'Solaranlagen planen und installieren', 'Solarplanung und Installation für Ihr Zuhause oder Geschäftsgebäude.', [$button('solar-knopf', 'Solaranlage anfragen', '/kontakt', ['desktop' => ['margin' => ['top' => '20px']]])]),
$textkoerper('solar', [
['Für Wohnhäuser und Geschäftsgebäude', 'Jedes Gebäude bringt andere Voraussetzungen mit. Die Elektro Bernhard Regenerative Energie GmbH plant und installiert Solaranlagen und erstellt ein maßgeschneidertes Angebot für Ihr Vorhaben.'],
['Planung und Installation', 'Beschreiben Sie uns Ihr Gebäude und Ihre Vorstellungen. Auf dieser Grundlage besprechen wir die nächsten Schritte für Ihre Solarplanung.'],
['Ihr Ansprechpartner', "<strong>Elektro Bernhard Regenerative Energie GmbH</strong><br>Ooser Hauptstr. 51 · 76532 Baden-Baden<br>Geschäftsführer: Ridvan Kantarci · HRB 741275"],
['', 'Die Gesellschaft ist rechtlich eigenständig und unabhängig von der Elektro Bernhard GmbH.'],
], [], [
$text('solar-kontaktlink', '<a href="/kontakt">Kontakt zur Solarplanung aufnehmen →</a>', ['desktop' => [...$linkStil['desktop'], 'margin' => ['top' => '30px']]]),
$cta('solar-cta'),
]),
],
], 'Solarplanung & Installation | Elektro Bernhard Regenerative Energie', $medien['team']['id']);
$anlegen('impressum', null, [
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
'description' => 'Anbieterangaben und Kontaktinformationen der Elektro Bernhard GmbH und der Elektro Bernhard Regenerative Energie GmbH.',
'blocks' => [
$seitenkopf('impressum', 'Rechtliche Informationen', 'Impressum'),
$textkoerper('impressum', [
['Elektro Bernhard GmbH', "Ooser Hauptstr. 51<br>76532 Baden-Baden<br><br>Vertreten durch den Geschäftsführer Ridvan Kantarci.<br><br>Telefon: <a href=\"tel:+4972213944909\">+49 7221 3944909</a><br>E-Mail: <a href=\"mailto:$mail\">$mail</a><br><br>Handelsregister: HRB 731961<br>Umsatzsteuer-Identifikationsnummer: DE 815 618 082"],
['Elektro Bernhard Regenerative Energie GmbH', "Eigenständige Gesellschaft, unabhängig von der Elektro Bernhard GmbH.<br>Ooser Hauptstr. 51<br>76532 Baden-Baden<br>Vertreten durch Ridvan Kantarci.<br>Handelsregister: HRB 741275"],
['Gestaltung und Umsetzung', '<a href="https://www.kant-it.de/">Kant-IT Solutions</a>'],
['Bildnachweise', 'Logo, Teamfoto und Elektroinstallationsmotiv aus dem bestehenden Webauftritt von Elektro Bernhard. Markenbezeichnungen gehören ihren jeweiligen Inhabern.'],
]),
],
], 'Impressum | Elektro Bernhard GmbH');
$anlegen('datenschutzerklaerung', null, [
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutzhinweise',
'description' => 'Informationen zur Verarbeitung personenbezogener Daten bei Elektro Bernhard: Websitebesuch, Kontaktaufnahme und Ihre Rechte.',
'blocks' => [
$seitenkopf('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, wie wir Daten beim Besuch unserer Website und bei Ihrer Kontaktaufnahme verarbeiten.'),
$textkoerper('datenschutz', [
['1. Verantwortlicher', "Elektro Bernhard GmbH, Ooser Hauptstr. 51, 76532 Baden-Baden. Geschäftsführer: Ridvan Kantarci. Telefon: +49 7221 3944909. E-Mail: <a href=\"mailto:$mail\">$mail</a>."],
['2. Besuch der Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten, insbesondere IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserinformationen und HTTP-Status. Das dient der sicheren Bereitstellung und Fehleranalyse. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Umfang und Dauer der Serverprotokollierung richten sich nach der eingesetzten Hostingkonfiguration.'],
['3. Lokale Inhalte und Cookies', 'Die Website selbst setzt keine Analyse- oder Werbecookies ein. Bilder, Schriften und Gestaltung werden vom eigenen Webserver geladen. Es werden keine Trackingdienste oder Karten beim Seitenaufruf eingebettet.'],
['4. Kontaktanfragen', 'Wenn Sie uns kontaktieren, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten zur Bearbeitung Ihrer Anfrage. Rechtsgrundlage ist bei vertragsbezogenen Anfragen Art. 6 Abs. 1 lit. b DSGVO, ansonsten unser berechtigtes Interesse an der Bearbeitung von Anfragen gemäß Art. 6 Abs. 1 lit. f DSGVO.<br><br>Das Kontaktformular dieser Website überträgt Ihre Angaben an unseren Server und versendet sie als E-Mail an uns. NOCH ZU PRÜFEN: Hostinganbieter, Verarbeitungsländer, Auftragsverarbeitung und konkrete Aufbewahrungsdauer ergänzen.<br><br>Wir löschen Anfragedaten, wenn sie für die Bearbeitung nicht mehr erforderlich sind und keine gesetzlichen Aufbewahrungspflichten oder berechtigten Gründe für eine weitere Speicherung bestehen.'],
['5. Externe Links', 'Google Maps, Google-Bewertungen und unsere Profile in sozialen Netzwerken sind als Links eingebunden. Erst beim Öffnen eines solchen Links wird die jeweilige externe Website aufgerufen. Dort gelten die Datenschutzinformationen des betreffenden Anbieters.'],
['6. Ihre Rechte', "Unter den gesetzlichen Voraussetzungen haben Sie Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Sie können einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO widersprechen. Eine erteilte Einwilligung können Sie mit Wirkung für die Zukunft widerrufen.<br><br>Außerdem können Sie sich bei einer Datenschutzaufsichtsbehörde beschweren, beispielsweise beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.<br><br>Für Fragen wenden Sie sich bitte an <a href=\"mailto:$mail\">$mail</a>."],
]),
],
], 'Datenschutz | Elektro Bernhard GmbH');
$fehlerId = $anlegen('404', null, [
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
'description' => 'Die gesuchte Seite existiert nicht oder wurde verschoben. Über die Startseite finden Sie zurück.',
'blocks' => [
$seitenkopf('fehler', 'Fehler 404', 'Seite nicht gefunden', 'Die gesuchte Seite existiert nicht oder wurde verschoben.', [$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '20px']]])]),
],
]);
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
// ---------------------------------------------------------------------------------------------
// Menüs, Einstellungen, Weiterleitungen, Konto
// ---------------------------------------------------------------------------------------------
$seite = static fn (string $label, string $slug, string $anchor = ''): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt[$slug], 'anchor' => $anchor, 'children' => []];
$menus->save('header', 'Hauptnavigation', [
$seite('Leistungen', 'leistungen'),
$seite('Über uns', 'home', 'ueber-uns'),
$seite('Solarenergie', 'regenerative-energie'),
$seite('Kontakt', 'kontakt'),
], 0);
$menus->save('secondary', 'Notdienst', [
['label' => '24h Notdienst '.$notdienst, 'type' => 'url', 'url' => 'tel:+4915125245851', 'icon' => 'phone', 'iconPosition' => 'before', 'children' => []],
], 0);
$menus->save('footer', 'Fußzeile', [
$seite('Unsere Leistungen', 'leistungen'),
$seite('Das Unternehmen', 'home', 'ueber-uns'),
$seite('Regenerative Energie', 'regenerative-energie'),
$seite('Kontakt & Anfahrt', 'kontakt'),
], 0);
$menus->save('legal', 'Rechtliches', [$seite('Impressum', 'impressum'), $seite('Datenschutz', 'datenschutzerklaerung')], 0);
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–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
// „Soziale Profile“ in Kopfleiste und Fuß, geführt als sameAs in den strukturierten Daten.
'social' => [
'facebook' => 'https://www.facebook.com/elektrobernhardgmbh',
'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,
'motion' => 'always',
'homePath' => 'home', 'privacyPath' => 'datenschutzerklaerung', 'imprintPath' => 'impressum',
'consentBanner' => false, 'scrollTop' => true, 'scrollTopColor' => '@rot', 'scrollTopInk' => '@papier', 'postMetaHeader' => true,
]);
echo "Einstellungen gespeichert\n";
foreach (['/datenschutz' => '/datenschutzerklaerung', '/service' => '/leistungen', '/ueber-uns' => '/home'] as $von => $nach) {
$redirects->save($von, $nach, 301);
}
echo "Weiterleitungen angelegt\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";
+996
View File
@@ -0,0 +1,996 @@
<?php
/**
* Baut die Website „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ als Inhalt dieses CMS auf.
*
* Vorlage ist das gelieferte Paket „Immobilien-DEEG-Website.zip“ (15.09.2026): ein Onepager aus
* index.html, style.css und main.js mit dem unveränderten DEEG-Logo. Wortlaut, Farben (Logo-Blau
* #0081c7, Anthrazit #343a40, helle Fläche #f5fafd), Schriftgrößen, Abstände und die Reihenfolge
* der Abschnitte sind daraus übernommen. Gebaut ist alles aus gewöhnlichen Bausteinen – kein
* eigener Baustein, kein Plugin, kein eigenes Skript, keine externe Schrift (Arial wie die Vorlage).
*
* Bewusste Abweichungen:
* - Kontakt und Bewertungsanfrage sind zwei serverseitige Formulare dieses CMS („kontakt“ und
* „bewertung“ – ein Preisfinder in drei Schritten mit Feldern je Objektart und einer
* Zusammenfassung). Die Vorlage verlinkt dafür noch auf die bisherigen Seiten unter
* www.immobiliendeeg.de; hier landen die Anfragen in der Verwaltung und per E-Mail.
* - Nur „Aktuelle Angebote ansehen“ zeigt weiter auf www.immobiliendeeg.de/immobilien/: die
* Immobilien-Datenbank ist nicht Teil des Pakets.
* - Impressum und Datenschutz sind eigene Seiten. Register und Vertretung stammen vom Impressum
* der laufenden Website (15.09.2026); was nur DEEG beantworten kann, steht als „NOCH ZU PRÜFEN“.
* - Blau als Fläche mit weißer Schrift trägt #0076b6 statt #0081c7 (4,9:1 statt 4,2:1), kleine
* blaue Schrift auf Weiß #006aa3. Das Logo-Blau bleibt für Linien und große Überschriften.
*
* 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-immobilien-deeg.sh).
* Erstes Argument: der Ordner mit logo-deeg.png, architecture.jpg und architecture-detail.jpg.
*/
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, 'immobilien-deeg')) {
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-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);
$firma = 'Deeg Hausverwaltung und Immobilien GmbH';
$mail = 'deeg@immobiliendeeg.de';
$telefon = '07441 84542';
$telHref = 'tel:+49744184542';
$angebote = 'https://www.immobiliendeeg.de/immobilien/';
$route = 'https://www.google.com/maps/search/?api=1&query=Ringstra%C3%9Fe+30+72250+Freudenstadt';
$SCHEMA = 23;
// ---------------------------------------------------------------------------------------------
// Bausteine
// ---------------------------------------------------------------------------------------------
$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];
$obenUnten = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
$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: 108 px am Schreibtisch, 80 auf dem Tablet, 65 auf dem Telefon. */
$abschnitt = static function (string $id, array $children, array $style = [], ?string $anchor = null) use ($section): array {
$basis = [
'desktop' => ['padding' => ['top' => '108px', 'bottom' => '108px']],
'tablet' => ['padding' => ['top' => '80px', 'bottom' => '80px']],
'mobile' => ['padding' => ['top' => '65px', 'bottom' => '65px']],
];
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 – Kleinzeile, Aktionen, Leiste, Fuß. */
$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', ?string $anchor = null, string $css = ''): array {
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
if ([] !== $style) {
$block['style'] = $style;
}
if ('all' !== $sichtbarkeit) {
$block['layout'] = ['visibility' => $sichtbarkeit];
}
if (null !== $anchor) {
$block['anchor'] = $anchor;
}
if ('' !== $css) {
$block['css'] = $css;
}
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 kurze Strich vor der Kleinzeile, der Verlauf über dem Startbild. */
$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;
};
/** Ein Button ohne den Pfeil des Bausteins: die Vorlage kommt ausdrücklich ohne Pfeile aus. */
$button = static function (string $id, string $text, string $href, array $style = []): array {
return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style, 'css' => '& span{display:none}'];
};
$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.
*
* @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.
$h2Stil = ['desktop' => ['fontWeight' => '400', 'fontSize' => '45px', 'lineHeight' => 1.16, 'letterSpacing' => '-0.045em', 'margin' => ['bottom' => '26px']], 'tablet' => ['fontSize' => '38px'], 'mobile' => ['fontSize' => '35px']];
$h3Stil = ['desktop' => ['fontWeight' => '400', 'fontSize' => '24px', 'lineHeight' => 1.3, 'letterSpacing' => '-0.035em', 'margin' => ['bottom' => '18px']], 'tablet' => ['fontSize' => '22px']];
$fliess = ['desktop' => ['color' => '@text', 'fontSize' => '16px', 'lineHeight' => 1.85, 'margin' => ['bottom' => '20px']]];
$kleinStil = ['desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '25px']], 'mobile' => ['fontSize' => '11px', 'margin' => ['bottom' => '18px']]];
$linkCss = "& a{color:#006aa3;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#343a40}";
$textLinkCss = "& a{display:inline-flex;align-items:center;font-size:14px;font-weight:600;border-bottom:1px solid #8d9a9f;padding-bottom:4px;text-decoration:none}\n& a:hover{color:#006aa3;border-color:#006aa3}";
$formularCss = '& .form-field label{font-size:14px;font-weight:600;color:#343a40}
& .form-field input{background:#fff;border:1px solid #c8dce8;padding:12px 13px;font-size:16px;color:#343a40}
& .form-field select{background:#fff;border:1px solid #c8dce8;padding:12px 13px;font-size:16px;color:#343a40}
& .form-field textarea{background:#fff;border:1px solid #c8dce8;padding:12px 13px;font-size:16px;color:#343a40;min-height:120px}
& .field-note{font-size:13px;color:#58666d}
& .check-row label{font-weight:400;font-size:14px;line-height:1.6}
& .cms-button{width:100%;justify-content:center;margin-top:4px}';
/** Die Kleinzeile in Versalien; im Hero mit dem kurzen blauen Strich davor. */
$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@anthrazit', string $abstand = '25px'): array => $text($id, $inhalt, [
'desktop' => [...$kleinStil['desktop'], 'color' => $farbe, 'margin' => ['bottom' => $abstand]],
'mobile' => $kleinStil['mobile'],
]);
// ---------------------------------------------------------------------------------------------
// Website, Gestaltung, Medien, Formulare
// ---------------------------------------------------------------------------------------------
$db->insert('website', ['id' => 'default', 'name' => 'DEEG Immobilien & Hausverwaltung', 'default_locale' => 'de', 'theme' => '{}']);
$theme->save(
[
['key' => 'blau', 'name' => 'Logo-Blau', 'value' => '#0081c7'],
['key' => 'blau-tief', 'name' => 'Blau als Fläche', 'value' => '#0076b6'],
['key' => 'blau-dunkel', 'name' => 'Blau dunkel', 'value' => '#006aa3'],
['key' => 'anthrazit', 'name' => 'Anthrazit', 'value' => '#343a40'],
['key' => 'text', 'name' => 'Fließtext', 'value' => '#58666d'],
['key' => 'meta', 'name' => 'Nebentext', 'value' => '#66747b'],
['key' => 'hell', 'name' => 'Helle Fläche', 'value' => '#f5fafd'],
['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#dbe1e3'],
['key' => 'linie-hell', 'name' => 'Trennlinie hell', 'value' => '#e0ebf1'],
['key' => 'kante', 'name' => 'Kopfkante', 'value' => '#e9edef'],
['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'],
],
[
// Die Vorlage lädt bewusst keine Webschrift; Arial steht auf jedem Gerät bereit.
['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 700], 'fallback' => 'sans-serif'],
],
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'backgroundColor' => '@papier', 'linkColor' => '@anthrazit', 'containerWidth' => '1320px', 'headingScale' => 1.25, 'linkUnderline' => false],
['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 14, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau-tief', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
);
echo "Farben, Schriften und Buttons gesetzt\n";
$medien = [];
foreach ([
'logo' => ['logo-deeg.png', 'DEEG Hausverwaltung und Immobilien'],
'hero' => ['architecture.jpg', 'Moderne Wohnarchitektur mit klaren Linien und großzügigen Fenstern'],
'detail' => ['architecture-detail.jpg', 'Wohngebäude mit charaktervoller Architektur'],
// Die Bildauswahl der Objektart im Preisfinder: einfache Piktogramme als Platzhalter,
// in der Mediathek jederzeit gegen Fotos tauschbar.
'opt-wohnung' => ['option-wohnung.png', 'Piktogramm: Wohnung in einem Gebäude'],
'opt-haus' => ['option-haus.png', 'Piktogramm: Haus'],
'opt-mfh' => ['option-mehrfamilienhaus.png', 'Piktogramm: Mehrfamilienhaus'],
'opt-grund' => ['option-grundstueck.png', 'Piktogramm: Grundstück mit Baum'],
] 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";
}
$einwilligung = 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden verwendet, um meine Anfrage zu beantworten.';
$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' => 'anliegen', 'type' => 'select', 'label' => 'Worum geht es?', 'required' => false, 'options' => ['Verkauf', 'Vermietung', 'Verwaltung', 'Etwas anderes']],
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true],
['name' => 'datenschutz', 'type' => 'consent', 'label' => $einwilligung],
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über immobiliendeeg.de', 'success' => 'Vielen Dank. Ihre Nachricht ist angekommen – wir melden uns bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Nachricht senden'], 0);
echo "Formular angelegt: kontakt\n";
// Die Bewertungsanfrage als Preisfinder in drei Schritten, nach dem Vorbild der laufenden
// Website: Objektart und Anlass, dann die Eckdaten je Objektart (Felder hinter Bedingungen), dann
// Lage und Kontakt, zum Schluss die Zusammenfassung. Bewusst kein Plugin - ein Formular mit
// Schritten und Bedingungen reicht, und die Anfrage liegt wie jede andere in der Verwaltung.
$wohnen = ['Wohnung', 'Haus', 'Mehrfamilienhaus'];
$bedingt = static fn (array $feld, array $werte): array => [...$feld, 'showIf' => ['field' => 'immobilienart', 'values' => $werte]];
$forms->save('bewertung', 'Bewertungsanfrage', [
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Womit können wir Ihnen helfen?'],
['name' => 'immobilienart', 'type' => 'radio', 'label' => 'Um welche Immobilie geht es?', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Mehrfamilienhaus', 'Grundstück'],
'images' => ['Wohnung' => $medien['opt-wohnung']['id'], 'Haus' => $medien['opt-haus']['id'], 'Mehrfamilienhaus' => $medien['opt-mfh']['id'], 'Grundstück' => $medien['opt-grund']['id']]],
['name' => 'anlass', 'type' => 'radio', 'label' => 'Was haben Sie vor?', 'required' => true, 'options' => ['Verkaufen', 'Vermieten']],
['name' => 'schritt_eckdaten', 'type' => 'step', 'label' => 'Eckdaten', 'help' => 'Was wir über die Immobilie wissen sollten. Schätzwerte genügen.'],
$bedingt(['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche', 'required' => true, 'min' => 10, 'max' => 2000, 'unit' => 'm²'], $wohnen),
$bedingt(['name' => 'grundstueck', 'type' => 'number', 'label' => 'Grundstücksfläche', 'required' => false, 'min' => 10, 'max' => 100000, 'unit' => 'm²'], ['Haus', 'Mehrfamilienhaus']),
$bedingt(['name' => 'flaeche_grundstueck', 'type' => 'number', 'label' => 'Grundstücksfläche', 'required' => true, 'min' => 10, 'max' => 100000, 'unit' => 'm²'], ['Grundstück']),
$bedingt(['name' => 'zimmer', 'type' => 'select', 'label' => 'Zimmer', 'required' => false, 'options' => ['1', '2', '3', '4', '5', '6', '7', '8 oder mehr']], ['Wohnung', 'Haus']),
$bedingt(['name' => 'einheiten', 'type' => 'number', 'label' => 'Wohneinheiten', 'required' => true, 'min' => 2, 'max' => 200], ['Mehrfamilienhaus']),
$bedingt(['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr', 'required' => false, 'min' => 1800, 'max' => 2026], $wohnen),
$bedingt(['name' => 'zustand', 'type' => 'select', 'label' => 'Zustand', 'required' => false, 'options' => ['Neuwertig', 'Gepflegt', 'Renovierungsbedürftig', 'Sanierungsbedürftig']], $wohnen),
$bedingt(['name' => 'einbaukueche', 'type' => 'select', 'label' => 'Einbauküche', 'required' => false, 'options' => ['ja (neuwertig)', 'ja (gebraucht)', 'keine vorhanden']], ['Wohnung']),
$bedingt(['name' => 'aufzug', 'type' => 'select', 'label' => 'Aufzug', 'required' => false, 'options' => ['ja', 'nein']], ['Wohnung']),
$bedingt(['name' => 'parken', 'type' => 'select', 'label' => 'Parkmöglichkeit', 'required' => false, 'options' => ['Tiefgarage', 'Garage', 'Doppelgarage', 'Carport', 'Stellplatz', 'mehrere', 'keine']], $wohnen),
$bedingt(['name' => 'bebaubar', 'type' => 'select', 'label' => 'Ist das Grundstück bebaubar?', 'required' => false, 'options' => ['ja', 'nein', 'unklar']], ['Grundstück']),
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Lage und Kontakt', 'help' => 'Damit wir die Lage einordnen und Sie erreichen können.'],
['name' => 'strasse', 'type' => 'text', 'label' => 'Straße und Hausnummer der Immobilie', 'required' => true],
['name' => 'ort', 'type' => 'text', 'label' => 'PLZ und Ort', 'required' => true],
['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 Rückfragen zur Immobilie'],
['name' => 'hinweise', 'type' => 'textarea', 'label' => 'Was sollten wir noch wissen?', 'required' => false],
['name' => 'datenschutz', 'type' => 'consent', 'label' => $einwilligung],
], ['recipient' => $mail, 'subject' => 'Bewertungsanfrage über immobiliendeeg.de', 'success' => 'Vielen Dank. Ihre Bewertungsanfrage ist angekommen – wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Bewertung anfragen', 'summary' => true], 0);
echo "Formular angelegt: bewertung\n";
// ---------------------------------------------------------------------------------------------
// Kopfbereich: helle Leiste, darunter der weiße Kopf, der beim Scrollen oben bleibt
// ---------------------------------------------------------------------------------------------
$logo = static fn (string $id, string $breite, string $mobil) => $bild($id, $medien['logo'], 'DEEG Hausverwaltung und Immobilien', [
'desktop' => ['maxWidth' => $breite, 'margin' => $seiten('0px')],
'mobile' => ['maxWidth' => $mobil],
], '/');
$kopf = [
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
'blocks' => [
$section('kopf-leiste', [
$zeile('kopf-leiste-zeile', [
$column('kopf-leiste-ort', [
$text('kopf-leiste-ort-text', 'In Freudenstadt zuhause. Seit 1994.', ['desktop' => ['fontSize' => '13px', 'letterSpacing' => '0.02em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]]),
], [], 'hide-mobile'),
$zeile('kopf-leiste-kontakt', [
$text('kopf-leiste-telefon', '<a href="'.$telHref.'">'.$telefon.'</a>', [
'desktop' => ['fontSize' => '13px', 'letterSpacing' => '0.02em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '12px'],
], '& a:hover{text-decoration:underline}'),
$column('kopf-leiste-label', [
$text('kopf-leiste-label-text', '· Wir sind für Sie da', ['desktop' => ['fontSize' => '13px', 'letterSpacing' => '0.02em', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
], [], 'hide-mobile'),
], '5px'),
], '20px', ['justifyContent' => 'space-between'], ['justifyContent' => 'center']),
], [
'desktop' => ['backgroundColor' => '@hell', 'color' => '@anthrazit', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '9px', 'bottom' => '9px']],
]),
$section('kopf-haupt', [
$row('kopf-haupt-zeile', [
$column('kopf-marke', [$logo('kopf-logo', '113px', '100px')], ['desktop' => ['flexShrink' => 0]]),
$column('kopf-navigation', [
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'mobile', 'burgerLabel' => 'both'],
'style' => ['desktop' => [
'fontSize' => '14px', 'fontWeight' => '600', 'justifyContent' => 'flex-end', 'display' => 'flex',
// Der Menü-Knopf im Zuschnitt der Vorlage: feiner Rahmen, keine Rundung.
'burgerColor' => '@anthrazit', 'burgerBorderColor' => '#cbd4d6', 'burgerBorderRadius' => '0px', 'burgerFontSize' => '14px',
]],
// Der letzte Eintrag ist der blaue Knopf „Immobilie bewerten“.
'css' => "& .menu-list{gap:4px 30px;justify-content:flex-end;align-items:center}\n& .menu-list a{transition:color .2s,background .2s}\n& .menu-list a:hover{color:#006aa3;text-decoration:none}\n& .menu-list>li:last-child>a{background:#0076b6;color:#fff;padding:13px 18px}\n& .menu-list>li:last-child>a:hover{background:#006aa3;color:#fff}",
],
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'center']]),
], [], [
'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'space-between', 'gap' => '30px', 'minHeight' => '99px', 'margin' => ['top' => '0px']],
'tablet' => ['minHeight' => '86px'],
'mobile' => ['minHeight' => '80px'],
]),
], [
'desktop' => ['backgroundColor' => '#fffffff5', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@kante', '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', [$logo('fuss-logo', '113px', '100px')], ['desktop' => ['flexShrink' => 0]]),
$column('fuss-spruch', [
$text('fuss-spruch-text', 'Gut beraten. Persönlich begleitet.', [
'desktop' => ['fontSize' => '15px', 'color' => '@text', 'textAlign' => 'center', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '13px', 'textAlign' => 'left'],
]),
], ['mobile' => ['order' => 3, 'flexBasis' => '100%']]),
$column('fuss-nachoben', [
$text('fuss-nachoben-text', '<a href="#start">Nach oben</a>', [
'desktop' => ['fontSize' => '14px', 'color' => '@blau-dunkel', 'textAlign' => 'right', 'margin' => ['bottom' => '0px']],
'mobile' => ['margin' => ['left' => 'auto']],
], "& a{color:#006aa3}\n& a:hover{text-decoration:underline}"),
]),
], '30px', ['justifyContent' => 'space-between', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', 'padding' => ['top' => '45px', 'bottom' => '36px']], ['flexWrap' => 'wrap', 'gap' => '25px', 'padding' => ['top' => '30px', 'bottom' => '25px']]),
$zeile('fuss-unten', [
$column('fuss-recht', [
// Die Farbe steht am Block selbst: der Fuß hellt kleinen Text sonst auf 3,8:1 auf.
$text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '11px', 'color' => '@text', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '10px']]),
]),
$zeile('fuss-links', [
// Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü.
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
'style' => ['desktop' => ['fontSize' => '11px', 'gap' => '4px']],
'css' => '& a:hover{color:#006aa3}'],
$text('fuss-agentur', '<a href="https://kant-it.de">Website by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '11px', 'margin' => ['bottom' => '0px']]], '& a:hover{color:#006aa3}'),
], '25px', ['flexWrap' => 'wrap'], ['rowGap' => '12px', 'columnGap' => '18px', 'justifyContent' => 'flex-start']),
], '30px', ['justifyContent' => 'space-between', 'alignItems' => 'center', 'padding' => ['top' => '23px', 'bottom' => '23px'], 'color' => '@text'], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px', 'padding' => ['top' => '22px', 'bottom' => '28px']]),
], [
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', '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 auf heller Fläche, dann Absätze mit
* Zwischenüberschriften. Was nur DEEG beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text.
*/
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $abschnitt, $row, $column, $kleinzeile, $heading, $text, $nurOben, $nurUnten, $linkCss): array {
$kopf = $section($praefix.'-kopf', [
[...$kleinzeile($praefix.'-label', $label), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
[...$heading($praefix.'-h1', $titel, 1, [
'desktop' => ['fontWeight' => '400', 'fontSize' => '56px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.05em', 'margin' => ['bottom' => '22px']],
'tablet' => ['fontSize' => '44px'],
'mobile' => ['fontSize' => '38px', 'margin' => ['bottom' => '16px']],
]), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
[...$text($praefix.'-vorspann', $vorspann, [
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '16px'],
]), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
], [
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '84px', 'bottom' => '72px']],
'tablet' => ['padding' => ['top' => '64px', 'bottom' => '56px']],
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '40px']],
]);
$punkte = [];
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
$punkte[] = $row($praefix.'-punkt-'.$i, [
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
'desktop' => ['fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '-0.01em', 'lineHeight' => 1.5, 'color' => '@anthrazit', 'margin' => ['bottom' => '0px']],
]),
], [
'desktop' => ['flexBasis' => '280px', 'flexShrink' => 0],
'tablet' => ['flexBasis' => '220px'],
'mobile' => ['flexBasis' => 'auto'],
]),
$column($praefix.'-punkt-'.$i.'-text', [
$text($praefix.'-p-'.$i, $inhalt, [
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']],
], $linkCss),
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0']]),
], [], [
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '48px', 'margin' => ['top' => '0px'],
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '28px', 'bottom' => '28px']],
'tablet' => ['gap' => '32px'],
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '22px', 'bottom' => '22px']],
]);
}
$inhalt = $abschnitt($praefix, [
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '960px']]),
], ['desktop' => ['padding' => ['top' => '56px', 'bottom' => '108px']], 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '65px']]]);
return [$kopf, $inhalt];
};
// ---------------------------------------------------------------------------------------------
// Startseite
// ---------------------------------------------------------------------------------------------
// Nummer, Sprungziel, Symbol, Titel, Text, Titel des Aufklappers, dessen Inhalt – die drei Karten.
$leistungen = [
['01 / VERKAUF', 'verkauf', 'home', 'Ein guter Verkauf<br>beginnt mit Vertrauen.',
'Wir kennen die Region und beobachten den Markt. Gemeinsam finden wir einen realistischen Angebotspreis und bringen Ihre Immobilie überzeugend zur Geltung.',
'So begleiten wir Ihren Verkauf',
'<ol><li><strong>Wert einschätzen.</strong> Lage, Zustand und Marktsituation bilden die Grundlage.</li><li><strong>Passend präsentieren.</strong> Wir stellen Ihre Immobilie in den passenden Angebotsmedien vor.</li><li><strong>Persönlich begleiten.</strong> Sie bleiben über die Schritte informiert – bis zum Notartermin und zur Übergabe.</li></ol><p><a href="#kontakt">Verkauf besprechen</a></p>'],
['02 / VERMIETUNG', 'vermietung', 'people', 'Die richtigen Menschen.<br>Für Ihre Immobilie.',
'Eine gute Vermietung braucht Zeit und Erfahrung. Wir unterstützen Sie dabei, passende Mietinteressenten zu finden und nehmen Ihnen Arbeit ab.',
'Mehr zur Vermietung',
'<p>Wir vermitteln seit vielen Jahren Wohnungen und kennen die Fragen, die Eigentümer beschäftigen. Wir begleiten die Suche nach geeigneten Mietern und stimmen das Vorgehen mit Ihnen ab.</p><p><a href="#kontakt">Vermietung anfragen</a></p>'],
['03 / VERWALTUNG', 'verwaltung', 'shield', 'Werte erhalten.<br>Mit Weitblick verwalten.',
'Ihr Eigentum verdient Aufmerksamkeit. Wir betreuen Wohn- und Gewerbeimmobilien zuverlässig – mit Sachkenntnis, Transparenz und persönlichem Einsatz.',
'Unsere Verwaltungsleistungen',
'<ul><li>WEG-Verwaltung für Gemeinschaften unterschiedlicher Größe</li><li>Sondereigentumsverwaltung einzelner Wohnungen und Gewerbeeinheiten</li><li>Mietverwaltung für Ihre vermieteten Immobilien</li></ul><p><a href="#kontakt">Verwaltungsangebot anfragen</a></p>'],
];
$startseite = [
'schemaVersion' => $SCHEMA,
'title' => 'DEEG Immobilien & Hausverwaltung',
'description' => 'Immobilien verkaufen, vermieten und verwalten in Freudenstadt. DEEG begleitet Eigentümer persönlich – seit 1994. Jetzt Kontakt aufnehmen.',
'blocks' => [
// 1 – Hero: Text links, das Foto rechts mit Schild, Bildzeile und blauem Aufruf.
$section('hero', [
$row('hero-zeile', [
$column('hero-text', [
$zeile('hero-label', [
$flaeche('hero-label-strich', ['desktop' => ['width' => '29px', 'height' => '1px', 'backgroundColor' => '@blau', 'flexShrink' => 0]], 'min-height:0'),
$text('hero-label-text', 'IMMOBILIEN. VERTRAUEN. DEEG.', ['desktop' => [...$kleinStil['desktop'], 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px']]),
], '11px', ['margin' => ['top' => '0px', 'bottom' => '25px']], ['margin' => ['top' => '0px', 'bottom' => '22px']]),
$heading('hero-titel', 'Für Ihr Eigentum.<br>Für Ihr Zuhause.<br><em>Für Sie.</em>', 1, [
'desktop' => ['fontWeight' => '400', 'fontSize' => '66px', 'lineHeight' => 1.075, 'letterSpacing' => '-0.057em', 'margin' => ['bottom' => '25px']],
'tablet' => ['fontSize' => '52px'],
'mobile' => ['fontSize' => '46px', 'margin' => ['bottom' => '23px']],
], '& em{color:#0081c7;font-style:normal}'),
$text('hero-intro', 'Eine Immobilie ist mehr als ein Gebäude. Wir begleiten Sie beim Verkauf, bei der Vermietung und in der Verwaltung – persönlich und mit einem klaren Blick für Ihre Ziele.', [
'desktop' => ['maxWidth' => '440px', 'color' => '@text', 'fontSize' => '16px', 'lineHeight' => 1.85, 'margin' => ['bottom' => '30px']],
'mobile' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'maxWidth' => '450px', 'margin' => ['bottom' => '24px']],
]),
$zeile('hero-aktionen', [
$column('hero-aktion-knopf', [
$button('hero-knopf', 'Lassen Sie uns sprechen', '#kontakt', ['desktop' => ['margin' => ['top' => '0px']]]),
], ['desktop' => ['flexShrink' => 0]]),
$column('hero-aktion-link', [
$text('hero-textlink', '<a href="#leistungen">DEEG kennenlernen</a>', ['desktop' => ['margin' => ['bottom' => '0px']]], $textLinkCss),
], ['desktop' => ['flexShrink' => 0]]),
], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '14px', 'columnGap' => '22px']),
$zeile('hero-notiz', [
$text('hero-notiz-zahl', '30<em>+</em>', [
'desktop' => ['fontSize' => '46px', 'fontWeight' => '400', 'lineHeight' => 1, 'letterSpacing' => '-0.055em', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '38px'],
], '& em{font-size:26px;vertical-align:top;line-height:1.4;color:#0081c7;font-style:normal}'),
$text('hero-notiz-text', 'Jahre Erfahrung.<br><strong>Und immer persönlich für Sie da.</strong>', [
'desktop' => ['fontSize' => '12px', 'lineHeight' => 1.6, 'color' => '@text', 'borderWidth' => $nurLinks('1px'), 'borderStyle' => 'solid', 'borderColor' => '#d1d8da', 'padding' => ['left' => '20px'], 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '11px', 'padding' => ['left' => '12px']],
], '& strong{font-weight:400;color:#343a40}'),
], '20px', ['margin' => ['top' => 'auto'], 'padding' => ['top' => '38px']], ['gap' => '12px', 'padding' => ['top' => '29px']]),
], [
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'padding' => ['top' => '41px', 'bottom' => '12px']],
'tablet' => ['padding' => ['top' => '14px', 'bottom' => '0px']],
'mobile' => ['padding' => ['top' => '10px', 'bottom' => '0px']],
]),
$column('hero-visual', [
$bild('hero-bild', $medien['hero'], 'Moderne Wohnarchitektur mit klaren Linien und großzügigen Fenstern', [
'desktop' => ['imageHeight' => '580px', 'objectFit' => 'cover', 'objectPosition' => 'center', 'margin' => $seiten('0px')],
'tablet' => ['imageHeight' => '565px'],
'mobile' => ['imageHeight' => '420px'],
], null, '& img{display:block;width:100%}'),
// Der dunkle Verlauf über der unteren Bildhälfte, damit die Bildzeile lesbar bleibt.
$flaeche('hero-verlauf', ['desktop' => ['position' => 'absolute', 'left' => '0px', 'right' => '0px', 'bottom' => '0px', 'width' => 'auto', 'height' => 'auto', 'zIndex' => 1]], 'top:50%;min-height:0;background:linear-gradient(transparent,rgba(5,23,32,.6));pointer-events:none'),
$text('hero-schild', 'RAUM FÜR NEUE PERSPEKTIVEN', [
'desktop' => ['position' => 'absolute', 'top' => '24px', 'left' => '24px', 'zIndex' => 2, 'padding' => ['top' => '8px', 'right' => '12px', 'bottom' => '8px', 'left' => '12px'], 'fontSize' => '10px', 'fontWeight' => '700', 'letterSpacing' => '0.12em', 'color' => '@papier', 'backgroundColor' => '#12272d8c', 'margin' => ['bottom' => '0px']],
'mobile' => ['top' => '20px', 'left' => '20px', 'fontSize' => '9px'],
], 'backdrop-filter:blur(8px)'),
$zeile('hero-bildzeile', [
$text('hero-bildzeile-ort', 'Freudenstadt & Umgebung', ['desktop' => ['fontSize' => '11px', 'letterSpacing' => '0.04em', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
$text('hero-bildzeile-lage', '48° N · 8° O', ['desktop' => ['fontSize' => '11px', 'letterSpacing' => '0.04em', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
], '20px', ['position' => 'absolute', 'left' => '27px', 'right' => '27px', 'bottom' => '27px', 'zIndex' => 2, 'justifyContent' => 'space-between'], ['left' => '20px', 'right' => '20px', 'bottom' => '20px']),
$text('hero-aufruf', '<a href="#bewertung">Was ist Ihre<br><strong>Immobilie wert?</strong></a>', [
'desktop' => ['position' => 'absolute', 'right' => '0px', 'bottom' => '83px', 'zIndex' => 3, 'margin' => ['bottom' => '0px']],
'mobile' => ['bottom' => '64px'],
], "& a{display:flex;align-items:center;gap:36px;background:#0076b6;color:#fff;padding:24px 28px;font-size:15px;line-height:1.5;text-decoration:none;transition:background .2s}\n& a:hover{background:#006aa3}\n& strong{font-weight:600}"),
], [
'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'backgroundColor' => '#d7dee0', 'minHeight' => '580px'],
'tablet' => ['minHeight' => '565px'],
'mobile' => ['minHeight' => '420px'],
]),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '42px', 'alignItems' => 'stretch', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '22px'],
'mobile' => ['gap' => '27px'],
]),
], [
'desktop' => ['padding' => ['top' => '36px', 'bottom' => '42px']],
'tablet' => ['padding' => ['top' => '24px', 'bottom' => '42px']],
'mobile' => ['padding' => ['top' => '14px', 'bottom' => '27px']],
], 'start'),
// 2 – Die Leiste mit den drei Leistungen.
$section('leiste', [
$zeile('leiste-zeile', [
$column('leiste-satz', [
$text('leiste-satz-text', 'Ihr Eigentum in guten Händen.', ['desktop' => ['fontSize' => '13px', 'color' => '@meta', 'margin' => ['bottom' => '0px']]]),
], ['desktop' => ['flexGrow' => 1.5, 'flexBasis' => '0']], 'hide-mobile'),
...array_map(
static fn (array $l, int $i): array => $column('leiste-'.($i + 1), [
$text('leiste-'.($i + 1).'-link', '<a href="#'.$l[1].'">'.$l[0].' <em>0'.($i + 1).'</em></a>', [
'desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '13px'],
], "& a{display:flex;justify-content:space-between;align-items:center;gap:10px;font-weight:600;text-decoration:none;transition:color .2s}\n& a:hover{color:#0076b6}\n& em{font-style:normal;font-size:11px;font-weight:400;color:#66747b}"),
], [
'desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'borderWidth' => $nurLinks('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '4px', 'right' => '27px', 'bottom' => '4px', 'left' => '27px']],
'tablet' => ['padding' => ['top' => '4px', 'right' => '17px', 'bottom' => '4px', 'left' => '17px']],
'mobile' => ['padding' => ['top' => '5px', 'right' => '12px', 'bottom' => '5px', 'left' => '12px']],
]),
[['Verkaufen', 'verkauf'], ['Vermieten', 'vermietung'], ['Verwalten', 'verwaltung']], [0, 1, 2],
),
], '0px', ['alignItems' => 'stretch']),
], [
'desktop' => ['borderWidth' => $obenUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '21px', 'bottom' => '21px']],
'mobile' => ['padding' => ['top' => '16px', 'bottom' => '16px']],
]),
// 3 – Leistungen: Kopfzeile, darunter drei Karten mit Aufklapper.
$abschnitt('leistungen-abschnitt', [
$zeile('leistungen-kopf', [
$column('leistungen-kopf-titel', [
$kleinzeile('leistungen-label', 'WAS WIR FÜR SIE TUN'),
$heading('leistungen-h2', 'Drei Leistungen.<br>Ein verlässlicher Partner.', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'margin' => ['bottom' => '0px']]]),
]),
$column('leistungen-kopf-text', [
$text('leistungen-intro', 'Ob neue Pläne oder bestehendes Eigentum: Wir kümmern uns um Ihre Immobilie, damit Sie sich auf das Wesentliche konzentrieren können.', [
'desktop' => ['maxWidth' => '360px', 'fontSize' => '16px', 'lineHeight' => 1.85, 'color' => '@text', 'margin' => ['bottom' => '4px']],
'tablet' => ['maxWidth' => '280px'],
'mobile' => ['maxWidth' => '100%', 'margin' => ['top' => '23px', 'bottom' => '0px']],
]),
]),
], '45px', ['alignItems' => 'flex-end', 'justifyContent' => 'space-between', 'margin' => ['top' => '0px', 'bottom' => '44px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '0px', 'margin' => ['top' => '0px', 'bottom' => '30px']], ['gap' => '28px']),
$row('leistungen-raster', array_map(
static function (array $l, int $i) use ($column, $zeile, $text, $symbol, $heading, $h3Stil, $linkCss, $nurOben): array {
$n = $i + 1;
return $column('leistung-'.$n, [
$zeile('leistung-'.$n.'-kopf', [
$text('leistung-'.$n.'-nummer', $l[0], ['desktop' => ['fontSize' => '11px', 'fontWeight' => '700', 'letterSpacing' => '0.11em', 'margin' => ['bottom' => '0px']]]),
$symbol('leistung-'.$n.'-symbol', $l[2], 40, ['desktop' => ['color' => '@anthrazit', 'flexShrink' => 0], 'mobile' => ['width' => '34px', 'height' => '34px']]),
], '12px', ['justifyContent' => 'space-between', 'margin' => ['top' => '0px', 'bottom' => '34px']], ['margin' => ['top' => '0px', 'bottom' => '24px']]),
$heading('leistung-'.$n.'-titel', $l[3], 3, $h3Stil),
$text('leistung-'.$n.'-text', $l[4], [
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.85, 'color' => '@text', 'minHeight' => '155px', 'margin' => ['bottom' => '0px']],
'tablet' => ['minHeight' => '0px'],
'mobile' => ['minHeight' => '0px'],
]),
['id' => 'leistung-'.$n.'-mehr', 'type' => 'accordion', 'props' => ['faq' => false],
'style' => ['desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', 'margin' => ['top' => '22px']]],
'children' => [
['id' => 'leistung-'.$n.'-punkt', 'type' => 'accordionitem', 'props' => ['title' => $l[5], 'open' => false],
'css' => "& summary{border:0;border-radius:0;background:transparent;padding:17px 0;font-size:14px;font-weight:600}\n& .accordion-body{padding:0 0 12px}",
'children' => [
$text('leistung-'.$n.'-details', $l[6], ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], "& li{margin:12px 0}\n& ol{padding-left:18px}\n& ul{padding-left:18px}\n& strong{color:#343a40}\n".$linkCss),
],
],
],
],
], [
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurOben('2px'), 'borderStyle' => 'solid', 'borderColor' => '#dce7ed', 'padding' => ['top' => '32px', 'right' => '29px', 'bottom' => '18px', 'left' => '29px']],
'tablet' => ['padding' => ['top' => '26px', 'right' => '26px', 'bottom' => '12px', 'left' => '26px']],
'mobile' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '12px', 'left' => '25px']],
], 'all', $l[1], "transition:border-color .3s\n&:hover{border-color:#0081c7}");
},
$leistungen, array_keys($leistungen),
), ['desktop' => ['columns' => 'three'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '24px', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '12px'],
'mobile' => ['gap' => '17px'],
]),
], [], 'leistungen'),
// 4 – Bewertung: blaue Fläche, rechts die weiße Karte mit der Bewertungsanfrage.
$section('bewertung-abschnitt', [
$row('bewertung-zeile', [
$column('bewertung-text', [
$kleinzeile('bewertung-label', 'EINE GUTE ENTSCHEIDUNG BEGINNT MIT KLARHEIT', '@papier'),
$heading('bewertung-h2', 'Was steckt in<br>Ihrer Immobilie?', 2, [
'desktop' => ['fontWeight' => '400', 'fontSize' => '53px', 'lineHeight' => 1.16, 'letterSpacing' => '-0.045em', 'color' => '@papier', 'margin' => ['bottom' => '26px']],
'tablet' => ['fontSize' => '42px'],
'mobile' => ['fontSize' => '40px'],
]),
$text('bewertung-intro', 'Lage, Zustand und Ausstattung machen den Unterschied. Geben Sie uns die Eckdaten – wir helfen Ihnen, den Wert Ihrer Immobilie einzuschätzen.', [
'desktop' => ['maxWidth' => '425px', 'color' => '@papier', 'fontSize' => '16px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '0px']],
]),
$zeile('bewertung-punkte', [
$text('bewertung-punkt-1', '✓ Kostenlose Ersteinschätzung', ['desktop' => ['fontSize' => '12px', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
$text('bewertung-punkt-2', '✓ Persönliche Beratung', ['desktop' => ['fontSize' => '12px', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]),
], '24px', ['flexWrap' => 'wrap', 'margin' => ['top' => '28px']], ['rowGap' => '8px', 'columnGap' => '20px']),
]),
$column('bewertung-karte', [
$text('bewertung-karte-label', 'IHRE BEWERTUNGSANFRAGE IN DREI SCHRITTEN', ['desktop' => ['fontSize' => '14px', 'letterSpacing' => '0.12em', 'color' => '@blau-dunkel', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '12px']]),
$heading('bewertung-karte-titel', 'Um welche Immobilie geht es?', 3, [
'desktop' => ['fontWeight' => '400', 'fontSize' => '22px', 'letterSpacing' => '-0.02em', 'lineHeight' => 1.3, 'margin' => ['top' => '18px', 'bottom' => '24px']],
]),
['id' => 'bewertung-formular', 'type' => 'form', 'props' => ['form' => 'bewertung'], 'css' => $formularCss],
$text('bewertung-karte-hinweis', 'Kostenlos und unverbindlich. Vor dem Absenden sehen Sie alle Angaben noch einmal – mehr zum Umgang damit in den <a href="/datenschutz">Datenschutzhinweisen</a>.', [
'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '20px', 'bottom' => '0px']],
], $linkCss),
], [
'desktop' => ['backgroundColor' => '@papier', 'color' => '@anthrazit', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => $seiten('36px')],
'tablet' => ['padding' => ['top' => '25px', 'right' => '20px', 'bottom' => '25px', 'left' => '20px']],
'mobile' => ['padding' => ['top' => '27px', 'right' => '22px', 'bottom' => '27px', 'left' => '22px']],
]),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '100px', 'alignItems' => 'center', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '30px'],
'mobile' => ['gap' => '25px'],
]),
], [
'desktop' => ['backgroundColor' => '@blau-tief', 'color' => '@papier', 'padding' => ['top' => '76px', 'bottom' => '76px']],
'mobile' => ['padding' => ['top' => '55px', 'bottom' => '55px']],
], 'bewertung'),
// 5 – Über uns: Foto mit Jahreszahl links, Text rechts.
$abschnitt('ueber-uns-abschnitt', [
$row('ueber-uns-zeile', [
$column('ueber-uns-visual', [
$bild('ueber-uns-bild', $medien['detail'], 'Wohngebäude mit charaktervoller Architektur', [
'desktop' => ['imageHeight' => '500px', 'objectFit' => 'cover', 'objectPosition' => 'center', 'margin' => $seiten('0px')],
'tablet' => ['imageHeight' => '480px'],
'mobile' => ['imageHeight' => '390px'],
], null, '& img{display:block;width:100%}'),
$column('ueber-uns-seit', [
$text('ueber-uns-seit-oben', 'SEIT', ['desktop' => ['fontSize' => '10px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]]),
$text('ueber-uns-seit-jahr', '1994', ['desktop' => ['fontSize' => '61px', 'fontWeight' => '400', 'letterSpacing' => '-0.06em', 'lineHeight' => 1.3, 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '48px']]),
$text('ueber-uns-seit-ort', 'IN FREUDENSTADT', ['desktop' => ['fontSize' => '10px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]]),
], [
'desktop' => ['position' => 'absolute', 'right' => '-24px', 'bottom' => '-23px', 'zIndex' => 1, 'backgroundColor' => '@blau-tief', 'color' => '@papier', 'display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'center', 'padding' => ['top' => '29px', 'right' => '37px', 'bottom' => '29px', 'left' => '37px']],
'tablet' => ['right' => '-12px', 'padding' => ['top' => '20px', 'right' => '27px', 'bottom' => '20px', 'left' => '27px']],
'mobile' => ['right' => '-13px', 'bottom' => '-25px', 'padding' => ['top' => '20px', 'right' => '27px', 'bottom' => '20px', 'left' => '27px']],
]),
], [
'desktop' => ['position' => 'relative', 'backgroundColor' => '#eef1f1', 'margin' => ['right' => '24px', 'bottom' => '23px']],
'tablet' => ['margin' => ['right' => '12px', 'bottom' => '23px']],
'mobile' => ['margin' => ['right' => '13px', 'bottom' => '25px']],
]),
$column('ueber-uns-text', [
$kleinzeile('ueber-uns-label', 'IN DER REGION VERWURZELT'),
$heading('ueber-uns-h2', 'Immobilien verstehen.<br>Menschen kennen.', 2, $h2Stil),
$text('ueber-uns-lead', 'Hinter jeder Immobilie stehen Menschen und persönliche Pläne. Genau dort beginnt unsere Arbeit.', [
'desktop' => ['fontSize' => '18px', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'margin' => ['bottom' => '20px']],
]),
$text('ueber-uns-p1', 'Seit 1994 ist DEEG in Freudenstadt für Eigentümer von Wohn- und Gewerbeimmobilien da. Von der einzelnen Wohnung bis zur Eigentümergemeinschaft: Sie erhalten die gleiche Aufmerksamkeit und einen Ansprechpartner, der sich kümmert.', $fliess),
$text('ueber-uns-p2', 'Unser Ziel ist, den Wert Ihrer Immobilie zu erhalten und weiterzuentwickeln. Dabei zählen für uns nachvollziehbare Entscheidungen, ein gutes Miteinander und eine Zusammenarbeit auf Augenhöhe.', $fliess),
$text('ueber-uns-link', '<a href="#kontakt">Lernen wir uns kennen</a>', ['desktop' => ['margin' => ['top' => '10px', 'bottom' => '0px']]], $textLinkCss),
]),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '95px', 'alignItems' => 'center', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '40px'],
'mobile' => ['gap' => '60px'],
]),
], [], 'ueber-uns'),
// 6 – Angebote: ein helles Band mit Aufruf zu den aktuellen Angeboten.
$section('angebote-abschnitt', [
$column('angebote-band', [
$zeile('angebote-zeile', [
$column('angebote-text', [
$kleinzeile('angebote-label', 'IMMOBILIENANGEBOTE', '@anthrazit', '20px'),
$heading('angebote-h2', 'Vielleicht beginnt hier<br>Ihr nächstes Kapitel.', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'fontSize' => '40px'], 'tablet' => ['fontSize' => '34px'], 'mobile' => ['fontSize' => '32px']]),
$text('angebote-intro', 'Entdecken Sie unsere aktuellen Immobilienangebote.<br>Wir helfen Ihnen gerne bei der Suche.', [
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '15px'],
]),
]),
$column('angebote-aktion', [
// Die Angebotsliste liegt weiter auf der laufenden Website; ein Textlink im Zuschnitt
// des hellen Buttons der Vorlage.
$text('angebote-knopf', '<a href="'.$angebote.'">Aktuelle Angebote ansehen</a>', ['desktop' => ['margin' => ['bottom' => '0px']], 'mobile' => ['width' => '100%']],
"& a{display:inline-flex;justify-content:center;align-items:center;width:100%;border:1px solid #006aa3;color:#006aa3;padding:18px 24px;font-size:14px;font-weight:600;line-height:1.4;text-decoration:none;transition:background .2s,color .2s,transform .2s}\n& a:hover{background:#0076b6;color:#fff;border-color:#0076b6;transform:translateY(-2px)}"),
], ['desktop' => ['flexShrink' => 0], 'mobile' => ['width' => '100%']]),
], '40px', ['justifyContent' => 'space-between'], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '28px'], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '26px']),
], [
'desktop' => ['backgroundColor' => '@hell', 'padding' => ['top' => '55px', 'right' => '60px', 'bottom' => '55px', 'left' => '60px']],
'tablet' => ['padding' => $seiten('40px')],
'mobile' => ['padding' => ['top' => '32px', 'right' => '25px', 'bottom' => '32px', 'left' => '25px']],
]),
], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '0px']]], 'angebote'),
// 7 – Kontakt: Gespräch links mit Adresse und Bürozeiten, rechts die Formularkarte.
$abschnitt('kontakt-abschnitt', [
$row('kontakt-zeile', [
$column('kontakt-text', [
$kleinzeile('kontakt-label', 'WIR FREUEN UNS AUF SIE'),
$heading('kontakt-h2', 'Ihre Immobilie.<br>Unser nächstes Gespräch.', 2, $h2Stil),
$text('kontakt-intro', 'Verkaufen, vermieten oder verwalten?<br>Erzählen Sie uns, was Sie vorhaben.', [
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '0px']],
]),
$text('kontakt-telefon', '<a href="'.$telHref.'">'.$telefon.'</a>', [
'desktop' => ['fontSize' => '43px', 'letterSpacing' => '-0.04em', 'lineHeight' => 1.4, 'margin' => ['top' => '26px', 'bottom' => '0px']],
'tablet' => ['fontSize' => '36px'],
'mobile' => ['fontSize' => '36px'],
], "& a{text-decoration:none}\n& a:hover{color:#006aa3}"),
$text('kontakt-mail', '<a href="mailto:'.$mail.'">'.$mail.'</a>', [
'desktop' => ['fontSize' => '17px', 'color' => '#61717a', 'margin' => ['top' => '6px', 'bottom' => '0px']],
], "& a{overflow-wrap:anywhere;text-decoration:none}\n& a:hover{color:#006aa3;text-decoration:underline}"),
$zeile('kontakt-info', [
$column('kontakt-adresse', [
$text('kontakt-adresse-label', 'BESUCHEN SIE UNS', ['desktop' => [...$kleinStil['desktop'], 'margin' => ['bottom' => '12px']]]),
$text('kontakt-adresse-text', $firma.'<br>Ringstraße 30<br>72250 Freudenstadt', ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.85, 'margin' => ['bottom' => '14px']]]),
$text('kontakt-route', '<a href="'.$route.'">Route planen</a>', ['desktop' => ['margin' => ['bottom' => '0px']]], $textLinkCss),
], ['desktop' => ['flexBasis' => '0', 'flexGrow' => 1, 'minWidth' => '220px']]),
$column('kontakt-zeiten', [
$text('kontakt-zeiten-label', 'UNSERE BÜROZEITEN', ['desktop' => [...$kleinStil['desktop'], 'margin' => ['bottom' => '12px']]]),
$text('kontakt-zeiten-1', 'Montag – Freitag<br><strong>08:00 – 12:00</strong>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '12px']]], '& strong{font-weight:600;color:#343a40}'),
$text('kontakt-zeiten-2', 'Montag, Dienstag & Donnerstag<br><strong>14:00 – 17:00</strong>', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']]], '& strong{font-weight:600;color:#343a40}'),
], ['desktop' => ['flexBasis' => '0', 'flexGrow' => 1, 'minWidth' => '220px']]),
], '40px', ['alignItems' => 'flex-start', 'flexWrap' => 'wrap', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '30px'], 'margin' => ['top' => '40px']], ['gap' => '28px', 'margin' => ['top' => '30px']]),
]),
$column('kontakt-karte', [
$text('kontakt-karte-label', 'SCHREIBEN SIE UNS', ['desktop' => ['fontSize' => '11px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'color' => '@meta', 'margin' => ['bottom' => '18px']]]),
$heading('kontakt-karte-titel', 'Was können wir<br>für Sie tun?', 3, [
'desktop' => ['fontWeight' => '400', 'fontSize' => '28px', 'lineHeight' => 1.25, 'letterSpacing' => '-0.03em', 'margin' => ['bottom' => '25px']],
]),
['id' => 'kontakt-formular', 'type' => 'form', 'props' => ['form' => 'kontakt'], 'css' => $formularCss],
$text('kontakt-formular-hinweis', 'Ihre Nachricht geht direkt an DEEG. 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' => '@text', 'margin' => ['top' => '19px', 'bottom' => '0px']],
], $linkCss),
], [
'desktop' => ['alignSelf' => 'flex-start', 'backgroundColor' => '@papier', 'borderWidth' => ['top' => '3px', 'right' => '1px', 'bottom' => '1px', 'left' => '1px'], 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '32px', 'right' => '35px', 'bottom' => '32px', 'left' => '35px'], 'shadowY' => '10px', 'shadowBlur' => '40px', 'shadowColor' => '#343a4012'],
'tablet' => ['padding' => ['top' => '28px', 'right' => '25px', 'bottom' => '28px', 'left' => '25px']],
'mobile' => ['padding' => ['top' => '28px', 'right' => '22px', 'bottom' => '28px', 'left' => '22px']],
], 'all', null, 'border-top-color:#0081c7'),
], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [
'desktop' => ['gap' => '100px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px']],
'tablet' => ['gap' => '35px'],
'mobile' => ['gap' => '35px'],
]),
], [], '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 – wie die Vorlage, die die
// Abschnittsköpfe, Bewertung, Über-uns-Text und Kontaktkarte einblendet.
$startseite = $einblenden($startseite, [
'hero-label' => ['fade'],
'hero-titel' => ['rise', 'short'],
'hero-intro' => ['rise', 'medium'],
'hero-aktionen' => ['rise', 'long'],
'hero-notiz' => ['fade', 'long'],
// Kein Hereinschieben von rechts: das ragt für einen Moment über den Rand hinaus.
'hero-visual' => ['fade', 'short'],
'leistungen-kopf' => ['rise'],
'leistung-1' => ['rise'],
'leistung-2' => ['rise', 'short'],
'leistung-3' => ['rise', 'medium'],
'bewertung-text' => ['rise'],
'bewertung-karte' => ['rise', 'short'],
'ueber-uns-visual' => ['fade'],
'ueber-uns-text' => ['rise', 'short'],
'angebote-band' => ['rise'],
'kontakt-text' => ['rise'],
'kontakt-karte' => ['rise', 'short'],
]);
$anlegen('home', $startseite, 'DEEG · Immobilien & Hausverwaltung in Freudenstadt', $medien['hero']['id']);
$anlegen('impressum', [
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
'description' => 'Anbieterangaben der Deeg Hausverwaltung und Immobilien GmbH in Freudenstadt.',
'blocks' => [
...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [
['Anbieterin', $firma.'<br>Ringstraße 30<br>72250 Freudenstadt'],
['Vertreten durch', 'Furkan Kurt, Geschäftsführer'],
['Registereintrag', 'Handelsregister: HRB 430747<br>Registergericht: Amtsgericht Stuttgart'],
['Kontakt', "Telefon: <a href=\"$telHref\">$telefon</a><br>E-Mail: <a href=\"mailto:$mail\">$mail</a>"],
['Umsatzsteuer', 'NOCH ZU PRÜFEN: Umsatzsteuer-Identifikationsnummer nach § 27a UStG eintragen.'],
['Aufsichtsbehörde', 'NOCH ZU PRÜFEN: Die Vermittlung und Verwaltung von Immobilien braucht eine Erlaubnis nach § 34c GewO. Erlaubnisbehörde und Aufsicht (in der Regel die Stadt Freudenstadt oder das Landratsamt Freudenstadt) hier eintragen.'],
['Verantwortlich für den Inhalt', 'Furkan Kurt, Anschrift wie oben.'],
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
['Haftung für Inhalte', 'Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den allgemeinen Gesetzen bleiben hiervon unberührt. Bei Bekanntwerden von Rechtsverletzungen entfernen wir diese Inhalte umgehend.'],
['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. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Bei Bekanntwerden von Rechtsverletzungen entfernen wir solche Links umgehend.'],
['Urheberrecht', 'Die von uns erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.'],
['Bildnachweis', 'Titelbild: Unsplash (Unsplash-Lizenz), illustrative Architekturaufnahme, kein Objekt aus unserem Angebot. Zweites Gebäudefoto: aus der bisherigen Website übernommen – NOCH ZU PRÜFEN: Urheber und Lizenz eintragen. Logo: '.$firma.'.'],
]),
],
], 'Impressum | '.$firma);
$anlegen('datenschutz', [
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
'description' => 'Wie die Deeg Hausverwaltung und Immobilien GmbH Daten beim Besuch der Website und bei einer Anfrage verarbeitet.',
'blocks' => [
...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Anfrage verarbeitet werden – und welche Rechte Sie haben.', [
['1. Verantwortliche', "$firma, Ringstraße 30, 72250 Freudenstadt, vertreten durch Furkan Kurt. 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. Der Link „Route planen“ führt zu Google Maps; erst mit dem Klick verlassen Sie unsere Website, und es gelten die Datenschutzbestimmungen von Google. Unsere aktuellen Immobilienangebote sind auf unserer bisherigen Website unter www.immobiliendeeg.de verlinkt.'],
['4. Kontaktanfrage und Bewertungsanfrage', 'Wenn Sie uns über eines der Formulare schreiben, verarbeiten wir die von Ihnen angegebenen Kontakt- und Inhaltsdaten – bei der Bewertungsanfrage auch die Angaben zur Immobilie –, um Ihre Anfrage zu beantworten und Ihnen ein Angebot zu unterbreiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertrags), sonst Art. 6 Abs. 1 lit. f DSGVO. Die Formulare speichern den Zeitpunkt Ihrer Einwilligung und schützen sich gegen automatisierte Eingaben, ohne dafür Dienste Dritter zu laden.'],
['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 Auftrag, gelten für die dann anfallenden Unterlagen die handels- und steuerrechtlichen Fristen.'],
['6. Empfänger', 'Ihre Angaben erreichen uns per E-Mail und liegen in der Verwaltung dieser Website. Darüber hinaus geben wir sie nicht weiter, außer wir sind dazu gesetzlich verpflichtet oder Sie wünschen es, etwa bei der Weitergabe eines Angebots an Interessenten.<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 | '.$firma);
$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' => ['fontWeight' => '400', 'fontSize' => '56px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.05em', 'margin' => ['bottom' => '25px']],
'mobile' => ['fontSize' => '38px'],
], '& em{color:#0081c7;font-style:normal}'),
$text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [
'desktop' => ['maxWidth' => '465px', 'color' => '@text', 'fontSize' => '17px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '28px']],
]),
$button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['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('Leistungen', 'leistungen'),
$sprung('Über uns', 'ueber-uns'),
$sprung('Immobilien', 'angebote'),
$sprung('Kontakt', 'kontakt'),
$sprung('Immobilie bewerten', 'bewertung'),
], 0);
echo "Menü angelegt\n";
$settings->assignNotFoundPage($fehlerId, '');
$settings->save([
'legalName' => $firma,
'description' => 'Immobilien verkaufen, vermieten und verwalten in Freudenstadt. DEEG begleitet Eigentümer von Wohn- und Gewerbeimmobilien persönlich – seit 1994.',
'phone' => $telefon, 'email' => $mail,
'street' => 'Ringstraße 30', 'postalCode' => '72250', 'city' => 'Freudenstadt', 'country' => 'DE',
'localBusiness' => true, 'llmsTxt' => true,
'stickyHeader' => true, 'headerOverlay' => false,
'motion' => 'always',
'faviconMediaId' => $medien['logo']['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";
+328
View File
@@ -0,0 +1,328 @@
<?php
/**
* Inhalte der Website „Ihr Malerservice Drews“, am 15.09.2026 von maler-drews.com übernommen.
* Erzeugt aus dem HTML der laufenden Website; Wortlaut unverändert, nur Auszeichnung bereinigt.
* Wird von seed-maler-drews.php eingelesen.
*/
return [
'impressum' => '<p>Axel Drews<br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach</p> <p>Telefon: +49 7224 40821<br>Telefax: +49 7224 990503<br>E-Mail: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a><br></p> <p><strong>Umsatzsteuer-ID:</strong><br>Umsatzsteuer-Identifikationsnummer nach §27a Umsatzsteuergesetz: DE205838338</p> <p><strong>Hinweis gemäß Online-Streitbeilegungs-Verordnung</strong></p> <p>Nach geltendem Recht sind wir verpflichtet, Verbraucher auf die Existenz der Europäischen Online-Streitbeilegungs-Plattform hinzuweisen, die für die Beilegung von Streitigkeiten genutzt werden kann, ohne dass ein Gericht eingeschaltet werden muss. Für die Einrichtung der Plattform ist die Europäische Kommission zuständig. Die Europäische Online-Streitbeilegungs-Plattform ist hier zu finden: <a href="http://ec.europa.eu/odr">http://ec.europa.eu/odr</a>. Unsere E-Mail lautet: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a></p> <p>Wir weisen aber darauf hin, dass wir nicht bereit sind, uns am Streitbeilegungsverfahren im Rahmen der Europäischen Online-Streitbeilegungs-Plattform zu beteiligen. Nutzen Sie zur Kontaktaufnahme bitte unsere obige E-Mail und Telefonnummer.</p> <p><strong>Disclaimer – rechtliche Hinweise</strong></p> <p><strong>§ 1 Warnhinweis zu Inhalten</strong></p> <p>Die kostenlosen und frei zugänglichen Inhalte dieser Webseite wurden mit größtmöglicher Sorgfalt erstellt. Der Anbieter dieser Webseite übernimmt jedoch keine Gewähr für die Richtigkeit und Aktualität der bereitgestellten kostenlosen und frei zugänglichen journalistischen Ratgeber und Nachrichten. Namentlich gekennzeichnete Beiträge geben die Meinung des jeweiligen Autors und nicht immer die Meinung des Anbieters wieder. Allein durch den Aufruf der kostenlosen und frei zugänglichen Inhalte kommt keinerlei Vertragsverhältnis zwischen dem Nutzer und dem Anbieter zustande, insoweit fehlt es am Rechtsbindungswillen des Anbieters.</p> <p><strong>§ 2 Externe Links</strong></p> <p>Diese Website enthält Verknüpfungen zu Websites Dritter („externe Links“). Diese Websites unterliegen der Haftung der jeweiligen Betreiber. Der Anbieter hat bei der erstmaligen Verknüpfung der externen Links die fremden Inhalte daraufhin überprüft, ob etwaige Rechtsverstöße bestehen. Zu dem Zeitpunkt waren keine Rechtsverstöße ersichtlich. Der Anbieter hat keinerlei Einfluss auf die aktuelle und zukünftige Gestaltung und auf die Inhalte der verknüpften Seiten. Das Setzen von externen Links bedeutet nicht, dass sich der Anbieter die hinter dem Verweis oder Link liegenden Inhalte zu Eigen macht. Eine ständige Kontrolle der externen Links ist für den Anbieter ohne konkrete Hinweise auf Rechtsverstöße nicht zumutbar. Bei Kenntnis von Rechtsverstößen werden jedoch derartige externe Links unverzüglich gelöscht.<br><br><strong>§ 3 Urheber- und Leistungsschutzrechte<br></strong><br>Die auf dieser Website veröffentlichten Inhalte unterliegen dem deutschen Urheber- und Leistungsschutzrecht. Jede vom deutschen Urheber- und Leistungsschutzrecht nicht zugelassene Verwertung bedarf der vorherigen schriftlichen Zustimmung des Anbieters oder jeweiligen Rechteinhabers. Dies gilt insbesondere für Vervielfältigung, Bearbeitung, Übersetzung, Einspeicherung, Verarbeitung bzw. Wiedergabe von Inhalten in Datenbanken oder anderen elektronischen Medien und Systemen. Inhalte und Rechte Dritter sind dabei als solche gekennzeichnet. Die unerlaubte Vervielfältigung oder Weitergabe einzelner Inhalte oder kompletter Seiten ist nicht gestattet und strafbar. Lediglich die Herstellung von Kopien und Downloads für den persönlichen, privaten und nicht kommerziellen Gebrauch ist erlaubt. Die Darstellung dieser Website in fremden Frames ist nur mit schriftlicher Erlaubnis zulässig.<br><br><strong>§ 4 Besondere Nutzungsbedingungen<br></strong><br>Soweit besondere Bedingungen für einzelne Nutzungen dieser Website von den vorgenannten Paragraphen abweichen, wird an entsprechender Stelle ausdrücklich darauf hingewiesen. In diesem Falle gelten im jeweiligen Einzelfall die besonderen Nutzungsbedingungen. </p>',
'datenschutz' => [
'<p>Wir informieren Sie nachfolgend gemäß den gesetzlichen Vorgaben des Datenschutzrechts (insb. gemäß BDSG n.F. und der europäischen Datenschutz-Grundverordnung ‚DS-GVO‘) über die Art, den Umfang und Zweck der Verarbeitung personenbezogener Daten durch unser Unternehmen. Diese Datenschutzerklärung gilt auch für unsere Websites und Sozial-Media-Profile. Bezüglich der Definition von Begriffen wie etwa „personenbezogene Daten“ oder „Verarbeitung“ verweisen wir auf Art. 4 DS-GVO.</p> <strong>Name und Kontaktdaten des / der Verantwortlichen</strong><br> Unser/e Verantwortliche/r (nachfolgend „Verantwortlicher“) i.S.d. Art. 4 Zif. 7 DS-GVO ist:<br> <br>Ihr Malerservice Drews<br>Am Bachgarten 1<br>76593 Gernsbach<br>Geschäftsführer: Axel Drews <br>Tel: +49 7224 40821 <br>Fax: +49 7224 990503<br>E-Mail-Adresse: <a href="mailto:info@maler-drews.com">info@maler-drews.com</a> <br><br> <strong>Datenarten, Zwecke der Verarbeitung und Kategorien betroffener Personen</strong><br> <p>Nachfolgend informieren wir Sie über Art, Umfang und Zweck der Erhebung, Verarbeitung und Nutzung personenbezogener Daten.</p> <strong>1. Arten der Daten, die wir verarbeiten</strong><br>Nutzungsdaten (Zugriffszeiten, besuchte Websites etc.), Kontaktdaten (Telefonnummer, E-Mail, Fax etc.), Kommunikationsdaten (IP-Adresse etc.), <br> <br> <strong>2. Zwecke der Verarbeitung nach Art. 13 Abs. 1 c) DS-GVO</strong><br>Website technisch und wirtschaftlich optimieren, Leichten Zugang zur Website ermöglichen, Erfüllung gesetzlicher Aufbewahrungspflichten, Optimierung und statistische Auswertung unserer Dienste, Vermeidung von SPAM und Missbrauch, Kontaktanfragen abwickeln, <br> <br> <strong>3. Kategorien der betroffenen Personen nach Art. 13 Abs. 1 e) DS-GVO</strong><br>Besucher/Nutzer der Website, Kunden, Bewerber, <br> <br> <p>Die betroffenen Personen werden zusammenfassend als „Nutzer“ bezeichnet.</p> <br> <strong>Rechtsgrundlagen der Verarbeitung personenbezogener Daten</strong> <p>Nachfolgend Informieren wir Sie über die Rechtsgrundlagen der Verarbeitung personenbezogener Daten:</p> <ol> <li>Wenn wir Ihre Einwilligung für die Verarbeitung personenbezogenen Daten eingeholt haben, ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung eines Vertrags oder zur Durchführung vorvertraglicher Maßnahmen erforderlich, die auf Ihre Anfrage hin erfolgen, so ist Art. 6 Abs. 1 S. 1 lit. b) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Erfüllung einer rechtlichen Verpflichtung erforderlich, der wir unterliegen (z.B. gesetzliche Aufbewahrungspflichten), so ist Art. 6 Abs. 1 S. 1 lit. c) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung erforderlich, um lebenswichtige Interessen der betroffenen Person oder einer anderen natürlichen Person zu schützen, so ist Art. 6 Abs. 1 S. 1 lit. d) DS-GVO Rechtsgrundlage.</li> <li>Ist die Verarbeitung zur Wahrung unserer oder der berechtigten Interessen eines Dritten erforderlich und überwiegen diesbezüglich Ihre Interessen oder Grundrechte und Grundfreiheiten nicht, so ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO Rechtsgrundlage.</li> </ol> <br> <strong>Weitergabe personenbezogener Daten an Dritte und Auftragsverarbeiter</strong> <p>Ohne Ihre Einwilligung geben wir grundsätzlich keine Daten an Dritte weiter. Sollte dies doch der Fall sein, dann erfolgt die Weitergabe auf der Grundlage der zuvor genannten Rechtsgrundlagen z.B. bei der Weitergabe von Daten an Online-Paymentanbieter zur Vertragserfüllung oder aufgrund gerichtlicher Anordnung oder wegen einer gesetzlichen Verpflichtung zur Herausgabe der Daten zum Zwecke der Strafverfolgung, zur Gefahrenabwehr oder zur Durchsetzung der Rechte am geistigen Eigentum.<br> Wir setzen zudem Auftragsverarbeiter (externe Dienstleister z.B. zum Webhosting unserer Websites und Datenbanken) zur Verarbeitung Ihrer Daten ein. Wenn im Rahmen einer Vereinbarung zur Auftragsverarbeitung an die Auftragsverarbeiter Daten weitergegeben werden, erfolgt dies immer nach Art. 28 DS-GVO. Wir wählen dabei unsere Auftragsverarbeiter sorgfältig aus, kontrollieren diese regelmäßig und haben uns ein Weisungsrecht hinsichtlich der Daten einräumen lassen. Zudem müssen die Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen getroffen haben und die Datenschutzvorschriften gem. BDSG n.F. und DS-GVO einhalten</p> <br> <strong>Datenübermittlung in Drittstaaten</strong> <p>Durch die Verabschiedung der europäischen Datenschutz-Grundverordnung (DS-GVO) wurde eine einheitliche Grundlage für den Datenschutz in Europa geschaffen. Ihre Daten werden daher vorwiegend durch Unternehmen verarbeitet, für die DS-GVO Anwendung findet. Sollte doch die Verarbeitung durch Dienste Dritter außerhalb der Europäischen Union oder des Europäischen Wirtschaftsraums stattfinden, so müssen diese die besonderen Voraussetzungen der Art. 44 ff. DS-GVO erfüllen. Das bedeutet, die Verarbeitung erfolgtLine truncated
'<strong>Kontaktaufnahme per Kontaktformular / E-Mail / Fax / Post</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Kontaktformular, Fax, Post oder E-Mail werden Ihre Angaben zum Zwecke der Abwicklung der Kontaktanfrage verarbeitet.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Daten ist bei Vorliegen einer Einwilligung von Ihnen Art. 6 Abs. 1 S. 1 lit. a) DS-GVO. Rechtsgrundlage für die Verarbeitung der Daten, die im Zuge einer Kontaktanfrage oder E-Mail, eines Briefes oder Faxes übermittelt werden, ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Der Verantwortliche hat ein berechtigtes Interesse an der Verarbeitung und Speicherung der Daten, um Anfragen der Nutzer beantworten zu können, zur Beweissicherung aus Haftungsgründen und um ggf. seiner gesetzlichen Aufbewahrungspflichten bei Geschäftsbriefen nachkommen zu können. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 S. 1 lit. b) DS-GVO.<br><br></li> <li>Wir können Ihre Angaben und Kontaktanfrage in unserem Customer-Relationship-Management System („CRM System“) oder einem vergleichbaren System speichern.<br><br></li> <li>Die Daten werden gelöscht, sobald sie für die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind. Für die personenbezogenen Daten aus der Eingabemaske des Kontaktformulars und diejenigen, die per E-Mail übersandt wurden, ist dies dann der Fall, wenn die jeweilige Konversation mit Ihnen beendet ist. Beendet ist die Konversation dann, wenn sich aus den Umständen entnehmen lässt, dass der betroffene Sachverhalt abschließend geklärt ist. Anfragen von Nutzern, die über einen Account bzw. Vertrag mit uns verfügen, speichern wir bis zum Ablauf von zwei Jahren nach Vertragsbeendigung. Im Fall von gesetzlichen Archivierungspflichten erfolgt die Löschung nach deren Ablauf: Ende handelsrechtlicher (6 Jahre) und steuerrechtlicher (10 Jahre) Aufbewahrungspflicht.<br><br></li> <li>Sie haben jederzeit die Möglichkeit, die Einwilligung nach Art. 6 Abs. 1 S. 1 lit. a) DS-GVO zur Verarbeitung der personenbezogenen Daten zu widerrufen. Nehmen Sie per E-Mail Kontakt mit uns auf, so können Sie der Speicherung der personenbezogenen Daten jederzeit widersprechen.<br><br></li> </ol><br> <strong>Kontaktaufnahme per Telefon</strong> <ol> <li>Bei der Kontaktaufnahme mit uns per Telefon wird Ihre Telefonnummer zur Bearbeitung der Kontaktanfrage und deren Abwicklung verarbeitet und temporär im RAM / Cache des Telefongerätes / Displays gespeichert bzw. angezeigt. Die Speicherung erfolgt aus Haftungs- und Sicherheitsgründen, um den Beweis des Anrufs führen zu können sowie aus wirtschaftlichen Gründen, um einen Rückruf zu ermöglichen. Im Falle von unberechtigten Werbeanrufen, sperren wir die Rufnummern.<br><br></li> <li>Rechtsgrundlage für die Verarbeitung der Telefonnummer ist Art. 6 Abs. 1 S. 1 lit. f) DS-GVO. Zielt der Kontakt auf den Abschluss eines Vertrages ab, so ist zusätzliche Rechtsgrundlage für die Verarbeitung Art. 6 Abs. 1 lit. b) DS-GVO.<br><br></li> <li>Der Gerätecache speichert die Anrufe Tage und überschreibt bzw. löscht sukzessiv alte Daten, bei Entsorgung des Gerätes werden alle Daten gelöscht und der Speicher ggf. zerstört. Gesperrte Telefonnummer werden jährlich auf die Notwendigkeit der Sperrung geprüft.<br><br></li> <li>Sie können die Anzeige der Telefonnummer verhindern, indem Sie mit unterdrückter Telefonnummer anrufen.<br><br></li> </ol><br> <strong>Google Analytics</strong> <ol> <li>Wir haben das Webseitenanalyse-Tool „Google Analytics“ (<strong>Dienstanbieter:</strong> Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) auf unserer Website integriert.<br><br></li> <li><strong>Datenkategorien und Beschreibung der Datenverarbeitung:</strong> User-ID, IP-Adresse (anonymisiert). Beim Besuch unserer Website setzt Google einen Cookie auf Ihren Computer, um die Benutzung unserer Website durch Sie analysieren zu können. Wir haben die IP-Anonymisierung „anonymizeIP“ aktiviert, wodurch die IP-Adressen nur gekürzt weiterverarbeitet werden. Auf dieser Webseite wird Ihre IP-Adresse von Google daher innerhalb von Mitgliedstaaten der Europäischen Union oder in anderen Vertragsstaaten des Abkommens über den Europäischen Wirtschaftsraum zuvor gekürzt. Nur in Ausnahmefällen wird die volle IP-Adresse an einen Server von Google in den USA übertragen und dort gekürzt. Im Auftrag des Betreibers dieser Webseite wird Google diese Informationen benutzen, um Ihre Nutzung der Webseite auszuwerten, um Reports über die Webseitenaktivitäten zusammenzustellen und um weitere, mit der Websitenutzung und der Internetnutzung verbundene, Dienstleistungen gegenüber dem Verantwortlichen zu erbringen. Wir haben darüber hinaus die geräteübergreifende Analyse von Website-Besuchern aktiviert, die über eine sog. User-ID durchgeführt wird. Die im Rahmen von GoogleLine truncated
'<strong>Google Maps</strong> <ol> <li>Wir haben auf unserer Website Karten von „Google Maps“ (<strong>Anbieter</strong>: Google Ireland Limited, Registernr.: 368047, Gordon House, Barrow Street, Dublin 4, Irland) integriert.<br><br></li> <li><strong>Datenkategorie und Beschreibung der Datenverarbeitung:</strong> Nutzungsdaten (z.B. IP, Standort, aufgerufene Seite). Mit Google Maps können wir den Standort von Adressen und eine Anfahrtsbeschreibung direkt auf unserer Website in interaktiven Karten anzeigen und Ihnen die Nutzung dieses Tools ermöglichen. Bei dem Abruf unserer Website, wo Google Maps integriert ist, wird eine Verbindung zu den Servern von Google in den USA aufgebaut. Hierbei können Ihre IP und Standort an Google übertragen werden. Zudem erhält Google die Information, dass Sie die entsprechende Seite aufgerufen haben. Dies erfolgt auch ohne Nutzerkonto bei Google. Sollten Sie in Ihren Google-Account eingeloggt sein, kann Google die obigen Daten Ihrem Account zuordnen. Wenn Sie dies nicht wünschen, müssen Sie sich bei Ihrem Google-Account ausloggen. Google erstellt aus solchen Daten Nutzerprofile und nutzt diese Daten zum Zwecke der Werbung, Marktforschung oder Optimierung seiner Websites.<br><br></li> <li><strong>Zweck der Verarbeitung:</strong> Bereitstellung einer nutzerfreundlichen, wirtschaftlichen und optimierten Webseite.<br><br></li> <li><strong>Rechtsgrundlagen:</strong> Haben Sie für Verarbeitung Ihrer personenbezogenen Daten mittels „Google Maps“ vom Drittanbieter Ihre Einwilligung erteilt („Opt-in“), dann ist Art. 6 Abs. 1 S. 1 lit. a) DS-GVO die Rechtsgrundlage. Rechtsgrundlage ist zudem unser in den obigen Zwecken liegendes berechtigtes Interesse an der Datenverarbeitung nach Art. 6 Abs. 1 S.1 lit. f) DS-GVO.<br><br></li> <li><strong>Datenübermittlung/Empfängerkategorie:</strong> Drittanbieter in den USA.<br><br></li> <li><strong>Speicherdauer:</strong> Cookies bis zu 6 Monate oder bis zur Löschung durch Sie. Ansonsten sobald sie nicht mehr für die Verarbeitungszwecke benötigt werden.<br><br></li> <li><strong>Widerspruchs- und Beseitigungsmöglichkeit:</strong> Sie haben gegenüber Google ein Widerspruchsrecht gegen die Bildung von Nutzerprofilen. Bitte richten Sie sich deswegen direkt an Google über die unten genannte Datenschutzerklärung. Ein Opt-Out-Widerspruch hinsichtlich der Werbe-Cookies können Sie hier in Ihrem Google-Account vornehmen:<br><a href="https://adssettings.google.com/authenticated">https://adssettings.google.com/authenticated</a>.<br><br></li> <li>In den Nutzungsbedingungen von Google Maps unter <a href="https://www.google.com/intl/de_de/help/terms_maps.html">https://www.google.com/intl/de_de/help/terms_maps.html</a> und in der Datenschutzerklärung für Werbung von Google unter <a href="https://policies.google.com/technologies/ads">https://policies.google.com/technologies/ads</a> finden Sie weitere Informationen zur Verwendung von Google Cookies und deren Werbetechnologien, Speicherdauer, Anonymisierung, Standortdaten, Funktionsweise und Ihre Rechte. Allgemeine Datenschutzerklärung von Google: <a href="https://policies.google.com/privacy">https://policies.google.com/privacy</a>.<br><br></li> </ol><br> <strong>Datenschutz bei Bewerbungen und im Bewerberverfahren</strong> <ol> <li>Bewerbungen, die auf elektronischem Wege oder per Post an den Verantwortlichen gesendet werden, werden zum Zwecke der Abwicklung des Bewerberverfahrens elektronisch oder manuell verarbeitet.<br><br></li> <li>Wir weisen ausdrücklich darauf hin, dass Bewerbungsunterlagen mit „besonderen Kategorien personenbezogener Daten“ nach Art. 9 DS-GVO (z.B. ein Foto, welches Rückschlüsse auf Ihre ethnische Herkunft, Religion oder Ihren Familienstand gibt), mit Ausnahme einer eventuellen Schwerbehinderung, welche Sie aus freier Entscheidung offenlegen möchten, unerwünscht sind. Sie sollen Ihre Bewerbung ohne diese Daten einreichen. Dies hat keine Auswirkungen auf Ihre Bewerberchancen.<br><br></li> <li>Rechtsgrundlagen für die Verarbeitung sind Art. 6 Abs. 1 S.1 lit. b) DS-GVO sowie § 26 BDSG n.F.<br><br></li> <li>Wird nach Abschluss des Bewerberverfahrens, ein Arbeitsverhältnis mit dem Bewerber / der Bewerberin eingegangen, werden die Bewerberdaten unter Beachtung einschlägiger Datenschutzvorschriften gespeichert. Wird Ihnen nach Abschluss des Bewerberverfahrens keine Stelle angeboten, so wird Ihr eingereichtes Bewerbungsschreiben samt Unterlagen 6 Monate nach Versand der Absage gelöscht, um etwaigen Ansprüchen und Nachweispflichten nach AGG genügen zu können.<br><br></li> </ol><br> <b>Rechte der betroffenen Person</b> <ol> <li><strong>Widerspruch oder Widerruf gegen die Verarbeitung Ihrer Daten<br><br> Soweit die Verarbeitung auf Ihrer Einwilligung gemäß Art. 6 Abs. 1 S. 1 lit. a), Art. 7 DS-GVO beruht, haben Sie das Recht, die Einwilligung jederzeit zu widerrufen. Die Rechtmäßigkeit der aufgrund der Einwilligung bis zum Widerruf erfolgten VLine truncated
],
'beitraege' => [
[
'slug' => '60-jahre-ihr-malerservice-drews-ein-jubilaeum-mit-tradition-und-zukunft',
'kind' => 'news',
'title' => '60 Jahre Ihr Malerservice Drews – Ein Jubiläum mit Tradition und Zukunft',
'date' => '2025-10-17 09:36',
'excerpt' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
'description' => 'In diesem Jahr feiern wir ein ganz besonderes Ereignis: 60 Jahre Ihr Malerservice Drews!Seit unserer …',
'content' => '<p>In diesem Jahr feiern wir ein ganz besonderes Ereignis: <strong>60 Jahre Ihr Malerservice Drews</strong>!<br>Seit unserer Gründung im Jahr 1965 steht unser Familienbetrieb für Qualität, Zuverlässigkeit und Kreativität rund um alle Maler- und Lackierarbeiten. Was einst im Loffenauer Milchhäusle begann, hat sich über sechs Jahrzehnte zu einem modernen Handwerksunternehmen mit einem starken Team und zufriedenen Kundinnen und Kunden in der gesamten Region entwickelt.</p> <p>Wir freuen uns sehr, dass unser Jubiläum auch in der regionalen Presse aufgegriffen wurde. Der Artikel gibt spannende Einblicke in unsere Geschichte, unsere Philosophie und die Leidenschaft, mit der wir täglich an die Arbeit gehen.</p> <p><strong>Ein herzliches Dankeschön</strong> gilt all unseren Kundinnen, Kunden, Geschäftspartnern und unserem großartigen Team – ohne Sie wäre dieser Erfolg nicht möglich gewesen.<br>Mit Stolz blicken wir auf 60 erfolgreiche Jahre zurück und mit Tatkraft und Freude in die Zukunft!</p>',
'images' => ['bnn.jpg'],
],
[
'slug' => 'aussenfassade-2',
'kind' => 'projekt',
'title' => 'Außenfassade',
'date' => '2020-10-19 14:44',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li></ul>',
'images' => ['img-0008-scaled.jpg', 'img-4658.jpg', 'img-0007-scaled.jpg'],
],
[
'slug' => 'aussenfassade-3',
'kind' => 'projekt',
'title' => 'Außenfassade',
'date' => '2021-12-14 12:12',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li></ul>',
'images' => ['photo-2021-12-14-13-02-09.jpg'],
],
[
'slug' => 'aussenfassade-44',
'kind' => 'projekt',
'title' => 'Außenfassade 44',
'date' => '2020-08-03 12:29',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li></ul>',
'images' => ['photo-2020-08-03-11-31-21-2-1.jpg', 'photo-2020-08-03-11-31-21-2.jpg', 'photo-2020-08-03-11-31-21-3.jpg'],
],
[
'slug' => 'aussenfassade-fachwerkhaus',
'kind' => 'projekt',
'title' => 'Aussenfassade Fachwerkhaus',
'date' => '2020-08-03 12:33',
'excerpt' => '',
'description' => '',
'content' => '</li></ul>',
'images' => ['photo-2020-08-03-11-31-21.jpg'],
],
[
'slug' => 'aussenfassade-sanierung',
'kind' => 'projekt',
'title' => 'Außenfassade Sanierung',
'date' => '2021-04-08 06:28',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
'images' => ['gse-buetthof-bilder.jpg', 'img-0103-scaled.jpg', 'img-0104-scaled.jpg', 'img-0118-scaled.jpg'],
],
[
'slug' => 'aussenfassade',
'kind' => 'projekt',
'title' => 'Außenfassade',
'date' => '2020-09-14 06:18',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
'images' => ['photo-2020-09-12-09-50-01-1.jpg'],
],
[
'slug' => 'bad',
'kind' => 'projekt',
'title' => 'Bad',
'date' => '2020-10-19 14:42',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
'images' => ['a934b725-d7dc-4d23-a967-a8ceab1a7268.jpg'],
],
[
'slug' => 'betriebsferien-vom-11-08-bis-22-08',
'kind' => 'news',
'title' => 'Betriebsferien vom 11.08. bis 22.08.',
'date' => '2025-07-23 05:25',
'excerpt' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
'description' => 'Liebe Kundinnen und Kunden,bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August …',
'content' => '<p>Liebe Kundinnen und Kunden,<br>bitte beachten Sie, dass wir vom 11. August bis einschließlich 22. August Betriebsferien machen. In dieser Zeit ist unser Betrieb geschlossen. Ab dem 25. August sind wir wieder wie gewohnt für Sie da.<br><br>Vielen Dank für Ihr Verständnis und eine schöne Sommerzeit!<br>Ihr Malerservice Drews<br><br>Danke!</p>',
'images' => ['betriebsferien.jpg', 'betriebsferien-1.jpg'],
],
[
'slug' => 'decke-zierleisten',
'kind' => 'projekt',
'title' => 'Rekonstruktion Decken Zierleiste',
'date' => '2021-02-24 06:52',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li><li></li></ul>',
'images' => ['photo-2021-02-24-06-47-34-3.jpg', 'photo-2021-02-24-06-47-34.jpg', 'photo-2021-02-24-06-47-34-1.jpg', 'photo-2021-02-24-06-47-34-2.jpg', 'photo-2021-02-24-06-48-19.jpg', 'photo-2021-02-24-06-48-191.jpg'],
],
[
'slug' => 'dekoration',
'kind' => 'projekt',
'title' => 'Dekoration',
'date' => '2020-08-03 12:20',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li></ul>',
'images' => ['photo-2020-08-03-11-29-15.jpg', 'photo-2020-08-03-11-31-21-1.jpg'],
],
[
'slug' => 'exklusiv-in-mittelbaden-wir-sind-offizieller-little-greene-partner',
'kind' => 'news',
'title' => 'Exklusiv in Mittelbaden: Wir sind offizieller Little Greene Partner',
'date' => '2025-04-11 17:44',
'excerpt' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
'description' => 'Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner …',
'content' => '<p>Wir freuen uns sehr, Ihnen mitteilen zu können, dass unser Malerbetrieb ab sofort exklusiver Partner im Raum Mittelbaden für den traditionsreichen Farben- und Tapetenhersteller Little Greene ist.<br>Little Greene steht seit jeher für hochwertige sowie umweltfreundliche Farben mit beeindruckender Farbtiefe, historische Farbkollektionen mit britischem Flair und handverlesene Tapetendesigns mit Stil und Charakter.</p> <p>Als exklusiver Partner in der Region Mittelbaden bieten wir Ihnen nicht nur Zugang zu den Produkten von Little Greene, sondern auch eine umfassende Beratung und professionelle Verarbeitung direkt vom Fachbetrieb. Ob Neubau, Altbausanierung oder Innenraumgestaltung – wir setzen Ihre Wünsche mit hochwertigen Materialien und einem Sinn für Ästhetik um.<br>Auf unserem Instagram-Account erhalten Sie regelmäßig neue Inspiration für Ihre Raumgestaltung mit Farben und Tapeten von Little Greene.</p>',
'images' => ['photo-2025-04-11-14-47-35.jpg'],
],
[
'slug' => 'fensterladen-verzierung',
'kind' => 'projekt',
'title' => 'Fensterladen Verzierung',
'date' => '2021-12-01 14:02',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li></ul>',
'images' => ['img-5893-scaled.jpg', 'img-5869.jpg', 'img-0136-scaled.jpg'],
],
[
'slug' => 'freilegen-einer-backsteinwand',
'kind' => 'projekt',
'title' => 'Freilegen einer Backsteinwand',
'date' => '2023-02-16 07:37',
'excerpt' => '',
'description' => '',
'content' => '',
'images' => ['1676472540643-scaled.jpg', '1676472540620-scaled.jpg', '1676472540632-scaled.jpg'],
],
[
'slug' => 'friedrichsbad-baden-baden',
'kind' => 'projekt',
'title' => 'Glanzvoll renoviert: Malerarbeiten im historischen Friedrichsbad, Baden‑Baden',
'date' => '2025-07-30 09:48',
'excerpt' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
'description' => 'Im ehrwürdigen Friedrichsbad in Baden‑Baden, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, …',
'content' => '<p>Im ehrwürdigen <strong>Friedrichsbad in Baden‑Baden</strong>, dem prachtvollen römisch-irischen Thermalbad im neorenaissance Stil, wurden die Wände und Decken behutsam restauriert und neu gestaltet. Mit fein abgestimmten Farbtönen und hochwertigen Techniken hat Malermeister Drews die prachtvollen Majolika-Kacheln, die eleganten Stuckelemente und die beeindruckende Kuppelarchitektur gekonnt in Szene gesetzt – ohne den historischen Charakter zu verfälschen.</p> <p>Das Ergebnis: ein stimmiger Mix aus eleganter Modernisierung und historischer Ästhetik, der das Bad nicht nur technisch aufwertet, sondern auch den Besuchern eine visuelle Reise durch vergangene Epochen ermöglicht. Gerade in Bereichen wie Eingangsbereich, Kuppelsaal und Umkleiden wurde mit viel Feingefühl gearbeitet, um die subtile Balance zwischen Restaurierung und zeitgemäßem Farbkonzept zu wahren.</p> <p>Ob renovierte Wandflächen, frische Texturen oder veredelte Holz- und Metallbereiche – das Team hat dem Friedrichsbad ein innenarchitektonisches Gesicht verliehen, das Geschichte atmet und gleichzeitig einladend wirkt.</p>',
'images' => ['kopie-von-2024-06-24-2.jpg', 'kopie-von-2024-06-24-4.jpg', 'kopie-von-2024-06-24-3.jpg', 'kopie-von-2024-06-24-5.jpg'],
],
[
'slug' => 'frohe-weihnachten-und-einen-guten-start-ins-neue-jahr',
'kind' => 'news',
'title' => 'Frohe Weihnachten und einen guten Start ins neue Jahr!',
'date' => '2025-11-23 19:33',
'excerpt' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
'description' => 'Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr. Der …',
'content' => '<p>Wir bedanken uns herzlich für Ihr Vertrauen und die angenehme Zusammenarbeit im vergangenen Jahr.</p> <p>Der gesamte Malerbetrieb wünscht Ihnen und Ihren Liebsten besinnliche Feiertage, erholsame Stunden und einen farbenfrohen Start in das Jahr 2026.</p> <p>**Betriebsferien**</p> <p>Unser Betrieb bleibt vom **22.12.2025 bis einschließlich 06.01.2026** geschlossen.</p> <p>Ab dem **07.01.2026** sind wir wie gewohnt wieder für Sie erreichbar und freuen uns darauf, Ihre Projekte mit frischen Ideen und handwerklicher Qualität umzusetzen.</p> <p>Wir wünschen Ihnen eine wundervolle Weihnachtszeit! 🎄</p>',
'images' => ['image0.jpeg', 'image0-1.jpeg'],
],
[
'slug' => 'fugenloses-bad-und-boden',
'kind' => 'projekt',
'title' => 'Fugenloses Bad und Boden',
'date' => '2020-08-04 08:32',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li></ul>',
'images' => ['photo-2020-08-03-16-36-12.jpg', 'photo-2020-08-03-16-36-12-2.jpg', 'photo-2020-08-03-16-36-12-1.jpg'],
],
[
'slug' => 'fugenloses-bad',
'kind' => 'projekt',
'title' => 'Fugenloses Bad',
'date' => '2020-12-01 22:14',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li></ul>',
'images' => ['44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106-1.jpg', 'img-20201130-155129-resized-20201130-035353784.jpg', 'img-20201130-155118-resized-20201130-035352490.jpg', 'img-20201130-155109-resized-20201130-035353150.jpg', '44d46f3a-255b-441d-8d80-e4810a150368-e1606860739106.jpg'],
],
[
'slug' => 'grund-und-hauptschule',
'kind' => 'projekt',
'title' => 'Grund- und Hauptschule',
'date' => '2020-08-06 08:46',
'excerpt' => '',
'description' => '',
'content' => '',
'images' => ['binarydata-big-256998.jpg'],
],
[
'slug' => 'kirche',
'kind' => 'projekt',
'title' => 'Kirche',
'date' => '2020-07-20 12:56',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
'images' => ['malerservice-drews-112-von-116.jpg'],
],
[
'slug' => 'modernisierung-neugestaltung',
'kind' => 'projekt',
'title' => 'Modernisierung & Neugestaltung',
'date' => '2021-11-29 13:55',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li></ul>',
'images' => ['img-5922-scaled.jpg', 'img-5921-scaled.jpg', 'img-5923-scaled.jpg', 'img-5924-scaled.jpg'],
],
[
'slug' => 'sanierung-fachwerkhaus',
'kind' => 'projekt',
'title' => 'Sanierung Fachwerkhaus',
'date' => '2020-09-08 04:54',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
'images' => ['photo-2020-09-07-10-06-38-5-1.jpg', 'photo-2020-09-07-10-06-49.jpg'],
],
[
'slug' => 'sanierung-wohnzimmer-mit-kaminofen',
'kind' => 'projekt',
'title' => 'Sanierung Wohnzimmer mit Kaminofen',
'date' => '2020-11-20 13:06',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li><li></li></ul>',
'images' => ['photo-2020-11-19-18-30-50.jpg', 'photo-2020-11-19-18-30-46-1.jpg', 'photo-2020-11-19-18-30-46.jpg', 'photo-2020-11-19-18-30-47.jpg', 'photo-2020-11-19-18-30-48-1.jpg', 'photo-2020-11-19-18-30-48.jpg', 'photo-2020-11-19-18-30-49-1.jpg', 'photo-2020-11-19-18-30-49.jpg', 'photo-2020-11-19-18-30-50-1.jpg', 'photo-2020-11-19-18-30-51-1.jpg', 'photo-2020-11-19-18-30-51.jpg', 'photo-2020-11-19-18-30-52.jpg'],
],
[
'slug' => 'schulkindbetreuung-hilpertsau',
'kind' => 'projekt',
'title' => 'Unterwasserzauber – Malerarbeiten in der Schulkindbetreuung Hilpertsau',
'date' => '2025-07-30 09:38',
'excerpt' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
'description' => 'Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll …',
'content' => '<p>Im ehemaligen Feuerwehrhaus in Hilpertsau wurde das Treppenhaus der Schulkindbetreuung mit liebevoll gestalteten Wandmalereien in eine fantasievolle Unterwasserwelt verwandelt. Seepferdchen, bunte Fische und wellenförmige Elemente erwecken die Wände und Decken zum Leben und laden Kinder zum Staunen und Entdecken ein.</p> <p>Die kreative Gestaltung stammt vom renommierten Malermeisterbetrieb Drews aus Gernsbach – sie schafft nicht nur optische Freude, sondern fördert auch die kindliche Vorstellungskraft und Spielmotivationen</p>',
'images' => ['kopie-von-2024-04-19-4.jpg', 'kopie-von-2024-04-19-3.jpg', 'kopie-von-2024-04-19-5.jpg', 'kopie-von-2024-04-19-6.jpg', 'kopie-von-img-1398.jpg'],
],
[
'slug' => 'sommerfest-2024',
'kind' => 'news',
'title' => 'Sommerfest 2024',
'date' => '2024-09-28 15:37',
'excerpt' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
'description' => 'Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.Zu Beginn …',
'content' => '<p>Am vergangenen Freitag durften wir bei strahlendem Spätsommerwetter unser Sommerfest auf dem Firmengelände feiern.<br>Zu Beginn des Festes richtete unser Geschäftsführer Axel Drews einige Worte an die Anwesenden. In seiner Rede bedankte er sich bei allen für ihren unermüdlichen Einsatz und die stetige Bereitschaft, das Unternehmen mit vollem Engagement zu unterstützen. Danach folgte der Höhepunkt des Tages: Die Ehrung langjähriger Mitarbeiter.</p> <p>Drei unserer geschätzten Kollegen wurden für ihre beeindruckende Betriebszugehörigkeit geehrt und mit einer Urkunde sowie einem liebevoll zusammengestellten Präsentkorb bedacht. Eine besonders bemerkenswerte Auszeichnung erhielt Dennis Bähring, der bereits seit unglaublichen 30 Jahren Teil unseres Unternehmens ist. Dennis begann seine Laufbahn bei uns mit seiner Ausbildung und ist seitdem ein fester Bestandteil unseres Teams. In seiner langen Zeit bei uns hat er zahlreiche Projekte und Veränderungen miterlebt. Sein Engagement, seine Loyalität und sein Fachwissen sind für uns von unschätzbarem Wert.</p> <p>Andreas Vetter und Jan Kassner wurden ebenfalls für ihre langjährige Treue zum Unternehmen gewürdigt. Beide blicken auf stolze 20 Jahre Betriebszugehörigkeit zurück. In dieser Zeit haben sie durch ihre tägliche Arbeit maßgeblich zum Erfolg des Betriebs beigetragen. Sie haben sich stets als verlässliche und engagierte Mitarbeiter erwiesen, und es war uns eine Ehre, ihnen für ihre Loyalität und ihren Einsatz zu danken.</p> <p>Das Sommerfest bot den idealen Rahmen, um diese besonderen Jubiläen zu feiern und den Teamgeist noch weiter zu stärken. Bei leckerem Essen, erfrischenden Getränken und geselligen Gesprächen genossen alle Anwesenden den restlichen Abend. Es war ein gelungenes Fest, das nicht nur als Dankeschön für die Mitarbeit der vergangenen Monate diente, sondern auch als Erinnerung daran, wie wichtig das gemeinsame Miteinander in unserem Unternehmen ist.</p> <p>Noch einmal herzlichen Glückwunsch an unsere Jubilare und ein großes Dankeschön an alle Mitarbeiterinnen und Mitarbeiter für euer tägliches Engagement und eure wertvolle Arbeit! Wir freuen uns darauf, gemeinsam weiterhin erfolgreich in die Zukunft zu gehen.</p>',
'images' => ['photo-2024-09-27-15-22-38-1.jpg', 'photo-2024-09-27-15-22-38.jpg'],
],
[
'slug' => 'treppenhaus-sanierung',
'kind' => 'projekt',
'title' => 'Treppenhaus Sanierung',
'date' => '2022-02-24 13:27',
'excerpt' => '',
'description' => '',
'content' => '',
'images' => ['image-50379521-scaled.jpg', 'image-50438401-scaled.jpg', 'image-50424065-scaled.jpg', 'image-50386433-scaled.jpg', 'image-50382337-scaled.jpg', 'treppenhaus-axel-2-scaled.jpg', 'treppenhaus-axel-3-scaled.jpg', 'treppenhaus-axel-4-scaled.jpg', 'treppenhaus-axel-1-scaled.jpg', 'treppenhaus-axel-5-scaled.jpg', 'treppenhaus-axel-scaled.jpg'],
],
[
'slug' => 'verkleidung-einer-fassade-aus-nf-brettern',
'kind' => 'projekt',
'title' => 'Verkleidung einer Fassade aus N+F Brettern',
'date' => '2023-12-19 06:23',
'excerpt' => '',
'description' => '',
'content' => '',
'images' => ['img-0594-scaled.jpg', 'screenshot-2023-12-18-084035.jpg', 'pfarramt-fassade.png', 'img-0597-scaled.jpg', 'img-0595-scaled.jpg', 'img-0596-scaled.jpg'],
],
[
'slug' => 'von-abgenutzt-zu-atemberaubend-die-verwandlung-einer-tuer-durch-restauration-und-malerei',
'kind' => 'projekt',
'title' => 'Von Abgenutzt zu Atemberaubend: Die Verwandlung einer Tür durch Restauration und Malerei!',
'date' => '2023-08-17 09:26',
'excerpt' => '',
'description' => '',
'content' => '<p><a href="https://www.facebook.com/photo/?fbid=308837308169469&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837158169484&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837301502803&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837224836144&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p> <p><a href="https://www.facebook.com/photo/?fbid=308837141502819&set=pcb.308837688169431&__cft__[0]=AZWbtxJ5wO1PC_el2WxDYu_8NYfaCIN9SR4vq9CrGTh6wUB5B_YhXjHs3C0lVjXxzhdSH_HkZCAdf_HEW8d1B04aE0n--y2TF4PoD1_CXQ6Do9K6s--X_DV_NEyRSOT5_rs&__tn__=*bH-R"></a></p>',
'images' => ['1692193874729-scaled.jpg', '1692193874743-scaled.jpg', 'img-9452-scaled.jpg', '1692193874974-scaled.jpg', '1692193874955-scaled.jpg'],
],
[
'slug' => 'wand-beton-optik',
'kind' => 'projekt',
'title' => 'Wand Beton Optik',
'date' => '2022-03-09 07:42',
'excerpt' => '',
'description' => '',
'content' => '',
'images' => ['4e4c6616-c9bb-4660-a3f5-d3617f2f9ddb-1.jpg', '41bad44a-d3b5-4859-a6ae-447dd5de5fc6.jpg', '64c6dc8d-8425-441c-8838-fe9dda248c4e.jpg', 'img-1047-scaled.jpg', 'img-1045-scaled.jpg', 'img-1046-scaled.jpg'],
],
[
'slug' => 'wappen-sanierung',
'kind' => 'projekt',
'title' => 'Wappen Sanierung',
'date' => '2020-10-01 11:11',
'excerpt' => '',
'description' => '',
'content' => '</li><li></li></ul>',
'images' => ['photo-2020-09-29-09-15-34-1.jpg'],
],
[
'slug' => 'wohnzimmer-gestaltung',
'kind' => 'projekt',
'title' => 'Wohnzimmer Gestaltung',
'date' => '2022-02-28 15:18',
'excerpt' => '',
'description' => '',
'content' => '',
'images' => ['wohnzimmergestaltung-2.jpg'],
],
],
];
File diff suppressed because it is too large. Load diff
+909
View File
@@ -0,0 +1,909 @@
<?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}"],
]),
], '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 ($section, $abschnitt, $row, $column, $kleinzeile, $heading, $text, $serif, $emGruen, $nurOben, $nurUnten): array {
// Oben ein ruhiges Band in der Farbe des Angebotsbereichs: Kleinzeile, Titel, Vorspann.
// Die Sektion bleibt voll breit; die Breite begrenzt erst der Inhalt darin. So steht der
// Text auf derselben Kante wie Marke und Menü im Kopf, statt am Fensterrand zu kleben.
$kopf = $section($praefix.'-kopf', [
[...$kleinzeile($praefix.'-label', $label), 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
[...$heading($praefix.'-h1', $titel, 1, [
'desktop' => [...$serif, 'fontSize' => '59px', 'margin' => ['bottom' => '22px']],
'tablet' => ['fontSize' => '46px'],
'mobile' => ['fontSize' => '40px', 'margin' => ['bottom' => '16px']],
], $emGruen), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
[...$text($praefix.'-vorspann', $vorspann, [
'desktop' => ['maxWidth' => '640px', 'fontSize' => '19px', 'lineHeight' => 1.7, 'color' => '@gedaempft', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '17px'],
]), 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
], [
'desktop' => ['backgroundColor' => '@blass', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '84px', 'bottom' => '72px']],
'tablet' => ['padding' => ['top' => '64px', 'bottom' => '56px']],
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '40px']],
]);
// Darunter jeder Punkt als eigene Zeile: Zwischentitel links, Text rechts, getrennt durch
// eine feine Linie. Auf dem Telefon untereinander.
$punkte = [];
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
$punkte[] = $row($praefix.'-punkt-'.$i, [
$column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [
$heading($praefix.'-h2-'.$i, $zwischentitel, 2, [
'desktop' => ['fontFamily' => 'body', 'fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '-0.01em', 'lineHeight' => 1.5, 'color' => '@tinte', 'margin' => ['bottom' => '0px']],
]),
], [
'desktop' => ['flexBasis' => '280px', 'flexShrink' => 0],
'tablet' => ['flexBasis' => '220px'],
'mobile' => ['flexBasis' => 'auto'],
]),
$column($praefix.'-punkt-'.$i.'-text', [
$text($praefix.'-p-'.$i, $inhalt, [
'desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@gedaempft', 'margin' => ['bottom' => '0px']],
], "& a{color:#2e7595;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#245e79}"),
], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0']]),
], [], [
'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '48px', 'margin' => ['top' => '0px'],
'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '28px', 'bottom' => '28px']],
'tablet' => ['gap' => '32px'],
'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '22px', 'bottom' => '22px']],
]);
}
$inhalt = $abschnitt($praefix, [
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '960px']]),
], ['desktop' => ['padding' => ['top' => '56px', 'bottom' => '100px']], 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '61px']]]);
return [$kopf, $inhalt];
};
// ---------------------------------------------------------------------------------------------
// 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";
+5 -9
View File
@@ -17,7 +17,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
* ankommt, kostet Rechenzeit und Logzeilen. Heißt die Verwaltung anders, findet sie niemand, der
* sie nicht kennt. Das ersetzt kein gutes Passwort - es nimmt der Maschinerie nur das Ziel.
*
* Der Wert steht in `.env.local` und nicht in der Datenbank: die Routen stehen fest, bevor die
* Der Wert steht in `.env` und nicht in der Datenbank: die Routen stehen fest, bevor die
* erste Datenbankabfrage läuft. Deshalb dieser Befehl - er schreibt die Datei, prüft vorher, dass
* die Adresse benutzbar ist, und räumt den Zwischenspeicher auf, damit die neue Adresse sofort
* gilt.
@@ -68,14 +68,10 @@ final class AdminPathCommand extends Command
return Command::FAILURE;
}
$datei = $this->projectDir.'/.env.local';
$inhalt = is_file($datei) ? (string) file_get_contents($datei) : '';
$zeile = sprintf('ADMIN_PATH=%s', $neu);
$inhalt = 1 === preg_match('/^ADMIN_PATH=.*$/m', $inhalt)
? (string) preg_replace('/^ADMIN_PATH=.*$/m', $zeile, $inhalt)
: rtrim($inhalt)."\n".$zeile."\n";
if (false === @file_put_contents($datei, ltrim($inhalt, "\n"))) {
$io->error('.env.local ist nicht beschreibbar. Trage dort von Hand ein: '.$zeile);
try {
\App\Ops\Environment::write($this->projectDir, ['ADMIN_PATH' => $neu]);
} catch (\RuntimeException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Command;
use App\Ops\Scheduler;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Der dritte Weg: ein echter Cronjob oder ein Dauerläufer.
*
* Für Installationen, die eine Konsole haben. Er tut dasselbe wie der Besuchs-Auslöser, nur ohne
* dessen enge Grenzen – und er läuft auch nachts um drei, wenn die Website leer ist.
*
* * * * * * cd /pfad && php bin/console cms:tasks:run (jede Minute, Standardweg)
* php bin/console cms:tasks:run --loop=60 --max-time=3600 (Dauerläufer, z. B. unter Supervisor)
* php bin/console cms:tasks:run --task=backup (eine bestimmte, sofort)
*/
#[AsCommand(name: 'cms:tasks:run', description: 'Führt fällige Aufgaben aus. Für einen echten Cronjob oder als Dauerläufer.')]
final class RunTasksCommand extends Command
{
public function __construct(private Scheduler $scheduler)
{
parent::__construct();
}
protected function configure(): void
{
$this->addOption('task', null, InputOption::VALUE_REQUIRED, 'Nur diese Aufgabe, unabhängig vom Zeitplan.')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Aufgaben je Durchlauf.', '10')
->addOption('budget', null, InputOption::VALUE_REQUIRED, 'Höchstens so viele Sekunden je Durchlauf.', '300')
->addOption('loop', null, InputOption::VALUE_REQUIRED, 'Als Dauerläufer: Sekunden zwischen zwei Durchläufen.')
->addOption('max-time', null, InputOption::VALUE_REQUIRED, 'Der Dauerläufer endet nach so vielen Sekunden.', '3600');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (null !== ($name = $input->getOption('task'))) {
$result = $this->scheduler->runNow((string) $name, 'konsole');
if (null === $result) {
$io->error('Diese Aufgabe gibt es nicht oder sie läuft gerade schon. `cms:tasks` zeigt, welche es gibt.');
return Command::FAILURE;
}
$this->report($io, [$result]);
return 'ok' === $result['status'] ? Command::SUCCESS : Command::FAILURE;
}
$limit = max(1, (int) $input->getOption('limit'));
$budget = max(1, (int) $input->getOption('budget'));
$loop = null !== $input->getOption('loop') ? max(5, (int) $input->getOption('loop')) : null;
$deadline = microtime(true) + max(0, (int) $input->getOption('max-time'));
$failed = false;
while (true) {
$results = $this->scheduler->runDue('konsole', $limit, $budget);
$failed = $failed || [] !== array_filter($results, static fn (array $r): bool => 'ok' !== $r['status']);
$this->report($io, $results);
if (null === $loop) {
break;
}
// Ein Dauerläufer, der nie endet, sammelt Speicher und hält eine Datenbankverbindung
// über Tage. Er endet nach `--max-time`; der Prozessverwalter startet ihn neu.
if (microtime(true) + $loop > $deadline) {
$io->writeln(sprintf('<comment>Ende nach %d Sekunden – neu starten lassen.</comment>', (int) $input->getOption('max-time')));
break;
}
sleep($loop);
}
return $failed ? Command::FAILURE : Command::SUCCESS;
}
/** @param list<array{name: string, label: string, status: string, message: string, ms: int}> $results */
private function report(SymfonyStyle $io, array $results): void
{
if ([] === $results) {
$io->writeln(sprintf('<info>%s</info> nichts fällig.', gmdate('H:i:s')));
return;
}
foreach ($results as $result) {
$io->writeln(sprintf(
'<info>%s</info> %s %s (%d ms): %s',
gmdate('H:i:s'),
'ok' === $result['status'] ? '<info>ok</info> ' : '<error>fehler</error>',
$result['name'],
$result['ms'],
$result['message'],
));
}
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Command;
use App\Ops\Environment;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Zeigt die öffentliche Adresse der Website oder ändert sie.
*
* Die Adresse (`SITE_ORIGIN`) steht in `.env`, weil sie zur Installation gehört und nicht zum
* Inhalt: Der Installer schreibt sie einmal, danach gab es nur den Editor für die Datei. Dieser
* Befehl prüft, was hineinkommt - mit Schema, ohne Pfad -, und schreibt es an dieselbe Stelle.
*
* Mit oder ohne www ist damit eine Entscheidung an einem Ort: Canonical, Sitemap, Feed und die
* Umleitung der anderen Schreibweise folgen dieser Einstellung.
*/
#[AsCommand(name: 'cms:site:origin', description: 'Zeigt die öffentliche Adresse der Website (SITE_ORIGIN) oder ändert sie.')]
final class SiteOriginCommand extends Command
{
public function __construct(private string $projectDir, private string $siteOrigin)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('adresse', InputArgument::OPTIONAL, 'Die neue Adresse mit Schema, z. B. https://www.meine-firma.de. Ohne Angabe wird die aktuelle gezeigt.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$neu = trim((string) $input->getArgument('adresse'));
if ('' === $neu) {
$io->writeln(sprintf('Die Website ist eingetragen als <info>%s</info>.', '' === $this->siteOrigin ? '(leer)' : $this->siteOrigin));
$io->writeln('Ändern: <comment>php bin/console cms:site:origin https://www.meine-firma.de</comment>');
return Command::SUCCESS;
}
try {
$neu = self::normalize($neu);
} catch (\InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
try {
Environment::write($this->projectDir, ['SITE_ORIGIN' => $neu]);
} catch (\RuntimeException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
$io->success(sprintf('Die Website ist jetzt eingetragen als %s.', $neu));
$io->writeln('Canonical, Sitemap, Feed und strukturierte Daten nennen diese Adresse; die Schreibweise '.(str_starts_with(parse_url($neu, PHP_URL_HOST) ?: '', 'www.') ? 'ohne' : 'mit').' www leitet dorthin um.');
$io->writeln('Falls die Website den alten Wert noch kennt: <comment>php bin/console cache:clear --env=prod</comment>');
return Command::SUCCESS;
}
/**
* Schema und Host, kleingeschrieben, ohne Pfad, Abfrage oder abschließenden Schrägstrich.
*
* @throws \InvalidArgumentException
*/
public static function normalize(string $adresse): string
{
$adresse = rtrim(trim($adresse), '/');
$teile = parse_url($adresse);
$schema = strtolower((string) ($teile['scheme'] ?? ''));
if (!\in_array($schema, ['http', 'https'], true)) {
throw new \InvalidArgumentException('Die Adresse beginnt mit https:// – zum Beispiel https://www.meine-firma.de');
}
$host = strtolower((string) ($teile['host'] ?? ''));
if ('' === $host || false === filter_var($schema.'://'.$host, FILTER_VALIDATE_URL) || isset($teile['user'], $teile['pass'])) {
throw new \InvalidArgumentException('Diese Adresse ist keine gültige URL.');
}
if (isset($teile['path']) && '' !== $teile['path'] || isset($teile['query']) || isset($teile['fragment'])) {
throw new \InvalidArgumentException('Nur Schema und Domain, ohne Pfad: https://www.meine-firma.de statt https://www.meine-firma.de/start');
}
return $schema.'://'.$host.(isset($teile['port']) ? ':'.$teile['port'] : '');
}
}
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff. Show more