Merge feat/german-locale: German version, glow effects and search metadata
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 } },
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user