Compare commits

..

2 commits

Author SHA1 Message Date
Lara
ad92f1f512 Load the zoom view in strips so it opens instantly
The full-page captures (up to 1.3 MB each) are now cut into strips by
scripts/ganzseiten-kacheln.mjs. The first strip is ~60 KB and is fetched
as soon as a device scrolls into view; further strips load just before
they are scrolled to. Strip sizes are in the file names, so the dialog
has its full height immediately without layout jumps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:19:46 +02:00
Lara
f000d64cca Design round: logo colours, own fonts, blob shapes, broken grid
Prototype for review, not deployed. Newsreader + Schibsted Grotesk
(served locally, italic included), palette from the logo (petrol, sage,
sand, rose) instead of gold, the logo blob as a recurring shape, petrol
bands with a wave edge, margin-note section labels and uneven columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:09:28 +02:00
63 changed files with 782 additions and 220 deletions

View file

@ -10,6 +10,7 @@ const FONT_OPTIONS = [
{ name: 'Merriweather', fallback: "Georgia, 'Times New Roman', serif" }, { name: 'Merriweather', fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Libre Baskerville', fallback: "Georgia, 'Times New Roman', serif" }, { name: 'Libre Baskerville', fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'DM Serif Display', fallback: "Georgia, 'Times New Roman', serif" }, { name: 'DM Serif Display', fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Newsreader', fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Georgia (Systemschrift)', fallback: "Georgia, 'Times New Roman', serif" }, { name: 'Georgia (Systemschrift)', fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Inter', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Inter', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Roboto', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Roboto', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
@ -19,6 +20,7 @@ const FONT_OPTIONS = [
{ name: 'Nunito', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Nunito', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Work Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Work Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Source Sans 3', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Source Sans 3', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Schibsted Grotesk', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'System UI (Systemschrift)', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'System UI (Systemschrift)', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
]; ];

View file

@ -173,8 +173,8 @@ module.exports = [
{ key: 'image', label: 'Bildschirmfoto', type: 'image' }, { key: 'image', label: 'Bildschirmfoto', type: 'image' },
{ key: 'mobileImage', label: 'Handy-Ansicht (Hochformat, leer = kein Handy)', type: 'image' }, { key: 'mobileImage', label: 'Handy-Ansicht (Hochformat, leer = kein Handy)', type: 'image' },
{ key: 'video', label: 'Video im Laptop (Pfad zu einer .mp4, leer = Standbild)', type: 'text' }, { key: 'video', label: 'Video im Laptop (Pfad zu einer .mp4, leer = Standbild)', type: 'text' },
{ key: 'fullImage', label: 'Ganze Seite, Computer (Pfad, öffnet sich beim Anklicken; leer = nicht vergrößerbar)', type: 'text' }, { key: 'fullImage', label: 'Ganze Seite, Computer (Ordner mit Streifen, z. B. /mappe/ganz/name/; leer = nicht vergrößerbar)', type: 'text' },
{ key: 'fullMobileImage', label: 'Ganze Seite, Handy (Pfad, öffnet sich beim Anklicken)', type: 'text' }, { key: 'fullMobileImage', label: 'Ganze Seite, Handy (Ordner mit Streifen, z. B. /mappe/ganz/name-mobil/)', type: 'text' },
{ key: 'kategorie', label: 'Kategorie', type: 'text' }, { key: 'kategorie', label: 'Kategorie', type: 'text' },
{ key: 'titel', label: 'Titel / Name', type: 'text' }, { key: 'titel', label: 'Titel / Name', type: 'text' },
{ key: 'aufgabe', label: 'Die Aufgabe (leer = nur Text zeigen)', type: 'textarea' }, { key: 'aufgabe', label: 'Die Aufgabe (leer = nur Text zeigen)', type: 'textarea' },

View file

@ -64,7 +64,7 @@
.flatMap(([name, list]) => .flatMap(([name, list]) =>
list.map( list.map(
(face) => (face) =>
`@font-face { font-family: '${name}'; font-weight: ${face.weight}; font-display: swap; ` + `@font-face { font-family: '${name}'; font-style: ${face.style || 'normal'}; font-weight: ${face.weight}; font-display: swap; ` +
`src: url('/admin${face.file}') format('woff2'); unicode-range: ${face.unicodeRange}; }`, `src: url('/admin${face.file}') format('woff2'); unicode-range: ${face.unicodeRange}; }`,
), ),
) )

View file

@ -187,8 +187,8 @@
"image": "/mappe/elektro-rabe.webp", "image": "/mappe/elektro-rabe.webp",
"mobileImage": "/mappe/elektro-rabe-mobil.webp", "mobileImage": "/mappe/elektro-rabe-mobil.webp",
"video": "/mappe/elektro-rabe.mp4", "video": "/mappe/elektro-rabe.mp4",
"fullImage": "/mappe/elektro-rabe-ganz.webp", "fullImage": "/mappe/ganz/elektro-rabe/",
"fullMobileImage": "/mappe/elektro-rabe-mobil-ganz.webp" "fullMobileImage": "/mappe/ganz/elektro-rabe-mobil/"
}, },
{ {
"kategorie": "Konzeptarbeit · Tanzstudio", "kategorie": "Konzeptarbeit · Tanzstudio",
@ -199,8 +199,8 @@
"image": "/mappe/maison-verte.webp", "image": "/mappe/maison-verte.webp",
"mobileImage": "/mappe/maison-verte-mobil.webp", "mobileImage": "/mappe/maison-verte-mobil.webp",
"video": "/mappe/maison-verte.mp4", "video": "/mappe/maison-verte.mp4",
"fullImage": "/mappe/maison-verte-ganz.webp", "fullImage": "/mappe/ganz/maison-verte/",
"fullMobileImage": "/mappe/maison-verte-mobil-ganz.jpg" "fullMobileImage": "/mappe/ganz/maison-verte-mobil/"
}, },
{ {
"kategorie": "Konzeptarbeit · Kosmetikstudio", "kategorie": "Konzeptarbeit · Kosmetikstudio",
@ -211,8 +211,8 @@
"image": "/mappe/atelier-rosenhain.webp", "image": "/mappe/atelier-rosenhain.webp",
"mobileImage": "/mappe/atelier-rosenhain-mobil.webp", "mobileImage": "/mappe/atelier-rosenhain-mobil.webp",
"video": "", "video": "",
"fullImage": "/mappe/atelier-rosenhain-ganz.webp", "fullImage": "/mappe/ganz/atelier-rosenhain/",
"fullMobileImage": "/mappe/atelier-rosenhain-mobil-ganz.webp" "fullMobileImage": "/mappe/ganz/atelier-rosenhain-mobil/"
} }
] ]
}, },

View file

