Kant CMS: Grundgerüst, visueller Editor, Mediathek

Symfony 7.4 / Twig / Doctrine DBAL auf herkömmlichem PHP-Webhosting.
Versionierte Blockdokumente (v1-v3) mit unveränderlichen Revisionen,
getrennten Entwürfen/Veröffentlichungen und optimistischer
Konflikterkennung. Visueller Editor mit Strukturbaum, Live-Vorschau,
responsiven Gerätewerten, Abschnittsvorlagen, Bildmediathek,
Galerien und Effekten. Öffentliche Ausgabe ohne Editor-JavaScript.

Geprüft: 41 PHP-Tests / 155 Assertions, 10 JavaScript-Modelltests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 10:19:05 +02:00
commit 82b07820bc
87 files changed
+14463

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
:root{font-family:Arial,Helvetica,sans-serif;color:#203b34;background:#f6f7f4;--ink:#203b34;--muted:#5a6861;--line:#dce2dc;--paper:#fff;--mint:#e5f0b6;--accent:#315b49;font-synthesis:none}
.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;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);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}}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit}button,a,input,textarea,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.48}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,iframe:focus-visible{outline:3px solid #b55b12;outline-offset:3px}input,textarea,select{width:100%;border:1px solid #bcc9c0;border-radius:7px;background:#fff;color:var(--ink);padding:11px 12px;min-height:42px}textarea{resize:vertical;min-height:110px;line-height:1.6}label{display:block;margin:21px 0 8px;font-size:13px;font-weight:600}h1,h2,h3,p{margin-top:0}h1{font-size:38px;font-weight:500;letter-spacing:-1.5px;line-height:1.15}h2{font-size:18px;letter-spacing:-.4px}h3{font-size:16px}.muted{color:var(--muted);line-height:1.6}.eyebrow{font-size:10px;font-weight:700;letter-spacing:1.7px;color:var(--accent);margin-bottom:16px}.brand{display:inline-flex;align-items:center;font-size:25px;font-weight:700;text-decoration:none;letter-spacing:-1.3px;gap:3px}.brand>span:last-child{font-weight:400}.brand-icon{display:grid;place-items:center;width:33px;height:33px;background:var(--ink);color:var(--mint);border-radius:9px;margin-right:7px}.primary,.secondary,.subtle,.icon-button,.text-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}.primary{background:var(--ink);color:white;border-color:var(--ink)}.primary:hover:not(:disabled){background:#37604b}.secondary:hover:not(:disabled){background:#f0f4ed}.subtle,.text-button{border-color:transparent;background:transparent}.text-button{padding:4px 8px;min-height:28px}.full-width{width:100%}.skip-link{position:fixed;top:-100px;left:16px;z-index:100;background:var(--ink);color:white;padding:15px}.skip-link:focus{top:10px}.notice,.editor-message{padding:14px 18px;border-radius:7px;line-height:1.6;font-size:13px}.error{background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7}.login-body{min-height:100vh;display:grid;place-items:center;background:radial-gradient(ellipse at top left,#e5f0b6,transparent 65%),#f6f7f4;padding:30px}.login-card{width:100%;max-width:450px;padding:44px;background:white;border:1px solid var(--line);border-radius:18px;box-shadow:0 20px 60px #203b3408}.login-card .brand{margin-bottom:44px}.login-card h1{font-size:31px}.login-card .primary{margin-top:26px}.quiet-link{display:block;font-size:12px;text-align:center;margin-top:25px}.admin-header{padding:24px 42px;background:white;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:20px}.header-actions{display:flex;align-items:center;gap:24px;font-size:12px}.user-name{padding-left:24px;border-left:1px solid var(--line)}.pages-main{max-width:1120px;margin:74px auto;padding:0 30px}.section-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:50px;margin-bottom:20px}.section-heading h2{margin:0}.section-heading>span{font-size:12px}.count{font-size:12px;background:#e5ebdf;padding:4px 8px;border-radius:6px;margin-left:8px}.page-list{display:grid;gap:13px}.page-card{display:flex;gap:20px;align-items:center;background:white;padding:24px;border:1px solid var(--line);border-radius:10px}.page-icon{font-size:25px;width:44px;height:50px;background:#f0f4e9;border-radius:8px;display:grid;place-items:center}.page-info{flex:1;min-width:0}.page-info h3{margin:0 0 9px}.page-info h3 a{text-decoration:none}.page-info p{font-size:12px;color:var(--muted);margin:0}.badge{font-size:10px;letter-spacing:.2px;background:#fff0d5;color:#78551d;padding:7px 10px;border-radius:20px;white-space:nowrap}.published{background:#edf3dc;color:#315b49}.footnote{font-size:11px;color:var(--muted);margin-top:27px}.empty-state{padding:35px;border:1px dashed var(--line);border-radius:10px}.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}Line truncated
@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}.admin-header{padding:20px}.header-actions{gap:12px}.header-actions .user-name{display:none}.page-card{flex-wrap:wrap;padding:18px;gap:14px}.page-info{min-width:180px}.pages-main{margin:40px auto;padding:0 20px}.section-heading{align-items:flex-start;flex-direction:column}.login-card{padding:30px}.login-body{padding:20px}.editor-header .back-link{padding-right:12px}.editor-message{font-size:11px}.editor-layout .panel-hint{font-size:10px}}
@media(prefers-reduced-motion:no-preference){button,a{transition:background-color .12s,border-color .12s}}
.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}.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}}
+295
View File
@@ -0,0 +1,295 @@
{
"align": {
"label": "Ausrichtung",
"types": [
"section",
"row",
"column",
"heading",
"text"
],
"values": {
"left": [
"Links",
"text-align:left;margin-inline:0"
],
"center": [
"Mittig",
"text-align:center;margin-inline:auto"
],
"right": [
"Rechts",
"text-align:right;margin-inline:auto 0"
]
}
},
"fontSize": {
"label": "Schriftgröße",
"types": [
"heading",
"text",
"button"
],
"values": {
"small": [
"Klein · 14 px",
"font-size:14px"
],
"body": [
"Fließtext · 18 px",
"font-size:18px"
],
"large": [
"Groß · 24 px",
"font-size:24px"
],
"title": [
"Titel · 36 px",
"font-size:36px"
],
"display": [
"Display · 52 px",
"font-size:52px"
],
"hero": [
"Hero · 72 px",
"font-size:72px"
]
}
},
"fontWeight": {
"label": "Schriftstärke",
"types": [
"heading",
"text",
"button"
],
"values": {
"regular": [
"Normal",
"font-weight:400"
],
"medium": [
"Mittel",
"font-weight:500"
],
"bold": [
"Fett",
"font-weight:700"
]
}
},
"lineHeight": {
"label": "Zeilenhöhe",
"types": [
"heading",
"text",
"button"
],
"values": {
"tight": [
"Kompakt · 1,15",
"line-height:1.15"
],
"normal": [
"Normal · 1,5",
"line-height:1.5"
],
"relaxed": [
"Luftig · 1,8",
"line-height:1.8"
]
}
},
"paddingY": {
"label": "Innenabstand oben / unten",
"types": [
"section",
"column"
],
"values": {
"none": [
"Keiner",
"padding-block:0"
],
"small": [
"Klein · 16 px",
"padding-block:16px"
],
"medium": [
"Mittel · 32 px",
"padding-block:32px"
],
"large": [
"Groß · 64 px",
"padding-block:64px"
],
"huge": [
"Extra groß · 96 px",
"padding-block:96px"
]
}
},
"paddingX": {
"label": "Innenabstand links / rechts",
"types": [
"column"
],
"values": {
"none": [
"Keiner",
"padding-inline:0"
],
"small": [
"Klein · 16 px",
"padding-inline:16px"
],
"medium": [
"Mittel · 32 px",
"padding-inline:32px"
],
"large": [
"Groß · 64 px",
"padding-inline:64px"
]
}
},
"marginTop": {
"label": "Außenabstand oben",
"types": [
"section",
"row",
"column",
"heading",
"text",
"image",
"button",
"gallery"
],
"values": {
"none": [
"Keiner",
"margin-top:0"
],
"small": [
"Klein · 8 px",
"margin-top:8px"
],
"medium": [
"Mittel · 24 px",
"margin-top:24px"
],
"large": [
"Groß · 48 px",
"margin-top:48px"
]
}
},
"marginBottom": {
"label": "Außenabstand unten",
"types": [
"section",
"row",
"column",
"heading",
"text",
"image",
"button",
"gallery"
],
"values": {
"none": [
"Keiner",
"margin-bottom:0"
],
"small": [
"Klein · 8 px",
"margin-bottom:8px"
],
"medium": [
"Mittel · 24 px",
"margin-bottom:24px"
],
"large": [
"Groß · 48 px",
"margin-bottom:48px"
]
}
},
"gap": {
"label": "Spaltenabstand",
"types": [
"row",
"gallery"
],
"values": {
"none": [
"Keiner",
"gap:0"
],
"small": [
"Klein · 12 px",
"gap:12px"
],
"medium": [
"Mittel · 24 px",
"gap:24px"
],
"large": [
"Groß · 48 px",
"gap:48px"
]
}
},
"columns": {
"label": "Spaltenaufteilung",
"types": [
"row",
"gallery"
],
"values": {
"auto": [
"Automatisch umbrechen",
"grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))"
],
"one": [
"1 Spalte",
"grid-template-columns:minmax(0,1fr)"
],
"two": [
"2 gleiche Spalten",
"grid-template-columns:repeat(2,minmax(0,1fr))"
],
"three": [
"3 gleiche Spalten",
"grid-template-columns:repeat(3,minmax(0,1fr))"
],
"four": [
"4 gleiche Spalten",
"grid-template-columns:repeat(4,minmax(0,1fr))"
]
}
},
"radius": {
"label": "Ecken",
"types": [
"column",
"image",
"button"
],
"values": {
"none": [
"Gerade",
"border-radius:0"
],
"small": [
"Leicht · 8 px",
"border-radius:8px"
],
"large": [
"Rund · 24 px",
"border-radius:24px"
]
}
}
}
+137
View File
@@ -0,0 +1,137 @@
/* Generated from design-tokens.json by npm run build:design. */
#main .d-desktop-align-left{text-align:left;margin-inline:0}
#main .d-desktop-align-center{text-align:center;margin-inline:auto}
#main .d-desktop-align-right{text-align:right;margin-inline:auto 0}
#main .d-desktop-fontSize-small{font-size:14px}
#main .d-desktop-fontSize-body{font-size:18px}
#main .d-desktop-fontSize-large{font-size:24px}
#main .d-desktop-fontSize-title{font-size:36px}
#main .d-desktop-fontSize-display{font-size:52px}
#main .d-desktop-fontSize-hero{font-size:72px}
#main .d-desktop-fontWeight-regular{font-weight:400}
#main .d-desktop-fontWeight-medium{font-weight:500}
#main .d-desktop-fontWeight-bold{font-weight:700}
#main .d-desktop-lineHeight-tight{line-height:1.15}
#main .d-desktop-lineHeight-normal{line-height:1.5}
#main .d-desktop-lineHeight-relaxed{line-height:1.8}
#main .d-desktop-paddingY-none{padding-block:0}
#main .d-desktop-paddingY-small{padding-block:16px}
#main .d-desktop-paddingY-medium{padding-block:32px}
#main .d-desktop-paddingY-large{padding-block:64px}
#main .d-desktop-paddingY-huge{padding-block:96px}
#main .d-desktop-paddingX-none{padding-inline:0}
#main .d-desktop-paddingX-small{padding-inline:16px}
#main .d-desktop-paddingX-medium{padding-inline:32px}
#main .d-desktop-paddingX-large{padding-inline:64px}
#main .d-desktop-marginTop-none{margin-top:0}
#main .d-desktop-marginTop-small{margin-top:8px}
#main .d-desktop-marginTop-medium{margin-top:24px}
#main .d-desktop-marginTop-large{margin-top:48px}
#main .d-desktop-marginBottom-none{margin-bottom:0}
#main .d-desktop-marginBottom-small{margin-bottom:8px}
#main .d-desktop-marginBottom-medium{margin-bottom:24px}
#main .d-desktop-marginBottom-large{margin-bottom:48px}
#main .d-desktop-gap-none{gap:0}
#main .d-desktop-gap-small{gap:12px}
#main .d-desktop-gap-medium{gap:24px}
#main .d-desktop-gap-large{gap:48px}
#main .d-desktop-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
#main .d-desktop-columns-one{grid-template-columns:minmax(0,1fr)}
#main .d-desktop-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
#main .d-desktop-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
#main .d-desktop-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
#main .d-desktop-radius-none{border-radius:0}
#main .d-desktop-radius-small{border-radius:8px}
#main .d-desktop-radius-large{border-radius:24px}
@media(max-width:1024px){
#main .d-tablet-align-left{text-align:left;margin-inline:0}
#main .d-tablet-align-center{text-align:center;margin-inline:auto}
#main .d-tablet-align-right{text-align:right;margin-inline:auto 0}
#main .d-tablet-fontSize-small{font-size:14px}
#main .d-tablet-fontSize-body{font-size:18px}
#main .d-tablet-fontSize-large{font-size:24px}
#main .d-tablet-fontSize-title{font-size:36px}
#main .d-tablet-fontSize-display{font-size:52px}
#main .d-tablet-fontSize-hero{font-size:72px}
#main .d-tablet-fontWeight-regular{font-weight:400}
#main .d-tablet-fontWeight-medium{font-weight:500}
#main .d-tablet-fontWeight-bold{font-weight:700}
#main .d-tablet-lineHeight-tight{line-height:1.15}
#main .d-tablet-lineHeight-normal{line-height:1.5}
#main .d-tablet-lineHeight-relaxed{line-height:1.8}
#main .d-tablet-paddingY-none{padding-block:0}
#main .d-tablet-paddingY-small{padding-block:16px}
#main .d-tablet-paddingY-medium{padding-block:32px}
#main .d-tablet-paddingY-large{padding-block:64px}
#main .d-tablet-paddingY-huge{padding-block:96px}
#main .d-tablet-paddingX-none{padding-inline:0}
#main .d-tablet-paddingX-small{padding-inline:16px}
#main .d-tablet-paddingX-medium{padding-inline:32px}
#main .d-tablet-paddingX-large{padding-inline:64px}
#main .d-tablet-marginTop-none{margin-top:0}
#main .d-tablet-marginTop-small{margin-top:8px}
#main .d-tablet-marginTop-medium{margin-top:24px}
#main .d-tablet-marginTop-large{margin-top:48px}
#main .d-tablet-marginBottom-none{margin-bottom:0}
#main .d-tablet-marginBottom-small{margin-bottom:8px}
#main .d-tablet-marginBottom-medium{margin-bottom:24px}
#main .d-tablet-marginBottom-large{margin-bottom:48px}
#main .d-tablet-gap-none{gap:0}
#main .d-tablet-gap-small{gap:12px}
#main .d-tablet-gap-medium{gap:24px}
#main .d-tablet-gap-large{gap:48px}
#main .d-tablet-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
#main .d-tablet-columns-one{grid-template-columns:minmax(0,1fr)}
#main .d-tablet-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
#main .d-tablet-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
#main .d-tablet-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
#main .d-tablet-radius-none{border-radius:0}
#main .d-tablet-radius-small{border-radius:8px}
#main .d-tablet-radius-large{border-radius:24px}
}
@media(max-width:650px){
#main .d-mobile-align-left{text-align:left;margin-inline:0}
#main .d-mobile-align-center{text-align:center;margin-inline:auto}
#main .d-mobile-align-right{text-align:right;margin-inline:auto 0}
#main .d-mobile-fontSize-small{font-size:14px}
#main .d-mobile-fontSize-body{font-size:18px}
#main .d-mobile-fontSize-large{font-size:24px}
#main .d-mobile-fontSize-title{font-size:36px}
#main .d-mobile-fontSize-display{font-size:52px}
#main .d-mobile-fontSize-hero{font-size:72px}
#main .d-mobile-fontWeight-regular{font-weight:400}
#main .d-mobile-fontWeight-medium{font-weight:500}
#main .d-mobile-fontWeight-bold{font-weight:700}
#main .d-mobile-lineHeight-tight{line-height:1.15}
#main .d-mobile-lineHeight-normal{line-height:1.5}
#main .d-mobile-lineHeight-relaxed{line-height:1.8}
#main .d-mobile-paddingY-none{padding-block:0}
#main .d-mobile-paddingY-small{padding-block:16px}
#main .d-mobile-paddingY-medium{padding-block:32px}
#main .d-mobile-paddingY-large{padding-block:64px}
#main .d-mobile-paddingY-huge{padding-block:96px}
#main .d-mobile-paddingX-none{padding-inline:0}
#main .d-mobile-paddingX-small{padding-inline:16px}
#main .d-mobile-paddingX-medium{padding-inline:32px}
#main .d-mobile-paddingX-large{padding-inline:64px}
#main .d-mobile-marginTop-none{margin-top:0}
#main .d-mobile-marginTop-small{margin-top:8px}
#main .d-mobile-marginTop-medium{margin-top:24px}
#main .d-mobile-marginTop-large{margin-top:48px}
#main .d-mobile-marginBottom-none{margin-bottom:0}
#main .d-mobile-marginBottom-small{margin-bottom:8px}
#main .d-mobile-marginBottom-medium{margin-bottom:24px}
#main .d-mobile-marginBottom-large{margin-bottom:48px}
#main .d-mobile-gap-none{gap:0}
#main .d-mobile-gap-small{gap:12px}
#main .d-mobile-gap-medium{gap:24px}
#main .d-mobile-gap-large{gap:48px}
#main .d-mobile-columns-auto{grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr))}
#main .d-mobile-columns-one{grid-template-columns:minmax(0,1fr)}
#main .d-mobile-columns-two{grid-template-columns:repeat(2,minmax(0,1fr))}
#main .d-mobile-columns-three{grid-template-columns:repeat(3,minmax(0,1fr))}
#main .d-mobile-columns-four{grid-template-columns:repeat(4,minmax(0,1fr))}
#main .d-mobile-radius-none{border-radius:0}
#main .d-mobile-radius-small{border-radius:8px}
#main .d-mobile-radius-large{border-radius:24px}
}
+194
View File
@@ -0,0 +1,194 @@
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie' };
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦' };
export const isContainer = block => ['section', 'row', 'column', 'gallery'].includes(block?.type);
export const canContain = (parent, block) => isContainer(parent) && (parent.type !== 'gallery' || block.type === 'image');
export const clone = value => structuredClone(value);
export function locate(blocks, id, parent = null) {
for (let index = 0; index < blocks.length; index++) {
const block = blocks[index];
if (block.id === id) return { block, siblings: blocks, index, parent };
const child = locate(block.children || [], id, block);
if (child) return child;
}
return null;
}
export function createBlock(type) {
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
if (isContainer(block)) block.children = [];
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
return block;
}
export function insertBlock(document, type, selected) {
const block = createBlock(type);
if (type === 'gallery') upgradeDocument(document, 3);
let target = locate(document.blocks, selected);
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
if (!target) document.blocks.push(block);
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
else target.siblings.splice(target.index + 1, 0, block);
return block.id;
}
export function duplicateBlock(document, selected) {
const target = locate(document.blocks, selected);
if (!target) return null;
const copy = clone(target.block);
const rekey = block => {
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
(block.children || []).forEach(rekey);
};
rekey(copy);
target.siblings.splice(target.index + 1, 0, copy);
return copy.id;
}
export function moveBlock(document, selected, targetId, inside = false) {
const source = locate(document.blocks, selected);
const target = locate(document.blocks, targetId);
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;
source.siblings.splice(source.index, 1);
const destination = locate(document.blocks, targetId);
if (inside) (destination.block.children ??= []).push(source.block);
else destination.siblings.splice(destination.index, 0, source.block);
return true;
}
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
// Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) {
if (![1, 2, 3].includes(document.schemaVersion) || ![2, 3].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
export function effectiveDesign(block, device, key) {
let value, source;
for (const name of Object.keys(devices)) {
if (block.layout?.design?.[name]?.[key] !== undefined) {
value = block.layout.design[name][key]; source = name;
}
if (name === device) break;
}
return { value, source };
}
export function setDesign(document, id, device, key, value) {
if (![1, 2, 3].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
if (value === '') {
const design = block.layout?.design;
if (!design?.[device] || !Object.hasOwn(design[device], key)) return;
delete design[device][key];
if (!Object.keys(design[device]).length) delete design[device];
if (!Object.keys(design).length) delete block.layout.design;
} else {
upgradeDocument(document);
((block.layout ??= {}).design ??= {})[device] ??= {};
block.layout.design[device][key] = value;
}
}
export function blockPath(blocks, id) {
const found = locate(blocks, id);
return found ? [...(found.parent ? blockPath(blocks, found.parent.id) : []), found.block] : [];
}
export function setEffect(document, id, key, value) {
const block = locate(document.blocks, id)?.block;
if (!block) return;
upgradeDocument(document, 3);
((block.layout ??= {}).effects ??= {})[key] = value;
}
export function mediaProps(media) {
const largest = media.variants.at(-1);
return { mediaId: media.id, src: largest.src, width: largest.width, height: largest.height, alt: media.decorative ? '' : media.alt, decorative: Boolean(media.decorative), caption: '', sources: media.variants.map(({ src, width }) => ({ src, width })) };
}
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; }
let parent = target;
if (target?.type !== 'gallery') {
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
}
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
return parent.id;
}
export function insertPreset(document, name, selected) {
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; };
const paragraph = text => { const block = createBlock('text'); block.props.text = text; return block; };
section.layout = { tone: name === 'cta' ? 'ink' : 'paper', width: name === 'columns' ? 'normal' : 'narrow' };
section.children = [heading(name === 'cta' ? 'Lass uns gemeinsam starten.' : name === 'columns' ? 'Was wir für dich tun.' : 'Raum für deine nächste Idee.'), paragraph('Erzähle hier, was dein Angebot besonders macht und welchen Nutzen deine Besucher davon haben.')];
if (name === 'columns') {
const row = createBlock('row');
row.layout = { design: { desktop: { columns: 'two', gap: 'medium' }, mobile: { columns: 'one' } } };
row.children = ['Persönlich beraten', 'Gemeinsam umsetzen'].map(text => {
const column = createBlock('column');
const title = heading(text); title.props.level = 3;
column.children = [title, paragraph('Beschreibe dein Angebot in wenigen verständlichen Sätzen.')];
return column;
});
section.children.push(row);
upgradeDocument(document);
} else section.children.push(createBlock('button'));
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, section);
return section.id;
}
export class EditorHistory {
constructor(document) { this.reset(document); }
reset(document) {
this.document = clone(document);
this.past = [];
this.future = [];
this.mergeKey = null;
this.saved = JSON.stringify(document);
}
change(mutator, mergeKey = null) {
const next = clone(this.document);
const result = mutator(next);
if (JSON.stringify(next) === JSON.stringify(this.document)) return result;
if (!mergeKey || this.mergeKey !== mergeKey) this.past.push(this.document);
if (this.past.length > 100) this.past.shift();
this.document = next;
this.future = [];
this.mergeKey = mergeKey;
return result;
}
undo() {
if (!this.past.length) return;
this.future.push(this.document);
this.document = this.past.pop();
this.mergeKey = null;
}
redo() {
if (!this.future.length) return;
this.past.push(this.document);
this.document = this.future.pop();
this.mergeKey = null;
}
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
get dirty() { return JSON.stringify(this.document) !== this.saved; }
}
+403
View File
@@ -0,0 +1,403 @@
import { EditorHistory, labels, icons, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
const root = document.querySelector('#editor');
const $ = selector => document.querySelector(selector);
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
let device = 'desktop', propertyPanel = 'content', designTokens;
const collapsed = new Set();
function notify(message = '', recover = false) {
$('#message').textContent = message;
$('#message').hidden = !message;
$('#recovery').hidden = !recover;
}
async function request(path, body, signal) {
let response;
try {
response = await fetch(api + path, body === undefined ? { credentials: 'same-origin' } : {
method: 'POST', credentials: 'same-origin', signal,
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify(body),
});
} catch (error) {
if (error.name === 'AbortError') throw error;
throw new Error('Die Verbindung ist unterbrochen. Deine Änderungen bleiben im Editor. Bitte erneut versuchen oder als JSON sichern.');
}
if (response.redirected) throw new Error('Deine Sitzung ist abgelaufen. Sichere deine Änderungen und melde dich erneut an.');
if (!response.ok) {
if (response.status === 409) conflict = true;
let message = response.status === 403 ? 'Für diese Aktion fehlt die Berechtigung oder die Sitzung ist abgelaufen.' : `Die Anfrage ist fehlgeschlagen (${response.status}). Deine Änderungen bleiben im Editor.`;
if (response.headers.get('content-type')?.includes('application/json')) message = (await response.json()).error || message;
throw new Error(message);
}
return response.headers.get('content-type')?.includes('application/json') ? response.json() : response.text();
}
function controls() {
const ready = Boolean(history);
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
$('#save').disabled = !ready || busy || conflict || !history.dirty;
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
$('#undo').disabled = !ready || busy || !history.past.length;
$('#redo').disabled = !ready || busy || !history.future.length;
const current = ready ? locate(history.document.blocks, selected) : null;
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent']) $(`#${id}`).disabled = !current || busy;
if (current) {
$('#move-up').disabled ||= current.index === 0;
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
$('#outdent').disabled ||= !current.parent;
}
$('#library').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
document.querySelectorAll('[data-panel],[data-viewport],#tree-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
$('[data-panel="design"]').disabled ||= !current;
$('#select-page').disabled = !ready || busy;
$('#open-media-library').disabled = !ready || busy;
}
function renderTree() {
const activeId = $('#structure').contains(document.activeElement) ? document.activeElement.dataset.blockId : null;
const query = $('#tree-search').value.trim().toLocaleLowerCase('de');
const matches = block => `${labels[block.type]} ${block.props?.text || ''} ${block.props?.alt || ''}`.toLocaleLowerCase('de').includes(query) || (block.children || []).some(matches);
const build = blocks => {
const list = document.createElement('ul'); list.className = 'structure-list';
for (const block of blocks) {
if (query && !matches(block)) continue;
const item = document.createElement('li');
if (block.children?.length) {
const toggle = document.createElement('button'); toggle.className = 'tree-toggle'; toggle.type = 'button';
toggle.textContent = collapsed.has(block.id) && !query ? '▸' : '▾';
toggle.dataset.toggle = block.id;
toggle.setAttribute('aria-label', `${labels[block.type]} ${block.id} ${collapsed.has(block.id) ? 'öffnen' : 'einklappen'}`);
toggle.setAttribute('aria-expanded', String(!collapsed.has(block.id) || Boolean(query)));
toggle.disabled = Boolean(query); item.append(toggle);
}
const button = document.createElement('button');
button.className = 'tree-item'; button.type = 'button'; button.draggable = true;
button.dataset.blockId = block.id;
button.setAttribute('aria-pressed', String(block.id === selected));
button.setAttribute('aria-label', `${labels[block.type]}: ${block.props?.text || block.props?.alt || labels[block.type]}`);
const icon = document.createElement('span'); icon.className = 'tree-icon'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = icons[block.type];
const text = document.createElement('span'); text.textContent = block.props?.text || block.props?.alt || labels[block.type];
button.append(icon, text); item.append(button);
if (block.children?.length && (!collapsed.has(block.id) || query)) item.append(build(block.children));
list.append(item);
}
return list;
};
$('#structure').replaceChildren(build(history.document.blocks));
if (!$('#structure .tree-item')) { const empty = document.createElement('p'); empty.className = 'panel-hint'; empty.textContent = 'Keine passenden Elemente.'; $('#structure').append(empty); }
if (activeId) Array.from($('#structure').querySelectorAll('button')).find(button => button.dataset.blockId === activeId)?.focus({ preventScroll: true });
}
function choose(id, scroll = true) {
if (busy) return;
selected = locate(history.document.blocks, id) ? id : null;
blockPath(history.document.blocks, selected).slice(0, -1).forEach(block => collapsed.delete(block.id));
if (!selected) propertyPanel = 'content';
history.mergeKey = null;
renderTree(); renderProperties(); controls(); highlight(scroll);
}
function highlight(scroll = false) {
const doc = $('#preview').contentDocument;
if (!doc) return;
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
const element = selected ? doc.getElementById(selected) : null;
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
}
function renderProperties() {
const block = locate(history.document.blocks, selected)?.block;
if (!block) propertyPanel = 'content';
$('#properties-title').textContent = block ? labels[block.type] : 'Seiteneinstellungen';
const panel = $('#properties'); panel.replaceChildren();
document.querySelectorAll('[data-panel]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.panel === propertyPanel)));
$('#breadcrumbs').replaceChildren();
for (const item of [{ id: null, type: null }, ...blockPath(history.document.blocks, selected)]) {
const button = document.createElement('button'); button.className = 'text-button'; button.textContent = item.type ? labels[item.type] : 'Seite';
button.setAttribute('aria-current', item.id === selected ? 'location' : 'false');
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
}
if (block && propertyPanel === 'design') { renderDesign(block, panel); return; }
const field = (label, key, value, options = {}) => {
const id = `property-${key}`;
const labelEl = document.createElement('label'); labelEl.htmlFor = id; labelEl.textContent = label;
let input;
if (options.choices) {
input = document.createElement('select');
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
input.id = id; input.value = value ?? '';
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
if (options.maxLength) input.maxLength = options.maxLength;
input.addEventListener('input', () => {
const next = options.number ? Number(input.value) : options.checkbox ? input.checked : input.value;
history.change(doc => {
const current = selected ? locate(doc.blocks, selected)?.block : doc;
if (!current) return;
if (options.layout) (current.layout ??= {})[key] = next;
else if (selected) { (current.props ??= {})[key] = next; if (key === 'decorative' && next) current.props.alt = ''; }
else current[key] = next;
if (key === 'caption') upgradeDocument(doc, 3);
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
}, `${selected || 'page'}:${key}`);
if (key === 'decorative') renderProperties();
renderTree(); controls(); schedulePreview();
});
input.addEventListener('blur', () => { history.mergeKey = null; });
if (options.checkbox) panel.append(labelEl); else panel.append(labelEl, input);
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); panel.append(help); }
};
if (!block) {
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
return;
}
if (['heading', 'text', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
if (block.type === 'image') {
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
field('Bildadresse', 'src', block.props.src, { help: 'Vorhandene lokale Datei oder HTTPS-Adresse. Eine Mediathek folgt später.' });
field('Dekoratives Bild', 'decorative', block.props.decorative, { checkbox: true });
if (!block.props.decorative) field('Alternativtext', 'alt', block.props.alt, { multiline: true });
field('Originalbreite (px)', 'width', block.props.width, { number: true });
field('Originalhöhe (px)', 'height', block.props.height, { number: true });
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
}
if (block.type === 'gallery') {
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
}
if (block.type === 'section') {
field('Farbwelt', 'tone', block.layout?.tone || 'paper', { layout: true, choices: { paper: 'Hell · Papier', mint: 'Hellgrün · Akzent', ink: 'Dunkel · Tinte' } });
field('Inhaltsbreite', 'width', block.layout?.width || 'normal', { layout: true, choices: { normal: 'Normal', narrow: 'Schmal' } });
}
}
function renderDesign(block, panel) {
const hint = document.createElement('p'); hint.className = 'design-scope';
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
panel.append(hint, help);
for (const [key, token] of Object.entries(designTokens)) {
if (!token.types.includes(block.type)) continue;
const label = document.createElement('label'); label.textContent = token.label; label.htmlFor = `design-${key}`;
const select = document.createElement('select'); select.id = label.htmlFor;
const previous = device === 'mobile' ? effectiveDesign(block, 'tablet', key) : device === 'tablet' ? effectiveDesign(block, 'desktop', key) : {};
const inherited = previous.value ? `${token.values[previous.value][0]} von ${devices[previous.source]}` : 'Theme-Standard';
for (const [value, text] of [['', `Übernehmen: ${inherited}`], ...Object.entries(token.values).map(([value, [label]]) => [value, label])]) {
const option = document.createElement('option'); option.value = value; option.textContent = text; select.append(option);
}
select.value = block.layout?.design?.[device]?.[key] || '';
select.addEventListener('change', () => {
history.change(doc => setDesign(doc, selected, device, key, select.value));
controls(); schedulePreview();
});
panel.append(label, select);
}
const reset = document.createElement('button'); reset.className = 'secondary design-reset'; reset.textContent = `${devices[device]}-Werte zurücksetzen`;
reset.addEventListener('click', () => {
history.change(doc => { const current = locate(doc.blocks, selected).block; for (const key of Object.keys(current.layout?.design?.[device] || {})) setDesign(doc, selected, device, key, ''); });
changed();
}); panel.append(reset);
const heading = document.createElement('h3'); heading.className = 'preset-heading'; heading.textContent = 'Effekte · alle Geräte'; panel.append(heading);
for (const [key, label, choices] of [
['shadow', 'Schatten', { none: 'Keiner', soft: 'Weich', strong: 'Kräftig' }],
['hover', 'Hover-Effekt', { none: 'Keiner', lift: 'Leicht anheben', zoom: 'Bild vergrößern' }],
['entrance', 'Einblendung beim Laden', { none: 'Keine', fade: 'Sanft einblenden', rise: 'Von unten einblenden' }],
]) {
const labelEl = document.createElement('label'); labelEl.textContent = label; labelEl.htmlFor = `effect-${key}`;
const select = document.createElement('select'); select.id = labelEl.htmlFor;
for (const [value, title] of Object.entries(choices)) { const option = document.createElement('option'); option.value = value; option.textContent = title; select.append(option); }
select.value = block.layout?.effects?.[key] || 'none';
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
panel.append(labelEl, select);
}
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Bewegungseffekte werden bei „Bewegung reduzieren“ ausgeschaltet. Zoom wirkt auf enthaltene Bilder.'; panel.append(note);
}
function chooseMedia(multiple) {
const target = selected;
openMedia({ multiple, onChoose: items => {
if (!items.length || busy) return;
selected = history.change(doc => insertMedia(doc, target, items)); changed();
} });
}
function resizePreview() {
const container = $('.canvas-scroll'), frame = $('#preview'), stage = $('#preview-stage');
const width = { desktop: 1240, tablet: 768, mobile: 390 }[device];
const padding = parseFloat(getComputedStyle(container).paddingLeft) * 2;
const available = Math.max(100, container.clientWidth - padding);
const scale = Math.min(1, available / width);
const height = Math.max(550, (container.clientHeight - padding) / scale);
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
frame.title = `Vorschau ${devices[device]} · ${width} px`;
}
function schedulePreview() {
clearTimeout(previewTimer);
// Invalidate an in-flight response immediately, including during the debounce window.
previewSequence++;
previewController?.abort();
$('#preview-status').textContent = 'Vorschau wird aktualisiert …';
previewTimer = setTimeout(updatePreview, 250);
}
async function updatePreview() {
const sequence = previewSequence;
const controller = new AbortController(); previewController = controller;
try {
const html = await request('/preview', { document: history.document }, controller.signal);
if (sequence !== previewSequence) return;
const frame = $('#preview');
const top = frame.contentWindow?.scrollY || 0;
frame.onload = () => {
if (sequence !== previewSequence) return;
const doc = frame.contentDocument;
const style = doc.createElement('link'); style.rel = 'stylesheet'; style.href = '/assets/preview.css'; doc.head.append(style);
doc.addEventListener('click', event => {
event.preventDefault();
const element = event.target.closest('[id]');
if (element && locate(history.document.blocks, element.id)) choose(element.id, false);
});
doc.addEventListener('submit', event => event.preventDefault());
frame.contentWindow.scrollTo(0, top); highlight();
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
};
frame.srcdoc = html;
} catch (error) {
if (error.name === 'AbortError' || sequence !== previewSequence) return;
$('#preview-status').textContent = `Letzte gültige Vorschau: ${error.message}`;
}
}
function changed() {
if (!locate(history.document.blocks, selected)) selected = null;
renderTree(); renderProperties(); controls(); schedulePreview();
}
async function load() {
busy = true; controls();
try {
const data = await request('');
if (!designTokens) {
const response = await fetch('/assets/design-tokens.json');
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
designTokens = await response.json();
}
history = new EditorHistory(data.document);
({ version, revisionId, publishedRevisionId, csrfToken } = data);
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
}
async function save(publish = false) {
if (!history || busy || conflict || (publish && history.dirty)) return;
const snapshot = clone(history.document);
busy = true; controls(); notify();
try {
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
({ version, revisionId, publishedRevisionId, csrfToken } = data);
history.markSaved(snapshot);
notify();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
}
$('#library').addEventListener('click', event => {
const type = event.target.closest('[data-add]')?.dataset.add;
if (!type || busy || !history) return;
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
});
$('#presets').addEventListener('click', event => {
const name = event.target.closest('[data-preset]')?.dataset.preset;
if (!name || busy || !history) return;
selected = history.change(doc => insertPreset(doc, name, selected)); propertyPanel = 'content'; changed();
});
$('#structure').addEventListener('click', event => {
const toggle = event.target.closest('[data-toggle]')?.dataset.toggle;
if (toggle) { collapsed.has(toggle) ? collapsed.delete(toggle) : collapsed.add(toggle); renderTree(); $(`[data-toggle="${toggle}"]`)?.focus({ preventScroll: true }); return; }
const id = event.target.closest('[data-block-id]')?.dataset.blockId; if (id) choose(id);
});
$('#tree-search').addEventListener('input', renderTree);
$('#toggle-library').addEventListener('click', () => {
const hidden = !$('#toolbox').hidden; $('#toolbox').hidden = hidden;
$('#toggle-library').setAttribute('aria-expanded', String(!hidden));
$('#toggle-library').setAttribute('aria-label', `Werkzeugkasten ${hidden ? 'öffnen' : 'einklappen'}`);
$('#toggle-library').textContent = hidden ? '+' : '−';
});
$('#expand-all').addEventListener('click', () => { collapsed.clear(); renderTree(); });
$('#collapse-all').addEventListener('click', () => {
const visit = blocks => blocks.forEach(block => { if (block.children?.length) { collapsed.add(block.id); visit(block.children); } });
visit(history.document.blocks); renderTree();
});
document.querySelectorAll('[data-panel]').forEach(button => button.addEventListener('click', () => { propertyPanel = button.dataset.panel; history.mergeKey = null; renderProperties(); controls(); }));
$('#structure').addEventListener('dragstart', event => {
if (busy) { event.preventDefault(); return; }
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
});
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
$('#structure').addEventListener('drop', event => {
event.preventDefault(); if (busy) return;
const target = event.target.closest('[data-block-id]')?.dataset.blockId;
const source = event.dataTransfer.getData('text/plain');
if (!target || !source) return;
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
selected = source; changed();
});
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
$('#delete').addEventListener('click', () => {
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
});
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
});
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
$('#outdent').addEventListener('click', () => {
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
});
$('#select-page').addEventListener('click', () => choose(null));
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
$('#save').addEventListener('click', () => save());
$('#publish')?.addEventListener('click', () => $('#publish-dialog').showModal());
$('#cancel-publish').addEventListener('click', () => $('#publish-dialog').close());
$('#confirm-publish').addEventListener('click', () => { $('#publish-dialog').close(); save(true); });
$('#reload').addEventListener('click', () => $('#reload-dialog').showModal());
$('#cancel-reload').addEventListener('click', () => $('#reload-dialog').close());
$('#confirm-reload').addEventListener('click', () => { $('#reload-dialog').close(); load(); });
$('#download').addEventListener('click', () => {
if (!history) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(history.document, null, 2)], { type: 'application/json' }));
const link = document.createElement('a'); link.href = url; link.download = 'kant-entwurf.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
});
document.querySelectorAll('[data-viewport]').forEach(button => button.addEventListener('click', () => {
document.querySelectorAll('[data-viewport]').forEach(item => item.setAttribute('aria-pressed', String(item === button)));
$('#preview').className = `viewport-${button.dataset.viewport}`;
device = button.dataset.viewport; history.mergeKey = null; resizePreview(); renderProperties(); controls();
}));
new ResizeObserver(resizePreview).observe($('.canvas-scroll'));
window.addEventListener('keydown', event => {
if (!history || busy || !(event.ctrlKey || event.metaKey)) return;
const key = event.key.toLowerCase();
if (key === 's') { event.preventDefault(); save(); }
// Keep native text-field undo intact; the toolbar always undoes the complete document.
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName)) return;
if (key === 'z' || key === 'y') { event.preventDefault(); (event.shiftKey || key === 'y') ? history.redo() : history.undo(); changed(); }
});
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
load();
+5
View File
@@ -0,0 +1,5 @@
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 1000 600"><rect width="1000" height="600" fill="#e9efdf"/><circle cx="735" cy="160" r="70" fill="#b7ca91"/><path d="M0 520 310 200 610 520Z" fill="#7e9b77"/><path d="M370 600 720 280 1000 540V600Z" fill="#385c49"/><path d="M0 515Q450 400 1000 565V600H0Z" fill="#203b34"/></svg>

