Merge feat/german-locale: German version, glow effects and search metadata
Build & Deploy / 🩺 Smoke Test (push) Failing after 2s
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Successful in 6m36s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🔔 Notify (push) Successful in 2s

Brings the German main language (English under /en), the binary glow
effects with the refined drawings and calls to action, and the search
metadata onto the server-side analytics from v2.1.0.

Both sides had added apps/web/proxy.ts. It is now one request shell: it
picks the language and maps the address (routes.ts), and records the page
view server-side (pageview-decision.ts) for every request that is not a
redirect, using the public address the visitor asked for. The browser
analytics component and the /stats proxy route stay removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:20:10 +02:00
co-authored by Claude Opus 5.5
81 changed files with 6319 additions and 1915 deletions
-55
View File
@@ -1,55 +0,0 @@
import type { Metadata } from "next";
import { EmailLink } from "@/src/components/EmailLink";
import { LegalPage, LegalSection } from "@/src/components/LegalPage";
import { OWNER } from "@/src/content/site";
export const metadata: Metadata = {
title: "Imprint",
description: "Legal notice (Impressum) for mintel.me.",
alternates: { canonical: "/imprint" },
robots: { index: false, follow: true },
};
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
export default function ImprintPage() {
return (
<LegalPage title="Imprint">
<LegalSection heading="Information according to section 5 DDG">
<p>
{OWNER.name}
<br />
{OWNER.street}
<br />
{OWNER.city}
<br />
{OWNER.country}
</p>
</LegalSection>
<LegalSection heading="Contact">
<p>
Email: <EmailLink className={LINK} />
</p>
</LegalSection>
<LegalSection heading="VAT identification number">
<p>
VAT identification number according to section 27a of the German VAT
Act (Umsatzsteuergesetz): {OWNER.vatId}
</p>
</LegalSection>
<LegalSection heading="Responsible for the content">
<p>{OWNER.name}, address as above (section 18 paragraph 2 MStV).</p>
</LegalSection>
<LegalSection heading="Consumer dispute resolution">
<p>
I am neither willing nor obliged to take part in dispute resolution
proceedings before a consumer arbitration board.
</p>
</LegalSection>
</LegalPage>
);
}
-77
View File
@@ -1,77 +0,0 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { BitBurst } from "@/src/components/bits/BitBurst";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
import {
SITE_DESCRIPTION,
SITE_NAME,
SITE_TITLE,
SITE_URL,
} from "@/src/content/site";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: SITE_TITLE,
template: `%s | ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.png",
},
// The images come from opengraph-image.png and twitter-image.png next to this file.
openGraph: {
type: "website",
url: "/",
siteName: SITE_NAME,
locale: "en_US",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
twitter: {
card: "summary_large_image",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
>
<body className="min-h-screen bg-white">
<Header />
<main>{children}</main>
<Footer />
<InteractiveElements />
<BitBurst />
</body>
</html>
);
}
@@ -1 +0,0 @@
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

-97
View File
@@ -1,97 +0,0 @@
import type { Metadata } from "next";
import { EmailLink } from "@/src/components/EmailLink";
import { LegalPage, LegalSection } from "@/src/components/LegalPage";
import { OWNER } from "@/src/content/site";
export const metadata: Metadata = {
title: "Privacy",
description: "How mintel.me handles personal data.",
alternates: { canonical: "/privacy" },
robots: { index: false, follow: true },
};
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
export default function PrivacyPage() {
return (
<LegalPage title="Privacy">
<LegalSection heading="Who is responsible">
<p>
{OWNER.name}, {OWNER.street}, {OWNER.city}, {OWNER.country}.
<br />
Email: <EmailLink className={LINK} />
</p>
</LegalSection>
<LegalSection heading="In short">
<p>
This website sets no cookies, shows no advertising and includes no
content from third parties. Fonts and images are served from this
website itself.
</p>
</LegalSection>
<LegalSection heading="Server logs">
<p>
When you open a page, the server processes technical data that your
browser sends: your IP address, the time, the page requested and the
browser type. This is necessary to deliver the website and to keep it
secure. The legal basis is my legitimate interest in running the
website (Article 6(1)(f) GDPR).
</p>
</LegalSection>
<LegalSection heading="Visitor statistics">
<p>
I count page views with Umami, which I run on my own server. It works
without cookies and does not create profiles of individual visitors.
The data is not passed on to third parties. The legal basis is my
legitimate interest in understanding how the website is used (Article
6(1)(f) GDPR).
</p>
</LegalSection>
<LegalSection heading="Error reports">
<p>
If something on this website fails technically, an error report is
sent to an error tracking service that I run on my own server. It
contains technical details such as the page, the browser and the error
message. Sessions are not recorded. The legal basis is my legitimate
interest in a working website (Article 6(1)(f) GDPR).
</p>
</LegalSection>
<LegalSection heading="Storage in your browser">
<p>
If loading a page fails, the website stores a timestamp in your
browser&apos;s session storage so that it reloads only once. It is
deleted when you close the tab and is not sent anywhere.
</p>
</LegalSection>
<LegalSection heading="When you write to me">
<p>
If you send me an email, I process what you write and your address in
order to answer you and, if it comes to that, to prepare a contract
(Article 6(1)(b) GDPR). I keep the correspondence for as long as it is
needed for that purpose or as the law requires.
</p>
</LegalSection>
<LegalSection heading="Your rights">
<p>You have the right to:</p>
<ul>
<li>ask what data I hold about you</li>
<li>have incorrect data corrected</li>
<li>have your data deleted or its processing restricted</li>
<li>object to processing that is based on legitimate interest</li>
<li>receive your data in a portable format</li>
<li>complain to a data protection supervisory authority</li>
</ul>
<p>
To use any of these rights, write to <EmailLink className={LINK} />.
</p>
</LegalSection>
</LegalPage>
);
}
-14
View File
@@ -1,14 +0,0 @@
import type { MetadataRoute } from "next";
/** The site is a single page. */
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://mintel.me";
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
];
}
@@ -1 +0,0 @@
Web apps and websites. Marc Mintel, freelance developer with 15 years of experience.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

+6
View File
@@ -0,0 +1,6 @@
import { notFound } from "next/navigation";
/** Any address that is not a page ends in the error page of its language. */
export default function UnknownPage() {
notFound();
}
@@ -3,6 +3,8 @@
import { useEffect } from "react";
import * as Sentry from "@sentry/nextjs";
import { Button } from "@/src/components/Button";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
export default function Error({
error,
@@ -11,6 +13,9 @@ export default function Error({
error: Error & { digest?: string };
reset: () => void;
}) {
const { error: copy } = useContent();
const home = pathFor("home", useLocale());
useEffect(() => {
// Log the error to Sentry/GlitchTip
Sentry.captureException(error);
@@ -37,22 +42,21 @@ export default function Error({
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-red-50 border border-red-100 rounded text-[10px] font-mono text-red-500 uppercase tracking-widest">
Error 500
{copy.kicker}
</span>
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
Kritischer Fehler.
{copy.title}
</h1>
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
Ein unerwartetes Problem ist aufgetreten. Unsere Systeme haben den
Vorfall protokolliert.
{copy.text}
</p>
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<Button href="#" onClick={() => reset()} variant="primary">
System neu starten (Retry)
{copy.retry}
</Button>
<Button href="/" variant="outline">
Zurück zur Basis
<Button href={home} variant="outline">
{copy.home}
</Button>
</div>
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { LegalPage } from "@/src/components/LegalPage";
import { IMPRINT } from "@/src/content/legal/imprint";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, description } = IMPRINT[locale];
return {
title,
description,
alternates: {
canonical: pathFor("imprint", locale),
languages: languageAlternates("imprint"),
},
robots: { index: false, follow: true },
};
}
export default async function Page({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, Body } = IMPRINT[locale];
return (
<LegalPage title={title}>
<Body />
</LegalPage>
);
}
+136
View File
@@ -0,0 +1,136 @@
import type { Metadata, Viewport } from "next";
import type { StaticImageData } from "next/image";
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { BitBurst } from "@/src/components/bits/BitBurst";
import { BitGlow } from "@/src/components/bits/BitGlow";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
import { ContentProvider } from "@/src/components/ContentProvider";
import { BitCurtain } from "@/src/components/bits/BitCurtain";
import shareImageDe from "@/src/assets/share/share-de.png";
import shareImageEn from "@/src/assets/share/share-en.png";
import { contentFor } from "@/src/content";
import { INDEXING, SITE_NAME, SITE_URL } from "@/src/content/site";
import { robotsMeta } from "@/src/domain/indexing";
import {
DEFAULT_LOCALE,
LOCALES,
parseLocale,
type Locale,
} from "@/src/domain/locale";
import { pathFor } from "@/src/domain/routes";
import "../globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
// A real monospaced fallback has almost the same letter width. The default
// fallback (a resized Arial) wraps the mono labels differently, so the hero
// jumped when the font arrived.
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
adjustFontFallback: false,
fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
});
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
type Props = {
readonly children: React.ReactNode;
readonly params: Promise<{ readonly locale: string }>;
};
/** The share image is a screenshot of the hero in that language (see scripts/render-share-image.mjs). */
const SHARE_IMAGE: Readonly<Record<Locale, StaticImageData>> = {
en: shareImageEn,
de: shareImageDe,
};
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { site } = contentFor(locale);
const image = SHARE_IMAGE[locale];
const images = [
{
url: image.src,
width: image.width,
height: image.height,
alt: site.shareImageAlt,
},
];
return {
metadataBase: new URL(SITE_URL),
title: {
default: site.title,
template: `%s | ${SITE_NAME}`,
},
description: site.description,
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
robots: robotsMeta(INDEXING),
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.png",
},
openGraph: {
type: "website",
url: pathFor("home", locale),
siteName: SITE_NAME,
locale: site.ogLocale,
alternateLocale: LOCALES.filter((other) => other !== locale).map(
(other) => contentFor(other).site.ogLocale,
),
title: site.title,
description: site.description,
images,
},
twitter: {
card: "summary_large_image",
title: site.title,
description: site.description,
images,
},
};
}
export const viewport: Viewport = {
themeColor: "#ffffff",
colorScheme: "light",
viewportFit: "cover",
};
export default async function RootLayout({ children, params }: Props) {
// The proxy only lets the two languages through; anything else is a file-like
// address that ends in the error page of the main language.
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
return (
<html
lang={locale}
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
>
<body className="min-h-screen bg-white">
<ContentProvider locale={locale} content={contentFor(locale)}>
<Header />
<main>{children}</main>
<Footer />
<InteractiveElements />
<BitCurtain />
</ContentProvider>
<BitBurst />
<BitGlow />
</body>
</html>
);
}
@@ -1,32 +1,28 @@
export const metadata = {
title: "404 - Seite nicht gefunden | Marc Mintel",
description: "Diese Seite konnte leider nicht gefunden werden.",
};
"use client";
import Link from "next/link";
import { Button } from "@/src/components/Button";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
export default function NotFound() {
const { notFound } = useContent();
const home = pathFor("home", useLocale());
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-slate-50 border border-slate-100 rounded text-[10px] font-mono text-slate-500 uppercase tracking-widest">
Error 404
{notFound.kicker}
</span>
<h1 className="text-5xl md:text-7xl font-black text-slate-900 tracking-tighter">
System-Anomalie.
{notFound.title}
</h1>
<p className="text-xl md:text-2xl text-slate-500 font-serif italic max-w-xl mx-auto leading-relaxed">
Die angeforderte URL existiert nicht in dieser Zeitleiste.
Möglicherweise wurde die Seite verschoben oder gelöscht.
{notFound.text}
</p>
<div className="pt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
<Button href="/" variant="primary">
Zurück zur Basis
</Button>
<Button href="/blog" variant="outline">
Aktuelle Artikel lesen
<Button href={home} variant="primary">
{notFound.home}
</Button>
</div>
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
@@ -12,118 +13,86 @@ import { AutomationFlow } from "@/src/components/illustrations/AutomationFlow";
import { CareerLine } from "@/src/components/illustrations/CareerLine";
import { ContactDiagram } from "@/src/components/illustrations/ContactDiagram";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import {
WorkShowcase,
type WorkItem,
} from "@/src/components/illustrations/WorkShowcase";
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
import { Reveal } from "@/src/components/Reveal";
import { CAREER, FIT, TECH } from "@/src/content/about";
import { SITE_DESCRIPTION, SITE_TITLE } from "@/src/content/site";
import { AUTOMATION_NOTES } from "@/src/content/automation";
import { contentFor } from "@/src/content";
import type { SplitTitle } from "@/src/content/types";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
export const metadata: Metadata = {
title: { absolute: SITE_TITLE },
description: SITE_DESCRIPTION,
alternates: { canonical: "/" },
};
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { site } = contentFor(locale);
return {
title: { absolute: site.title },
description: site.description,
alternates: {
canonical: pathFor("home", locale),
languages: languageAlternates("home"),
},
};
}
const MUTED = "text-slate-400";
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const PRINCIPLES = [
{
title: "One contact",
text: "You talk to me, and I am the one who does the work.",
},
{
title: "Hours you can check",
text: "I track my time. The monthly invoice shows what the hours were spent on.",
},
{
title: "Careful work",
text: "I test what I build before I hand it over, and I say so when something is not finished or I am not sure.",
},
{
title: "What I build is yours",
text: "Code and design belong to you. You can hand them to another developer.",
},
] as const;
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
title,
muted = MUTED,
}) => (
<>
{title.lead} <span className={muted}>{title.rest}</span>
</>
);
const WORK: readonly WorkItem[] = [
{
name: "klz-cables.com",
href: "https://klz-cables.com",
client: "Cable supplier",
text: "The company website of a supplier of energy cables, in German and English.",
did: [
"Product catalogue with categories and detail pages",
"Datasheets generated from the product data",
"Content the client can edit in a CMS",
],
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
desktop: { src: "/work/klz-long.webp", width: 1200, height: 1680 },
mobile: { src: "/work/klz-mobile.webp", width: 390, height: 3600 },
},
{
name: "e-tib.com",
href: "https://e-tib.com",
client: "Cable network construction",
text: "The company website of a group in cable network construction, in German and English.",
did: [
"Pages for services, references, careers and events",
"The same content in two languages",
"Works on phone, tablet and desktop",
],
tech: ["Next.js", "TypeScript", "Headless CMS", "Two languages"],
desktop: { src: "/work/etib-long.webp", width: 1200, height: 4333 },
mobile: { src: "/work/etib-mobile.webp", width: 390, height: 3600 },
},
];
export default async function LandingPage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { hello, services, automation, technology, working, work, faq } =
content;
export default function LandingPage() {
return (
<div className="relative flex flex-col bg-white">
<HomeJsonLd />
<HeroStatement />
<HomeJsonLd locale={locale} />
<HeroStatement hero={content.hero} />
<StickyBook />
<PortraitSection
id="hello"
index="01"
label="Hello"
title={<>I am Marc.</>}
label={hello.label}
title={hello.title}
portraitAlt={hello.portraitAlt}
>
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
I have been building for the web for 15 years: first in agencies,
later as a senior developer, and now on my own.
</p>
<p className="m-0 mt-8 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
My strength is the part people see and use: design and interface. I am
also at home with what lies behind it, so I can build a complete
application and not just its surface.
</p>
<p className="m-0 mt-6 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
You work with me directly. Whether you run a product team or are
commissioning something like this for the first time.
{hello.lead}
</p>
{hello.paragraphs.map((paragraph, i) => (
<p
key={paragraph}
className={`m-0 font-sans text-lg leading-relaxed text-slate-300 md:text-xl ${i === 0 ? "mt-8" : "mt-6"}`}
>
{paragraph}
</p>
))}
</PortraitSection>
<section className="bg-white">
<div className="mx-auto w-full max-w-[1400px] px-5 py-16 md:px-12 md:py-28">
<p className={`${MONO} m-0 mb-10`}>The path so far</p>
<CareerLine stations={CAREER} />
<section className="relative overflow-clip bg-slate-900">
<div className="mx-auto w-full max-w-[1400px] px-5 pb-20 pt-4 md:px-12 md:pb-36 md:pt-0">
<p className="m-0 mb-10 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
{hello.careerLabel}
</p>
<CareerLine stations={hello.career} />
</div>
</section>
<Block
index="02"
label="Services"
title={
<>
What do you need? <span className={MUTED}>Four typical cases.</span>
</>
}
label={services.label}
title={<Title title={services.title} />}
>
<Reveal>
<PathPicker />
@@ -132,26 +101,17 @@ export default function LandingPage() {
<Block
index="03"
label="Process automation"
title={
<>
Let the systems <span className={MUTED}>do the copying.</span>
</>
}
label={automation.label}
title={<Title title={automation.title} />}
>
<Reveal>
<p className={`${BODY} mb-12 max-w-3xl`}>
In most companies, people move information from one tool to another:
read it here, type it in there, send an email about it. Automation
connects those tools so that the work passes from one to the next on
its own. Three typical examples:
</p>
<p className={`${BODY} mb-12 max-w-3xl`}>{automation.intro}</p>
</Reveal>
<Reveal>
<AutomationFlow />
</Reveal>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
{AUTOMATION_NOTES.map((group, i) => (
{automation.notes.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div className="border-t border-slate-900 pt-6">
<p className={`${MONO} m-0`}>{group.title}</p>
@@ -173,15 +133,11 @@ export default function LandingPage() {
<Block
index="04"
label="Technology"
title={
<>
Which projects <span className={MUTED}>I am right for.</span>
</>
}
label={technology.label}
title={<Title title={technology.title} />}
>
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
{FIT.map((group, i) => (
{technology.fit.map((group, i) => (
<Reveal key={group.title} delay={0.1 * i}>
<div
className={`border-t pt-6 ${i === 2 ? "border-slate-300" : "border-slate-900"}`}
@@ -206,9 +162,9 @@ export default function LandingPage() {
<Reveal>
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
<p className={`${MONO} m-0`}>What I work with</p>
<p className={`${MONO} m-0`}>{technology.techLabel}</p>
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
{TECH.map((group) => (
{technology.tech.map((group) => (
<div key={group.area}>
<p className="m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl">
{group.area}
@@ -232,13 +188,10 @@ export default function LandingPage() {
<div className="mt-16 md:mt-24">
<Reveal>
<h3 className="m-0 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
What happens before a change goes live.
{technology.pipelineTitle}
</h3>
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
Software is connected: a change in one place can break another. I
secure what I build with automated tests, and every change has to
pass them before it is released. This is what such a run looks
like.
{technology.pipelineIntro}
</p>
</Reveal>
<Reveal>
@@ -249,20 +202,18 @@ export default function LandingPage() {
<Block
index="05"
label="Working with me"
title={
<>
How I work. <span className={MUTED}>Nothing unusual.</span>
</>
}
label={working.label}
title={<Title title={working.title} />}
>
<Reveal>
<div className="mb-16 overflow-hidden rounded-3xl bg-slate-900 p-4 md:mb-24 md:p-10">
<ContactDiagram />
</div>
<BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
<div className="overflow-hidden rounded-3xl bg-slate-900 p-4 md:p-10">
<ContactDiagram />
</div>
</BitAura>
</Reveal>
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
{PRINCIPLES.map((item, i) => (
{working.principles.map((item, i) => (
<Reveal key={item.title} delay={0.08 * i}>
<div className="border-t border-slate-900 pt-6">
<h3 className="m-0 font-sans text-3xl font-bold tracking-tight text-slate-900 md:text-4xl">
@@ -277,39 +228,25 @@ export default function LandingPage() {
<Block
index="06"
label="My work"
title={
<>
Two sites I built. <span className={MUTED}>Both are live.</span>
</>
}
label={work.label}
tone="dark"
title={<Title title={work.title} muted="text-slate-500" />}
>
<WorkShowcase items={WORK} />
<WorkShowcase />
<Reveal>
<p
className={`${BODY} mt-20 max-w-3xl border-t border-slate-200 pt-8 md:mt-28`}
>
Besides client work I build my own software, among other things a
system in Rust and Solidity that runs on seven blockchains.
<p className="m-0 mt-20 max-w-3xl border-t border-slate-800 pt-8 font-sans text-lg leading-relaxed text-slate-300 md:mt-28 md:text-xl">
{work.note}
</p>
</Reveal>
</Block>
<Block
index="07"
label="Questions"
title={
<>
Good to know <span className={MUTED}>before we talk.</span>
</>
}
>
<Block index="07" label={faq.label} title={<Title title={faq.title} />}>
<Reveal>
<Faq />
<Faq items={faq.items} />
</Reveal>
</Block>
<ClosingCta />
<ClosingCta contact={content.contact} />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { LegalPage } from "@/src/components/LegalPage";
import { PRIVACY } from "@/src/content/legal/privacy";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
import { languageAlternates, pathFor } from "@/src/domain/routes";
type Props = { readonly params: Promise<{ readonly locale: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, description } = PRIVACY[locale];
return {
title,
description,
alternates: {
canonical: pathFor("privacy", locale),
languages: languageAlternates("privacy"),
},
robots: { index: false, follow: true },
};
}
export default async function Page({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const { title, Body } = PRIVACY[locale];
return (
<LegalPage title={title}>
<Body />
</LegalPage>
);
}
@@ -1,8 +1,11 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Inter, Newsreader } from "next/font/google";
import { contentFor } from "@/src/content";
import { DEFAULT_LOCALE, type Locale } from "@/src/domain/locale";
import { localeOfPath } from "@/src/domain/routes";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
@@ -20,6 +23,11 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
// The root layout failed, so the language is read from the address.
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
useEffect(() => setLocale(localeOfPath(window.location.pathname)), []);
const copy = contentFor(locale).error;
useEffect(() => {
Sentry.captureException(error);
@@ -41,19 +49,18 @@ export default function GlobalError({
}, [error]);
return (
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
<html lang={locale} className={`${inter.variable} ${newsreader.variable}`}>
<body className="min-h-screen bg-white font-sans text-slate-900">
<div className="flex flex-col items-center justify-center min-h-screen px-5 py-20 text-center">
<div className="space-y-6 max-w-2xl mx-auto">
<span className="inline-block px-3 py-1 bg-red-50 border border-red-200 rounded text-[10px] font-mono text-red-600 uppercase tracking-widest border-dashed">
Root Level Error
{copy.kicker}
</span>
<h1 className="text-4xl md:text-6xl font-black tracking-tighter text-slate-900">
Systemausfall der Hauptebene.
{copy.title}
</h1>
<p className="text-lg md:text-xl text-slate-500 font-serif italic max-w-xl mx-auto">
Ein kritischer Fehler auf der Root-Layout Ebene hat das Rendering
blockiert. Der Vorfall wurde zur Untersuchung protokolliert.
{copy.text}
</p>
<div className="pt-8">
@@ -62,7 +69,7 @@ export default function GlobalError({
className="relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer px-8 py-4 text-[10px] bg-slate-900 text-white hover:shadow-xl hover:-translate-y-0.5"
>
<span className="relative z-10 flex items-center gap-3">
Notfall-Neustart (Reset)
{copy.retry}
</span>
</button>
</div>
@@ -321,26 +321,6 @@
/* Additional global styles from BaseLayout */
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Selection color */
::selection {
background: #0f172a;
@@ -1079,8 +1059,72 @@ pre:has(code[class*="language-"]) {
transform: translate(0, 0);
}
/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
.bit-glow {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
opacity: 0;
transition: opacity 400ms ease;
}
@media (hover: hover) {
[data-glow]:hover > .bit-glow {
opacity: 1;
}
}
/* Dark surfaces: the dot grid lights up around the pointer. */
.bit-glow-dots {
background-image:
radial-gradient(
320px circle at var(--gx, 50%) var(--gy, 50%),
rgba(148, 163, 184, 0.22),
transparent 70%
),
radial-gradient(#e2e8f0 1.25px, transparent 1.25px);
background-size:
auto,
20px 20px;
-webkit-mask-image: radial-gradient(
280px circle at var(--gx, 50%) var(--gy, 50%),
#000,
transparent 75%
);
mask-image: radial-gradient(
280px circle at var(--gx, 50%) var(--gy, 50%),
#000,
transparent 75%
);
}
/* Cards: only the border lights up, in the card's text colour. */
.bit-glow-edge {
inset: -1px;
padding: 2px;
background: radial-gradient(
320px circle at var(--gx, 50%) var(--gy, 50%),
currentColor,
transparent 70%
);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
}
/* Dark buttons: a spot of light under the pointer. */
.bit-glow-spot {
background: radial-gradient(
90px circle at var(--gx, 50%) var(--gy, 50%),
rgba(129, 140, 248, 0.5),
transparent 70%
);
}
@media (prefers-reduced-motion: reduce) {
.bit-burst {
.bit-burst,
.bit-glow {
display: none;
}
.bit-card::before,
@@ -1088,3 +1132,252 @@ pre:has(code[class*="language-"]) {
transition: none;
}
}
/*
* Calls to action: a light travels round the edge of the pill and glows softly
* behind it; on hover the glow comes up. `bit-cta-light` is for white pills on
* dark surfaces, `bit-cta-quiet` only lights up on hover.
*/
@property --cta-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.bit-cta {
--cta-a: rgba(129, 140, 248, 0.95);
--cta-b: #ffffff;
position: relative;
isolation: isolate;
}
.bit-cta-light {
--cta-a: rgba(99, 102, 241, 0.95);
--cta-b: #312e81;
}
.bit-cta-quiet {
--cta-a: rgba(99, 102, 241, 0.9);
--cta-b: #0f172a;
}
.bit-cta::before,
.bit-cta::after {
content: "";
position: absolute;
border-radius: inherit;
pointer-events: none;
background: conic-gradient(
from var(--cta-angle),
transparent 0 58%,
var(--cta-a) 76%,
var(--cta-b) 86%,
var(--cta-a) 93%,
transparent 100%
);
animation: bit-cta-turn 6s linear infinite;
}
/* The edge itself. */
.bit-cta::before {
inset: -1px;
padding: 1.5px;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
}
/* The glow behind the edge. */
.bit-cta::after {
inset: -2px;
z-index: -1;
padding: 6px;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box exclude,
linear-gradient(#000 0 0);
filter: blur(10px);
opacity: 0.55;
transition: opacity 300ms ease;
}
.bit-cta-quiet::before,
.bit-cta-quiet::after {
opacity: 0;
transition: opacity 300ms ease;
}
@media (hover: hover) {
.bit-cta:hover::after,
.group:hover > .bit-cta::after {
opacity: 1;
}
.bit-cta-quiet:hover::before {
opacity: 1;
}
.bit-cta-quiet:hover::after {
opacity: 0.6;
}
}
@keyframes bit-cta-turn {
to {
--cta-angle: 360deg;
}
}
/* Slow light drifting over a dark picture (see PortraitSection). */
.bit-aurora {
position: absolute;
inset: -10%;
pointer-events: none;
mix-blend-mode: screen;
background:
radial-gradient(
38% 46% at 38% 42%,
rgba(99, 102, 241, 0.55),
transparent 70%
),
radial-gradient(
30% 40% at 64% 72%,
rgba(129, 140, 248, 0.35),
transparent 70%
);
filter: blur(40px);
animation: bit-aurora-drift 16s ease-in-out infinite alternate;
}
@keyframes bit-aurora-drift {
from {
transform: translate3d(-4%, -3%, 0) scale(1);
}
to {
transform: translate3d(5%, 4%, 0) scale(1.12);
}
}
/* The current station on the career line: a node that breathes light. */
.bit-node-live {
animation: bit-node-breathe 3.2s ease-in-out infinite;
}
@keyframes bit-node-breathe {
0%,
100% {
box-shadow:
0 0 0 4px rgba(129, 140, 248, 0.22),
0 0 18px 2px rgba(129, 140, 248, 0.55);
}
50% {
box-shadow:
0 0 0 8px rgba(129, 140, 248, 0.12),
0 0 34px 8px rgba(129, 140, 248, 0.85);
}
}
/*
* Native feel: the page should behave like a well-made app on every input type.
*/
html {
color-scheme: light;
/* Anchor jumps stop below the fixed header. */
scroll-padding-top: 6rem;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
accent-color: #0f172a;
caret-color: #0f172a;
}
body {
text-rendering: optimizeLegibility;
font-kerning: normal;
}
/* Headlines break into even lines, running text avoids lonely last words. */
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p,
li,
dd,
summary {
text-wrap: pretty;
}
/* Controls react at once to a tap and are not selected like text. */
a,
button,
summary,
[role="tab"],
[role="button"] {
touch-action: manipulation;
}
button,
summary,
[role="tab"],
[role="button"],
a[href^="#"] {
-webkit-user-select: none;
user-select: none;
}
/* Decoration is neither selectable nor draggable. */
[aria-hidden="true"],
canvas,
img,
svg {
-webkit-user-select: none;
user-select: none;
}
img,
svg,
a {
-webkit-user-drag: none;
}
/* One visible keyboard focus, none for mouse and touch. */
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid #0f172a;
outline-offset: 3px;
}
a:focus-visible,
button:focus-visible,
summary:focus-visible {
border-radius: 6px;
}
/* A short press-in on anything that can be pressed. */
@media (prefers-reduced-motion: no-preference) {
button:active,
[role="tab"]:active,
a[class*="rounded-full"]:active,
a:active > [class*="rounded-full"] {
scale: 0.97;
transition-duration: 80ms;
}
}
/* Dark surfaces: light selection, light focus ring, dark system controls. */
.bg-slate-900,
.bg-slate-950 {
color-scheme: dark;
}
.bg-slate-900 ::selection,
.bg-slate-950 ::selection {
background: #f8fafc;
color: #0f172a;
}
.bg-slate-900 :focus-visible,
.bg-slate-950 :focus-visible {
outline-color: #f8fafc;
}
/* Fixed controls stay clear of the home indicator and rounded corners. */
@supports (padding: env(safe-area-inset-bottom)) {
body {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
import { INDEXING, SITE_URL } from "@/src/content/site";
import { robotsFile } from "@/src/domain/indexing";
export default function robots(): MetadataRoute.Robots {
const { rules, sitemap } = robotsFile(INDEXING, SITE_URL);
return {
rules: {
...rules,
disallow:
typeof rules.disallow === "string"
? rules.disallow
: [...rules.disallow],
},
sitemap,
};
}
+16
View File
@@ -0,0 +1,16 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/src/content/site";
import { sitemapEntries } from "@/src/domain/routes";
/**
* The site is a single page in two languages; the legal pages are not meant for
* search results. No modification date: it would have to be the date the content
* last changed, and a date that moves with every build teaches search engines to
* ignore it.
*/
export default function sitemap(): MetadataRoute.Sitemap {
return sitemapEntries(SITE_URL, ["home"]).map((entry) => ({
url: entry.url,
alternates: { languages: { ...entry.alternates.languages } },
}));
}