@ -1,7 +1,7 @@
{ {
"fonts": { "fonts": {
"heading": "Playfair Display", "heading": "Newsreader",
"body": "Inter" "body": "Schibsted Grotesk"
}, },
"fontSizes": { "fontSizes": {
"sm": "0.875rem", "sm": "0.875rem",
@ -20,9 +20,9 @@
"a": 1 "a": 1
}, },
"bgAlt": { "bgAlt": {
"r": 244, "r": 233,
"g": 239, "g": 239,
"b": 228, "b": 234,
"a": 1 "a": 1
}, },
"surface": { "surface": {
@ -32,57 +32,57 @@
"a": 1 "a": 1
}, },
"text": { "text": {
"r": 28, "r": 26,
"g": 24, "g": 38,
"b": 19, "b": 34,
"a": 1 "a": 1
}, },
"textMuted": { "textMuted": {
"r": 107, "r": 78,
"g": 97, "g": 93,
"b": 84, "b": 87,
"a": 1 "a": 1
}, },
"border": { "border": {
"r": 220, "r": 185,
"g": 207, "g": 198,
"b": 182, "b": 190,
"a": 1 "a": 1
}, },
"primary": { "primary": {
"r": 122, "r": 44,
"g": 93, "g": 74,
"b": 31, "b": 66,
"a": 1 "a": 1
}, },
"primaryDark": { "primaryDark": {
"r": 94, "r": 31,
"g": 71, "g": 53,
"b": 22, "b": 48,
"a": 1 "a": 1
}, },
"primaryContrast": { "primaryContrast": {
"r": 255, "r": 252,
"g": 253, "g": 250,
"b": 248, "b": 246,
"a": 1 "a": 1
}, },
"accent": { "accent": {
"r": 122, "r": 142,
"g": 93, "g": 79,
"b": 31, "b": 63,
"a": 1 "a": 1
}, },
"accentDark": { "accentDark": {
"r": 94, "r": 115,
"g": 71, "g": 62,
"b": 22, "b": 49,
"a": 1 "a": 1
}, },
"success": { "success": {
"r": 79, "r": 63,
"g": 107, "g": 107,
"b": 66, "b": 85,
"a": 1 "a": 1
}, },
"danger": { "danger": {
@ -101,7 +101,7 @@
"r": 138, "r": 138,
"g": 59, "g": 59,
"b": 34, "b": 34,
"a": 0.1 "a": 1
}, },
"googleBlue": { "googleBlue": {
"r": 26, "r": 26,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none"><path d="M6 52 C6 26 24 8 52 6 C76 4 94 24 94 48 C94 74 76 96 48 94 C24 92 6 76 6 52 Z"/></svg>

After

Width:  |  Height:  |  Size: 185 B

1
public/form/fleck.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none"><defs><linearGradient id="v" gradientUnits="userSpaceOnUse" x1="4" y1="96" x2="96" y2="4"><stop offset="0" stop-color="#B3C4B6"/><stop offset=".48" stop-color="#CFCBAC"/><stop offset="1" stop-color="#D9C0B4"/></linearGradient></defs><path d="M6 52 C6 26 24 8 52 6 C76 4 94 24 94 48 C94 74 76 96 48 94 C24 92 6 76 6 52 Z" fill="url(#v)" stroke="#2C4A42" stroke-width="1.6" vector-effect="non-scaling-stroke"/></svg>

After

Width:  |  Height:  |  Size: 504 B

1
public/form/pille.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 502 240" preserveAspectRatio="none"><defs><linearGradient id="v" gradientUnits="userSpaceOnUse" x1="20" y1="220" x2="490" y2="20"><stop offset="0" stop-color="#B3C4B6"/><stop offset=".48" stop-color="#CFCBAC"/><stop offset="1" stop-color="#D9C0B4"/></linearGradient></defs><path d="M10 120 C10 74 40 40 92 32 C192 10 315 56 408 30 C468 18 492 60 492 120 C492 180 468 222 408 210 C315 184 192 230 92 208 C40 200 10 166 10 120 Z" fill="url(#v)"/></svg>

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -1,30 +1,41 @@
import re, json, urllib.request, urllib.error, pathlib # Aufruf: python scripts/fonts-holen.py -> alle Schriften neu holen
# python scripts/fonts-holen.py "Name" ... -> nur diese, Manifest wird ergänzt
import re, json, sys, urllib.request, urllib.error, pathlib
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36" UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36"
FAMS = ['Playfair Display','Lora','Merriweather','Libre Baskerville','DM Serif Display','Inter','Roboto','Open Sans','Poppins','Montserrat','Nunito','Work Sans','Source Sans 3'] FAMS = ['Playfair Display','Lora','Merriweather','Libre Baskerville','DM Serif Display','Inter','Roboto','Open Sans','Poppins','Montserrat','Nunito','Work Sans','Source Sans 3','Newsreader','Schibsted Grotesk']
# Diese Schriften auch kursiv holen (echte Kursive statt schräg gerechneter)
ITAL = {'Newsreader'}
manifest_path = pathlib.Path('src/lib/font-faces.json')
if sys.argv[1:]:
FAMS = sys.argv[1:]
out_dir = pathlib.Path('public/fonts'); out_dir.mkdir(parents=True, exist_ok=True) out_dir = pathlib.Path('public/fonts'); out_dir.mkdir(parents=True, exist_ok=True)
manifest = {} manifest = json.load(open(manifest_path, encoding='utf8')) if sys.argv[1:] else {}
def get(url): def get(url):
return urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': UA})).read() return urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': UA})).read()
for fam in FAMS: for fam in FAMS:
css = None css = None
for w in ['400;500;600;700', '400;700', '400']: for w in ['400;500;600;700', '400;700', '400']:
axis = 'wght'
if fam in ITAL:
axis = 'ital,wght'; w = ';'.join([f'0,{x}' for x in w.split(';')] + [f'1,{x}' for x in w.split(';')])
try: try:
css = get(f"https://fonts.googleapis.com/css2?family={fam.replace(' ','+')}:wght@{w}&display=swap").decode(); break css = get(f"https://fonts.googleapis.com/css2?family={fam.replace(' ','+')}:{axis}@{w}&display=swap").decode(); break
except urllib.error.HTTPError: pass except urllib.error.HTTPError: pass
slug = fam.lower().replace(' ', '-'); d = out_dir / slug; d.mkdir(exist_ok=True) slug = fam.lower().replace(' ', '-'); d = out_dir / slug; d.mkdir(exist_ok=True)
faces = []; seen = {} faces = []; seen = {}
for subset, body in re.findall(r'/\* ([\w-]+) \*/\s*@font-face \{(.*?)\}', css, re.S): for subset, body in re.findall(r'/\* ([\w-]+) \*/\s*@font-face \{(.*?)\}', css, re.S):
if subset not in ('latin', 'latin-ext'): continue if subset not in ('latin', 'latin-ext'): continue
weight = re.search(r'font-weight: (\d+)', body).group(1) weight = re.search(r'font-weight: (\d+)', body).group(1)
style = re.search(r'font-style: (\w+)', body).group(1)
url = re.search(r'url\((.*?)\)', body).group(1) url = re.search(r'url\((.*?)\)', body).group(1)
rng = re.search(r'unicode-range: (.*?);', body).group(1) rng = re.search(r'unicode-range: (.*?);', body).group(1)
if url not in seen: if url not in seen:
fn = f"{slug}-{subset}-{len(seen)}.woff2"; (d / fn).write_bytes(get(url)); seen[url] = fn fn = f"{slug}-{subset}-{len(seen)}.woff2"; (d / fn).write_bytes(get(url)); seen[url] = fn
faces.append({'weight': int(weight), 'subset': subset, 'file': f"/fonts/{slug}/{seen[url]}", 'unicodeRange': rng}) faces.append({'weight': int(weight), 'style': style, 'subset': subset, 'file': f"/fonts/{slug}/{seen[url]}", 'unicodeRange': rng})
# gleiche Datei fuer mehrere Gewichte (variable Schrift) -> eine Regel mit Gewichtsbereich # gleiche Datei fuer mehrere Gewichte (variable Schrift) -> eine Regel mit Gewichtsbereich
merged = {} merged = {}
for f in faces: for f in faces:
k = (f['file'], f['subset']); merged.setdefault(k, dict(f, weights=[]))['weights'].append(f['weight']) k = (f['file'], f['subset'], f['style']); merged.setdefault(k, dict(f, weights=[]))['weights'].append(f['weight'])
manifest[fam] = [{'file': v['file'], 'weight': f"{min(v['weights'])} {max(v['weights'])}" if len(v['weights']) > 1 else str(v['weights'][0]), 'unicodeRange': v['unicodeRange']} for v in merged.values()] manifest[fam] = [dict({'file': v['file'], 'weight': f"{min(v['weights'])} {max(v['weights'])}" if len(v['weights']) > 1 else str(v['weights'][0]), 'unicodeRange': v['unicodeRange']}, **({'style': 'italic'} if v['style'] == 'italic' else {})) for v in merged.values()]
print(fam, len(manifest[fam]), 'Regeln', len(seen), 'Dateien') print(fam, len(manifest[fam]), 'Regeln', len(seen), 'Dateien')
json.dump(manifest, open('src/lib/font-faces.json', 'w', encoding='utf8'), indent=2) json.dump(manifest, open(manifest_path, 'w', encoding='utf8'), indent=2)

View file

@ -0,0 +1,43 @@
// Schneidet die Ganzseitenaufnahmen der Mappe in Streifen, damit die
// Großansicht sofort etwas zeigt und der Rest beim Scrollen nachlädt.
// Aufruf: node scripts/ganzseiten-kacheln.mjs <Ordner mit den PNGs>
// Erwartet <name>.png (Computer, 1440 px breit) und <name>-mobil.png (Handy, 780 px).
// Ergebnis: public/mappe/ganz/<name>[-mobil]/NNN-BREITExHOEHE.webp
// Die Maße stehen im Dateinamen, so braucht der Build kein Bildwerkzeug.
import sharp from 'sharp';
import { mkdirSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const quelle = process.argv[2];
if (!quelle) throw new Error('Ordner mit den PNGs angeben');
const ziel = new URL('../public/mappe/ganz/', import.meta.url);
// Erster Streifen klein (schnell sichtbar), danach größere
const STREIFEN = { desktop: [900, 1800], mobil: [1700, 3200] };
for (const datei of readdirSync(quelle).filter((f) => f.endsWith('.png') && !f.startsWith('v-'))) {
const name = datei.replace(/\.png$/, '');
const art = name.endsWith('-mobil') ? 'mobil' : 'desktop';
const pfad = join(quelle, datei);
const { width, height } = await sharp(pfad, { limitInputPixels: false }).metadata();
const ordner = new URL(`${name}/`, ziel);
rmSync(ordner, { recursive: true, force: true });
mkdirSync(ordner, { recursive: true });
let top = 0;
let i = 0;
let summe = 0;
while (top < height) {
const h = Math.min(i === 0 ? STREIFEN[art][0] : STREIFEN[art][1], height - top);
const out = new URL(`${String(i).padStart(3, '0')}-${width}x${h}.webp`, ordner);
const info = await sharp(pfad, { limitInputPixels: false })
.extract({ left: 0, top, width, height: h })
.webp({ quality: 80 })
.toFile(fileURLToPath(out));
summe += info.size;
top += h;
i++;
}
console.log(`${name}: ${i} Streifen, ${Math.round(summe / 1024)} KB`);
}

View file

@ -6,10 +6,12 @@
* Mit `screenZoom`/`phoneZoom` wird das Gerät anklickbar und öffnet die ganze * Mit `screenZoom`/`phoneZoom` wird das Gerät anklickbar und öffnet die ganze
* Seite in der Großansicht (<Zoom>, einmal pro Seite eingebunden). * Seite in der Großansicht (<Zoom>, einmal pro Seite eingebunden).
*/ */
import type { Streifen } from '../lib/ganzseiten';
export interface ZoomSource { export interface ZoomSource {
title: string; title: string;
desktop?: string; desktop?: Streifen[];
mobile?: string; mobile?: Streifen[];
} }
interface Props { interface Props {
@ -27,8 +29,8 @@ const loading = eager ? 'eager' : 'lazy';
const zoomAttrs = (z: ZoomSource, mode: 'desktop' | 'mobile') => ({ const zoomAttrs = (z: ZoomSource, mode: 'desktop' | 'mobile') => ({
'data-zoom': mode, 'data-zoom': mode,
'data-zoom-title': z.title, 'data-zoom-title': z.title,
'data-zoom-desktop': z.desktop || undefined, 'data-zoom-desktop': z.desktop ? JSON.stringify(z.desktop) : undefined,
'data-zoom-mobile': z.mobile || undefined, 'data-zoom-mobile': z.mobile ? JSON.stringify(z.mobile) : undefined,
'aria-label': `${z.title} ${mode === 'desktop' ? 'am Computer' : 'auf dem Handy'} vergrößert ansehen`, 'aria-label': `${z.title} ${mode === 'desktop' ? 'am Computer' : 'auf dem Handy'} vergrößert ansehen`,
}); });
const laptopZoom = screenZoom && (screenZoom.desktop || screenZoom.mobile) ? screenZoom : undefined; const laptopZoom = screenZoom && (screenZoom.desktop || screenZoom.mobile) ? screenZoom : undefined;

View file

@ -0,0 +1,22 @@
---
/**
* Abschnittskennung als Randnotiz: "02 · Was uns unterscheidet" wird zu
* großer Ziffer im Fleck plus kleiner Zeile darunter. Ohne Ziffer am Anfang
* bleibt es eine einfache Zeile.
*/
interface Props {
text: string;
}
const { text } = Astro.props;
const match = text.match(/^(\d+)\s*·\s*(.+)$/);
---
{match ? (
<p class="eyebrow eyebrow-rand">
<span class="eyebrow-num">{match[1]}</span>
<span class="eyebrow-text">{match[2]}</span>
</p>
) : (
<p class="eyebrow">{text}</p>
)}

View file

@ -25,12 +25,12 @@
<div class="zoom-browser"> <div class="zoom-browser">
<div class="zoom-browser-top" aria-hidden="true"><span></span><span></span><span></span></div> <div class="zoom-browser-top" aria-hidden="true"><span></span><span></span><span></span></div>
<div class="zoom-scroll" tabindex="0" aria-label="Seite am Computer, zum Durchscrollen"> <div class="zoom-scroll" tabindex="0" aria-label="Seite am Computer, zum Durchscrollen">
<img alt="" decoding="async" data-slot="desktop" /> <div class="zoom-pages" data-slot="desktop"></div>
</div> </div>
</div> </div>
<div class="zoom-phone"> <div class="zoom-phone">
<div class="zoom-scroll" tabindex="0" aria-label="Seite auf dem Handy, zum Durchscrollen"> <div class="zoom-scroll" tabindex="0" aria-label="Seite auf dem Handy, zum Durchscrollen">
<img alt="" decoding="async" data-slot="mobile" /> <div class="zoom-pages" data-slot="mobile"></div>
</div> </div>
</div> </div>
</div> </div>
@ -39,48 +39,145 @@
</dialog> </dialog>
<script> <script>
/*
* Die Aufnahmen liegen als Streifen vor (scripts/ganzseiten-kacheln.mjs).
* Schnell wird es durch drei Dinge:
* 1. Streifen haben feste Maße, die Seite steht sofort in voller Höhe.
* 2. Nur sichtbare Streifen laden, der Rest kurz bevor man hinscrollt.
* 3. Erste Streifen werden vorab geholt, sobald ein Gerät in Sichtweite
* kommt, und beim Zeigen oder Antippen zusätzlich der zweite.
*/
type Streifen = { src: string; w: number; h: number };
type Mode = 'desktop' | 'mobile';
const parse = (raw?: string): Streifen[] => {
try {
return raw ? (JSON.parse(raw) as Streifen[]) : [];
} catch {
return [];
}
};
const geholt = new Set<string>();
const vorab = (liste: Streifen[], anzahl: number) => {
liste.slice(0, anzahl).forEach(({ src }) => {
if (geholt.has(src)) return;
geholt.add(src);
const img = new Image();
img.decoding = 'async';
img.src = src;
});
};
const vorabFuer = (trigger: HTMLElement, anzahl: number) => {
const mode = trigger.dataset.zoom as Mode;
vorab(parse(mode === 'mobile' ? trigger.dataset.zoomMobile : trigger.dataset.zoomDesktop), anzahl);
};
const dialog = document.querySelector<HTMLDialogElement>('dialog.zoom'); const dialog = document.querySelector<HTMLDialogElement>('dialog.zoom');
if (dialog && typeof dialog.showModal === 'function') { if (dialog && typeof dialog.showModal === 'function') {
const title = dialog.querySelector<HTMLElement>('.zoom-title')!; const title = dialog.querySelector<HTMLElement>('.zoom-title')!;
const stage = dialog.querySelector<HTMLElement>('.zoom-stage')!; const stage = dialog.querySelector<HTMLElement>('.zoom-stage')!;
const switcher = dialog.querySelector<HTMLElement>('.zoom-switch')!; const switcher = dialog.querySelector<HTMLElement>('.zoom-switch')!;
const imgs = { const pages = {
desktop: dialog.querySelector<HTMLImageElement>('img[data-slot="desktop"]')!, desktop: dialog.querySelector<HTMLElement>('.zoom-pages[data-slot="desktop"]')!,
mobile: dialog.querySelector<HTMLImageElement>('img[data-slot="mobile"]')!, mobile: dialog.querySelector<HTMLElement>('.zoom-pages[data-slot="mobile"]')!,
};
let sources: Record<Mode, Streifen[]> = { desktop: [], mobile: [] };
const observers: Partial<Record<Mode, IntersectionObserver>> = {};
const laden = (img: HTMLImageElement) => {
if (img.dataset.src) {
img.src = img.dataset.src;
delete img.dataset.src;
}
};
const aufbauen = (mode: Mode) => {
const box = pages[mode];
const key = sources[mode].map((s) => s.src).join('|');
if (box.dataset.key === key) return;
box.dataset.key = key;
observers[mode]?.disconnect();
box.replaceChildren();
const scroller = box.closest<HTMLElement>('.zoom-scroll')!;
// Streifen erst laden, wenn sie in die Nähe des sichtbaren Bereichs kommen
const io =
'IntersectionObserver' in window
? new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
laden(e.target as HTMLImageElement);
io?.unobserve(e.target);
});
},
{ root: scroller, rootMargin: '1500px 0px' },
)
: undefined;
observers[mode] = io;
sources[mode].forEach((s, i) => {
const img = document.createElement('img');
img.alt = '';
img.decoding = 'async';
if (s.w && s.h) {
img.width = s.w;
img.height = s.h;
}
if (i < 2 || !io) {
if (i === 0) img.setAttribute('fetchpriority', 'high');
img.src = s.src;
} else {
img.dataset.src = s.src;
io.observe(img);
}
box.append(img);
});
}; };
type Mode = keyof typeof imgs;
let sources: Record<Mode, string> = { desktop: '', mobile: '' };
const show = (mode: Mode) => { const show = (mode: Mode) => {
stage.dataset.mode = mode; stage.dataset.mode = mode;
switcher.querySelectorAll<HTMLButtonElement>('button').forEach((b) => { switcher.querySelectorAll<HTMLButtonElement>('button').forEach((b) => {
b.setAttribute('aria-pressed', String(b.dataset.mode === mode)); b.setAttribute('aria-pressed', String(b.dataset.mode === mode));
}); });
const img = imgs[mode]; aufbauen(mode);
if (img.getAttribute('src') !== sources[mode]) { pages[mode].closest('.zoom-scroll')!.scrollTop = 0;
img.classList.remove('is-loaded'); // die andere Ansicht schon mal anwärmen
img.src = sources[mode]; vorab(sources[mode === 'desktop' ? 'mobile' : 'desktop'], 1);
}
img.closest('.zoom-scroll')!.scrollTop = 0;
}; };
Object.values(imgs).forEach((img) => { // Vorab holen: erster Streifen, sobald ein Gerät in Sichtweite ist
img.addEventListener('load', () => img.classList.add('is-loaded')); const trigger = document.querySelectorAll<HTMLElement>('[data-zoom]');
if ('IntersectionObserver' in window) {
const nah = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
vorabFuer(e.target as HTMLElement, 1);
nah.unobserve(e.target);
});
},
{ rootMargin: '300px 0px' },
);
trigger.forEach((t) => nah.observe(t));
}
// Absicht erkennen: Maus drauf, Finger drauf, Tastaturfokus, dann den zweiten Streifen dazu
trigger.forEach((t) => {
const absicht = () => vorabFuer(t, 2);
t.addEventListener('pointerenter', absicht, { passive: true });
t.addEventListener('touchstart', absicht, { passive: true });
t.addEventListener('focus', absicht);
}); });
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
const trigger = (event.target as Element).closest<HTMLElement>('[data-zoom]'); const t = (event.target as Element).closest<HTMLElement>('[data-zoom]');
if (!trigger) return; if (!t) return;
sources = { sources = { desktop: parse(t.dataset.zoomDesktop), mobile: parse(t.dataset.zoomMobile) };
desktop: trigger.dataset.zoomDesktop || '', title.textContent = t.dataset.zoomTitle || '';
mobile: trigger.dataset.zoomMobile || '', switcher.hidden = !(sources.desktop.length && sources.mobile.length);
}; const wanted = t.dataset.zoom as Mode;
title.textContent = trigger.dataset.zoomTitle || '';
switcher.hidden = !(sources.desktop && sources.mobile);
const wanted = trigger.dataset.zoom as Mode;
show(sources[wanted] ? wanted : sources.desktop ? 'desktop' : 'mobile');
dialog.showModal(); dialog.showModal();
document.documentElement.classList.add('zoom-open'); document.documentElement.classList.add('zoom-open');
show(sources[wanted].length ? wanted : sources.desktop.length ? 'desktop' : 'mobile');
}); });
switcher.addEventListener('click', (event) => { switcher.addEventListener('click', (event) => {
@ -257,16 +354,13 @@
background: #fff; background: #fff;
} }
.zoom-scroll img { /* Streifen liegen lückenlos untereinander; bis sie da sind, zeigt die
feste Höhe eine helle Fläche statt eines Sprungs */
.zoom-pages :global(img) {
display: block; display: block;
width: 100%; width: 100%;
height: auto; height: auto;
opacity: 0; background: #f1efe9;
transition: opacity 0.25s;
}
.zoom-scroll img.is-loaded {
opacity: 1;
} }
.zoom-hint { .zoom-hint {
@ -302,9 +396,4 @@
} }
} }
@media (prefers-reduced-motion: reduce) {
.zoom-scroll img {
transition: none;
}
}
</style> </style>

