commit 306ea0862d4c78957bef54ceecb6b243d61ec91e Author: ANDJ Date: Sat Jul 11 17:04:35 2026 +0200 Initiale Unger Reisen Website (statisch, mobil optimiert) - One-Page-Site nach Design-Handoff: Hero mit scroll-gesteuertem Bus, Stats, Leistungen, Foto-Band, Flotte, interaktive MapLibre-Karte, Karriere, Footer - Vollständige Handy-Optimierung: Hamburger-Menü, responsive Grids, touch-freundliche Karte - Bus-Foto lokal in assets/ Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2c624d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# OS / editor +.DS_Store +Thumbs.db +*.log + +# Claude Code harness config (local) +.claude/ + +# Dependencies (falls später ein Build dazukommt) +node_modules/ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a323e83 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Unger Reisen — Website + +Statische One-Page-Website für das Omnibusunternehmen **Unger Reisen** (Buch am Erlbach & Landshut, Niederbayern). + +## Aufbau + +``` +index.html # Markup aller Sektionen +css/style.css # Styles, Design-Tokens, Animationen, Mobile-Optimierung +js/main.js # Daten-Rendering, Scroll-Bus, Mobil-Menü, MapLibre-Karte +assets/ # Bilder (Bus-Foto) +design_handoff_unger_reisen/ # Original-Design-Handoff (Referenz) +``` + +## Lokal starten + +Beliebiger statischer Server, z. B.: + +```bash +npx serve -l 4173 . +``` + +Dann `http://localhost:4173` öffnen. + +## Hinweise + +- Fonts (Anton, Archivo) und die Karte (MapLibre GL JS + Carto „dark-matter") werden per CDN geladen. + Für DSGVO-konformen Betrieb ggf. selbst hosten. +- Rechtsseiten (Impressum, Datenschutz, AGB) sind noch als Platzhalter verlinkt. diff --git a/assets/startslider-bus.png b/assets/startslider-bus.png new file mode 100644 index 0000000..b9bfd80 Binary files /dev/null and b/assets/startslider-bus.png differ diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..29dafc5 --- /dev/null +++ b/css/style.css @@ -0,0 +1,544 @@ +/* ========================================================= + Unger Reisen — Website Redesign + Design tokens, layout, components & motion + ========================================================= */ + +*{ margin:0; padding:0; box-sizing:border-box; } + +:root{ + --red:#C8102E; /* primary accent */ + --ink:#0a0e18; /* page bg (near-black navy) */ + --night:#0a0e18; + --paper:#f4f0e8; /* light text / light cards */ + --cream:#efe9dc; + --card:#111725; /* dark card surface */ + --card-hover:#151d30; + --line:rgba(255,255,255,.14); + --muted:#8b93a7; + --body:#c8cdda; /* secondary body text on dark */ +} + +html{ scroll-behavior:smooth; } + +body{ + background:var(--ink); + color:var(--paper); + font-family:'Archivo', system-ui, sans-serif; + -webkit-font-smoothing:antialiased; + line-height:1.5; + overflow-x:hidden; +} + +a{ color:inherit; text-decoration:none; } +::selection{ background:var(--red); color:#fff; } + +.display{ + font-family:'Anton', sans-serif; + font-weight:400; + text-transform:uppercase; + letter-spacing:.5px; + line-height:.9; +} + +.text-red{ color:var(--red); } + +/* ---------- Buttons ---------- */ +.btn{ + display:inline-flex; + align-items:center; + justify-content:center; + gap:10px; + font-size:15px; + font-weight:700; + text-transform:uppercase; + letter-spacing:1px; + padding:17px 34px; + transition:transform .2s ease, border-color .2s ease, background .2s ease; + cursor:pointer; +} +.btn--red{ background:var(--red); color:#fff; } +.btn--red:hover{ transform:translateY(-2px); } +.btn--outline{ border:1.5px solid rgba(255,255,255,.3); color:#fff; padding:17px 30px; } +.btn--outline:hover{ border-color:#fff; } +.btn--white{ background:#fff; color:var(--ink); padding:17px 34px; white-space:nowrap; } +.btn--white:hover{ transform:translateY(-2px); } +.btn--block{ width:100%; padding:16px; } + +/* ---------- Marquee ---------- */ +.marquee{ + background:var(--red); + color:#fff; + overflow:hidden; + white-space:nowrap; + padding:9px 0; + position:relative; + z-index:60; + font-weight:700; + font-size:13px; + letter-spacing:2px; + text-transform:uppercase; +} +.marquee__track{ + display:inline-flex; + animation:marquee 24s linear infinite; + will-change:transform; +} +.marquee__group{ display:inline-flex; } +.marquee__item{ padding:0 26px; } +.marquee__sep{ opacity:.55; } + +/* ---------- Nav ---------- */ +.nav{ + position:absolute; + top:39px; left:0; right:0; + z-index:50; + display:flex; + align-items:center; + justify-content:space-between; + padding:22px clamp(20px,5vw,60px); + transition:background .35s ease, padding .35s ease, box-shadow .35s ease, top .35s ease; +} +.nav.is-scrolled{ + background:rgba(10,14,24,.9); + -webkit-backdrop-filter:blur(12px); + backdrop-filter:blur(12px); + top:0; + padding:14px clamp(20px,5vw,60px); + box-shadow:0 1px 0 rgba(255,255,255,.08); +} + +.logo{ display:flex; align-items:baseline; gap:9px; } +.logo__name{ font-size:26px; color:#fff; } +.logo__name--lg{ font-size:30px; } +.logo__sub{ font-weight:700; font-size:11px; letter-spacing:5px; color:var(--red); } + +.nav__right{ display:flex; align-items:center; gap:34px; } +.nav__links{ + display:flex; gap:28px; + font-size:13.5px; font-weight:600; + text-transform:uppercase; letter-spacing:1px; +} +.nav__links a{ transition:color .2s ease; } +.nav__links a:hover{ color:var(--red); } +.nav__cta{ font-size:13px; padding:12px 24px; } + +/* Hamburger (mobile only) */ +.nav__burger{ + display:none; + flex-direction:column; align-items:center; justify-content:center; + gap:5px; width:42px; height:42px; padding:0; + background:transparent; border:0; cursor:pointer; + position:relative; z-index:55; +} +.nav__burger span{ + display:block; width:24px; height:2px; background:#fff; border-radius:2px; + transition:transform .3s ease, opacity .2s ease; +} +.nav__burger.is-open span:nth-child(1){ transform:translateY(7px) rotate(45deg); } +.nav__burger.is-open span:nth-child(2){ opacity:0; } +.nav__burger.is-open span:nth-child(3){ transform:translateY(-7px) rotate(-45deg); } + +/* Mobile menu overlay */ +.mobileMenu{ + display:none; + position:fixed; inset:0; z-index:45; + background:rgba(10,14,24,.98); + -webkit-backdrop-filter:blur(10px); backdrop-filter:blur(10px); + flex-direction:column; gap:2px; + padding:88px 24px 40px; +} +.mobileMenu.is-open{ display:flex; } +.mobileMenu a:not(.btn){ + font-size:22px; font-weight:700; text-transform:uppercase; letter-spacing:1px; + color:#fff; padding:18px 4px; border-bottom:1px solid var(--line); +} +.mobileMenu__cta{ margin-top:24px; } + +/* ---------- Hero ---------- */ +.hero{ + position:relative; + min-height:88vh; + display:flex; + flex-direction:column; + justify-content:center; + overflow:hidden; + background:radial-gradient(120% 90% at 70% 10%, #182238 0%, #0a0e18 60%); +} +.hero__type{ + position:relative; + z-index:20; + padding:150px clamp(20px,5vw,60px) 0; + pointer-events:none; +} +.eyebrow{ + display:inline-flex; align-items:center; gap:12px; + font-size:12px; font-weight:700; letter-spacing:3px; + text-transform:uppercase; color:var(--red); + margin-bottom:22px; pointer-events:auto; +} +.eyebrow__rule{ width:34px; height:2px; background:var(--red); } +.hero__h1{ + font-size:clamp(64px,13vw,190px); + color:#fff; letter-spacing:-1px; +} +.hero__lead{ + max-width:520px; margin-top:26px; + font-size:clamp(16px,1.5vw,19px); + color:var(--body); pointer-events:auto; +} +.hero__actions{ + display:flex; flex-wrap:wrap; gap:14px; + margin-top:34px; pointer-events:auto; +} + +/* ---------- Driving scene ---------- */ +.scene{ + position:relative; + z-index:10; + height:clamp(280px,42vh,420px); + margin-top:auto; + width:100%; +} +.hillsFar{ + position:absolute; bottom:64px; left:0; right:0; height:120px; + background-repeat:repeat-x; background-size:800px 120px; + opacity:.4; animation:scrollFar 40s linear infinite; + background-image: + radial-gradient(120px 120px at 120px 120px, #223052 0 99%, transparent 100%), + radial-gradient(160px 160px at 460px 150px, #1c2743 0 99%, transparent 100%), + radial-gradient(120px 120px at 700px 130px, #223052 0 99%, transparent 100%); +} +.hills{ + position:absolute; bottom:58px; left:0; right:0; height:90px; + background-repeat:repeat-x; background-size:360px 90px; + opacity:.7; animation:scrollx 18s linear infinite; + background-image: + radial-gradient(90px 90px at 70px 100px, #2a3a5f 0 99%, transparent 100%), + radial-gradient(120px 120px at 250px 120px, #26355699 0 99%, transparent 100%); +} +.road{ + position:absolute; bottom:0; left:0; right:0; height:64px; + background:#11151f; border-top:3px solid #1c2233; +} +.stripes{ + position:absolute; bottom:29px; left:0; right:0; height:5px; + background-repeat:repeat-x; background-size:120px 5px; + animation:scrollxFast .5s linear infinite; + background-image:linear-gradient(90deg, rgba(228,0,43,.9) 0 60px, transparent 60px 120px); +} + +/* ---------- Bus ---------- */ +.bus{ + position:absolute; bottom:52px; left:0; + width:clamp(380px,52vw,620px); + will-change:transform; + transform:translateX(-30%); +} +.bus__headlight{ + position:absolute; right:-40px; bottom:6px; width:120px; height:60px; + background:radial-gradient(ellipse at left center, rgba(255,220,120,.5), transparent 70%); + animation:headlightPulse 2s ease-in-out infinite; +} +.bus__body{ + position:relative; + height:clamp(120px,17vw,178px); + background:linear-gradient(180deg,#f2f0ea 0%,#dfdbd0 100%); + border-radius:22px 30px 12px 12px; + box-shadow:0 18px 30px -10px rgba(0,0,0,.6); + overflow:hidden; +} +.bus__stripe{ position:absolute; bottom:24%; left:0; right:0; height:15%; background:var(--red); } +.bus__windshield{ + position:absolute; top:14%; right:5%; width:20%; height:42%; + background:linear-gradient(120deg,#8fb4d8,#4d6f96); + border-radius:6px 14px 6px 6px; +} +.bus__windows{ + position:absolute; top:14%; left:6%; width:66%; height:42%; border-radius:6px; + background-color:#4d6f96; + background-image:linear-gradient(90deg, transparent 0 3%, #6f92b8 3% 15%, transparent 15% 18%); + background-size:16.6% 100%; background-repeat:repeat-x; +} +.bus__brand{ + position:absolute; bottom:6%; left:7%; + font-size:clamp(11px,1.5vw,15px); color:var(--ink); letter-spacing:1px; +} +.bus__door{ + position:absolute; top:14%; right:27%; width:8%; height:70%; + background:#37506f; border-radius:4px; +} +.wheel{ + position:absolute; bottom:-14px; + width:clamp(42px,6vw,60px); height:clamp(42px,6vw,60px); + border-radius:50%; background:#14181f; border:6px solid #23262e; + animation:wheel .5s linear infinite; +} +.wheel--front{ left:16%; } +.wheel--rear{ right:18%; } +.wheel__hub{ position:absolute; inset:34%; background:#3a3f49; border-radius:50%; } +.wheel__spoke{ + position:absolute; top:2px; left:50%; width:2px; height:40%; + background:#454a55; transform:translateX(-50%); +} + +/* ---------- Stats band ---------- */ +.stats{ + background:var(--red); color:#fff; + padding:clamp(34px,5vw,54px) clamp(20px,5vw,60px); +} +.stats__grid{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); + gap:30px; +} +.stat__big{ font-size:clamp(44px,6vw,72px); color:#fff; line-height:.85; } +.stat__label{ font-size:14px; font-weight:600; margin-top:10px; color:rgba(255,255,255,.85); } + +/* ---------- Generic section ---------- */ +.section{ padding:clamp(70px,10vw,130px) clamp(20px,5vw,60px); } +.section--karriere{ padding:clamp(60px,8vw,110px) clamp(20px,5vw,60px); } + +.section__head{ + display:flex; align-items:flex-end; justify-content:space-between; + flex-wrap:wrap; gap:20px; margin-bottom:54px; +} +.section__head--stack{ display:block; margin-bottom:48px; } +.section__title{ font-size:clamp(40px,7vw,90px); color:#fff; } +.section__title--wide{ max-width:12ch; } +.section__intro{ max-width:320px; color:var(--muted); font-size:16px; } + +.eyebrow-label{ + font-size:12px; font-weight:700; letter-spacing:3px; + text-transform:uppercase; color:var(--red); margin-bottom:16px; +} + +/* ---------- Leistungen ---------- */ +.services{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); + gap:16px; +} +.service{ + position:relative; + background:var(--card); + border:1px solid var(--line); + padding:38px 30px 34px; + min-height:260px; + display:flex; flex-direction:column; overflow:hidden; + transition:background .3s ease, transform .3s ease; +} +.service:hover{ background:var(--card-hover); transform:translateY(-4px); } +.service__no{ font-size:60px; color:rgba(228,0,43,.9); line-height:.8; } +.service__title{ + font-size:22px; font-weight:800; margin:auto 0 10px; + text-transform:uppercase; letter-spacing:.3px; +} +.service__desc{ font-size:15px; color:var(--muted); line-height:1.55; } + +/* ---------- Photo band ---------- */ +.photo{ + position:relative; height:clamp(320px,48vw,520px); + overflow:hidden; display:flex; align-items:center; justify-content:center; + text-align:center; +} +.photo--fallback{ background:linear-gradient(135deg,#151d30,#0a0e18); } +.photo__img{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; object-position:center; } +.photo__overlay{ position:absolute; inset:0; background:linear-gradient(180deg, rgba(10,14,24,.55), rgba(10,14,24,.75)); } +.photo__text{ position:relative; z-index:2; padding:20px; } +.photo__headline{ font-size:clamp(30px,5vw,64px); color:#fff; max-width:16ch; margin:0 auto; } + +/* ---------- Flotte ---------- */ +.fleet{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); + gap:16px; +} +.fleetCard{ border:1px solid var(--line); background:var(--card); display:flex; flex-direction:column; } +.fleetCard__head{ + padding:34px 30px 22px; border-bottom:1px solid var(--line); + display:flex; align-items:center; justify-content:space-between; gap:16px; +} +.fleetCard__name{ font-size:30px; color:#fff; } +.fleetCard__seats{ display:flex; align-items:baseline; gap:8px; white-space:nowrap; } +.fleetCard__seatsWord{ font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:1px; } +.fleetCard__seatsNum{ font-size:40px; color:var(--red); line-height:1; } +.fleetCard__body{ padding:24px 30px 30px; } +.fleetCard__desc{ font-size:15px; color:var(--muted); line-height:1.55; margin-bottom:20px; } +.fleetCard__tags{ display:flex; flex-wrap:wrap; gap:8px; } +.tag{ font-size:12px; font-weight:600; border:1px solid var(--line); padding:6px 13px; color:var(--body); } + +/* ---------- Kontakt + Karte ---------- */ +.kontakt__grid{ + display:grid; + grid-template-columns:1.35fr .95fr; + gap:20px; align-items:stretch; +} +.mapPanel{ + position:relative; border:1px solid var(--line); background:#0d1420; + overflow:hidden; min-height:440px; border-radius:2px; +} +.mapPanel__map{ position:absolute; inset:0; } +.mapPanel__chip{ + position:absolute; left:16px; bottom:16px; z-index:2; pointer-events:none; + background:rgba(10,14,24,.85); -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px); + border:1px solid var(--line); padding:10px 14px; + display:flex; align-items:center; gap:10px; +} +.mapPanel__km{ font-size:20px; color:var(--red); } +.mapPanel__chipLabel{ + font-size:11px; color:#9aa6c0; text-transform:uppercase; + letter-spacing:1px; font-weight:700; line-height:1.2; +} + +.contactCard{ + border:1px solid var(--line); background:var(--card); + padding:clamp(28px,3vw,40px); + display:flex; flex-direction:column; gap:30px; +} +.contactCard__row{ display:flex; gap:16px; } +.dot{ width:14px; height:14px; border-radius:50%; flex-shrink:0; margin-top:4px; } +.dot--white{ background:#fff; border:4px solid var(--red); } +.dot--red{ background:var(--red); border:4px solid rgba(255,255,255,.25); } +.contactCard__label{ + font-size:11px; font-weight:800; letter-spacing:2px; + text-transform:uppercase; color:var(--red); margin-bottom:6px; +} +.contactCard__addr{ font-size:17px; font-weight:700; color:#fff; line-height:1.4; } +.contactCard__tel{ display:inline-block; margin-top:8px; font-size:15px; font-weight:600; color:var(--body); } +.contactCard__divider{ height:1px; background:var(--line); } +.contactCard__actions{ margin-top:auto; display:flex; flex-direction:column; gap:12px; } + +/* ---------- Karriere ---------- */ +.karriere{ + border:1px solid var(--line); padding:clamp(34px,5vw,60px); + display:flex; justify-content:space-between; align-items:center; + flex-wrap:wrap; gap:28px; +} +.karriere__text{ max-width:600px; } +.karriere__title{ font-size:clamp(34px,5vw,62px); color:#fff; margin-bottom:14px; } +.karriere__lead{ font-size:16px; color:var(--muted); } + +/* ---------- Footer ---------- */ +.footer{ + border-top:1px solid var(--line); + padding:clamp(50px,6vw,80px) clamp(20px,5vw,60px) 40px; +} +.footer__grid{ + display:grid; grid-template-columns:1.4fr 1fr 1fr; gap:40px; + padding-bottom:44px; border-bottom:1px solid var(--line); +} +.footer__logo{ margin-bottom:18px; } +.footer__blurb{ font-size:15px; color:var(--muted); max-width:300px; } +.footer__col{ font-size:14.5px; color:var(--muted); line-height:1.9; } +.footer__col a{ color:var(--muted); } +.footer__colHead{ + color:#fff; font-weight:700; margin-bottom:12px; + letter-spacing:1.5px; font-size:12px; text-transform:uppercase; +} +.footer__bar{ + display:flex; justify-content:space-between; flex-wrap:wrap; gap:14px; + padding-top:24px; font-size:13px; color:#5a6274; +} +.footer__legal{ display:flex; gap:22px; } +.footer__legal a{ color:#5a6274; } + +/* ---------- Reveal on scroll ---------- */ +[data-reveal]{ + opacity:0; transform:translateY(28px); + transition:opacity .7s cubic-bezier(.2,.7,.2,1), transform .7s cubic-bezier(.2,.7,.2,1); +} +[data-reveal].is-visible{ opacity:1; transform:none; } + +/* ---------- Responsive ---------- */ +@media (max-width:980px){ + .kontakt__grid{ grid-template-columns:1fr; } + .footer__grid{ grid-template-columns:1fr; gap:30px; } + .section__head{ margin-bottom:44px; } +} + +@media (max-width:680px){ + /* Nav → fixed mobile header + hamburger */ + .marquee{ display:none; } + .nav, .nav.is-scrolled{ + position:fixed; top:0; left:0; right:0; + background:rgba(10,14,24,.95); + -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); + padding:12px 20px; + box-shadow:0 1px 0 rgba(255,255,255,.08); + } + .nav__links{ display:none; } + .nav__cta{ display:none; } + .nav__burger{ display:flex; } + + /* Hero */ + .hero{ min-height:auto; } + .hero__type{ padding:104px 20px 0; } + .hero__h1{ font-size:clamp(44px,12vw,80px); } + .hero__lead{ font-size:16px; margin-top:20px; } + .hero__actions{ margin-top:28px; } + .scene{ height:clamp(200px,32vh,300px); } + .bus{ width:clamp(240px,62vw,440px); } + .bus__body{ height:clamp(80px,15vw,150px); } + .wheel{ width:clamp(34px,9vw,52px); height:clamp(34px,9vw,52px); } + + /* Stats — 2 per row */ + .stats{ padding:36px 20px; } + .stats__grid{ grid-template-columns:repeat(2,1fr); gap:22px; } + .stat__big{ font-size:clamp(40px,13vw,58px); } + + /* Sections */ + .section{ padding:64px 20px; } + .section--karriere{ padding:56px 20px; } + .section__head{ flex-direction:column; align-items:flex-start; gap:14px; margin-bottom:34px; } + .section__intro{ max-width:none; } + .section__title{ font-size:clamp(38px,10vw,60px); } + + /* Photo band */ + .photo__headline{ font-size:clamp(28px,8vw,44px); } + + /* Map + contact */ + .mapPanel{ min-height:300px; } + + /* Karriere */ + .karriere{ flex-direction:column; align-items:flex-start; padding:32px 22px; } + .karriere__cta{ width:100%; } + + /* Footer */ + .footer__bar{ flex-direction:column; gap:10px; } +} + +@media (max-width:480px){ + .hero__actions{ flex-direction:column; align-items:stretch; } + .hero__actions .btn{ width:100%; } + .fleetCard__head{ flex-wrap:wrap; gap:10px; } + .stats__grid{ gap:18px 20px; } +} + +/* ---------- MapLibre marker labels ---------- */ +.pin{ display:flex; flex-direction:column; align-items:center; gap:6px; } +.pin__label{ + background:rgba(10,14,24,.9); border:1px solid rgba(255,255,255,.16); + padding:5px 10px; white-space:nowrap; text-align:center; border-radius:2px; +} +.pin__title{ + font-family:'Anton',sans-serif; text-transform:uppercase; color:#fff; + font-size:14px; letter-spacing:.5px; line-height:1.05; +} +.pin__sub{ + font-family:'Archivo',sans-serif; font-size:9px; font-weight:700; + letter-spacing:1.5px; text-transform:uppercase; color:#9aa6c0; margin-top:2px; +} +.pin__dot{ width:18px; height:18px; border-radius:50%; } + +/* ---------- Keyframes ---------- */ +@keyframes scrollx{ to{ background-position-x:-360px; } } +@keyframes scrollxFast{ to{ background-position-x:-120px; } } +@keyframes scrollFar{ to{ background-position-x:-800px; } } +@keyframes wheel{ to{ transform:rotate(360deg); } } +@keyframes marquee{ to{ transform:translateX(-50%); } } +@keyframes headlightPulse{ 0%,100%{ opacity:.55; } 50%{ opacity:.9; } } + +@media (prefers-reduced-motion:reduce){ + .hills,.hillsFar,.stripes,.marquee__track,.wheel,.bus__headlight{ animation:none !important; } + html{ scroll-behavior:auto; } +} diff --git a/design_handoff_unger_reisen/README.md b/design_handoff_unger_reisen/README.md new file mode 100644 index 0000000..8c90e6d --- /dev/null +++ b/design_handoff_unger_reisen/README.md @@ -0,0 +1,245 @@ +# Handoff: Unger Reisen — Website-Redesign (One-Pager) + +## Overview +A modern, bold one-page marketing website for **Unger Reisen**, an omnibus (coach/bus) +company from Niederbayern (Buch am Erlbach & Landshut, Germany). The page pitches their +services (coach hire, corporate/club trips, transfers), shows the fleet, displays both +locations on an interactive map, and drives visitors to call. Language: **German**. + +The signature element is a **scroll-driven hero**: a stylized bus drives left→right across +the bottom of the hero as the user scrolls down. + +## About the Design Files +The file in this bundle (`Unger Reisen.dc.html`) is a **design reference created in HTML** — +a working prototype showing the intended look, motion, and behavior. It is **not** production +code to copy verbatim. The task is to **recreate this design in the target codebase's +environment** (React/Next.js, Vue, Astro, plain Vite, etc.) using that project's established +patterns, component library, and conventions. If no codebase exists yet, pick the most +appropriate stack (a React/Next.js + Tailwind setup fits this well) and implement it there. + +> Note on the file format: the prototype is authored as a "Design Component" (`.dc.html`) +> with a small custom template runtime (``, `{{ }}` holes, ``, a +> `class Component extends DCLogic`). **Ignore that runtime.** It is only a prototyping +> harness. Read it as: HTML structure + inline styles + a plain JS class whose `renderVals()` +> returns the data arrays and event handlers. Reimplement with normal components and state. + +## Fidelity +**High-fidelity (hifi).** Final colors, typography, spacing, layout, motion, and copy are all +intentional. Recreate pixel-closely, but re-express the styling in the target system +(e.g. Tailwind classes / styled-components) rather than copying inline styles. + +--- + +## Design Tokens + +### Colors +| Token | Value | Usage | +|---|---|---| +| `--red` (accent) | `#C8102E` | Primary accent. Marquee, stat band, buttons, pins, route, section eyebrows. (Prototype default is `#C8102E`; original palette also used `#E4002B`. Curated options: `#E4002B`, `#D21F1F`, `#C8102E`, `#EE3524`.) | +| `--ink` / `--night` | `#0a0e18` | Page background (near-black navy) | +| `--paper` | `#f4f0e8` | Light text on dark; light card backgrounds | +| Card surface | `#111725` | Dark cards (services, fleet, contact) | +| Card surface (hover) | `#151d30` | Service card hover | +| Map panel bg | `#0d1420` | Map container background | +| `--line` | `rgba(255,255,255,.14)` | Hairline borders/dividers on dark | +| `--muted` | `#8b93a7` | Muted body text | +| Body text on dark | `#c8cdda` | Hero paragraph, secondary text | +| Sub-label text | `#9aa6c0` | Pin sublabels, small caps labels | +| Footer legal text | `#5a6274` / `#40485a` | Footer links & disclaimer | +| Input border (light card) | `#d3ccbe` | Form inputs (note: forms were later removed) | + +### Typography +- **Display font:** `Anton` (Google Fonts). Used for all big headlines, stat numbers, logo, + pin labels. Always `text-transform: uppercase`, `letter-spacing: .5px`, `line-height: .9`. +- **Body/UI font:** `Archivo` (Google Fonts), weights 400–900. Body, nav, labels, buttons. +- Google Fonts import: + `https://fonts.googleapis.com/css2?family=Anton&family=Archivo:wght@400;500;600;700;800;900&display=swap` +- Type scale (fluid, `clamp`): + - Hero H1: `clamp(64px, 13vw, 190px)` + - Section H2 (Anton): `clamp(40px, 7vw, 90–96px)` + - Stat numbers: `clamp(44px, 6vw, 72px)` + - Fleet seat number: `40px` + - Body: `16–19px`; nav/labels/buttons: `12–15px` uppercase, `letter-spacing 1–3px` + +### Spacing / Layout +- Section vertical padding: `clamp(70px, 10vw, 130px)`; horizontal: `clamp(20px, 5vw, 60px)` +- Card grid gaps: `16px`; large column gaps: `clamp(30px, 5vw, 70px)` +- Border radius: mostly **sharp** (0–2px). Buttons are rectangular (no pill). Only pins/dots are circular. + +### Shadows +- Bus body: `0 18px 30px -10px rgba(0,0,0,.6)` +- Map pin dot: `0 0 0 5px 40, 0 2px 6px rgba(0,0,0,.55)` (red glow ring + drop) +- Buttons lift on hover via `transform: translateY(-2px)` (transition `.2s ease`) + +--- + +## Screens / Views +Single continuous page. Sections top→bottom: + +### 1. Top Marquee +- Full-width red (`--red`) bar, white uppercase text scrolling right→left infinitely. +- Content, `/`-separated: "Reisebusse mit Fahrer / Firmen & Vereine / Tagesfahrten / + Transfers rund um die Uhr / Niederbayern". +- Implementation: duplicate the track and animate `translateX(0 → -50%)` linear infinite (~24s). +- `font-size:13px; font-weight:700; letter-spacing:2px`. + +### 2. Nav (fixed/absolute over hero) +- Left: logo — "UNGER" (Anton, 26px, white) + "REISEN" (Archivo, 11px, letter-spacing 5px, red). +- Right: links (uppercase, 13.5px, weight 600) — Leistungen, Flotte, Karriere, Kontakt — + plus a red CTA button **"Jetzt anrufen"** (`href="tel:+498762896"`). +- Sits at `top:39px` (below marquee) when at page top; **on scroll >40px** it solidifies: + background `rgba(10,14,24,.9)`, `backdrop-filter: blur(12px)`, moves to `top:0`, reduces + padding, adds a hairline bottom shadow. Transition `.35s ease`. + +### 3. Hero (`#top`, min-height:88vh) +- Dark radial-gradient bg: `radial-gradient(120% 90% at 70% 10%, #182238 0%, #0a0e18 60%)`. +- Eyebrow: red rule + "OMNIBUSUNTERNEHMEN SEIT GENERATIONEN". +- H1 (Anton): "Wir bringen / Sie **weiter.**" — "weiter." in red. +- Paragraph (max 520px, `#c8cdda`): "Moderne Reisebusse mit erfahrenen Fahrern — für Firmen, + Vereine und Gruppen in ganz Bayern und Europa." +- Buttons: red **"Jetzt anrufen →"** (`tel:`), outline **"Unsere Flotte"** (`#flotte`). +- **Driving scene** (bottom of hero, `height: clamp(280px,42vh,420px)`, pinned bottom): + - Layered parallax: far scenery hills, near hills, a dark road strip, red-dashed lane stripes. + Each is a repeating radial/linear-gradient background animated via `background-position-x` + at different speeds (far slow ~40s, near ~18s, stripes fast ~.5s) — evokes forward motion. + - **The bus**: CSS-drawn side view — cream body with red mid-stripe, windshield, window strip, + "UNGER REISEN" wordmark, door, two spinning wheels (`@keyframes wheel` rotate, .5s linear), + a pulsing headlight glow. Bus width `clamp(380px,52vw,620px)`. + - **Scroll-driven horizontal motion:** on scroll, compute + `hp = clamp(scrollY / (heroHeight * 0.9), 0, 1)` and set the bus + `transform: translate(x, bob)` where `x` interpolates from just off-left + (`-busWidth*0.30`) to `sceneWidth - busWidth*0.70`, and `bob = sin(now/380)*4` (gentle + vertical bob; 0 if reduced-motion). Driven by `requestAnimationFrame`. + - Respect `prefers-reduced-motion`: disable marquee, hills, stripes, wheel spin, and bob. + +### 4. Stat Band +- Full-width red bg, white. Auto-fit grid of 4 stats (Anton big number + label): + - **2** — Standorte in Bayern + - **60+** — Sitzplätze pro Reisebus + - **24/7** — Für Sie erreichbar + - **∞** — Ziele in ganz Europa + +### 5. Leistungen (`#leistungen`) +- Header row: H2 "Das fahren wir für Sie" + intro paragraph. +- Auto-fit grid (min 260px) of 4 dark cards (`#111725`, hairline border, min-height 260px). + Each: big red Anton index (01–04), title (uppercase 22px/800), description. Hover: bg + `#151d30` + `translateY(-4px)`. + 1. **Busanmietung** — "Moderne Reisebusse mit Fahrer — flexibel für einzelne Fahrten oder mehrtägige Touren." + 2. **Firmenreisen** — "Betriebsausflüge, Tagungen und Events. Ihr Team sicher und entspannt am Ziel." + 3. **Vereinsfahrten** — "Ausflüge, Feste und Sportevents — komfortabel für die ganze Gruppe." + 4. **Transfers** — "Flughafen-, Hotel- und Event-Transfers. Pünktlich und rund um die Uhr planbar." + +### 6. Real Photo Band +- Full-bleed band (`height: clamp(320px,48vw,520px)`) with a background bus photo, dark + gradient overlay (`rgba(10,14,24,.55 → .75)`), centered Anton headline: + "Komfort, auf den Sie sich verlassen können." +- Prototype image URL: `https://www.unger-reisen.de/wp-content/uploads/startslider-bus-1024x366.png` + (external; **replace with a licensed/owned photo** — see Assets). Has an `onerror` fallback + to a gradient. + +### 7. Flotte (`#flotte`) +- Eyebrow "Unsere Flotte" + H2 "Für jede Gruppengröße". +- Auto-fit grid (min 280px) of 3 cards. Card header row: name (Anton 30px) on the left, and a + right-aligned inline seat unit: small "bis" + big red number (Anton 40px) + small "Sitze". + Body: description + tag chips (hairline-bordered pills of text). + - **Reisebus** — bis **60** — "Der Große für lange Strecken und große Gruppen — voll ausgestattet." Tags: Klimaanlage, WC, Bordküche, WLAN. + - **Midibus** — bis **35** — "Wendig und komfortabel — ideal für mittlere Gruppen und Tagestouren." Tags: Klimaanlage, Komfortbestuhlung. + - **Kleinbus** — bis **19** — "Persönlich und flexibel für kleine Gruppen, Transfers und Sonderfahrten." Tags: Flexibel, Wendig. + +### 8. Kontakt + Karte (`#kontakt`) +- Eyebrow "So erreichen Sie uns" + H2 "Zwei Standorte. / Ein Anruf." +- Two-column grid (`1.35fr / .95fr`, stack on mobile): + - **Left — interactive map** (panel `min-height:440px`, bg `#0d1420`, hairline border): + - **MapLibre GL JS** map, dark Carto style + `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json`. + - Two custom HTML markers (`anchor:'bottom'`): a label box (Anton title + Archivo sub) above + a circular dot with a red glow ring. + - **Landshut** — "Niederlassung" — coord `[12.1508, 48.5449]` — dot red, ring white. + - **Buch a. Erlbach** — "Hauptsitz" — coord `[12.0517, 48.4703]` — dot white, ring red. + - **Route line** between the two coords: a GeoJSON `LineString`, red, `width 3`, + `line-dasharray [1.6,1.4]`, plus a wider low-opacity casing (`width 9, opacity .18`). + - `NavigationControl` (zoom + compass) top-right. **`scrollZoom` disabled** so page scroll + isn't hijacked. `fitBounds` to both points with padding `{top:96,bottom:70,left:70,right:70}`, + `maxZoom:12`. Call `map.resize()` shortly after load. + - Overlay chip (bottom-left, `pointer-events:none`): "**15 km** zwischen den Standorten". + - Requires `maplibre-gl` (JS + CSS). In prototype loaded from + `https://unpkg.com/maplibre-gl@4.7.1/...`; in a real app install the npm package. + Map tiles load **online** — expected for a live site. + - **Right — contact card** (`#111725`, hairline border): + - **Hauptsitz** (white dot / red ring): "Eichenstr. 7+9, Vatersdorf · 84172 Buch am Erlbach" + · Tel. +49 (0)8762 / 896 + - divider + - **Niederlassung** (red dot / white ring): "Jenaer Straße 4a · 84034 Landshut" + · Tel. +49 (0)871 / 96 58 586-0 + - CTAs pinned to bottom: red **"Jetzt anrufen"** (`tel:+498762896`) + outline + **"info@unger-reisen.de"** (`mailto:`). + +### 9. Karriere (`#karriere`) +- Bordered band. Left: eyebrow "Karriere & Jobs", H2 "Fahren Sie mit uns", paragraph + "Wir suchen Busfahrer:innen, Begleitpersonen und Sachbearbeiter:innen — steigen Sie ein." +- Right: white button **"Offene Stellen →"** (`mailto:info@unger-reisen.de`). + +### 10. Footer +- 3-column grid on `--ink`, hairline top border: + - Logo + blurb: "Günter Unger e.K. — Omnibus- & Verkehrsunternehmen. Reisen erster Klasse aus Niederbayern." + - **Hauptsitz**: Eichenstr. 7+9, Vatersdorf · 84172 Buch am Erlbach · Tel. +49 (0)8762 / 896 + - **Niederlassung**: Jenaer Straße 4a · 84034 Landshut · Tel. +49 (0)871 / 96 58 586-0 +- Bottom bar: "© 2026 Unger Reisen · Alle Rechte vorbehalten" + Impressum / Datenschutz / AGB. +- Small disclaimer: "Konzept-Entwurf für ein mögliches Website-Redesign · noch nicht offiziell." + (**Remove this line** once it's the real site.) + +--- + +## Interactions & Behavior +- **Nav solidify on scroll** (>40px) — see §2. +- **Scroll-driven bus** across hero — see §3. Core formula: + `hp = clamp(scrollY/(heroHeight*0.9),0,1)`; `x = lerp(-busW*0.3, sceneW - busW*0.7, hp)`. +- **Reveal on scroll**: elements marked for reveal start `opacity:0; translateY(28px)` and + animate to `opacity:1; none` via IntersectionObserver (threshold ~0.12), transition + `.7s cubic-bezier(.2,.7,.2,1)`. Provide a safety fallback that reveals everything after ~2.5s. +- **Marquee / hills / stripes / wheels**: continuous CSS keyframe loops (see §1, §3). +- **Buttons/links hover**: `translateY(-2px)` or border color → white. +- **Map**: pan/zoom via drag + control buttons; scroll-zoom disabled; compass resets bearing. +- **Smooth anchor scrolling** for nav links (`scroll-behavior: smooth`) to `#leistungen`, + `#flotte`, `#karriere`, `#kontakt`, `#top`. +- **Reduced motion**: disable all decorative animation; keep static end states. + +## State Management +Minimal — the page is largely presentational. In a component framework you need: +- Nav-scrolled boolean (or do it with a scroll listener / IntersectionObserver on a sentinel). +- The bus `x`/`bob` transform driven by a scroll + rAF loop (a ref + effect). +- Reveal-on-scroll via IntersectionObserver (a small reusable hook/directive). +- The MapLibre instance held in a ref; init in mount effect, `remove()` on unmount. +- Data arrays for **stats**, **services**, **fleet** (see copy above) — render with `.map`. +- (An earlier version had a controlled contact form; it was **removed** in favor of call/email CTAs. + If you re-add a form, wire name/contact/dest/date/pax/message + a submitted state.) + +## Configurable knobs (from the prototype's props) +- **accentColor** — the red. Default `#C8102E`; options `#E4002B / #D21F1F / #C8102E / #EE3524`. + Applied as a CSS variable `--red`; also recolors the map route on change. Expose as a theme + token in the real app. + +## Assets +- **Fonts:** Anton + Archivo (Google Fonts) — see import above. +- **Icons:** none required (all visuals are CSS/SVG-drawn or map markers). The original shadcn + map component referenced `lucide-react` for map control icons — use those (Plus, Minus, Locate, + Maximize, Compass/X) if you build custom controls; MapLibre's built-in `NavigationControl` + needs no icon assets. +- **Bus hero photo (§6):** placeholder points at unger-reisen.de. **Replace with an owned or + licensed photo** of an Unger coach. If you need a temporary stand-in, use a royalty-free coach + photo (e.g. an Unsplash coach/bus image) and swap later. +- **Map:** MapLibre GL JS + CSS (npm `maplibre-gl`), Carto "dark-matter" style (free, attribution + required — keep the attribution control). Tiles fetched at runtime. +- **No logo file** — the "UNGER REISEN" logo is set in type (Anton + Archivo). + +## Files +- `Unger Reisen.dc.html` — the full design prototype (all sections, styles, and JS behavior). + Read the `` body for structure/markup, the ` + + +
+ + +
+
+ + Reisebusse mit Fahrer/ + Firmen & Vereine/ + Tagesfahrten/ + Transfers rund um die Uhr/ + Niederbayern/ + + +
+
+ + + + + +
+ + +
+
+ Omnibusunternehmen seit Generationen +
+

