diff --git a/app/apple-icon.png b/app/apple-icon.png
new file mode 100644
index 0000000..e963573
Binary files /dev/null and b/app/apple-icon.png differ
diff --git a/app/favicon.ico b/app/favicon.ico
index 718d6fe..22c19a3 100644
Binary files a/app/favicon.ico and b/app/favicon.ico differ
diff --git a/app/fonts.ts b/app/fonts.ts
new file mode 100644
index 0000000..a7b8468
--- /dev/null
+++ b/app/fonts.ts
@@ -0,0 +1,21 @@
+import { JetBrains_Mono, Mona_Sans } from "next/font/google";
+
+// next/font downloads the files at build time and serves them from our own
+// domain, so no request ever reaches Google (no extra roundtrip, GDPR-safe).
+
+export const sans = Mona_Sans({
+ subsets: ["latin", "latin-ext"],
+ // The width axis is the main typographic lever (see DESIGN.md §3)
+ axes: ["wdth"],
+ variable: "--font-mona",
+ display: "swap",
+ preload: true,
+});
+
+export const mono = JetBrains_Mono({
+ subsets: ["latin", "latin-ext"],
+ variable: "--font-jetbrains",
+ display: "swap",
+ // Only needed for small labels, not for the LCP element
+ preload: false,
+});
diff --git a/app/globals.css b/app/globals.css
index c56032b..67e4668 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -2,129 +2,474 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
-@custom-variant dark (&:is(.dark *));
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
+
+/* ── Design tokens ─────────────────────────────────────────────────────── */
+
+@theme {
+ /* Ink scale: luminance only, no hue */
+ --color-ink-0: oklch(1 0 0);
+ --color-ink-50: oklch(0.975 0 0);
+ --color-ink-100: oklch(0.945 0 0);
+ --color-ink-200: oklch(0.885 0 0);
+ --color-ink-300: oklch(0.785 0 0);
+ --color-ink-400: oklch(0.665 0 0);
+ --color-ink-500: oklch(0.555 0 0);
+ --color-ink-600: oklch(0.45 0 0);
+ --color-ink-700: oklch(0.35 0 0);
+ --color-ink-800: oklch(0.245 0 0);
+ --color-ink-900: oklch(0.155 0 0);
+ --color-ink-950: oklch(0.085 0 0);
+ --color-ink-1000: oklch(0 0 0);
+
+ --color-signal-error: oklch(0.63 0.21 25);
+ --color-signal-success: oklch(0.72 0.17 150);
+
+ /* Type scale */
+ --text-mega: clamp(3.25rem, 12.5vw, 14rem);
+ --text-mega--line-height: 0.84;
+ --text-display: clamp(2.75rem, 8.5vw, 8.5rem);
+ --text-display--line-height: 0.86;
+ --text-statement: clamp(2rem, 5vw, 4.75rem);
+ --text-statement--line-height: 1;
+ --text-title: clamp(1.5rem, 2.6vw, 2.5rem);
+ --text-title--line-height: 1.1;
+ --text-lead: clamp(1.125rem, 1.5vw, 1.375rem);
+ --text-lead--line-height: 1.5;
+ --text-body: 1.0625rem;
+ --text-body--line-height: 1.6;
+ --text-label: 0.8125rem;
+ --text-label--line-height: 1.4;
+
+ /* Space and layout */
+ --spacing-section: clamp(7rem, 16vh, 14rem);
+ --spacing-gutter: clamp(1.25rem, 4vw, 4rem);
+ --container-site: 90rem;
+ --radius-glass: 1.5rem;
+
+ --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1);
+}
@theme inline {
+ --font-sans: var(--font-mona), ui-sans-serif, system-ui, sans-serif;
+ --font-mono: var(--font-jetbrains), ui-monospace, monospace;
+ --font-heading: var(--font-sans);
+
+ --color-bg: var(--bg);
+ --color-fg: var(--fg);
+ --color-fg-muted: var(--fg-muted);
+ --color-fg-subtle: var(--fg-subtle);
+ --color-line: var(--line);
+ --color-surface: var(--surface);
+
+ /* shadcn/ui bridge */
--color-background: var(--background);
--color-foreground: var(--foreground);
- --font-sans: var(--font-sans);
- --font-mono: var(--font-geist-mono);
- --font-heading: var(--font-sans);
- --color-sidebar-ring: var(--sidebar-ring);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar: var(--sidebar);
- --color-chart-5: var(--chart-5);
- --color-chart-4: var(--chart-4);
- --color-chart-3: var(--chart-3);
- --color-chart-2: var(--chart-2);
- --color-chart-1: var(--chart-1);
- --color-ring: var(--ring);
- --color-input: var(--input);
- --color-border: var(--border);
- --color-destructive: var(--destructive);
- --color-accent-foreground: var(--accent-foreground);
- --color-accent: var(--accent);
- --color-muted-foreground: var(--muted-foreground);
- --color-muted: var(--muted);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-secondary: var(--secondary);
- --color-primary-foreground: var(--primary-foreground);
- --color-primary: var(--primary);
- --color-popover-foreground: var(--popover-foreground);
- --color-popover: var(--popover);
- --color-card-foreground: var(--card-foreground);
--color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
- --radius-3xl: calc(var(--radius) * 2.2);
- --radius-4xl: calc(var(--radius) * 2.6);
+}
+
+/* ── Themes ────────────────────────────────────────────────────────────── */
+
+:root,
+[data-theme="light"] {
+ --bg: var(--color-ink-0);
+ --fg: var(--color-ink-1000);
+ --fg-muted: var(--color-ink-600);
+ --fg-subtle: var(--color-ink-400);
+ --line: var(--color-ink-200);
+ --surface: var(--color-ink-100);
+}
+
+[data-theme="dark"] {
+ --bg: var(--color-ink-1000);
+ --fg: var(--color-ink-0);
+ --fg-muted: var(--color-ink-400);
+ --fg-subtle: var(--color-ink-600);
+ --line: var(--color-ink-800);
+ --surface: var(--color-ink-900);
}
:root {
- --background: oklch(1 0 0);
- --foreground: oklch(0.145 0 0);
- --card: oklch(1 0 0);
- --card-foreground: oklch(0.145 0 0);
- --popover: oklch(1 0 0);
- --popover-foreground: oklch(0.145 0 0);
- --primary: oklch(0.205 0 0);
- --primary-foreground: oklch(0.985 0 0);
- --secondary: oklch(0.97 0 0);
- --secondary-foreground: oklch(0.205 0 0);
- --muted: oklch(0.97 0 0);
- --muted-foreground: oklch(0.556 0 0);
- --accent: oklch(0.97 0 0);
- --accent-foreground: oklch(0.205 0 0);
- --destructive: oklch(0.577 0.245 27.325);
- --border: oklch(0.922 0 0);
- --input: oklch(0.922 0 0);
- --ring: oklch(0.708 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --radius: 0.625rem;
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
+ --radius: 0.75rem;
+ /* Static light position for the glass edge highlight */
+ --light-x: 30%;
+ --light-y: 0%;
}
-.dark {
- --background: oklch(0.145 0 0);
- --foreground: oklch(0.985 0 0);
- --card: oklch(0.205 0 0);
- --card-foreground: oklch(0.985 0 0);
- --popover: oklch(0.205 0 0);
- --popover-foreground: oklch(0.985 0 0);
- --primary: oklch(0.922 0 0);
- --primary-foreground: oklch(0.205 0 0);
- --secondary: oklch(0.269 0 0);
- --secondary-foreground: oklch(0.985 0 0);
- --muted: oklch(0.269 0 0);
- --muted-foreground: oklch(0.708 0 0);
- --accent: oklch(0.269 0 0);
- --accent-foreground: oklch(0.985 0 0);
- --destructive: oklch(0.704 0.191 22.216);
- --border: oklch(1 0 0 / 10%);
- --input: oklch(1 0 0 / 15%);
- --ring: oklch(0.556 0 0);
- --chart-1: oklch(0.87 0 0);
- --chart-2: oklch(0.556 0 0);
- --chart-3: oklch(0.439 0 0);
- --chart-4: oklch(0.371 0 0);
- --chart-5: oklch(0.269 0 0);
- --sidebar: oklch(0.205 0 0);
- --sidebar-foreground: oklch(0.985 0 0);
- --sidebar-primary: oklch(0.488 0.243 264.376);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.269 0 0);
- --sidebar-accent-foreground: oklch(0.985 0 0);
- --sidebar-border: oklch(1 0 0 / 10%);
- --sidebar-ring: oklch(0.556 0 0);
+/*
+ * Custom properties resolve where they are declared, so the shadcn mapping
+ * is re-declared on every themed element.
+ */
+:root,
+[data-theme] {
+ --background: var(--bg);
+ --foreground: var(--fg);
+ --card: var(--bg);
+ --card-foreground: var(--fg);
+ --popover: var(--bg);
+ --popover-foreground: var(--fg);
+ --primary: var(--fg);
+ --primary-foreground: var(--bg);
+ --secondary: var(--surface);
+ --secondary-foreground: var(--fg);
+ --muted: var(--surface);
+ --muted-foreground: var(--fg-muted);
+ --accent: var(--surface);
+ --accent-foreground: var(--fg);
+ --destructive: var(--color-signal-error);
+ --border: var(--line);
+ --input: var(--color-ink-500);
+ --ring: var(--fg);
}
@layer base {
* {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
+ @apply border-border;
}
html {
@apply font-sans;
+ scroll-padding-top: 6rem;
}
-}
\ No newline at end of file
+ body {
+ @apply bg-background text-foreground;
+ font-size: var(--text-body);
+ line-height: 1.6;
+ }
+ [data-theme] {
+ background-color: var(--bg);
+ color: var(--fg);
+ }
+ ::selection {
+ background: var(--fg);
+ color: var(--bg);
+ }
+}
+
+:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible {
+ outline: 2px solid var(--fg);
+ outline-offset: 3px;
+ box-shadow: 0 0 0 5px var(--bg);
+}
+
+/* ── Smooth scroll (Lenis) ────────────────────────────────────────────── */
+
+html.lenis,
+html.lenis body {
+ height: auto;
+}
+.lenis:not(.lenis-autoToggle).lenis-stopped {
+ overflow: clip;
+}
+.lenis [data-lenis-prevent] {
+ overscroll-behavior: contain;
+}
+.lenis.lenis-smooth iframe {
+ pointer-events: none;
+}
+
+/* ── Layout & type utilities ──────────────────────────────────────────── */
+
+@utility container-site {
+ width: 100%;
+ max-width: var(--container-site);
+ margin-inline: auto;
+ padding-inline: var(--spacing-gutter);
+}
+
+/* Poster type: heavy, condensed, uppercase */
+@utility type-poster {
+ font-variation-settings: "wght" 800, "wdth" 75;
+ text-transform: uppercase;
+ letter-spacing: -0.01em;
+ text-wrap: balance;
+}
+
+@utility type-statement {
+ font-size: var(--text-statement);
+ font-variation-settings: "wght" 560, "wdth" 90;
+ line-height: 1;
+ letter-spacing: -0.025em;
+ text-wrap: balance;
+}
+
+@utility type-title {
+ font-size: var(--text-title);
+ font-variation-settings: "wght" 600, "wdth" 90;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ text-wrap: balance;
+}
+
+@utility type-body {
+ line-height: 1.6;
+ max-width: 62ch;
+ text-wrap: pretty;
+}
+
+@utility type-label {
+ font-family: var(--font-mono);
+ font-size: var(--text-label);
+ color: var(--fg-muted);
+}
+
+/*
+ * Outlined type. Variable fonts have overlapping contours that a plain stroke
+ * would draw inside the letters, so the stroke is painted first at double
+ * width and the fill (in the background color) covers its inner half.
+ */
+@utility text-outline {
+ color: var(--bg);
+ -webkit-text-stroke: max(2px, 0.024em) var(--stroke-color, var(--fg));
+ paint-order: stroke fill;
+}
+
+/* ── Grain over everything ────────────────────────────────────────────── */
+
+body::after {
+ content: "";
+ position: fixed;
+ inset: -50%;
+ z-index: 90;
+ pointer-events: none;
+ opacity: 0.06;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 1.2 -0.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+
+/* ── Glass ────────────────────────────────────────────────────────────── */
+
+.glass {
+ position: relative;
+ isolation: isolate;
+ background: linear-gradient(
+ 135deg,
+ rgb(255 255 255 / 0.08),
+ rgb(255 255 255 / 0.02)
+ );
+ backdrop-filter: blur(24px) saturate(1.2);
+ border: 1px solid rgb(255 255 255 / 0.12);
+ border-radius: var(--radius-glass);
+ box-shadow:
+ 0 1px 0 0 rgb(255 255 255 / 0.14) inset,
+ 0 30px 80px -30px rgb(0 0 0 / 0.6);
+}
+.glass::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ border-radius: inherit;
+ pointer-events: none;
+ background: radial-gradient(
+ 120% 90% at var(--light-x) var(--light-y),
+ rgb(255 255 255 / 0.1),
+ transparent 60%
+ );
+}
+@media (prefers-reduced-transparency: reduce) {
+ .glass {
+ backdrop-filter: none;
+ background: var(--color-ink-900);
+ }
+}
+
+/* ── Intro gating ─────────────────────────────────────────────────────── */
+
+/*
+ * An inline script adds `is-intro` before the first paint when the preloader
+ * should play. Everything that animates in after the preloader is paused while
+ * that class is present. Without JavaScript the class never appears and the
+ * page simply animates in.
+ */
+.preloader {
+ display: none;
+ clip-path: inset(0 0 0 0);
+ transition: clip-path 1000ms var(--ease-in-out-quart);
+}
+html.is-intro .preloader,
+.preloader[data-state="leaving"] {
+ display: flex;
+}
+.preloader[data-state="leaving"] {
+ clip-path: inset(0 0 100% 0);
+}
+html.is-intro {
+ overflow: hidden;
+}
+html.is-intro :is(.hero-char, .hero-fade) {
+ animation-play-state: paused;
+}
+
+@keyframes char-in {
+ from {
+ translate: 0 105%;
+ rotate: 6deg;
+ }
+ to {
+ translate: 0 0;
+ rotate: 0deg;
+ }
+}
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ translate: 0 1.5rem;
+ }
+}
+
+.hero-char {
+ display: inline-block;
+ transform-origin: 0 100%;
+ animation: char-in 1100ms var(--ease-out-expo) calc(150ms + var(--i) * 28ms) both;
+}
+.hero-fade {
+ animation: fade-up 1200ms var(--ease-out-expo) calc(700ms + var(--d, 0ms)) both;
+}
+
+/* ── Header: inverts itself over light and dark sections ──────────────── */
+
+.site-header {
+ mix-blend-mode: difference;
+ color: white;
+ transition: translate 500ms var(--ease-in-out-quart);
+}
+.site-header[data-hidden="true"] {
+ translate: 0 -120%;
+}
+
+/* ── Sound toggle equalizer ───────────────────────────────────────────── */
+
+@keyframes equalizer {
+ 0%,
+ 100% {
+ scale: 1 0.25;
+ }
+ 50% {
+ scale: 1 1;
+ }
+}
+@utility animate-equalizer {
+ animation: equalizer 0.9s ease-in-out infinite;
+}
+
+/* ── Custom cursor ────────────────────────────────────────────────────── */
+
+@media (hover: hover) and (pointer: fine) {
+ html.has-cursor,
+ html.has-cursor * {
+ cursor: none !important;
+ }
+ html.has-cursor :is(input, textarea, select) {
+ cursor: text !important;
+ }
+}
+
+/* ── Services: rows wipe to inverted on hover ─────────────────────────── */
+
+.service-row {
+ position: relative;
+ isolation: isolate;
+}
+.service-row::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ background: var(--fg);
+ scale: 1 0;
+ transform-origin: 50% 100%;
+ transition: scale 600ms var(--ease-in-out-quart);
+}
+.service-row :is(.service-name, .service-meta) {
+ transition:
+ color 400ms var(--ease-in-out-quart),
+ translate 600ms var(--ease-in-out-quart);
+}
+.service-row .service-details {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 600ms var(--ease-in-out-quart);
+}
+@media (hover: hover) and (pointer: fine) {
+ .service-row:hover::before,
+ .service-row:focus-within::before {
+ scale: 1 1;
+ transform-origin: 50% 0%;
+ }
+ .service-row:is(:hover, :focus-within) :is(.service-name, .service-meta) {
+ color: var(--bg);
+ }
+ .service-row:is(:hover, :focus-within) .service-name {
+ translate: 1.5rem 0;
+ }
+ .service-row:is(:hover, :focus-within) .service-details {
+ grid-template-rows: 1fr;
+ }
+}
+/* Touch devices: details are always visible */
+@media not ((hover: hover) and (pointer: fine)) {
+ .service-row .service-details {
+ grid-template-rows: 1fr;
+ }
+}
+
+/* ── Scroll-driven reveal (progressive enhancement) ──────────────────── */
+
+@supports (animation-timeline: view()) {
+ .reveal {
+ animation: fade-up linear both;
+ animation-timeline: view();
+ animation-range: entry 5% cover 30%;
+ }
+ .clip-reveal {
+ animation: clip-reveal linear both;
+ animation-timeline: view();
+ animation-range: entry 0% cover 40%;
+ }
+ @keyframes clip-reveal {
+ from {
+ clip-path: inset(18% 8% 18% 8% round 3rem);
+ }
+ to {
+ clip-path: inset(0 0 0 0 round var(--radius-glass));
+ }
+ }
+}
+
+/* ── Reduced motion ───────────────────────────────────────────────────── */
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-delay: 0ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ .reveal,
+ .clip-reveal {
+ animation: none !important;
+ }
+}
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..4f3109e
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/layout.tsx b/app/layout.tsx
index 9852c15..fa6482d 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,29 +1,69 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import type { Metadata, Viewport } from "next";
+
+import { site } from "@/modules/shared/config/site";
+import { introScript } from "@/modules/shared/lib/intro";
+import { CustomCursor } from "@/modules/shared/ui/components/custom-cursor";
+import { InlineScript } from "@/modules/shared/ui/components/inline-script";
+import { Preloader } from "@/modules/shared/ui/components/preloader";
+import { SiteFooter } from "@/modules/shared/ui/components/site-footer";
+import { SiteHeader } from "@/modules/shared/ui/components/site-header";
+import { SmoothScroll } from "@/modules/shared/ui/components/smooth-scroll";
+import { SoundEffects } from "@/modules/shared/ui/components/sound-effects";
+
+import { mono, sans } from "./fonts";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ metadataBase: new URL(site.url),
+ title: {
+ default: "since3 – Websites und Software aus Steinfurt",
+ template: "%s – since3",
+ },
+ description: site.description,
+ openGraph: {
+ type: "website",
+ locale: "de_DE",
+ siteName: site.name,
+ url: site.url,
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: "#000000",
+ colorScheme: "dark",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
+ // The intro script adds a class before hydration
-
{children}
+
+
+
+
+
+
+ Zum Inhalt springen
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
);
}
diff --git a/app/manifest.ts b/app/manifest.ts
new file mode 100644
index 0000000..abe03c9
--- /dev/null
+++ b/app/manifest.ts
@@ -0,0 +1,26 @@
+import type { MetadataRoute } from "next";
+
+import { site } from "@/modules/shared/config/site";
+
+// Used when the site is added to a home screen (Android, desktop PWA install)
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: "since3 – Digitalagentur aus Steinfurt",
+ short_name: site.name,
+ description: site.description,
+ start_url: "/",
+ display: "standalone",
+ background_color: "#000000",
+ theme_color: "#000000",
+ icons: [
+ { src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
+ { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
+ {
+ src: "/icons/icon-maskable-512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ ],
+ };
+}
diff --git a/app/page.tsx b/app/page.tsx
index 8801369..d5cd104 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,8 +1,5 @@
+import { LandingPageView } from "@/modules/landingPage/ui/views/landing-page-view";
export default function Home() {
- return (
-
-
-
- );
+ return ;
}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
index 997e0c7..97433b4 100644
--- a/components/ui/button.tsx
+++ b/components/ui/button.tsx
@@ -1,39 +1,36 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "cn"
+import { ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "group/button relative inline-flex shrink-0 items-center justify-center rounded-full border font-medium whitespace-nowrap transition-[background-color,border-color,color,scale] duration-200 ease-out select-none active:not-aria-[haspopup]:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/80",
+ // Black on light, white on dark
+ solid:
+ "border-transparent bg-fg text-bg hover:bg-ink-800 dark:hover:bg-ink-200",
+ // Hairline only
outline:
- "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
- ghost:
- "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
- destructive:
- "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
- link: "text-primary underline-offset-4 hover:underline",
+ "border-fg/20 bg-transparent text-fg hover:border-fg/60 aria-expanded:border-fg/60",
+ // Only on top of glass surfaces or dark sections
+ glass:
+ "border-white/15 bg-white/10 text-white backdrop-blur-md hover:bg-white/20",
+ ghost: "border-transparent text-fg hover:bg-fg/5 aria-expanded:bg-fg/5",
+ link: "rounded-none border-transparent px-0 text-fg underline-offset-4 hover:underline",
},
size: {
- default:
- "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
- lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
- icon: "size-8",
- "icon-xs":
- "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
- "icon-sm":
- "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
- "icon-lg": "size-9",
+ default: "h-11 gap-2 px-5 text-[0.9375rem]",
+ sm: "h-9 gap-1.5 px-4 text-sm",
+ lg: "h-13 gap-2 px-7 text-base",
+ icon: "size-10",
+ "icon-sm": "size-8",
},
},
defaultVariants: {
- variant: "default",
+ variant: "solid",
size: "default",
},
}
@@ -41,7 +38,7 @@ const buttonVariants = cva(
function Button({
className,
- variant = "default",
+ variant = "solid",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps) {
@@ -54,4 +51,22 @@ function Button({
)
}
-export { Button, buttonVariants }
+/**
+ * The arrow is not part of the label: it appears on hover only.
+ * Place it as the last child of a button or a link styled with `buttonVariants`.
+ */
+function ButtonArrow({ className }: { className?: string }) {
+ return (
+
+
+
+ )
+}
+
+export { Button, ButtonArrow, buttonVariants }
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..38d3386
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,96 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+/**
+ * Two strictly separate variants (DESIGN.md §8):
+ * - `flat`: hairline, no shadow, no blur. For lists.
+ * - `glass`: tool panels only. Never mix both in one section.
+ */
+const cardVariants = cva("flex flex-col text-card-foreground", {
+ variants: {
+ variant: {
+ flat: "card-flat rounded-2xl border border-line bg-transparent",
+ glass: "glass",
+ },
+ },
+ defaultVariants: {
+ variant: "flat",
+ },
+})
+
+function Card({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+ cardVariants,
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..8886e9d
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..8219a7f
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..932f46f
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/lib/sound.ts b/lib/sound.ts
new file mode 100644
index 0000000..36809f7
--- /dev/null
+++ b/lib/sound.ts
@@ -0,0 +1,161 @@
+// Synthesized UI sounds via the Web Audio API: zero network cost, no files.
+// Off by default; the visitor opts in with the toggle in the header.
+
+const STORAGE_KEY = "since3:sound"
+export const SOUND_CHANGE_EVENT = "since3:sound-change"
+
+export type SoundVoice = "hover" | "click" | "tick" | "on" | "off" | "confirm" | "whoosh"
+
+// Fallback when localStorage is blocked (private mode, strict cookie settings)
+let memoryEnabled = false
+
+export function isEnabled() {
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY)
+ return stored === null ? memoryEnabled : stored === "on"
+ } catch {
+ return memoryEnabled
+ }
+}
+
+export function setEnabled(enabled: boolean) {
+ memoryEnabled = enabled
+ try {
+ window.localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off")
+ } catch {
+ // Storage blocked: the in-memory value keeps working for this visit
+ }
+ window.dispatchEvent(new Event(SOUND_CHANGE_EVENT))
+}
+
+/* ── Engine ─────────────────────────────────────────────────────────── */
+
+type Engine = { ctx: AudioContext; master: GainNode; noise: AudioBuffer }
+let engine: Engine | null = null
+
+// Created lazily on first use, which is always inside a user gesture
+function getEngine(): Engine {
+ if (!engine) {
+ const ctx = new AudioContext()
+ // A gentle compressor glues overlapping sounds and prevents clipping
+ const compressor = ctx.createDynamicsCompressor()
+ compressor.threshold.value = -16
+ compressor.ratio.value = 4
+ const master = ctx.createGain()
+ master.gain.value = 0.9
+ master.connect(compressor).connect(ctx.destination)
+
+ const noise = ctx.createBuffer(1, ctx.sampleRate / 2, ctx.sampleRate)
+ const data = noise.getChannelData(0)
+ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1
+
+ engine = { ctx, master, noise }
+ }
+ if (engine.ctx.state === "suspended") void engine.ctx.resume()
+ return engine
+}
+
+// Slight random variation so repeated sounds never feel mechanical
+function vary(value: number, amount: number) {
+ return value * (1 + (Math.random() * 2 - 1) * amount)
+}
+
+type ToneOptions = {
+ freq: number
+ endFreq?: number
+ type?: OscillatorType
+ start: number
+ attack?: number
+ decay: number
+ peak: number
+}
+
+function tone({ ctx, master }: Engine, options: ToneOptions) {
+ const { freq, endFreq, type = "sine", start, attack = 0.004, decay, peak } = options
+ const osc = ctx.createOscillator()
+ const gain = ctx.createGain()
+ osc.type = type
+ osc.frequency.setValueAtTime(freq, start)
+ if (endFreq) osc.frequency.exponentialRampToValueAtTime(endFreq, start + attack + decay)
+ gain.gain.setValueAtTime(0.0001, start)
+ gain.gain.exponentialRampToValueAtTime(peak, start + attack)
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + attack + decay)
+ osc.connect(gain).connect(master)
+ osc.start(start)
+ osc.stop(start + attack + decay + 0.02)
+}
+
+type NoiseOptions = {
+ start: number
+ attack?: number
+ decay: number
+ peak: number
+ filter: BiquadFilterType
+ freq: number
+ endFreq?: number
+ q?: number
+}
+
+function noiseBurst({ ctx, master, noise }: Engine, options: NoiseOptions) {
+ const { start, attack = 0.002, decay, peak, filter, freq, endFreq, q = 1 } = options
+ const source = ctx.createBufferSource()
+ source.buffer = noise
+ const biquad = ctx.createBiquadFilter()
+ biquad.type = filter
+ biquad.Q.value = q
+ biquad.frequency.setValueAtTime(freq, start)
+ if (endFreq) biquad.frequency.exponentialRampToValueAtTime(endFreq, start + attack + decay)
+ const gain = ctx.createGain()
+ gain.gain.setValueAtTime(0.0001, start)
+ gain.gain.exponentialRampToValueAtTime(peak, start + attack)
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + attack + decay)
+ source.connect(biquad).connect(gain).connect(master)
+ source.start(start, Math.random() * 0.3)
+ source.stop(start + attack + decay + 0.02)
+}
+
+const VOICES: Record void> = {
+ // Light glassy tick when the pointer enters a link or button
+ hover: (e, t) => tone(e, { freq: vary(2400, 0.06), start: t, attack: 0.002, decay: 0.035, peak: 0.07 }),
+
+ // Short pitch drop plus a click transient; audible on laptop speakers too
+ click: (e, t) => {
+ tone(e, { freq: 900, endFreq: 280, start: t, attack: 0.002, decay: 0.07, peak: 0.4 })
+ tone(e, { freq: 1800, endFreq: 900, type: "triangle", start: t, attack: 0.001, decay: 0.03, peak: 0.12 })
+ noiseBurst(e, { start: t, decay: 0.012, peak: 0.2, filter: "highpass", freq: 3500 })
+ },
+
+ // Typewriter tick for each line of tool output
+ tick: (e, t) =>
+ noiseBurst(e, { start: t, decay: 0.02, peak: 0.28, filter: "bandpass", freq: vary(4200, 0.15), q: 4 }),
+
+ // Sound switched on: two rising notes (a fifth)
+ on: (e, t) => {
+ tone(e, { freq: 660, type: "triangle", start: t, decay: 0.18, peak: 0.28 })
+ tone(e, { freq: 990, type: "triangle", start: t + 0.08, decay: 0.28, peak: 0.28 })
+ },
+
+ // Sound switched off: the same notes falling
+ off: (e, t) => {
+ tone(e, { freq: 990, type: "triangle", start: t, decay: 0.14, peak: 0.22 })
+ tone(e, { freq: 660, type: "triangle", start: t + 0.07, decay: 0.2, peak: 0.18 })
+ },
+
+ // Success: bright major arpeggio
+ confirm: (e, t) => {
+ ;[1046.5, 1318.5, 1568, 2093].forEach((freq, i) =>
+ tone(e, { freq, type: "triangle", start: t + i * 0.07, attack: 0.005, decay: 0.45, peak: 0.2 })
+ )
+ },
+
+ // Menu open/close: filtered noise sweep
+ whoosh: (e, t) =>
+ noiseBurst(e, { start: t, attack: 0.12, decay: 0.25, peak: 0.3, filter: "bandpass", freq: 350, endFreq: 3200, q: 0.9 }),
+}
+
+export function play(voice: SoundVoice, { force = false } = {}) {
+ if (typeof window === "undefined") return
+ if (!force && !isEnabled()) return
+ const current = getEngine()
+ VOICES[voice](current, current.ctx.currentTime)
+}
diff --git a/lib/utils.ts b/lib/utils.ts
index f815ded..6b8c3b0 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1 +1,15 @@
-export { cn } from "cn"
+import { createCn } from "cn/config"
+
+// Register the custom type scale from globals.css so class merging treats
+// `text-mega` etc. as font sizes instead of colors (which would drop one of them).
+export const cn = createCn({
+ extend: {
+ classGroups: {
+ "font-size": [
+ {
+ text: ["mega", "display", "statement", "title", "lead", "body", "label"],
+ },
+ ],
+ },
+ },
+})
diff --git a/modules/landingPage/data/content.ts b/modules/landingPage/data/content.ts
new file mode 100644
index 0000000..e5fc426
--- /dev/null
+++ b/modules/landingPage/data/content.ts
@@ -0,0 +1,135 @@
+// All copy of the landing page in one place, so text changes never touch layout code.
+
+export type Project = {
+ slug: string
+ title: string
+ client: string
+ year: number
+ // One sentence about the benefit for the client, not about us
+ benefit: string
+ tags: string[]
+ url?: string
+ image?: { src: string; alt: string }
+}
+
+// TODO: replace with real projects and screenshots (AVIF/WebP in /public/projects).
+export const projects: Project[] = [
+ {
+ slug: "projekt-eins",
+ title: "Relaunch einer Unternehmenswebsite",
+ client: "[Kundenname]",
+ year: 2026,
+ benefit:
+ "[Ein Satz zum messbaren Nutzen, z. B. mehr Anfragen seit dem Launch]",
+ tags: ["Webentwicklung", "SEO", "Next.js"],
+ },
+ {
+ slug: "projekt-zwei",
+ title: "Interne Auftragsverwaltung",
+ client: "[Kundenname]",
+ year: 2025,
+ benefit: "[Ein Satz zum messbaren Nutzen, z. B. eingesparte Stunden pro Woche]",
+ tags: ["Softwareentwicklung", "Dashboard"],
+ },
+ {
+ slug: "projekt-drei",
+ title: "Automatisierte Angebotserstellung",
+ client: "[Kundenname]",
+ year: 2025,
+ benefit: "[Ein Satz zum messbaren Nutzen, z. B. schnellere Angebote]",
+ tags: ["KI-Automatisierung"],
+ },
+]
+
+export type Service = {
+ slug: string
+ title: string
+ description: string
+ tags: string[]
+}
+
+export const services: Service[] = [
+ {
+ slug: "webentwicklung",
+ title: "Webentwicklung",
+ description:
+ "Schnelle, barrierefreie Websites, die Besucher zu Anfragen machen.",
+ tags: ["Next.js", "Headless CMS", "BFSG"],
+ },
+ {
+ slug: "softwareentwicklung",
+ title: "Softwareentwicklung",
+ description:
+ "Individuelle Web-Anwendungen für Abläufe, die kein Standardtool abbildet.",
+ tags: ["Web-Apps", "Schnittstellen", "Dashboards"],
+ },
+ {
+ slug: "seo",
+ title: "SEO",
+ description:
+ "Bessere Rankings, mehr Sichtbarkeit, mehr Kunden. Lokal und bei KI-Suchen.",
+ tags: ["Technisches SEO", "Local SEO", "GEO"],
+ },
+ {
+ slug: "ki-automatisierung",
+ title: "KI-Automatisierung",
+ description:
+ "Wiederkehrende Arbeit an Software abgeben, damit Menschen Zeit für Kunden haben.",
+ tags: ["Workflows", "LLM-Integration"],
+ },
+ {
+ slug: "it-betreuung",
+ title: "IT-Betreuung",
+ description:
+ "Hosting, Updates und ein fester Ansprechpartner, wenn etwas nicht läuft.",
+ tags: ["Hosting", "Wartung", "Support"],
+ },
+]
+
+export const processSteps = [
+ {
+ title: "Kostenloses Erstgespräch",
+ description:
+ "Wir hören zu, stellen Fragen und sagen ehrlich, ob wir die Richtigen sind.",
+ },
+ {
+ title: "Individuelle Planung",
+ description:
+ "Ein schriftliches Konzept mit Umfang, Zeitplan und Festpreis. Keine Überraschungen.",
+ },
+ {
+ title: "Entwicklung",
+ description:
+ "Wöchentliche Zwischenstände, die man anklicken kann, statt Statusberichten.",
+ },
+ {
+ title: "Live-Schaltung",
+ description:
+ "Launch, Einweisung und Betreuung danach. Wir bleiben erreichbar.",
+ },
+]
+
+export const aboutStatement =
+ "since3 sind zwei Freunde aus Steinfurt. Wir bauen Websites, Software und Automatisierungen, die schnell laden, für alle funktionieren und jeden Tag Arbeit abnehmen. Kein Agentur-Blabla. Nur Dinge, die funktionieren."
+
+export type Stat = { value: number; label: string; prefix?: string; suffix?: string }
+
+// Commitments, not vanity metrics. Only list what we can back up.
+export const stats: Stat[] = [
+ { value: 2, label: "Gründer, die selbst entwickeln" },
+ { value: 100, label: "Lighthouse-Score als Ziel für jede Website" },
+ { value: 48, suffix: "h", label: "bis zur ersten Antwort auf deine Anfrage" },
+ { value: 0, label: "Subunternehmer. Alles aus einer Hand." },
+]
+
+export const aiPrompt =
+ "Was macht die Digitalagentur since3 aus Steinfurt und für wen ist sie die richtige Wahl?"
+
+export const aiAssistants = [
+ { name: "ChatGPT", href: `https://chatgpt.com/?q=${encodeURIComponent(aiPrompt)}` },
+ {
+ name: "Perplexity",
+ href: `https://www.perplexity.ai/search?q=${encodeURIComponent(aiPrompt)}`,
+ },
+ { name: "Claude", href: `https://claude.ai/new?q=${encodeURIComponent(aiPrompt)}` },
+]
diff --git a/modules/landingPage/data/pricing.ts b/modules/landingPage/data/pricing.ts
new file mode 100644
index 0000000..809e11e
--- /dev/null
+++ b/modules/landingPage/data/pricing.ts
@@ -0,0 +1,124 @@
+// Single source of truth for the price calculator.
+//
+// TODO: replace the EXAMPLE values below with your real prices, then set
+// `isExample` to false. While it is true, the calculator shows a visible
+// "Beispielpreise" badge so placeholder prices can never go live unnoticed.
+
+export type VatMode =
+ // B2B: net prices plus VAT
+ | "net"
+ // B2C: prices incl. 19 % VAT (required by the PAngV when addressing consumers)
+ | "gross"
+ // Kleinunternehmer according to §19 UStG
+ | "small-business"
+
+export type ProjectTypeId = "landing" | "website" | "shop" | "software" | "automation"
+
+export type ProjectType = {
+ id: ProjectTypeId
+ label: string
+ description: string
+ base: number
+ // Delivery time in weeks at "small"/included scope
+ weeks: [number, number]
+ // Website-like projects are sized by page count
+ pages?: { included: number; max: number; pricePerPage: number }
+ // Software-like projects are sized by complexity instead
+ sizedByComplexity?: boolean
+}
+
+export type Extra = {
+ id: string
+ label: string
+ price: number
+ // Project types this extra is offered for
+ for: ProjectTypeId[]
+}
+
+export type MonthlyService = {
+ id: string
+ label: string
+ description: string
+ price: number
+}
+
+export const pricing = {
+ isExample: true,
+ vat: "net" as VatMode,
+ vatRate: 0.19,
+
+ // The estimate is shown as a range around the calculated value
+ spread: { low: 0.9, high: 1.2 },
+ roundTo: 100,
+
+ projectTypes: [
+ {
+ id: "landing",
+ label: "Landingpage",
+ description: "Eine starke Seite für ein Angebot oder eine Kampagne.",
+ base: 1500,
+ weeks: [2, 3],
+ },
+ {
+ id: "website",
+ label: "Website",
+ description: "Unternehmensauftritt mit mehreren Seiten.",
+ base: 3500,
+ weeks: [4, 6],
+ pages: { included: 5, max: 30, pricePerPage: 250 },
+ },
+ {
+ id: "shop",
+ label: "Online-Shop",
+ description: "Verkaufen mit Warenkorb, Zahlung und Versand.",
+ base: 6500,
+ weeks: [6, 10],
+ pages: { included: 5, max: 30, pricePerPage: 250 },
+ },
+ {
+ id: "software",
+ label: "Web-App",
+ description: "Individuelle Software, Portale, interne Tools.",
+ base: 8000,
+ weeks: [6, 10],
+ sizedByComplexity: true,
+ },
+ {
+ id: "automation",
+ label: "KI-Automatisierung",
+ description: "Abläufe automatisieren, KI sinnvoll einbinden.",
+ base: 2500,
+ weeks: [2, 4],
+ sizedByComplexity: true,
+ },
+ ] satisfies ProjectType[],
+
+ complexity: [
+ { id: "small", label: "Überschaubar", hint: "Ein klarer Ablauf, wenige Rollen", factor: 1 },
+ { id: "medium", label: "Mittel", hint: "Mehrere Abläufe, Nutzerkonten", factor: 1.8 },
+ { id: "large", label: "Umfangreich", hint: "Viele Schnittstellen und Rollen", factor: 3 },
+ ],
+
+ extras: [
+ { id: "cms", label: "Inhalte selbst pflegen (CMS)", price: 900, for: ["landing", "website"] },
+ { id: "language", label: "Zweite Sprache", price: 900, for: ["landing", "website", "shop", "software"] },
+ { id: "copy", label: "Texte schreiben wir", price: 600, for: ["landing", "website", "shop"] },
+ { id: "branding", label: "Logo & Markenauftritt", price: 1200, for: ["landing", "website", "shop", "software"] },
+ { id: "booking", label: "Terminbuchung", price: 800, for: ["landing", "website"] },
+ { id: "seo", label: "SEO-Grundpaket", price: 600, for: ["landing", "website", "shop"] },
+ { id: "integrations", label: "Anbindung an bestehende Systeme", price: 1500, for: ["shop", "software", "automation"] },
+ { id: "ai", label: "KI-Assistent / Chatbot", price: 1800, for: ["website", "shop", "software", "automation"] },
+ ] satisfies Extra[],
+
+ monthly: [
+ { id: "care", label: "Hosting & Wartung", description: "Updates, Backups, Sicherheit", price: 49 },
+ { id: "seo-care", label: "SEO-Betreuung", description: "Monatliche Optimierung und Reporting", price: 290 },
+ ] satisfies MonthlyService[],
+
+ express: { label: "Express", hint: "Etwa 40 % schneller fertig", factor: 1.25, weeksFactor: 0.6 },
+
+ // Always part of the price, shown as reassurance
+ included: ["Barrierefrei nach BFSG", "Responsive Design", "Schnelle Ladezeiten", "DSGVO-konform"],
+}
+
+export type ComplexityId = (typeof pricing.complexity)[number]["id"]
diff --git a/modules/landingPage/forms/contact-form.tsx b/modules/landingPage/forms/contact-form.tsx
new file mode 100644
index 0000000..552e1c1
--- /dev/null
+++ b/modules/landingPage/forms/contact-form.tsx
@@ -0,0 +1,193 @@
+"use client"
+
+import { useActionState, useEffect, useId, useRef } from "react"
+import { CircleAlert, CircleCheck } from "lucide-react"
+
+import { Button, ButtonArrow } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { play } from "@/lib/sound"
+import { cn } from "@/lib/utils"
+import { CONTACT_PREFILL_EVENT } from "@/modules/landingPage/lib/contact-prefill"
+import {
+ initialContactState,
+ type ContactField,
+} from "@/modules/landingPage/schema/contact-schema"
+import { submitContact } from "@/modules/landingPage/server/contact-action"
+
+export function ContactForm() {
+ const [state, formAction, pending] = useActionState(
+ submitContact,
+ initialContactState
+ )
+ const id = useId()
+ const messageRef = useRef(null)
+
+ useEffect(() => {
+ if (state.status === "success") play("confirm")
+ }, [state])
+
+ // Text handed over by other sections, e.g. the price calculator
+ useEffect(() => {
+ const onPrefill = (event: Event) => {
+ const el = messageRef.current
+ if (!el) return
+ const text = (event as CustomEvent).detail
+ // Keep whatever the visitor already wrote
+ el.value = el.value.trim() ? `${text}
+
+${el.value}` : text
+ // The page is still scrolling there, so focus without jumping
+ el.focus({ preventScroll: true })
+ el.setSelectionRange(el.value.length, el.value.length)
+ }
+ window.addEventListener(CONTACT_PREFILL_EVENT, onPrefill)
+ return () => window.removeEventListener(CONTACT_PREFILL_EVENT, onPrefill)
+ }, [])
+
+ const fieldId = (field: ContactField) => `${id}-${field}`
+ const errorId = (field: ContactField) => `${id}-${field}-error`
+ const errorFor = (field: ContactField) => state.fieldErrors?.[field]?.[0]
+
+ // Wires label, error text and aria attributes together for one field
+ const fieldProps = (field: ContactField) => ({
+ id: fieldId(field),
+ name: field,
+ "aria-invalid": errorFor(field) ? true : undefined,
+ "aria-describedby": errorFor(field) ? errorId(field) : undefined,
+ })
+
+ if (state.status === "success") {
+ return (
+
+
+
{state.message}
+
+ Du bekommst eine Antwort von einem Menschen, nicht von einem Ticketsystem.
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+function Field({
+ id,
+ message,
+ className,
+ children,
+}: {
+ id: string
+ message?: string
+ className?: string
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+ {/* Errors are shown in text and color, never color alone */}
+ {message ? (
+
+
+ {message}
+
+ ) : null}
+
+ )
+}
diff --git a/modules/landingPage/lib/contact-prefill.ts b/modules/landingPage/lib/contact-prefill.ts
new file mode 100644
index 0000000..b890e48
--- /dev/null
+++ b/modules/landingPage/lib/contact-prefill.ts
@@ -0,0 +1,7 @@
+// Lets other sections (e.g. the price calculator) hand text to the contact form.
+
+export const CONTACT_PREFILL_EVENT = "since3:contact-prefill"
+
+export function prefillContact(message: string) {
+ window.dispatchEvent(new CustomEvent(CONTACT_PREFILL_EVENT, { detail: message }))
+}
diff --git a/modules/landingPage/lib/pricing.ts b/modules/landingPage/lib/pricing.ts
new file mode 100644
index 0000000..d8f98de
--- /dev/null
+++ b/modules/landingPage/lib/pricing.ts
@@ -0,0 +1,152 @@
+// Pure pricing logic for the calculator. No React, so it is easy to test.
+
+import {
+ pricing,
+ type ComplexityId,
+ type ProjectType,
+ type ProjectTypeId,
+} from "@/modules/landingPage/data/pricing"
+
+export type CalculatorState = {
+ type: ProjectTypeId
+ pages: number
+ complexity: ComplexityId
+ extras: string[]
+ monthly: string[]
+ express: boolean
+}
+
+export type Estimate = {
+ low: number
+ high: number
+ monthly: number
+ weeks: [number, number]
+ lines: { label: string; amount: number }[]
+}
+
+export const initialCalculatorState: CalculatorState = {
+ type: "website",
+ pages: 5,
+ complexity: "small",
+ extras: [],
+ monthly: ["care"],
+ express: false,
+}
+
+export function getProjectType(id: ProjectTypeId): ProjectType {
+ return pricing.projectTypes.find((type) => type.id === id) ?? pricing.projectTypes[0]
+}
+
+export function extrasFor(id: ProjectTypeId) {
+ return pricing.extras.filter((extra) => (extra.for as ProjectTypeId[]).includes(id))
+}
+
+function roundTo(value: number, step: number) {
+ return Math.round(value / step) * step
+}
+
+// Converts net prices into what the visitor should see
+export function toDisplayPrice(value: number) {
+ return pricing.vat === "gross" ? value * (1 + pricing.vatRate) : value
+}
+
+export function estimate(state: CalculatorState): Estimate {
+ const type = getProjectType(state.type)
+ const lines: Estimate["lines"] = [{ label: type.label, amount: type.base }]
+
+ let project = type.base
+ let weeks: [number, number] = [...type.weeks]
+
+ if (type.pages) {
+ const extraPages = Math.max(0, state.pages - type.pages.included)
+ if (extraPages > 0) {
+ const amount = extraPages * type.pages.pricePerPage
+ project += amount
+ lines.push({ label: `${extraPages} weitere Seiten`, amount })
+ // Roughly one more week per ten extra pages
+ const extraWeeks = Math.ceil(extraPages / 10)
+ weeks = [weeks[0] + extraWeeks, weeks[1] + extraWeeks]
+ }
+ }
+
+ if (type.sizedByComplexity) {
+ const level = pricing.complexity.find((c) => c.id === state.complexity) ?? pricing.complexity[0]
+ if (level.factor !== 1) {
+ const amount = project * (level.factor - 1)
+ project += amount
+ lines.push({ label: `Umfang: ${level.label}`, amount })
+ weeks = [Math.round(weeks[0] * level.factor), Math.round(weeks[1] * level.factor)]
+ }
+ }
+
+ // Only extras that exist for this project type count
+ for (const extra of extrasFor(state.type)) {
+ if (state.extras.includes(extra.id)) {
+ project += extra.price
+ lines.push({ label: extra.label, amount: extra.price })
+ }
+ }
+
+ if (state.express) {
+ const amount = project * (pricing.express.factor - 1)
+ project += amount
+ lines.push({ label: "Express-Zuschlag", amount })
+ weeks = [
+ Math.max(1, Math.round(weeks[0] * pricing.express.weeksFactor)),
+ Math.max(1, Math.round(weeks[1] * pricing.express.weeksFactor)),
+ ]
+ }
+
+ const monthly = pricing.monthly
+ .filter((service) => state.monthly.includes(service.id))
+ .reduce((sum, service) => sum + service.price, 0)
+
+ return {
+ low: roundTo(toDisplayPrice(project * pricing.spread.low), pricing.roundTo),
+ high: roundTo(toDisplayPrice(project * pricing.spread.high), pricing.roundTo),
+ monthly: toDisplayPrice(monthly),
+ weeks,
+ lines: lines.map((line) => ({ ...line, amount: toDisplayPrice(line.amount) })),
+ }
+}
+
+const currency = new Intl.NumberFormat("de-DE", {
+ style: "currency",
+ currency: "EUR",
+ maximumFractionDigits: 0,
+})
+
+const plain = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 })
+
+export function formatEuro(value: number) {
+ return currency.format(Math.round(value))
+}
+
+export function formatNumber(value: number) {
+ return plain.format(Math.round(value))
+}
+
+export const vatNote = {
+ net: "Netto, zzgl. 19 % MwSt. Angebot für Unternehmen.",
+ gross: "Inkl. 19 % MwSt.",
+ "small-business": "Gemäß §19 UStG wird keine Umsatzsteuer berechnet.",
+}[pricing.vat]
+
+/** Plain-text summary that prefills the contact form. */
+export function summarize(state: CalculatorState, result: Estimate) {
+ const type = getProjectType(state.type)
+ const parts = [`Projekt: ${type.label}`]
+ if (type.pages) parts.push(`Seiten: ca. ${state.pages}`)
+ if (type.sizedByComplexity) {
+ const level = pricing.complexity.find((c) => c.id === state.complexity)
+ if (level) parts.push(`Umfang: ${level.label}`)
+ }
+ const extras = extrasFor(state.type).filter((extra) => state.extras.includes(extra.id))
+ if (extras.length) parts.push(`Extras: ${extras.map((extra) => extra.label).join(", ")}`)
+ const monthly = pricing.monthly.filter((service) => state.monthly.includes(service.id))
+ if (monthly.length) parts.push(`Laufend: ${monthly.map((service) => service.label).join(", ")}`)
+ if (state.express) parts.push("Tempo: Express")
+ parts.push(`Schätzung laut Preisrechner: ${formatEuro(result.low)} – ${formatEuro(result.high)}`)
+
+ return `Hallo since3,\n\n${parts.join("\n")}\n\nKurz zu meinem Vorhaben: `
+}
diff --git a/modules/landingPage/lib/tools.ts b/modules/landingPage/lib/tools.ts
new file mode 100644
index 0000000..82cb5d8
--- /dev/null
+++ b/modules/landingPage/lib/tools.ts
@@ -0,0 +1,129 @@
+// Pure logic of the live tools. No React here, so it stays easy to test.
+
+export type OutputTone = "default" | "muted" | "success" | "error"
+export type OutputLine = { text: string; tone?: OutputTone }
+
+/* ── Contrast check (WCAG 2.2) ──────────────────────────────────────── */
+
+export function parseHex(input: string): [number, number, number] | null {
+ const hex = input.trim().replace(/^#/, "")
+ const full =
+ hex.length === 3
+ ? hex
+ .split("")
+ .map((c) => c + c)
+ .join("")
+ : hex
+ if (!/^[0-9a-f]{6}$/i.test(full)) return null
+ return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as [
+ number,
+ number,
+ number,
+ ]
+}
+
+function relativeLuminance([r, g, b]: [number, number, number]) {
+ const channel = (value: number) => {
+ const c = value / 255
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
+ }
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
+}
+
+export function contrastRatio(
+ a: [number, number, number],
+ b: [number, number, number]
+) {
+ const [light, dark] = [relativeLuminance(a), relativeLuminance(b)].sort(
+ (x, y) => y - x
+ )
+ return (light + 0.05) / (dark + 0.05)
+}
+
+export function runContrastCheck(fg: string, bg: string): OutputLine[] {
+ const command: OutputLine = {
+ text: `$ kontrast --text ${fg} --hintergrund ${bg}`,
+ tone: "muted",
+ }
+ const fgRgb = parseHex(fg)
+ const bgRgb = parseHex(bg)
+
+ if (!fgRgb || !bgRgb) {
+ return [
+ command,
+ { text: "fehler: bitte Farben als Hex angeben, z. B. #1a1a1a", tone: "error" },
+ ]
+ }
+
+ // Floor instead of round, otherwise 4.499 would be reported as passing 4.5
+ const ratio = Math.floor(contrastRatio(fgRgb, bgRgb) * 100) / 100
+ const check = (label: string, min: number): OutputLine => {
+ const pass = ratio >= min
+ return {
+ text: `${label.padEnd(22)}${pass ? "bestanden" : "nicht bestanden"} (min. ${min}:1)`,
+ tone: pass ? "success" : "error",
+ }
+ }
+
+ return [
+ command,
+ { text: `verhältnis ${ratio.toFixed(2)} : 1` },
+ check("AA fließtext", 4.5),
+ check("AA große schrift", 3),
+ check("AA bedienelemente", 3),
+ check("AAA fließtext", 7),
+ ]
+}
+
+/* ── Search snippet check ───────────────────────────────────────────── */
+
+const TITLE_RANGE = [30, 60] as const
+const DESCRIPTION_RANGE = [70, 160] as const
+
+function rangeLine(
+ label: string,
+ length: number,
+ [min, max]: readonly [number, number]
+): OutputLine {
+ const ok = length >= min && length <= max
+ const hint = length < min ? "zu kurz" : length > max ? "wird abgeschnitten" : "passt"
+ return {
+ text: `${label.padEnd(16)}${String(length).padStart(3)} zeichen ${hint} (${min}–${max})`,
+ tone: ok ? "success" : "error",
+ }
+}
+
+export function runSnippetCheck(title: string, description: string): OutputLine[] {
+ const lines: OutputLine[] = [
+ { text: "$ snippet --pruefen", tone: "muted" },
+ rangeLine("titel", title.trim().length, TITLE_RANGE),
+ rangeLine("beschreibung", description.trim().length, DESCRIPTION_RANGE),
+ ]
+
+ const titleWords = new Set(
+ title
+ .toLowerCase()
+ .split(/[^\p{L}\p{N}]+/u)
+ .filter((word) => word.length > 3)
+ )
+ const shared = description
+ .toLowerCase()
+ .split(/[^\p{L}\p{N}]+/u)
+ .filter((word) => titleWords.has(word))
+
+ lines.push(
+ shared.length > 0
+ ? { text: `suchbegriff in beiden: ${[...new Set(shared)].join(", ")}`, tone: "success" }
+ : {
+ text: "suchbegriff titel und beschreibung teilen kein wort",
+ tone: "error",
+ }
+ )
+
+ return lines
+}
+
+export function truncate(text: string, max: number) {
+ const trimmed = text.trim()
+ return trimmed.length > max ? `${trimmed.slice(0, max - 1).trimEnd()} …` : trimmed
+}
diff --git a/modules/landingPage/schema/contact-schema.ts b/modules/landingPage/schema/contact-schema.ts
new file mode 100644
index 0000000..2521caa
--- /dev/null
+++ b/modules/landingPage/schema/contact-schema.ts
@@ -0,0 +1,35 @@
+import { z } from "zod"
+
+// Shared between the server action and the form so both validate identically.
+export const contactSchema = z.object({
+ name: z
+ .string()
+ .trim()
+ .min(2, "Bitte gib deinen Namen an."),
+ email: z
+ .string()
+ .trim()
+ .pipe(z.email("Bitte gib eine gültige E-Mail-Adresse an.")),
+ company: z.string().trim().max(120, "Maximal 120 Zeichen.").optional(),
+ message: z
+ .string()
+ .trim()
+ .min(20, "Erzähl uns in mindestens 20 Zeichen, worum es geht.")
+ .max(4000, "Maximal 4000 Zeichen."),
+ privacy: z.literal("on", {
+ error: "Bitte stimme der Verarbeitung deiner Angaben zu.",
+ }),
+})
+
+export type ContactInput = z.infer
+export type ContactField = keyof ContactInput
+
+export type ContactFormState = {
+ status: "idle" | "success" | "error"
+ message?: string
+ fieldErrors?: Partial>
+ // Echoed back so the form keeps its values after a failed submit
+ values?: Partial>
+}
+
+export const initialContactState: ContactFormState = { status: "idle" }
diff --git a/modules/landingPage/server/contact-action.ts b/modules/landingPage/server/contact-action.ts
new file mode 100644
index 0000000..335adca
--- /dev/null
+++ b/modules/landingPage/server/contact-action.ts
@@ -0,0 +1,50 @@
+"use server"
+
+import { z } from "zod"
+
+import {
+ contactSchema,
+ type ContactFormState,
+} from "@/modules/landingPage/schema/contact-schema"
+
+export async function submitContact(
+ _prevState: ContactFormState,
+ formData: FormData
+): Promise {
+ // Honeypot: real people never see or fill this field
+ if (formData.get("website")) {
+ return { status: "success", message: "Danke! Wir melden uns innerhalb von zwei Werktagen." }
+ }
+
+ const raw = {
+ name: String(formData.get("name") ?? ""),
+ email: String(formData.get("email") ?? ""),
+ company: String(formData.get("company") ?? "") || undefined,
+ message: String(formData.get("message") ?? ""),
+ privacy: formData.get("privacy") ?? undefined,
+ }
+
+ const parsed = contactSchema.safeParse(raw)
+
+ if (!parsed.success) {
+ return {
+ status: "error",
+ message: "Bitte prüfe die markierten Felder.",
+ fieldErrors: z.flattenError(parsed.error).fieldErrors,
+ values: {
+ name: raw.name,
+ email: raw.email,
+ company: raw.company,
+ message: raw.message,
+ },
+ }
+ }
+
+ // TODO: deliver the inquiry, e.g. via a mail provider (Resend, Postmark, SMTP).
+ // Keep personal data out of logs.
+
+ return {
+ status: "success",
+ message: "Danke! Wir melden uns innerhalb von zwei Werktagen.",
+ }
+}
diff --git a/modules/landingPage/ui/components/about-section.tsx b/modules/landingPage/ui/components/about-section.tsx
new file mode 100644
index 0000000..b42f2a0
--- /dev/null
+++ b/modules/landingPage/ui/components/about-section.tsx
@@ -0,0 +1,57 @@
+// TODO: replace the placeholders with real team photos.
+const team = [
+ { name: "[Name]", role: "Gründer, Entwicklung" },
+ { name: "[Name]", role: "Gründer, Design und SEO" },
+]
+
+export function AboutSection() {
+ return (
+
+
+
(Über uns)
+
+ Zwei Freunde.
+
+ Ein Anspruch.
+
+
+
+
+ {team.map((person, index) => (
+
+
+
+
+ Foto folgt
+
+
+
+ {person.name}
+ {person.role}
+
+
+
+ ))}
+
+
+
+
+ Du redest mit denen, die bauen.
+
+
+ since3 ist eine kleine Agentur aus Steinfurt. Wir arbeiten auf
+ Augenhöhe, sagen ehrlich, was sinnvoll ist, und bauen Technik, die
+ schnell lädt, für alle funktioniert und Arbeit abnimmt, statt neue zu
+ machen.
+
+
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/ai-visibility-section.tsx b/modules/landingPage/ui/components/ai-visibility-section.tsx
new file mode 100644
index 0000000..2d7e6f3
--- /dev/null
+++ b/modules/landingPage/ui/components/ai-visibility-section.tsx
@@ -0,0 +1,53 @@
+import { ArrowUpRight } from "lucide-react"
+
+import { aiAssistants, aiPrompt } from "@/modules/landingPage/data/content"
+import { Magnetic } from "@/modules/shared/ui/components/magnetic"
+
+/** "Ask an AI about us": proof of the AI visibility we also sell. */
+export function AiVisibilitySection() {
+ return (
+
+
+
+
(KI-Sichtbarkeit)
+
+ Frag nicht uns.
+
+ Frag eine KI.
+
+
+
+ >
+ {aiPrompt}
+
+
+
+
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/contact-section.tsx b/modules/landingPage/ui/components/contact-section.tsx
new file mode 100644
index 0000000..99d3ec4
--- /dev/null
+++ b/modules/landingPage/ui/components/contact-section.tsx
@@ -0,0 +1,53 @@
+import { ContactForm } from "@/modules/landingPage/forms/contact-form"
+import { site } from "@/modules/shared/config/site"
+
+export function ContactSection() {
+ return (
+
+
+
+
+
(Kontakt)
+
+ Lass uns
+
+ reden.
+
+
+
+
+
+ Das Erstgespräch ist kostenlos. Erzähl uns, was du vorhast, wir
+ melden uns innerhalb von zwei Werktagen.
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/count-up.tsx b/modules/landingPage/ui/components/count-up.tsx
new file mode 100644
index 0000000..717f8f3
--- /dev/null
+++ b/modules/landingPage/ui/components/count-up.tsx
@@ -0,0 +1,36 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { animate, useInView } from "motion/react"
+
+/**
+ * Counts from 0 to `value` once it scrolls into view.
+ * The server renders the final number, so it is correct without JavaScript.
+ */
+export function CountUp({ value, className }: { value: number; className?: string }) {
+ const ref = useRef(null)
+ const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" })
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
+ if (!inView) {
+ el.textContent = "0"
+ return
+ }
+ const controls = animate(0, value, {
+ duration: 1.8,
+ ease: [0.16, 1, 0.3, 1],
+ onUpdate: (latest) => {
+ el.textContent = String(Math.round(latest))
+ },
+ })
+ return () => controls.stop()
+ }, [inView, value])
+
+ return (
+
+ {value}
+
+ )
+}
diff --git a/modules/landingPage/ui/components/hero/hero-section.tsx b/modules/landingPage/ui/components/hero/hero-section.tsx
new file mode 100644
index 0000000..d8635cc
--- /dev/null
+++ b/modules/landingPage/ui/components/hero/hero-section.tsx
@@ -0,0 +1,83 @@
+import { ArrowDownRight } from "lucide-react"
+
+import { site } from "@/modules/shared/config/site"
+import { Magnetic } from "@/modules/shared/ui/components/magnetic"
+
+import { HeroShader } from "./hero-shader"
+import { KineticHeadline } from "./kinetic-headline"
+
+export function HeroSection() {
+ return (
+
+
+ {/* Keeps header, label and headline readable on the brightest parts of the shader */}
+
+
+
+
+
+ (Digitalagentur aus {site.city})
+ 52.15° N / 7.34° E
+
+
+
+
+
+
+
+
+ Wir entwickeln Websites, Web-Anwendungen und KI-Automatisierungen
+ für Unternehmen, die Ergebnisse sehen wollen statt Präsentationen.
+
+
+
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/hero/hero-shader.tsx b/modules/landingPage/ui/components/hero/hero-shader.tsx
new file mode 100644
index 0000000..0b19644
--- /dev/null
+++ b/modules/landingPage/ui/components/hero/hero-shader.tsx
@@ -0,0 +1,330 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+
+import { cn } from "@/lib/utils"
+import { onIntroEnd } from "@/modules/shared/lib/intro"
+
+const VERTEX = `
+attribute vec2 a_position;
+void main() {
+ gl_Position = vec4(a_position, 0.0, 1.0);
+}
+`
+
+// Monochrome liquid chrome: domain-warped fbm noise turned into sharp
+// specular bands. The pointer acts as a lens that pushes the liquid away.
+const FRAGMENT = `
+#ifdef GL_FRAGMENT_PRECISION_HIGH
+precision highp float;
+#else
+precision mediump float;
+#endif
+uniform vec2 u_res;
+uniform float u_time;
+uniform vec2 u_mouse;
+uniform float u_force;
+uniform float u_reveal;
+
+float hash(vec2 p) {
+ p = fract(p * vec2(123.34, 456.21));
+ p += dot(p, p + 45.32);
+ return fract(p.x * p.y);
+}
+
+float noise(vec2 p) {
+ vec2 i = floor(p);
+ vec2 f = fract(p);
+ vec2 u = f * f * (3.0 - 2.0 * f);
+ return mix(
+ mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
+ mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
+ u.y
+ );
+}
+
+float fbm(vec2 p) {
+ float value = 0.0;
+ float amplitude = 0.5;
+ mat2 rotate = mat2(1.6, 1.2, -1.2, 1.6);
+ for (int i = 0; i < 5; i++) {
+ value += amplitude * noise(p);
+ p = rotate * p;
+ amplitude *= 0.5;
+ }
+ return value;
+}
+
+void main() {
+ vec2 p = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;
+ vec2 m = (u_mouse - 0.5 * u_res) / u_res.y;
+ vec2 dm = p - m;
+ float lens = exp(-dot(dm, dm) * 7.0) * u_force;
+ p -= dm * lens * 0.6;
+
+ float t = u_time * 0.045;
+ vec2 q = vec2(fbm(p * 1.3 + vec2(0.0, t)), fbm(p * 1.3 + vec2(5.2, -t)));
+ vec2 r = vec2(
+ fbm(p * 1.3 + 2.8 * q + vec2(1.7, 9.2) + 0.9 * t),
+ fbm(p * 1.3 + 2.8 * q + vec2(8.3, 2.8) - 0.7 * t)
+ );
+ float f = fbm(p * 1.3 + 3.0 * r);
+
+ float bands = 0.5 + 0.5 * cos(6.28318 * (f * 2.4 + r.y * 0.8 + lens * 0.9));
+ float chrome = pow(bands, 6.0) * 1.15 + pow(f, 2.5) * 0.4;
+
+ // Darker towards the bottom left, where the headline sits
+ vec2 uv = gl_FragCoord.xy / u_res;
+ float shade = smoothstep(-0.15, 1.1, uv.x * 0.5 + uv.y * 0.85);
+ float c = chrome * mix(0.18, 1.0, shade);
+ c *= smoothstep(1.7, 0.2, length(p * vec2(0.7, 1.0)));
+ c *= u_reveal;
+ c += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.045;
+
+ gl_FragColor = vec4(vec3(clamp(c, 0.0, 1.0)), 1.0);
+}
+`
+
+// Rendering below device resolution keeps the GPU cost low; the image is soft anyway
+const RENDER_SCALE = 0.5
+const REVEAL_MS = 1800
+
+type Renderer = {
+ draw: (uniforms: {
+ width: number
+ height: number
+ time: number
+ mouseX: number
+ mouseY: number
+ force: number
+ reveal: number
+ }) => void
+ dispose: () => void
+}
+
+function compile(gl: WebGLRenderingContext, type: number, source: string) {
+ const shader = gl.createShader(type)
+ if (!shader) return null
+ gl.shaderSource(shader, source)
+ gl.compileShader(shader)
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ console.warn("[HeroShader]", gl.getShaderInfoLog(shader))
+ gl.deleteShader(shader)
+ return null
+ }
+ return shader
+}
+
+/** Sets up program and geometry. Returns null if WebGL is not usable. */
+function createRenderer(gl: WebGLRenderingContext): Renderer | null {
+ if (gl.isContextLost()) return null
+
+ const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX)
+ const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)
+ const program = gl.createProgram()
+ if (!vertex || !fragment || !program) return null
+
+ gl.attachShader(program, vertex)
+ gl.attachShader(program, fragment)
+ gl.linkProgram(program)
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.warn("[HeroShader]", gl.getProgramInfoLog(program))
+ return null
+ }
+ gl.useProgram(program)
+
+ // One triangle that covers the whole viewport
+ const buffer = gl.createBuffer()
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
+ const position = gl.getAttribLocation(program, "a_position")
+ gl.enableVertexAttribArray(position)
+ gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0)
+
+ const uRes = gl.getUniformLocation(program, "u_res")
+ const uTime = gl.getUniformLocation(program, "u_time")
+ const uMouse = gl.getUniformLocation(program, "u_mouse")
+ const uForce = gl.getUniformLocation(program, "u_force")
+ const uReveal = gl.getUniformLocation(program, "u_reveal")
+
+ return {
+ draw({ width, height, time, mouseX, mouseY, force, reveal }) {
+ gl.viewport(0, 0, width, height)
+ gl.uniform2f(uRes, width, height)
+ gl.uniform1f(uTime, time)
+ gl.uniform2f(uMouse, mouseX, mouseY)
+ gl.uniform1f(uForce, force)
+ gl.uniform1f(uReveal, reveal)
+ gl.drawArrays(gl.TRIANGLES, 0, 3)
+ },
+ // Frees GPU resources but keeps the context alive. Losing the context on
+ // purpose would break React Strict Mode, which mounts effects twice.
+ dispose() {
+ if (gl.isContextLost()) return
+ gl.deleteBuffer(buffer)
+ gl.deleteProgram(program)
+ gl.deleteShader(vertex)
+ gl.deleteShader(fragment)
+ },
+ }
+}
+
+export function HeroShader({ className }: { className?: string }) {
+ const canvasRef = useRef(null)
+ // The CSS fallback below stays visible until the first frame is drawn
+ const [ready, setReady] = useState(false)
+
+ useEffect(() => {
+ const canvas = canvasRef.current
+ if (!canvas) return
+ const gl = canvas.getContext("webgl", {
+ antialias: false,
+ alpha: false,
+ depth: false,
+ powerPreference: "high-performance",
+ })
+ if (!gl) return
+
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ const scale = Math.min(window.devicePixelRatio, 2) * RENDER_SCALE
+
+ let renderer = createRenderer(gl)
+ let width = 1
+ let height = 1
+ let mouseX = 0
+ let mouseY = 0
+ let targetX = 0
+ let targetY = 0
+ let force = 0
+ let targetForce = 0
+ let revealStart = -1
+ let visible = true
+ let frame = 0
+ let shown = false
+ const start = performance.now()
+
+ const resize = () => {
+ width = Math.max(1, Math.round(canvas.clientWidth * scale))
+ height = Math.max(1, Math.round(canvas.clientHeight * scale))
+ canvas.width = width
+ canvas.height = height
+ if (!targetX) {
+ targetX = mouseX = width * 0.7
+ targetY = mouseY = height * 0.6
+ }
+ }
+
+ const draw = (now: number) => {
+ if (!renderer) return
+ mouseX += (targetX - mouseX) * 0.06
+ mouseY += (targetY - mouseY) * 0.06
+ force += (targetForce - force) * 0.04
+ targetForce *= 0.985
+
+ const progress = revealStart < 0 ? 0 : Math.min((now - revealStart) / REVEAL_MS, 1)
+ renderer.draw({
+ width,
+ height,
+ time: (now - start) / 1000,
+ mouseX,
+ mouseY,
+ force,
+ reveal: reduced ? 1 : 1 - Math.pow(1 - progress, 3),
+ })
+ if (!shown) {
+ shown = true
+ setReady(true)
+ }
+ }
+
+ const loop = (now: number) => {
+ if (!visible || document.hidden || !renderer) {
+ frame = 0
+ return
+ }
+ draw(now)
+ frame = requestAnimationFrame(loop)
+ }
+
+ const play = () => {
+ if (reduced) draw(performance.now())
+ else if (!frame) frame = requestAnimationFrame(loop)
+ }
+
+ const onPointerMove = (event: PointerEvent) => {
+ const rect = canvas.getBoundingClientRect()
+ targetX = (event.clientX - rect.left) * scale
+ // WebGL's origin is bottom left
+ targetY = (rect.bottom - event.clientY) * scale
+ targetForce = 1
+ }
+
+ // The GPU may drop the context (driver reset, too many tabs): show the
+ // fallback meanwhile and rebuild once the browser restores it.
+ const onContextLost = (event: Event) => {
+ event.preventDefault()
+ cancelAnimationFrame(frame)
+ frame = 0
+ renderer = null
+ shown = false
+ setReady(false)
+ }
+ const onContextRestored = () => {
+ renderer = createRenderer(gl)
+ resize()
+ play()
+ }
+
+ const observer = new IntersectionObserver(([entry]) => {
+ visible = entry.isIntersecting
+ if (visible) play()
+ })
+ const resizeObserver = new ResizeObserver(() => {
+ resize()
+ if (reduced) draw(performance.now())
+ })
+ const onVisibility = () => {
+ if (!document.hidden) play()
+ }
+
+ resize()
+ observer.observe(canvas)
+ resizeObserver.observe(canvas)
+ canvas.addEventListener("webglcontextlost", onContextLost)
+ canvas.addEventListener("webglcontextrestored", onContextRestored)
+ document.addEventListener("visibilitychange", onVisibility)
+ window.addEventListener("pointermove", onPointerMove, { passive: true })
+
+ const cleanupIntro = onIntroEnd(() => {
+ revealStart = performance.now()
+ })
+
+ play()
+
+ return () => {
+ cleanupIntro()
+ cancelAnimationFrame(frame)
+ observer.disconnect()
+ resizeObserver.disconnect()
+ canvas.removeEventListener("webglcontextlost", onContextLost)
+ canvas.removeEventListener("webglcontextrestored", onContextRestored)
+ document.removeEventListener("visibilitychange", onVisibility)
+ window.removeEventListener("pointermove", onPointerMove)
+ renderer?.dispose()
+ }
+ }, [])
+
+ return (
+
+ {/* Static fallback when WebGL is unavailable (e.g. hardware acceleration off) */}
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/hero/kinetic-headline.tsx b/modules/landingPage/ui/components/hero/kinetic-headline.tsx
new file mode 100644
index 0000000..ac37313
--- /dev/null
+++ b/modules/landingPage/ui/components/hero/kinetic-headline.tsx
@@ -0,0 +1,127 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+
+import { cn } from "@/lib/utils"
+import { onIntroEnd } from "@/modules/shared/lib/intro"
+
+// Resting state: heavy and condensed. Under the pointer: thin and wide.
+const REST = { wght: 800, wdth: 75 }
+const NEAR = { wght: 200, wdth: 125 }
+const RADIUS = 280
+const EASE = 0.14
+
+/**
+ * Headline whose letters react to the pointer along the font's weight and
+ * width axes. Each letter also slides in after the preloader (CSS).
+ */
+export function KineticHeadline({
+ lines,
+ className,
+}: {
+ lines: string[]
+ className?: string
+}) {
+ const rootRef = useRef(null)
+
+ useEffect(() => {
+ const root = rootRef.current
+ if (!root) return
+ const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ if (!fine || reduced) return
+
+ const chars = Array.from(root.querySelectorAll("[data-char]"))
+ const current = chars.map(() => 0)
+ let pointerX = -9999
+ let pointerY = -9999
+ let frame = 0
+ let active = false
+
+ const tick = () => {
+ // Read all positions first, then write, to avoid layout thrashing
+ const rects = chars.map((char) => char.getBoundingClientRect())
+ let moving = false
+
+ rects.forEach((rect, index) => {
+ const dx = pointerX - (rect.left + rect.width / 2)
+ const dy = pointerY - (rect.top + rect.height / 2)
+ const distance = Math.hypot(dx, dy)
+ const raw = Math.max(0, 1 - distance / RADIUS)
+ const target = raw * raw * (3 - 2 * raw)
+ current[index] += (target - current[index]) * EASE
+ if (Math.abs(target - current[index]) > 0.002) moving = true
+ })
+
+ chars.forEach((char, index) => {
+ const t = current[index]
+ const wght = REST.wght + (NEAR.wght - REST.wght) * t
+ const wdth = REST.wdth + (NEAR.wdth - REST.wdth) * t
+ char.style.fontVariationSettings = `"wght" ${wght.toFixed(0)}, "wdth" ${wdth.toFixed(1)}`
+ })
+
+ frame = moving ? requestAnimationFrame(tick) : 0
+ }
+
+ const onMove = (event: PointerEvent) => {
+ if (!active) return
+ pointerX = event.clientX
+ pointerY = event.clientY
+ if (!frame) frame = requestAnimationFrame(tick)
+ }
+ const onLeave = () => {
+ pointerX = pointerY = -9999
+ if (!frame) frame = requestAnimationFrame(tick)
+ }
+
+ // Wait for the letters to finish sliding in
+ let timeout = 0
+ const cleanupIntro = onIntroEnd(() => {
+ timeout = window.setTimeout(() => (active = true), 1400)
+ })
+
+ window.addEventListener("pointermove", onMove, { passive: true })
+ document.documentElement.addEventListener("pointerleave", onLeave)
+ return () => {
+ cleanupIntro()
+ window.clearTimeout(timeout)
+ cancelAnimationFrame(frame)
+ window.removeEventListener("pointermove", onMove)
+ document.documentElement.removeEventListener("pointerleave", onLeave)
+ }
+ }, [])
+
+ let charIndex = 0
+
+ return (
+
+ {lines.join(" ")}
+ {lines.map((line) => (
+
+ {line.split(" ").map((word, wordIndex) => (
+
+ {word.split("").map((char) => {
+ const index = charIndex++
+ return (
+
+ {char}
+
+ )
+ })}
+ {wordIndex < line.split(" ").length - 1 ? " " : null}
+
+ ))}
+
+ ))}
+
+ )
+}
diff --git a/modules/landingPage/ui/components/marquee-band.tsx b/modules/landingPage/ui/components/marquee-band.tsx
new file mode 100644
index 0000000..1f56dd4
--- /dev/null
+++ b/modules/landingPage/ui/components/marquee-band.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { useRef } from "react"
+import {
+ motion,
+ useAnimationFrame,
+ useMotionValue,
+ useReducedMotion,
+ useScroll,
+ useSpring,
+ useTransform,
+ useVelocity,
+} from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+function wrap(min: number, max: number, value: number) {
+ const range = max - min
+ return ((((value - min) % range) + range) % range) + min
+}
+
+/**
+ * Endless text band. Scrolling speeds it up, scrolling up reverses it.
+ * `baseVelocity` is in percent of one copy per second.
+ */
+function VelocityRow({
+ items,
+ baseVelocity,
+ className,
+}: {
+ items: string[]
+ baseVelocity: number
+ className?: string
+}) {
+ const reduced = useReducedMotion()
+ const baseX = useMotionValue(0)
+ const { scrollY } = useScroll()
+ const velocity = useVelocity(scrollY)
+ const smoothVelocity = useSpring(velocity, { damping: 50, stiffness: 400 })
+ const factor = useTransform(smoothVelocity, [0, 1000], [0, 4], { clamp: false })
+ const x = useTransform(baseX, (value) => `${wrap(-50, 0, value)}%`)
+ const direction = useRef(1)
+
+ useAnimationFrame((_, delta) => {
+ if (reduced) return
+ const f = factor.get()
+ if (f < 0) direction.current = -1
+ else if (f > 0) direction.current = 1
+ let move = direction.current * baseVelocity * (delta / 1000)
+ move += move * Math.abs(f)
+ baseX.set(baseX.get() + move)
+ })
+
+ const content = (
+
+ {items.map((item, index) => (
+
+ {item}
+ ✦
+
+ ))}
+
+ )
+
+ return (
+
+
+ {content}
+ {content}
+
+
+ )
+}
+
+/** Two crossing bands between hero and statement. */
+export function MarqueeBand({ items }: { items: string[] }) {
+ return (
+
+ )
+}
diff --git a/modules/landingPage/ui/components/pricing/animated-number.tsx b/modules/landingPage/ui/components/pricing/animated-number.tsx
new file mode 100644
index 0000000..f0544db
--- /dev/null
+++ b/modules/landingPage/ui/components/pricing/animated-number.tsx
@@ -0,0 +1,49 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { animate } from "motion/react"
+
+/**
+ * Number that rolls smoothly to its new value. React renders only the first
+ * value; every later update is written by the animation, so the text never
+ * flashes to the final value before rolling.
+ * `format` must be a stable function (defined at module level).
+ */
+export function AnimatedNumber({
+ value,
+ format,
+ className,
+}: {
+ value: number
+ format: (value: number) => string
+ className?: string
+}) {
+ const ref = useRef(null)
+ const displayed = useRef(value)
+ const [initial] = useState(value)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el || displayed.current === value) return
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ displayed.current = value
+ el.textContent = format(value)
+ return
+ }
+ const controls = animate(displayed.current, value, {
+ duration: 0.7,
+ ease: [0.16, 1, 0.3, 1],
+ onUpdate: (latest) => {
+ displayed.current = latest
+ el.textContent = format(latest)
+ },
+ })
+ return () => controls.stop()
+ }, [value, format])
+
+ return (
+
+ {format(initial)}
+
+ )
+}
diff --git a/modules/landingPage/ui/components/pricing/price-calculator.tsx b/modules/landingPage/ui/components/pricing/price-calculator.tsx
new file mode 100644
index 0000000..46f198a
--- /dev/null
+++ b/modules/landingPage/ui/components/pricing/price-calculator.tsx
@@ -0,0 +1,376 @@
+"use client"
+
+import { useEffect, useId, useRef, useState } from "react"
+import { ArrowDown, ArrowRight, Check, Plus } from "lucide-react"
+
+import { play } from "@/lib/sound"
+import { cn } from "@/lib/utils"
+import { pricing, type ProjectTypeId } from "@/modules/landingPage/data/pricing"
+import { prefillContact } from "@/modules/landingPage/lib/contact-prefill"
+import {
+ estimate,
+ extrasFor,
+ formatEuro,
+ formatNumber,
+ getProjectType,
+ initialCalculatorState,
+ summarize,
+ toDisplayPrice,
+ vatNote,
+ type CalculatorState,
+} from "@/modules/landingPage/lib/pricing"
+
+import { AnimatedNumber } from "./animated-number"
+
+// Selectable tile: the real input is visually hidden, the label shows its state
+const tileClass =
+ "group relative flex cursor-pointer flex-col gap-1 rounded-2xl border border-line p-5 transition-[background-color,border-color,color] duration-300 hover:border-fg/60 has-checked:border-fg has-checked:bg-fg has-checked:text-bg has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg"
+const tileMuted = "text-fg-muted transition-colors duration-300 group-has-checked:text-bg/70"
+
+export function PriceCalculator({ className }: { className?: string }) {
+ const id = useId()
+ const [state, setState] = useState(initialCalculatorState)
+ const rootRef = useRef(null)
+ const resultRef = useRef(null)
+ const [barVisible, setBarVisible] = useState(false)
+
+ const type = getProjectType(state.type)
+ const result = estimate(state)
+ const extras = extrasFor(state.type)
+
+ const update = (patch: Partial) => setState((current) => ({ ...current, ...patch }))
+
+ const toggleIn = (key: "extras" | "monthly", value: string) =>
+ setState((current) => ({
+ ...current,
+ [key]: current[key].includes(value)
+ ? current[key].filter((item) => item !== value)
+ : [...current[key], value],
+ }))
+
+ const selectType = (next: ProjectTypeId) => {
+ const nextType = getProjectType(next)
+ update({
+ type: next,
+ pages: nextType.pages ? Math.min(Math.max(state.pages, 1), nextType.pages.max) : state.pages,
+ })
+ }
+
+ // Mobile: a floating price bar while the calculator is on screen but the result card is not
+ useEffect(() => {
+ const root = rootRef.current
+ const card = resultRef.current
+ if (!root || !card) return
+ let rootVisible = false
+ let cardVisible = false
+ const observer = new IntersectionObserver((entries) => {
+ for (const entry of entries) {
+ if (entry.target === root) rootVisible = entry.isIntersecting
+ if (entry.target === card) cardVisible = entry.isIntersecting
+ }
+ setBarVisible(rootVisible && !cardVisible)
+ })
+ observer.observe(root)
+ observer.observe(card)
+ return () => observer.disconnect()
+ }, [])
+
+ // Steps adapt to the project type, so their numbers are computed
+ let step = 0
+ const next = () => ++step
+
+ return (
+
+
+
+
+ {pricing.projectTypes.map((option) => (
+
+ selectType(option.id)}
+ className="sr-only"
+ />
+
+ {option.label}
+
+
+ {option.description}
+
+ ab {formatEuro(toDisplayPrice(option.base))}
+
+
+ ))}
+
+
+
+ {type.pages ? (
+
+
+
+ {state.pages}
+
+
+ {state.pages === 1 ? "Seite" : "Seiten"}
+
+
+ {
+ update({ pages: Number(event.target.value) })
+ // A tick per step makes the slider feel mechanical
+ play("hover")
+ }}
+ aria-label="Anzahl Seiten"
+ className="mt-6 h-2 w-full cursor-pointer accent-fg"
+ />
+
+ {type.pages.included} Seiten inklusive, jede weitere{" "}
+ {formatEuro(toDisplayPrice(type.pages.pricePerPage))}
+
+
+ ) : null}
+
+ {type.sizedByComplexity ? (
+
+
+ {pricing.complexity.map((level) => (
+
+ update({ complexity: level.id })}
+ className="sr-only"
+ />
+ {level.label}
+ {level.hint}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+ {pricing.included.map((item) => (
+
+
+ {item}
+ (immer inklusive)
+
+ ))}
+
+
+
+
+
+ {pricing.monthly.map((service) => (
+
+ toggleIn("monthly", service.id)}
+ className="sr-only"
+ />
+
+ {service.label}
+
+
+ {service.description}
+
+ {formatEuro(toDisplayPrice(service.price))} / Monat
+
+
+ ))}
+
+
+
+
+
+ {[
+ { express: false, label: "Standard", hint: "Normale Planung, kein Aufpreis" },
+ {
+ express: true,
+ label: pricing.express.label,
+ hint: `${pricing.express.hint}, +${Math.round((pricing.express.factor - 1) * 100)} %`,
+ },
+ ].map((option) => (
+
+ update({ express: option.express })}
+ className="sr-only"
+ />
+ {option.label}
+ {option.hint}
+
+ ))}
+
+
+
+
+ {/* Result */}
+
+
+ {/* Mobile: keeps the estimate in sight while choosing */}
+
+
+ {formatNumber(result.low)} – {formatEuro(result.high)}
+
+
+
+ Zur Schätzung
+
+
+
+ )
+}
+
+function Step({
+ index,
+ title,
+ hint,
+ children,
+}: {
+ index: number
+ title: string
+ hint?: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+ {String(index).padStart(2, "0")}
+
+ {title}
+ {hint ? (
+ {hint}
+ ) : null}
+
+ {children}
+
+ )
+}
diff --git a/modules/landingPage/ui/components/pricing/pricing-section.tsx b/modules/landingPage/ui/components/pricing/pricing-section.tsx
new file mode 100644
index 0000000..e778e05
--- /dev/null
+++ b/modules/landingPage/ui/components/pricing/pricing-section.tsx
@@ -0,0 +1,26 @@
+import { PriceCalculator } from "./price-calculator"
+
+export function PricingSection() {
+ return (
+
+
+
+
+
(Preise)
+
+ Was kostet
+
+ das?
+
+
+
+ Stell dein Projekt zusammen und sieh sofort eine ehrliche Preisspanne.
+ Keine versteckten Kosten, kein „Preis auf Anfrage“.
+
+
+
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/process-section.tsx b/modules/landingPage/ui/components/process-section.tsx
new file mode 100644
index 0000000..aee2f10
--- /dev/null
+++ b/modules/landingPage/ui/components/process-section.tsx
@@ -0,0 +1,64 @@
+import { processSteps } from "@/modules/landingPage/data/content"
+
+// Each card sticks a little lower than the previous one, so they stack up.
+const cardThemes = ["light", "dark", "light", "dark"] as const
+
+export function ProcessSection() {
+ return (
+
+
+
+
+
(Prozess)
+ {/* Sized to fit its four columns, so it never runs into the cards */}
+
+ Vier
+
+ Schritte.
+
+
+ Vom ersten Gespräch bis zum Launch. Mit Festpreis und ohne
+ Überraschungen.
+
+
+
+
+ {/* One empty column between title and cards */}
+
+ {processSteps.map((step, index) => (
+
+
+
+ Schritt {index + 1} von {processSteps.length}
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
+
+
{step.title}
+
{step.description}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/projects/project-visual.tsx b/modules/landingPage/ui/components/projects/project-visual.tsx
new file mode 100644
index 0000000..68114d7
--- /dev/null
+++ b/modules/landingPage/ui/components/projects/project-visual.tsx
@@ -0,0 +1,105 @@
+import Image from "next/image"
+
+import type { Project } from "@/modules/landingPage/data/content"
+
+/**
+ * Abstract stand-in visuals until real screenshots exist.
+ * Each project gets its own composition so the track does not feel repetitive.
+ */
+export function ProjectVisual({ project, index }: { project: Project; index: number }) {
+ if (project.image) {
+ return (
+
+ )
+ }
+
+ const variant = index % 3
+
+ if (variant === 0) {
+ // Typography poster, sized so the longest word always fits the card
+ const words = project.title.split(" ")
+ const longest = Math.max(...words.map((word) => word.length))
+ const fontSize = Math.min(14, 70 / (longest * 0.5))
+ return (
+
+ {words.map((word, i) => (
+
+ {word}
+
+ ))}
+
+ )
+ }
+
+ if (variant === 1) {
+ // Interface skeleton
+ return (
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+ {["128", "4.2k", "99%"].map((value) => (
+
+ ))}
+
+
+
+
+ )
+ }
+
+ // Orbit: automation as moving parts
+ return (
+
+ {[1, 2, 3, 4].map((ring) => (
+
+ ))}
+ {[0, 60, 150, 230, 300].map((angle, i) => (
+
+ ))}
+
input → ki → output
+
+ )
+}
diff --git a/modules/landingPage/ui/components/projects/projects-showcase.tsx b/modules/landingPage/ui/components/projects/projects-showcase.tsx
new file mode 100644
index 0000000..8670117
--- /dev/null
+++ b/modules/landingPage/ui/components/projects/projects-showcase.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { motion, useScroll, useTransform } from "motion/react"
+
+import { projects } from "@/modules/landingPage/data/content"
+import { Magnetic } from "@/modules/shared/ui/components/magnetic"
+
+import { ProjectVisual } from "./project-visual"
+
+/**
+ * Vertical scroll drives a horizontal track (md and up). On small screens the
+ * cards simply stack, because the track then has no horizontal overflow.
+ */
+export function ProjectsShowcase() {
+ const sectionRef = useRef(null)
+ const trackRef = useRef(null)
+ const [distance, setDistance] = useState(0)
+
+ useEffect(() => {
+ const track = trackRef.current
+ if (!track) return
+ const desktop = window.matchMedia("(min-width: 48rem)")
+ const measure = () => {
+ setDistance(desktop.matches ? Math.max(0, track.scrollWidth - window.innerWidth) : 0)
+ }
+ const observer = new ResizeObserver(measure)
+ observer.observe(track)
+ desktop.addEventListener("change", measure)
+ return () => {
+ observer.disconnect()
+ desktop.removeEventListener("change", measure)
+ }
+ }, [])
+
+ const { scrollYProgress } = useScroll({
+ target: sectionRef,
+ offset: ["start start", "end end"],
+ })
+ const x = useTransform(scrollYProgress, (value) => -value * distance)
+ const progress = useTransform(scrollYProgress, (value) => (distance ? value : 0))
+
+ return (
+
+
+
+ {/* Intro panel */}
+
+
+ (Ausgewählte Arbeiten — {String(projects.length).padStart(2, "0")})
+
+
+ Was wir
+
+ gebaut
+
+ haben.
+
+
+ Websites, interne Tools und Automatisierungen. Scroll weiter.
+
+
+
+ {projects.map((project, index) => (
+
+
+
+
+ ({String(index + 1).padStart(2, "0")})
+
+
+
+
+
+ {project.url ? (
+
+ {project.title}
+
+ ) : (
+ project.title
+ )}
+
+
{project.benefit}
+
+
+ Jahr
+ {project.year}
+ Kunde
+ {project.client}
+
+
+
+ {project.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ ))}
+
+ {/* Outro: the next project could be yours */}
+
+
+
+ {/* Progress of the horizontal journey */}
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/scroll-statement.tsx b/modules/landingPage/ui/components/scroll-statement.tsx
new file mode 100644
index 0000000..f7a4bcc
--- /dev/null
+++ b/modules/landingPage/ui/components/scroll-statement.tsx
@@ -0,0 +1,68 @@
+"use client"
+
+import { useRef } from "react"
+import {
+ motion,
+ useReducedMotion,
+ useScroll,
+ useTransform,
+ type MotionValue,
+} from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+function Word({
+ children,
+ progress,
+ range,
+}: {
+ children: string
+ progress: MotionValue
+ range: [number, number]
+}) {
+ const opacity = useTransform(progress, range, [0.14, 1])
+ return (
+
+ {children}{" "}
+
+ )
+}
+
+/** A statement whose words light up one after another while scrolling. */
+export function ScrollStatement({
+ text,
+ className,
+}: {
+ text: string
+ className?: string
+}) {
+ const ref = useRef(null)
+ const reduced = useReducedMotion()
+ const { scrollYProgress } = useScroll({
+ target: ref,
+ offset: ["start 0.85", "end 0.5"],
+ })
+ const words = text.split(" ")
+
+ if (reduced) {
+ return (
+
+ {text}
+
+ )
+ }
+
+ return (
+
+ {words.map((word, index) => (
+
+ {word}
+
+ ))}
+
+ )
+}
diff --git a/modules/landingPage/ui/components/services-section.tsx b/modules/landingPage/ui/components/services-section.tsx
new file mode 100644
index 0000000..98ce899
--- /dev/null
+++ b/modules/landingPage/ui/components/services-section.tsx
@@ -0,0 +1,66 @@
+import { services } from "@/modules/landingPage/data/content"
+
+/**
+ * Giant rows. On hover a black fill wipes up from the bottom, the name slides
+ * right and the details unfold. Touch devices always show the details.
+ * TODO: link each row to /leistungen/[slug] once the subpages exist.
+ */
+export function ServicesSection() {
+ return (
+
+
+
+ Was wir
+
+ machen
+
+
+ Fünf Disziplinen, alles aus einer Hand. Ohne Subunternehmer, ohne
+ Stille Post zwischen Design und Entwicklung.
+
+
+
+
+ {services.map((service, index) => (
+
+
+ {/*
+ * Flex with wrapping instead of fixed columns: long names like
+ * "Softwareentwicklung" push the tags onto their own line
+ * instead of running into them.
+ */}
+
+
+ ({String(index + 1).padStart(2, "0")})
+
+
+ {service.title}
+
+
+ {service.tags.map((tag) => (
+ {tag}
+ ))}
+
+
+
+
+
+ {service.description}
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/stats-section.tsx b/modules/landingPage/ui/components/stats-section.tsx
new file mode 100644
index 0000000..a575049
--- /dev/null
+++ b/modules/landingPage/ui/components/stats-section.tsx
@@ -0,0 +1,24 @@
+import { stats } from "@/modules/landingPage/data/content"
+
+import { CountUp } from "./count-up"
+
+export function StatsSection() {
+ return (
+
+
+ {stats.map((stat) => (
+
+
+ {stat.label}
+
+
+ {stat.prefix}
+
+ {stat.suffix}
+
+
+ ))}
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/tools-section.tsx b/modules/landingPage/ui/components/tools-section.tsx
new file mode 100644
index 0000000..aad1ce1
--- /dev/null
+++ b/modules/landingPage/ui/components/tools-section.tsx
@@ -0,0 +1,43 @@
+import { ToolPanel } from "./tools/tool-panel"
+
+/** Usable software instead of a service description. */
+export function ToolsSection() {
+ return (
+
+ )
+}
diff --git a/modules/landingPage/ui/components/tools/contrast-tool.tsx b/modules/landingPage/ui/components/tools/contrast-tool.tsx
new file mode 100644
index 0000000..890c123
--- /dev/null
+++ b/modules/landingPage/ui/components/tools/contrast-tool.tsx
@@ -0,0 +1,73 @@
+"use client"
+
+import { useId, useState } from "react"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { parseHex, runContrastCheck, type OutputLine } from "@/modules/landingPage/lib/tools"
+
+import { ToolOutput } from "./tool-output"
+
+export function ContrastTool() {
+ const id = useId()
+ const [fg, setFg] = useState("#767676")
+ const [bg, setBg] = useState("#ffffff")
+ const [lines, setLines] = useState(null)
+
+ const preview = parseHex(fg) && parseHex(bg) ? { color: fg, background: bg } : undefined
+
+ const onSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ setLines(runContrastCheck(fg, bg))
+ }
+
+ return (
+
+
+ Prüft, ob Text auf einem Hintergrund die WCAG-Kontrastwerte erfüllt, die
+ das BFSG verlangt.
+
+
+
+
+ Textfarbe
+ setFg(event.target.value)}
+ spellCheck={false}
+ autoComplete="off"
+ className="font-mono"
+ />
+
+
+ Hintergrund
+ setBg(event.target.value)}
+ spellCheck={false}
+ autoComplete="off"
+ className="font-mono"
+ />
+
+
+ Aa
+
+
+
+
+
+ Kontrast prüfen
+
+
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/tools/snippet-tool.tsx b/modules/landingPage/ui/components/tools/snippet-tool.tsx
new file mode 100644
index 0000000..5cc28d7
--- /dev/null
+++ b/modules/landingPage/ui/components/tools/snippet-tool.tsx
@@ -0,0 +1,71 @@
+"use client"
+
+import { useId, useState } from "react"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { runSnippetCheck, truncate, type OutputLine } from "@/modules/landingPage/lib/tools"
+
+import { ToolOutput } from "./tool-output"
+
+export function SnippetTool() {
+ const id = useId()
+ const [title, setTitle] = useState("Webdesign in Steinfurt – since3 Digitalagentur")
+ const [description, setDescription] = useState(
+ "Schnelle, barrierefreie Websites aus Steinfurt. Kostenloses Erstgespräch vereinbaren."
+ )
+ const [lines, setLines] = useState(null)
+
+ const onSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ setLines(runSnippetCheck(title, description))
+ }
+
+ return (
+
+
+ Zeigt, wie dein Suchergebnis aussieht, und prüft Länge und Suchbegriffe
+ von Titel und Beschreibung.
+
+
+
+
+ Seitentitel
+ setTitle(event.target.value)}
+ />
+
+
+ Meta-Beschreibung
+ setDescription(event.target.value)}
+ className="min-h-20"
+ />
+
+
+
+ {/* Live search result preview */}
+
+
since3.de
+
{truncate(title, 60) || "Seitentitel"}
+
+ {truncate(description, 160) || "Meta-Beschreibung"}
+
+
+
+
+
+ Snippet prüfen
+
+
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/components/tools/tool-output.tsx b/modules/landingPage/ui/components/tools/tool-output.tsx
new file mode 100644
index 0000000..d8ab6e5
--- /dev/null
+++ b/modules/landingPage/ui/components/tools/tool-output.tsx
@@ -0,0 +1,81 @@
+"use client"
+
+import { useEffect, useState, useSyncExternalStore } from "react"
+
+import { play } from "@/lib/sound"
+import { cn } from "@/lib/utils"
+import type { OutputLine, OutputTone } from "@/modules/landingPage/lib/tools"
+
+// Slow enough that each typewriter tick is audible on its own
+const LINE_DELAY_MS = 55
+const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
+
+const toneClass: Record = {
+ default: "text-white",
+ muted: "text-ink-400",
+ success: "text-signal-success",
+ error: "text-signal-error",
+}
+
+function subscribeReducedMotion(onChange: () => void) {
+ const query = window.matchMedia(REDUCED_MOTION)
+ query.addEventListener("change", onChange)
+ return () => query.removeEventListener("change", onChange)
+}
+
+function usePrefersReducedMotion() {
+ return useSyncExternalStore(
+ subscribeReducedMotion,
+ () => window.matchMedia(REDUCED_MOTION).matches,
+ () => false
+ )
+}
+
+/** Terminal-style output that appears line by line (motion #7). */
+export function ToolOutput({
+ lines,
+ placeholder,
+}: {
+ lines: OutputLine[] | null
+ placeholder: string
+}) {
+ const reduced = usePrefersReducedMotion()
+ const [reveal, setReveal] = useState({ lines, visible: 0 })
+
+ // A new result restarts the reveal (state adjusted during render, not in an effect)
+ if (reveal.lines !== lines) setReveal({ lines, visible: 0 })
+
+ useEffect(() => {
+ if (!lines || reduced) return
+ let shown = 0
+ const interval = window.setInterval(() => {
+ shown += 1
+ play("tick")
+ setReveal({ lines, visible: shown })
+ if (shown >= lines.length) window.clearInterval(interval)
+ }, LINE_DELAY_MS)
+ return () => window.clearInterval(interval)
+ }, [lines, reduced])
+
+ const visible = reduced ? (lines?.length ?? 0) : reveal.visible
+
+ return (
+
+ {/* Announce the complete result once, not every single line */}
+
+ {lines ? lines.slice(1).map((line) => line.text).join(". ") : ""}
+
+ {lines ? (
+
+ {lines.slice(0, visible).map((line, index) => (
+
+ {line.text}
+
+ ))}
+
+ ) : (
+
{placeholder}
+ )}
+
+ )
+}
diff --git a/modules/landingPage/ui/components/tools/tool-panel.tsx b/modules/landingPage/ui/components/tools/tool-panel.tsx
new file mode 100644
index 0000000..1647f35
--- /dev/null
+++ b/modules/landingPage/ui/components/tools/tool-panel.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { useId, useRef, useState } from "react"
+import { motion } from "motion/react"
+
+import { cn } from "@/lib/utils"
+
+import { ContrastTool } from "./contrast-tool"
+import { SnippetTool } from "./snippet-tool"
+
+const TOOLS = [
+ { key: "kontrast", label: "Kontrast-Check", Component: ContrastTool },
+ { key: "snippet", label: "Snippet-Check", Component: SnippetTool },
+] as const
+
+type ToolKey = (typeof TOOLS)[number]["key"]
+
+/** Glass panel hosting the live tools, with accessible tabs. */
+export function ToolPanel() {
+ const id = useId()
+ const [active, setActive] = useState("kontrast")
+ const tabRefs = useRef>({})
+
+ const select = (key: ToolKey) => {
+ if (key === active) return
+ setActive(key)
+ }
+
+ // Arrow keys move between tabs (WAI-ARIA tabs pattern)
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ const index = TOOLS.findIndex((tool) => tool.key === active)
+ const offset = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0
+ if (!offset) return
+ event.preventDefault()
+ const next = TOOLS[(index + offset + TOOLS.length) % TOOLS.length]
+ select(next.key)
+ tabRefs.current[next.key]?.focus()
+ }
+
+ const ActiveTool = TOOLS.find((tool) => tool.key === active)!.Component
+
+ return (
+
+
+ {TOOLS.map((tool) => {
+ const selected = tool.key === active
+ return (
+ {
+ tabRefs.current[tool.key] = node
+ }}
+ role="tab"
+ type="button"
+ id={`${id}-tab-${tool.key}`}
+ aria-selected={selected}
+ aria-controls={`${id}-panel`}
+ tabIndex={selected ? 0 : -1}
+ onClick={() => select(tool.key)}
+ className={cn(
+ "relative rounded-full px-4 py-2 font-mono text-label transition-colors duration-200",
+ selected ? "text-black" : "text-ink-300 hover:text-white"
+ )}
+ >
+ {selected ? (
+
+ ) : null}
+ {tool.label}
+
+ )
+ })}
+
+
+
+ {/* key resets the tool state when switching tabs */}
+
+
+
+ )
+}
diff --git a/modules/landingPage/ui/views/landing-page-view.tsx b/modules/landingPage/ui/views/landing-page-view.tsx
new file mode 100644
index 0000000..6ef2fbc
--- /dev/null
+++ b/modules/landingPage/ui/views/landing-page-view.tsx
@@ -0,0 +1,42 @@
+import { aboutStatement, services } from "@/modules/landingPage/data/content"
+import { AboutSection } from "@/modules/landingPage/ui/components/about-section"
+import { AiVisibilitySection } from "@/modules/landingPage/ui/components/ai-visibility-section"
+import { ContactSection } from "@/modules/landingPage/ui/components/contact-section"
+import { HeroSection } from "@/modules/landingPage/ui/components/hero/hero-section"
+import { MarqueeBand } from "@/modules/landingPage/ui/components/marquee-band"
+import { PricingSection } from "@/modules/landingPage/ui/components/pricing/pricing-section"
+import { ProcessSection } from "@/modules/landingPage/ui/components/process-section"
+import { ProjectsShowcase } from "@/modules/landingPage/ui/components/projects/projects-showcase"
+import { ScrollStatement } from "@/modules/landingPage/ui/components/scroll-statement"
+import { ServicesSection } from "@/modules/landingPage/ui/components/services-section"
+import { StatsSection } from "@/modules/landingPage/ui/components/stats-section"
+import { ToolsSection } from "@/modules/landingPage/ui/components/tools-section"
+
+// Repeated so one copy of the band is always wider than the viewport
+const marqueeItems = [...services, ...services].map((service) => service.title)
+
+export function LandingPageView() {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/modules/shared/config/site.ts b/modules/shared/config/site.ts
new file mode 100644
index 0000000..36b980e
--- /dev/null
+++ b/modules/shared/config/site.ts
@@ -0,0 +1,40 @@
+// Site-wide facts used by header, footer, metadata and the contact section.
+
+export const site = {
+ name: "since3",
+ url: "https://since3.de",
+ description:
+ "since3 ist eine Digitalagentur aus Steinfurt. Wir entwickeln Websites, Software und KI-Automatisierungen für Unternehmen im Münsterland und darüber hinaus.",
+ email: "info@since3.de",
+ phone: "+49 176 47601638",
+ phoneHref: "tel:+4917647601638",
+ city: "Steinfurt",
+} as const
+
+export const mainNav = [
+ { label: "Projekte", href: "#projekte" },
+ { label: "Leistungen", href: "#leistungen" },
+ { label: "Tools", href: "#tools" },
+ { label: "Prozess", href: "#prozess" },
+ { label: "Preise", href: "#preise" },
+ { label: "Über uns", href: "#ueber-uns" },
+] as const
+
+// Regional terms for local SEO.
+// TODO: turn these into links once the regional landing pages exist.
+export const regions = [
+ "Steinfurt",
+ "Münster",
+ "Rheine",
+ "Emsdetten",
+ "Greven",
+ "Ochtrup",
+ "Borghorst",
+ "Burgsteinfurt",
+] as const
+
+export const legalNav = [
+ { label: "Impressum", href: "/impressum" },
+ { label: "Datenschutz", href: "/datenschutz" },
+ { label: "Barrierefreiheit", href: "/barrierefreiheit" },
+] as const
diff --git a/modules/shared/lib/intro.ts b/modules/shared/lib/intro.ts
new file mode 100644
index 0000000..e7c02a0
--- /dev/null
+++ b/modules/shared/lib/intro.ts
@@ -0,0 +1,25 @@
+// Shared contract between the inline intro script, the preloader and
+// everything that waits for the intro to finish (smooth scroll, hero shader).
+
+export const INTRO_CLASS = "is-intro"
+export const INTRO_END_EVENT = "since3:intro-end"
+export const INTRO_STORAGE_KEY = "since3:intro-seen"
+
+// Runs synchronously before the first paint. Plays the intro once per session
+// and never for people who prefer reduced motion. The timeout is a failsafe in
+// case the JavaScript bundle never arrives.
+export const introScript = `(function(){try{var d=document.documentElement;if(!sessionStorage.getItem("${INTRO_STORAGE_KEY}")&&!matchMedia("(prefers-reduced-motion: reduce)").matches){d.classList.add("${INTRO_CLASS}");setTimeout(function(){d.classList.remove("${INTRO_CLASS}")},8000)}}catch(e){}})()`
+
+export function isIntroRunning() {
+ return document.documentElement.classList.contains(INTRO_CLASS)
+}
+
+/** Calls `callback` once the intro is over (immediately if there is none). */
+export function onIntroEnd(callback: () => void) {
+ if (!isIntroRunning()) {
+ callback()
+ return () => {}
+ }
+ window.addEventListener(INTRO_END_EVENT, callback, { once: true })
+ return () => window.removeEventListener(INTRO_END_EVENT, callback)
+}
diff --git a/modules/shared/ui/components/custom-cursor.tsx b/modules/shared/ui/components/custom-cursor.tsx
new file mode 100644
index 0000000..e34e841
--- /dev/null
+++ b/modules/shared/ui/components/custom-cursor.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+
+import { cn } from "@/lib/utils"
+
+type CursorMode = "hidden" | "default" | "hover" | "label"
+
+const INTERACTIVE = "a, button, summary, label, [role='tab'], [data-cursor]"
+const FOLLOW = 0.22
+
+/**
+ * A blend-mode dot that inverts whatever is below it. It grows over
+ * interactive elements and shows a label over elements with `data-cursor`.
+ */
+export function CustomCursor() {
+ const ref = useRef(null)
+ const [mode, setMode] = useState("hidden")
+ const [label, setLabel] = useState("")
+ const [pressed, setPressed] = useState(false)
+
+ useEffect(() => {
+ const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ if (!fine || reduced) return
+
+ const root = document.documentElement
+ root.classList.add("has-cursor")
+
+ let x = -100
+ let y = -100
+ let targetX = -100
+ let targetY = -100
+ let frame = 0
+
+ const tick = () => {
+ x += (targetX - x) * FOLLOW
+ y += (targetY - y) * FOLLOW
+ if (ref.current) ref.current.style.transform = `translate3d(${x}px, ${y}px, 0)`
+ const settled = Math.abs(targetX - x) < 0.1 && Math.abs(targetY - y) < 0.1
+ frame = settled ? 0 : requestAnimationFrame(tick)
+ }
+
+ const onMove = (event: PointerEvent) => {
+ targetX = event.clientX
+ targetY = event.clientY
+ // First movement after load or after leaving the window
+ setMode((current) => (current === "hidden" ? "default" : current))
+ if (!frame) frame = requestAnimationFrame(tick)
+ }
+
+ const onOver = (event: PointerEvent) => {
+ const target = (event.target as Element | null)?.closest(INTERACTIVE)
+ if (target?.dataset.cursor) {
+ setLabel(target.dataset.cursor)
+ setMode("label")
+ } else {
+ setMode(target ? "hover" : "default")
+ }
+ }
+
+ const onLeaveWindow = () => setMode("hidden")
+ const onDown = () => setPressed(true)
+ const onUp = () => setPressed(false)
+
+ window.addEventListener("pointermove", onMove, { passive: true })
+ document.addEventListener("pointerover", onOver)
+ document.documentElement.addEventListener("pointerleave", onLeaveWindow)
+ window.addEventListener("pointerdown", onDown)
+ window.addEventListener("pointerup", onUp)
+
+ return () => {
+ root.classList.remove("has-cursor")
+ cancelAnimationFrame(frame)
+ window.removeEventListener("pointermove", onMove)
+ document.removeEventListener("pointerover", onOver)
+ document.documentElement.removeEventListener("pointerleave", onLeaveWindow)
+ window.removeEventListener("pointerdown", onDown)
+ window.removeEventListener("pointerup", onUp)
+ }
+ }, [])
+
+ const scale = {
+ hidden: 0,
+ default: 0.12,
+ hover: 0.5,
+ label: 1,
+ }[mode]
+
+ return (
+
+ )
+}
diff --git a/modules/shared/ui/components/inline-script.tsx b/modules/shared/ui/components/inline-script.tsx
new file mode 100644
index 0000000..fab0589
--- /dev/null
+++ b/modules/shared/ui/components/inline-script.tsx
@@ -0,0 +1,13 @@
+/**
+ * Script that runs during HTML parsing, before the first paint.
+ * On the client it is rendered inert so React does not warn about script tags.
+ */
+export function InlineScript({ html }: { html: string }) {
+ return (
+
+ )
+}
diff --git a/modules/shared/ui/components/live-clock.tsx b/modules/shared/ui/components/live-clock.tsx
new file mode 100644
index 0000000..209b122
--- /dev/null
+++ b/modules/shared/ui/components/live-clock.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import { useSyncExternalStore } from "react"
+
+const formatter = new Intl.DateTimeFormat("de-DE", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZone: "Europe/Berlin",
+})
+
+function subscribe(onChange: () => void) {
+ const interval = window.setInterval(onChange, 1000)
+ return () => window.clearInterval(interval)
+}
+
+/** Local time in Steinfurt, ticking every second. */
+export function LiveClock({ className }: { className?: string }) {
+ const time = useSyncExternalStore(
+ subscribe,
+ () => formatter.format(new Date()),
+ () => "--:--:--"
+ )
+
+ return (
+
+ {time}
+
+ )
+}
diff --git a/modules/shared/ui/components/magnetic.tsx b/modules/shared/ui/components/magnetic.tsx
new file mode 100644
index 0000000..0d3b282
--- /dev/null
+++ b/modules/shared/ui/components/magnetic.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+
+import { cn } from "@/lib/utils"
+
+/** Pulls its child towards the pointer while hovered. */
+export function Magnetic({
+ children,
+ strength = 0.35,
+ className,
+}: {
+ children: React.ReactNode
+ strength?: number
+ className?: string
+}) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ if (!fine || reduced) return
+
+ const onMove = (event: PointerEvent) => {
+ const rect = el.getBoundingClientRect()
+ const dx = event.clientX - (rect.left + rect.width / 2)
+ const dy = event.clientY - (rect.top + rect.height / 2)
+ el.style.translate = `${dx * strength}px ${dy * strength}px`
+ }
+ const onLeave = () => {
+ el.style.translate = "0px 0px"
+ }
+
+ el.addEventListener("pointermove", onMove)
+ el.addEventListener("pointerleave", onLeave)
+ return () => {
+ el.removeEventListener("pointermove", onMove)
+ el.removeEventListener("pointerleave", onLeave)
+ }
+ }, [strength])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/modules/shared/ui/components/preloader.tsx b/modules/shared/ui/components/preloader.tsx
new file mode 100644
index 0000000..1ff1d04
--- /dev/null
+++ b/modules/shared/ui/components/preloader.tsx
@@ -0,0 +1,105 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+
+import {
+ INTRO_CLASS,
+ INTRO_END_EVENT,
+ INTRO_STORAGE_KEY,
+ isIntroRunning,
+} from "@/modules/shared/lib/intro"
+
+const COUNT_DURATION_MS = 1600
+const CURTAIN_DURATION_MS = 1000
+// The hero starts animating while the curtain is still moving up
+const HERO_START_OFFSET_MS = 250
+
+// Fast start, dramatic hold near 100
+function easeCount(t: number) {
+ return t < 0.7 ? (t / 0.7) * 0.86 : 0.86 + (1 - Math.pow(1 - (t - 0.7) / 0.3, 3)) * 0.14
+}
+
+export function Preloader() {
+ const rootRef = useRef(null)
+ const countRef = useRef(null)
+ const barRef = useRef(null)
+
+ useEffect(() => {
+ const root = rootRef.current
+ if (!root || !isIntroRunning()) return
+
+ const timers: number[] = []
+ let frame = 0
+ const start = performance.now()
+
+ const finish = () => {
+ root.dataset.state = "leaving"
+ timers.push(
+ window.setTimeout(() => {
+ document.documentElement.classList.remove(INTRO_CLASS)
+ try {
+ sessionStorage.setItem(INTRO_STORAGE_KEY, "1")
+ } catch {
+ // Storage blocked: the intro simply plays again next time
+ }
+ window.dispatchEvent(new Event(INTRO_END_EVENT))
+ }, HERO_START_OFFSET_MS),
+ window.setTimeout(() => {
+ root.dataset.state = "done"
+ }, CURTAIN_DURATION_MS)
+ )
+ }
+
+ const tick = (now: number) => {
+ const t = Math.min((now - start) / COUNT_DURATION_MS, 1)
+ const value = easeCount(t)
+ if (countRef.current) {
+ countRef.current.textContent = String(Math.round(value * 100)).padStart(3, "0")
+ }
+ if (barRef.current) barRef.current.style.scale = `${value} 1`
+
+ if (t < 1) frame = requestAnimationFrame(tick)
+ else finish()
+ }
+
+ frame = requestAnimationFrame(tick)
+
+ return () => {
+ cancelAnimationFrame(frame)
+ timers.forEach(window.clearTimeout)
+ }
+ }, [])
+
+ return (
+
+
+ Digitalagentur
+ Steinfurt, DE
+
+
+
+ < since3
+ >
+
+
+
+
+ Lädt Erlebnis
+
+ 000
+
+
+
+
+
+ )
+}
diff --git a/modules/shared/ui/components/scramble-link.tsx b/modules/shared/ui/components/scramble-link.tsx
new file mode 100644
index 0000000..79f18e7
--- /dev/null
+++ b/modules/shared/ui/components/scramble-link.tsx
@@ -0,0 +1,62 @@
+"use client"
+
+import { useRef } from "react"
+
+import { cn } from "@/lib/utils"
+
+const GLYPHS = "!<>-_\\/[]{}=+*^?#01"
+const DURATION_MS = 420
+
+/** Link whose label decodes itself on hover, like terminal output. */
+export function ScrambleLink({
+ href,
+ children,
+ className,
+ onClick,
+}: {
+ href: string
+ children: string
+ className?: string
+ onClick?: () => void
+}) {
+ const textRef = useRef(null)
+ const frameRef = useRef(0)
+
+ const scramble = () => {
+ const el = textRef.current
+ if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
+ cancelAnimationFrame(frameRef.current)
+ const start = performance.now()
+
+ const tick = (now: number) => {
+ const progress = Math.min((now - start) / DURATION_MS, 1)
+ const revealed = Math.floor(progress * children.length)
+ el.textContent = children
+ .split("")
+ .map((char, index) =>
+ index < revealed || char === " "
+ ? char
+ : GLYPHS[Math.floor(Math.random() * GLYPHS.length)]
+ )
+ .join("")
+ if (progress < 1) frameRef.current = requestAnimationFrame(tick)
+ }
+ frameRef.current = requestAnimationFrame(tick)
+ }
+
+ return (
+
+ {/* Fixed width per character so the scramble never shifts the layout */}
+
+ {children}
+
+
+ )
+}
diff --git a/modules/shared/ui/components/site-footer.tsx b/modules/shared/ui/components/site-footer.tsx
new file mode 100644
index 0000000..0b14684
--- /dev/null
+++ b/modules/shared/ui/components/site-footer.tsx
@@ -0,0 +1,91 @@
+import Image from "next/image"
+import Link from "next/link"
+import { ArrowUp } from "lucide-react"
+
+import { legalNav, mainNav, regions, site } from "@/modules/shared/config/site"
+
+import { LiveClock } from "./live-clock"
+import { Magnetic } from "./magnetic"
+
+export function SiteFooter() {
+ const year = new Date().getFullYear()
+
+ return (
+
+ )
+}
diff --git a/modules/shared/ui/components/site-header.tsx b/modules/shared/ui/components/site-header.tsx
new file mode 100644
index 0000000..31c26fd
--- /dev/null
+++ b/modules/shared/ui/components/site-header.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import Image from "next/image"
+
+import { play } from "@/lib/sound"
+import { cn } from "@/lib/utils"
+import { mainNav, site } from "@/modules/shared/config/site"
+
+import { LiveClock } from "./live-clock"
+import { ScrambleLink } from "./scramble-link"
+import { SoundToggle } from "./sound-toggle"
+
+/**
+ * Fixed header in `mix-blend-mode: difference`, so it stays readable on black
+ * and on white sections. Hides while scrolling down, returns when scrolling up.
+ */
+export function SiteHeader() {
+ const [hidden, setHidden] = useState(false)
+ const [menuOpen, setMenuOpen] = useState(false)
+ const firstLinkRef = useRef(null)
+
+ useEffect(() => {
+ let lastY = window.scrollY
+ const onScroll = () => {
+ const y = window.scrollY
+ if (Math.abs(y - lastY) < 6) return
+ setHidden(y > lastY && y > 160)
+ lastY = y
+ }
+ window.addEventListener("scroll", onScroll, { passive: true })
+ return () => window.removeEventListener("scroll", onScroll)
+ }, [])
+
+ useEffect(() => {
+ if (!menuOpen) return
+ firstLinkRef.current?.focus()
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setMenuOpen(false)
+ }
+ document.documentElement.style.overflow = "hidden"
+ window.addEventListener("keydown", onKey)
+ return () => {
+ document.documentElement.style.overflow = ""
+ window.removeEventListener("keydown", onKey)
+ }
+ }, [menuOpen])
+
+ const close = () => setMenuOpen(false)
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {mainNav.map((item) => (
+
+
+ {item.label}
+
+
+ ))}
+
+
+
+
+
+
+ Projekt starten
+
+
{
+ play("whoosh")
+ setMenuOpen((open) => !open)
+ }}
+ aria-expanded={menuOpen}
+ aria-controls="mobile-menu"
+ className="grid size-10 place-items-center lg:hidden"
+ >
+ {menuOpen ? "Menü schließen" : "Menü öffnen"}
+
+
+
+
+
+
+
+
+
+ {/* Outside the header so it does not get blended */}
+
+ >
+ )
+}
diff --git a/modules/shared/ui/components/smooth-scroll.tsx b/modules/shared/ui/components/smooth-scroll.tsx
new file mode 100644
index 0000000..2f61543
--- /dev/null
+++ b/modules/shared/ui/components/smooth-scroll.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import { useEffect } from "react"
+import Lenis from "lenis"
+
+import { onIntroEnd } from "@/modules/shared/lib/intro"
+
+/** Inertia scrolling for the whole page. Renders nothing. */
+export function SmoothScroll() {
+ useEffect(() => {
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
+
+ const lenis = new Lenis({
+ autoRaf: true,
+ lerp: 0.09,
+ // Same-page anchor links scroll smoothly too
+ anchors: { offset: 0 },
+ })
+
+ // Hold the page still while the preloader plays
+ lenis.stop()
+ const cleanupIntro = onIntroEnd(() => lenis.start())
+
+ return () => {
+ cleanupIntro()
+ lenis.destroy()
+ }
+ }, [])
+
+ return null
+}
diff --git a/modules/shared/ui/components/sound-effects.tsx b/modules/shared/ui/components/sound-effects.tsx
new file mode 100644
index 0000000..c3f17b9
--- /dev/null
+++ b/modules/shared/ui/components/sound-effects.tsx
@@ -0,0 +1,56 @@
+"use client"
+
+import { useEffect } from "react"
+
+import { play } from "@/lib/sound"
+
+const INTERACTIVE =
+ "a[href], button:not(:disabled), [role='tab'], summary, input[type='checkbox'], input[type='radio'], label:has(input), [data-sound]"
+const HOVER_THROTTLE_MS = 60
+
+/**
+ * Page-wide sound design via event delegation: a tick when the mouse enters
+ * a link or button, a click when it is activated. Elements with
+ * `data-sound="off"` play their own sounds (e.g. the sound toggle). Renders nothing.
+ */
+export function SoundEffects() {
+ useEffect(() => {
+ let lastTarget: Element | null = null
+ let lastHover = 0
+
+ const interactiveTarget = (event: Event) => {
+ const target = (event.target as Element | null)?.closest?.(INTERACTIVE)
+ return target && target.getAttribute("data-sound") !== "off" ? target : null
+ }
+
+ const onPointerOver = (event: PointerEvent) => {
+ // Touch has no hover; a tap would otherwise play hover and click at once
+ if (event.pointerType !== "mouse") return
+ const target = interactiveTarget(event)
+ if (target === lastTarget) return
+ lastTarget = target
+ if (!target) return
+ const now = performance.now()
+ if (now - lastHover < HOVER_THROTTLE_MS) return
+ lastHover = now
+ play("hover")
+ }
+
+ const onClick = (event: MouseEvent) => {
+ const target = interactiveTarget(event)
+ // A click on a label is forwarded to its input, which plays the sound
+ if (!target || target.tagName === "LABEL") return
+ play("click")
+ }
+
+ document.addEventListener("pointerover", onPointerOver)
+ // Capture phase, so the sound starts before any navigation or scrolling
+ document.addEventListener("click", onClick, true)
+ return () => {
+ document.removeEventListener("pointerover", onPointerOver)
+ document.removeEventListener("click", onClick, true)
+ }
+ }, [])
+
+ return null
+}
diff --git a/modules/shared/ui/components/sound-toggle.tsx b/modules/shared/ui/components/sound-toggle.tsx
new file mode 100644
index 0000000..f2976d7
--- /dev/null
+++ b/modules/shared/ui/components/sound-toggle.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import { useSyncExternalStore } from "react"
+
+import { SOUND_CHANGE_EVENT, isEnabled, play, setEnabled } from "@/lib/sound"
+import { cn } from "@/lib/utils"
+
+function subscribe(onChange: () => void) {
+ window.addEventListener(SOUND_CHANGE_EVENT, onChange)
+ window.addEventListener("storage", onChange)
+ return () => {
+ window.removeEventListener(SOUND_CHANGE_EVENT, onChange)
+ window.removeEventListener("storage", onChange)
+ }
+}
+
+// Each bar gets its own speed so the equalizer looks organic
+const BAR_DURATIONS = ["0.9s", "0.65s", "1.1s", "0.8s"]
+
+/** Equalizer bars that dance while sound is on and lie flat while it is off. */
+export function SoundToggle({ className }: { className?: string }) {
+ const enabled = useSyncExternalStore(subscribe, isEnabled, () => false)
+
+ const toggle = () => {
+ const next = !enabled
+ setEnabled(next)
+ // Forced, so switching off is still audible as feedback
+ play(next ? "on" : "off", { force: true })
+ }
+
+ return (
+
+
+ {BAR_DURATIONS.map((duration, index) => (
+
+ ))}
+
+
+ {enabled ? "Sound an" : "Sound aus"}
+
+
+ )
+}
diff --git a/package-lock.json b/package-lock.json
index 84f9061..9e24f82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,12 +11,15 @@
"@base-ui/react": "^1.8.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
+ "lenis": "^1.3.26",
"lucide-react": "^1.48.0",
+ "motion": "^13.4.3",
"next": "16.3.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -5191,6 +5194,29 @@
"node": ">= 0.6"
}
},
+ "node_modules/framer-motion": {
+ "version": "13.4.3",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.3.tgz",
+ "integrity": "sha512-mPQe1GtRHWS30zRGaQAfmlIg0baA0jRhkpbFPT1d+s9bZPjMW6pJSjWM6ArmUEjzMgEm089ZL1wd8aPv1/5DJQ==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^13.4.2",
+ "motion-utils": "^13.3.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -6459,6 +6485,37 @@
"node": ">=0.10"
}
},
+ "node_modules/lenis": {
+ "version": "1.3.26",
+ "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.26.tgz",
+ "integrity": "sha512-s/xTCZCxTFvHbAN1OzuhNaN5YPJH2ail0XAkctKW1b+RUAG4nUL5UHLXwNko1h8aEeT2jspBXegMgPJd8zcuag==",
+ "license": "MIT",
+ "workspaces": [
+ "packages/*",
+ "playground",
+ "playground/*"
+ ],
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/darkroomengineering"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": ">=3.0.0",
+ "react": ">=17.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -6974,6 +7031,43 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/motion": {
+ "version": "13.4.3",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-13.4.3.tgz",
+ "integrity": "sha512-Js4rk+VUxSRMKOFT4hOMyMKlxyF11ee9jomiLT3uuwMOU88EoSro0ZEbTYes4m457pqZQ2G3RsLdHQzoEDyBCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "framer-motion": "^13.4.3",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "13.4.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.4.2.tgz",
+ "integrity": "sha512-mY293Iyj4rOQo5sDxUwPYLOKiveLQrJoK1tKfG8V1H3MMGMfan3s2i7NBW18WnD39DD79ogyiL/1fmy22GLG5w==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^13.3.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "13.3.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz",
+ "integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/package.json b/package.json
index ff5afcb..b83da81 100644
--- a/package.json
+++ b/package.json
@@ -12,12 +12,15 @@
"@base-ui/react": "^1.8.0",
"class-variance-authority": "^0.7.1",
"cn": "^0.4.0",
+ "lenis": "^1.3.26",
"lucide-react": "^1.48.0",
+ "motion": "^13.4.3",
"next": "16.3.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..1069c2b
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..077fb1b
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000..26e5f4d
Binary files /dev/null and b/public/icons/icon-maskable-512.png differ
diff --git a/public/pictures/since3-logo-weiss.svg b/public/pictures/since3-logo-weiss.svg
new file mode 100644
index 0000000..efe4154
--- /dev/null
+++ b/public/pictures/since3-logo-weiss.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/pictures/since3-logo.png b/public/pictures/since3-logo.png
new file mode 100644
index 0000000..5be64b1
Binary files /dev/null and b/public/pictures/since3-logo.png differ
diff --git a/public/pictures/since3-logo.svg b/public/pictures/since3-logo.svg
new file mode 100644
index 0000000..77a3b40
--- /dev/null
+++ b/public/pictures/since3-logo.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file