Compare commits

...
181 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 6b99781819 Release 1.36.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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:20:48 +02:00
kantarciandClaude Opus 5.5 6cfbc6cf7e Update Symfony to the latest 7.4 patch releases
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:20:48 +02:00
kantarciandClaude Opus 5.5 bb736e8258 Make the registry search live
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:13:38 +02:00
kantarciandClaude Opus 5.5 7cdb431fd4 Add search, filters and pages to the central registry
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 Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:04:46 +02:00
kantarciandClaude Opus 5.5 821c799225 Note the 1.36.0 release in the status
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:45:31 +02:00
kantarciandClaude Opus 5.5 02e4412771 Release 1.36.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 Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:44:26 +02:00
kantarciandClaude Opus 5.5 723680b7de Add the central site registry and remote updates
Every installation now reports address and version to sites.kant-it.de
every 15 minutes and picks up update orders there (ADR 123). Reports and
answers are HMAC-signed with a per-installation key sent on first
contact; an order can only install a published release with checksum.
The next request after the file swap runs the migrations, so a remote
update finishes without anyone in the admin.

The platform itself lives in zentrale/ (plain PHP + SQLite, not part of
the release archive): overview with outdated, silent and unassigned
installations, single and bulk updates, per-site detail and history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:43:13 +02:00
kantarciandClaude Opus 5.5 32cb77b72e Serve Lora and Manrope for the EBI Immobilien draft
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 Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:01:39 +02:00
kantarciandClaude Opus 5.5 233bab1e43 Add the EBI Immobilien redesign draft
Rebuild of ebi-immobilien.de (Karlsruhe) in its own installation on
ports 8106/8107: home page, search request and valuation as step forms,
contact, legal pages and redirects for the old WordPress addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:01:18 +02:00
kantarciandClaude Opus 5.5 7fb4d2bfbd Note the 1.35.1 release in the status
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:50:01 +02:00
kantarciandClaude Opus 5.5 ef7cb02cbd Release 1.35.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 7eaa3f6efa Tidy the mail and system screens and let the mail log be cleared
Text in admin cards had no size of its own, so 16px browser default sat
next to 13px notes; cards now use 13px throughout while fields stay at
16px for iOS. The mail page gets option cards, a 2x2 SMTP grid, a styled
save button in the card foot and a log with status pills and collapsed
server messages, plus "Protokoll leeren". The package card uses the same
sections and a styled file picker. The E-Mail-Versand subitem only shows
inside System and sits beside it in the horizontal strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 daaeb52588 Note the 1.35.0 release in the status
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:08:27 +02:00
kantarciandClaude Opus 5.5 6b1293cdb8 Release 1.35.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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 b982cb8944 Give mail delivery its own admin page and move the origin probe off /.well-known
The mail page under System tries SMTP, PHP mail() and both sendmail
modes one by one or all at once, and saves the chosen way to .env
without ever showing the password. IONOS answers everything under
/.well-known/ itself with 403, so the address and HTTPS checks never
reached the installation; the probe now lives at /kantsites-origin
(ADR 122).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:07:27 +02:00
kantarciandClaude Opus 5.5 2497b8cb69 Note the 1.34.0 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:30:39 +02:00
kantarciandClaude Opus 5.5 f5b9ae03e3 Release 1.34.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:29:36 +02:00
kantarciandClaude Opus 5.5 d313156c73 Send a test mail from the admin and log why delivery failed
Form submissions marked as not sent gave no reason: the transport
exception was swallowed. Every send now goes through MailCheck, which
logs the server's answer and a hint in German to var/log/mail.log
(server lines only, no credentials). The system screen shows the mail
route, sends a test mail to any address and lists recent attempts
(ADR 121).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:29:36 +02:00
kantarciandClaude Opus 5.5 090436233d Release 1.33.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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:22:56 +02:00
kantarciandClaude Opus 5.5 8b212d5cc9 Force HTTPS from the admin, with a probe before and after
System > HTTPS and cms:https switch the redirect lines in public/.htaccess
only when the site answers over HTTPS, and take them back on a loop. HSTS
is a separate second switch. The redirect block moves before the front
controller rules, where it never reached pages, and .well-known is no
longer denied, so the origin probe works on Apache. The Git updater keeps
switched-on lines like the archive update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:22:56 +02:00
kantarciandClaude Opus 5.5 daad5a1740 Release 1.32.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:50:23 +02:00
kantarciandClaude Opus 5.5 dd5bc16413 Close the collapsible menu after choosing a link
Same-page anchors kept the details menu open. scrolled.js closes it on
any link click and loads only where a collapsible menu holds such links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:50:23 +02:00
kantarciandClaude Opus 5.5 bb9d7c6b45 Note the 1.32.0 release in the status
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:32:24 +02:00
kantarciandClaude Opus 5.5 be4a4f23d8 Release 1.32.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:31:07 +02:00
kantarciandClaude Opus 5.5 32e42f973b Move site content between installations as a package
System now exports a content package and imports one, by upload or from
var/pakete. Pages with their revisions, areas, menus, media, forms,
redirects, terms, translations, design and settings are replaced;
submissions, statistics, tasks, plugin state and service keys stay, and
accounts only come along when that is chosen explicitly. A backup comes
first, the site is in maintenance meanwhile, and every file and document
in the package is checked before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:31:06 +02:00
kantarciandClaude Opus 5.5 a57b829a15 Note the 1.31.0 release in the status
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:07:28 +02:00
kantarciandClaude Opus 5.5 e465ec7437 Release 1.31.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:06:15 +02:00
kantarciandClaude Opus 5.5 9e82918a5e Close the open menu on a click beside it and add social icons
The open burger menu now closes when someone clicks elsewhere, without a
script: an invisible layer that belongs to the menu button covers the
page while the menu is open. Facebook, Instagram and WhatsApp join the
icon set, so menu buttons can carry them. The stroke attributes of every
icon move into the stylesheet, which saves about 4 KB per page.

Hüfner gets a right-aligned menu with the quote button, Facebook and
Instagram as menu buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:06:15 +02:00
kantarciandClaude Opus 5.5 3c5c50f499 Note the 1.30.0 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:40:22 +02:00
kantarciandClaude Opus 5.5 c9c797e592 Release 1.30.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:26 +02:00
kantarciandClaude Opus 5.5 ad7c333215 Harden KantSites after the security review
- The installer only opens with a per-installation key that install.php
  creates and shows; the key is deleted with the first account.
- Lock files keep .env, the database, backups and logs out of reach when a
  domain points at the project folder instead of public/.
- Linked sections render by their own schema version, so an old section
  cannot carry unfiltered HTML into a newer page.
- Editors no longer move, trash or change areas and post data of published
  pages; a scheduled publication goes live with the revision that was
  scheduled (migration 20261012000100).
- Form stamps are bound to their form, IPv6 senders count per /64, and a
  flood per form or overall stops storing with a visible message.
- The consent redirect rejects control characters, update targets must look
  like 1.2.3, and own CSS cannot reach outside its block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:38:25 +02:00
kantarciandClaude Opus 5.5 14e7a3b310 Open Hüfner's outside links in a new tab and crop the tree for the favicon
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:03:03 +02:00
kantarciandClaude Opus 5.5 69f7977ecf Note the 1.29.0 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:57:31 +02:00
kantarciandClaude Opus 5.5 6f6e5d876c Release 1.29.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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6fa303f901 Save consent without reloading the page
consent.js posts the consent form in the background, the server answers
204 with the cookie, and the script swaps in the embeds and the legal line
from a fresh copy of the page. Embed hosts are in frame-src from the start,
the content address still only after consent. Without JavaScript the form
keeps working with a reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6390c00876 Rework the Hüfner site after the customer meeting
Tree green from the logo everywhere, the 100 emoji inline wherever 100
appears, downloads for the exemption certificates and the PQ certificate,
the image video behind consent, Google reviews, a 48-hour quote promise,
WhatsApp, two job cards and a page for the alpacas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:56:16 +02:00
kantarciandClaude Opus 5.5 6ee05f4c70 Remove the duplicated 1.28.1 status entry
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:35:11 +02:00
kantarciandClaude Opus 5.5 1b6cbb2247 Note the 1.28.1 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:34:58 +02:00
kantarciandClaude Opus 5.5 b298b7469f Release 1.28.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:33:31 +02:00
kantarciandClaude Opus 5.5 760efc3297 Stop the rewrite rule from looping on IONOS
IONOS answered every rewritten address with a blank Apache 500: the
!-f condition did not recognise the already rewritten index.php, so the
rule fired again until Apache gave up. RewriteBase and an explicit stop
for index.php, as in WordPress, fix it (ADR 114).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:33:31 +02:00
kantarciandClaude Opus 5.5 28070298aa Note the 1.28.0 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:46:20 +02:00
kantarciandClaude Opus 5.5 436c66ecd3 Release 1.28.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 Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:43:12 +02:00
kantarciandClaude Opus 5.5 a6a334fe68 Show a maintenance page instead of a blank 500 and update in one click
Emergency layer logs fatal errors to var/log/notfall.log and answers 503.
The system page checks for a new release on load, shows its notes, runs
backup, download, switch, migration and health check in one flow with live
progress, and switches back automatically when the new version fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 18:33:53 +02:00
kantarciandClaude Opus 5.5 339a48c810 Note the 1.27.1 release in the status
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 Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:37:45 +02:00
kantarciandClaude Opus 5.5 f2b29afe22 Release 1.27.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:25 +02:00
kantarciandClaude Opus 5.5 47292c7266 Finish an archive update in one go and translate Kantarci Reisen into Turkish
The request that swaps the files now ends with a plain page instead of running on through the
framework with old code in memory and new code on disk - that is what answered with a 500 after
a successful update. The page calls the second step itself with a one-time key: migrations,
clean-up, maintenance off, back to the system screen (ADR 112).

Turkish is complete in the fixed template texts and form messages. Two language switches on one
page get distinct landmark names. Kantarci Reisen gets a Turkish version of every page, the
header, footer, menu and enquiry form, with a DE · TR switch; the reset script applies it.
The DM Sans 600 files are needed after all and are committed with fonts.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:36:25 +02:00
kantarciandClaude Opus 5.5 8aee38bde8 Note the removed unused DM Sans files in the status
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:04:57 +02:00
kantarciandClaude Opus 5.5 91ebfde829 Note the 1.27.0 release in the status
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:03:12 +02:00
kantarciandClaude Opus 5.5 ad86e4874b Release 1.27.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:00:54 +02:00
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:52 +02:00
kantarciandClaude Opus 5.5 c7705b7083 Add the Kantarci Reisen build scripts and local launch configurations
Seed, reset and asset scripts for the tenth installation, plus dev and prod launch entries
for it and for the multilingual test copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:44 +02:00
kantarciandClaude Opus 5.5 28b2442188 Release 1.26.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 Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:39:57 +02:00
kantarciandClaude Opus 5.5 8e628b9417 Keep a checkbox from toggling when the empty part of its row is clicked
Check labels spanned the full width, so a click in the empty middle of
"Als Button darstellen" unticked it and folded the menu button settings.
The label now ends after its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:39:57 +02:00
kantarciandClaude Opus 5.5 13f6759df8 Let menu entries appear as buttons with icon, colours and phone placement
The menu editor gets "Als Button darstellen" per entry: shape, appearance and
size from the button design, fill and ink with hover and palette, icon colour,
icon from the dialog, motion, icon-only and where the button sits on the phone
(in the opened menu, beside the burger, or there as an icon only). Colours go
into the page stylesheet under a stable per-button id because the CSP forbids
inline styles. ADR 110.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:36:12 +02:00
kantarciandClaude Opus 5.5 48ae2443cd Use the core truck icon in the Hüfner advantages bar
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
Since 1.25.0 the icon set has a truck, so the fleet advantage no longer needs
an own-HTML block with the template's SVG. The seed builds it like its three
neighbours (icon block, 32 px, light green); the local Hüfner database was
patched the same way and republished.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:10:00 +02:00
kantarciandClaude Opus 5.5 f00fbfef09 Release 1.25.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:01:18 +02:00
kantarciandClaude Opus 5.5 08998de533 Offer 98 icons in a dialog and style a button icon under Design
The icon set grows from 33 to 98 drawings in seven categories (arrows,
contact, commerce, files and media, people and security, house and trade,
general), including truck, cart, euro, camera, tools and solar. icons.js is
the single source; npm run build:icons writes the Twig drawings and the
validator list, and a test keeps them equal.

Choosing an icon no longer fills the panel with tiles: the panel shows the
current icon with "Change", which opens a modal with category tabs and named
tiles, keyboard navigable, closing with Escape. Button, icon and icon card
share it.

A new style group "Symbol" targets the icon inside a button per device and
on hover: colour, background, size, padding, radius, rotation, plus the gap
to the text. Schema v26 adds hover motion (slide, reveal) and an icon-only
button whose label stays as the accessible name and keeps the line height.
ADR 109.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:01:18 +02:00
kantarciandClaude Opus 5.5 6af2e2aead Release 1.24.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 Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:39:40 +02:00
kantarciandClaude Opus 5.5 88737a95a9 Let a button carry a chosen icon before or after its text, and pick icons from tiles
Schema v25: a button takes an optional icon (any name from the shared set or
"none") and an icon position (start or end). Without a choice it keeps the
diagonal arrow behind the text, still with the button-arrow class, so stored
pages and their own CSS look exactly as before.

Thirteen everyday icons join the set (arrow down, chevron, calendar, message,
send, download, document, play, search, heart, info, plus, person). Button,
icon and icon card choose from a tile picker with a drawing per icon, grouped,
keyboard navigable, instead of a select with English keys. icons.js holds the
paths for the editor; a test keeps them equal to the Twig drawings and the
validator list. ADR 108.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:39:34 +02:00
kantarciandClaude Opus 5.5 e1753fa7e4 Release 1.23.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:47:07 +02:00
kantarciandClaude Opus 5.5 5bfe1f3c6e Build Hüfner Gartenbau as a ninth installation, let buttons call and write, colour the button arrow like its text
- Website of Garten- und Landschaftsbau Hüfner GmbH rebuilt from the delivered design with
  imprint, privacy, 404 and the enquiry form (scripts/reset-huefner.sh, ports 8102/8103),
  Barlow and Barlow Condensed served locally with their licence.
- Buttons, icon cards, calls to action, icons and linked images accept tel: and mailto:
  links, the same targets menus and rich text already allowed (ADR 107).
- The arrow inside a button takes the button's text colour instead of the accent colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:47:06 +02:00
kantarciandClaude Opus 5.5 0ba82b7d33 Prepare the Kantarci Markt opening page with offers from the app
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
- /eroeffnung (noindex, unlinked): opening hero, offers embedded from
  app.kantarci-markt.de without title and app button, app store buttons,
  visit section with route, hours and contact placeholders.
- The height script on the embed asks the app for its height once it runs
  (it is deferred and missed the first report) and scrolls to the offer
  popup when the app reports it outside the viewport.
- audit.mjs takes CMS_HOST_RULES, because the app allows framing only from
  localhost while the server listens on 127.0.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:02:57 +02:00
kantarciandClaude Opus 5.5 668287f640 Release 1.23.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 Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:38:01 +02:00
kantarciandClaude Opus 5.5 5e5d380fd3 Build Kantarci Markt as an eighth installation, allow content in own CSS and keep other fonts when fetching
- Coming-soon page "Kantarci Markt – Neueröffnung" rebuilt from the delivered package with
  imprint, privacy and 404 (scripts/reset-kantarci-markt.sh, ports 8100/8101).
- Own CSS accepts a short quoted string for `content`, so ::before/::after work.
- cms:fonts:fetch replaces only the rules of the fetched families in fonts.css.
- A variable font is linked and preloaded once with a weight range instead of once per weight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:37:54 +02:00
kantarciandClaude Opus 5 6fb6d30e8d Release 1.22.5
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 Opus 5 <noreply@anthropic.com>
2026-09-22 12:21:35 +02:00
kantarciandClaude Opus 5 ace6c50743 Raise loaded documents to the schema their blocks need, and bring the old browser checks back
The kant-it.de header used the hamburger switch at schema 19, so the server refused its
preview and save; the editor now raises a loaded document to the version its blocks and
properties require. The admin, button and navigation checks select blocks through the
structure tab, find the motion button by id and expect what the site's motion setting says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:21:35 +02:00
kantarciandClaude Opus 5 82a74277cb Release 1.22.4
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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-22 10:59:21 +02:00
kantarciandClaude Opus 5 5d55a65c22 Put the menu drag handle next to the summary instead of inside it
A button inside a summary is an interactive element in an interactive element; screen
readers announce it unreliably. The handle keeps its look and its mouse, touch and arrow
key behaviour. Also corrects the anchor hints and waits for the menu error in the admin check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:59:21 +02:00
kantarciandClaude Opus 5 24c1d9c871 Release 1.22.3
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 Opus 5 <noreply@anthropic.com>
2026-09-22 09:56:07 +02:00
kantarciandClaude Opus 5 a976c146de Choose menu anchors, link targets in plugin settings and existing post terms
A menu entry picks its anchor from the linked page, link settings of plugins and own menu
links get page and PDF choice next to the address, and the post settings offer existing
categories and tags to click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:56:07 +02:00
kantarciandClaude Opus 5 3ea28a03fc Release 1.22.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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:35:05 +02:00
kantarciandClaude Opus 5 711af312af Pick images, pages, forms, sections and terms instead of typing ids and addresses
The share image and featured image of a page become image fields with the library dialog,
as do image settings of plugins. The editor offers forms, sections, categories and tags as
lists, a page search next to every link target and in text links, the redirect target has a
page search, and the form editor picks the reply field, the condition and the option images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:35:05 +02:00
kantarciandClaude Opus 5 31fb53c1fc Release 1.22.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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:38:02 +02:00
kantarciandClaude Opus 5 24f98633d0 Make every image of a zoomable gallery zoomable unless it chose otherwise
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:38:02 +02:00
kantarciandClaude Opus 5 e47f278a13 Release 1.22.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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-22 07:56:34 +02:00
kantarciandClaude Opus 5 e8a9dcdf5c Upload PDFs to the library, link them from any link target, show them in a viewer
PDFs go through the same upload field as images, are checked by their content and kept
unchanged in their own table. Every link field can pick one, and a page that links a PDF
opens it in a dialog with the browser's own viewer; touch devices and browsers without a
viewer get a new tab instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 07:56:29 +02:00
kantarciandClaude Opus 5 04927b9563 Release 1.21.8
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 Opus 5 <noreply@anthropic.com>
2026-09-21 08:36:39 +02:00
kantarciandClaude Opus 5 3b1dfc0948 Set the site address in the administration, checked against the installation itself
- System > This installation > Address has a field and a link to the other
  spelling (with or without www), like the WordPress address. It writes
  SITE_ORIGIN to .env through Environment::write and takes effect on the
  next request. With SITE_ORIGIN from the server environment there is no
  field, and a POST is refused.
- Before saving, OriginCheck requests /.well-known/kantsites-origin under the
  new address; it has to return an HMAC of APP_SECRET that only this
  installation knows. Switching to www without DNS or certificate would
  otherwise send every page, the administration included, nowhere. "Save
  anyway" appears only after a refusal. The probe also answers during
  maintenance.
- The message says when the administration changes host and needs a new
  sign-in, since the session cookie belongs to one spelling.