+ Wir bringen
Sie weiter. +

+

+ Moderne Reisebusse mit erfahrenen Fahrern — für Firmen, Vereine und Gruppen in ganz Bayern und Europa. +

+ +
+ + + +
+ + +
+
+ +
+
{{ s.big }}
+
{{ s.label }}
+
+
+
+
+ + +
+
+

Das fahren wir für Sie

+

Ein Ansprechpartner, ein Bus, alles organisiert — von der Anfrage bis zur Rückfahrt.

+
+
+ +
+
{{ s.no }}
+

{{ s.title }}

+

{{ s.desc }}

+
+
+
+
+ + +
+ Unger Reisen Reisebus +
+
+
Komfort, auf den Sie sich verlassen können.
+
+
+ + +
+
+
Unsere Flotte
+

Für jede Gruppengröße

+
+
+ +
+
+

{{ f.name }}

+
+ bis + {{ f.seats }} + Sitze +
+
+
+

{{ f.desc }}

+
+ + {{ t }} + +
+
+
+
+
+
+ + +
+
+
So erreichen Sie uns
+

Zwei Standorte.
Ein Anruf.

+
+ +
+ + +
+
+
+ 15 km + zwischen
den Standorten
+
+
+ + +
+
+
+
+
Hauptsitz
+
Eichenstr. 7+9, Vatersdorf
84172 Buch am Erlbach
+ Tel. +49 (0)8762 / 896 +
+
+ +
+ +
+
+
+
Niederlassung
+
Jenaer Straße 4a
84034 Landshut
+ Tel. +49 (0)871 / 96 58 586-0 +
+
+ + +
+
+
+ + +
+
+
+
Karriere & Jobs
+