View file

@ -184,5 +184,41 @@
"weight": "400 700", "weight": "400 700",
"unicodeRange": "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" "unicodeRange": "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"
} }
],
"Newsreader": [
{
"file": "/fonts/newsreader/newsreader-latin-ext-0.woff2",
"weight": "400 700",
"unicodeRange": "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",
"style": "italic"
},
{
"file": "/fonts/newsreader/newsreader-latin-1.woff2",
"weight": "400 700",
"unicodeRange": "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",
"style": "italic"
},
{
"file": "/fonts/newsreader/newsreader-latin-ext-2.woff2",
"weight": "400 700",
"unicodeRange": "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"
},
{
"file": "/fonts/newsreader/newsreader-latin-3.woff2",
"weight": "400 700",
"unicodeRange": "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"
}
],
"Schibsted Grotesk": [
{
"file": "/fonts/schibsted-grotesk/schibsted-grotesk-latin-ext-0.woff2",
"weight": "400 700",
"unicodeRange": "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"
},
{
"file": "/fonts/schibsted-grotesk/schibsted-grotesk-latin-1.woff2",
"weight": "400 700",
"unicodeRange": "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"
}
] ]
} }

View file

@ -18,6 +18,7 @@ export const FONT_OPTIONS: FontOption[] = [
{ name: 'Merriweather', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" }, { name: 'Merriweather', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Libre Baskerville', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" }, { name: 'Libre Baskerville', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'DM Serif Display', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" }, { name: 'DM Serif Display', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Newsreader', category: 'serif', google: true, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Georgia (Systemschrift)', category: 'serif', google: false, fallback: "Georgia, 'Times New Roman', serif" }, { name: 'Georgia (Systemschrift)', category: 'serif', google: false, fallback: "Georgia, 'Times New Roman', serif" },
{ name: 'Inter', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Inter', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Roboto', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Roboto', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
@ -27,6 +28,7 @@ export const FONT_OPTIONS: FontOption[] = [
{ name: 'Nunito', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Nunito', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Work Sans', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Work Sans', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Source Sans 3', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'Source Sans 3', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'Schibsted Grotesk', category: 'sans', google: true, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
{ name: 'System UI (Systemschrift)', category: 'sans', google: false, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, { name: 'System UI (Systemschrift)', category: 'sans', google: false, fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" },
]; ];
@ -43,7 +45,7 @@ export function fontFamilyCss(name: string): string {
* public/fonts (see font-faces.json). The fonts are served from our own * public/fonts (see font-faces.json). The fonts are served from our own
* server so a page view never contacts Google. System fonts are skipped. */ * server so a page view never contacts Google. System fonts are skipped. */
export function localFontFacesCss(fontNames: string[]): string { export function localFontFacesCss(fontNames: string[]): string {
const faces = fontFaces as Record<string, { file: string; weight: string; unicodeRange: string }[]>; const faces = fontFaces as Record<string, { file: string; weight: string; unicodeRange: string; style?: string }[]>;
return fontNames return fontNames
.map(findFont) .map(findFont)
.filter((f) => f.google) .filter((f) => f.google)
@ -51,7 +53,7 @@ export function localFontFacesCss(fontNames: string[]): string {
.flatMap((f) => .flatMap((f) =>
(faces[f.name] || []).map( (faces[f.name] || []).map(
(face) => (face) =>
`@font-face { font-family: '${f.name}'; font-style: normal; font-weight: ${face.weight}; ` + `@font-face { font-family: '${f.name}'; font-style: ${face.style || 'normal'}; font-weight: ${face.weight}; ` +
`font-display: swap; src: url('${face.file}') format('woff2'); unicode-range: ${face.unicodeRange}; }`, `font-display: swap; src: url('${face.file}') format('woff2'); unicode-range: ${face.unicodeRange}; }`,
), ),
) )

28
src/lib/ganzseiten.ts Normal file
View file

@ -0,0 +1,28 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
export interface Streifen {
src: string;
w: number;
h: number;
}
/**
* Liest die Streifen einer Ganzseitenaufnahme aus public/<ordner> (beim Build).
* Dateinamen: NNN-BREITExHOEHE.webp, erzeugt von scripts/ganzseiten-kacheln.mjs.
* Ein einzelner Bildpfad statt eines Ordners geht auch (ein Streifen ohne Maße).
*/
export function streifen(ordner?: string): Streifen[] | undefined {
if (!ordner) return undefined;
if (!ordner.endsWith('/')) return [{ src: ordner, w: 0, h: 0 }];
try {
const liste = readdirSync(join(process.cwd(), 'public', ordner))
.map((f) => f.match(/^\d+-(\d+)x(\d+)\.(webp|jpg|png)$/) && { f, m: f.match(/^\d+-(\d+)x(\d+)/)! })
.filter((x): x is { f: string; m: RegExpMatchArray } => Boolean(x))
.sort((a, b) => a.f.localeCompare(b.f))
.map(({ f, m }) => ({ src: ordner + f, w: Number(m[1]), h: Number(m[2]) }));
return liste.length ? liste : undefined;
} catch {
return undefined;
}
}

View file

@ -2,7 +2,9 @@
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import Devices from '../components/Devices.astro'; import Devices from '../components/Devices.astro';
import Zoom from '../components/Zoom.astro'; import Zoom from '../components/Zoom.astro';
import Kicker from '../components/Kicker.astro';
import { site, rich } from '../lib/content'; import { site, rich } from '../lib/content';
import { streifen } from '../lib/ganzseiten';
const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site; const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, kontakt } = site;
@ -10,7 +12,7 @@ const { hero, warum, preis, highlights, leistungen, mappe, ablauf, about, faq, k
type MappeItem = (typeof mappe.items)[number]; type MappeItem = (typeof mappe.items)[number];
const zoomFor = (item?: MappeItem) => const zoomFor = (item?: MappeItem) =>
item && !item.nameHidden && (item.fullImage || item.fullMobileImage) item && !item.nameHidden && (item.fullImage || item.fullMobileImage)
? { title: item.titel, desktop: item.fullImage, mobile: item.fullMobileImage } ? { title: item.titel, desktop: streifen(item.fullImage), mobile: streifen(item.fullMobileImage) }
: undefined; : undefined;
const findItem = (src: string) => const findItem = (src: string) =>
mappe.items.find((i) => src && (i.image === src || i.mobileImage === src)); mappe.items.find((i) => src && (i.image === src || i.mobileImage === src));
@ -21,7 +23,7 @@ const findItem = (src: string) =>
<section class="section hero"> <section class="section hero">
<div class="container hero-grid"> <div class="container hero-grid">
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow">{hero.eyebrow}</p> <Kicker text={hero.eyebrow} />
<h1>{hero.titleBefore}<em>{hero.titleEmphasis}</em>{hero.titleAfter}</h1> <h1>{hero.titleBefore}<em>{hero.titleEmphasis}</em>{hero.titleAfter}</h1>
<p class="text-lead" set:html={rich(hero.lead)} /> <p class="text-lead" set:html={rich(hero.lead)} />
<div class="hero-actions"> <div class="hero-actions">
@ -33,6 +35,7 @@ const findItem = (src: string) =>
</div> </div>
<div class="hero-visual"> <div class="hero-visual">
<span class="hero-fleck" aria-hidden="true"></span>
{hero.visual.customImage ? ( {hero.visual.customImage ? (
<img class="hero-visual-image" src={hero.visual.customImage} alt="" loading="lazy" /> <img class="hero-visual-image" src={hero.visual.customImage} alt="" loading="lazy" />
) : ( ) : (
@ -52,7 +55,7 @@ const findItem = (src: string) =>
<section id="preis" class="section-tight section-dark"> <section id="preis" class="section-tight section-dark">
<div class="container price-grid"> <div class="container price-grid">
<div class="price-main"> <div class="price-main">
<p class="eyebrow">{preis.eyebrow}</p> <Kicker text={preis.eyebrow} />
<h2>{preis.title}</h2> <h2>{preis.title}</h2>
<p class="price-tag"><span class="price-amount">{preis.amount}</span> {preis.amountNote}</p> <p class="price-tag"><span class="price-amount">{preis.amount}</span> {preis.amountNote}</p>
<p class="text-lead" set:html={rich(preis.lead)} /> <p class="text-lead" set:html={rich(preis.lead)} />
@ -78,9 +81,9 @@ const findItem = (src: string) =>
</section> </section>
<!-- Warum uns unterscheidet --> <!-- Warum uns unterscheidet -->
<section id="warum" class="section"> <section id="warum" class="section section-warum">
<div class="container"> <div class="container split">
<p class="eyebrow">{warum.eyebrow}</p> <Kicker text={warum.eyebrow} />
<h2>{warum.title}</h2> <h2>{warum.title}</h2>
<p class="text-lead" set:html={rich(warum.lead)} /> <p class="text-lead" set:html={rich(warum.lead)} />
@ -117,9 +120,9 @@ const findItem = (src: string) =>
</section> </section>
<!-- Leistungen --> <!-- Leistungen -->
<section id="leistungen" class="section"> <section id="leistungen" class="section section-leistungen tint-sand">
<div class="container"> <div class="container split">
<p class="eyebrow">{leistungen.eyebrow}</p> <Kicker text={leistungen.eyebrow} />
<h2>{leistungen.title}</h2> <h2>{leistungen.title}</h2>
<p class="text-lead" set:html={rich(leistungen.lead)} /> <p class="text-lead" set:html={rich(leistungen.lead)} />
@ -139,7 +142,7 @@ const findItem = (src: string) =>
<h3>{leistungen.nichtMachenTitle}</h3> <h3>{leistungen.nichtMachenTitle}</h3>
<div class="grid grid-2"> <div class="grid grid-2">
{leistungen.nichtMachen.map((text) => ( {leistungen.nichtMachen.map((text) => (
<p class="nicht-machen-item">— {text}</p> <p class="nicht-machen-item">{text}</p>
))} ))}
</div> </div>
</div> </div>
@ -147,9 +150,9 @@ const findItem = (src: string) =>
</section> </section>
<!-- Mappe / Arbeiten --> <!-- Mappe / Arbeiten -->
<section id="arbeiten" class="section"> <section id="arbeiten" class="section section-arbeiten">
<div class="container"> <div class="container split">
<p class="eyebrow">{mappe.eyebrow}</p> <Kicker text={mappe.eyebrow} />
<h2>{mappe.title}</h2> <h2>{mappe.title}</h2>
<div class="mappe-grid"> <div class="mappe-grid">
@ -190,9 +193,9 @@ const findItem = (src: string) =>
</section> </section>
<!-- Ablauf --> <!-- Ablauf -->
<section id="ablauf" class="section"> <section id="ablauf" class="section section-ablauf tint-salbei">
<div class="container"> <div class="container split">
<p class="eyebrow">{ablauf.eyebrow}</p> <Kicker text={ablauf.eyebrow} />
<h2>{ablauf.title}</h2> <h2>{ablauf.title}</h2>
<div class="ablauf-list"> <div class="ablauf-list">
@ -211,7 +214,7 @@ const findItem = (src: string) =>
</section> </section>
<!-- Über uns --> <!-- Über uns -->
<section class="section about"> <section class="section about section-about tint-rose">
<div class="container about-grid"> <div class="container about-grid">
{about.photo ? ( {about.photo ? (
<div class="about-photo about-photo-image"> <div class="about-photo about-photo-image">
@ -227,9 +230,9 @@ const findItem = (src: string) =>
)} )}
<div class="about-copy"> <div class="about-copy">
<p class="eyebrow">{about.eyebrow}</p> <Kicker text={about.eyebrow} />
<h2>{about.title}</h2> <h2>{about.title}</h2>
<p class="about-quote">{about.quote}</p> <blockquote class="about-quote"><p>{about.quote}</p></blockquote>
{about.paragraphs.map((paragraph) => ( {about.paragraphs.map((paragraph) => (
<p set:html={rich(paragraph)} /> <p set:html={rich(paragraph)} />
@ -240,8 +243,8 @@ const findItem = (src: string) =>
<!-- FAQ --> <!-- FAQ -->
<section class="section faq"> <section class="section faq">
<div class="container"> <div class="container split">
<p class="eyebrow">{faq.eyebrow}</p> <Kicker text={faq.eyebrow} />
<h2>{faq.title}</h2> <h2>{faq.title}</h2>
<div class="faq-list"> <div class="faq-list">
@ -264,7 +267,7 @@ const findItem = (src: string) =>
<section id="kontakt" class="section-tight section-dark contact"> <section id="kontakt" class="section-tight section-dark contact">
<div class="container contact-grid"> <div class="container contact-grid">
<div class="contact-intro"> <div class="contact-intro">
<p class="eyebrow">{kontakt.eyebrow}</p> <Kicker text={kontakt.eyebrow} />
<h2>{kontakt.title}</h2> <h2>{kontakt.title}</h2>
<p class="text-lead" set:html={rich(kontakt.lead)} /> <p class="text-lead" set:html={rich(kontakt.lead)} />
@ -363,19 +366,43 @@ const findItem = (src: string) =>
<style> <style>
/* Hero */ /* Hero */
.hero {
overflow: hidden;
}
.hero-grid { .hero-grid {
position: relative;
isolation: isolate;
display: grid; display: grid;
grid-template-columns: 1.1fr 0.9fr; grid-template-columns: 1.15fr 0.85fr;
gap: var(--space-8); gap: var(--space-8);
align-items: center; align-items: center;
} }
.hero-copy { .hero-copy {
max-width: 34rem; max-width: 38rem;
} }
.hero h1 { .hero h1 {
font-size: clamp(2.5rem, 5vw, 3.5rem); font-size: clamp(2.8rem, 6.2vw, 5.4rem);
line-height: 1.02;
letter-spacing: -0.035em;
font-weight: 400;
max-width: none;
}
.hero-visual {
position: relative;
}
/* Die Pille aus dem Logo, groß und schräg hinter den Geräten */
.hero-fleck {
position: absolute;
z-index: -1;
inset: -14% -30% -8% -12%;
background: url('/form/pille.svg') center / 100% 100% no-repeat;
transform: rotate(-8deg);
opacity: 0.85;
} }
.hero h1 em { .hero h1 em {
@ -408,11 +435,21 @@ const findItem = (src: string) =>
margin: var(--space-4) 0; margin: var(--space-4) 0;
} }
.price-tag {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0 var(--space-4);
}
.price-amount { .price-amount {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: clamp(2.5rem, 6vw, 4rem); font-size: clamp(5rem, 13vw, 10rem);
line-height: 0.9;
letter-spacing: -0.05em;
color: var(--color-accent); color: var(--color-accent);
font-weight: 600; font-weight: 400;
font-style: italic;
} }
.price-progress { .price-progress {
@ -422,10 +459,10 @@ const findItem = (src: string) =>
} }
.price-progress span { .price-progress span {
height: 4px; height: 5px;
width: 3rem; width: 3rem;
background: var(--color-accent); background: var(--color-accent);
border-radius: 2px; border-radius: 999px;
} }
.price-note { .price-note {
@ -452,10 +489,12 @@ const findItem = (src: string) =>
content: ''; content: '';
position: absolute; position: absolute;
left: 0; left: 0;
top: 0.5em; top: 0.4em;
width: 8px; width: 11px;
height: 8px; height: 10px;
background: var(--color-accent); background: var(--color-accent);
-webkit-mask: url('/form/fleck-maske.svg') center / 100% 100% no-repeat;
mask: url('/form/fleck-maske.svg') center / 100% 100% no-repeat;
} }
.price-list-excluded { .price-list-excluded {
@ -463,8 +502,8 @@ const findItem = (src: string) =>
} }
.price-list-excluded::before { .price-list-excluded::before {
background: transparent; background: var(--color-text-muted);
border: 1px solid var(--color-text-muted); opacity: 0.45;
} }
.price-aside { .price-aside {
@ -477,31 +516,75 @@ const findItem = (src: string) =>
} }
/* Warum / Differentiators */ /* Warum / Differentiators */
.differentiators { .section-warum .differentiators,
margin-top: var(--space-7); .section-warum .readability {
grid-column: 1 / -1;
} }
.differentiator { .differentiators {
border-top: 1px solid var(--color-border); margin-top: var(--space-8);
padding-top: var(--space-4); grid-template-columns: 1.35fr 1fr 0.9fr;
gap: var(--space-7);
align-items: start;
}
.differentiator:nth-child(2) {
margin-top: var(--space-8);
}
.differentiator:nth-child(3) {
margin-top: calc(var(--space-8) * 2);
}
.differentiator:first-child h3 {
font-size: clamp(1.6rem, 2.4vw, 2.1rem);
} }
.roman { .roman {
display: block; position: relative;
display: grid;
place-items: center;
width: 3.6rem;
height: 3.2rem;
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: var(--font-size-2xl); font-style: italic;
color: var(--color-text-muted); font-size: var(--font-size-xl);
margin-bottom: var(--space-3); color: #2c4a42;
margin-bottom: var(--space-4);
isolation: isolate;
} }
.roman::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: url('/form/fleck.svg') center / 100% 100% no-repeat;
}
.differentiator:nth-child(2) .roman::before { transform: rotate(70deg) scaleX(-1); }
.differentiator:nth-child(3) .roman::before { transform: rotate(160deg); }
.readability { .readability {
margin-top: var(--space-8); margin-top: var(--space-9);
padding-top: var(--space-8); display: grid;
border-top: 1px solid var(--color-border); grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
gap: 0 var(--space-8);
align-items: center;
}
.readability h3 {
font-size: clamp(1.6rem, 2.6vw, 2.2rem);
grid-column: 1;
}
.readability .text-lead {
grid-column: 1;
} }
.readability-hours { .readability-hours {
margin-top: var(--space-6); grid-column: 2;
grid-row: 1 / span 2;
max-width: 26rem; max-width: 26rem;
} }
@ -514,9 +597,10 @@ const findItem = (src: string) =>
.hours-card { .hours-card {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1.5px solid #2c4a42;
border-radius: var(--radius-md); border-radius: 28px 40px 30px 44px / 36px 28px 42px 30px;
padding: var(--space-5); padding: var(--space-6);
transform: rotate(-1.5deg);
} }
.hours-card h4 { .hours-card h4 {
@ -542,21 +626,30 @@ const findItem = (src: string) =>
} }
.leistungen-list li { .leistungen-list li {
display: flex; display: grid;
gap: var(--space-6); grid-template-columns: 3rem minmax(0, 1fr);
padding-block: var(--space-5); gap: var(--space-5);
padding-block: var(--space-6);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
.leistungen-list li > div {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
gap: var(--space-6);
align-items: baseline;
}
.leistungen-num { .leistungen-num {
font-family: var(--font-serif); font-family: var(--font-serif);
font-style: italic;
font-size: var(--font-size-lg);
color: var(--color-accent); color: var(--color-accent);
flex-shrink: 0;
width: 2.5rem;
} }
.leistungen-list h3 { .leistungen-list h3 {
margin-bottom: var(--space-2); margin: 0;
font-size: clamp(1.3rem, 2vw, 1.6rem);
} }
.leistungen-list p { .leistungen-list p {
@ -570,19 +663,50 @@ const findItem = (src: string) =>
} }
.nicht-machen-item { .nicht-machen-item {
position: relative;
padding-left: 1.7rem;
color: var(--color-text); color: var(--color-text);
} }
.nicht-machen-item::before {
content: '';
position: absolute;
left: 0;
top: 0.2em;
width: 1.05rem;
height: 1rem;
background: url('/form/fleck.svg') center / 100% 100% no-repeat;
}
/* Mappe */ /* Mappe */
.mappe-grid { .mappe-grid {
grid-column: 1 / -1;
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.75fr);
gap: var(--space-7); gap: var(--space-8);
margin-top: var(--space-7); margin-top: var(--space-8);
align-items: start;
} }
.mappe-item:first-child { .mappe-item:first-child {
grid-column: 1 / -1; grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 0.7fr);
gap: 0 var(--space-7);
align-items: end;
}
.mappe-item:first-child .mappe-devices {
grid-row: 1 / span 4;
margin: 0;
}
.mappe-item:nth-child(3) {
margin-top: calc(var(--space-9) * 1.5);
}
.mappe-item h3 {
font-size: clamp(1.5rem, 2.4vw, 2rem);
} }
.mappe-shot { .mappe-shot {
@ -609,12 +733,11 @@ const findItem = (src: string) =>
} }
.mappe-fall dt { .mappe-fall dt {
font-size: 0.75rem; font-family: var(--font-serif);
font-weight: 600; font-style: italic;
letter-spacing: 0.06em; font-size: var(--font-size-md);
text-transform: uppercase;
color: var(--color-accent); color: var(--color-accent);
margin-top: var(--space-3); margin-top: var(--space-4);
} }
.mappe-fall dd { .mappe-fall dd {
@ -632,24 +755,61 @@ const findItem = (src: string) =>
/* Ablauf */ /* Ablauf */
.ablauf-list { .ablauf-list {
margin-top: var(--space-7); grid-column: 1 / -1;
position: relative;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-6);
margin-top: var(--space-8);
}
/* geschwungener Faden durch die vier Ziffern */
.ablauf-list::before {
content: '';
position: absolute;
left: 2rem;
right: 14%;
top: 1rem;
height: 3.5rem;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 40' preserveAspectRatio='none'%3E%3Cpath d='M0 20 C 120 2 220 38 340 20 S 560 4 670 20 S 880 36 1000 18' fill='none' stroke='%232C4A42' stroke-width='1.5' stroke-dasharray='5 7' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
} }
.ablauf-step { .ablauf-step {
display: grid; position: relative;
grid-template-columns: 3rem 1fr auto; display: flex;
gap: var(--space-5); flex-direction: column;
align-items: baseline; gap: var(--space-4);
padding-block: var(--space-6); }
border-bottom: 1px solid var(--color-border);
.ablauf-step:nth-child(even) {
margin-top: var(--space-6);
} }
.ablauf-num { .ablauf-num {
position: relative;
display: grid;
place-items: center;
width: 4.6rem;
height: 4rem;
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: var(--font-size-2xl); font-style: italic;
color: var(--color-accent); font-size: var(--font-size-xl);
color: #2c4a42;
isolation: isolate;
} }
.ablauf-num::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: url('/form/fleck.svg') center / 100% 100% no-repeat;
}
.ablauf-step:nth-child(2) .ablauf-num::before { transform: rotate(80deg); }
.ablauf-step:nth-child(3) .ablauf-num::before { transform: scaleX(-1) rotate(20deg); }
.ablauf-step:nth-child(4) .ablauf-num::before { transform: rotate(200deg); }
.ablauf-body h3 { .ablauf-body h3 {
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
@ -659,9 +819,13 @@ const findItem = (src: string) =>
} }
.ablauf-dauer { .ablauf-dauer {
align-self: flex-start;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text);
white-space: nowrap; white-space: nowrap;
padding: 0.15rem 0.7rem;
border: 1px solid #2c4a42;
border-radius: 999px;
} }
/* About */ /* About */
@ -673,10 +837,10 @@ const findItem = (src: string) =>
} }
.about-photo { .about-photo {
aspect-ratio: 3 / 4; aspect-ratio: 4 / 5;
background: var(--color-surface); background: url('/form/fleck.svg') center / 100% 100% no-repeat;
border: 1px dashed var(--color-border); border: 0;
border-radius: var(--radius-md); color: #2c4a42;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -688,8 +852,9 @@ const findItem = (src: string) =>
} }
.about-photo-image { .about-photo-image {
border: none;
padding: 0; padding: 0;
-webkit-mask: url('/form/fleck-maske.svg') center / 100% 100% no-repeat;
mask: url('/form/fleck-maske.svg') center / 100% 100% no-repeat;
} }
.about-photo-image img { .about-photo-image img {
@ -699,13 +864,17 @@ const findItem = (src: string) =>
} }
.about-quote { .about-quote {
margin: 0 0 var(--space-6);
}
.about-quote p {
font-family: var(--font-serif); font-family: var(--font-serif);
font-style: italic; font-style: italic;
color: var(--color-accent); color: var(--color-accent);
font-size: var(--font-size-lg); font-size: clamp(1.6rem, 3vw, 2.4rem);
border-left: 2px solid var(--color-accent); line-height: 1.15;
padding-left: var(--space-4); letter-spacing: -0.02em;
margin-bottom: var(--space-5); margin: 0;
} }
/* FAQ */ /* FAQ */
@ -724,8 +893,10 @@ const findItem = (src: string) =>
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: var(--space-5);
list-style: none; list-style: none;
font-weight: var(--font-weight-medium); font-family: var(--font-serif);
font-size: var(--font-size-lg);
color: var(--color-text); color: var(--color-text);
} }
@ -734,8 +905,16 @@ const findItem = (src: string) =>
} }
.faq-icon { .faq-icon {
color: var(--color-accent); display: grid;
place-items: center;
flex: none;
width: 2.2rem;
height: 2rem;
font-family: var(--font-sans);
color: #2c4a42;
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
background: url('/form/fleck.svg') center / 100% 100% no-repeat;
transition: transform var(--transition-base);
} }
.faq-item[open] .faq-icon { .faq-item[open] .faq-icon {
@ -768,12 +947,12 @@ const findItem = (src: string) =>
.contact-phone { .contact-phone {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: var(--font-size-lg); font-size: var(--font-size-xl);
} }
.contact-email { .contact-email {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: var(--font-size-lg); font-size: var(--font-size-xl);
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
} }
@ -793,7 +972,7 @@ const findItem = (src: string) =>
width: 100%; width: 100%;
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-md);
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
} }
@ -829,20 +1008,62 @@ const findItem = (src: string) =>
position: static; position: static;
} }
.mappe-grid { .mappe-grid,
grid-template-columns: 1fr; .mappe-item:first-child {
grid-template-columns: minmax(0, 1fr);
} }
.mappe-item:first-child { .mappe-item:first-child {
grid-column: auto; grid-column: auto;
} }
.ablauf-step { .mappe-item:first-child .mappe-devices {
grid-template-columns: 2.5rem 1fr; grid-row: auto;
margin-bottom: var(--space-5);
} }
.ablauf-dauer { .mappe-item:nth-child(3) {
grid-column: 2; margin-top: 0;
}
.differentiators {
grid-template-columns: minmax(0, 1fr);
}
.differentiator:nth-child(n) {
margin-top: 0;
}
.readability {
grid-template-columns: minmax(0, 1fr);
}
.readability-hours {
grid-column: 1;
grid-row: auto;
margin-top: var(--space-5);
}
.ablauf-list {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-7);
}
.ablauf-list::before {
display: none;
}
.ablauf-step:nth-child(even) {
margin-top: 0;
}
.leistungen-list li > div {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-2);
}
.hero-fleck {
inset: -6% -20% -4% -10%;
} }
} }
</style> </style>

View file

@ -27,8 +27,8 @@
--color-google-blue: #8fb2e8; --color-google-blue: #8fb2e8;
/* Typografie */ /* Typografie */
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif; --font-serif: 'Newsreader', Georgia, 'Times New Roman', serif;
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; --font-sans: 'Schibsted Grotesk', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-size-sm: 0.875rem; --font-size-sm: 0.875rem;
--font-size-base: 1rem; --font-size-base: 1rem;
@ -106,13 +106,20 @@ svg {
h1, h2, h3, h4 { h1, h2, h3, h4 {
margin: 0 0 var(--space-4) 0; margin: 0 0 var(--space-4) 0;
line-height: var(--line-height-tight); line-height: var(--line-height-tight);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-medium);
letter-spacing: -0.01em; letter-spacing: -0.015em;
font-family: var(--font-serif); font-family: var(--font-serif);
color: var(--color-text);
} }
h1 { font-size: var(--font-size-3xl); } h1 { font-size: var(--font-size-3xl); }
h2 { font-size: var(--font-size-2xl); } /* Skala mit Loch: Fließtext bleibt klein, Abschnittstitel werden deutlich größer */
h2 {
font-size: clamp(2.1rem, 4.2vw, 3.5rem);
line-height: 1.08;
letter-spacing: -0.025em;
max-width: 20ch;
}
h3 { font-size: var(--font-size-lg); } h3 { font-size: var(--font-size-lg); }
h4 { font-size: var(--font-size-md); } h4 { font-size: var(--font-size-md); }
@ -196,49 +203,145 @@ textarea {
padding-block: var(--space-7); padding-block: var(--space-7);
} }
/* Dunkles Band (aus dem Prototyp "Nacht"): Angebot und Kontakt. /* Dunkles Band in Petrol (Logofarbe): Angebot und Kontakt.
Die Farben werden hier als Variablen neu gesetzt, damit Text, Karten, Die Farben werden hier als Variablen neu gesetzt, damit Text, Karten,
Buttons und Formularfelder darin automatisch mitdrehen - keine Buttons und Formularfelder darin automatisch mitdrehen - keine
Einzelregeln, die gegen Bauteilklassen verlieren koennten. Einzelregeln, die gegen Bauteilklassen verlieren koennten.
Kontraste auf #1E1A14: Text 15,0 · leise 9,9 · Gold 9,1 · Rand 3,8. */ Kontraste auf #1F3530: Text 11,5 · leise 8,2 · Rosé 7,6 · Rand 3,4.
Oberkante als Welle aus der Logoform: per Maske ausgeschnitten, darüber
scheint der Papiergrund durch. */
.section-dark { .section-dark {
--color-bg: #1e1a14; --color-bg: #1f3530;
--color-bg-alt: #2a241b; --color-bg-alt: #2a443d;
--color-surface: #2a241b; --color-surface: #2a443d;
--color-text: #f5eee2; --color-text: #f3f0e8;
--color-text-muted: #cfc2ac; --color-text-muted: #c5d0c9;
--color-border: #82755f; --color-border: #6c877e;
--color-primary: #d8b87e; --color-primary: #d9c0b4;
--color-primary-dark: #e8d2a6; --color-primary-dark: #ead8cf;
--color-primary-contrast: #241c0b; --color-primary-contrast: #1f3530;
--color-accent: #d8b87e; --color-accent: #d9c0b4;
--color-accent-dark: #e8d2a6; --color-accent-dark: #ead8cf;
--color-flag: #e0836a; --color-flag: #f0a08a;
--color-flag-bg: rgba(224, 131, 106, 0.14); --color-flag-bg: rgba(240, 160, 138, 0.14);
--welle: 56px;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
padding-block: var(--space-9); padding-block: calc(var(--space-9) + var(--welle) * 0.5) var(--space-9);
-webkit-mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 56' preserveAspectRatio='none'%3E%3Cpath d='M0 34 C 240 2 520 52 830 24 C 1070 4 1270 8 1440 30 V56 H0 Z'/%3E%3C/svg%3E") top / 100% var(--welle) no-repeat,
linear-gradient(#000 0 0) 0 calc(var(--welle) - 1px) / 100% 100% no-repeat;
mask:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1440 56' preserveAspectRatio='none'%3E%3Cpath d='M0 34 C 240 2 520 52 830 24 C 1070 4 1270 8 1440 30 V56 H0 Z'/%3E%3C/svg%3E") top / 100% var(--welle) no-repeat,
linear-gradient(#000 0 0) 0 calc(var(--welle) - 1px) / 100% 100% no-repeat;
} }
.section-dark h1, .section-dark h1,
.section-dark h2, .section-dark h2,
.section-dark h3 { .section-dark h3 {
color: #fffaf2; color: #fbf8f1;
} }
.section-dark ::placeholder { .section-dark ::placeholder {
color: #9a8f7b; color: #9fb1a8;
opacity: 1; opacity: 1;
} }
/* Getönte Abschnitte für Rhythmus: Salbei, Sand, Rosé aus dem Logoverlauf */
.tint-salbei { background: #e9efea; }
.tint-sand { background: #f1efe3; }
.tint-rose { background: #f5eae4; }
.eyebrow { .eyebrow {
display: inline-block; display: inline-block;
color: var(--color-primary); color: var(--color-text-muted);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-medium);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
letter-spacing: 0.06em; letter-spacing: 0.01em;
text-transform: uppercase; margin-bottom: var(--space-4);
margin-bottom: var(--space-3); }
/* Abschnittskennung: Ziffer im Fleck aus dem Logo + Zeile daneben */
.eyebrow-rand {
display: flex;
align-items: center;
gap: var(--space-3);
}
.eyebrow-num {
position: relative;
display: grid;
place-items: center;
flex: none;
width: 2.6em;
height: 2.3em;
font-family: var(--font-serif);
font-style: italic;
font-size: 1.05rem;
font-weight: 500;
color: #2c4a42;
isolation: isolate;
}
.eyebrow-num::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: url('/form/fleck.svg') center / 100% 100% no-repeat;
}
/* Randspalten-Raster: Kennung links in der Marginalie, Inhalt rechts.
Kinder landen standardmäßig in Spalte 2; wer volle Breite braucht,
setzt grid-column: 1 / -1. */
.split {
display: grid;
grid-template-columns: minmax(8rem, 13rem) minmax(0, 1fr);
column-gap: clamp(2rem, 5vw, 5rem);
}
.split > * {
grid-column: 2;
}
.split > .eyebrow-rand {
grid-column: 1;
grid-row: 1 / span 3;
align-self: start;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
max-width: 12rem;
}
.split > .eyebrow-rand .eyebrow-num {
font-size: 2.6rem;
}
@media (max-width: 900px) {
.split {
grid-template-columns: minmax(0, 1fr);
}
.split > *,
.split > .eyebrow-rand {
grid-column: 1;
grid-row: auto;
}
.split > .eyebrow-rand {
flex-direction: row;
align-items: center;
max-width: none;
}
.split > .eyebrow-rand .eyebrow-num {
font-size: 1.4rem;
}
.section-dark {
--welle: 32px;
}
} }
.text-lead { .text-lead {
@ -268,7 +371,7 @@ textarea {
.card { .card {
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
padding: var(--space-6); padding: var(--space-6);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@ -277,8 +380,8 @@ textarea {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
border-radius: var(--radius-sm); border-radius: 999px;
padding: var(--space-3) var(--space-5); padding: 0.8rem 1.6rem;
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base); font-size: var(--font-size-base);
border: 1px solid transparent; border: 1px solid transparent;
@ -299,13 +402,13 @@ textarea {
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-primary);
border-color: var(--color-border); border: 1.5px solid var(--color-primary);
} }
.btn-secondary:hover { .btn-secondary:hover {
border-color: var(--color-primary); background: var(--color-primary);
color: var(--color-primary); color: var(--color-primary-contrast);
text-decoration: none; text-decoration: none;
} }