Tests: 584 PHPUnit on PHP 8.2 and 8.4, 35 node, test:editor and
test:browser green (test:browser reports only the known editor colour
contrast on /grundlagen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:36:38 +02:00
kantarciandClaude Opus 5 e21e800f2c Release 1.21.7
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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-21 07:41:15 +02:00
kantarciandClaude Opus 5 d8c8cbe7bd Remove the old favicon.svg in the second update step, which runs the new code
The file copy still runs the code of the installed version, which does not
know the list; the archive check from 1.21.6 to 1.21.7 left the file behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:41:15 +02:00
kantarciandClaude Opus 5 45d1141951 Show the own site icon to Google under every address, and the KANTARCI landing page
- Google kept showing the KantSites K for Elektro Bernhard after the site
  icon was changed. The own icon only existed at 32 and 180 px, and Google
  takes multiples of 48 only; /favicon.svg was a file in public/, so the web
  server always delivered the bundled K.
- The icon is now also cut at 48 and 192 px. The head links /favicon-192.png,
  /favicon.ico serves the 48 px PNG, and /favicon.svg is answered by the
  application: the bundled mark without an own icon (now in
  public/assets/favicon.svg), otherwise a 302 to the 192 px PNG. Sites that
  set their icon earlier get the new sizes on first request. A removed icon
  no longer counts even though its files remain.
- The archive update only adds files to public/, so it now deletes the old
  public/favicon.svg explicitly.
- Seventh local reference site: the KANTARCI Immobilien landing page with a
  three-step rental request, own database, seed and reset scripts.

Tests: 582 PHPUnit on PHP 8.2 and 8.4, 35 node, test:editor and
test:browser green (test:browser reports only the known editor colour
contrast on /grundlagen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:37:04 +02:00
kantarciandClaude Opus 5 42e74af052 Release 1.21.6
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 Opus 5 <noreply@anthropic.com>
2026-09-18 07:43:11 +02:00
kantarciandClaude Opus 5 106f84dfd5 Pick the site icon from the library and crop it, find legal pages by search, list newest first
- The site icon is no longer a select at the end of the company data. It has
  its own section: a button opens the media library (upload included), then
  a square is dragged, resized by its corner, a slider or +/-, and previewed
  at 180 and 32 px. The crop is stored as fractions (faviconCrop) and the
  PNGs are cut from it; faviconVersion changes the link so browsers pick up
  a new cut. Icons without a crop keep their old link.
- openMedia() takes needsAlt: false, since the icon never appears as
  content. The dialog styles moved from editor.css to media-dialog.css so
  the dialog works outside the editor.
- Privacy page and imprint are chosen with the content search, like the 404
  page. The picker can store the path instead of the id (valueKey/data-value).
- The page and post lists in the administration sort newest first; the
  subpage indentation is gone there, as it would point at the wrong parent.

Tests: 581 PHPUnit, 35 node, test:editor green (the consent step now picks
the privacy page by search). test:admin --admin-only fails only at the known
nested-interactive menu summaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:43:05 +02:00
kantarciandClaude Fable 5.1 6632836580 Release 1.21.5
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-17 12:56:57 +02:00
kantarciandClaude Fable 5.1 d0019c80af Arrow icons, cascading own CSS, a preload for the hero image, and EB Solar
Built while setting up the redesign of ebsolar.de as the sixth local
installation (scripts/seed-ebsolar.php, reset-ebsolar.sh, ports 8096/8097).
The rebuild surfaced four things in the core:

- The icon set knows three arrows now: arrow-right, arrow-left,
  arrow-up-right. The button block and the post navigation render them as
  inline SVG inside the same aria-hidden span as before, so a template that
  hides the arrow with "& span{display:none}" keeps working. Until now the
  arrow was a text glyph that looked different in every font. Umut: "bei
  alle Projekte bzw bei allen möchte ich dieses emoji icon weg haben".

- Own CSS with the same nested selector twice kept only the last rule.
  "& .post-card{border:0}" followed by "& .post-card{transform:none}" lost
  the border - silently, which brought the core's card frame back on the
  project grid. The parser now joins the declarations in source order, as
  CSS does; the same for "&{…}" next to the element's own properties.

- The eager image over the fold was not preloaded. The background image of
  the first section has had a preload link for a while; a plain <img> was
  found only after the stylesheet was parsed, and that round trip went
  straight into LCP. PageController::heroImage() now writes the same src,
  srcset and sizes the block template writes, and page.html.twig emits the
  link - unless a background image is already preloaded.

- Measured on the way: an entrance effect on the LCP element costs about
  900 ms of render delay. The EB Solar hero image therefore has none.

Tests: 581 green, the icon test accepts quoted keys, two cases for the CSS
cascade, the preload asserted in the big-picture test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 12:56:57 +02:00
kantarciandClaude Fable 5.1 ab9e6a7ea0 Release 1.21.4
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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-16 22:09:15 +02:00
kantarciandClaude Fable 5.1 8328bf0964 Give the scroll-to-top target a block box, and take the toolbar lift back out
Umut, after 1.21.3: "fix hat nichts gebracht" - and the fix could not have,
for two reasons. kant-it.de still runs 1.20.1, so nothing since then was ever
live there. And the diagnosis behind 1.21.3 rested on a test that proved
nothing: Safari's folded address bar shows only the domain, never the
fragment, so "nothing in the address bar" said nothing about whether the tap
had arrived.

Reproduced this time in the real engine: Playwright's WebKit with the iPhone 14
profile. The tap arrives, #seitenanfang lands in the URL, and scrollY stays at
1500. Setting location.hash directly does the same. In the desktop profile as
well, so this is Safari on the Mac too. Chromium jumps to 0.

Narrowed down with fourteen variants of the target: WebKit does not scroll to a
fragment target whose box is inline and has no area. The same empty span with
display:block jumps; an empty div with no height jumps; #top, #main and body
jump. It is the inline box, not the size. The section anchors are not affected
- position:absolute already blockifies them and they jump in WebKit.

So the target gets display:block, one rule in site.css. The template stays as
it is.

And the lift from 1.21.3 comes out again: --kant-browser-chrome and the six
places that added it to bottom or padding. It moved the button and the consent
bar up by the folded toolbar's height on every iPhone, for a cause that turned
out not to be the cause. On the desktop it was zero anyway.

WebKit iPhone 14 after the change: tap 0, click 0, hash 0. Chromium unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 22:06:58 +02:00
kantarciandClaude Opus 5 245f189d57 Release 1.21.3
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 Opus 5 <noreply@anthropic.com>
2026-09-16 21:56:12 +02:00
kantarciandClaude Opus 5 99b3aba39a Lift what sticks to the bottom above the browser's own bar
Umut: "Beim Smarphone/iphone geht der button scroll to top nicht. hier gehts."
The button was there and did nothing, on every page of every installation, and
tapping it did not even put #seitenanfang in the address bar - so the tap never
reached the page.

Safari on the iPhone folds its address bar into a narrow strip while reading
and lays that over the content. A fixed element measures its "bottom" against
the full viewport, not against the part that is left: the button stood in the
strip the browser had taken for itself. Drawn, visible, and every tap landed on
the bar. Measured at 390x844 the button occupies y 780 to 828 - the bottom 64
pixels, which is where that strip sits.

The difference between the large viewport (bar folded) and the small one (bar
out) is exactly that height, so --kant-browser-chrome carries it and the pieces
that stick to the bottom add it. Where there is no such bar it is zero and
nothing moves. The @supports around it is not caution but necessity: a browser
that does not know lvh/svh would find the calculation invalid at computed-value
time, and bottom would fall back to auto - the button would then stand
anywhere.

The consent bar has the same geometry, so "Annehmen" and "Ablehnen" were just
as hard to hit. It is lifted the same way, except where it is glued to the edge
- raising it there would leave a gap with the page showing through, so its
bottom padding grows instead and the buttons move up with it.

Verified with a test page that sets the height to 50px, the only way to see the
effect without an iPhone: the button moves up by exactly 50px, the glued bar
keeps its edge and its padding goes from 16px to 66px. On the real page in
Chromium bottom stays 16px and the jump still goes 1500 to 0.

Not verified on a real iPhone - that needs the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:56:07 +02:00
kantarciandClaude Opus 5 c91f996524 Release 1.21.2
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 21:30:23 +02:00
kantarciandClaude Opus 5 0ed4c83e41 Fix the mobile menu writing white on white and the hero losing its gutter
Umut on Elektro Bernhard: "Menü im Mobil Dropdown textfarben sind weiß also
weiß auf weiß und Hero banner texte sind ohne abstand rechts links im mobil".
Both are core faults, not content - every site with a floating header and a
flex section had them.

The opened burger panel paints its own white surface but took its text colour
from the header. A header that hovers over a dark photo writes white, so the
menu was white on white until the page was scrolled and the header turned
light. The design area has settings for the burger button - text, surface,
border, corners, size - but none for the panel, so there was no way to fix
this from the editor. The panel now sets its own colour alongside its own
background.

The gutter was lost to "flex:1 1 auto" on .section-inner, added in 67b8575 so
a full height section could grow. Growing works along the main axis, and that
defaults to the row: the inner box grew sideways and pushed the container
gutter out. On the desktop the theme's maximum width stopped it before anyone
noticed; on a phone there is none, and the text sat hard against the edge
while the header and every other section kept their 18px. It is "flex:0 1
auto" now. The height never needed it - across the main axis a flex item
stretches anyway, and where "align-items: center" is set, the middle is what
was asked for.

Measured at 375px: hero content was x 0 / 375 wide, now x 18 / 339, the same
as the header. At 1440px nothing changes (x 93 / 1240). Checked against the
full screen hero template as well - edge to edge, 100dvh, content centred -
which renders as before.

579 PHP tests / 4798 assertions on 8.4 and 8.2, 35 JavaScript model tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:30:19 +02:00
kantarciandClaude Opus 5 9e0365f841 Release 1.21.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 15:56:25 +02:00
kantarciandClaude Opus 5 ff2b57db58 Make the start page of a new installation a construction page
Umut: "Kannst du bitte fuer die Erstseite bzw. nach der Installation eine
schoene Erstseite erstellen. Under Construction oder so ohne Details mit melde
dich an usw. Am besten auch noch Logo von KantSites."

starterPage() shipped two sections of instructions for the owner: "Melde dich
an, oeffne sie im Editor ..." and "Die naechsten Schritte" with design, menus
and settings. That stood on the only page a fresh installation serves
publicly, so the visitor read it, not the owner - who gets the same steps on
the last installer screen and in the admin area anyway.

It is now the page such a site actually needs: the KantSites mark, the label
"In Arbeit" in the accent colour, the site name as the single H1, "Hier
entsteht eine neue Webpraesenz.", a short rule and the line "Erstellt mit
KantSites". The section is a flex container with min-height 70vh and a
gradient from @paper to @mint, so the little that is there sits in the middle
of the screen; the colours are palette references and follow a design change.
Everything is an ordinary block with ordinary style values and can be changed
or removed in the editor. The page description is a real sentence now instead
of "NOCH AUSZUFUELLEN", so a freshly installed site does not carry a
placeholder into search results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:56:21 +02:00
kantarciandClaude Opus 5 741eaa80f7 Release 1.21.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 Opus 5 <noreply@anthropic.com>
2026-09-16 09:18:34 +02:00
kantarciandClaude Opus 5 23fdca4edb Move image corners into the design area
Umut on the DEEG hero image: "It should be rounded, but it was done from
somewhere, and the corners look odd. And the editor has no image corner
rounding at all, does it?"

It did have one - per block, under Design > Bild > Bildecken. What it did not
have was the 12px that enhancements.css set on .block-image for every
installation: rounded on screen, present in no field, and only removable by
overriding it on each block. On a template that is square throughout it shows,
because the elements above the image - the gradient, the sign, the blue call
to action - are square and stick out of the rounded corner.

The radius is now theme.base.imageRadius, validated like the container width,
emitted as --k-image-radius and used by the stylesheet. The default stays 12px
so existing sites look the same; 0px makes every image square. The per-block
setting still wins.

DEEG is set to 0px, in the seed script and in the installation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:18:27 +02:00
kantarciandClaude Opus 5 a8368068aa Release 1.20.2
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 09:05:17 +02:00
kantarciandClaude Opus 5 f4ec0d9877 Make the entity repair a console command, not a script
scripts/ is on the exclusion list of build-release.php, so an installation
that runs from the release archive - the usual case on shared hosting -
would never have seen scripts/repair-doubled-entities.php. The 1.20.1 notes
told everyone to run it, which was only true for a Git working copy.

cms:repair:entities does the same work and ships with the application: a dry
run by default, --schreiben to apply, each change validated the way saving
validates it (pages with the heading rule, areas without, as in
PartialRepository). The script is gone; there is one way to do this now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:05:11 +02:00
kantarciandClaude Opus 5 d08d6f648a Release 1.20.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 08:57:26 +02:00
kantarciandClaude Opus 5 4539f357c1 Stop text from being encoded again on every save
Umut: "Beim Startseitenbild wird der Text & umgewandelt, aber in der Ansicht
nach dem Speichern wird &amp; daraus."

RichText::sanitise() treated text without a single tag as plain text and only
escaped it. What the editor sends is HTML, tag or no tag - so "Freudenstadt
&amp; Umgebung" gained one level of encoding on every save (&amp;amp;,
&amp;amp;amp; ...) until the entity stood on the page for everyone to read.
The draft of the kant-it.de home page had already reached three levels.

The same branch carried a second fault of the same kind: nl2br() left the
newline behind the <br>, and the next pass turned it into a second <br> via
breakLines() - one paragraph became two. The branch now decodes first and
replaces the line break instead of adding to it, so it ends up with the same
representation as the DOM branch and both are stable under repeated saves.

scripts/repair-doubled-entities.php cleans up what is already stored: the
published and draft revision of every page and the areas, with a dry run,
each change checked against the validator. Older revisions stay as history.

DEEG: favicon is now its own square mark - the roof gable from the logo,
white on the logo blue, rendered from logo-deeg.png - instead of the centre
crop of the wide logo, which showed the "EE" of the wordmark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:57:20 +02:00
kantarciandClaude Opus 5 d885b3ddb5 Release 1.20.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 08:27:49 +02:00
kantarciandClaude Opus 5 59a40157a4 Add an own-HTML block, and build the DEEG offers page on the embed block
Umut asked for a block that takes custom HTML, and for an iframe on a new
DEEG offers page. Only one of those was missing.

New core block "Eigenes HTML" (document schema v24): one property, the
markup, up to 20.000 characters, written to the page unchanged. Changing it
is reserved for administrators and compared against what is stored - on
saving, on saving an area, and on restoring an older revision - exactly like
an own script. <script> and <iframe> are refused with the way to the right
place instead of being swallowed by the content security policy: scripts
belong in the script field, frames in the "Externer Inhalt" block, because
only that one asks for consent and puts the host into frame-src (ADR 106).

DEEG: /angebote existed as a draft with placeholder text. It is now built
like the legal pages - small caps line, large light heading, lead on the
tinted band - with the IS24 result list below it and a line pointing to the
portal and to the contact form. Navigation and the offers band on the home
page point there. The consent question is switched on, so the frame loads
only after the visitor agrees, and both the banner and the placeholder carry
the site's own shapes and colours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:27:43 +02:00
kantarciandClaude Opus 5 6b1aa50d02 Release 1.19.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 Opus 5 <noreply@anthropic.com>
2026-09-16 07:39:36 +02:00
kantarciandClaude Opus 5 30aac4389d Take design packages back out of plugins
A plugin can no longer replace the site palette. "Designpaket übernehmen"
wrote the plugin's colours, fonts and buttons into the website for good:
no confirmation, no way back, right next to a switch that is safe to flip.
Colours belong to the design screen, where they have a preview and one-step
undo, so the feature is gone rather than guarded (ADR 105).

Removed: PluginRegistry::themePackage() and applyTheme(), "theme" in the
manifest and in the plugin structure, the admin_plugin_theme route and the
Theme dependency of PluginAdminController, the button and the design-package
line on the "Plugins" screen, and plugins/nachtblau. A manifest now needs at
least one block, one route or one preset.

The test says the opposite of what it used to: switching a plugin on and off
leaves both the design and the content alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:39:31 +02:00
kantarciandClaude Opus 5 53fb713f74 Release 1.18.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 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.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:16:56 +02:00
kantarciandClaude Fable 5.1 d07154eec1 Release 1.17.0
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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: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.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 15:20:44 +02:00
kantarciandClaude Fable 5.1 937344e642 Release 1.16.1
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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: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.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, 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 / php (8.2) (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: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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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 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.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
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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 / php (8.2) (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 07:14:46 +02:00
kantarciandClaude Fable 5.1 0aa1ff6d3e Line up the consent notice footer in 1.14.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
"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.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 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.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
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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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 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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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
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.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 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.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
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.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-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.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
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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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-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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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-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.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
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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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
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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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
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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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-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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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:35:52 +02:00
kantarci c82a37643b Document verified 1.10.1 release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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:46:55 +02:00
kantarci eea6747d9e Document published 1.9.0 release
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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: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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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: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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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:18:33 +02:00
kantarci a3c89bb69d docs: record verified 1.7.4 release artifacts
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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: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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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: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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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: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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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:09:24 +02:00
kantarci 11b60fc5d8 Dokumentiert die geprüfte Veröffentlichung von 1.7.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
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.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 10:49:54 +02:00
kantarci b164c4edfe Dokumentiert die gepruefte Veroeffentlichung von 1.6.2
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / php (8.2) (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.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 09:22:23 +02:00
kantarci 149e1fe520 Dokumentiert die gepruefte Veroeffentlichung von 1.6.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 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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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
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 / php (8.2) (push) Canceled after 0s
CMS quality / browser (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.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
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.4) (push) Canceled after 0s
CMS quality / php (8.2) (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
383 changed files with 47251 additions and 2628 deletions

No files matched your search

+216
View File
@@ -18,6 +18,222 @@
"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-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php"
],
"port": 8092
},
{
"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": "ebsolar",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8096'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8096 -t public bin/dev-router.php"
],
"port": 8096
},
{
"name": "ebsolar-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8097'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8097 -t public bin/dev-router.php"
],
"port": 8097
},
{
"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
},
{
"name": "kantarci-immobilien",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8098'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8098 -t public bin/dev-router.php"
],
"port": 8098
},
{
"name": "kantarci-immobilien-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php"
],
"port": 8099
},
{
"name": "kantarci-markt",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8100'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8100 -t public bin/dev-router.php"
],
"port": 8100
},
{
"name": "kantarci-markt-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
],
"port": 8101
},
{
"name": "huefner",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8102'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8102 -t public bin/dev-router.php"
],
"port": 8102
},
{
"name": "huefner-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php"
],
"port": 8103
},
{
"name": "kantarci-reisen",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8104'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8104 -t public bin/dev-router.php"
],
"port": 8104
},
{
"name": "kantarci-reisen-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8105'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8105 -t public bin/dev-router.php"
],
"port": 8105
},
{
"name": "sprachen-test",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8111'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8111 -t public bin/dev-router.php"
],
"port": 8111
},
{
"name": "sprachen-test-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
],
"port": 8112
},
{
"name": "ebi-immobilien",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8106'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8106 -t public bin/dev-router.php"
],
"port": 8106
},
{
"name": "ebi-immobilien-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8107'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8107 -t public bin/dev-router.php"
],
"port": 8107
},
{
"name": "zentrale",
"runtimeExecutable": "C:/php84/php.exe",
"runtimeArgs": ["-d", "curl.cainfo=C:/Program Files/Git/mingw64/etc/ssl/certs/ca-bundle.crt", "-S", "127.0.0.1:8115", "-t", "zentrale/public", "zentrale/public/index.php"],
"port": 8115
}
]
}
-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
+10 -4
View File
@@ -1,14 +1,20 @@
# 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
# Die Zentrale, bei der sich diese Installation mit Adresse und Fassung meldet und von der sie
# Updates annimmt. Ohne Eintrag: https://sites.kant-it.de. Abschalten mit ZENTRALE_URL=aus.
#ZENTRALE_URL=https://sites.kant-it.de
+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
+8
View File
@@ -3,6 +3,9 @@
/var/
/node_modules/
/.env.local
/.env
/.env.lock
/.env.*.tmp
/.env.*.local
/phpunit.cache/
/reports/
@@ -15,3 +18,8 @@
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
/public/assets/build/
# Zentrale (sites.kant-it.de): Konfiguration und Daten gehoeren dem Server.
/zentrale/config.php
/zentrale/var/*
!/zentrale/var/.htaccess
+24
View File
@@ -0,0 +1,24 @@
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
#
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
# Sperrdatei.
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
+302 -11
View File
@@ -1,9 +1,175 @@
# 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`.
## Buttons im Menü (ab 1.26.0)
Unter **Menüs** einen Eintrag aufklappen und **Als Button darstellen** anhaken. Dann stehen Form, Darstellung und Größe (leer = Website-Standard unter Design → Buttons), Fläche und Schrift – auch beim Überfahren – sowie die Symbolfarbe mit der Farbpalette zur Wahl; das Symbol wird wie im Editor im Fenster gewählt, dazu Bewegung und „Nur das Symbol zeigen“. **Auf dem Telefon**: im aufgeklappten Menü (volle Breite), neben dem Menü-Knopf oder dort nur als Symbol. Eine Vorschau zeigt den Button normal und beim Überfahren. Einträge mit Unterpunkten können kein Button sein. Prüfung: `npm run test:menu-buttons`, Screenshots unter `reports/menu-buttons/`. Details: ADR 110.
## Bildecken unter Design (ab 1.21.0)
Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden.
## Kaufmännisches Und und Zeilenumbrüche bleiben, wie sie sind (ab 1.20.1)
**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&amp;`, beim nächsten Speichern `&amp;amp;` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `<br>` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php bin/console cms:repair:entities` auf: ohne Option zeigt er nur, was sich ändern würde, mit `--schreiben` wendet er es an. Angefasst werden die veröffentlichte Fassung und der Entwurf jeder Seite sowie die Bereiche; ältere Fassungen bleiben Historie.
## Eigenes HTML und eingebettete Seiten (ab 1.20.0)
Der Baustein **Eigenes HTML** (Werkzeugkasten, Gruppe „Seite und Daten“) nimmt Markup auf, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. Ändern darf ihn nur ein **Administrator**, genau wie das Feld „Eigenes Skript“; für alle anderen ist er sichtbar und schreibgeschützt, und der Server weist eine Änderung ohnehin ab. Zwei Tags nimmt er nicht an: `<script>` gehört unter „Erweitert“ in „Eigenes Skript“, und ein `<iframe>` in den Baustein **Externer Inhalt** – nur der fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein (ADR 106).
Am eingebetteten Rahmen lässt sich alles gestalten, was dieser Website gehört: Höhe als Eigenschaft, Abstände, Breite, Rand und Ecken über Design-Tokens und freie Stile, dazu eigenes CSS am Block (`& iframe{…}`) und für den Platzhalter vor der Einwilligung (`&.embed-blocked`, `& .embed-title`, `& .consent-button`). **Was im Rahmen steht, gehört dem Anbieter** – daran kommt kein CSS dieser Website heran, das trennt der Browser zwischen zwei Herkünften. Den Einwilligungsbalken selbst gestaltet man unter **Einstellungen** (Form und vier Farben).
## 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), und Seitenvorlagen für den Editor mitbringen. Farben, Schriften und Buttons fasst ein Plugin nicht an – die ändert man unter **Design** (ADR 105). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert ist **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen). 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 +184,30 @@ 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.
**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. **Seit 1.30.0 öffnet sich der Installer nur mit dem Installationsschlüssel** (ADR 116): `install.php` legt ihn an und zeigt ihn mit einem fertigen Verweis auf seiner Abschlussseite; wer per FTP entpackt, findet ihn in `var/install.key`. Mit dem ersten Konto wird er gelöscht. Das Paket bringt Sperrdateien mit (`.htaccess` im Projektstamm und in `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/`; `var/.htaccess` legt die Website selbst an), damit auch bei einer Domain, die versehentlich auf den Projektordner zeigt, weder `.env` noch Datenbank oder Protokolle abrufbar sind. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
**Inhalte zwischen Installationen übertragen** (seit 1.32.0, ADR 118): System → „Inhalte übertragen“ exportiert ein Inhaltspaket (Zip) und importiert eines – per Upload oder aus `var/pakete/`. Ersetzt werden Seiten, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen, Statistik und Turnstile-Zugangsdaten bleiben, Konten nur mit eigenem Häkchen. Vorher wird gesichert. Auf der Konsole: `php bin/console cms:paket:export` und `php bin/console cms:paket:import <datei>`.
## 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 +218,31 @@ 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`.
**Symbole (ab 1.25.0):** Button, Symbol oder Symbolkarte auswählen → Inhalt → **Symbol** → **Ändern …**. Das Fenster zeigt 98 Symbole in sieben Kategorien, mit Namen; Pfeiltasten und Escape funktionieren, beim Button gibt es „Kein Symbol“. Am Button außerdem **Stellung** (vor/nach dem Text), **Beim Überfahren** (Ruhig, Rückt, Erscheint) und **Nur das Symbol zeigen** (quadratischer Knopf, die Beschriftung bleibt für Vorlesesoftware). Farbe, Fläche, Größe, Ecken, Drehung und Abstand stehen unter **Design → Symbol**, je Gerät und beim Überfahren. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil. Neue Symbole kommen nach `public/assets/icons.js`, danach `npm run build:icons`. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108 und 109.
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 +261,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 +290,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. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. 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 selbst; unter „System“ → „HTTPS“ schaltet „HTTPS erzwingen“ die drei Zeilen in `public/.htaccess` ein – nur wenn die Adresse vorher über HTTPS hier ankommt, mit Probe danach und Rücknahme bei einer Schleife. HSTS ist ein eigener Schalter. Konsole: `php bin/console cms:https` (Stand), `cms:https an|aus|hsts-an|hsts-aus` (ADR 120).
`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 +306,33 @@ 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.
Die Aufgabe „Bei der Zentrale melden“ schickt alle 15 Minuten Adresse und Fassung an `https://sites.kant-it.de` und holt dort angestoßene Updates ab (ADR 123, `zentrale/README.md`). Abschalten mit `ZENTRALE_URL=aus` in `.env`; Installationen mit lokalem `SITE_ORIGIN` melden nie. Protokollprüfung: `php scripts/zentrale-check.php`.
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 +341,72 @@ 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 `20261009000100` legt die Tabelle `page_translation` für die Mehrsprachigkeit an (neue Tabelle, kein Eingriff in Bestehendes; Sprachen unter Einstellungen → Sprachen, ADR 111). 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.
### Sechste lokale Referenzwebsite: EB Solar
Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden) liegt in `var/ebsolar.db` mit Medien in `var/ebsolar-media`; die Startkonfiguration `ebsolar` in `.claude/launch.json` nutzt Port 8096, `ebsolar-prod` denselben Bestand im Produktionsmodus auf Port 8097 zum Messen. `scripts/seed-ebsolar.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als wiederverwendbare Sektion, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge mit Galerie und Bildbetrachter, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `solarcheck` (acht Schritte wie das Original auf ebsolar.de, die fünf Auswahlfragen als Bildkarten – gezeichnet und gerastert von `node scripts/build-ebsolar-options.mjs`); `sh scripts/reset-ebsolar.sh [Bilderordner]` (Standard `var/ebsolar-assets`: Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos und `projects.json` 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.
### Siebte lokale Referenzwebsite: KANTARCI Immobilien (Landingpage)
Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`.
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server und schreibt zuletzt das Inhaltspaket `var/huefner-paket.zip` für die Live-Seite (dort unter System importieren, Konten bleiben). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
### Redesign-Entwurf: EBI Immobilien (Karlsruhe)
Der Nachbau von www.ebi-immobilien.de liegt in `var/ebi-immobilien.db` mit Medien in `var/ebi-immobilien-media`; die Startkonfiguration `ebi-immobilien` nutzt Port 8106, `ebi-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8107 zum Messen. Die Originalbilder der alten Website liegen in `var/ebi-immobilien-quelle`; eine Datei `startbild.jpg/.png/.webp` dort ersetzt das Startbild. `sh scripts/reset-ebi-immobilien.sh [Quellordner]` bereitet die Bilder vor (`scripts/build-ebi-immobilien-assets.php`, ohne Argument nimmt es die schon vorbereiteten aus `var/ebi-immobilien-assets`), wirft die Installation weg, baut mit `scripts/seed-ebi-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Suchauftrag, Immobilienbewertung, Kontakt, Impressum, Datenschutz, 404, Weiterleitungen der alten Adressen, Menü und die Formulare `suchauftrag`, `bewertung`, `kontakt` auf, holt Manrope und Lora auf den eigenen Server und schreibt das Inhaltspaket `var/ebi-immobilien-paket.zip`. Das Passwort für „umut“ steht nach jedem Reset in `var/ebi-immobilien-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8107 CMS_PATHS=/,/suchauftrag,/immobilienbewertung,/kontakt CMS_AUDIT_DIR=reports/lighthouse-ebi-immobilien node scripts/audit.mjs`. Die bewussten Abweichungen vom Original stehen im Kopf des Seed-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.36.1
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+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";
Generated
+216 -217
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+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)%'
+1
View File
@@ -2,3 +2,4 @@ framework:
mailer:
# Tests never send real mail; the transport collects messages in memory instead.
dsn: 'null://null'
+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
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+24
View File
@@ -0,0 +1,24 @@
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
#
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
# Sperrdatei.
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
</FilesMatch>
+123 -6
View File
@@ -10,30 +10,53 @@ parameters:
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
kant.asset_dir: '%kernel.project_dir%/public/assets'
kant.mail_log: '%kernel.project_dir%/var/log/mail.log'
kant.mailer_dsn: 'sendmail://default'
# Die Einstellung, die die Verwaltung anzeigt und ausprobiert; Tests setzen sie fest.
kant.mail_dsn: '%env(default:kant.mailer_dsn:MAILER_DSN)%'
# Die Zentrale, bei der sich jede Installation meldet (ADR 123). ZENTRALE_URL=aus schaltet ab.
kant.zentrale_default: 'https://sites.kant-it.de'
kant.zentrale_url: '%env(default:kant.zentrale_default:ZENTRALE_URL)%'
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)%'
App\Content\DocumentLibrary:
arguments:
$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%'
@@ -43,10 +66,19 @@ services:
App\Content\StyleBundle:
arguments:
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
# Versand und Protokoll (ADR 121). MAIL_LOG nur fuer Tests, damit sie nicht ins echte var/log
# schreiben.
App\Ops\MailCheck:
arguments:
$dsn: '%kant.mail_dsn%'
$logFile: '%env(default:kant.mail_log:MAIL_LOG)%'
App\Controller\FormController:
arguments:
$secret: '%kernel.secret%'
$siteOrigin: '%env(SITE_ORIGIN)%'
App\EventSubscriber\ErrorLogSubscriber:
arguments:
$debug: '%kernel.debug%'
App\Controller\SeoController:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
@@ -54,21 +86,46 @@ services:
App\Command\HealthCommand:
arguments:
$projectDir: '%kernel.project_dir%'
App\Controller\MailController:
arguments:
$projectDir: '%kernel.project_dir%'
$siteOrigin: '%env(SITE_ORIGIN)%'
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%'
# Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive.
App\Ops\OriginCheck:
arguments:
$secret: '%kernel.secret%'
$download: null
# HTTPS erzwingen und HSTS (ADR 120); die Probe ueber http kommt im Test als Closure herein.
App\Ops\HttpsRedirect:
arguments:
$projectDir: '%kernel.project_dir%'
$httpProbe: null
App\Command\HttpsCommand:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
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 +136,74 @@ 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)%'
App\Ops\SitePackage:
arguments:
$root: '%kernel.project_dir%'
$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\Zentrale:
arguments:
$root: '%kernel.project_dir%'
$url: '%kant.zentrale_url%'
$siteOrigin: '%env(SITE_ORIGIN)%'
$databaseUrl: '%env(DATABASE_URL)%'
$senden: null
App\Ops\Tasks\ZentraleTask:
arguments:
$siteOrigin: '%env(SITE_ORIGIN)%'
$projectDir: '%kernel.project_dir%'
$anstossen: null
App\EventSubscriber\RemoteUpdateFinishSubscriber:
arguments:
$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'
# Tests: das Versandprotokoll gehoert nicht nach var/log der echten Installation, und die
# Verwaltung soll einen festen SMTP-Weg als eingestellt sehen. Verschickt wird trotzdem ueber
# null://null (config/packages/test/mailer.yaml), weil derselbe Weg den Transport des
# Frameworks nimmt. Hier und nicht in config/packages/test, weil diese Datei danach geladen wird.
when@test:
parameters:
kant.mail_log: '%kernel.cache_dir%/mail.log'
kant.mail_dsn: 'smtp://cms.test:587'
+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`.
+644 -7
View File
@@ -1,6 +1,610 @@
# Architekturentscheidungen
Stand: 12.09.2026. Arbeitstitel KantSites; Produktname offen.
Stand: 14.10.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 123 – Zentrale auf sites.kant-it.de: Meldung, Übersicht, Update aus der Ferne (14.10.2026)
Anlass: Umut: „Alle KantSites melden sich bei meiner Hauptplattform an … ich kann sehen, welche Domains welche Versionen nutzen, und kann von remote Aktualisierung anstoßen.“ Entschieden: `sites.kant-it.de`, ohne Lizenzschlüssel, Updates einzeln und für alle.
**Meldung (CMS).** Neue Aufgabe `zentrale` (`App\Ops\Tasks\ZentraleTask`, alle 15 Minuten) ruft `App\Ops\Zentrale::melden()` auf: `POST https://sites.kant-it.de/api/melden` mit Kennung, Adresse (`SITE_ORIGIN`), Fassung, PHP-Version, Datenbankart, Git/Archiv, Wartungszustand und Updatestand. Keine Inhalte, keine Besucherdaten, keine Zugangsdaten. Kennung und Schlüssel (32 bzw. 64 Hexzeichen, zufällig) liegen in `var/zentrale.json` und bleiben bei Updates erhalten. Abschalten mit `ZENTRALE_URL=aus`; Installationen mit lokalem `SITE_ORIGIN` (localhost, 127.0.0.1, `*.test`, `*.localhost`, `*.invalid`) melden nie.
**Echtheit ohne Lizenzschlüssel.** Beim ersten Kontakt schickt die Installation ihren Schlüssel über HTTPS mit (Vertrauen beim ersten Kontakt). Danach ist jede Meldung mit HMAC-SHA256 über den Rohtext unterschrieben (`X-KantSites-Signatur`), jede Antwort der Zentrale ebenso; die Installation verwirft Antworten ohne gültige Unterschrift. Eine bekannte Kennung mit fremdem Schlüssel weist die Zentrale ab (403). Kennt die Zentrale eine Kennung nicht mehr (409), meldet sich die Installation einmal mit Schlüssel neu an. Ein Auftrag kann nur „Update auf eine höhere Fassung der Form 1.2.3“ sein und läuft über `ArchiveUpdate` mit Prüfsumme der Releaseseite – mehr kann auch eine kompromittierte Zentrale nicht auslösen.
**Pull statt Push.** Die Zentrale verbindet sich nie mit einer Website. Sie merkt sich den Auftrag; die Website holt ihn mit ihrer nächsten Meldung ab. Keine Installation braucht dafür eine von außen erreichbare Steuerschnittstelle, und es geht auch hinter Firewalls.
**Update aus der Ferne.** `ZentraleTask` führt `ArchiveUpdate::run()` aus (Sicherung, Archiv, Prüfsumme, Dateitausch, Wartung an) und markiert den offenen zweiten Schritt mit `fern` (`ArchiveUpdate::markiereFern()`). Den zweiten Schritt erledigt die nächste Anfrage mit dem neuen Code: `RemoteUpdateFinishSubscriber` (kernel.request, Priorität 48, also vor der Wartungsseite) nimmt die Updatesperre, führt `finish()` mit der Funktionsprüfung aus und gibt die Website frei oder schaltet zurück. Damit das nicht auf den nächsten Besucher wartet, ruft die Aufgabe die eigene Startseite einmal auf (bis 300 s). Das Ergebnis geht mit der nächsten Meldung an die Zentrale und wird nach der Quittung aus `var/zentrale.json` gelöscht. Git-Arbeitskopien lehnen Aufträge ab. Die Funktionsprüfung nach den Migrationen ist dafür aus `SystemController` in `App\Ops\Funktionspruefung` gewandert; die Verwaltung nutzt denselben Dienst.
**Zentrale.** Eigener Ordner `zentrale/` im Repository, nicht Teil des Releasearchivs (`scripts/build-release.php` nimmt nur die aufgezählten Einträge). Reines PHP ab 8.1 mit SQLite (`var/zentrale.db`), ohne Framework und Composer, damit sie per FTP auf jedes Hosting passt. Anmeldung mit `password_hash`, Sitzungscookie `__Host-` mit SameSite=Strict, CSRF-Token auf jedem Formular, fünf Fehlversuche je IP in 15 Minuten, strenge CSP ohne Skripte, `noindex`. Das erste Konto legt an, wer den `einrichtungscode` aus `config.php` kennt. Die Übersicht zeigt Domain, Kunde, Fassung, letzte Meldung und Auftragsstand, zählt aktuelle, veraltete, verstummte (über 48 h) und nicht zugeordnete Installationen und bietet „Auf x.y.z“ je Zeile und „Alle aktualisieren“ mit Bestätigung (ohne verstummte, wartende und Git-Installationen). Die neueste Fassung kommt von derselben Releaseseite wie bei den Installationen, 30 Minuten zwischengespeichert. Fremde Nutzung erkennt man an „Unbekannt“ (kein Kunde zugeordnet) und am Hinweis „vorher …“ bei einer Kennung, die sich unter neuer Domain meldet.
**Grenzen.** Wer den Code verändert und die Meldung ausbaut, bleibt unsichtbar; ohne Lizenzschlüssel ist das kein Schutz, nur eine Übersicht. Installationen vor 1.36.0 erscheinen erst nach einem Update von Hand. Erreicht eine Website sich selbst nicht, steht die Wartungsseite bis zum nächsten Besuch oder Cron-Aufruf. Die Zentrale speichert die IP-Adresse der letzten Meldung; das gehört in die Datenschutzerklärung beziehungsweise Auftragsverarbeitung mit den Kunden.
**Folgen.** Keine Migration im CMS: `var/zentrale.json` entsteht beim ersten Lauf, die Aufgabe trägt sich über ihr Etikett selbst in `scheduled_task` ein. Das Schema der Zentrale migriert sich über `PRAGMA user_version` (`zentrale/app/Datenbank.php`). Protokollprüfung ohne Netz: `php scripts/zentrale-check.php`; der Ablauf mit Dateitausch und Abschluss steht in `tests/ArchiveUpdateTest.php`.
## ADR 122 – E-Mail-Versand als eigene Seite, Probe nicht mehr unter /.well-known (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS. Mit 1.34.0 zeigte die Testmail, dass `sendmail -bs` dort abbricht und `native://default` (PHP `mail()`) geht, solange der Absender zu einer Domain des Vertrags gehört. Umut: „Können wir die Einstellungsmethoden ins Backend verlagern? Dass man dort alle Methoden probiert und auch SMTP setzt?“ – und „bau es in ein Untermenü ein, nicht System vollstopfen“. Gleichzeitig scheiterte „HTTPS erzwingen“ mit „antwortet mit 403“, obwohl HTTPS lief.
**E-Mail-Versand.** Eigene Seite `/admin/system/e-mail` (`MailController`), im Menü als Unterpunkt von „System“; die Karte auf der Systemseite ist weg. Wege: SMTP mit Postfach (Server, Port, Benutzer, Passwort), PHP `mail()`, `sendmail -t`, `sendmail -bs`. „Diesen Weg testen“ schickt eine Testmail über den gewählten Weg, ohne ihn zu speichern; „Alle Wege testen“ probiert alle Wege des Servers und – wenn eingetragen – SMTP nacheinander; „Diesen Weg speichern“ schreibt `MAILER_DSN` über `Environment::write` in `.env` (nur wenn sie dort steht, nicht aus der Server-Umgebung). `MailCheck::smtpDsn` baut die DSN mit URL-kodiertem Benutzer und Passwort, Port 465 wird `smtps`. Das Passwort steht nie im Formular: leer gelassen gilt das gespeicherte, solange Server und Benutzer gleich bleiben. Derselbe Weg wie eingestellt nutzt den Transport des Frameworks, ein anderer wird mit `Transport::fromDsn` gebaut. Hinweise neu: fremder Absender bei Server-Versand („Sender address is not allowed“) und bei sendmail-Fehlern zuerst `mail()`.
**Probe.** `OriginCheck::PATH` ist jetzt `/kantsites-origin` statt `/.well-known/kantsites-origin`. IONOS beantwortet alles unter `/.well-known/` selbst mit 403 (nachgemessen mit und ohne www, http und https); die Anfrage erreicht die Installation nie, deshalb konnten „Adresse speichern“ und „HTTPS erzwingen“ dort nicht gelingen. Die Ausnahme für `.well-known` in `public/.htaccess` bleibt für Zertifikatsanbieter.
**Folgen.** Die Tests setzen die angezeigte Einstellung über `when@test` in `config/services.yaml` (`kant.mail_dsn`, `kant.mail_log`); in `config/packages/test/` wurden die Parameter von `services.yaml` überschrieben, so dass die Tests von 1.34.0 in das lokale `var/log/mail.log` schrieben (nicht Teil eines Releases). Keine Migration.
## ADR 121 – E-Mail-Versand mit Protokoll und Testmail (12.10.2026)
Anlass: Auf huefner-gartenbau.de (IONOS) standen zwei Anfragen als „Nicht versendet“ in der Verwaltung, und nirgends stand, warum: `FormController` fing die `TransportException` und verwarf sie. Umut: „Bau mal in den Backend einen Knopf ein, Testmail an Mailadresse, wo man einträgt, und wo Logs angezeigt werden, woran es liegt.“
**Entscheidung.** Jeder Versand geht durch `App\Ops\MailCheck`: Formular und Testmail. Die Klasse versendet über den Transport aus `MAILER_DSN`, wirft nie und schreibt je Versuch eine JSON-Zeile nach `var/log/mail.log` (per FTP lesbar, ab 256 KB nach `.1` rotiert): Zeit, Anlass, Empfänger, Absender, Ergebnis, Meldung des Servers, ein Hinweis auf Deutsch für die häufigen Fälle (Anmeldung abgelehnt, Server nicht erreichbar, TLS, sendmail nicht nutzbar, Absender abgelehnt, ungültige DSN, `null://`) und die Antworten des Servers. Aus dem SMTP-Gespräch bleiben nur die Zeilen des Servers (`< `), denn die Zeilen des Clients enthalten bei AUTH LOGIN Benutzer und Passwort in Base64. Die Besucheradresse (Antwortadresse) steht nicht im Protokoll.
**Systemseite.** Neue Karte „E-Mail-Versand“: Versandweg ohne Passwort (Art, Server, Benutzer, Quelle der Einstellung), Feld für eine beliebige Empfängeradresse und einen Absender (vorbelegt mit dem SMTP-Benutzer, wenn er eine Adresse ist), danach Ergebnis, Hinweis und Serverantworten, darunter die letzten zehn Versuche. Nur Administratoren, mit CSRF-Token.
**Folgen.** Der Transport wird träge gebaut (`lazy`), damit eine ungültige `MAILER_DSN` den Versand scheitern lässt und im Protokoll steht, statt die Systemseite selbst. Die Einstellung bleibt in `.env`; ein Feld zum Ändern der DSN in der Verwaltung gibt es bewusst noch nicht (Passwort im Formular). Tests schreiben ihr Protokoll in den Cache (`kant.mail_log`, überschreibbar mit `MAIL_LOG`). Keine Migration; der Grund steht im Protokoll, nicht an der Einsendung.
## ADR 120 – HTTPS erzwingen aus der Verwaltung (12.10.2026)
Anlass: Umut: „HTTPS wird noch nicht erzwungen, wie machen wir das allgemein?“ Bisher hieß das: per FTP die drei auskommentierten Zeilen in `public/.htaccess` einschalten. Die Anwendung selbst erzwingt bewusst nichts (ADR zur Adresse: hinter einem Proxy sieht PHP oft nur `http`, ohne vertrauenswürdige Proxys wäre das eine Schleife).
**Zwei Fehler, beim Bau gefunden und auf einem echten Apache (XAMPP 2.4) nachgestellt.**
1. Seit 1.28.1 stand der HTTPS-Block **hinter** `RewriteRule ^index\.php$ - [L]` und der Regel für `index.php`. Nach dem Umschreiben auf `index.php` beginnt die Runde neu und endet an der ersten Regel, die Umleitung kam für Seiten nie an die Reihe: Mit eingeschalteten Zeilen antworteten `/` und `/leistungen` mit 200, nur echte Dateien (`/x.css`) mit 301. Der Block steht jetzt direkt nach `RewriteBase`; nachgemessen: alle Pfade 301, mit `X-Forwarded-Proto: https` 200 (keine Schleife hinter einem Proxy).
2. `FilesMatch "^\."` sperrte auch `/.well-known/…`: Apache prüft den ersten nicht vorhandenen Pfadteil gegen die Regel. Die Probe `/.well-known/kantsites-origin` antwortete deshalb unter Apache mit 403 – auch die Prüfung bei „Adresse speichern“ konnte dort nie gelingen. Ausgenommen ist jetzt genau `.well-known` (`^\.(?!well-known$)`); `.env`, `.htaccess`, `.htpasswd` bleiben 403 (nachgemessen).
**Entscheidung.** `App\Ops\HttpsRedirect` schaltet die vorhandenen Zeilen ein und aus, unter System („HTTPS“) und als `cms:https [an|aus|hsts-an|hsts-aus]`:
- Einschalten nur mit `SITE_ORIGIN` auf `https://` und nur, wenn die Installation dort vorher mit gültigem Zertifikat ankommt (`OriginCheck`, dieselbe Kennungsprobe wie beim Adresswechsel).
- Danach dieselbe Probe noch einmal. Kommt sie nicht mehr an, ist es eine Schleife (der Server meldet HTTPS nicht und setzt keinen Proxy-Kopf): Die Datei wird sofort zurückgeschrieben.
- Dann `http://<Adresse>` ohne Weiterleitungen folgen: 301/308 auf genau die https-Adresse ist der Erfolg. Antwortet http anders, liest der Server die Regel nicht (mod_rewrite, AllowOverride) – die Zeilen schaden nicht, die Meldung verweist auf die Weiterleitung des Hosters. Erreicht der Server sich selbst nicht über http, sagt die Meldung das statt zu raten.
- HSTS ist ein eigener zweiter Schalter, nur bei eingeschalteter Umleitung und funktionierendem HTTPS, mit Warnung: Ein Browser hält den Kopf ein Jahr. „Umleitung ausschalten“ nimmt HSTS mit.
- Darf PHP die Datei nicht schreiben oder fehlen die Zeilen, zeigt System, was von Hand zu tun ist.
**Updates.** Die Zusammenführung, die eingeschaltete Zeilen in die neue `.htaccess` mitnimmt, steht jetzt in `HttpsRedirect::keepEnabled()` und gilt für beide Wege: das Archiv-Update wie bisher, und neu der Git-Updater, der die Datei vorher mit `checkout --force` überschrieb. Eine geänderte `public/.htaccess` zählt im Git-Weg nicht mehr als lokale Änderung, die das Update verweigert. Weil die Zeilen nach Inhalt und nicht nach Position gefunden werden, landet eine von Hand eingeschaltete Umleitung beim Update an der neuen, richtigen Stelle.
**Folgen.** Keine Migration. Installationen, die die Zeilen schon von Hand eingeschaltet haben, leiteten bisher nur Dateien um; mit dem Update auf 1.33.0 leiten sie alles um.
## ADR 119 – Das Menü schließt nach einer Wahl (12.10.2026)
Anlass: Umut: „Wenn man Links zu Ankern, also #kontakt, im Menü klickt, dann schließt sich das Menü nicht beim Mobilmenü. Also immer schließen, wenn man ausgewählt hat, egal was.“
**Ursache.** Das aufklappbare Menü ist ein `details` (ADR 117: öffnet ohne Skript). Ein Sprungziel auf derselben Seite lädt nicht neu, also bleibt `details` offen und liegt über dem Ziel. CSS kann ein `details` bei einem Linkklick nicht schließen.
**Entscheidung.** `scrolled.js`, das kleine Skript des Kopfbereichs, schließt das Menü beim Klick auf einen Link darin (`initMenus`). Es kommt nur dorthin, wo es gebraucht wird: wenn ein aufklappbares Menü (Burger oder waagerecht mit Zusammenklappen) einen Link auf ein Sprungziel **dieser** Seite trägt (`#…` oder der eigene Pfad mit `#…`). Ein Link auf eine andere Seite braucht nichts: Die Seite lädt neu, und weil öffentliche Seiten `no-store` senden, stellt „Zurück“ kein offenes Menü aus dem Seiten-Cache her (im Browser geprüft). So bleibt jede Seite ohne solche Sprünge bei `script-src 'none'`, und auf Seiten, die `scrolled.js` ohnehin laden (Nach-oben-Knopf, Kopf mit Scrollfarben), kommt kein Skript dazu.
**Geprüft:** Im Browser auf Hüfner (iPhone): „Kontakt“, „Angebot anfragen“ und „Leistungen“ schließen das Menü und springen; „Alpakas“ und „Zurück“ zeigen es geschlossen. Die bestehenden Tests, die `script-src 'none'` auf Seiten mit aufklappbarem Menü ohne Sprungziele verlangen, bleiben unverändert grün.
## ADR 118 – Inhaltspakete: Export und Import ohne die Konten (12.10.2026)
Anlass: Umut: „Können wir sowas wie einen Importer einbauen, weil sich nach jeder Anpassung sonst das Passwort ändert? Wo man auswählen muss, ob Passwort/Benutzer überschrieben werden sollen. Gerne auch als Plugin.“ Bisher wurde eine lokal gebaute Website als ganze SQLite-Datei hochgeladen – samt Konten (neues Passwort nach jedem Neuaufbau) und ohne das, was nur live entstanden war (Anfragen, Statistik).
**Entscheidung: im Kern, nicht als Plugin.** Plugins führen bewusst keinen eigenen Servercode aus (ADR zum Pluginsystem; serverseitiger Code nur mit Isolation). Ein Import schreibt Datenbank und Medienordner – das gehört in den Kern, unter System, nur für Administratoren.
**Paket.** Zip mit `paket.json` (Art, Format 1, Fassung, Website, Konten ja/nein, Zahlen, SHA-256 von `inhalt.json`), `inhalt.json` (Zeilen je Tabelle) und `medien/` (Bildvarianten, PDFs, erzeugte Seitensymbole). JSON statt SQLite-Datei: Ein Paket geht von SQLite nach MySQL und zurück, und jede Zeile wird vor dem Schreiben geprüft. Inhalt: `page`, `revision`, `partial`, `menu`, `media`, `document`, `form`, `redirect`, `term`, `page_term`, `page_translation` und die Website (Name, Sprache, Gestaltung, Einstellungen). **Nicht** darin: Anfragen, Statistik, Aufgaben, Plugin-Stand, Migrationsliste und die Zugangsdaten zu Diensten (`turnstile`, `turnstileSiteKey`, `turnstileSecret`). Konten nur mit „Konten mitnehmen“ beim Export.
**Import (`App\Ops\SitePackage::import`).** Prüfen vor Schreiben: nur bekannte Einträge im Zip (keine Pfade nach oben, keine anderen Dateitypen, Größengrenzen gegen Zip-Bomben), Prüfsumme von `inhalt.json`, nur bekannte Tabellen und Spaltennamen, jedes Seiten- und Bereichsdokument durch `DocumentValidator`, Paket nicht aus einer neueren Fassung. Dann Sicherung (`vor Import`), Wartungszustand, Inhalte in einer Transaktion ersetzen (nur Spalten, die es hier gibt – ein älteres Paket passt so in ein neueres Schema), Website-Einstellungen übernehmen außer den Dienst-Zugangsdaten, Anfragen über die Formularadresse dem neuen Formular zuordnen, Medienordner ersetzen. **Konten nur mit eigenem Häkchen** („Konten und Passwörter aus dem Paket übernehmen“); ohne bleiben Benutzer und Passwörter der Website. Ein Häkchen bei einem Paket ohne Konten bricht ab, ohne etwas zu ändern.
**Oberfläche.** System → „Inhalte übertragen“: Export als Download (Datei wird nach dem Senden gelöscht), Import per Upload oder aus `var/pakete/` (für Pakete über der Upload-Grenze des Hostings, die angezeigt wird), Bestätigungshäkchen, Ergebnisseite mit den Schritten und einem Hinweis, falls Schriften der neuen Gestaltung auf dem Server fehlen. Konsole: `cms:paket:export [--mit-benutzern] [--ziel=…]`, `cms:paket:import <datei> [--mit-benutzern] [--ja]`.
**Folgen.** Das Zurückspielen einer Sicherung bleibt Konsolensache; der Import ist der Weg für Inhalte aus einer anderen Installation, mit eigener Sicherung davor. Reset-Skripte können ein Paket schreiben (zuerst `reset-huefner.sh` → `var/huefner-paket.zip`). Keine Migration.
## ADR 117 – Menü schließt beim Klick daneben, Netzwerk-Symbole, schlankere Symbole (12.10.2026)
Anlass: Umut: „Bei Mobilmenü, wenn es aufgeklappt ist, schließt es nicht, wenn man irgendwo anders hinklickt.“ Und für Hüfner: Facebook, Instagram und „Angebot anfragen“ als Menü-Buttons statt eigener Knöpfe im Kopf.
**Entscheidungen.**
1. **Klick daneben schließt – ohne Skript.** Das Menü bleibt ein `details` (es soll auf jeder Seite ohne Skript aufgehen). Solange es offen ist, legt `summary::before` eine unsichtbare, feste Fläche über die Seite (`z-index:29`); sie gehört zum Knopf, ein Klick darauf schließt also. Die Menüfläche liegt darüber (`z-index:30`; außerhalb des Kopfbereichs per `:where()` mit niedriger Spezifität, damit die Platzierung im Kopf gewinnt). Der erste Klick daneben schließt nur und löst nichts darunter aus – wie bei jedem aufgeklappten Menü. Escape schließt weiterhin nicht (das bräuchte ein Skript). Grenze: Hat ein Vorfahr des Menüs `transform`/`filter`, deckt die feste Fläche nur diesen Vorfahren ab.
2. **Facebook, Instagram, WhatsApp im Symbolsatz.** Dieselben Strichformen wie `social-icon.html.twig`, als Pfad in `icon.html.twig`, `icons.js` (Reiter „Soziale Netzwerke“) und `DocumentValidator::ICONS`. Damit sind sie an Menü-Buttons, Buttons, Symbolkarten und Symbolen wählbar. Keine Schemaänderung; ein Dokument mit diesen Symbolen braucht aber einen Kern ab 1.31.0.
3. **Strichattribute ins Stylesheet.** Jedes Symbol trug `fill`, `stroke`, `stroke-width`, `stroke-linecap` und `stroke-linejoin` als Attribute, rund 105 Zeichen, bei etwa 40 Symbolen je Seite gut 4 KB HTML. Jetzt steht das einmal als `svg.block-icon{…}` in `site.css` und `admin.css`; `width`/`height` bleiben am Element, damit nichts ohne Stylesheet riesig wird. Anlass war die Hüfner-Startseite, die mit den Menü-Buttons das HTML-Budget von 150 KB riss.
**Geprüft:** Eigene Browserprüfung auf iPad (quer) und iPhone: Menü öffnet, Menülinks liegen über der Fläche, Klick daneben schließt, kein waagerechtes Scrollen. `test:menu-buttons` (Menü-Buttons, axe), `test:editor` (Verwaltung mit Symbolen, axe), `npm test` (Symbolsatz in Twig, JS und Validator gleich). `test:responsive-navigation` scheitert im Editorteil am Klick auf den Strukturbaum – ebenso ohne diese Änderungen (Gegenprobe mit zurückgesetzten Dateien), also vorbestehend.
## ADR 116 – Härtung nach dem Sicherheitscheck (12.10.2026)
Anlass: Umut: „Kannst du mal einen Sicherheitscheck für KantSites machen? Das Ding muss bombensicher sein.“ Fünf getrennte Prüfungen (Anmeldung/Rollen, öffentliche Eingaben, XSS/Ausgabe, Uploads/Dateien, Installer/Updater/Betrieb) plus ein Blick von außen auf die Live-Seiten. Keine ausnutzbare Lücke ohne Anmeldung bei korrekt eingerichteter Installation; behoben wird, was übrig blieb.
**Entscheidungen.**
1. **Installationsschlüssel.** Der Installer war bis zum ersten Konto für jeden offen – wer eine frisch entpackte Installation zuerst aufrief, konnte sie übernehmen (und vorher über den MySQL-Host fremde Rechner abtasten). Jetzt öffnet sich kein Schritt ohne einen 128-Bit-Schlüssel in `var/install.key`. `install.php` legt ihn an und zeigt ihn nur auf seiner Abschlussseite mit fertigem Verweis (`/install/schluessel?schluessel=…`); wer per FTP entpackt, liest die Datei. Im Browser bleibt ein Cookie `kant_install` mit einem HMAC über den Schlüssel (HttpOnly, SameSite=Strict, Pfad `/install`), nie der Schlüssel selbst. Mit dem ersten Konto wird die Datei gelöscht; das Anlegen des ersten Kontos läuft in einer Transaktion. Restrisiko: Wer `install.php` vor dem Betreiber aufruft, sieht den Schlüssel – das Fenster ist die Zeit zwischen Hochladen und Aufrufen.
2. **Sperrdateien.** Zeigte die Domain auf den Projektordner statt auf `public/`, waren `.env`, Datenbank, Sicherungen und Protokolle abrufbar; die Sperren standen nur in `public/.htaccess`. Jetzt sperren `.htaccess`-Dateien `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/` (Buildskript) und `var/` (legt die Website selbst an, `App\Ops\Protection`), dazu im Projektstamm gezielt Dotfiles, `env|db|sqlite|lock|md|yaml|yml|dist|log|key` und `composer.json`, `VERSION`, `cli-config.php`, `migrations.php`. Bewusst **kein** pauschales „alles sperren“ im Projektstamm: Apache wendet die Datei auch auf `public/` an, ein Update hätte jede Seite mit 403 lahmlegen können. Eine eigene `.htaccess` des Betreibers im Projektstamm bleibt unberührt; fehlende Dateien trägt die Website beim ersten Aufruf nach (auch nach einem Update von einer Fassung, deren Updater sie nicht kannte). Vorlagen: `config/schutz/`.
3. **Sektionen nach ihrem eigenen Schema-Stand.** Eine verknüpfte Sektion wurde mit den Ausgabeflags der Seite gerendert. Eine Sektion mit Schema unter v12 (deren Text nicht gefiltert wird) gab so in einer neueren Seite ungefiltertes HTML aus – ein Publisher konnte `<meta refresh>` oder fremde Links setzen, was sonst nur der Admin darf. Jetzt rechnet die Vorlage mit `section.schemaVersion`; neue Sektionen beginnen im aktuellen Schema.
4. **Redakteure ändern nichts Veröffentlichtes.** Adresse verschieben, Papierkorb (jeweils samt Unterseiten), Kopf/Fuß wählen und Beitragsdaten (robots, SEO-Titel, Datum …) wirkten sofort öffentlich und brauchten nur ROLE_EDITOR. Jetzt verlangen sie ROLE_PUBLISHER, sobald die Seite (bzw. eine Unterseite) veröffentlicht ist; Entwürfe bleiben frei.
5. **Geplante Veröffentlichung mit fester Fassung.** Zum Termin ging der neueste Entwurf online, auch einer, den ein Redakteur nach dem Planen gespeichert hatte. Jetzt speichert `schedule()` die damalige Fassung in `page.publish_revision_id` (Migration `20261012000100`, eine Spalte); `publishDue()` veröffentlicht genau sie. Planungen ohne Fassung (aus älteren Versionen) veröffentlichen weiterhin den Entwurf.
6. **Formulare.** Das Zeitsiegel ist an das Formular und den Zweck gebunden (`FormRepository::stamp`). Die Grenze je Absender zählt bei IPv6 das /64-Netz. Neu: höchstens 30 gespeicherte Anfragen je Formular und 120 insgesamt je Stunde; darüber wird nichts gespeichert und nichts verschickt – sichtbar mit Hinweis aufs Telefon statt still, damit kein echter Besucher glaubt, seine Anfrage sei angekommen.
7. **Kleinere Härtungen.** Einwilligungs-Rücksprung ohne Steuerzeichen und Backslash (Tabulator machte aus `/<Tab>/fremd` eine offene Weiterleitung). Update-Ziel nur in der Form `1.2.3` bzw. `v1.2.3`, `git checkout` mit `--`. Eigenes CSS: keine Geschwister des Blocks selbst (`& ~`, `& +`), Anführungszeichen paarweise, keine Kommentarzeichen in Werten; bereits gespeichertes CSS, das das verletzt, entfällt beim Ausgeben still, statt die Seite zu brechen.
**Nicht umgesetzt, mit Grund.** Signierte Releases (Umut: später); dann gehört ein Offline-Schlüssel dazu. Antwortzeit beim Login für unbekannte Benutzer, Trusted Proxies, Turnstile-Hostname, `%PDF-` ab Byte 0, archivierte PDFs, Statistik-Obergrenzen, MySQL-Passwort in der Prozessliste: niedrig, offen in STATUS.
**Folgen.** Neuinstallationen brauchen den Schlüssel; die Doku sagt, wo er steht. Bestehende Installationen merken nur die Sperrdateien (keine Wirkung bei korrekter Einrichtung) und die engeren Rollen. Migration läuft mit dem Updater. Ausgeliefert mit 1.30.0.
## ADR 115 – Einwilligung ohne Neuladen (12.10.2026)
Anlass: Umut auf der Hüfner-Seite: „dieses Einwilligen, da lädt die Seite komplett neu. Bekommen wir das auch ohne hin?“ Hinweis und Knopf am eingebetteten Inhalt waren reine Formulare (ADR zur Einwilligung: kein Skript), der Server setzte das Cookie und leitete zurück.
**Entscheidung.** Das Formular bleibt der Weg, ein kleines Skript `public/assets/consent.js` verbessert ihn. Es schickt dasselbe Formular im Hintergrund an `/einwilligung` mit der Kopfzeile `X-Kant-Consent: 1`; der Server antwortet dann mit 204 und dem Cookie statt mit der Weiterleitung (Herkunftsprüfung unverändert). Wurde etwas erlaubt, holt das Skript die Seite noch einmal und übernimmt daraus nur die eingebetteten Inhalte und die Zeile „Rechtliches“ (dort steht jetzt „Einwilligung ändern“); dann verschwindet der Hinweis. Der Fokus geht an den eingesetzten Rahmen bzw. an `#main`. Schlägt etwas fehl, sendet das Skript das Formular normal ab. Die Adresse eines Inhalts steht weiterhin erst nach der Einwilligung im HTML – kein `data-src` im Platzhalter.
**Was sich dafür ändert.** (1) Die Sicherheitsrichtlinie nennt die Hosts aller eingebetteten Inhalte der Seite in `frame-src`, auch solange die Einwilligung fehlt: eine Richtlinie steht beim Laden fest, ein nachträglich eingesetzter Rahmen bräuchte sonst ein Neuladen. Die Erlaubnis lädt nichts. (2) Seiten mit offener Frage (Hinweis offen oder ein Inhalt wartet) senden `script-src 'self'` und laden `consent.js`; nach der Entscheidung ohne wartende Inhalte wieder `script-src 'none'`. (3) Das Skriptbudget von `test:browser` (3 je Seite) zählt `consent.js` nicht mit, weil es nur bis zur Entscheidung da ist.
**Folgen.** Ohne JavaScript bleibt alles wie bisher. Andere Vorlagen als `PageController` (falls sie Einbettungen zeigen) laden das Skript nicht und bleiben beim Neuladen. Ausgeliefert mit 1.29.0.
## ADR 114 – Die Umschreibregel fängt index.php selbst ab (12.10.2026)
Anlass: huefner-gartenbau.de bei IONOS (PHP 8.4.26) antwortete auf `/`, `/install` und `/assets/` mit einem weißen 500er von Apache. Die Notfallschicht (ADR 113) griff nicht, weil PHP gar nicht erreicht wurde. `/index.php/install` lieferte dagegen 200, auch mit der `.htaccess` – die Datei wurde also gelesen, der Fehler lag in der Umschreibregel. Vermutete Ursache: `RewriteCond %{REQUEST_FILENAME} !-f` erkennt dort die bereits umgeschriebene `index.php` nicht als Datei, die Regel greift erneut, Apache bricht nach zehn internen Umleitungen ab.
**Entscheidung.** `public/.htaccess` setzt `RewriteBase /` und beendet die Umschreibung für `index.php` selbst (`RewriteRule ^index\.php$ - [L]`), wie die `.htaccess` von WordPress. Auf dem Hosting von Umut mit beiden Zeilen bestätigt; welche der beiden allein genügt hätte, ist nicht geprüft. `CmsTest` sichert die Zeilen ab.
**Folgen.** `RewriteBase /` setzt voraus, dass die Domain auf `public/` zeigt – das war schon Bedingung, eine Installation in einem Unterordner einer Domain ist nicht vorgesehen. Bestehende Installationen bekommen die Zeilen mit dem nächsten Archivupdate, weil der Updater die `.htaccess` des Releases übernimmt (nur eingeschaltete HTTPS-/HSTS-Zeilen werden erhalten). `Options -Indexes -MultiViews` verträgt IONOS: auf huefner-gartenbau.de wieder eingeschaltet, die Seite lädt weiter. Ausgeliefert mit 1.28.1.
## ADR 113 – Kein leerer 500er mehr, Update mit einem Klick nach reiseSoft (09.10.2026)
Anlass: Auf kantarci-reisen.de wurde die Datenbank per FTP ersetzt, ohne vorher den Code zu aktualisieren – danach antwortete jede Adresse mit einer leeren 500-Seite, und nirgends stand, warum. Umut: „Ich meine, dass es nicht Fehler 500 einfach so gibt.“ Und zum Updater: suchen, dann einspielen, dann migrieren soll *ein* Vorgang sein, den man beim Aufruf der Systemseite sieht – „mach es wie bei reiseSoft“.
**Notfallschicht.** `src/Ops/Notfall.php` hängt an nichts außer PHP und wird in `public/index.php` vor dem Bootstrap geladen. Sie fängt Ausnahmen, die bis zum Front-Controller durchschlagen, und schwere Fehler (Shutdown-Handler: Parse-, Compile-, Speicherfehler), schreibt eine Zeile mit Zeit, Adresse, Fehler und bis zu drei Ursachen nach `var/log/notfall.log` (ab 1 MB nach `.1` rotiert) und gibt eine schlichte Wartungsseite mit 503 und `Retry-After: 300` aus. Damit ist die Datei per FTP lesbar, auch wenn Symfony gar nicht startet – das ist auf geteiltem Hosting oft der einzige Zugang. 503 statt 500, damit Suchmaschinen „gleich wieder da“ verstehen. Einzelheiten stehen nur im Protokoll, nie auf der Seite.
**Fehler innerhalb des Frameworks.** `ErrorLogSubscriber` hört auf `kernel.exception` mit Priorität −64: nach der Sicherheitsschicht (sonst wird „Zugriff verweigert“ zur Wartungsseite statt zur Anmeldung – so schlug `CmsTest` an), vor der Fehlerseite des Frameworks (−128). Er übergeht HTTP-Fehler unter 500 sowie Anmelde-, Zugriffs- und Abmeldeausnahmen, protokolliert den Rest und setzt außerhalb des Debugmodus dieselbe Wartungsseite. Im Debugmodus bleibt die ausführliche Seite.
**Update mit einem Klick.** Vorbild ist die Systemseite von reiseSoft: beim Öffnen fragt die Seite selbst nach einer neuen Fassung (`GET /admin/system/update/pruefen`) und zeigt Name, Datum und Hinweise des Release-Eintrags. Ein Knopf, ein Bestätigungsdialog, dann läuft alles durch: `POST …/update/starten` sichert, lädt und prüft das Archiv, schaltet um und endet mit reinem PHP (ADR 112); die Seite ruft danach `POST …/update/abschliessen` mit dem Einmalschlüssel auf – eine neue Anfrage mit dem neuen Code – und der migriert, prüft und schaltet die Wartung aus. Währenddessen fragt sie alle 1,5 s `GET …/update/status` ab und zeigt fünf Schritte: Daten sichern, Fassung laden und prüfen, Umschalten, Datenbank anpassen, Funktion prüfen. Der Fortschritt steht in `var/update/status.json` (Phase, Meldung, von/auf, `bei` = Schritt eines Fehlers; nach 30 Minuten ohne Änderung gilt ein Lauf als abgebrochen).
**Automatisch zurück.** Die Prüfung ist eine Unteranfrage auf `/` mit dem neuen Code; erwartet wird ein Status unter 400, wenn eine Startseite veröffentlicht ist. Scheitern Migration oder Prüfung, kommt die alte Fassung zurück: Code aus `var/update/<lauf>/alt`, überschriebene Dateien in `public/` aus `alt-public` (neu hinzugekommene werden entfernt, Liste in `public.json`), Datenbank aus der Sicherung des Laufs (`Backup::restore`). Erst danach geht die Wartungsseite aus; der Status heißt `zurueckgerollt`, die Seite markiert den gescheiterten Schritt.
**Folgen.** Die Endpunkte verlangen die Administratorrolle, schreibende zusätzlich CSRF (Kopfzeile `X-CSRF-Token`) bzw. den Einmalschlüssel. Ohne JavaScript bleiben die bisherigen Formulare unter „Weitere Möglichkeiten“. Wie ADR 112 wirkt der neue Ablauf erst bei Updates, die *von* einer Fassung mit diesem Code ausgehen; die Notfallschicht wirkt sofort nach dem Einspielen. Geprüft Ende zu Ende mit einer Installation aus dem Releasearchiv: Update 1.27.1 → Testfassung in etwa 8 s mit einem Klick; eine absichtlich kaputte Testfassung wurde vollständig zurückgeschaltet (VERSION, Vorlagen, Datenbank, Wartung aus, Startseite 200).
## ADR 112 – Das Archivupdate läuft in einem Zug durch (09.10.2026)
Umut nach dem Update von kantarci-reisen.de auf 1.27.0: „Update durchgelaufen, direkt danach kam 500, dann bin ich zurück und hab migrieren können.“ Und: „Können wir den Updater nicht besser machen, dass das alles automatisch abläuft, einfach wie bei WordPress?“
**Befund.** Lokal mit dem Releasearchiv 1.26.0 und der Reisen-Datenbank vor der Migration nachgestellt: Alle Schritte bis „Dateien sind ersetzt“ laufen sauber, dann stirbt dieselbe Anfrage mit 500. Der PHP-Prozess hat den alten Code geladen, auf der Platte liegt der neue; was danach noch nachgeladen wird (Antwort-Listener, Sitzung, Vorlagen), passt nicht mehr zum Geladenen. ADR 071 hatte das gesehen und die Migrationen deshalb in eine zweite Anfrage gelegt – aber die erste Anfrage lief nach dem Tausch trotzdem durch das ganze Framework weiter.
**Entscheidung.** Die Anfrage, die tauscht, gibt danach nur noch eine schlichte Seite mit reinem PHP aus (`SystemController::sendAndStop`: Kopfzeilen, Text, `exit`) – kein Framework-Code mehr. Die Seite ruft per `<meta http-equiv="refresh">` sofort `GET /admin/system/abschliessen?token=…` auf. Das ist eine neue Anfrage mit dem neuen Code: Migrationen, Aufräumen, Wartungszustand aus, Weiterleitung zur Systemseite mit „Update auf x.y.z abgeschlossen“. Ein Klick, keine Zwischenseite zum Bedienen, kein 500er.
**Sicherheit.** Der Einmalschlüssel (32 Hexzeichen) steht in `var/update/offen.json` neben Fassung und Zeitpunkt; der Abschluss verlangt ihn und die Administratorrolle (`hash_equals`). Er ersetzt das CSRF-Token, das die Zwischenseite nicht mehr erzeugen kann. Neu laden findet nichts mehr offen und meldet „bereits abgeschlossen“. Der bisherige Knopf „Migrationen ausführen“ bleibt als Rückweg, falls die Weiterleitung unterdrückt wird; daneben steht ein Link.
**Folge.** Wirkt ab Updates, die von einer Fassung mit diesem Code ausgehen: von 1.27.0 auf 1.27.1 läuft noch der alte Weg (einmal 500, dann „Migrationen ausführen“), ab 1.27.1 der neue. Geprüft mit einer echten Installation aus dem Archiv, die auf ein lokal gebautes 1.27.1 aktualisiert (Downloader für die Probe auf lokale Dateien umgestellt): Zwischenseite mit 200, Abschluss 302, Fassung 1.27.1, Wartungszustand aus.
## ADR 111 – Mehrsprachigkeit als Übersetzungsebene, nicht als zweite Seite (Dokumentschema v27, 09.10.2026)
Umut: „Ich füge weitere Sprache hinzu und man wählt aus und muss dann zu jedem Feld die 2. Sprache hinzufügen. Ich möchte nicht für die 2. Sprache die komplette Seite neu bauen.“ Danach: „`/en/`-Präfix und übersetzte Slugs.“ Das Datenmodell hatte seit ADR 003 eine Spalte `locale` an jeder Seite – also das WPML-Prinzip „eine Sprache = eine eigene Seite“. Genau das wollte Umut nicht.
**Entscheidung.** Eine Seite wird einmal gebaut. Je weitere Sprache trägt ihr Dokument unter `translations.<sprache>` nur Texte, adressiert über die stabile Block-ID: `{slug?, title?, description?, seoTitle?, blocks: {<id>: {<eigenschaft>: text}}, source: {<id>.<eigenschaft>: fnv1a32}}`. Welche Eigenschaften übersetzbar sind, steht einmal in `Translations::FIELDS` und gespiegelt in `translate.js` (ein Test vergleicht beide): Texte von Überschrift, Text, Button, Bild (Alt, Unterschrift), Symbolkarte, Kennzahl, Zitat, Aufruf-Band, Kontakt (Anschrift), Akkordeon, Tab, Rechtszeile, Beitragsnavigation, Symbol, Fortschritt, Externer Inhalt. Aufbau, Gestaltung, Bilder und Links gibt es nur einmal.
**Warum im Dokument.** So gehören Übersetzungen zu jeder Fassung: Entwurf, Veröffentlichen, Wiederherstellen und Undo im Editor gelten für alle Sprachen zugleich, ohne zweiten Revisions- und Veröffentlichungsweg. Veraltete Schreibzugriffe erkennt dieselbe Versionsprüfung. Ein gelöschter Block nimmt beim Speichern seine Übersetzungen mit (`Translations::normalise`). Folge: Veröffentlichen gilt für alle Sprachen einer Seite gemeinsam; eine Sprache einzeln zurückhalten geht nur, indem man ihre Texte leert.
**Ausgeliefert wird eine Sprache durch Überlagerung.** `Translations::apply` legt die Texte auf eine Kopie des Dokuments und entfernt `translations`; danach laufen dieselbe Prüfung (`DocumentValidator`, auch je Sprache beim Speichern) und dieselben Vorlagen wie für die Hauptsprache. Kein Renderer muss Sprachen kennen, und eine Übersetzung kann keine Länge, keine Auszeichnungsregel und keine H1-Regel umgehen; formatierter Text geht durch denselben `RichText`-Filter. Ein leeres Feld zeigt den Text der Hauptsprache. Ein dekoratives Bild bekommt auch übersetzt keinen Alternativtext.
**Adressen.** `/en` ist die Startseite der Sprache, `/en/<pfad>` eine übersetzte Seite. Der Pfad besteht aus den übersetzten Adressen der Seite und ihrer Vorfahren; fehlt einem Vorfahren die Übersetzung, steht seine Adresse der Hauptsprache dort. Die Tabelle `page_translation (page_id, locale, slug, path)` (Migration `20261009000100`, neue Tabelle, kein Eingriff in `page`) spiegelt genau den veröffentlichten Stand: Zeilen entstehen und verschwinden beim Veröffentlichen, beim Verschieben einer Seite werden alle Pfade neu berechnet. Doppelte Adressen werden beim Speichern des Entwurfs abgelehnt; eine geänderte veröffentlichte Adresse bekommt eine 301-Weiterleitung (`en/alt` → `en/neu`). Eine Wurzelseite darf nicht wie eine aktive Sprache heißen, und eine Sprache lässt sich nicht einschalten, solange eine Seite ihre Adresse belegt.
**Links.** Auf einer übersetzten Seite zeigen Links auf Seiten, die es in dieser Sprache gibt, auf deren Übersetzung – Buttons, Menüs, Fließtext und Brotkrumen gleichermaßen. Umgesetzt als eine Ersetzung über die fertige Ausgabe (`PageController::localiseLinks`, nur `<a href="/…">`, nur Pfade aus der Zuordnung der Sprache). Links der Sprachwahl tragen `hreflang` und bleiben unberührt.
**SEO.** `<html lang>`, `og:locale` samt `og:locale:alternate`, Canonical auf die übersetzte Adresse, `hreflang` je veröffentlichter Sprache und `x-default` auf die Hauptsprache, die übersetzten Adressen in der Sitemap. Eine Seite ohne Übersetzung bekommt keine Alternativen. Ohne eigenen übersetzten Suchtitel nimmt die Seite den übersetzten Titel, nie den Suchtitel der Hauptsprache.
**Einsprachig bleibt alles wie vorher.** Sprachen stehen unter Einstellungen → Sprachen (`settings.languages`, nur Administratoren). Ohne weitere Sprache gibt es keinen Umschalter, keine Felder, keine `/en`-Route, kein `hreflang`. Wird eine Sprache abgewählt, verschwinden ihre Seiten von der Website; die Übersetzungen bleiben im Dokument.
**Editor.** Sprachumschalter DE | EN neben der Vorschaugröße. In einer weiteren Sprache zeigt die Seitenleiste die Liste aller Texte in Seitenreihenfolge, je mit Original, Feld und Zustand (fehlt / übersetzt / „Original geändert“, erkannt am FNV-1a-Vermerk), Filter, „Original übernehmen“ und „Übersetzung passt“. Dazu Adresse (mit Vorschlag aus dem übersetzten Titel) und Suchtitel. Die Vorschau zeigt die Seite in der Sprache, ein Klick springt zu den Texten des Blocks. Alles, was den Aufbau ändert (Werkzeugkasten, Struktur, Blockaktionen, Einfügepunkte, Ziehen, Spaltenbreiten, Doppelklick-Bearbeitung, Kontextmenü), ist in dieser Ansicht aus.
**Baustein „Sprachwahl“ (`language`, Schema v27).** Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist, als Kürzel oder Name; auf einer nur einsprachigen Seite nichts. Kürzel tragen den Sprachnamen für Vorlesesoftware, jeder Link `lang` und `hreflang`, die aktuelle Sprache `aria-current`.
**Stufe 2: Bereiche, Menüs, feste Texte (09.10.2026).** Kopf, Fuß und verknüpfte Sektionen tragen dieselbe Übersetzungsebene im Dokument und werden im selben Editor mit dem Umschalter übersetzt – ohne Adresse und Seitentitel. Da Bereiche keinen Entwurf haben, gilt eine gespeicherte Übersetzung sofort. Ausgeliefert werden sie wie Seiten: in einer weiteren Sprache überlagert, in der Hauptsprache ohne `translations`; ebenso in der Vorschau von Seite und Bereich. Menüeinträge haben keine stabile ID, deshalb trägt der Eintrag selbst seine Beschriftungen: `labels: {en: "Services"}` (je höchstens 80 Zeichen, bekannte Sprache, leer = Hauptsprache). Sie wandern so beim Umsortieren mit. Die Menüverwaltung zeigt je weitere Sprache ein Feld „Beschriftung Englisch“. Feste Texte der öffentlichen Vorlagen (Sprunglink, Menü-Knopf, Namen der Navigationen, Brotkrumen-Start, Rechtslinks, Einwilligung, Beitragslisten, „Nach oben“) gehen durch den Filter `kant_t` mit einem Wörterbuch in `LanguageExtension::UI` – Englisch vollständig, Französisch und Türkisch teilweise, sonst Englisch als Rückfall. Die Sprache der Anfrage steht im Anfrageattribut `kant_locale`.
**Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026).** Formulare tragen ihre Übersetzungen in den Einstellungen des Formulars (`settings.translations.<sprache>`: Erfolgsmeldung, Button, je Feld Beschriftung, Hilfetext, Einheit und Optionen als Zuordnung Original → Anzeige); gepflegt am Feld in der Formularverwaltung. Abgeschickt werden weiter die Optionen der Hauptsprache, damit Bedingungen, Prüfung, gespeicherte Einsendungen und die E-Mail an den Empfänger unverändert bleiben. Ein verstecktes Feld `language` lässt die Prüfung ihre Meldungen in der Sprache geben (`FormRepository::MESSAGES`) und führt nach dem Absenden auf die übersetzte Seite zurück; das Schrittskript bekommt seine Texte als `data-texts`. Beiträge sind Seiten und werden wie Seiten übersetzt; Listen, Nachbarn und Archive zeigen übersetzte Titel und Auszüge, nicht übersetzte Beiträge in der Hauptsprache. Archive antworten unter `/en/beitraege`, `/en/kategorie/…`, `/en/schlagwort/…` (Präfixe bleiben deutsch). Namen von Kategorien und Schlagwörtern sowie Überschrift und Text des Einwilligungshinweises stehen je Sprache in den Einstellungen (`termNames`, `consentTexts`, Einstellungen → Sprachen). Eine 404 unter `/en/…` antwortet auf Englisch: die eigene 404-Seite mit ihrer Übersetzung, die mitgelieferte über das Wörterbuch.
**Grenzen.** Feed, Einwilligungsseite `/einwilligung`, Datumsformat und die Präfixe der Archivadressen bleiben in der Hauptsprache. Eigenes HTML und Plugin-Bausteine bleiben unübersetzt. Bilder sind bewusst nicht je Sprache austauschbar (Umut, 09.10.2026). Die Kennzeichnung einzelner nicht übersetzter Texte mit `lang="de"` fehlt.
**Rückweg.** Ältere Kerne lehnen Dokumente mit Schema v27 ab (wie bei jeder Schemaerhöhung); Installationen zuerst aktualisieren. Die Migration lässt sich zurücknehmen (`down` löscht nur `page_translation`); die Übersetzungen bleiben dann im Dokument, sind aber nicht mehr erreichbar.
## ADR 110 – Menüeinträge als Button (07.10.2026)
Umut: „Buttons im Menü sind in KantSites aktuell nicht möglich … kannst du das im Menüeditor einbauen, dass man individuell Buttons nach dem gleichen Prinzip wie im Editor einbauen kann? Mit Icon, Farbe usw.“ Typisch ist „Angebot anfragen“ oder „Anrufen“ rechts im Kopf, auf dem Telefon griffbereit neben dem Menü-Knopf.
**Datenvertrag (keine Schemaänderung der Seiten).** Ein Menüeintrag trägt optional `button` = `{id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?}` im JSON-Baum des Menüs (ADR 012). Form, Darstellung und Größe stammen aus `ButtonDesign::OPTIONS`, Bewegung und „nur Symbol“ wie am Button-Baustein (ADR 109), das Symbol ist das vorhandene `icon` des Eintrags. Fehlende Werte heißen Website-Standard unter Design → Buttons. Farben sind `#hex` oder `@palette` (`StyleSchema::COLOR`). `mobile`: `menu` (im aufgeklappten Menü, volle Breite), `bar` (neben dem Menü-Knopf) oder `bar-icon` (dort nur als Symbol – verlangt ein Symbol). Ein Button hat keine Unterpunkte. `MenuButton::validate` lehnt alles andere ab, also auch CSS in Farbwerten.
**Darstellung.** `templates/menu-button.html.twig` erzeugt dieselben Klassen wie der Button-Baustein (`cms-button`, `b-desktop-*`, `icon-start`, `icon-motion-*`, `icon-only`), damit gelten `buttons.css`, Website-Standards, Hover und „Bewegung reduzieren“ unverändert. Die Menüregeln für Links (Polster, Unterstreichung, Großschrift im aufgeklappten Kopf, `margin-top` von `.button`) gelten für Menü-Buttons nicht.
**Eigene Farben ohne Inline-Stil.** Die CSP erlaubt keine `style`-Attribute. Jeder Button bekommt deshalb eine feste Kennung (`m` + 10 Hexzeichen, bleibt beim Speichern erhalten), und `MenuRepository::buttonCss` schreibt `.menu-button-<id>{--kb-fill…}` samt der abgeleiteten Werte der Darstellung in das Seiten-Stylesheet – auf der Website, in der Editor- und in der Bereichsvorschau.
**Telefon.** `bar`/`bar-icon` werden im Menübaustein ein zweites Mal als `ul.menu-actions` vor dem Menü-Knopf ausgegeben; sichtbar nur, solange das Menü zusammengeklappt ist (Tablet bzw. Telefon je nach Einstellung), im aufgeklappten Menü ist das Original dann ausgeblendet (`menu-keep-visible`) – nichts steht doppelt. Neben dem Knopf sind Buttons kompakter und brechen nicht um; Leiste und Knopf bleiben in einer Zeile. Erster Versuch nur mit `bar`: Ein zweizeiliger Text-Button schob den Menü-Knopf auf dem 390-px-Telefon in eine neue Zeile – daher die Variante nur mit Symbol.
**Menüverwaltung.** Je Eintrag „Als Button darstellen“; erst dann erscheinen Form, Darstellung, Größe, fünf Farbfelder mit Palette (wie im Editor), Bewegung, „Nur das Symbol zeigen“ und die Telefon-Anzeige, dazu eine Vorschau normal und beim Überfahren. Das Symbol wird wie im Editor im Fenster gewählt (ADR 109). Ohne JavaScript bleiben alle Felder normale Formularfelder.
**Geprüft:** PHPUnit vollständig, darunter `testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger`, neun Ablehnungsfälle und `testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId`. `npm test`. `npm run test:menu-buttons` im Browser auf isolierter Pilotkopie: Verwaltung mit Fenster, Palette, Vorschau und axe; Speichern/Neuladen; Website bei 1440 und 390 px (Symbol und Menü-Knopf auf einer Linie, aufgeklappt nicht doppelt, zweiter Button im aufgeklappten Menü in voller Breite mit Rahmen, axe, kein waagerechtes Scrollen). Screenshots unter `reports/menu-buttons/`.
**Bekannt:** Ist die Spalte mit dem Menü schmal (Pilot: zwei gleich breite Spalten), bricht ein Button wie jeder Menüeintrag in die nächste Zeile; abhelfen über die Spaltenbreite oder Größe „Klein“. Menüs mit Buttons brauchen einen Kern ab 1.26.0; ältere Kerne lehnen den unbekannten Schlüssel ab.
## ADR 109 – Symbole durchdacht: 98 im Satz, Auswahl im Fenster, Gestaltung unter Design (07.10.2026)
Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal […] Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“ Zu Recht: 33 Symbole waren zu wenig, die Kachelwand drückte das Panel nach unten, und Farbe oder Größe des Symbols gingen nur über eigenes CSS.
**Symbolsatz.** 98 Symbole in sieben Kategorien (Pfeile, Kontakt & Ort, Handel & Geld, Dateien & Medien, Personen & Sicherheit, Haus & Handwerk, Allgemein). Neu sind unter anderem Warenkorb, Euro, Lieferung (LKW), Paket, Karte, Navigation, Hochladen, Drucken, Teilen, Kamera, Schloss, Auszeichnung, Werkzeug, Hammer, Malerrolle, Baum, Blatt, Sonne, Wasser, Strom, Solar, Auto und Warnung – also auch das LKW-Symbol, das bei Hüfner fehlte. Weiter keine Markenlogos. Eine Quelle: `public/assets/icons.js`. `npm run build:icons` schreibt daraus `icon.html.twig` und `DocumentValidator::ICONS`, `tests/icons.test.mjs` prüft die Gleichheit.
**Auswahl im Fenster.** Im Panel steht nur das gewählte Symbol mit Vorschau, Namen und „Ändern …“. Das öffnet ein modales Fenster (`<dialog>`): Kategorien als Reiter links, Symbole mit Namen als Kacheln, Öffnen bei der Kategorie des gewählten Symbols mit Fokus darauf, Pfeiltasten in Reitern und Raster, Escape oder Klick auf den Rand schließt ohne Änderung, der Fokus kehrt zurück. Beim Button gibt es dort „Kein Symbol“. Symbol und Symbolkarte nutzen dasselbe Fenster.
**Gestaltung unter Design (keine Schemaänderung).** Neue Gruppe „Symbol“ im Style-Schema mit dem Ziel `icon`: Symbolfarbe, Fläche hinter dem Symbol (beide auch beim Überfahren), Symbolgröße, Innenabstand und Ecken der Fläche, Drehung (auch beim Überfahren), dazu Abstand zum Text am Button selbst. Damit gelten Gerätewerte, Vererbung, Hover, „Gestaltung kopieren“ und die Farbpalette wie überall. Der Compiler schreibt `#id .button-icon{…}` und `#id:hover .button-icon,#id:focus-visible .button-icon{…}`. Größe und Abstand laufen über `--icon-size` und `--icon-gap`, die `site.css` mit 1,15 em und 0,65 em als Vorgabe liest – ohne Angabe sieht alles aus wie vorher. Die Gruppe erscheint nur bei Buttons mit Symbol und ist dort aufgeklappt; ein Knopf im Inhalt führt hin.
**Verhalten im Inhalt (Schema v26).** `iconMotion`: `none` (ruhig), `slide` (rückt beim Überfahren in Leserichtung) oder `reveal` (erscheint erst beim Überfahren). `iconOnly`: ein Button nur aus Symbol; die Beschriftung steht dann als `.button-label` ohne Breite im Link – sie bleibt der zugängliche Name und hält die Zeilenhöhe, sodass der Knopf quadratisch und genauso hoch ist wie Text-Buttons daneben (zuerst mit `aspect-ratio` versucht: das zog die Höhe auf die Breite herunter). `iconOnly` ohne Symbol lehnt der Validator ab, und die Wahl „Kein Symbol“ nimmt es im Editor mit weg. Bewegungen folgen „Bewegung reduzieren“.
**Geprüft:** PHPUnit vollständig (616), darunter `testAButtonIconMovesOnHoverOrStandsAlone`, `testButtonIconStylesTargetTheIconPerDeviceAndOnHover`, `testAButtonWithoutAnIconCannotBeIconOnly` und neue Ablehnungsfälle. `npm test` (38). `npm run test:button-icons` im Browser: Panel ohne Kachelwand, Fenster mit sieben Kategorien und axe ohne Befund, Escape, Pfeiltasten, Stellung, Bewegung mit Rückgängig, nur Symbol, Design-Gruppe mit allen Werten und wirksamer Größe, Speichern als v26 und Neuladen, gemessenes Aussehen (Flächenfarbe, Rundung, Drehung beim Überfahren, quadratischer Nur-Symbol-Button). `test:buttons` und `test:browser` (Hüfner, Entwicklungsmodus) grün, HTML 148 KB unter dem Budget.
## ADR 108 – Das Symbol am Button ist wählbar, ausgewählt wird mit Kacheln (07.10.2026)
Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – und dann: „Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche.“ Bis hierher hängte `block.html.twig` jedem Button fest `arrow-up-right` an, auch dem Textlink. Ändern ging nur über eigenes CSS (Hüfner blendet den Pfeil so aus, EB Solar drehte ihn).
**Datenvertrag (Schema v25).** Zwei neue, optionale Eigenschaften am Button: `icon` – ein Name aus `DocumentValidator::ICONS` oder `none` – und `iconPosition` – `start` oder `end`. Fehlt `icon`, bleibt es beim schrägen Pfeil (`BUTTON_DEFAULT_ICON`); fehlt `iconPosition`, steht das Symbol hinter dem Text. Ein Dokument ohne die beiden Felder sieht also genau aus wie vorher und braucht keinen Migrationsweg. Beide Felder verlangen v25, damit ein älterer Kern ein Dokument mit Symbolwahl ablehnt, statt es still mit Pfeil zu zeigen. Der Editor hebt das Dokument bei der ersten Wahl an, und `repairSchema` tut es beim Laden.
**Kompatibilität mit eigenem CSS.** Die Hülle des Symbols heißt jetzt `button-icon`. Der Standardpfeil trägt zusätzlich weiter `button-arrow`, damit Regeln wie `& .button-arrow{display:none}` auf bestehenden Websites wirken wie bisher. Wer dort ein anderes Symbol wählt, bekommt es sichtbar – die alte Regel trifft es nicht. Die Farb- und Größenregeln aus ADR 107 gelten jetzt für `button-icon`.
**Symbolsatz.** Dreizehn alltägliche Symbole kommen dazu: Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person. Sie stehen allen Bausteinen mit Symbol offen (Symbol, Symbolkarte, Button), nicht nur dem Button; das erweitert eine bestehende Liste und braucht wie ADR 107 kein neues Schema. Keine Markenzeichen (etwa WhatsApp): deren Logos haben eigene Nutzungsregeln.
**Auswahl mit Kacheln.** `public/assets/icons.js` hält Pfade, deutsche Namen und die Gruppen (Pfeile, Kontakt, Allgemein; bei Symbol und Symbolkarte zusätzlich „Weitere“) und zeichnet die Auswahl: jedes Symbol ein Knopf mit seiner Zeichnung und `aria-pressed`, „Kein Symbol“ vorn, Pfeiltasten wandern, Tab verlässt die Gruppe. Die bisherige Auswahlliste mit englischen Schlüsseln bei Symbol und Symbolkarte ist durch dieselbe Kachelauswahl ersetzt. Die Stellung ist ein Umschalter aus zwei Knöpfen und erscheint nur, wenn ein Symbol gewählt ist. Die Pfade stehen damit zweimal (Twig für die Website, JS für die Vorschau); `tests/icons.test.mjs` hält beide und die Liste im Validator gleich.
**Geprüft:** `CmsTest::testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone` (Standardpfeil mit `button-arrow`, Telefon vorn, kein Symbol), `testAButtonRejectsUnknownIconsAndOlderSchemas`, `tests/icons.test.mjs`, `repairSchema` hebt auf v25, `npm run test:button-icons` im Browser (Kachel, Stellung, Tastatur, Rückgängig, kein Symbol, Speichern als v25 und Neuladen).
## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
**`tel:` und `mailto:` als Linkziel von Bausteinen.** Menüeinträge (`MenuRepository::url`), Links im formatierten Text (`RichText::safeHref`) und Plugin-Felder kannten beide längst. Nur die Linkziele der Bausteine – Button, Symbolkarte, Handlungsaufruf, Symbol, verlinktes Bild – liefen über `DocumentValidator::url()` und nahmen bloß Pfad, Sprungmarke und HTTPS. Ein Knopf „Jetzt anrufen“ ging deshalb nur als Textlink im Knopf-Aussehen. Jetzt prüft `link()` diese fünf Stellen: zuerst wie bisher, dazu `tel:` mit Ziffern, `+`, Klammern und Bindestrich (keine Leerzeichen, wie im Menü) und `mailto:` mit einer gültigen Adresse und höchstens einer prozentkodierten Abfrage (`?subject=…`). Bildquellen und eingebettete Inhalte bleiben bei `url()`: `tel:` ist dort kein sinnvolles Ziel.
Das Dokumentschema bleibt bei v24. Die Änderung erlaubt nur zusätzliche Werte in einem bestehenden Feld und braucht keinen Migrationsweg. Ein Dokument mit `tel:` an einem Knopf lässt sich aber auf einem Kern vor dieser Fassung nicht speichern. Eine Installation, die solche Dokumente bekommt, braucht also zuerst das Update.
**Der Pfeil im Knopf folgt der Schriftfarbe.** Das Pfeilsymbol trägt die Klasse `block-icon`, und die färbt jedes Symbol in der Akzentfarbe. Auf einem roten Knopf mit weißer Schrift stand so ein grüner Pfeil, und auch die Farbe beim Überfahren erreichte ihn nicht. `.button-arrow .block-icon{color:inherit}` in `site.css` setzt das richtig. Wo eine Website ihren Pfeil absichtlich anders färbt, tut sie das weiter über das eigene CSS des Knopfs; das geht vor.
**Geprüft:** `CmsTest::testButtonsAndLinkedBlocksMayCallOrWrite` (speichern, ausgeben, Bildquelle mit `tel:` abgewiesen), neue Ungültig-Fälle in `invalidDocuments` (Buchstaben oder Leerzeichen in der Nummer, `mailto:` ohne Adresse oder mit Anführungszeichen bzw. `<`), `testTheButtonArrowTakesTheColourOfTheButtonText`.
## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
**Den Rahmen gab es schon.** Der Baustein `embed` („Externer Inhalt“, Schema v14) bettet eine fremde Seite ein: Adresse, Titel, Anbieter, Höhe – und davor die Einwilligungsfrage. Vor der Einwilligung wird nichts geladen, kein Bild und keine Anfrage; erst danach kommt der Host in `frame-src` der Sicherheitsrichtlinie. Ein `<iframe>`, das jemand als rohes Markup einfügt, hätte beides nicht: keine Frage, und der Browser lädt es gar nicht erst, weil `frame-src` den Host nicht kennt. Deshalb ist `<iframe>` im HTML-Baustein eine Fehlermeldung mit dem Weg zum richtigen Baustein – nicht etwas, das man einfügen kann und das dann schweigend nichts tut. Dasselbe gilt für `<script>`: das Feld „Eigenes Skript“ unter „Erweitert“ liefert es als eigene Datei aus, inline würde die Richtlinie es ohnehin blockieren.
**Der Baustein (Schema v24).** `html` mit einer einzigen Eigenschaft: das Markup, bis 20.000 Zeichen, unverändert ausgegeben. Es gibt keine Filterliste – wer ihn setzen darf, ist die eigentliche Antwort, und die ist dieselbe wie beim eigenen Skript: **nur ein Administrator**. Geprüft wird wie dort gegen den gespeicherten Stand (`CustomCode::assertHtmlUnchanged()`), in beiden Controllern und auch beim Zurückholen einer alten Fassung – ein Redakteur kann eine Seite mit HTML also weiter bearbeiten, ohne das HTML anfassen zu können. Im Editor ist das Feld für ihn sichtbar und schreibgeschützt, wie das Skriptfeld.
**Warum keine Filterung wie bei Rich Text.** Genau die Fälle, für die man den Baustein nimmt – ein fremdes Widget, eine eigene Tabelle, eine Auszeichnung, die es als Baustein nicht gibt – fallen durch jede Whitelist. Ein gefilterter HTML-Baustein hätte den Wunsch nicht erfüllt, sondern nur verschoben.
**Folgen.** `README.md` nennt den Baustein und seine Rolle. Nicht gebaut: eine Vorschau, die fremde Skripte ausführt (die Editorvorschau führt keine aus, das bleibt so), und eine Ausnahme von der Administratorregel für einzelne Seiten.
## ADR 105 – Designpakete in Plugins zurückgenommen: das Design gehört der Website (16.09.2026)
Umut hatte auf der DEEG-Installation „Nachtblau“ eingeschaltet und wieder ausgeschaltet – und die Farben blieben. Die Ursache war nicht der Schalter (`activate()`/`deactivate()` fassen das Design nie an), sondern der zweite Knopf daneben: „Designpaket übernehmen“ schrieb die Palette des Plugins über `Theme::save()` endgültig in die Website. Technisch war das gewollt (ADR 103: ein Import, damit sich das Ergebnis unter Design weiter anpassen lässt und beim Ausschalten nichts verlorengeht) – in der Bedienung war es eine Falle: eine Aktion, die die gesamte Palette ersetzt, ohne Rückfrage und ohne Weg zurück, auf einem Bildschirm, wo daneben ein Schalter sitzt, den man gefahrlos umlegen kann.
**Entscheidung: `theme` fällt aus dem Manifest.** Kein `themePackage()`, kein `applyTheme()`, keine Route `admin_plugin_theme`, kein Knopf auf dem Bildschirm „Plugins“, kein `plugins/nachtblau`. Ein Manifest braucht jetzt mindestens einen Baustein, eine Adresse oder eine Vorlage. Der Alternativweg – Sicherung der alten Palette plus „Design zurücksetzen“ – wäre eine Spalte, eine Migration und ein zweiter Zustand gewesen, um eine Funktion zu retten, die niemand vermisst: Farben, Schriften und Buttons ändert man unter **Design**, dort mit Vorschau und in einem Schritt rückgängig.
**Folgen.** Ein Plugin kann das Aussehen der Website nur noch über das CSS seiner eigenen Bausteine beeinflussen – nie über die Palette. Der Test dazu heißt jetzt umgekehrt: Ein- und Ausschalten lässt Design **und** Inhalte unangetastet (`PluginTest::testSwitchingAPluginOnAndOffLeavesTheDesignAlone`). Bestehende Installationen behalten die Palette, die sie haben; wer ein übernommenes Paket loswerden will, setzt die Farben unter Design. Auf der DEEG-Installation ist die eigene Palette (Logo-Blau `#0081c7`, Anthrazit, Arial, eckige Buttons) aus dem Aufbauskript wieder eingespielt. Die Abnahme „Templatewechsel erhält die Inhalte“ aus dem Ausbauplan gilt damit als nicht gebaut; ein Templatewechsel gehört, wenn er kommt, in den Designbereich und nicht in ein Plugin.
## 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 (Designpakete zurückgenommen, ADR 105) – 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.** *(Zurückgenommen in ADR 105 – es gibt sie nicht mehr.)* `"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` war das Designpaket und ist mit ADR 105 entfallen.
**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. Seit 1.21.8 lässt sie sich dort auch ändern, wie die „WordPress-Adresse“: Das Feld schreibt über `Environment::write` in `.env`, aber erst nachdem `OriginCheck` die Installation unter der neuen Adresse erreicht hat (`GET /.well-known/kantsites-origin` liefert einen HMAC aus `APP_SECRET`). Eine Umstellung auf www ohne DNS oder Zertifikat würde sonst jede Seite samt Verwaltung ins Leere leiten. „Trotzdem übernehmen“ erscheint erst nach einer Ablehnung. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld.
**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 +799,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 +847,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 +885,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.
@@ -348,6 +952,8 @@ Umut wollte an jedem Element eigenes CSS **und** eigenes Skript setzen können.
Zwei echte Löcher kamen beim Testen heraus. `& .block-text` wurde zu `#id.block-text`, weil das führende Leerzeichen wegtrimmt wurde – aus dem Nachfahren wurde derselbe Knoten, die Regel traf das falsche Element. Und `url(//fremd.example/x.png)` kam durch, weil die Prüfung jedes `/` erlaubte: eine protokollrelative Adresse hätte über das Schema der Seite von Dritten geladen. Beides ist behoben und durch Tests abgedeckt.
**Nachtrag 23.09.2026: `content` mit Text.** Werte durften bisher keine Anführungszeichen enthalten, damit gab es kein `::before` oder `::after` – beim Nachbau von Kantarci Markt fehlten dadurch der Verlauf über einem Foto, ein Goldstrich und ein Buchstabe als Wasserzeichen. Jetzt nimmt `content` einen Text in einfachen oder doppelten Anführungszeichen an, höchstens 40 Zeichen aus Buchstaben, Ziffern, Leerzeichen und `.,:!?·•–-`. Kein `<`, kein Backslash, kein zweites Anführungszeichen: Der Wert kann weder den String noch das `<style>` verlassen. Jede andere Eigenschaft bleibt ohne Anführungszeichen (also auch kein `font-family:'…'`). Folge: Dekoration, die Vorlesesoftware nicht vorlesen soll, gehört in ein Pseudo-Element statt in einen Textbaustein.
## ADR 029 – Verwaltung: ein Rahmen statt sieben Kopfzeilen (12.09.2026)
Umuts Auftrag war „backend komplett neugestalten, einfacher schöner Bedienbarkeit komplett einfach“, mit ausdrücklicher Gestaltungsfreiheit. Das Problem war weniger die Optik als die Orientierung: jede Verwaltungsseite trug ihre eigene Kopfzeile und ihre eigene, unvollständige Liste von Links auf die anderen Seiten – die Seitenübersicht verlinkte sieben Bereiche, das Menü fünf, die Fassungen drei. Wo man war, stand nirgends.
@@ -438,7 +1044,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)
@@ -660,7 +1297,7 @@ Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon is
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein quadratischer Ausschnitt in 32 px (Reiter), 180 px (Startbildschirm) sowie 48 und 192 px (Google nimmt nur Vielfache von 48), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png`, `/favicon-192.png`, `/favicon.ico` (48 px) und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt. Auch `/favicon.svg` beantwortet die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen, mit eigenem ein 302 auf das 192er – als Datei in `public/` hätte der Webserver immer das mitgelieferte Zeichen ausgeliefert, und Suchmaschinen hätten es behalten.
Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler.
@@ -856,7 +1493,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; Referenzplugin „Bildvergleich“. Designpakete in Plugins am selben Tag wieder entfernt – das Design gehört der Website, ADR 105. 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
+4 -4
View File
@@ -20,17 +20,17 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
## Nicht geprüft – muss vor dem Umschalten passieren
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.
2. **HTTPS.** Zertifikat einrichten, dann unter System → „HTTPS“ auf „HTTPS erzwingen“ (prüft vorher und nachher, ADR 120; von Hand: die drei Zeilen in `public/.htaccess` einschalten). **Erst wenn das eine Weile läuft**, „HSTS einschalten“ – HSTS lässt sich in einem Browser ein Jahr lang nicht zurücknehmen.
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 und Adressen in den Editor und auf die
Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört der Website (ADR 105). 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
```
Das mitgelieferte Plugin ist die Vorlage zum Abschreiben: `plugins/vergleich` (Bausteine,
Container, Einstellungen, Adresse, Seitenvorlage).
## 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" }
}
}
```
- `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 oder eine Vorlage. 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`.
### Was ein Plugin nicht kann: das Design
Es gibt kein `theme` im Manifest. Ein Plugin gestaltet seine eigenen Bausteine über deren
Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der Website ändert
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
## 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.
+1480 -2
View File
File diff suppressed because it is too large. Load diff
+17 -1
View File
@@ -97,6 +97,10 @@ Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nich
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
## Der vierte Weg: aus der Zentrale
Ab 1.36.0 meldet sich jede Installation bei `sites.kant-it.de` und holt dort angestoßene Updates ab. Es läuft derselbe Archivweg wie über den Knopf: Sicherung, Archiv mit Prüfsumme, Dateitausch, Migrationen mit dem neuen Code, Prüfung der Startseite, sonst Zurückschalten. Den zweiten Schritt erledigt die nächste Anfrage an die Website; die Aufgabe ruft dafür die eigene Startseite einmal auf. Git-Arbeitskopien lehnen Aufträge ab. Einzelheiten: ADR 123, `zentrale/README.md`.
## Ein Release veröffentlichen
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
@@ -124,4 +128,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“.
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+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');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260917000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Dokumente: PDFs in der Mediathek, zum Verlinken und fuer den Betrachter.';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Eine eigene Tabelle statt einer Art-Spalte in media: jede Stelle, die ein Bild sucht,
// erwartet Varianten mit Breite und Hoehe. Ein PDF dort waere ein Bild ohne Bild - an
// einem Dutzend Stellen. Getrennt kann keine Bildauswahl je ein Dokument finden.
$document = $schema->createTable('document');
$document->addColumn('id', 'string', ['length' => 32]);
$document->addColumn('title', 'string', ['length' => 160]);
$document->addColumn('bytes', 'integer');
$document->addColumn('archived', 'boolean', ['default' => false]);
$document->addColumn('version', 'integer', ['default' => 1]);
$document->addColumn('created_at', 'datetime_immutable');
$document->setPrimaryKey(['id']);
}
public function down(Schema $schema): void
{
$schema->dropTable('document');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261009000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Mehrsprachigkeit: die öffentlichen Adressen der übersetzten Seiten (ADR 111).';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Die Texte einer Übersetzung stehen im Dokument der Seite und laufen so mit Entwurf,
// Veröffentlichung und Fassungen mit. Hier steht nur, was die Website zum Auffinden
// braucht: unter welcher Adresse eine Seite in welcher Sprache veröffentlicht ist. Die
// Zeilen entstehen beim Veröffentlichen und spiegeln genau den öffentlichen Stand.
// Eine neue Tabelle, keine Änderung an `page`: auf SQLite baut jede Indexänderung an einer
// bestehenden Tabelle sie neu auf (siehe ADR 010).
$table = $schema->createTable('page_translation');
$table->addColumn('page_id', 'string', ['length' => 32]);
$table->addColumn('website_id', 'string', ['length' => 32]);
$table->addColumn('locale', 'string', ['length' => 12]);
$table->addColumn('slug', 'string', ['length' => 80]);
$table->addColumn('path', 'string', ['length' => 400]);
$table->setPrimaryKey(['page_id', 'locale']);
$table->addUniqueIndex(['website_id', 'locale', 'path'], 'uniq_page_translation_path');
}
public function down(Schema $schema): void
{
// Die Übersetzungen selbst bleiben in den Dokumenten; ohne diese Tabelle sind sie nur
// nicht mehr öffentlich erreichbar.
$schema->dropTable('page_translation');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261012000100 extends AbstractMigration
{
public function getDescription(): string
{
return 'Geplante Veröffentlichung: welche Fassung zum Termin online geht (ADR 116).';
}
public function isTransactional(): bool
{
return false;
}
public function up(Schema $schema): void
{
// Bisher ging zum Termin der jeweils neueste Entwurf online - auch einer, den nach dem
// Planen jemand ohne Recht zum Veröffentlichen gespeichert hatte. Jetzt merkt sich die
// Planung die Fassung, die beim Planen vorlag. Eine Spalte, kein Tabellenneubau.
$schema->getTable('page')->addColumn('publish_revision_id', 'string', ['length' => 32, 'notnull' => false, 'default' => null]);
}
public function down(Schema $schema): void
{
$schema->getTable('page')->dropColumn('publish_revision_id');
}
}
+18 -1
View File
@@ -3,12 +3,29 @@
"private": true,
"type": "module",
"scripts": {
"build:buttons": "node scripts/build-buttons.mjs",
"test:buttons": "node scripts/button-check.mjs",
"test:button-icons": "node scripts/button-icon-check.mjs",
"test:menu-buttons": "node scripts/menu-button-check.mjs",
"build:icons": "node scripts/build-icons.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",
+9
View File
@@ -0,0 +1,9 @@
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
@@ -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 }}
+28 -14
View File
@@ -2,25 +2,35 @@ DirectoryIndex index.php
Options -Indexes -MultiViews
# Alles, was keine Datei ist, beantwortet PHP.
#
# Die Zeile fuer index.php selbst ist kein Zierrat: Bei IONOS erkennt !-f die bereits
# umgeschriebene index.php nicht als Datei, die Regel greift wieder und wieder, und Apache bricht
# nach zehn Runden mit einem weissen 500er ab (huefner-gartenbau.de, 12.10.2026). Dieselbe
# Absicherung steht in der .htaccess von WordPress.
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
RewriteBase /
# HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Vor dem Livegang mit
# Zertifikat: die drei Zeilen einschalten - und erst danach die HSTS-Zeile weiter unten,
# denn HSTS laesst sich in einem Browser ein halbes Jahr lang nicht zuruecknehmen.
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Einschalten am besten in
# der Verwaltung unter System -> "HTTPS erzwingen": dort wird vorher und nachher geprueft und
# bei einer Schleife zurueckgenommen (ADR 120). Von Hand: die drei Zeilen einschalten - und erst
# danach die HSTS-Zeile weiter unten, denn HSTS laesst sich in einem Browser ein Jahr lang nicht
# zuruecknehmen.
#
# Die Zeilen stehen vor den Regeln fuer index.php, nicht dahinter: Nach dem Umschreiben auf
# index.php beginnt die Runde neu, und "^index\.php$ - [L]" beendet sie, bevor eine spaetere
# Regel an die Reihe kommt. Weiter unten griff die Umleitung nur fuer echte Dateien.
#RewriteCond %{HTTPS} !=on
#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]
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [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.
@@ -62,13 +72,17 @@ RewriteRule ^ index.php [L]
# ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
Header always unset X-Powered-By
Header always unset X-AspNet-Version
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben).
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben; System -> HSTS).
#Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
</IfModule>
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
<FilesMatch "^\.|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
# .well-known ausgenommen: Zertifikatsanbieter (acme-challenge) brauchen den Ordner, und Apache
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel. Die Probe der Verwaltung
# (Adresse speichern, HTTPS erzwingen) liegt nicht mehr dort, sondern unter /kantsites-origin:
# IONOS beantwortet /.well-known/ selbst mit 403 (ADR 122).
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
Require all denied
</FilesMatch>
# Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON.
+541 -71
View File
@@ -1,3 +1,5 @@
/* Der Strich aller Symbole aus icon.html.twig, wie auf der Website (site.css). */
svg.block-icon{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
/* Administration: shared foundation and the management screens.
The editor has its own stylesheet (editor.css) that only the editor and the media page load. */
@@ -70,8 +72,13 @@ textarea { resize: vertical; min-height: 110px; line-height: 1.6 }
label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.field-help { font-size: 11px; line-height: 1.55; color: var(--muted); margin: 7px 0 0 }
.field-help code, .card code { background: #eef2ea; padding: 1px 5px; border-radius: 4px; font-size: .95em }
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500 }
/* Nur Häkchen und Text sind klickbar: über die volle Breite schaltete ein Klick in die leere Mitte
der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% }
.check-label input { width: 16px; min-height: 16px }
/* Eine Gruppe von Häkchen nebeneinander, die auf schmalen Bildschirmen umbricht (Sprachen). */
.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0 }
.check-grid legend { font-weight: 600; margin-bottom: 8px; padding: 0 }
/* A grid of fields that becomes one column when there is no room. */
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px }
.fields > div > label:first-child, .field > label:first-child { margin-top: 14px }
@@ -147,31 +154,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 +248,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 +293,29 @@ 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; font-size: 13px; line-height: 1.6 }
/* Fließtext in Karten ist 13 px wie Hinweise und Fakten. Felder bleiben bei 16 px - darunter
vergrößert iOS beim Antippen die ganze Seite. :where() hält die Regel schwächer als jede Klasse. */
:where(.card-body) :where(input:not([type=checkbox], [type=radio]), select, textarea) { font-size: 16px }
.card-body h3 { font-size: 14px; margin: 26px 0 4px }
.card-body h3:first-child { margin-top: 14px }
/* Abschnitte einer Karte: Trennlinie, Überschrift, gleicher Abstand - als fieldset oder form. */
.card-section { border: 0; border-top: 1px solid var(--line); margin: 22px 0 0; padding: 18px 0 0; min-width: 0 }
.card-section > legend { float: left; width: 100%; font-weight: 650; font-size: 14px; margin: 0 0 12px; padding: 0 }
.card-section > legend + * { clear: both }
.card-section > h3, .card-body .card-section > h3:first-child { font-size: 14px; font-weight: 650; margin: 0 0 12px }
.card-section .check-label { margin: 14px 0 0 }
.card-section > h3 + .check-label, .card-section > h3 + input + .check-label { margin-top: 0 }
.card-section label.field-first { margin-top: 0 }
/* Dateiauswahl im Stil der übrigen Knöpfe statt des grauen Browserknopfs. */
input[type=file] { padding: 6px; cursor: pointer; max-width: 720px }
input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 6px; background: #f3f5f1; color: var(--ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer }
input[type=file]:hover::file-selector-button { background: #e8ede6 }
.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 }
@@ -240,6 +323,22 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.card-body > p:first-child { margin-top: 14px }
.card-body .primary, .card-body .secondary { margin-top: 22px }
.card-foot { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafbf8; border-radius: 0 0 12px 12px }
/* Der Fuss eines Formulars in einer Karte: Hauptknopf links, Erklärung daneben. */
.form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; font-size: 12px }
.form-foot .primary { margin: 0 }
/* Knöpfe nebeneinander, gleicher Abstand, ohne den Abstand nach oben der Einzelknöpfe. */
.button-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px }
.card-body .button-row .primary, .card-body .button-row .secondary { margin: 0 }
/* Auswahl als Karten: ganze Fläche klickbar, Titel und eine Zeile Erklärung. */
.option-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; max-width: 720px }
.option { display: flex; align-items: flex-start; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); font-weight: 400; cursor: pointer }
.option:hover { border-color: #a9b8af }
.option:has(input:checked) { border-color: var(--accent); background: #f4f8f1; box-shadow: inset 0 0 0 1px var(--accent) }
.option input { width: 16px; min-height: 16px; margin: 2px 0 0; flex: none; accent-color: var(--accent) }
.option > span { display: grid; gap: 2px; min-width: 0 }
.option strong { font-size: 13px; font-weight: 600 }
.option > span > span { font-size: 12px; color: var(--muted); line-height: 1.45 }
.check-label input, .option input { accent-color: var(--accent) }
/* A card that opens: used for everything secondary, so a screen starts calm. */
details.card > summary {
@@ -310,6 +409,7 @@ details.card > summary:hover { background: #fafbf8 }
.pill-live { background: #e7f0d6; color: #2c5340 }
.pill-draft { background: #fff2d9; color: #6f4e18 }
.pill-off { background: #f1f2f0; color: #5a635e }
.pill-error { background: #fff0eb; color: var(--danger) }
/* The per-row settings. One disclosure that opens in place, instead of six competing popovers
that clip each other at the edge of the card. */
@@ -428,7 +528,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 +536,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 +570,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 +605,37 @@ 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 }
/* In der Leiste stehen Unterpunkte direkt neben ihrem Hauptpunkt, nicht in einer eigenen Zeile. */
.nav-list > li:has(.nav-sub) { display: flex; gap: 4px }
.nav-sub { display: flex; margin: 0 }
.nav-sub .nav-item { padding: 8px 12px }
.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 +649,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 +668,80 @@ 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 }
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
.origin-form { margin-top: 4px; max-width: 560px }
.origin-form label:first-of-type { margin-top: 10px }
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
.card-body .origin-row .secondary { margin: 0 }
.https-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px }
.card-body .https-actions .secondary { margin: 0 }
.origin-form .check-label { margin-top: 10px }
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
.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 +756,39 @@ 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) }
.mail-result { margin-top: 16px }
.mail-result p { margin: 0 0 6px }
.mail-result p:last-child { margin-bottom: 0 }
.mail-result pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin: 6px 0 0 }
.mail-result-head { margin: 0 0 10px }
/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0; display: grid; gap: 2px }
.nav-sub .nav-item { padding: 7px 10px 7px 39px; font-size: 12.5px; color: var(--shell-muted) }
.nav-sub .nav-item:hover { color: var(--shell-ink) }
.nav-sub .nav-item[aria-current=page] { color: #fff; background: #21362d }
/* E-Mail-Versand: der eingestellte Weg als Zeile, darunter Abschnitte mit Trennlinie. */
.mail-current { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #f6f8f4 }
.mail-current-source { flex-basis: 100%; font-size: 12px }
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 14px; max-width: 720px }
.mail-grid label { margin: 0 0 7px }
.mail-grid > div { margin-bottom: 4px }
/* Protokoll: Zustand links, daneben was, wann und - bei Fehlern - warum. */
.mail-log { list-style: none; margin: 14px 0 0; padding: 0 }
.mail-entry { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 4px 14px; align-items: start; padding: 12px 0; border-top: 1px solid var(--line) }
.mail-entry:first-child { border-top: 0; padding-top: 4px }
.mail-entry .pill { justify-self: start; margin-top: 1px }
.mail-entry-text p { margin: 0 }
.mail-entry-line { overflow-wrap: anywhere }
.mail-entry-time { font-size: 12px; color: var(--muted) }
.mail-entry-hint { margin-top: 6px !important; color: var(--ink) }
.mail-entry-detail { margin-top: 6px }
.mail-entry-detail summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; width: fit-content }
.mail-entry-detail pre { margin: 6px 0 0; padding: 10px 12px; border-radius: 8px; background: #f6f8f4; font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere }
@media (max-width: 560px) {
.mail-entry { grid-template-columns: minmax(0, 1fr) }
}
/* Ohne SMTP brauchen die Felder niemand; wo :has() fehlt, bleiben sie einfach sichtbar. */
.mail-form:has(input[name=weg]:checked:not([value=smtp])) .smtp-fields { display: none }
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
@@ -651,9 +839,25 @@ details.card > summary:hover { background: #fafbf8 }
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
.favicon-field{display:flex;align-items:center;gap:12px}
.favicon-field select{flex:1}
.favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
.favicon-editor[hidden]{display:none}
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
.favicon-stage img{display:block;max-width:100%;max-height:360px;width:auto;height:auto}
.favicon-frame{position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid rgba(0,0,0,.6);box-shadow:0 0 0 9999px rgba(20,30,25,.55);cursor:move}
.favicon-stage{overflow:hidden}
.favicon-frame:focus-visible{outline:3px solid var(--accent);outline-offset:0}
.favicon-handle{position:absolute;right:-7px;bottom:-7px;width:14px;height:14px;background:#fff;border:1px solid rgba(0,0,0,.6);border-radius:3px;cursor:nwse-resize}
.favicon-side input[type=range]{width:100%}
.favicon-samples{display:flex;align-items:flex-end;gap:16px;margin:12px 0}
.favicon-samples figure{margin:0;text-align:center;font-size:11px;color:var(--muted)}
.favicon-samples canvas{display:block;margin:0 auto 6px;border-radius:8px;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/12px 12px;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.favicon-sample-large{width:90px;height:90px}
.favicon-sample-small{width:32px;height:32px;border-radius:4px!important}
@media(max-width:700px){.favicon-editor{grid-template-columns:1fr}}
.color-field { display: flex; gap: 8px; align-items: center }
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
@@ -665,26 +869,292 @@ 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 }
.nav-list > li:has(.nav-sub) { display: block }
.nav-sub { display: grid; margin: 2px 0 4px }
.nav-sub .nav-item { padding: 10px 14px 10px 44px }
.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: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8 }
.field-card.is-open { padding-bottom: 14px; background: #fff; box-shadow: 0 1px 6px #203b3414 }
.field-card-new { border-style: dashed; background: var(--paper) }
.field-card-head { display: grid; grid-template-columns: 24px minmax(0, 1fr) 170px auto auto 34px; gap: 0 10px; align-items: center }
.field-card-head input, .field-card-head select { min-height: 36px }
.field-card-index { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
.field-card label { margin-top: 12px }
.field-card-head .field-card-required { margin: 0; font-size: 13px; white-space: nowrap }
.field-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 0 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--accent); font-size: 12px; font-weight: 650; white-space: nowrap }
.field-toggle:hover { background: #eef3ec }
.field-toggle .form-chevron { transition: transform .15s }
.field-card.is-open .field-toggle .form-chevron { transform: rotate(180deg) }
.field-card-meta { margin: 4px 0 0 34px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 34px }
.field-card-body[hidden] { display: none }
.field-translation { grid-column: 1 / -1; margin-top: 12px; padding-top: 8px; border-top: 1px dashed var(--line) }
.field-translation summary { cursor: pointer; font-size: 13px; font-weight: 650 }
/* Ein Formular je zuklappbarer Karte, Abschnitte darin ebenfalls zuklappbar. */
.form-card > .form-card-summary { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; list-style: none }
details.form-card > summary.form-card-summary::before, details.form-card[open] > summary.form-card-summary::before { content: none }
.form-card > .form-card-summary::-webkit-details-marker, .form-section > summary::-webkit-details-marker { display: none }
.form-card-name { font-size: 16px; font-weight: 700 }
.form-card > .form-card-summary { cursor: default; border-bottom: 1px solid var(--line) }
.form-list-name { margin: 0; font-size: 15px; font-weight: 650 }
.form-list-name a { text-decoration: none }
.form-list-name a:hover { text-decoration: underline }
.form-list .row { align-items: center }
.back-to-list { display: inline-block; margin-top: 4px }
.form-card-facts { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.form-chevron { flex: none; color: #52665a; transition: transform .15s }
.form-card[open] > .form-card-summary { border-bottom: 1px solid var(--line) }
.form-card[open] > .form-card-summary > .form-chevron, .form-section[open] > summary > .form-chevron { transform: rotate(180deg) }
.form-section { border-bottom: 1px solid var(--line); padding-bottom: 12px }
.form-section > summary { display: flex; align-items: center; gap: 10px; padding: 14px 0 2px; cursor: pointer; list-style: none }
.form-section > summary .form-part { margin: 0 }
.form-section-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
.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) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px }
.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb }
.field-image-picker-thumb[hidden] { display: block; visibility: hidden }
.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } }
.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 }
.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px }
.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 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-head { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px }
.field-card-title { grid-column: 2 / 4; padding-right: 36px }
.field-card-type { grid-column: 2 }
.field-card-head .field-card-required { grid-column: 3 }
.field-toggle { grid-column: 2 }
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
.field-card-meta { margin-left: 0 }
.field-card-body { padding-left: 0 }
.form-card-facts { white-space: normal }
.form-card > .form-card-summary { flex-wrap: wrap; padding: 14px 16px }
}
/* ---- 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 } }
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */
.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px }
.media-field { min-width: 0 }
.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff }
.media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb }
.media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
.media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px }
/* Ein Linkfeld: die Adresse zum Tippen, darunter Seite oder PDF zum Wählen. */
.link-field-actions { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px }
.link-field-actions .text-button { padding: 4px 0; font-size: 13px }
/* Vorhandene Kategorien und Schlagwörter zum Anklicken. */
.term-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px }
.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer }
.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 }
.term-chip[aria-pressed="true"]::before { content: '✓ '; }
/* Der Ziehknopf eines Menüeintrags liegt über der linken Ecke der Zusammenfassung, gehört aber
nicht zu ihr - sonst stünde ein Knopf in einem Knopf. */
.menu-entry { position: relative }
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
.menu-entry summary { padding-left: 58px; min-height: 60px }
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
/* Texte der Website in weiteren Sprachen (Einstellungen → Sprachen, ADR 111). */
.language-texts { margin-top: 24px; padding-top: 16px; border-top: 1px solid #dfe5e1 }
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
.language-text-group legend { font-weight: 600; padding: 0 4px }
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
@keyframes update-dreh { to { transform: rotate(360deg) } }
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
.update-neu h3 { margin: 0; font-size: 17px }
.update-neu .muted { margin: 0 }
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
.update-schritt.is-offen { color: #8f9e96 }
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
.update-meldung.is-ok { color: #9fdcb5 }
.update-meldung.is-fehler { color: #ffb4a6 }
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
.update-letztes { margin: 0 0 12px }
.update-manual { margin-top: 6px }
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
.update-dialog::backdrop { background: #1f2d2766 }
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
+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}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pLine truncated
+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;
}
+91
View File
@@ -0,0 +1,91 @@
// Einwilligung ohne Neuladen (ADR 115).
//
// Ohne dieses Skript schicken der Hinweis und der Knopf am eingebetteten Inhalt ein Formular ab,
// der Server setzt das Cookie und leitet auf dieselbe Seite zurück - die Seite lädt neu. Mit dem
// Skript geht dasselbe Formular im Hintergrund an denselben Endpunkt; die Kopfzeile
// X-Kant-Consent sagt dem Server, dass keine Weiterleitung nötig ist. Danach holt das Skript die
// Seite noch einmal und übernimmt daraus nur, was sich durch die Entscheidung ändert: die
// eingebetteten Inhalte und die Zeile „Rechtliches“. Die Adresse eines Inhalts steht so weiterhin
// erst nach der Einwilligung im Dokument.
//
// Geht dabei etwas schief, schickt das Skript das Formular ganz normal ab: lieber einmal neu
// laden als eine Entscheidung, die nicht ankommt.
(() => {
const forms = document.querySelectorAll('.consent-bar form, .embed-blocked form');
if (forms.length === 0) {
return;
}
const replaceFrom = async () => {
const response = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
if (!response.ok) {
throw new Error('Seite nicht geladen');
}
const fresh = new DOMParser().parseFromString(await response.text(), 'text/html');
for (const blocked of document.querySelectorAll('.embed-blocked[id]')) {
const next = fresh.getElementById(blocked.id);
if (next && !next.classList.contains('embed-blocked')) {
blocked.replaceWith(document.importNode(next, true));
}
}
for (const legal of document.querySelectorAll('.block-legal[id]')) {
const next = fresh.getElementById(legal.id);
if (next) {
legal.replaceWith(document.importNode(next, true));
}
}
};
for (const form of forms) {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const button = event.submitter;
const body = new FormData(form, button);
const placeholder = form.closest('.embed-blocked');
for (const control of form.querySelectorAll('button')) {
control.disabled = true;
}
try {
const saved = await fetch(form.action, { method: 'POST', body, credentials: 'same-origin', headers: { 'X-Kant-Consent': '1' } });
if (!saved.ok) {
throw new Error('Entscheidung nicht gespeichert');
}
// Wer etwas erlaubt hat, bekommt die Inhalte jetzt; wer abgelehnt hat, braucht keinen
// zweiten Abruf - es ändert sich nichts außer dem Hinweis.
if (body.get('choice') !== 'none' && document.querySelector('.embed-blocked')) {
await replaceFrom();
}
document.querySelector('.consent-bar')?.remove();
if (placeholder) {
// Der Knopf ist mit dem Platzhalter verschwunden; der Fokus geht an den Inhalt, der an
// seiner Stelle steht.
const frame = document.getElementById(placeholder.id)?.querySelector('iframe');
frame?.focus();
} else {
// Entschieden im Hinweis: der Fokus beginnt wieder am Seitenanfang, wie nach dem
// Neuladen - der nächste Tab führt zu „Zum Inhalt springen“.
document.getElementById('seitenanfang')?.focus({ preventScroll: true });
}
} catch {
// Rückfall: das Formular normal absenden, samt gedrücktem Knopf.
for (const control of form.querySelectorAll('button')) {
control.disabled = false;
}
withChoice(form, button).submit();
}
});
}
// form.submit() schickt den gedrückten Knopf nicht mit; sein Wert wandert als verstecktes Feld
// ins Formular, damit der Rückfall dieselbe Entscheidung sendet.
function withChoice(form, button) {
if (button?.name) {
const field = document.createElement('input');
field.type = 'hidden';
field.name = button.name;
field.value = button.value;
form.append(field);
}
return form;
}
})();
+105 -43
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,9 @@ export const GROUPS = {
image: 'Bild',
layout: 'Layout',
effect: 'Wirkung',
menu: 'Menü-Knopf',
slider: 'Slider-Steuerung',
icon: 'Symbol',
};
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
@@ -103,7 +107,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 +126,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 +152,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 +208,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 +235,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 +448,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 +467,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,12 +513,17 @@ 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;
details.append(element('summary', null, groupLabel));
const groupKey = `${block.type}:${group}`;
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing' || group === 'icon');
renderedGroups.set(groupKey, details);
details.append(element('summary', null, groupLabel)); details.dataset.group = group;
const body = element('div', 'design-group-body');
for (const [key, property] of properties) {
@@ -509,12 +543,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 +557,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 +575,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 +652,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}
+188 -24
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', html: 'Eigenes HTML', language: 'Sprachwahl' };
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: '⚭', html: '</>', language: '文' };
// 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,9 +10,9 @@ 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'] },
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
];
/** How many columns a row can be added with. */
@@ -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.
@@ -92,6 +128,10 @@ export function createBlock(type) {
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
if (type === 'legal') block.props = { label: 'Rechtliches' };
// Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt.
if (type === 'language') block.props = { display: 'code' };
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
if (type === 'html') block.props = { html: '' };
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
@@ -107,24 +147,67 @@ 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;
}
/**
* Hebt ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen.
*
* Normalerweise tut das der Editor in dem Moment, in dem jemand etwas einstellt. Ein Dokument,
* das ein Skript geschrieben hat, kann aber eine Eigenschaft tragen, die seine Fassung noch nicht
* kennt - der Kopfbereich von kant-it.de hatte die Hamburger-Umschaltung bei Fassung 19. Die
* Website zeigt so etwas, der Server lehnt Vorschau und Speichern aber ab, und der Bereich war im
* Editor nicht mehr zu bearbeiten. Nur Fassungen, die etwas hinzufügen, werden hier gehoben; die
* Umstellung auf formatierten Text (12, 17) ändert die Bedeutung und bleibt eine bewusste Wahl.
*/
export function repairSchema(document) {
const walk = blocks => {
for (const block of blocks || []) {
upgradeForType(document, block.type);
const props = block.props || {};
if ('caption' in props) upgradeDocument(document, 3);
if ('click' in props || 'lightbox' in props) upgradeDocument(document, 19);
if ('newTab' in props) upgradeDocument(document, 20);
if (Object.values(block.style || {}).some(values => values?.backgroundImage === 'none')) upgradeDocument(document, 20);
if (block.anchor) upgradeDocument(document, 21);
if ('collapse' in props) upgradeDocument(document, 22);
if ('faq' in props) upgradeDocument(document, 23);
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
walk(block.children);
}
};
walk(document.blocks);
if (document.translations) upgradeDocument(document, 27);
return document;
}
/** 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 (type === 'html') upgradeDocument(document, 24);
if (type === 'language') upgradeDocument(document, 27);
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 +224,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 +254,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 +269,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 +302,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 +348,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 = 27;
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 +419,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 +492,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 +553,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 +628,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 +683,17 @@ 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 === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
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;
}
+226 -189
View File
@@ -1,149 +1,179 @@
/* 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-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}
.media-heading .eyebrow{margin-bottom:8px}
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.media-tools label{margin-top:10px}
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
.media-card strong{font-size:12px;overflow-wrap:anywhere}
.media-card span{font-size:11px;color:var(--muted)}
.media-details{padding-left:22px;border-left:1px solid var(--line)}
.media-details button,.media-details a{margin-top:12px;display:block}
.media-details input{font-size:13px}
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
.media-footer label{margin:0;font-size:12px}
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
#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}
/* ---- 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 +190,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 +227,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 +238,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 +254,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 +265,55 @@ 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))}
}
/* Übersetzen (ADR 111). Der Umschalter sitzt neben der Vorschaugröße; im Übersetzungsmodus
verschwindet alles, was den Aufbau ändert - Reiter, Blockaktionen, Werkzeuge der Vorschau. */
.language-switch{display:flex;gap:2px;padding:3px;background:#ffffff14;border-radius:9px}
.language-switch[hidden]{display:none}
.language-switch button{border:0;background:transparent;color:#c9d4cd;padding:0 10px;min-height:30px;min-width:36px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.04em}
.language-switch button:hover:not(:disabled){color:#fff}
.editor-header .language-switch button[aria-pressed=true]{background:#fff;color:var(--ink)}
#editor[data-language] .language-switch button[aria-pressed=true]{background:#f5c451;color:#1d1d1b}
#editor[data-language] .sidebar-nav,#editor[data-language] .sidebar-foot,#editor[data-language] .ov-toolbar,#editor[data-language] .ov-insert,#editor[data-language] .ov-resize,#editor[data-language] .ov-drop{display:none!important}
.translate-head{display:grid;gap:8px;margin-bottom:16px}
.translate-head h2{margin:0;font-size:16px}
.translate-head h2:focus{outline:none}
.translate-head progress{width:100%;height:8px;accent-color:var(--ink)}
.translate-summary{margin:0;font-size:13px;font-weight:600}
.translate-group{border-top:1px solid var(--line,#dfe5e1);padding:12px 0 4px;scroll-margin-top:12px}
.translate-group.is-current{box-shadow:inset 3px 0 0 #f5c451;padding-left:10px}
.translate-group h3{margin:0 0 8px;font-size:13px}
.translate-jump{border:0;background:none;padding:0;font:inherit;font-weight:700;color:inherit;text-align:left;cursor:pointer}
.translate-jump:hover{text-decoration:underline}
.translate-field{display:grid;gap:5px;margin-bottom:14px}
.translate-field-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.translate-field-head label{margin:0}
.translate-state{font-size:11px;font-weight:700;padding:2px 7px;border-radius:999px;background:#e8ece9;color:#384640;white-space:nowrap}
.translate-field[data-state=missing] .translate-state{background:#fde9c8;color:#6b3f00}
.translate-field[data-state=stale] .translate-state{background:#fbd5d0;color:#7a1d12}
.translate-field[data-state=done] .translate-state{background:#d7eedf;color:#1c5233}
.translate-original{margin:0;padding:6px 8px;border-radius:6px;background:#f2f4f3;color:#46514c;font-size:12.5px;line-height:1.45;white-space:pre-line;max-height:7.5em;overflow:auto}
.translate-actions{display:flex;gap:12px;flex-wrap:wrap}
.translate-slug{display:flex;align-items:center;gap:4px}
.translate-slug input{flex:1;min-width:0}
.translate-prefix{font-size:12px;color:#5b6862;white-space:nowrap}
.translate-filter{margin-top:4px}
+1002 -99
View File
File diff suppressed because it is too large. Load diff
+18 -70
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)}
.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:var(--k-image-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 }
}
+137
View File
@@ -0,0 +1,137 @@
import { openMedia } from './media.js';
/*
* The site icon in the settings: an image from the media library (uploads happen right there in
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
* left and top of the width and height, the edge of the shorter side - so it holds for every
* variant of the image, and the server cuts the PNG files from it when the form is saved.
*/
const field = document.querySelector('[data-favicon-field]');
if (field) setup(field);
function setup(field) {
const idInput = field.querySelector('[name="faviconMediaId"]');
const cropInput = field.querySelector('[name="faviconCrop"]');
const choose = field.querySelector('[data-favicon-choose]');
const remove = field.querySelector('[data-favicon-remove]');
const current = field.querySelector('.favicon-current');
const base = document.querySelector('meta[name="admin-base"]').content;
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
<div class="favicon-side">
<label for="favicon-size">Größe des Ausschnitts</label>
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
<div class="favicon-samples" aria-label="Vorschau">
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
</div>
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
</div>`;
field.after(editor);
const stage = editor.querySelector('.favicon-stage');
const image = stage.querySelector('img');
const frame = stage.querySelector('.favicon-frame');
const size = editor.querySelector('#favicon-size');
const large = editor.querySelector('.favicon-sample-large');
const small = editor.querySelector('.favicon-sample-small');
let crop = { x: 0, y: 0, s: 1 };
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
/** Keep the square inside the picture, then show it and write it into the form. */
const apply = () => {
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
crop.s = clamp(crop.s, 0.02, 1);
const e = edge();
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
size.value = Math.round(crop.s * 100);
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
for (const canvas of [large, small]) {
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingQuality = 'high';
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
}
};
const center = () => {
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
};
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
const resize = s => {
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
crop.s = clamp(s, 0.02, 1); const after = edge();
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
};
const load = (src, initial) => new Promise((resolve, reject) => {
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
image.src = src;
});
const largest = item => item.variants.at(-1).src;
const showCurrent = label => {
current.replaceChildren(); current.append(document.createTextNode(label));
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
remove.hidden = !idInput.value;
};
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
if (!items.length) return;
idInput.value = items[0].id; cropInput.value = '';
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
catch (error) { showCurrent(error.message); }
} }));
remove.addEventListener('click', () => {
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
});
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
size.addEventListener('input', () => resize(size.value / 100));
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
let drag = null;
frame.addEventListener('pointerdown', event => {
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
});
frame.addEventListener('pointermove', event => {
if (!drag) return;
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
else {
// The corner follows the larger of both movements, measured in image pixels.
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
crop.x = drag.crop.x; crop.y = drag.crop.y;
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
}
apply();
});
const stop = () => { drag = null; };
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
frame.addEventListener('keydown', event => {
const step = event.shiftKey ? 0.1 : 0.01;
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
});
// An icon that is already set opens with its picture and its cut-out.
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
.then(({ item }) => {
const parts = cropInput.value.split(',').map(Number);
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
})
.catch(() => {});
}
}
File renamed without changes.
+18
View File
@@ -19,6 +19,24 @@ 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".
**DM Sans** (Fließtext der Seite „Kantarci Markt") steht unter der SIL Open Font License 1.1, Text
in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/googlefonts/dm-fonts).
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
**Barlow** und **Barlow Condensed** (Fließtext und Überschriften der Seite „Hüfner Gartenbau“)
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
**Lora** und **Manrope** (Überschriften und Fließtext des Entwurfs „EBI Immobilien“) stehen unter
der SIL Open Font License 1.1, Text in `lora-OFL.txt` und `manrope-OFL.txt`. Copyright 2011 The
Lora Project Authors (github.com/cyrealtype/Lora-Cyrillic), Reserved Font Name „Lora“; Copyright
2018 The Manrope Project Authors (github.com/googlefonts/manrope).
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
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:
http://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.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
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.
+36
View File
@@ -10,3 +10,39 @@
@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}
/* latin-ext */
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-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:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-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:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-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}
/* latin-ext */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-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}
/* latin-ext */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-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:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-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:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-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:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-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-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-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}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
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:
http://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.
Loaded 100 of 383 files, more files were not shown because too many files have changed in this diff. Show more