Fahren Sie mit uns

+

Wir suchen Busfahrer:innen, Begleitpersonen und Sachbearbeiter:innen — steigen Sie ein.

+
+ Offene Stellen → +
+
+ + +
+
+
+
+ UNGER + REISEN +
+

Günter Unger e.K. — Omnibus- & Verkehrsunternehmen. Reisen erster Klasse aus Niederbayern.

+
+
+
Hauptsitz
+ Eichenstr. 7+9, Vatersdorf
84172 Buch am Erlbach
Tel. +49 (0)8762 / 896 +
+
+
Niederlassung
+ Jenaer Straße 4a
84034 Landshut
Tel. +49 (0)871 / 96 58 586-0 +
+
+
+
© 2026 Unger Reisen · Alle Rechte vorbehalten
+ +
+
Konzept-Entwurf für ein mögliches Website-Redesign · noch nicht offiziell.
+
+
+
+ + + diff --git a/design_handoff_unger_reisen/support.js b/design_handoff_unger_reisen/support.js new file mode 100644 index 0000000..2a774eb --- /dev/null +++ b/design_handoff_unger_reisen/support.js @@ -0,0 +1,1702 @@ +// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time — + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error — kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state — used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see — internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => fetch(url)).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + fetch(url).then((res) => { + if (!res.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res.text(); + }).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..162afec --- /dev/null +++ b/js/main.js @@ -0,0 +1,260 @@ +/* ========================================================= + Unger Reisen — behaviour + - render data-driven sections (stats / services / fleet) + - scroll-driven bus across the hero + - nav solidify on scroll + - reveal-on-scroll (IntersectionObserver) + - interactive MapLibre map (two locations + route) + ========================================================= */ +(function () { + 'use strict'; + + var ACCENT = '#C8102E'; + var reduceMotion = window.matchMedia && + window.matchMedia('(prefers-reduced-motion:reduce)').matches; + + /* ---------- Data ---------- */ + var stats = [ + { big: '2', label: 'Standorte in Bayern' }, + { big: '60+', label: 'Sitzplätze pro Reisebus' }, + { big: '24/7', label: 'Für Sie erreichbar' }, + { big: '∞', label: 'Ziele in ganz Europa' } + ]; + + var services = [ + { no: '01', title: 'Busanmietung', desc: 'Moderne Reisebusse mit Fahrer — flexibel für einzelne Fahrten oder mehrtägige Touren.' }, + { no: '02', title: 'Firmenreisen', desc: 'Betriebsausflüge, Tagungen und Events. Ihr Team sicher und entspannt am Ziel.' }, + { no: '03', title: 'Vereinsfahrten', desc: 'Ausflüge, Feste und Sportevents — komfortabel für die ganze Gruppe.' }, + { no: '04', title: 'Transfers', desc: 'Flughafen-, Hotel- und Event-Transfers. Pünktlich und rund um die Uhr planbar.' } + ]; + + var fleet = [ + { name: 'Reisebus', seats: '60', desc: 'Der Große für lange Strecken und große Gruppen — voll ausgestattet.', tags: ['Klimaanlage', 'WC', 'Bordküche', 'WLAN'] }, + { name: 'Midibus', seats: '35', desc: 'Wendig und komfortabel — ideal für mittlere Gruppen und Tagestouren.', tags: ['Klimaanlage', 'Komfortbestuhlung'] }, + { name: 'Kleinbus', seats: '19', desc: 'Persönlich und flexibel für kleine Gruppen, Transfers und Sonderfahrten.', tags: ['Flexibel', 'Wendig'] } + ]; + + function el(tag, cls, html) { + var n = document.createElement(tag); + if (cls) n.className = cls; + if (html != null) n.innerHTML = html; + return n; + } + + /* ---------- Render sections ---------- */ + function renderStats() { + var grid = document.getElementById('stats-grid'); + if (!grid) return; + stats.forEach(function (s) { + var wrap = el('div', null); + wrap.setAttribute('data-reveal', ''); + wrap.appendChild(el('div', 'display stat__big', s.big)); + wrap.appendChild(el('div', 'stat__label', s.label)); + grid.appendChild(wrap); + }); + } + + function renderServices() { + var grid = document.getElementById('services-grid'); + if (!grid) return; + services.forEach(function (s) { + var card = el('div', 'service'); + card.setAttribute('data-reveal', ''); + card.appendChild(el('div', 'display service__no', s.no)); + card.appendChild(el('h3', 'service__title', s.title)); + card.appendChild(el('p', 'service__desc', s.desc)); + grid.appendChild(card); + }); + } + + function renderFleet() { + var grid = document.getElementById('fleet-grid'); + if (!grid) return; + fleet.forEach(function (f) { + var card = el('div', 'fleetCard'); + card.setAttribute('data-reveal', ''); + + var head = el('div', 'fleetCard__head'); + head.appendChild(el('h3', 'display fleetCard__name', f.name)); + var seats = el('div', 'fleetCard__seats', + 'bis' + + '' + f.seats + '' + + 'Sitze'); + head.appendChild(seats); + card.appendChild(head); + + var body = el('div', 'fleetCard__body'); + body.appendChild(el('p', 'fleetCard__desc', f.desc)); + var tags = el('div', 'fleetCard__tags'); + f.tags.forEach(function (t) { tags.appendChild(el('span', 'tag', t)); }); + body.appendChild(tags); + card.appendChild(body); + + grid.appendChild(card); + }); + } + + /* ---------- Nav solidify on scroll ---------- */ + function initNav() { + var nav = document.getElementById('nav'); + if (!nav) return; + var onScroll = function () { + nav.classList.toggle('is-scrolled', window.scrollY > 40); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + } + + /* ---------- Mobile menu ---------- */ + function initMobileMenu() { + var burger = document.getElementById('nav-burger'); + var menu = document.getElementById('mobile-menu'); + if (!burger || !menu) return; + + function setOpen(open) { + menu.classList.toggle('is-open', open); + burger.classList.toggle('is-open', open); + burger.setAttribute('aria-expanded', open ? 'true' : 'false'); + burger.setAttribute('aria-label', open ? 'Menü schließen' : 'Menü öffnen'); + document.body.style.overflow = open ? 'hidden' : ''; + } + + burger.addEventListener('click', function () { + setOpen(!menu.classList.contains('is-open')); + }); + menu.querySelectorAll('a').forEach(function (a) { + a.addEventListener('click', function () { setOpen(false); }); + }); + window.addEventListener('keydown', function (e) { + if (e.key === 'Escape') setOpen(false); + }); + // Close if we grow past the mobile breakpoint. + window.addEventListener('resize', function () { + if (window.innerWidth > 680) setOpen(false); + }); + } + + /* ---------- Scroll-driven bus ---------- */ + function initBus() { + var bus = document.getElementById('bus'); + var hero = document.getElementById('top'); + if (!bus || !hero) return; + + function tick() { + var hp = Math.min(1, Math.max(0, window.scrollY / (hero.offsetHeight * 0.9))); + var scene = bus.parentNode; + var sceneW = scene ? scene.offsetWidth : window.innerWidth; + var busW = bus.offsetWidth || 400; + var minX = -busW * 0.30; + var maxX = sceneW - busW * 0.70; + var x = minX + (maxX - minX) * hp; + var bob = reduceMotion ? 0 : Math.sin(Date.now() / 380) * 4; + bus.style.transform = 'translate(' + x.toFixed(1) + 'px,' + bob.toFixed(1) + 'px)'; + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + } + + /* ---------- Reveal on scroll ---------- */ + function initReveal() { + var els = Array.prototype.slice.call(document.querySelectorAll('[data-reveal]')); + if ('IntersectionObserver' in window) { + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (e.isIntersecting) { + e.target.classList.add('is-visible'); + io.unobserve(e.target); + } + }); + }, { threshold: 0.12 }); + els.forEach(function (n) { io.observe(n); }); + } else { + els.forEach(function (n) { n.classList.add('is-visible'); }); + } + // Safety fallback: reveal everything after 2.5s regardless. + setTimeout(function () { + els.forEach(function (n) { n.classList.add('is-visible'); }); + }, 2500); + } + + /* ---------- Interactive map ---------- */ + function initMap() { + var HOME = [12.0517, 48.4703]; // Buch am Erlbach / Vatersdorf + var BRANCH = [12.1508, 48.5449]; // Landshut + var container = document.getElementById('unger-map'); + if (!container || typeof maplibregl === 'undefined') return; + + function makePin(fill, ring, title, sub) { + var wrap = el('div', 'pin'); + wrap.appendChild(el('div', 'pin__label', + '
' + title + '
' + + '
' + sub + '
')); + var dot = el('div', 'pin__dot'); + dot.style.background = fill; + dot.style.border = '3px solid ' + ring; + dot.style.boxShadow = '0 0 0 5px ' + ACCENT + '40, 0 2px 6px rgba(0,0,0,.55)'; + wrap.appendChild(dot); + return wrap; + } + + var map = new maplibregl.Map({ + container: container, + style: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', + center: [(HOME[0] + BRANCH[0]) / 2, (HOME[1] + BRANCH[1]) / 2], + zoom: 10.4, + attributionControl: { compact: true } + }); + map.scrollZoom.disable(); + map.addControl(new maplibregl.NavigationControl({ showCompass: true, visualizePitch: false }), 'top-right'); + + // On phones, keep the map static so a one-finger drag scrolls the page + // instead of getting trapped panning the map. + if (window.matchMedia && window.matchMedia('(max-width:680px)').matches) { + map.dragPan.disable(); + if (map.touchZoomRotate) map.touchZoomRotate.disable(); + if (map.touchPitch) map.touchPitch.disable(); + } + + new maplibregl.Marker({ element: makePin(ACCENT, '#fff', 'Landshut', 'Niederlassung'), anchor: 'bottom' }) + .setLngLat(BRANCH).addTo(map); + new maplibregl.Marker({ element: makePin('#fff', ACCENT, 'Buch a. Erlbach', 'Hauptsitz'), anchor: 'bottom' }) + .setLngLat(HOME).addTo(map); + + map.on('load', function () { + map.addSource('unger-route', { + type: 'geojson', + data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [HOME, BRANCH] } } + }); + map.addLayer({ + id: 'unger-route-casing', type: 'line', source: 'unger-route', + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': ACCENT, 'line-width': 9, 'line-opacity': 0.18 } + }); + map.addLayer({ + id: 'unger-route-line', type: 'line', source: 'unger-route', + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': ACCENT, 'line-width': 3, 'line-dasharray': [1.6, 1.4] } + }); + map.fitBounds([HOME, BRANCH], { padding: { top: 96, bottom: 70, left: 70, right: 70 }, duration: 0, maxZoom: 12 }); + setTimeout(function () { try { map.resize(); } catch (e) {} }, 60); + }); + } + + /* ---------- Boot ---------- */ + function init() { + renderStats(); + renderServices(); + renderFleet(); + initNav(); + initMobileMenu(); + initBus(); + initReveal(); + initMap(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();