After

Width:  |  Height:  |  Size: 357 B

+101
View File
@@ -0,0 +1,101 @@
const element = (tag, text, className) => { const node = document.createElement(tag); if (text) node.textContent = text; if (className) node.className = className; return node; };
export function openMedia({ multiple = false, onChoose } = {}) {
const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
dialog.setAttribute('aria-labelledby', 'media-title');
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">BILDER FÜR DEINE WEBSITE</p><h2 id="media-title">Medienverwaltung</h2></div><button class="secondary" id="media-close">Schließen</button></div>
<p class="panel-hint">JPEG, PNG oder WebP · bis 8 MB und 12 Megapixel. Bilder werden optimiert und sind über ihre Bildadresse öffentlich erreichbar. Archivieren erhält bestehende Verwendungen.</p>
<div class="media-tools"><div><label for="media-search">Bilder suchen</label><input id="media-search" type="search" placeholder="Titel oder Alternativtext"></div><div><label for="media-upload">Bilder hochladen</label><input id="media-upload" type="file" accept="image/jpeg,image/png,image/webp" multiple></div></div>
<label class="check-label"><input id="media-archived" type="checkbox"> Archivierte Bilder anzeigen</label>
<p id="media-status" role="status" aria-live="polite">Wird geladen …</p><p id="media-error" role="alert" hidden></p>
<div class="media-layout"><div id="media-grid" class="media-grid" role="group" aria-label="Bilder auswählen"></div><div id="media-details" class="media-details"><p>Ein Bild auswählen, um seine Angaben zu bearbeiten.</p></div></div>
<div class="media-footer"><label class="check-label" id="decorative-choice"><input id="media-decorative" type="checkbox"> Auswahl in dieser Seite dekorativ verwenden</label><button id="media-insert" class="primary" disabled>Auswahl übernehmen</button></div>`;
document.body.append(dialog);
const $ = selector => dialog.querySelector(selector);
let items = [], selected = new Set(), active = null, token, working = false;
const error = message => { $('#media-error').textContent = message; $('#media-error').hidden = !message; };
const lock = state => {
working = state; dialog.querySelectorAll('button,input').forEach(node => node.disabled = state);
$('#media-insert').disabled = state || !selected.size;
};
async function api(path = '', body) {
const response = await fetch('/admin/api/media' + path, body === undefined ? {} : { method: 'POST', headers: { 'X-CSRF-Token': token, ...(body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, body: body instanceof FormData ? body : JSON.stringify(body) });
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Medienanfrage fehlgeschlagen (${response.status}).`);
return data;
}
function render() {
const focusedMedia = dialog.contains(document.activeElement) ? document.activeElement.dataset.mediaId : null;
const query = $('#media-search').value.toLocaleLowerCase('de');
const visible = items.filter(item => ($('#media-archived').checked || !item.archived) && `${item.title} ${item.alt}`.toLocaleLowerCase('de').includes(query));
$('#media-grid').replaceChildren();
for (const item of visible) {
const card = element('button', '', 'media-card'); card.type = 'button'; card.dataset.mediaId = item.id;
card.setAttribute('aria-label', `${item.title}${item.archived ? ' · Archiviert' : ''}`);
card.setAttribute('aria-pressed', String(selected.has(item.id))); card.disabled = working;
const image = element('img'); image.src = item.variants[0].src; image.alt = ''; image.loading = 'lazy'; image.width = 180; image.height = 130;
card.append(image, element('strong', item.title), element('span', item.archived ? 'Archiviert' : `${item.variants.at(-1).width} × ${item.variants.at(-1).height} px`));
card.addEventListener('click', () => {
active = item;
if (!multiple) selected.clear();
if (selected.has(item.id)) selected.delete(item.id); else if (!item.archived) selected.add(item.id);
render(); details();
}); $('#media-grid').append(card);
}
if (!visible.length) $('#media-grid').append(element('p', 'Keine Bilder gefunden. Lade ein Bild hoch oder ändere den Suchbegriff.', 'muted'));
$('#media-status').textContent = `${items.filter(item => !item.archived).length} Bilder · ${selected.size} ausgewählt`;
$('#media-insert').textContent = `Auswahl übernehmen (${selected.size})`; $('#media-insert').disabled = working || !selected.size;
if (focusedMedia) dialog.querySelector(`[data-media-id="${focusedMedia}"]`)?.focus({ preventScroll: true });
}
function details() {
const panel = $('#media-details'); panel.replaceChildren(); if (!active) return;
const current = active;
panel.append(element('h3', 'Bildangaben'));
for (const [key, label] of [['title', 'Bildtitel'], ['alt', 'Alternativtext des Mediums']]) {
const caption = element('label', label); caption.htmlFor = key === 'title' ? 'media-title-field' : `media-${key}`;
const input = element('input'); input.id = caption.htmlFor; input.value = current[key]; input.maxLength = key === 'title' ? 160 : 300;
panel.append(caption, input);
}
panel.append(element('p', 'Alt-Text beschreibt den Bildinhalt. Änderungen hier gelten für zukünftiges Einfügen; vorhandene Seiten behalten ihre eigenen Texte.', 'field-help'));
const save = element('button', 'Bildangaben speichern', 'secondary'); save.id = 'media-save';
const archive = element('button', current.archived ? 'Wiederherstellen' : 'Archivieren', 'secondary'); archive.id = 'media-archive';
const update = async archived => {
const data = { title: $('#media-title-field').value, alt: $('#media-alt').value, archived, version: current.version };
lock(true); error('');
try { const result = await api('/' + current.id, data); Object.assign(current, result.item); if (archived) selected.delete(current.id); render(); details(); }
catch (err) { error(err.message || 'Verbindung unterbrochen. Bitte erneut versuchen.'); }
finally { lock(false); $('#media-save')?.focus({ preventScroll: true }); }
};
save.addEventListener('click', () => update(current.archived)); archive.addEventListener('click', () => update(!current.archived));
panel.append(save, archive);
const link = element('a', 'Bilddatei öffnen ↗'); link.href = current.variants.at(-1).src; link.target = '_blank'; link.rel = 'noopener'; panel.append(link);
}
$('#media-search').addEventListener('input', render); $('#media-archived').addEventListener('change', render);
$('#media-close').addEventListener('click', () => dialog.close());
dialog.addEventListener('cancel', event => { if (working) event.preventDefault(); });
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); else document.querySelector('#properties button')?.focus(); });
$('#media-upload').addEventListener('change', async () => {
const files = [...$('#media-upload').files]; if (!files.length) return;
lock(true); error(''); const failures = [];
for (let index = 0; index < files.length; index++) {
const file = files[index]; $('#media-status').textContent = `Upload ${index + 1} von ${files.length}: ${file.name}`;
if (file.size > 8 * 1024 * 1024) { failures.push(`${file.name}: größer als 8 MB`); continue; }
try { const form = new FormData(); form.append('file', file); const data = await api('/upload', form); items.unshift(data.item); active = data.item; }
catch (err) { failures.push(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
}
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
});
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
$('#media-insert').addEventListener('click', () => {
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
const decorative = $('#media-decorative').checked;
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
});
dialog.showModal(); lock(true);
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
}
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
+2
View File
@@ -0,0 +1,2 @@
[id].editor-selected{outline:3px solid #437754!important;outline-offset:-3px;scroll-margin-top:14px}
[id].editor-hover:not(.editor-selected){outline:2px dashed #64864e;outline-offset:-2px}
+6
View File
@@ -0,0 +1,6 @@
@charset "UTF-8";
h1,h2,h3,p,a{overflow-wrap:anywhere;hyphens:auto}
.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid #b85c12;outline-offset:6px}main:focus{outline:none}.shell{width:min(1180px,calc(100% - 64px));margin-inline:auto}.site-header{display:flex;justify-content:space-between;align-items:center;min-height:106px;border-bottom:1px solid var(--line);gap:24px}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--accent);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:var(--muted);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:#294b41}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{border:1px solid var(--line);border-radius:12px;padding:30px;min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:var(--accent);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:#dae3da}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;padding-block:34px;border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header{min-height:100px;align-items:flex-start;flex-direction:column;justify-content:center;gap:18px;padding-block:22px}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.block-column{padding:26px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}