Compare commits

...
Author SHA1 Message Date
mmintelandClaude Opus 5.5 6f6e52dd63 feat: section on web presentations, in German and English
Build & Deploy / 🔔 Notify (push) Successful in 2s
Build & Deploy / 🔍 Prepare (push) Successful in 4s
Build & Deploy / 🏗️ Build (push) Successful in 6m37s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🩺 Smoke Test (push) Failing after 3s
A presentation built as a website instead of a slide file: why, for what,
and when slides are enough. The main drawing shows the same presentation as
a slide and as a web page on a projector, a laptop and a phone; the slide
keeps its format and shrinks, the page rearranges. Four small drawings stand
next to the reasons. No prices: none are agreed for this yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:58:35 +02:00
mmintelandClaude Opus 5.5 666b1d7547 fix: hero digits are readable on phones
On a phone the headline was built from 4px cells and the background field
kept its desktop ink, so the digits were specks or not visible at all. The
headline now uses cells of at least 6px there, and the background digits are
larger and stronger below 640px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:58:35 +02:00
mmintelandClaude Opus 5.5 60f094a754 fix: quiet sparks instead of dashed rings around the automation panel
The pulsing dashed rings around the example workflow read as a second,
broken frame. The panel now sheds a few digits at its left edge like the
other frames on light backgrounds, and the ring motion, which nothing else
used, is removed from BitAura.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:58:34 +02:00
mmintelandClaude Opus 5.5 f04e8c8463 feat: contact address with a sheen and a light on its underline
The address is the one thing to act on in the contact section, but it was a
plain underlined line next to a glowing message card. A slow sheen now runs
through the letters and a light travels along the underline; on hover the
whole line lights up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:47:11 +02:00
mmintelandClaude Opus 5.5 9ed436330b 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>
2026-10-04 14:20:10 +02:00
mmintelandClaude Opus 5.5 75993100fa feat: German as the main language, English under /en, with a language switch
The site now exists in German (at the root, with /impressum and /datenschutz)
and English (/en). A request without a language follows the visitor's choice
from the language switch, then the browser language, then German; the old
English legal addresses move to /en for good.

All visible texts are data in src/content/de.ts and en.ts, so components hold
none of their own. The header carries a DE/EN switch whose bit curtain covers
the page while it is swapped and reopens at the same reading position. Title,
description, canonical, hreflang, Open Graph, the share image, the sitemap and
the structured data are per language. The privacy notice now describes the
cookie that remembers the chosen language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:07:22 +02:00
mmintelandClaude Opus 5.5 e89a248ca5 feat: binary glow effects, refined drawings and calls to action
Dark surfaces now carry light: frames glow along their edge and shed binary
digits (BitAura), dark sections open with a lit seam (BitSeam), the career
path is a line of light and "My work" became a dark stage. Buttons carry a
travelling light and a spot that follows the pointer (BitGlow).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:07:09 +02:00
mmintelandClaude Opus 5.5 e9f05b02fc feat: search metadata, structured data and indexing rules
Only the production deployment may be indexed; testing and preview builds
answer with noindex and a closed robots file. The homepage now describes the
person, the services and the FAQ as structured data, and title, description
and the 404 page are written for what people search for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:07:03 +02:00
84 changed files with 7389 additions and 2091 deletions
+22 -7
View File
@@ -25,22 +25,37 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
- Never mention AI as the owner's tool, and no internal method vocabulary (specs, TDD, mutation, coverage,
pull requests). The automation service may name AI as part of what the client gets. Plain language.
- Modest and factual: no promises the owner did not confirm, no superlatives. See the concept, section 1.
- One page: everything lives on the homepage. The header has only "Get in touch", which jumps to the contact
section (`#contact`). Contact is one email address: no calendar integration, no form. The only other
pages are `/imprint` and `/privacy`. URLs of the previous site redirect to `/` (see `next.config.mjs`).
- Title, description and link previews come from `SITE_TITLE` and `SITE_DESCRIPTION` in `src/content/site.ts`.
The share image is a screenshot of the real hero: after changing the hero headline or its design, run
`pnpm --filter @mintel/web share-image` and commit the two PNGs in `app/(site)/`.
- One page: everything lives on the homepage. The header has the language switch and "Get in touch", which
jumps to the contact section (`#contact`). Contact is one email address: no calendar integration, no form.
The only other pages are the imprint and the privacy notice. URLs of the previous site redirect to `/`
(see `next.config.mjs`).
- Title, description and link previews come from `site` in `src/content/de.ts` and `en.ts`.
The share image is a screenshot of the real hero, one per language: after changing the hero headline or
its design, run `pnpm --filter @mintel/web share-image` and commit the PNGs in `src/assets/share/`.
- The email address is kept in parts (`src/content/site.ts`) and assembled in the browser by `EmailLink`.
Never write the full address into markup, metadata or copy.
- The hero design (binary-digit headline) is approved: do not change its design.
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
## Two languages (German is the main one)
- German lives at the root (`/`, `/impressum`, `/datenschutz`), English under `/en` (`/en/imprint`,
`/en/privacy`). The app directory renders both under `app/[locale]`; `proxy.ts` maps the public addresses
onto it. All routing and detection rules are pure functions in `src/domain/routes.ts` and `locale.ts`.
- Detection: an address without a language follows the `lang` cookie (set only by the language switch), then
the browser's `Accept-Language`, then German. `/en/...` is never redirected.
- Every visible text is data in `src/content/de.ts` and `en.ts` (type `Content` in `types.ts`). Components
hold no texts: server components take them from `contentFor(locale)`, client components from
`useContent()`. A new text goes into both files; German uses the formal "Sie".
- Never name a file `src/i18n.ts` or `i18n/request.ts`: `@mintel/next-config` would switch on next-intl.
- The privacy notice describes the language cookie; keep it true when the detection changes.
## Motion (binary micro-interactions)
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
`BinaryTicker`, `ScrollBits`, `BitBurst`. Reuse them instead of inventing new effects.
`BinaryTicker`, `ScrollBits`, `BitBurst`, `BitCurtain` (the language switch transition). Reuse them
instead of inventing new effects.
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
-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

-315
View File
@@ -1,315 +0,0 @@
import type { Metadata } from "next";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { Faq } from "@/src/components/home/Faq";
import { HeroStatement } from "@/src/components/home/HeroStatement";
import { HomeJsonLd } from "@/src/components/home/HomeJsonLd";
import { PathPicker } from "@/src/components/home/PathPicker";
import { PortraitSection } from "@/src/components/home/PortraitSection";
import { StickyBook } from "@/src/components/home/StickyBook";
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 { 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";
export const metadata: Metadata = {
title: { absolute: SITE_TITLE },
description: SITE_DESCRIPTION,
alternates: { canonical: "/" },
};
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 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 function LandingPage() {
return (
<div className="relative flex flex-col bg-white">
<HomeJsonLd />
<HeroStatement />
<StickyBook />
<PortraitSection
id="hello"
index="01"
label="Hello"
title={<>I am Marc.</>}
>
<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.
</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} />
</div>
</section>
<Block
index="02"
label="Services"
title={
<>
What do you need? <span className={MUTED}>Four typical cases.</span>
</>
}
>
<Reveal>
<PathPicker />
</Reveal>
</Block>
<Block
index="03"
label="Process automation"
title={
<>
Let the systems <span className={MUTED}>do the copying.</span>
</>
}
>
<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>
</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) => (
<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>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className="border-b border-slate-100 py-4 font-sans text-lg text-slate-900 md:text-xl"
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
</Block>
<Block
index="04"
label="Technology"
title={
<>
Which projects <span className={MUTED}>I am right for.</span>
</>
}
>
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
{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"}`}
>
<p className={`${MONO} m-0`}>{group.title}</p>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
i === 2 ? "text-slate-400" : "text-slate-900"
}`}
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
<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>
<div className="mt-8 grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
{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}
</p>
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
{group.items.map((item) => (
<li
key={item}
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
>
<DecodeText text={item} trigger="both" />
</li>
))}
</ul>
</div>
))}
</div>
</div>
</Reveal>
<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.
</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.
</p>
</Reveal>
<Reveal>
<PipelineRun />
</Reveal>
</div>
</Block>
<Block
index="05"
label="Working with me"
title={
<>
How I work. <span className={MUTED}>Nothing unusual.</span>
</>
}
>
<Reveal>
<div className="mb-16 overflow-hidden rounded-3xl bg-slate-900 p-4 md:mb-24 md:p-10">
<ContactDiagram />
</div>
</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) => (
<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">
{item.title}
</h3>
<p className={`${BODY} mt-4`}>{item.text}</p>
</div>
</Reveal>
))}
</div>
</Block>
<Block
index="06"
label="My work"
title={
<>
Two sites I built. <span className={MUTED}>Both are live.</span>
</>
}
>
<WorkShowcase items={WORK} />
<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>
</Reveal>
</Block>
<Block
index="07"
label="Questions"
title={
<>
Good to know <span className={MUTED}>before we talk.</span>
</>
}
>
<Reveal>
<Faq />
</Reveal>
</Block>
<ClosingCta />
</div>
);
}
-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>
+336
View File
@@ -0,0 +1,336 @@
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";
import { Faq } from "@/src/components/home/Faq";
import { HeroStatement } from "@/src/components/home/HeroStatement";
import { HomeJsonLd } from "@/src/components/home/HomeJsonLd";
import { PathPicker } from "@/src/components/home/PathPicker";
import { PortraitSection } from "@/src/components/home/PortraitSection";
import { StickyBook } from "@/src/components/home/StickyBook";
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 {
PresentationCompare,
ReasonDrawing,
} from "@/src/components/illustrations/WebPresentation";
import { WorkShowcase } from "@/src/components/illustrations/WorkShowcase";
import { Reveal } from "@/src/components/Reveal";
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";
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 Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
title,
muted = MUTED,
}) => (
<>
{title.lead} <span className={muted}>{title.rest}</span>
</>
);
export default async function LandingPage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const {
hello,
services,
automation,
presentations,
technology,
working,
work,
faq,
} = content;
return (
<div className="relative flex flex-col bg-white">
<HomeJsonLd locale={locale} />
<HeroStatement hero={content.hero} />
<StickyBook />
<PortraitSection
id="hello"
index="01"
label={hello.label}
title={hello.title}
portraitAlt={hello.portraitAlt}
>
<p className="m-0 font-sans text-2xl leading-snug text-white md:text-4xl">
{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="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.label}
title={<Title title={services.title} />}
>
<Reveal>
<PathPicker />
</Reveal>
</Block>
<Block
index="03"
label={automation.label}
title={<Title title={automation.title} />}
>
<Reveal>
<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) => (
<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>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className="border-b border-slate-100 py-4 font-sans text-lg text-slate-900 md:text-xl"
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
</Block>
<Block
index="04"
label={presentations.label}
tone="dark"
title={<Title title={presentations.title} muted="text-slate-500" />}
>
<Reveal>
<p className="m-0 mb-12 max-w-3xl font-sans text-lg leading-relaxed text-slate-300 md:mb-16 md:text-xl">
{presentations.intro}
</p>
</Reveal>
<Reveal>
<BitAura source="around" tone="dark">
<div className="rounded-3xl border border-slate-700 bg-slate-950 px-4 py-8 md:px-10 md:py-12">
<PresentationCompare />
</div>
</BitAura>
</Reveal>
<div className="mt-16 grid grid-cols-1 gap-x-10 gap-y-14 sm:grid-cols-2 md:mt-28 lg:grid-cols-4">
{presentations.reasons.map((reason, i) => (
<Reveal key={reason.title} delay={0.08 * i}>
<div className="rounded-2xl border border-slate-800 bg-slate-950/60 p-4">
<ReasonDrawing index={i} />
</div>
<h3 className="m-0 mt-6 font-sans text-2xl font-bold tracking-tight text-white">
{reason.title}
</h3>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
{reason.text}
</p>
</Reveal>
))}
</div>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-28 md:grid-cols-12 md:gap-12">
<Reveal className="md:col-span-6">
<div className="border-t border-slate-700 pt-6">
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
{presentations.goodFor.title}
</p>
<ul className="m-0 mt-4 list-none p-0">
{presentations.goodFor.items.map((item) => (
<li
key={item}
className="border-b border-slate-800 py-4 font-sans text-lg text-slate-100 md:text-xl"
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
<Reveal delay={0.1} className="md:col-span-6">
<div className="rounded-3xl border border-slate-700 bg-slate-950/60 p-6 md:p-10">
<h3 className="m-0 font-sans text-2xl font-bold tracking-tight text-white md:text-3xl">
{presentations.honest.title}
</h3>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-300 md:text-lg">
{presentations.honest.text}
</p>
<a
href="#contact"
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
{presentations.cta} <span aria-hidden="true">→</span>
</a>
</div>
</Reveal>
</div>
</Block>
<Block
index="05"
label={technology.label}
title={<Title title={technology.title} />}
>
<div className="grid grid-cols-1 gap-12 md:grid-cols-3 md:gap-10">
{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"}`}
>
<p className={`${MONO} m-0`}>{group.title}</p>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
key={item}
className={`border-b border-slate-100 py-4 font-sans text-lg md:text-xl ${
i === 2 ? "text-slate-400" : "text-slate-900"
}`}
>
{item}
</li>
))}
</ul>
</div>
</Reveal>
))}
</div>
<Reveal>
<div className="mt-16 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
<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">
{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}
</p>
<ul className="m-0 mt-4 flex list-none flex-wrap gap-2 p-0">
{group.items.map((item) => (
<li
key={item}
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
>
<DecodeText text={item} trigger="both" />
</li>
))}
</ul>
</div>
))}
</div>
</div>
</Reveal>
<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">
{technology.pipelineTitle}
</h3>
<p className={`${BODY} mb-12 mt-6 max-w-3xl`}>
{technology.pipelineIntro}
</p>
</Reveal>
<Reveal>
<PipelineRun />
</Reveal>
</div>
</Block>
<Block
index="06"
label={working.label}
title={<Title title={working.title} />}
>
<Reveal>
<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">
{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">
{item.title}
</h3>
<p className={`${BODY} mt-4`}>{item.text}</p>
</div>
</Reveal>
))}
</div>
</Block>
<Block
index="07"
label={work.label}
tone="dark"
title={<Title title={work.title} muted="text-slate-500" />}
>
<WorkShowcase />
<Reveal>
<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="08" label={faq.label} title={<Title title={faq.title} />}>
<Reveal>
<Faq items={faq.items} />
</Reveal>
</Block>
<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,333 @@ 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;
}
}
/*
* The contact address on a dark surface: a slow sheen runs through the letters
* and a light travels along the underline. On hover the whole line lights up.
*/
.bit-mail {
position: relative;
display: inline-block;
padding-bottom: 0.42em;
background: linear-gradient(
100deg,
#ffffff 0 38%,
#a5b4fc 50%,
#ffffff 62% 100%
);
background-size: 260% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
animation: bit-mail-sheen 7s ease-in-out infinite;
transition: filter 300ms ease;
}
.bit-mail::before,
.bit-mail::after {
content: "";
position: absolute;
left: 0;
right: 0;
border-radius: 4px;
pointer-events: none;
}
/* The resting underline. */
.bit-mail::before {
bottom: 1px;
height: 2px;
background: rgba(255, 255, 255, 0.26);
}
/* The light travelling along it. */
.bit-mail::after {
bottom: 0;
height: 4px;
background: linear-gradient(
90deg,
transparent,
#818cf8 30%,
#ffffff 50%,
#818cf8 70%,
transparent
)
no-repeat;
background-size: 42% 100%;
filter: drop-shadow(0 0 8px rgba(129, 140, 248, 0.95));
animation: bit-mail-run 3.8s ease-in-out infinite alternate;
transition: background-size 400ms ease;
}
@media (hover: hover) {
.bit-mail:hover {
filter: drop-shadow(0 0 18px rgba(129, 140, 248, 0.7));
}
.bit-mail:hover::after {
background-size: 100% 100%;
}
}
@keyframes bit-mail-sheen {
0%,
100% {
background-position: 100% 0;
}
50% {
background-position: 0% 0;
}
}
@keyframes bit-mail-run {
from {
background-position: -30% 0;
}
to {
background-position: 130% 0;
}
}
/* 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 } },
}));
}
+2 -5
View File
@@ -28,6 +28,7 @@ const nextConfig = {
],
},
// The site is a single page. Old URLs from the previous site lead to it.
// The language of a request and the legal pages are handled in proxy.ts.
async redirects() {
return [
"/about",
@@ -41,11 +42,7 @@ const nextConfig = {
"/technologies/:path*",
"/tags/:path*",
]
.map((source) => ({ source, destination: "/", permanent: true }))
.concat([
{ source: "/impressum", destination: "/imprint", permanent: true },
{ source: "/datenschutz", destination: "/privacy", permanent: true },
]);
.map((source) => ({ source, destination: "/", permanent: true }));
},
// In Standalone mode, Next.js expects the tracing root to be the monorepo root
outputFileTracingRoot: path.join(dirname, '../../'),
+1 -1
View File
@@ -16,7 +16,7 @@ books a 20-minute intro call within 2 minutes. Low sales overhead, more bookable
- **No internal method vocabulary** on client-facing pages: no specs, TDD, mutation testing, coverage, pull requests. Translate into what the client gets: changes do not break things, a written update every week, predictable cost. Technical detail belongs on `/process` and `/notes`, for engineers.
- Do not say: "everything", "fullstack for anyone", "vibe coding". No location or tax talk; say "remote, available during European business hours".
- Pick the visitor up: introduce me first, then let them choose their need (developer for a product, make an app reliable, just a website), then explain for that need what they get, how it runs, what it costs.
- Language: English primary, German route `/de` for DACH.
- Language (owner decision, 2026-10-04): German is the main language at the root, English lives under `/en`. The language is detected from the browser and can be switched in the header.
### Audiences (priority order)
+52 -24
View File
@@ -1,8 +1,11 @@
/**
* Server request shell. Reads the incoming request once, classifies it via
* the pure domain decision, and records a page view without touching the
* response. All analytics happen server-side; no tracking script ships to
* the browser.
* Server request shell. Reads the incoming request once and does two things,
* both decided by pure functions in src/domain:
*
* 1. Picks the language of the request and maps the public address onto the
* app directory (routes.ts).
* 2. Records a page view server-side (pageview-decision.ts). No tracking
* script ships to the browser.
*
* Page views are browser navigations (`sec-fetch-dest: document`).
* Router-internal headers (RSC, next-router-prefetch) are stripped by the
@@ -12,7 +15,12 @@
import { NextResponse, type NextRequest } from "next/server";
import { recordPageviewToUmami } from "@/src/analytics/recordPageviewToUmami";
import { LOCALE_COOKIE } from "@/src/domain/locale";
import { decidePageview } from "@/src/domain/pageview-decision";
import { routeRequest } from "@/src/domain/routes";
const PERMANENT = 308;
const TEMPORARY = 307;
const primaryLanguage = (acceptLanguage: string | null): string =>
acceptLanguage?.split(",")[0]?.split(";")[0]?.trim() ?? "";
@@ -22,32 +30,52 @@ const firstForwardedIp = (forwardedFor: string | null): string =>
const hostnameWithoutPort = (host: string): string => host.replace(/:\d+$/, "");
export default function proxy(request: NextRequest): NextResponse {
const decision = decidePageview({
method: request.method,
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
export function proxy(request: NextRequest): NextResponse {
const route = routeRequest({
pathname: request.nextUrl.pathname,
search: request.nextUrl.search,
hostname: hostnameWithoutPort(
request.headers.get("x-forwarded-host") ??
request.headers.get("host") ??
"",
),
referrer: request.headers.get("referer") ?? "",
language: primaryLanguage(request.headers.get("accept-language")),
userAgent: request.headers.get("user-agent") ?? "",
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
cookie: request.cookies.get(LOCALE_COOKIE)?.value,
acceptLanguage: request.headers.get("accept-language"),
});
if (decision.track) {
recordPageviewToUmami(decision.facts);
const url = request.nextUrl.clone();
url.pathname = route.to;
const response =
route.kind === "redirect"
? NextResponse.redirect(url, route.permanent ? PERMANENT : TEMPORARY)
: NextResponse.rewrite(url);
// The answer depends on the visitor's language: caches must keep one per language.
if (route.negotiated) {
response.headers.append("Vary", "Accept-Language, Cookie");
}
return NextResponse.next();
// A redirect is not a view: the request that follows it is counted instead.
if (route.kind !== "redirect") {
const pageview = decidePageview({
method: request.method,
secFetchDest: request.headers.get("sec-fetch-dest") ?? "",
// The public address the visitor asked for, not the internal one it maps to.
pathname: request.nextUrl.pathname,
search: request.nextUrl.search,
hostname: hostnameWithoutPort(
request.headers.get("x-forwarded-host") ??
request.headers.get("host") ??
"",
),
referrer: request.headers.get("referer") ?? "",
language: primaryLanguage(request.headers.get("accept-language")),
userAgent: request.headers.get("user-agent") ?? "",
clientIp: firstForwardedIp(request.headers.get("x-forwarded-for")),
});
if (pageview.track) {
recordPageviewToUmami(pageview.facts);
}
}
return response;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|.*\\.(?:png|jpe?g|gif|svg|webp|avif|ico|css|mjs|cjs|js|map|txt|xml|json|webmanifest|woff2?|ttf|otf|mp4|webm|mov|pdf|zip)$).*)",
],
// Pages only: not Next's own files, the error proxy, or files.
matcher: ["/((?!_next/|errors/|.*\\..*).*)"],
};
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://mintel.me/sitemap.xml
+50 -37
View File
@@ -1,25 +1,35 @@
// Renders the share image (Open Graph and Twitter) from the real hero, so it always
// matches the site. Run it after the hero headline or its design changes:
// Renders the share images (Open Graph and Twitter) from the real hero, one per
// language, so they always match the site. Run it after the hero headline or its
// design changes:
//
// pnpm --filter @mintel/web share-image (uses the live site)
// pnpm --filter @mintel/web share-image http://mintel.localhost/
// pnpm --filter @mintel/web share-image http://localhost:3000
//
// It drives the locally installed Chrome through Playwright and writes
// app/(site)/opengraph-image.png and app/(site)/twitter-image.png.
import { copyFileSync } from "node:fs";
// src/assets/share/share-de.png and share-en.png (used by app/[locale]/layout.tsx).
import { chromium } from "playwright";
const url = process.argv[2] ?? "https://mintel.me/";
const out = "app/(site)/opengraph-image.png";
const origin = (process.argv[2] ?? "https://mintel.me").replace(/\/$/, "");
// The browser language matches the page, so the language detection lets it through.
const SHOTS = [
{ locale: "de", path: "/", browserLocale: "de-DE" },
{ locale: "en", path: "/en", browserLocale: "en-US" },
];
const browser = await chromium.launch({ channel: "chrome", headless: true });
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
});
await page.goto(url, { waitUntil: "load", timeout: 90000 });
await page.addStyleTag({
content: `
header, footer, #hero-cta, [class*="fixed"] { display: none !important; }
for (const shot of SHOTS) {
const out = `src/assets/share/share-${shot.locale}.png`;
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
locale: shot.browserLocale,
});
await page.goto(`${origin}${shot.path}`, {
waitUntil: "load",
timeout: 90000,
});
await page.addStyleTag({
content: `
header, footer, #hero-cta, [class*="fixed"], nextjs-portal { display: none !important; }
main > div > section:first-of-type > div.relative { min-height: 630px !important; height: 630px !important; padding: 0 72px !important; justify-content: center !important; max-width: none !important; }
main > div > section:first-of-type ul { display: none !important; }
main > div > section:first-of-type h1 { font-size: 150px !important; }
@@ -29,26 +39,29 @@ await page.addStyleTag({
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
html, body { overflow: hidden !important; }
`,
});
await page.evaluate(() => {
const section = document.querySelector("main section");
const tag = document.createElement("div");
tag.textContent = "MARC MINTEL";
tag.style.cssText =
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
const site = document.createElement("div");
site.textContent = "mintel.me";
site.style.cssText =
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
section.style.position = "relative";
section.append(tag, site);
window.scrollTo(0, 0);
});
await page.waitForTimeout(4500);
await page.screenshot({
path: out,
clip: { x: 0, y: 0, width: 1200, height: 630 },
});
});
// Let the page finish starting up first; it would discard the added labels.
await page.waitForTimeout(2000);
await page.evaluate(() => {
const section = document.querySelector("main section");
const tag = document.createElement("div");
tag.textContent = "MARC MINTEL";
tag.style.cssText =
"position:absolute;left:72px;top:54px;z-index:20;font:700 15px ui-monospace,Menlo,monospace;letter-spacing:.3em;color:#0f172a";
const site = document.createElement("div");
site.textContent = "mintel.me";
site.style.cssText =
"position:absolute;right:72px;top:54px;z-index:20;font:500 15px ui-monospace,Menlo,monospace;letter-spacing:.2em;color:#64748b";
section.style.position = "relative";
section.append(tag, site);
window.scrollTo(0, 0);
});
await page.waitForTimeout(4500);
await page.screenshot({
path: out,
clip: { x: 0, y: 0, width: 1200, height: 630 },
});
await page.close();
console.log("written:", out);
}
await browser.close();
copyFileSync(out, "app/(site)/twitter-image.png");
console.log("written:", out, "and app/(site)/twitter-image.png");
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

+57 -129
View File
@@ -1,29 +1,42 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
type ButtonVariant = "primary" | "outline" | "ghost";
type ButtonSize = "normal" | "large";
interface ButtonProps {
href: string;
children: React.ReactNode;
variant?: "primary" | "outline" | "ghost";
size?: "normal" | "large";
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
showArrow?: boolean;
onClick?: (e: React.MouseEvent<any>) => void;
[key: string]: any;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}
const BASE =
"group relative inline-flex cursor-pointer items-center justify-center gap-3 rounded-full font-sans font-bold uppercase not-italic no-underline transition-all duration-300";
const SIZES: Record<ButtonSize, string> = {
normal: "py-2.5 pl-7 pr-2.5 text-[11px] tracking-[0.18em]",
large: "py-3 pl-8 pr-3 text-[12px] tracking-[0.18em]",
};
const VARIANTS: Record<ButtonVariant, string> = {
primary:
"bit-cta bg-slate-900 text-white shadow-lg shadow-slate-900/10 hover:-translate-y-0.5",
outline:
"bit-cta bit-cta-quiet border border-slate-300 bg-white text-slate-900 hover:-translate-y-0.5",
ghost: "bg-transparent text-slate-500 hover:text-slate-900",
};
/**
* Premium Button: Pill-shaped, binary-accent hover effect, unified design.
*
* On hover:
* - A stream of binary characters scrolls across the button background
* - Primary: white binary on dark bg
* - Outline: blue binary on transparent bg
* - Subtle, fast, and satisfying
* Button: the pill-shaped call to action. A light travels round its edge
* (`bit-cta` in globals.css) and the dark variant carries a spot of light that
* follows the pointer (see BitGlow); the label itself stays still.
*/
export const Button: React.FC<ButtonProps> = ({
href,
@@ -33,135 +46,50 @@ export const Button: React.FC<ButtonProps> = ({
className = "",
showArrow = true,
onClick,
...props
}) => {
const [hovered, setHovered] = React.useState(false);
const [displayText, setDisplayText] = React.useState<string | null>(null);
const contentRef = React.useRef<HTMLSpanElement>(null);
// Binary scramble on hover
React.useEffect(() => {
if (!hovered) {
setDisplayText(null);
return;
}
const original = contentRef.current?.textContent || "";
if (!original) return;
const chars = original.split("");
const total = 20;
let frame = 0;
const iv = setInterval(() => {
frame++;
const s = chars.map((c, i) => {
if (c === " ") return " ";
const settle = (frame / total) * chars.length;
if (i < settle) return c;
return Math.random() > 0.5 ? "1" : "0";
});
setDisplayText(s.join(""));
if (frame >= total) {
setDisplayText(null);
clearInterval(iv);
}
}, 1000 / 60);
return () => clearInterval(iv);
}, [hovered]);
const [binaryStr, setBinaryStr] = React.useState(
"0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1",
);
React.useEffect(() => {
setBinaryStr(
Array.from({ length: 60 }, () => (Math.random() > 0.5 ? "0" : "1")).join(
" ",
),
);
}, []);
const base =
"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";
const sizes: Record<string, string> = {
normal: "px-8 py-4 text-[10px]",
large: "px-10 py-5 text-[11px]",
};
const variants: Record<string, string> = {
primary:
"bg-slate-900 text-white hover:shadow-xl hover:shadow-slate-900/20 hover:-translate-y-0.5",
outline:
"border border-slate-200 bg-transparent text-slate-900 hover:border-slate-400 hover:-translate-y-0.5",
ghost: "bg-transparent text-slate-500 hover:text-slate-900",
};
// Binary stream overlay colors by variant
const binaryColor =
variant === "primary"
? "rgba(255,255,255,0.12)"
: variant === "outline"
? "rgba(59,130,246,0.15)"
: "rgba(148,163,184,0.15)";
const inner = (
<motion.span
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
<span
data-glow={variant === "primary" ? "" : undefined}
className={`${BASE} ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
>
{/* Binary stream hover overlay */}
<span
className="absolute inset-0 flex items-center pointer-events-none overflow-hidden"
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.3s ease" }}
>
<motion.span
className="whitespace-nowrap font-mono text-xs tracking-[0.4em] select-none"
style={{ color: binaryColor }}
animate={hovered ? { x: [0, -200] } : { x: 0 }}
transition={
hovered ? { duration: 3, repeat: Infinity, ease: "linear" } : {}
}
>
{binaryStr}&nbsp;{binaryStr}
</motion.span>
</span>
{/* Shimmer line on hover (top edge) */}
<span
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
style={{
opacity: hovered ? 1 : 0,
transition: "opacity 0.5s ease",
background:
variant === "primary"
? "linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)"
: "linear-gradient(90deg, transparent, rgba(59,130,246,0.15), transparent)",
}}
/>
{/* Content */}
<span
className="relative z-10 flex items-center gap-3"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span ref={contentRef}>{displayText ?? children}</span>
{variant === "primary" && (
<span className="bit-glow bit-glow-spot" aria-hidden="true" />
)}
<span className="relative z-10 flex items-center gap-4">
{children}
{showArrow && (
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform duration-300" />
<span
className={`grid h-8 w-8 place-items-center rounded-full transition-colors duration-300 ${
variant === "primary"
? "bg-white/10 group-hover:bg-white group-hover:text-slate-900"
: "bg-slate-100 group-hover:bg-slate-900 group-hover:text-white"
}`}
aria-hidden="true"
>
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-0.5"
strokeWidth={2.25}
/>
</span>
)}
</span>
</motion.span>
</span>
);
if (href.startsWith("#")) {
return (
<a
href={href}
onClick={(e) => {
if (onClick) onClick(e);
e.preventDefault();
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
className="no-underline"
onClick={(event) => {
onClick?.(event);
event.preventDefault();
if (href.length > 1) {
document
.querySelector(href)
?.scrollIntoView({ behavior: "smooth" });
}
}}
{...props}
>
{inner}
</a>
@@ -169,7 +97,7 @@ export const Button: React.FC<ButtonProps> = ({
}
return (
<Link href={href} onClick={onClick} {...props}>
<Link href={href} onClick={onClick} className="no-underline">
{inner}
</Link>
);
@@ -0,0 +1,34 @@
"use client";
import * as React from "react";
import type { Content } from "@/src/content/types";
import type { Locale } from "@/src/domain/locale";
type Value = { readonly locale: Locale; readonly content: Content };
const ContentContext = React.createContext<Value | null>(null);
/**
* ContentProvider: hands the language of the page and its texts to the client
* components. The layout picks both; only the texts of that one language are
* sent to the browser.
*/
export const ContentProvider: React.FC<
Value & { children: React.ReactNode }
> = ({ locale, content, children }) => {
const value = React.useMemo(() => ({ locale, content }), [locale, content]);
return (
<ContentContext.Provider value={value}>{children}</ContentContext.Provider>
);
};
const useValue = (): Value => {
const value = React.useContext(ContentContext);
if (value === null) {
throw new Error("useContent and useLocale need a ContentProvider above.");
}
return value;
};
export const useContent = (): Content => useValue().content;
export const useLocale = (): Locale => useValue().locale;
@@ -56,10 +56,18 @@ export const AbstractCircuit: React.FC<{
// ── Seed characters ──
const LANE = 28;
const GAP = 20;
const PHONE_WIDTH = 640;
const PHONE_INK = 2.4;
const PHONE_GROW = 2;
const seed = () => {
const chars: Char[] = [];
const { w, h } = s;
// On a phone the field is small and held close: the digits need more
// size and ink there, or they are not seen at all.
const small = w < PHONE_WIDTH;
const ink = small ? PHONE_INK : 1;
const grow = small ? PHONE_GROW : 0;
// Horizontal lanes
for (let y = 0; y < h; y += LANE) {
@@ -73,8 +81,8 @@ export const AbstractCircuit: React.FC<{
val: Math.random() > 0.5 ? 1 : 0,
speed: spd,
vertical: false,
size: 9 + Math.floor(Math.random() * 2),
baseAlpha: 0.035 + Math.random() * 0.045,
size: 9 + grow + Math.floor(Math.random() * 2),
baseAlpha: (0.035 + Math.random() * 0.045) * ink,
phase: Math.random() * Math.PI * 2,
flipIn: 120 + Math.floor(Math.random() * 400),
});
@@ -93,8 +101,8 @@ export const AbstractCircuit: React.FC<{
val: Math.random() > 0.5 ? 1 : 0,
speed: spd,
vertical: true,
size: 8 + Math.floor(Math.random() * 2),
baseAlpha: 0.025 + Math.random() * 0.035,
size: 8 + grow + Math.floor(Math.random() * 2),
baseAlpha: (0.025 + Math.random() * 0.035) * ink,
phase: Math.random() * Math.PI * 2,
flipIn: 150 + Math.floor(Math.random() * 500),
});
+14 -6
View File
@@ -1,11 +1,18 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import * as React from "react";
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
import { pathFor } from "@/src/domain/routes";
import { BinaryTicker } from "./bits/BinaryTicker";
import { useContent, useLocale } from "./ContentProvider";
import { LanguageSwitch } from "./LanguageSwitch";
export const Footer: React.FC = () => {
const { nav } = useContent();
const locale = useLocale();
const currentYear = new Date().getFullYear();
return (
@@ -38,24 +45,25 @@ export const Footer: React.FC = () => {
<span>© {currentYear}</span>
<div className="flex flex-wrap gap-x-8 gap-y-3 md:justify-end">
<Link
href="/#contact"
href={`${pathFor("home", locale)}#contact`}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Get in touch
{nav.contactLong}
</Link>
<Link
href="/imprint"
href={pathFor("imprint", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Imprint
{nav.imprint}
</Link>
<Link
href="/privacy"
href={pathFor("privacy", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
Privacy
{nav.privacy}
</Link>
</div>
<LanguageSwitch variant="text" />
{/* Subtle binary decoration */}
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
{[77, 73, 78].map((code) => (
+21 -13
View File
@@ -3,12 +3,17 @@ import Image from "next/image";
import Link from "next/link";
import * as React from "react";
import { pathFor } from "@/src/domain/routes";
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
import { DecodeText } from "./bits/DecodeText";
import { ScrollBits } from "./bits/ScrollBits";
import { useContent, useLocale } from "./ContentProvider";
import { LanguageSwitch } from "./LanguageSwitch";
/** Header: the logo and one action. Everything else lives on the homepage. */
/** Header: the logo, the language switch and one action. Everything else lives on the homepage. */
export const Header: React.FC = () => {
const { nav } = useContent();
const home = pathFor("home", useLocale());
const [isScrolled, setIsScrolled] = React.useState(false);
React.useEffect(() => {
@@ -32,11 +37,11 @@ export const Header: React.FC = () => {
<div className="mx-auto w-full max-w-[1400px] px-5 md:px-12 py-4 flex items-center justify-between relative z-10">
<Link
href="/"
aria-label="Marc Mintel, back to top"
href={home}
aria-label={nav.logoLabel}
onClick={(event) => {
// Already on the homepage: scroll up instead of reloading the route.
if (window.location.pathname === "/") {
if (window.location.pathname === home) {
event.preventDefault();
window.scrollTo({ top: 0, behavior: "smooth" });
}
@@ -56,15 +61,18 @@ export const Header: React.FC = () => {
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
<Link
href="/#contact"
className="font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
style={{
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<DecodeText text="Get in touch" trigger="hover" />
</Link>
<div className="flex items-center gap-2.5 md:gap-4">
<LanguageSwitch />
<Link
href={`${home}#contact`}
className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
style={{
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}}
>
<DecodeText text={nav.contact} trigger="hover" />
</Link>
</div>
</div>
</header>
);
@@ -1,8 +1,10 @@
"use client";
import React, { useEffect, useState } from "react";
import { useContent } from "./ContentProvider";
export const InteractiveElements: React.FC = () => {
const { nav } = useContent();
const [progress, setProgress] = useState(0);
const [showBackToTop, setShowBackToTop] = useState(false);
@@ -42,7 +44,7 @@ export const InteractiveElements: React.FC = () => {
<button
onClick={scrollToTop}
className={`floating-back-to-top ${showBackToTop ? "visible" : ""}`}
aria-label="Back to top"
aria-label={nav.backToTop}
style={{ display: "flex" }}
>
<svg
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { usePathname } from "next/navigation";
import * as React from "react";
import { LOCALES, localeCookie, type Locale } from "@/src/domain/locale";
import { switchPath } from "@/src/domain/routes";
import { DecodeText } from "./bits/DecodeText";
import { useContent, useLocale } from "./ContentProvider";
import {
getIdleState,
getSwitchState,
startSwitch,
subscribe,
} from "./locale-switch-store";
const EASE = "cubic-bezier(0.23, 1, 0.32, 1)";
/**
* LanguageSwitch: the two languages side by side, with a marker that slides to
* the chosen one. Choosing a language closes the bit curtain (see BitCurtain),
* swaps the page behind it and keeps the reading position. The choice is stored
* in a cookie so that the language detection does not undo it.
*
* The options are plain links to the other language's address, so the switch
* also works without JavaScript and for search engines.
*/
export const LanguageSwitch: React.FC<{
/** `pill` for the header, `text` for the footer. */
variant?: "pill" | "text";
}> = ({ variant = "pill" }) => {
const locale = useLocale();
const { nav } = useContent();
const pathname = usePathname();
const state = React.useSyncExternalStore(
subscribe,
getSwitchState,
getIdleState,
);
// The marker moves as soon as a language is chosen, before the page follows.
const shown = state.phase === "idle" ? locale : state.target;
const choose = (target: Locale) => (event: React.MouseEvent) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
event.preventDefault();
if (target === locale) return;
document.cookie = localeCookie(target);
startSwitch(locale, target, switchPath(pathname, target));
};
if (variant === "text") {
return (
<nav aria-label={nav.language} className="flex gap-x-4">
{LOCALES.map((option) => (
<a
key={option}
href={switchPath(pathname, option)}
hrefLang={option}
lang={option}
aria-current={option === locale ? "true" : undefined}
onClick={choose(option)}
className={`no-underline transition-colors duration-300 hover:text-slate-900 ${
option === locale ? "text-slate-900" : "text-slate-400"
}`}
>
{nav.languageNames[option]}
</a>
))}
</nav>
);
}
return (
<nav
aria-label={nav.language}
className="group/lang relative flex items-center rounded-full border border-slate-200 bg-white/70 p-[3px] font-mono text-[10px] font-bold tracking-[0.2em] transition-colors duration-500 hover:border-slate-400"
>
<span
aria-hidden="true"
className="absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-full bg-slate-900 shadow-sm transition-transform duration-500"
style={{
transform: `translateX(${LOCALES.indexOf(shown) * 100}%)`,
transitionTimingFunction: EASE,
}}
/>
{LOCALES.map((option) => (
<a
key={option}
href={switchPath(pathname, option)}
hrefLang={option}
lang={option}
aria-label={nav.languageNames[option]}
aria-current={option === locale ? "true" : undefined}
title={nav.languageNames[option]}
onClick={choose(option)}
className={`relative z-10 grid h-8 w-10 place-items-center rounded-full pl-[0.2em] no-underline transition-colors duration-300 md:h-9 md:w-11 ${
option === shown
? "text-white"
: "text-slate-400 hover:text-slate-900"
}`}
>
<DecodeText text={option.toUpperCase()} trigger="hover" />
</a>
))}
</nav>
);
};
+406
View File
@@ -0,0 +1,406 @@
"use client";
import * as React from "react";
type AuraSource = "top" | "right" | "bottom" | "left" | "around";
type AuraTone = "dark" | "light";
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it. */
type AuraMotion = "sparks" | "rain";
interface BitAuraProps {
/** The edge the energy sits on, or `around` to let it travel round the element. */
source: AuraSource;
/** The background the element stands on: `dark` adds light, `light` only ink digits. */
tone: AuraTone;
motion?: AuraMotion;
/** Share of the usual amount of digits (1 = default). */
density?: number;
/** Corner radius of the lit element in px. */
radius?: number;
className?: string;
children: React.ReactNode;
}
interface Box {
width: number;
height: number;
}
/** A bright zone on the outline, placed by its position along the perimeter (0..1). */
interface Spot {
at: number;
power: number;
}
interface Digit {
x: number;
y: number;
vx: number;
vy: number;
age: number;
life: number;
seed: number;
power: number;
large: boolean;
/** Outward normal at the point of the outline the digit belongs to. */
nx: number;
ny: number;
}
interface Palette {
blend: GlobalCompositeOperation;
glow: string;
core: string;
digit: string;
gain: number;
/** Share of the digit sparks that are kept (1 = all). */
density: number;
}
const MARGIN = 180;
const FRAME_MS = 1000 / 30;
const MAX_STEP_S = 0.1;
const MAX_PIXEL_RATIO = 2;
/** `rounded-3xl` in this project's Tailwind theme. */
const DEFAULT_RADIUS = 32;
const STILL_FRAME_STEPS = 90;
/** The glow is the outline stroked from wide and faint to thin and bright. */
const GLOW_LAYERS = [
{ width: 150, alpha: 0.035, hot: false },
{ width: 110, alpha: 0.05, hot: false },
{ width: 78, alpha: 0.07, hot: false },
{ width: 52, alpha: 0.1, hot: false },
{ width: 32, alpha: 0.15, hot: false },
{ width: 18, alpha: 0.22, hot: false },
{ width: 7, alpha: 0.45, hot: true },
{ width: 2, alpha: 0.95, hot: true },
] as const;
const LIGHT_PALETTE: Palette = {
blend: "source-over",
glow: "15, 23, 42",
core: "15, 23, 42",
digit: "15, 23, 42",
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
gain: 0,
density: 0.6,
};
const DARK_PALETTE: Palette = {
blend: "lighter",
glow: "99, 102, 241",
core: "224, 231, 255",
digit: "199, 210, 254",
gain: 1,
density: 1,
};
const MOTION_DENSITY: Record<AuraMotion, number> = {
sparks: 0.45,
rain: 0.3,
};
/** How often a drifting digit switches between 0 and 1. */
const FLIPS_PER_S = 0.9;
const fraction = (value: number) => value - Math.floor(value);
const perimeter = ({ width, height }: Box) => 2 * (width + height);
/** Point and outward normal on the outline, walking clockwise from the top left corner. */
const outlinePoint = (at: number, { width, height }: Box) => {
let distance = fraction(at) * 2 * (width + height);
if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
distance -= width;
if (distance < height) return { x: width, y: distance, nx: 1, ny: 0 };
distance -= height;
if (distance < width) return { x: width - distance, y: height, nx: 0, ny: 1 };
distance -= width;
return { x: 0, y: height - distance, nx: -1, ny: 0 };
};
/** The stretch of the perimeter (0..1) that one edge covers. */
const edgeRange = (
source: Exclude<AuraSource, "around">,
box: Box,
): readonly [number, number] => {
const total = perimeter(box);
const top = box.width / total;
const right = (box.width + box.height) / total;
const bottom = (2 * box.width + box.height) / total;
if (source === "top") return [0, top];
if (source === "right") return [top, right];
if (source === "bottom") return [right, bottom];
return [bottom, 1];
};
/** Where the energy is at time `t` (seconds): it wanders along one edge or circles the element. */
const spotsAt = (
source: AuraSource,
motion: AuraMotion,
t: number,
box: Box,
): readonly Spot[] => {
if (source === "around") {
return [
{ at: fraction(t * 0.03), power: 1 },
{ at: fraction(t * 0.03 + 0.5), power: 0.7 },
{ at: fraction(1.2 - t * 0.018), power: 0.5 },
];
}
const [from, to] = edgeRange(source, box);
const along = (share: number) => from + (to - from) * share;
return [
{
at: along(0.5 + 0.32 * Math.sin(t * 0.5)),
power: 0.85 + 0.15 * Math.sin(t * 0.9),
},
{ at: along(0.5 + 0.4 * Math.sin(t * 0.31 + 2)), power: 0.6 },
{
at: along(0.5 + 0.45 * Math.sin(t * 0.83 + 4)),
power: 0.35 + 0.2 * Math.sin(t * 1.3),
},
];
};
const spawnDigit = (
spots: readonly Spot[],
motion: AuraMotion,
box: Box,
): Digit => {
const total = spots.reduce((sum, spot) => sum + spot.power, 0);
let pick = Math.random() * total;
const spot =
spots.find((candidate) => (pick -= candidate.power) <= 0) ?? spots[0];
const power = spot?.power ?? 1;
const base = {
age: 0,
seed: Math.random(),
power,
large: Math.random() < 0.22,
};
const wide = motion === "rain" ? 0.12 : 0.07;
const scatter = (Math.random() + Math.random() + Math.random() - 1.5) * wide;
const point = outlinePoint((spot?.at ?? 0) + scatter, box);
if (motion === "rain") {
const distance = 40 + Math.random() * 80;
const speed = 22 + Math.random() * 26;
return {
...base,
x: point.x + point.nx * distance,
y: point.y + point.ny * distance,
vx: -point.nx * speed,
vy: -point.ny * speed,
nx: point.nx,
ny: point.ny,
life: distance / speed,
};
}
const speed = 10 + Math.random() * 24;
const slide = (Math.random() - 0.5) * 12;
return {
...base,
x: point.x,
y: point.y,
vx: point.nx * speed - point.ny * slide,
vy: point.ny * speed + point.nx * slide,
nx: point.nx,
ny: point.ny,
life: 2.4 + Math.random() * 2.6,
};
};
/**
* BitAura: energy that hugs the outline of the element it wraps. The glow flows
* along the edge and binary digits drift off it like sparks. Decorative only:
* it runs while in view and stands still when reduced motion is requested.
*/
export const BitAura: React.FC<BitAuraProps> = ({
source,
tone,
motion = "sparks",
density = 1,
radius = DEFAULT_RADIUS,
className = "",
children,
}) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!wrapper || !canvas || !context) return;
const palette = tone === "light" ? LIGHT_PALETTE : DARK_PALETTE;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const fontFamily = window.getComputedStyle(canvas).fontFamily;
let box: Box = { width: 0, height: 0 };
let digits: Digit[] = [];
let owed = 0;
let clock = Math.random() * 40;
let last = 0;
let frame = 0;
let visible = false;
const step = (seconds: number) => {
clock += seconds;
const spots = spotsAt(source, motion, clock, box);
const rate =
Math.min(90, Math.max(28, perimeter(box) / 30)) *
palette.density *
MOTION_DENSITY[motion] *
density;
owed += rate * seconds;
while (owed >= 1) {
digits.push(spawnDigit(spots, motion, box));
owed -= 1;
}
const drag = 1 - 0.6 * seconds;
digits = digits.filter((digit) => {
digit.age += seconds;
if (motion !== "rain") {
digit.vx *= drag;
digit.vy = digit.vy * drag - 7 * seconds;
}
digit.x += digit.vx * seconds;
digit.y += digit.vy * seconds;
return digit.age < digit.life;
});
return spots;
};
const draw = (spots: readonly Spot[]) => {
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
context.setTransform(ratio, 0, 0, ratio, MARGIN * ratio, MARGIN * ratio);
context.clearRect(
-MARGIN,
-MARGIN,
box.width + 2 * MARGIN,
box.height + 2 * MARGIN,
);
context.globalCompositeOperation = palette.blend;
context.globalAlpha = 1;
context.beginPath();
context.roundRect(0, 0, box.width, box.height, radius);
const reach = Math.min(560, Math.max(220, perimeter(box) * 0.2));
for (const spot of palette.gain > 0 ? spots : []) {
const point = outlinePoint(spot.at, box);
for (const layer of GLOW_LAYERS) {
const colour = layer.hot ? palette.core : palette.glow;
const light = context.createRadialGradient(
point.x,
point.y,
0,
point.x,
point.y,
reach,
);
light.addColorStop(
0,
`rgba(${colour}, ${layer.alpha * spot.power * palette.gain})`,
);
light.addColorStop(1, `rgba(${colour}, 0)`);
context.strokeStyle = light;
context.lineWidth = layer.width;
context.stroke();
}
}
context.fillStyle = `rgb(${palette.digit})`;
context.textAlign = "center";
context.textBaseline = "middle";
for (const large of [false, true]) {
context.font = `${large ? 700 : 500} ${large ? 14 : 11}px ${fontFamily}`;
for (const digit of digits) {
if (digit.large !== large) continue;
const fade = Math.sin((Math.PI * digit.age) / digit.life);
context.globalAlpha = Math.min(1, fade * digit.power * 1.1);
const one =
Math.floor(digit.age * FLIPS_PER_S + digit.seed * 10) % 2 === 0;
context.fillText(one ? "1" : "0", digit.x, digit.y);
}
}
};
const resize = () => {
const rect = wrapper.getBoundingClientRect();
box = { width: rect.width, height: rect.height };
const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
canvas.width = Math.round((box.width + 2 * MARGIN) * ratio);
canvas.height = Math.round((box.height + 2 * MARGIN) * ratio);
if (still) {
digits = [];
let spots = spotsAt(source, motion, clock, box);
for (let i = 0; i < STILL_FRAME_STEPS; i++)
spots = step(FRAME_MS / 1000);
draw(spots);
}
};
const tick = (now: number) => {
frame = window.requestAnimationFrame(tick);
if (now - last < FRAME_MS) return;
const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
last = now;
draw(step(seconds));
};
const sizeObserver = new ResizeObserver(resize);
sizeObserver.observe(wrapper);
resize();
const viewObserver = new IntersectionObserver(
([entry]) => {
const next = entry?.isIntersecting ?? false;
if (next === visible || still) return;
visible = next;
if (visible) {
last = performance.now();
frame = window.requestAnimationFrame(tick);
} else {
window.cancelAnimationFrame(frame);
}
},
{ rootMargin: `${MARGIN}px` },
);
viewObserver.observe(wrapper);
return () => {
sizeObserver.disconnect();
viewObserver.disconnect();
window.cancelAnimationFrame(frame);
};
}, [source, tone, motion, density, radius]);
return (
<div
ref={wrapperRef}
// The wrapper has to be positioned; keep the caller's own positioning if it brings one.
className={
className.split(" ").includes("absolute")
? className
: `relative ${className}`
}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="bit-aura pointer-events-none absolute font-mono"
style={{
left: -MARGIN,
top: -MARGIN,
width: `calc(100% + ${2 * MARGIN}px)`,
height: `calc(100% + ${2 * MARGIN}px)`,
}}
/>
<div className="relative h-full">{children}</div>
</div>
);
};
+275
View File
@@ -0,0 +1,275 @@
"use client";
import { useRouter } from "next/navigation";
import * as React from "react";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import {
endSwitch,
enterPhase,
getIdleState,
getSwitchState,
subscribe,
type SwitchState,
} from "@/src/components/locale-switch-store";
import {
captureAnchor,
restoreAnchor,
type SectionBox,
} from "@/src/domain/scroll-anchor";
const COVER_MS = 460;
const REVEAL_MS = 560;
/** The curtain stays closed at least this long, so the language name can be read. */
const HOLD_MS = 320;
const LABEL_DELAY_MS = 140;
const LABEL_MS = 420;
/** If the new page has not arrived by then, load it the plain way. */
const GIVE_UP_MS = 6000;
const CELL = 16;
/** How ragged the edge of the curtain is, as a share of the screen width. */
const SPREAD = 0.24;
const TRAIL = 11;
const INK = "#0f172a"; // slate-900
const MARK = "#fde68a";
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
const easeInOut = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
/** A fixed pseudo-random number per row, so the edge looks the same after the page swap. */
const rowLag = (row: number): number => {
const x = Math.sin(row * 12.9898) * 43758.5453;
return x - Math.floor(x);
};
const sectionBoxes = (): SectionBox[] =>
Array.from(document.querySelectorAll("main section")).map((section) => {
const rect = section.getBoundingClientRect();
return { top: rect.top + window.scrollY, height: rect.height };
});
/** A tile of faint digits that textures the closed curtain. */
const makeTile = (dpr: number, mono: string): HTMLCanvasElement => {
const cells = 8;
const tile = document.createElement("canvas");
tile.width = cells * CELL * dpr;
tile.height = cells * CELL * dpr;
const ctx = tile.getContext("2d");
if (!ctx) return tile;
ctx.scale(dpr, dpr);
ctx.fillStyle = INK;
ctx.fillRect(0, 0, cells * CELL, cells * CELL);
ctx.font = `500 11px ${mono}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "rgba(148, 163, 184, 0.16)";
for (let row = 0; row < cells; row++) {
for (let col = 0; col < cells; col++) {
const bit = rowLag(row * cells + col + 1) < 0.5 ? "0" : "1";
ctx.fillText(bit, col * CELL + CELL / 2, row * CELL + CELL / 2);
}
}
return tile;
};
/**
* BitCurtain: the transition of the language switch. A curtain of binary digits
* closes over the page from left to right, the name of the new language resolves
* out of bits on it, the page is swapped behind it, and the curtain opens again
* on the same spot of the page in the other language.
*
* With reduced motion there is no curtain: the page is swapped at once.
*/
export const BitCurtain: React.FC = () => {
const locale = useLocale();
const { nav } = useContent();
const router = useRouter();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const state = React.useSyncExternalStore(
subscribe,
getSwitchState,
getIdleState,
);
const active = state.phase !== "idle";
// The loop below reads these through refs, so it survives re-renders.
const localeRef = React.useRef(locale);
localeRef.current = locale;
const namesRef = React.useRef(nav.languageNames);
namesRef.current = nav.languageNames;
React.useEffect(() => {
if (!active) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d") ?? null;
const mono =
getComputedStyle(document.documentElement)
.getPropertyValue("--font-jetbrains-mono")
.trim() || "ui-monospace";
const font = `${mono}, ui-monospace, Menlo, monospace`;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
let pattern: CanvasPattern | null = null;
const resize = () => {
if (!canvas || !ctx) return;
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.ceil(width * dpr);
canvas.height = Math.ceil(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
pattern = ctx.createPattern(makeTile(dpr, font), "repeat");
// The tile is drawn at device resolution; scale it back to CSS pixels.
pattern?.setTransform(new DOMMatrix().scale(1 / dpr));
};
const paint = (current: Exclude<SwitchState, { phase: "idle" }>) => {
if (!ctx) return;
const now = performance.now();
const elapsed = now - current.phaseAt;
const opening = current.phase === "reveal";
const progress =
current.phase === "hold"
? 1
: easeInOut(clamp01(elapsed / (opening ? REVEAL_MS : COVER_MS)));
const rows = Math.ceil(height / CELL);
// The edge of the curtain in each row. Closing, the curtain is left of it;
// opening, right of it.
const edge = (row: number): number =>
current.phase === "hold"
? width
: clamp01(progress * (1 + SPREAD) - rowLag(row) * SPREAD) * width;
ctx.clearRect(0, 0, width, height);
ctx.save();
ctx.beginPath();
for (let row = 0; row < rows; row++) {
const x = edge(row);
if (opening) ctx.rect(x, row * CELL, width - x, CELL);
else ctx.rect(0, row * CELL, x, CELL);
}
ctx.clip();
ctx.fillStyle = pattern ?? INK;
ctx.fillRect(0, 0, width, height);
// The name of the language, resolving out of bits. Clipped to the curtain.
const sinceStart = now - current.startedAt - LABEL_DELAY_MS;
const name = namesRef.current[current.target].toUpperCase();
const shown = Math.floor(clamp01(sinceStart / LABEL_MS) * name.length);
const text = Array.from(name)
.map((char, i) => (i < shown ? char : Math.random() < 0.5 ? "0" : "1"))
.join("");
const size = Math.min(width * 0.15, 168);
ctx.globalAlpha = clamp01(sinceStart / 160);
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.font = `800 ${size}px ${font}`;
ctx.fillStyle = "#ffffff";
ctx.fillText(text, width / 2, height / 2 + size * 0.2);
const textWidth = ctx.measureText(text).width;
ctx.fillStyle = MARK;
ctx.fillRect(
width / 2 - textWidth / 2,
height / 2 + size * 0.42,
textWidth * clamp01(sinceStart / LABEL_MS),
Math.max(3, size * 0.035),
);
ctx.font = `500 12px ${font}`;
ctx.fillStyle = "#94a3b8";
ctx.fillText(
`${current.from.toUpperCase()} → ${current.target.toUpperCase()}`,
width / 2,
height / 2 - size * 0.78,
);
ctx.globalAlpha = 1;
ctx.restore();
// Loose digits along the moving edge.
if (current.phase !== "hold") {
ctx.font = `700 12px ${font}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let row = 0; row < rows; row++) {
const x = edge(row);
if (x <= 0 || x >= width) continue;
for (let k = -2; k < TRAIL; k++) {
// k counts cells from the edge into the curtain; negative is on the page.
const cx = x + (opening ? 1 : -1) * (k + 0.5) * CELL;
const onPage = k < 0;
ctx.globalAlpha = onPage ? 0.5 : (1 - k / TRAIL) * 0.9;
ctx.fillStyle = onPage ? INK : k < 2 ? MARK : "#ffffff";
ctx.fillText(
Math.random() < 0.5 ? "0" : "1",
cx,
row * CELL + CELL / 2,
);
}
}
ctx.globalAlpha = 1;
}
};
let raf = 0;
const tick = () => {
const current = getSwitchState();
if (current.phase === "idle") return;
const elapsed = performance.now() - current.phaseAt;
if (current.phase === "cover" && (still || elapsed >= COVER_MS)) {
enterPhase("hold", captureAnchor(sectionBoxes(), window.scrollY));
router.push(current.href, { scroll: false });
} else if (current.phase === "hold") {
const arrived = localeRef.current === current.target;
if (arrived && (still || elapsed >= HOLD_MS)) {
window.scrollTo({
top: restoreAnchor(current.anchor, sectionBoxes()),
behavior: "instant",
});
if (still) endSwitch();
else enterPhase("reveal");
} else if (elapsed > GIVE_UP_MS) {
endSwitch();
window.location.assign(current.href);
return;
}
} else if (current.phase === "reveal" && elapsed >= REVEAL_MS) {
endSwitch();
return;
}
const next = getSwitchState();
if (next.phase === "idle") return;
if (!still) paint(next);
raf = requestAnimationFrame(tick);
};
const started = getSwitchState();
if (started.phase !== "idle") router.prefetch(started.href);
resize();
window.addEventListener("resize", resize);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
};
}, [active, router]);
return (
<div
aria-hidden="true"
className={`fixed inset-0 z-[300] motion-reduce:hidden ${active ? "block" : "hidden"}`}
style={{
// Closed: solid behind the canvas, so the page swap never shows through.
backgroundColor: state.phase === "hold" ? INK : "transparent",
}}
>
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
"use client";
import * as React from "react";
const SURFACE = "[data-glow]";
/**
* BitGlow: a light that follows the pointer across every `data-glow` surface.
* It only publishes the pointer position as `--gx` / `--gy` on those surfaces;
* the light itself is drawn in CSS by a `.bit-glow` child (see globals.css).
* Purely decorative, and off when reduced motion is requested.
*/
export const BitGlow: React.FC = () => {
React.useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let frame = 0;
let latest: PointerEvent | null = null;
const publish = () => {
frame = 0;
if (!latest) return;
const { clientX, clientY, target } = latest;
let surface =
target instanceof Element ? target.closest<HTMLElement>(SURFACE) : null;
// Surfaces nest (a button inside a dark section): each one gets its own position.
while (surface) {
const rect = surface.getBoundingClientRect();
surface.style.setProperty("--gx", `${clientX - rect.left}px`);
surface.style.setProperty("--gy", `${clientY - rect.top}px`);
surface =
surface.parentElement?.closest<HTMLElement>(SURFACE) ?? null;
}
};
const onPointerMove = (event: PointerEvent) => {
latest = event;
if (frame === 0) frame = window.requestAnimationFrame(publish);
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
return () => {
window.removeEventListener("pointermove", onPointerMove);
if (frame !== 0) window.cancelAnimationFrame(frame);
};
}, []);
return null;
};
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import { BitAura } from "./BitAura";
/**
* BitSeam: a lit top edge for a dark section, with digits drifting down from it.
* The section needs `relative` and has to clip its overflow.
*/
export const BitSeam: React.FC<{ density?: number }> = ({ density = 0.4 }) => (
<div
className="pointer-events-none absolute inset-x-0 top-0"
aria-hidden="true"
>
<BitAura source="bottom" tone="dark" radius={0} density={density}>
<div className="h-0" />
</BitAura>
</div>
);
+15 -3
View File
@@ -35,6 +35,7 @@ const LIGHT = "255, 255, 255";
const INTRO_MS = 1600;
const FRAME_MS = 1000 / 30;
const POINTER_RADIUS = 170;
const MIN_CELL = 6;
const easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
@@ -91,6 +92,8 @@ export const BitHeadline: React.FC<{
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
// Touch screens have no pointer to light the digits up, so a spot wanders on its own.
const noHover = window.matchMedia("(hover: none)").matches;
let measured: Measured[] = [];
let width = 0;
@@ -153,9 +156,11 @@ export const BitHeadline: React.FC<{
});
const fontSize = parseFloat(getComputedStyle(wrap).fontSize) || 100;
cell = Math.round(Math.min(9, Math.max(4, fontSize / 19)));
// Digits below about 6px are specks, not digits: on phones the cells stay
// readable and the letters are built from fewer of them.
cell = Math.round(Math.min(9, Math.max(MIN_CELL, fontSize / 19)));
// Small type needs a stronger wash under the digits to keep its shape.
wash = fontSize < 100 ? 0.2 : 0.07;
wash = fontSize < 100 ? 0.24 : 0.07;
cols = Math.ceil(width / cell);
rows = Math.ceil(height / cell);
bits = new Uint8Array(cols * rows);
@@ -306,6 +311,13 @@ export const BitHeadline: React.FC<{
if (disposed) return;
if (now - lastFrame >= FRAME_MS) {
lastFrame = now;
if (noHover) {
const seconds = now / 1000;
pointer = {
x: width * (0.5 + 0.46 * Math.sin(seconds * 0.55)),
y: height * (0.5 + 0.4 * Math.sin(seconds * 0.9 + 1)),
};
}
paint(now);
}
raf = requestAnimationFrame(loop);
@@ -339,7 +351,7 @@ export const BitHeadline: React.FC<{
restart();
setPainted(true);
resizeObserver.observe(wrap);
if (!reducedMotion) {
if (!reducedMotion && !noHover) {
window.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerleave", onPointerLeave);
}
+60 -32
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { BinaryTicker } from "@/src/components/bits/BinaryTicker";
import { BitSeam } from "@/src/components/bits/BitSeam";
import { BitWipe } from "@/src/components/bits/BitWipe";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Reveal } from "@/src/components/Reveal";
@@ -10,6 +11,8 @@ interface BlockProps {
label: string;
title: React.ReactNode;
id?: string;
/** `dark` turns the section into a dark stage. */
tone?: "light" | "dark";
children: React.ReactNode;
}
@@ -22,36 +25,61 @@ export const Block: React.FC<BlockProps> = ({
label,
title,
id,
tone = "light",
children,
}) => (
<section
id={id}
className="relative scroll-mt-24 border-t border-slate-200 bg-white"
>
<div className="mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-slate-900">
{index}
</span>
<span className="h-px w-10 bg-slate-300" />
<DecodeText
text={label}
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500"
/>
<BinaryTicker
value={parseInt(index, 10)}
alive
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
/>
</div>
</Reveal>
<BitWipe delay={0.1}>
<h2 className="m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
</div>
</section>
);
}) => {
const dark = tone === "dark";
return (
<section
id={id}
className={`relative scroll-mt-24 border-t ${
dark
? "overflow-clip border-slate-800 bg-slate-900"
: "overflow-x-clip border-slate-200 bg-white"
}`}
>
{dark && (
<div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
aria-hidden="true"
/>
)}
{dark && <BitSeam />}
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span
className={`font-mono text-[11px] font-bold tracking-[0.2em] ${dark ? "text-white" : "text-slate-900"}`}
>
{index}
</span>
<span
className={`h-px w-10 ${dark ? "bg-slate-700" : "bg-slate-300"}`}
/>
<DecodeText
text={label}
className={`font-mono text-[11px] uppercase tracking-[0.25em] ${dark ? "text-slate-400" : "text-slate-500"}`}
/>
<BinaryTicker
value={parseInt(index, 10)}
alive
className={`ml-auto hidden font-mono text-[10px] tracking-[0.3em] md:block ${dark ? "text-slate-600" : "text-slate-300"}`}
/>
</div>
</Reveal>
<BitWipe delay={0.1} tone={dark ? "light" : "dark"}>
<h2
className={`m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter md:text-7xl lg:text-[6.5rem] ${dark ? "text-white" : "text-slate-900"}`}
>
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
</div>
</section>
);
};
+17 -13
View File
@@ -1,19 +1,20 @@
import * as React from "react";
import { Reveal } from "@/src/components/Reveal";
import { EmailLink } from "@/src/components/EmailLink";
import { BitHeadline, type BitLine } from "./BitHeadline";
import { BitAura } from "@/src/components/bits/BitAura";
import { BitSeam } from "@/src/components/bits/BitSeam";
import type { Content } from "@/src/content/types";
import { BitHeadline } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer";
const CLOSING: readonly BitLine[] = [
{ text: "LET'S", tone: "bits" },
{ text: "TALK.", tone: "solid" },
];
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC = () => (
export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
contact,
}) => (
<section
id="contact"
className="relative border-t border-slate-200 bg-slate-900 text-white"
data-glow
className="relative overflow-clip border-t border-slate-200 bg-slate-900 text-white"
>
<div
className="pointer-events-none absolute inset-0 opacity-70"
@@ -23,24 +24,27 @@ export const ClosingCta: React.FC = () => (
}}
aria-hidden="true"
/>
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
<BitSeam />
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
<BitHeadline
as="h2"
invert
lines={CLOSING}
lines={contact.headline}
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] lg:text-[8.5vw] xl:text-[9.5rem]"
/>
<p className="m-0 mt-10 max-w-xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
Pick what you need and the email writes itself. Or write to me
directly:
{contact.lead}
</p>
<div className="mt-8">
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
<EmailLink className="bit-mail font-sans text-3xl font-bold tracking-tight no-underline md:text-5xl" />
</div>
</Reveal>
<Reveal delay={0.15} className="lg:col-span-6">
<ContactComposer />
<BitAura source="around" tone="dark">
<ContactComposer />
</BitAura>
</Reveal>
</div>
</section>
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { CONTACT_TOPICS } from "@/src/content/contact";
import { useContent } from "@/src/components/ContentProvider";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
import { assembleEmail, mailtoHref, spellOutEmail } from "@/src/domain/email";
@@ -51,7 +51,9 @@ const withBlanks = (text: string): React.ReactNode =>
* is assembled in the browser, so it never appears in the page source.
*/
export const ContactComposer: React.FC = () => {
const [topicId, setTopicId] = React.useState(CONTACT_TOPICS[0]?.id ?? "");
const { contact } = useContent();
const topics = contact.topics;
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
const [address, setAddress] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
@@ -59,8 +61,7 @@ export const ContactComposer: React.FC = () => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
const topic =
CONTACT_TOPICS.find((item) => item.id === topicId) ?? CONTACT_TOPICS[0];
const topic = topics.find((item) => item.id === topicId) ?? topics[0];
const typed = useTyped(topic?.draft.body ?? "");
if (!topic) return null;
@@ -87,18 +88,18 @@ export const ContactComposer: React.FC = () => {
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className={`${MONO} text-slate-400`}>New message</span>
<span className={`${MONO} text-slate-400`}>{contact.newMessage}</span>
</div>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
draft
{contact.draft}
</span>
</div>
<div className="border-b border-slate-800 px-5 py-5 md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>I need</p>
<p className={`${MONO} m-0 text-slate-500`}>{contact.need}</p>
<div className="mt-3 flex flex-wrap gap-2">
{CONTACT_TOPICS.map((item) => {
{topics.map((item) => {
const selected = item.id === topic.id;
return (
<button
@@ -121,13 +122,13 @@ export const ContactComposer: React.FC = () => {
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]">
<div className="flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">to</dt>
<dd className="m-0 text-slate-200">
<dt className="w-16 shrink-0 text-slate-500">{contact.to}</dt>
<dd className="m-0 select-all text-slate-200">
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
</dd>
</div>
<div className="mt-1 flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">subject</dt>
<dt className="w-16 shrink-0 text-slate-500">{contact.subject}</dt>
<dd className="m-0 text-white">{topic.draft.subject}</dd>
</div>
</dl>
@@ -148,9 +149,9 @@ export const ContactComposer: React.FC = () => {
href={
address ? mailtoHref(CONTACT_EMAIL_PARTS, topic.draft) : "#contact"
}
className="group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
className="bit-cta bit-cta-light group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
Open in my mail app
{contact.open}
<span
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
@@ -163,7 +164,7 @@ export const ContactComposer: React.FC = () => {
onClick={copy}
className="rounded-full border border-slate-700 px-6 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-300 transition-colors duration-200 hover:border-slate-400 hover:text-white"
>
{copied ? "Copied" : "Copy address"}
{copied ? contact.copied : contact.copy}
</button>
</div>
</div>
+6 -32
View File
@@ -1,41 +1,15 @@
import * as React from "react";
import type { FaqItem } from "@/src/content/types";
const QUESTIONS = [
{
q: "I am not technical. Is that a problem?",
a: "No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
},
{
q: "How long am I committed?",
a: "Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
},
{
q: "Who owns what you build?",
a: "You do. Code and design belong to you, and you can hand them to another developer.",
},
{
q: "Can you work with our existing team and code?",
a: "Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
},
{
q: "How do I reach you?",
a: "By email or video call. I work remotely and am available during European business hours.",
},
{
q: "What do you not do?",
a: "Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
},
] as const;
export const Faq: React.FC = () => (
export const Faq: React.FC<{ items: readonly FaqItem[] }> = ({ items }) => (
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
{QUESTIONS.map((item) => (
{items.map((item) => (
<details
key={item.q}
key={item.question}
className="group border-t border-slate-200 py-6 open:pb-8"
>
<summary className="flex cursor-pointer list-none items-start justify-between gap-6 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl [&::-webkit-details-marker]:hidden">
{item.q}
{item.question}
<span
className="mt-1 shrink-0 font-mono text-xl text-slate-400 transition-transform duration-300 group-open:rotate-45"
aria-hidden="true"
@@ -44,7 +18,7 @@ export const Faq: React.FC = () => (
</span>
</summary>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{item.a}
{item.answer}
</p>
</details>
))}
+11 -18
View File
@@ -1,16 +1,12 @@
import * as React from "react";
import { Button } from "@/src/components/Button";
import { AbstractCircuit } from "@/src/components/Effects";
import { BitHeadline, type BitLine } from "./BitHeadline";
import type { Content } from "@/src/content/types";
import { BitHeadline } from "./BitHeadline";
const HEADLINE: readonly BitLine[] = [
{ text: "WEB APPS", tone: "solid" },
{ text: "& WEBSITES.", tone: "bits" },
];
const TRUST = ["15 years in web", "Remote, EU core hours"] as const;
export const HeroStatement: React.FC = () => (
export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
hero,
}) => (
<section className="relative overflow-hidden bg-white">
<div className="absolute inset-0 opacity-70">
<AbstractCircuit />
@@ -18,16 +14,13 @@ export const HeroStatement: React.FC = () => (
<div className="relative z-10 mx-auto flex min-h-[calc(100dvh-5rem)] w-full max-w-[1400px] flex-col justify-center px-5 py-16 md:px-12 md:py-24">
<BitHeadline
lines={HEADLINE}
lines={hero.headline}
className="font-sans text-[12.5vw] font-black leading-[0.88] tracking-tighter md:text-[11.5vw] xl:text-[10.5rem]"
/>
<div className="mt-10 grid grid-cols-1 gap-10 md:mt-14 md:grid-cols-12 md:items-end md:gap-12">
<p className="m-0 max-w-2xl font-sans text-xl leading-snug text-slate-500 md:col-span-7 md:text-3xl">
Freelance developer with 15 years of experience.{" "}
<span className="text-slate-900">
I build web apps, interfaces and websites.
</span>
{hero.lead} <span className="text-slate-900">{hero.leadStrong}</span>
</p>
<div className="flex flex-col gap-4 md:col-span-5 md:items-end">
@@ -36,7 +29,7 @@ export const HeroStatement: React.FC = () => (
className="flex w-full flex-col gap-4 sm:flex-row md:w-auto"
>
<Button href="#contact" size="large" className="w-full sm:w-auto">
Get in touch
{hero.primaryCta}
</Button>
<Button
href="#hello"
@@ -44,14 +37,14 @@ export const HeroStatement: React.FC = () => (
size="large"
className="w-full sm:w-auto"
>
What I do
{hero.secondaryCta}
</Button>
</div>
</div>
</div>
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
{TRUST.map((item) => (
{hero.trust.map((item) => (
<li
key={item}
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
@@ -60,7 +53,7 @@ export const HeroStatement: React.FC = () => (
</li>
))}
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
Built and running:{" "}
{hero.builtAndRunning}{" "}
<a
href="https://klz-cables.com"
target="_blank"
+46 -42
View File
@@ -1,45 +1,49 @@
import { contentFor } from "@/src/content";
import { OWNER, PROFILES, SITE_NAME, SITE_URL } from "@/src/content/site";
import type { Locale } from "@/src/domain/locale";
import { pathFor } from "@/src/domain/routes";
import {
OWNER,
SITE_DESCRIPTION,
SITE_NAME,
SITE_URL,
} from "@/src/content/site";
buildStructuredData,
serializeJsonLd,
} from "@/src/domain/structured-data";
const STRUCTURED_DATA = {
"@context": "https://schema.org",
"@type": "ProfessionalService",
name: SITE_NAME,
url: SITE_URL,
description: SITE_DESCRIPTION,
image: `${SITE_URL}/marc-mintel.png`,
address: {
"@type": "PostalAddress",
streetAddress: OWNER.street,
postalCode: OWNER.city.split(" ")[0],
addressLocality: OWNER.city.split(" ").slice(1).join(" "),
addressCountry: "DE",
},
founder: {
"@type": "Person",
name: SITE_NAME,
jobTitle: "Freelance developer",
url: SITE_URL,
},
knowsAbout: [
"Web applications",
"User interfaces",
"Websites",
"Process automation",
"Web3 frontends",
],
} as const;
/** Built server-side from the page content; see src/domain/structured-data.ts. */
export const HomeJsonLd: React.FC<{ locale: Locale }> = ({ locale }) => {
const { site, services, technology, faq } = contentFor(locale);
const data = buildStructuredData({
siteUrl: SITE_URL,
pageUrl: `${SITE_URL}${pathFor("home", locale)}`,
siteName: SITE_NAME,
language: locale,
description: site.description,
imageUrl: `${SITE_URL}/marc-mintel.png`,
person: {
name: OWNER.name,
jobTitle: site.jobTitle,
profiles: PROFILES,
knowsAbout: [
...site.knowsAbout,
...technology.tech.flatMap((group) => group.items),
],
},
address: {
street: OWNER.street,
postalCode: OWNER.postalCode,
locality: OWNER.locality,
countryCode: OWNER.countryCode,
},
catalogName: site.catalogName,
services: services.paths.map((path) => ({
name: path.service,
description: path.intro,
})),
faq: faq.items,
});
/** Serialized server-side from constants only; "<" is escaped to keep it inert inside a script tag. */
export const HomeJsonLd: React.FC = () => (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(STRUCTURED_DATA).replace(/</g, "\\u003c"),
}}
/>
);
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
};
+40 -26
View File
@@ -3,9 +3,11 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
import { useContent } from "@/src/components/ContentProvider";
import type { ServicePath } from "@/src/content/types";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
const STEP_MS = 4800;
@@ -49,25 +51,33 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
return (
<div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12">
<div className="md:order-2 md:col-span-7">
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<DecodeText
key={current?.title}
text={current?.title ?? ""}
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
/>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
{step + 1} / {path.steps.length}
</span>
<BitAura source="top" tone="light" className="md:sticky md:top-28">
<div
data-glow
className="relative overflow-hidden rounded-3xl bg-slate-900"
>
<span className="bit-glow bit-glow-dots" aria-hidden="true" />
<div className="relative flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<DecodeText
key={current?.title}
text={current?.title ?? ""}
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
/>
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
{step + 1} / {path.steps.length}
</span>
</div>
<div className="relative px-2 py-4 md:px-10 md:py-8">
<PathScene
pathId={path.id}
step={step}
title={
current ? `${current.title}. ${current.text}` : path.need
}
/>
</div>
</div>
<div className="px-2 py-4 md:px-10 md:py-8">
<PathScene
pathId={path.id}
step={step}
title={current ? `${current.title}. ${current.text}` : path.need}
/>
</div>
</div>
</BitAura>
</div>
<ol className="m-0 list-none p-0 md:order-1 md:col-span-5">
@@ -134,16 +144,18 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
* All three panels stay in the DOM so the content is readable without JavaScript.
*/
export const PathPicker: React.FC = () => {
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
const { services } = useContent();
const paths = services.paths;
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
return (
<div>
<div
role="tablist"
aria-label="What do you need?"
aria-label={services.tablistLabel}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
>
{SERVICE_PATHS.map((path, index) => {
{paths.map((path, index) => {
const selected = path.id === activeId;
return (
<button
@@ -154,12 +166,14 @@ export const PathPicker: React.FC = () => {
aria-selected={selected}
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
data-glow
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
}`}
>
<span className="bit-glow bit-glow-edge" aria-hidden="true" />
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
@@ -176,7 +190,7 @@ export const PathPicker: React.FC = () => {
})}
</div>
{SERVICE_PATHS.map((path) => {
{paths.map((path) => {
const selected = path.id === activeId;
return (
<div
@@ -203,7 +217,7 @@ export const PathPicker: React.FC = () => {
</p>
</div>
<div className="md:col-span-5">
<p className={`${MONO} m-0`}>What you get</p>
<p className={`${MONO} m-0`}>{services.youGet}</p>
<ul className="m-0 mt-4 list-none p-0">
{path.youGet.map((item) => (
<li
@@ -222,12 +236,12 @@ export const PathPicker: React.FC = () => {
</div>
<div className="mt-14 md:mt-24">
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
<p className={`${MONO} m-0 mb-6`}>{services.howItRuns}</p>
<PathStory path={path} active={selected} />
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>What it costs</p>
<p className={`${MONO} m-0`}>{services.whatItCosts}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
@@ -1,5 +1,6 @@
"use client";
import { BitSeam } from "@/src/components/bits/BitSeam";
import * as React from "react";
import Image from "next/image";
import { motion } from "framer-motion";
@@ -16,8 +17,9 @@ export const PortraitSection: React.FC<{
index: string;
label: string;
title: React.ReactNode;
portraitAlt: string;
children: React.ReactNode;
}> = ({ id, index, label, title, children }) => (
}> = ({ id, index, label, title, portraitAlt, children }) => (
<section
id={id}
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
@@ -32,12 +34,14 @@ export const PortraitSection: React.FC<{
>
<Image
src="/marc-mintel.png"
alt="Portrait of Marc Mintel"
alt={portraitAlt}
fill
sizes="(min-width: 768px) 62vw, 100vw"
className="object-cover object-[50%_18%] grayscale"
priority={false}
/>
{/* Slow light behind the fades, so the dark surface is not flat. */}
<div className="bit-aurora" aria-hidden="true" />
{/* Fades: into the surface on the left, and at top and bottom. */}
<div className="absolute inset-0 hidden bg-gradient-to-r from-slate-900 via-slate-900/75 to-slate-900/10 md:block" />
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
@@ -72,5 +76,6 @@ export const PortraitSection: React.FC<{
<div className="mt-10 md:mt-14">{children}</div>
</motion.div>
</div>
<BitSeam density={1} />
</section>
);
+4 -2
View File
@@ -1,6 +1,7 @@
"use client";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
/**
* StickyBook: a quiet contact button (wide screens only; on phones the sticky header
@@ -8,6 +9,7 @@ import * as React from "react";
* scrolled away, and steps aside again when the closing section is in view.
*/
export const StickyBook: React.FC = () => {
const { nav } = useContent();
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
@@ -37,9 +39,9 @@ export const StickyBook: React.FC = () => {
<a
href="#contact"
tabIndex={visible ? 0 : -1}
className="flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
className="bit-cta flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
>
Get in touch <span aria-hidden="true">→</span>
{nav.contactLong} <span aria-hidden="true">→</span>
</a>
</div>
);
@@ -1,14 +1,11 @@
"use client";
import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react";
import { useNarrow } from "./useNarrow";
import {
KIND_LABEL,
WORKFLOWS,
type FlowNode,
type NodeKind,
type Workflow,
} from "@/src/content/automation";
import { useContent } from "@/src/components/ContentProvider";
import { workflowsFor, type FlowNode } from "@/src/content/automation";
import type { NodeKind } from "@/src/content/types";
/**
* AutomationFlow: a workflow drawn as connected systems, with the work visibly
@@ -94,6 +91,9 @@ const KIND_STYLE: Readonly<
human: { stroke: "#f8fafc", dash: "2 4", tag: "#f8fafc" },
};
/** The order of the legend below the drawing. */
const KINDS: readonly NodeKind[] = ["trigger", "rule", "ai", "action", "human"];
type Clock = {
/** Which run of the workflow is playing. */
readonly run: number;
@@ -106,7 +106,12 @@ type Clock = {
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
export const AutomationFlow: React.FC = () => {
const [flowId, setFlowId] = React.useState(WORKFLOWS[0]?.id ?? "");
const { automation } = useContent();
const workflows = React.useMemo(
() => workflowsFor(automation.workflows),
[automation.workflows],
);
const [flowId, setFlowId] = React.useState(workflows[0]?.id ?? "");
const [clock, setClock] = React.useState<Clock>({ run: 0, step: 0, t: 0 });
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(false);
@@ -115,7 +120,7 @@ export const AutomationFlow: React.FC = () => {
const tall = useNarrow();
const canvas = tall ? TALL : WIDE;
const flow = WORKFLOWS.find((item) => item.id === flowId) ?? WORKFLOWS[0];
const flow = workflows.find((item) => item.id === flowId) ?? workflows[0];
React.useEffect(() => {
const node = ref.current;
@@ -194,10 +199,15 @@ export const AutomationFlow: React.FC = () => {
}
});
const logLines: string[] = [];
const logLines: { text: string; kind: NodeKind }[] = [];
const log = (node: Placed) => {
if (logLines.every((line) => line.text !== node.log)) {
logLines.push({ text: node.log, kind: node.kind });
}
};
if (firstNode) {
const first = nodeOf(firstNode);
if (first) logLines.push(first.log);
if (first) log(first);
}
steps.forEach((edges, index) => {
const arrived =
@@ -205,7 +215,7 @@ export const AutomationFlow: React.FC = () => {
if (!arrived) return;
edges.forEach(([, to]) => {
const node = nodeOf(to);
if (node && !logLines.includes(node.log)) logLines.push(node.log);
if (node) log(node);
});
});
const finished = clock.step >= steps.length;
@@ -214,10 +224,10 @@ export const AutomationFlow: React.FC = () => {
<div ref={ref}>
<div
role="tablist"
aria-label="Example workflows"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{WORKFLOWS.map((item) => {
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
@@ -242,170 +252,171 @@ export const AutomationFlow: React.FC = () => {
{flow.summary}
</p>
<div className="mt-8 overflow-hidden rounded-3xl bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
/>
{finished ? "run complete" : "running"}
</span>
</div>
<div>
<svg
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
role="img"
aria-label={`${flow.name}: ${flow.summary}`}
className="block h-auto w-full"
>
<defs>
<pattern
id="flow-grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="1" fill="#1e293b" />
</pattern>
</defs>
<rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
{flow.edges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b) return null;
const done = still || travelled.has(`${from}>${to}`);
return (
<path
key={`${from}>${to}`}
d={pathOf(a, b, tall)}
fill="none"
stroke={done ? "#e2e8f0" : "#334155"}
strokeWidth={done ? 2 : 1.5}
style={{ transition: "stroke 300ms" }}
/>
);
})}
{activeEdges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b || clock.t >= 1) return null;
const eased = ease(clock.t);
return (
<g key={`packet-${from}>${to}`}>
{[0.16, 0.1, 0.05].map((lag, i) => {
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
return (
<circle
key={lag}
cx={p.x}
cy={p.y}
r={5 - i}
fill={MARK}
opacity={0.12 + i * 0.12}
/>
);
})}
<circle
cx={pointOn(a, b, eased, tall).x}
cy={pointOn(a, b, eased, tall).y}
r={7}
fill={MARK}
/>
</g>
);
})}
{placed.map((node) => {
const style = KIND_STYLE[node.kind];
const on = reached.has(node.id);
return (
<g key={node.id}>
<rect
x={node.cx - NODE_W / 2}
y={node.cy - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={14}
fill={on ? "#1e293b" : "#0f172a"}
stroke={on ? style.stroke : "#334155"}
strokeWidth={on ? 1.75 : 1.25}
strokeDasharray={style.dash}
style={{ transition: "fill 300ms, stroke 300ms" }}
/>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy - 8}
fill={on ? style.tag : "#64748b"}
style={{
fontFamily:
"var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
transition: "fill 300ms",
}}
>
{node.system}
</text>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy + 14}
fill={on ? "#f8fafc" : "#94a3b8"}
style={{
fontFamily:
"var(--font-inter), Inter, system-ui, sans-serif",
fontSize: 15,
fontWeight: 600,
transition: "fill 300ms",
}}
>
{node.label}
</text>
{on && (
<circle
cx={node.cx + NODE_W / 2 - 16}
cy={node.cy - NODE_H / 2 + 16}
r={4}
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
/>
)}
</g>
);
})}
</svg>
</div>
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
<ol
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
aria-live="off"
>
{logLines.map((line, index) => (
<li key={line} className="flex gap-3">
<span className="text-slate-600">
{String(index + 1).padStart(2, "0")}
</span>
<span
className={
line.startsWith("unclear") ? "text-amber-200" : undefined
}
>
{line}
</span>
</li>
))}
</ol>
<BitAura source="left" tone="light" className="mt-8">
<div className="overflow-hidden rounded-3xl bg-slate-900">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
<span
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
/>
{finished ? automation.runComplete : automation.running}
</span>
</div>
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
{(Object.keys(KIND_LABEL) as NodeKind[])
.filter((kind) => flow.nodes.some((node) => node.kind === kind))
.map((kind) => (
<div>
<svg
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
role="img"
aria-label={`${flow.name}: ${flow.summary}`}
className="block h-auto w-full"
>
<defs>
<pattern
id="flow-grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="1" fill="#1e293b" />
</pattern>
</defs>
<rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
{flow.edges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b) return null;
const done = still || travelled.has(`${from}>${to}`);
return (
<path
key={`${from}>${to}`}
d={pathOf(a, b, tall)}
fill="none"
stroke={done ? "#e2e8f0" : "#334155"}
strokeWidth={done ? 2 : 1.5}
style={{ transition: "stroke 300ms" }}
/>
);
})}
{activeEdges.map(([from, to]) => {
const a = nodeOf(from);
const b = nodeOf(to);
if (!a || !b || clock.t >= 1) return null;
const eased = ease(clock.t);
return (
<g key={`packet-${from}>${to}`}>
{[0.16, 0.1, 0.05].map((lag, i) => {
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
return (
<circle
key={lag}
cx={p.x}
cy={p.y}
r={5 - i}
fill={MARK}
opacity={0.12 + i * 0.12}
/>
);
})}
<circle
cx={pointOn(a, b, eased, tall).x}
cy={pointOn(a, b, eased, tall).y}
r={7}
fill={MARK}
/>
</g>
);
})}
{placed.map((node) => {
const style = KIND_STYLE[node.kind];
const on = reached.has(node.id);
return (
<g key={node.id}>
<rect
x={node.cx - NODE_W / 2}
y={node.cy - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={14}
fill={on ? "#1e293b" : "#0f172a"}
stroke={on ? style.stroke : "#334155"}
strokeWidth={on ? 1.75 : 1.25}
strokeDasharray={style.dash}
style={{ transition: "fill 300ms, stroke 300ms" }}
/>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy - 8}
fill={on ? style.tag : "#64748b"}
style={{
fontFamily:
"var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
transition: "fill 300ms",
}}
>
{node.system}
</text>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy + 14}
fill={on ? "#f8fafc" : "#94a3b8"}
style={{
fontFamily:
"var(--font-inter), Inter, system-ui, sans-serif",
fontSize: 15,
fontWeight: 600,
transition: "fill 300ms",
}}
>
{node.label}
</text>
{on && (
<circle
cx={node.cx + NODE_W / 2 - 16}
cy={node.cy - NODE_H / 2 + 16}
r={4}
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
/>
)}
</g>
);
})}
</svg>
</div>
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>{automation.log}</p>
<ol
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
aria-live="off"
>
{logLines.map((line, index) => (
<li key={line.text} className="flex gap-3">
<span className="text-slate-600">
{String(index + 1).padStart(2, "0")}
</span>
<span
className={
line.kind === "human" ? "text-amber-200" : undefined
}
>
{line.text}
</span>
</li>
))}
</ol>
</div>
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
{KINDS.filter((kind) =>
flow.nodes.some((node) => node.kind === kind),
).map((kind) => (
<li
key={kind}
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
@@ -423,22 +434,23 @@ export const AutomationFlow: React.FC = () => {
strokeDasharray={KIND_STYLE[kind].dash}
/>
</svg>
{KIND_LABEL[kind]}
{automation.kinds[kind]}
</li>
))}
</ul>
</ul>
</div>
</div>
</div>
</BitAura>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
<p className={`${MONO} m-0 text-slate-400`}>By hand</p>
<p className={`${MONO} m-0 text-slate-400`}>{automation.byHand}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
{flow.byHand}
</p>
</div>
<div className="rounded-3xl border border-slate-900 p-6 md:p-8">
<p className={`${MONO} m-0 text-slate-900`}>Automated</p>
<p className={`${MONO} m-0 text-slate-900`}>{automation.automated}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
{flow.automated}
</p>
@@ -2,29 +2,33 @@
import * as React from "react";
import { motion } from "framer-motion";
export type CareerStation = {
readonly years: string;
readonly role: string;
readonly place: string;
};
import { BitAura } from "@/src/components/bits/BitAura";
import type { CareerStation } from "@/src/content/types";
/**
* CareerLine: the path so far as a line that draws itself, one station after another.
* CareerLine: the path so far as a line of light that draws itself, one station
* after another. Made for a dark surface.
*/
export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
stations,
}) => (
<ol className="relative m-0 list-none p-0">
<motion.span
className="absolute left-[5px] top-2 w-px origin-top bg-slate-900 md:left-[7px]"
style={{ bottom: 8 }}
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 1.4, ease: "easeOut" }}
aria-hidden="true"
/>
<BitAura
source="left"
tone="dark"
radius={0}
density={0.5}
className="absolute bottom-2 left-[5px] top-2 w-px md:left-[7px]"
>
<motion.span
className="block h-full w-px origin-top bg-indigo-100"
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 1.4, ease: "easeOut" }}
aria-hidden="true"
/>
</BitAura>
{stations.map((station, index) => (
<motion.li
key={station.years}
@@ -35,19 +39,21 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
transition={{ duration: 0.4, delay: 0.15 + index * 0.18 }}
>
<span
className={`absolute left-0 top-1.5 h-[11px] w-[11px] rounded-full border-2 border-slate-900 md:h-[15px] md:w-[15px] ${
index === stations.length - 1 ? "bg-slate-900" : "bg-white"
className={`absolute left-0 top-1.5 h-[11px] w-[11px] rounded-full border-2 border-indigo-100 md:h-[15px] md:w-[15px] ${
index === stations.length - 1
? "bit-node-live bg-white"
: "bg-slate-900"
}`}
aria-hidden="true"
/>
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500 md:col-span-3 md:pt-1.5">
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400 md:col-span-3 md:pt-1.5">
{station.years}
</span>
<span className="md:col-span-9">
<span className="block font-sans text-xl font-bold tracking-tight text-slate-900 md:text-3xl">
<span className="block font-sans text-xl font-bold tracking-tight text-white md:text-3xl">
{station.role}
</span>
<span className="mt-1 block font-sans text-base text-slate-500 md:text-lg">
<span className="mt-1 block font-sans text-base text-slate-400 md:text-lg">
{station.place}
</span>
</span>
@@ -2,6 +2,7 @@
import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider";
import { useNarrow } from "./useNarrow";
const INK = "#f8fafc";
@@ -10,8 +11,6 @@ const MARK = "#fde68a";
const NODE_W = 112;
const NODE_H = 38;
const CHAIN = ["You", "Sales", "Manager", "Designer", "Developer"] as const;
const Node: React.FC<{
x: number;
y: number;
@@ -78,12 +77,13 @@ const Caption: React.FC<{
*/
export const ContactDiagram: React.FC = () => {
const tall = useNarrow();
const label =
"Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.";
const copy = useContent().working.diagram;
const chain = copy.chain;
const label = copy.description;
if (tall) {
// Two columns: the chain on the left, the direct line on the right.
const ys = CHAIN.map((_, i) => 96 + i * 84);
const ys = chain.map((_, i) => 96 + i * 84);
const first = ys[0] ?? 96;
const last = ys[ys.length - 1] ?? 432;
const stops = ys.flatMap((y) => [y, y]);
@@ -95,10 +95,10 @@ export const ContactDiagram: React.FC = () => {
className="block h-auto w-full"
>
<Caption x={24} y={38} size={12}>
often
{copy.often}
</Caption>
<Caption x={196} y={38} size={12} strong>
with me
{copy.withMe}
</Caption>
<line
x1={90}
@@ -109,7 +109,7 @@ export const ContactDiagram: React.FC = () => {
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{CHAIN.map((name, i) => (
{chain.map((name, i) => (
<Node
key={name}
x={90}
@@ -143,8 +143,8 @@ export const ContactDiagram: React.FC = () => {
stroke={INK}
strokeWidth={2}
/>
<Node x={250} y={first} label="You" size={12} />
<Node x={250} y={last} label="Me" size={12} solid />
<Node x={250} y={first} label={copy.you} size={12} />
<Node x={250} y={last} label={copy.me} size={12} solid />
<motion.circle
r={5}
cx={250}
@@ -159,7 +159,7 @@ export const ContactDiagram: React.FC = () => {
);
}
const xs = CHAIN.map((_, i) => 76 + i * 132);
const xs = chain.map((_, i) => 76 + i * 132);
const first = xs[0] ?? 76;
const last = xs[xs.length - 1] ?? 604;
const stops = xs.flatMap((x) => [x, x]);
@@ -183,7 +183,7 @@ export const ContactDiagram: React.FC = () => {
<rect width="680" height="250" fill="url(#contact-grid)" />
<Caption x={20} y={30} size={10}>
often: a request is passed along
{copy.oftenLong}
</Caption>
<line
x1={first}
@@ -194,7 +194,7 @@ export const ContactDiagram: React.FC = () => {
strokeWidth={1.5}
strokeDasharray="4 4"
/>
{CHAIN.map((name, i) => (
{chain.map((name, i) => (
<Node
key={name}
x={xs[i] ?? 0}
@@ -222,7 +222,7 @@ export const ContactDiagram: React.FC = () => {
/>
<Caption x={20} y={160} size={10} strong>
with me: one direct line
{copy.withMeLong}
</Caption>
<line
x1={first}
@@ -232,8 +232,8 @@ export const ContactDiagram: React.FC = () => {
stroke={INK}
strokeWidth={2}
/>
<Node x={first} y={204} label="You" size={10} />
<Node x={last} y={204} label="Me" size={10} solid />
<Node x={first} y={204} label={copy.you} size={10} />
<Node x={last} y={204} label={copy.me} size={10} solid />
<motion.circle
r={5}
cy={204}
@@ -2,6 +2,9 @@
import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider";
import type { SceneLabels } from "@/src/content/types";
import { fill } from "@/src/domain/template";
/**
* PathScenes: one small animated drawing per step of a service path.
@@ -15,6 +18,10 @@ const LINE = "#475569";
const SOFT = "#1e293b";
const MARK = "#fde68a";
const LIVE = "#10b981";
/** A lit surface: a light tint instead of a solid white block. */
const TINT = "rgba(248, 250, 252, 0.14)";
const RIM = "#64748b";
const FINE = 1.1;
const MONO = {
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
// Set on the svg: larger on phones, where the drawing is scaled down.
@@ -36,6 +43,9 @@ const appear = (delay: number, y = 6) => ({
transition: { duration: 0.4, delay, ease: "easeOut" as const },
});
/** A drawing of one step. `l` holds its labels in the language of the page. */
type Scene = React.FC<{ l: SceneLabels }>;
type Pt = readonly [number, number];
/** Points along a cubic curve, for packets that travel on it. */
@@ -70,8 +80,11 @@ const Packet: React.FC<{
pause?: number;
}> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => (
<motion.circle
r={5}
r={3.5}
fill={MARK}
stroke={MARK}
strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: xs[0], cy: ys[0], opacity: 0 }}
animate={{
cx: [...xs],
@@ -96,18 +109,29 @@ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
<g>
<circle
cx={x}
cy={128}
r={20}
fill={filled ? INK : PAPER}
cy={130}
r={15}
fill={filled ? TINT : "none"}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<path
d={`M ${x - 34} 196 Q ${x - 34} 158 ${x} 158 Q ${x + 34} 158 ${x + 34} 196`}
fill={filled ? INK : PAPER}
d={`M ${x - 28} 194 C ${x - 28} 172 ${x - 16} 160 ${x} 160 C ${x + 16} 160 ${x + 28} 172 ${x + 28} 194`}
fill={filled ? TINT : "none"}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
strokeLinecap="round"
/>
<line
x1={x - 36}
x2={x + 36}
y1={194}
y2={194}
stroke={LINE}
strokeWidth={1}
strokeLinecap="round"
/>
{filled && <circle cx={x + 12} cy={119} r={3.5} fill={MARK} />}
<text x={x} y={222} textAnchor="middle" style={MONO}>
{label}
</text>
@@ -115,10 +139,10 @@ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
);
/** Step 1 of every path: a short conversation. */
const Talk: React.FC = () => (
const Talk: Scene = ({ l }) => (
<g>
<Person x={84} label="You" />
<Person x={396} label="Me" filled />
<Person x={84} label={l.you} />
<Person x={396} label={l.me} filled />
{[
{ x: 150, y: 86, w: 120, side: "left", delay: 0.2 },
{ x: 210, y: 124, w: 120, side: "right", delay: 0.7 },
@@ -129,31 +153,41 @@ const Talk: React.FC = () => (
x={b.x}
y={b.y}
width={b.w}
height={26}
rx={13}
fill={b.side === "left" ? SOFT : INK}
height={24}
rx={12}
fill={b.side === "left" ? SOFT : TINT}
stroke={b.side === "left" ? LINE : INK}
strokeWidth={b.side === "left" ? 1 : FINE}
/>
<line
x1={b.x + 14}
x2={b.x + b.w - 14}
y1={b.y + 13}
y2={b.y + 13}
stroke={b.side === "left" ? "#94a3b8" : PAPER}
strokeWidth={2}
y1={b.y + 12}
y2={b.y + 12}
stroke={b.side === "left" ? "#94a3b8" : INK}
strokeWidth={1.5}
strokeLinecap="round"
/>
</motion.g>
))}
<motion.g {...appear(1.7)}>
<rect x={258} y={200} width={72} height={26} rx={13} fill={INK} />
<rect
x={270}
y={200}
width={60}
height={24}
rx={12}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
{[0, 1, 2].map((dot) => (
<motion.circle
key={dot}
cx={280 + dot * 14}
cy={213}
r={3}
fill={PAPER}
cx={288 + dot * 12}
cy={212}
r={2.25}
fill={INK}
animate={{ opacity: [0.2, 1, 0.2] }}
transition={{ duration: 1.1, delay: dot * 0.18, repeat: Infinity }}
/>
@@ -166,17 +200,15 @@ const Talk: React.FC = () => (
style={MONO}
{...appear(1.6, 0)}
>
about 20 minutes
{l.talkLength}
</motion.text>
</g>
);
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
/** Developer, step 2: agreed days of the week. */
const WeeklyHours: React.FC = () => (
const WeeklyHours: Scene = ({ l }) => (
<g>
{DAYS.map((day, d) => (
{l.days.map((day, d) => (
<g key={day}>
<text x={78 + d * 72} y={52} textAnchor="middle" style={MONO}>
{day}
@@ -186,15 +218,16 @@ const WeeklyHours: React.FC = () => (
return (
<motion.rect
key={h}
x={52 + d * 72}
y={64 + h * 22}
width={52}
height={16}
rx={4}
x={56 + d * 72}
y={66 + h * 21}
width={44}
height={10}
rx={5}
strokeWidth={1}
initial={{ fill: PAPER, stroke: LINE }}
animate={{
fill: reserved ? INK : PAPER,
stroke: reserved ? INK : LINE,
fill: reserved ? "#e2e8f0" : PAPER,
stroke: reserved ? "#e2e8f0" : LINE,
}}
transition={{
duration: 0.25,
@@ -212,13 +245,13 @@ const WeeklyHours: React.FC = () => (
style={MONO}
{...appear(1.3, 0)}
>
for example three days a week
{l.daysNote}
</motion.text>
</g>
);
/** Developer, step 3: the list gets worked through and comes back for review. */
const WorkList: React.FC = () => (
const WorkList: Scene = ({ l }) => (
<g>
{[0, 1, 2, 3].map((row) => {
const done = row < 3;
@@ -242,14 +275,14 @@ const WorkList: React.FC = () => (
rx={4}
fill={done ? INK : PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
{done && (
<motion.path
d={`M ${76} ${y + 16} l 3.5 3.5 l 6 -7`}
fill="none"
stroke={PAPER}
strokeWidth={2}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
{...draw(0.3 + row * 0.45)}
@@ -261,7 +294,7 @@ const WorkList: React.FC = () => (
y1={y + 16}
y2={y + 16}
stroke={done ? INK : LINE}
strokeWidth={2}
strokeWidth={1.5}
strokeLinecap="round"
/>
{done && (
@@ -269,7 +302,7 @@ const WorkList: React.FC = () => (
<path
d={`M 322 ${y + 16} h 46`}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
markerEnd="url(#arrow)"
/>
<Packet
@@ -297,7 +330,7 @@ const WorkList: React.FC = () => (
fontSize: "calc(var(--scene-label, 10px) - 1px)",
}}
>
you
{l.you}
</text>
</motion.g>
)}
@@ -311,15 +344,15 @@ const WorkList: React.FC = () => (
style={MONO}
{...appear(1.8, 0)}
>
finished parts go to you for review
{l.review}
</motion.text>
</g>
);
/** Developer, step 4: two weeks notice to the end of a month. */
const Notice: React.FC = () => (
const Notice: Scene = ({ l }) => (
<g>
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={2} />
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={1.5} />
{Array.from({ length: 5 }, (_, w) => (
<g key={w}>
<line
@@ -328,18 +361,18 @@ const Notice: React.FC = () => (
y1={140}
y2={160}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<text x={50 + w * 95} y={182} textAnchor="middle" style={MONO}>
{w === 4 ? "end of month" : `week ${w + 1}`}
{w === 4 ? l.monthEnd : fill(l.week, { n: w + 1 })}
</text>
</g>
))}
<motion.rect
x={240}
y={132}
height={36}
rx={6}
y={139}
height={22}
rx={5}
fill={MARK}
initial={{ width: 0, opacity: 0.35 }}
animate={{ width: 190 }}
@@ -350,13 +383,13 @@ const Notice: React.FC = () => (
d="M 240 118 v -14 h 190 v 14"
fill="none"
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}>
two weeks notice
{l.notice}
</text>
</motion.g>
<motion.circle cx={430} cy={150} r={7} fill={INK} {...appear(1.4, 0)} />
<motion.circle cx={430} cy={150} r={4.5} fill={INK} {...appear(1.4, 0)} />
</g>
);
@@ -385,7 +418,7 @@ const EDGES: readonly (readonly [number, number])[] = [
];
/** Rescue, step 2: fragile connections become solid over the week. */
const RepairWeek: React.FC = () => (
const RepairWeek: Scene = ({ l }) => (
<g>
{EDGES.map(([a, b], i) => {
const from = NODES[a]!;
@@ -398,7 +431,7 @@ const RepairWeek: React.FC = () => (
x2={to[0]}
y2={to[1]}
stroke="#f59e0b"
strokeWidth={1.5}
strokeWidth={FINE}
strokeDasharray="3 5"
/>
<motion.line
@@ -407,7 +440,7 @@ const RepairWeek: React.FC = () => (
x2={to[0]}
y2={to[1]}
stroke={INK}
strokeWidth={2}
strokeWidth={1.5}
{...draw(0.4 + i * 0.22)}
/>
</g>
@@ -418,9 +451,9 @@ const RepairWeek: React.FC = () => (
key={i}
cx={x}
cy={y}
r={7}
r={5}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
initial={{ fill: PAPER }}
animate={{ fill: LIVE }}
transition={{ duration: 0.4, delay: 1 + i * 0.28 }}
@@ -433,13 +466,13 @@ const RepairWeek: React.FC = () => (
style={MONO}
{...appear(2.6, 0)}
>
fragile parts get tests and fixes
{l.repair}
</motion.text>
</g>
);
/** Rescue, step 3: a written summary. */
const Summary: React.FC = () => (
const Summary: Scene = ({ l }) => (
<g>
<rect
x={150}
@@ -449,9 +482,9 @@ const Summary: React.FC = () => (
rx={10}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
{["found", "changed", "next"].map((label, s) => (
{l.summaryParts.map((label, s) => (
<g key={label}>
<motion.text
x={170}
@@ -469,7 +502,7 @@ const Summary: React.FC = () => (
y1={84 + s * 62 + l * 11}
y2={84 + s * 62 + l * 11}
stroke={LINE}
strokeWidth={3}
strokeWidth={2}
strokeLinecap="round"
{...draw(0.35 + s * 0.6 + l * 0.12)}
/>
@@ -483,75 +516,81 @@ const Summary: React.FC = () => (
style={MONO}
{...appear(2.2, 0)}
>
written so you can follow it
{l.summaryNote}
</motion.text>
</g>
);
/** Rescue, step 4: the client decides how to continue. */
const Decide: React.FC = () => (
const Decide: Scene = ({ l }) => (
<g>
<circle cx={90} cy={150} r={26} fill={INK} />
<circle
cx={90}
cy={150}
r={26}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<text
x={90}
y={154}
textAnchor="middle"
style={{
...MONO,
fill: PAPER,
fill: INK,
fontSize: "calc(var(--scene-label, 10px) - 1px)",
}}
>
you
{l.you}
</text>
{[
{ y: 70, label: "continue with me" },
{ y: 150, label: "your own team" },
{ y: 230, label: "stop here" },
].map((option, i) => (
<g key={option.label}>
<motion.path
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
fill="none"
stroke={INK}
strokeWidth={1.5}
{...draw(0.3 + i * 0.4)}
/>
<Packet
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
delay={1.6 + i * 1.3}
duration={1.1}
pause={2.8}
/>
<motion.g {...appear(0.7 + i * 0.4, 0)}>
<rect
x={250}
y={option.y - 16}
width={180}
height={32}
rx={16}
fill={PAPER}
stroke={LINE}
{[70, 150, 230]
.map((y, i) => ({ y, label: l.options[i] ?? "" }))
.map((option, i) => (
<g key={option.label}>
<motion.path
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
fill="none"
stroke={INK}
strokeWidth={FINE}
{...draw(0.3 + i * 0.4)}
/>
<text
x={340}
y={option.y + 4}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{option.label}
</text>
</motion.g>
</g>
))}
<Packet
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
delay={1.6 + i * 1.3}
duration={1.1}
pause={2.8}
/>
<motion.g {...appear(0.7 + i * 0.4, 0)}>
<rect
x={250}
y={option.y - 16}
width={180}
height={32}
rx={16}
fill={PAPER}
stroke={LINE}
/>
<text
x={340}
y={option.y + 4}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{option.label}
</text>
</motion.g>
</g>
))}
</g>
);
const Browser: React.FC<{
children: React.ReactNode;
url?: string;
live?: boolean;
}> = ({ children, url, live = false }) => (
/** Shown next to a pulsing dot once the page is live. */
onlineLabel?: string;
}> = ({ children, url, onlineLabel }) => (
<g>
<rect
x={70}
@@ -561,9 +600,9 @@ const Browser: React.FC<{
rx={12}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={1.5} />
<line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={FINE} />
{[0, 1, 2].map((d) => (
<circle key={d} cx={88 + d * 12} cy={44} r={3} fill={LINE} />
))}
@@ -581,7 +620,7 @@ const Browser: React.FC<{
{url}
</text>
)}
{live && (
{onlineLabel !== undefined && (
<motion.g {...appear(1.6, 0)}>
<motion.circle
cx={352}
@@ -600,7 +639,7 @@ const Browser: React.FC<{
fill: LIVE,
}}
>
online
{onlineLabel}
</text>
</motion.g>
)}
@@ -617,8 +656,58 @@ const BLOCKS: readonly { x: number; y: number; w: number; h: number }[] = [
{ x: 298, y: 174, w: 94, h: 62 },
];
/** What a finished page holds: a headline, a button, and a line of text per card. */
const PageDetail: React.FC<{ delay: number }> = ({ delay }) => (
<motion.g {...appear(delay, 0)}>
<line
x1={102}
x2={206}
y1={118}
y2={118}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
/>
<line
x1={102}
x2={176}
y1={129}
y2={129}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
/>
<rect x={102} y={140} width={40} height={11} rx={5.5} fill={INK} />
{[BLOCKS[2], BLOCKS[3], BLOCKS[4], BLOCKS[5]].map(
(block) =>
block && (
<g key={`${block.x}-${block.y}`}>
<line
x1={block.x + 10}
x2={block.x + block.w - 24}
y1={block.y + block.h - 20}
y2={block.y + block.h - 20}
stroke={RIM}
strokeWidth={2}
strokeLinecap="round"
/>
<line
x1={block.x + 10}
x2={block.x + block.w - 44}
y1={block.y + block.h - 12}
y2={block.y + block.h - 12}
stroke={LINE}
strokeWidth={2}
strokeLinecap="round"
/>
</g>
),
)}
</motion.g>
);
/** Website, step 2: a first design as outlines. */
const Wireframe: React.FC = () => (
const Wireframe: Scene = ({ l }) => (
<g>
<Browser>
{BLOCKS.map((b, i) => (
@@ -644,15 +733,15 @@ const Wireframe: React.FC = () => (
style={MONO}
{...appear(1.5, 0)}
>
a first version to click through
{l.firstVersion}
</motion.text>
</g>
);
/** Website, step 3: built and online. */
const Built: React.FC = () => (
const Built: Scene = ({ l }) => (
<g>
<Browser url="yourcompany.com" live>
<Browser url={l.exampleDomain} onlineLabel={l.online}>
{BLOCKS.map((b, i) => (
<motion.rect
key={i}
@@ -662,10 +751,14 @@ const Built: React.FC = () => (
height={b.h}
rx={5}
initial={{ fill: PAPER, stroke: LINE }}
animate={{ fill: i === 1 ? INK : SOFT, stroke: i === 1 ? INK : LINE }}
animate={{
fill: i === 1 ? TINT : SOFT,
stroke: i === 1 ? INK : LINE,
}}
transition={{ duration: 0.35, delay: 0.2 + i * 0.18 }}
/>
))}
<PageDetail delay={1.3} />
</Browser>
<motion.text
x={240}
@@ -674,15 +767,15 @@ const Built: React.FC = () => (
style={MONO}
{...appear(1.8, 0)}
>
you approve it before it goes live
{l.approve}
</motion.text>
</g>
);
/** Website, step 4: a change request by email. */
const ChangeByEmail: React.FC = () => (
const ChangeByEmail: Scene = ({ l }) => (
<g>
<Browser url="yourcompany.com" live>
<Browser url={l.exampleDomain} onlineLabel={l.online}>
{BLOCKS.map((b, i) => (
<rect
key={i}
@@ -691,10 +784,11 @@ const ChangeByEmail: React.FC = () => (
width={b.w}
height={b.h}
rx={5}
fill={i === 1 ? INK : SOFT}
fill={i === 1 ? TINT : SOFT}
stroke={i === 1 ? INK : LINE}
/>
))}
<PageDetail delay={0} />
<motion.rect
x={BLOCKS[4]!.x}
y={BLOCKS[4]!.y}
@@ -702,8 +796,6 @@ const ChangeByEmail: React.FC = () => (
height={BLOCKS[4]!.h}
rx={5}
fill={MARK}
stroke={INK}
strokeWidth={1.5}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 1.3 }}
@@ -718,20 +810,20 @@ const ChangeByEmail: React.FC = () => (
rx={5}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<path
d="M 16 190 l 23 16 l 23 -16"
fill="none"
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
</motion.g>
<motion.path
d="M 66 202 C 120 202 150 205 188 205"
fill="none"
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
markerEnd="url(#arrow)"
{...draw(0.6)}
/>
@@ -742,16 +834,17 @@ const ChangeByEmail: React.FC = () => (
style={MONO}
{...appear(1.7, 0)}
>
you send an email, I make the change
{l.changeByEmail}
</motion.text>
</g>
);
const TOOLS: readonly { x: number; y: number; label: string }[] = [
{ x: 90, y: 80, label: "Shop" },
{ x: 390, y: 80, label: "Accounting" },
{ x: 90, y: 200, label: "Email" },
{ x: 390, y: 200, label: "CRM" },
/** Where the four tools stand; their names are `tools` in the labels, in this order. */
const TOOLS: readonly { x: number; y: number }[] = [
{ x: 90, y: 80 },
{ x: 390, y: 80 },
{ x: 90, y: 200 },
{ x: 390, y: 200 },
];
const Tool: React.FC<{ x: number; y: number; label: string }> = ({
@@ -766,9 +859,9 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
width={112}
height={44}
rx={10}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
fill={SOFT}
stroke={RIM}
strokeWidth={FINE}
/>
<text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}>
{label}
@@ -777,18 +870,18 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
);
/** Automation, step 2: today a person carries the data from tool to tool. */
const ByHand: React.FC = () => (
const ByHand: Scene = ({ l }) => (
<g>
{TOOLS.map((tool) => (
<Tool key={tool.label} {...tool} />
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
{TOOLS.map((tool, i) => (
<motion.path
key={tool.label}
key={i}
d={`M ${tool.x + (tool.x < 240 ? 56 : -56)} ${tool.y} L ${tool.x < 240 ? 214 : 266} ${140 + (tool.y < 140 ? -12 : 12)}`}
fill="none"
stroke="#f59e0b"
strokeWidth={1.5}
strokeWidth={FINE}
strokeDasharray="3 5"
{...draw(0.3 + i * 0.25)}
/>
@@ -800,13 +893,13 @@ const ByHand: React.FC = () => (
r={13}
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
<path
d="M 218 172 Q 218 146 240 146 Q 262 146 262 172"
fill={PAPER}
stroke={INK}
strokeWidth={1.5}
strokeWidth={FINE}
/>
</motion.g>
<motion.text
@@ -816,7 +909,7 @@ const ByHand: React.FC = () => (
style={MONO}
{...appear(1.5, 0)}
>
today: copied by hand
{l.byHand}
</motion.text>
</g>
);
@@ -829,7 +922,7 @@ const WIRES: readonly string[] = [
];
/** Automation, step 3: the systems are connected one step at a time. */
const Wiring: React.FC = () => (
const Wiring: Scene = ({ l }) => (
<g>
{WIRES.map((d, i) => (
<motion.path
@@ -837,12 +930,12 @@ const Wiring: React.FC = () => (
d={d}
fill="none"
stroke={INK}
strokeWidth={2}
strokeWidth={1.5}
{...draw(0.3 + i * 0.4)}
/>
))}
{TOOLS.map((tool) => (
<Tool key={tool.label} {...tool} />
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
<motion.text
x={240}
@@ -851,16 +944,16 @@ const Wiring: React.FC = () => (
style={MONO}
{...appear(2, 0)}
>
connected one step at a time
{l.wiring}
</motion.text>
</g>
);
/** Automation, step 4: the work travels between the systems on its own. */
const Running: React.FC = () => (
const Running: Scene = ({ l }) => (
<g>
{WIRES.map((d) => (
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={2} />
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} />
))}
{[
{ from: [146, 80], to: [334, 80], delay: 0 },
@@ -870,10 +963,11 @@ const Running: React.FC = () => (
].map((dot) => (
<motion.circle
key={dot.delay}
r={6}
r={3.5}
fill={MARK}
stroke={INK}
strokeWidth={1.5}
stroke={MARK}
strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: dot.from[0], cy: dot.from[1], opacity: 0 }}
animate={{
cx: [dot.from[0]!, dot.to[0]!],
@@ -889,8 +983,8 @@ const Running: React.FC = () => (
}}
/>
))}
{TOOLS.map((tool) => (
<Tool key={tool.label} {...tool} />
{TOOLS.map((tool, i) => (
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
))}
<motion.text
x={240}
@@ -899,12 +993,12 @@ const Running: React.FC = () => (
style={MONO}
{...appear(0.6, 0)}
>
the steps run without anyone copying
{l.running}
</motion.text>
</g>
);
const SCENES: Readonly<Record<string, readonly React.FC[]>> = {
const SCENES: Readonly<Record<string, readonly Scene[]>> = {
developer: [Talk, WeeklyHours, WorkList, Notice],
rescue: [Talk, RepairWeek, Summary, Decide],
automation: [Talk, ByHand, Wiring, Running],
@@ -916,6 +1010,7 @@ export const PathScene: React.FC<{
step: number;
title: string;
}> = ({ pathId, step, title }) => {
const labels = useContent().services.scenes;
const Scene = SCENES[pathId]?.[step];
if (!Scene) return null;
@@ -932,8 +1027,8 @@ export const PathScene: React.FC<{
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="7"
markerHeight="7"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
@@ -957,7 +1052,7 @@ export const PathScene: React.FC<{
animate={{ opacity: 1 }}
transition={{ duration: 0.25 }}
>
<Scene />
<Scene l={labels} />
</motion.g>
</svg>
);
@@ -1,6 +1,9 @@
"use client";
import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
import { fill } from "@/src/domain/template";
/**
* PipelineRun: what happens between a change and its release, shown as the test run it is.
@@ -23,56 +26,37 @@ const SUITES: readonly Suite[] = [
{ file: "emails/invoice-mail.spec.ts", tests: 5, ms: 290 },
];
/** A change and the test it breaks elsewhere. The texts are in en.ts and de.ts, by id. */
type Scenario = {
readonly id: string;
readonly label: string;
readonly branch: string;
readonly failingFile: string;
readonly testName: string;
readonly expected: string;
readonly received: string;
};
const SCENARIOS: readonly Scenario[] = [
{
id: "orders",
label: "Orders",
branch: "change/order-discounts",
failingFile: "invoices/total.spec.ts",
testName: "invoice total matches the discounted order",
expected: "119.00",
received: "125.00",
},
{
id: "login",
label: "Login",
branch: "change/login-with-username",
failingFile: "emails/confirmation.spec.ts",
testName: "confirmation goes to the account's email address",
expected: '"anna@example.com"',
received: "undefined",
},
{
id: "invoices",
label: "Invoices",
branch: "change/invoice-layout",
failingFile: "emails/invoice-mail.spec.ts",
testName: "invoice email attaches the PDF",
expected: "1 attachment",
received: "0 attachments",
},
{
id: "emails",
label: "Emails",
branch: "change/email-queue",
failingFile: "orders/status.spec.ts",
testName: "order is confirmed once the email is queued",
expected: '"confirmed"',
received: '"pending"',
},
];
const STAGES = ["Lint", "Types", "Tests", "Deploy"] as const;
const STAGE_COUNT = 4;
const TOTAL_TESTS = SUITES.reduce((sum, suite) => sum + suite.tests, 0);
/**
@@ -128,6 +112,7 @@ const STAGE_MARK: Readonly<Record<StageState, string>> = {
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
export const PipelineRun: React.FC = () => {
const copy = useContent().technology.pipeline;
const [scenarioId, setScenarioId] = React.useState(SCENARIOS[0]?.id ?? "");
const [frameIndex, setFrameIndex] = React.useState(0);
const [run, setRun] = React.useState(0);
@@ -169,7 +154,8 @@ export const PipelineRun: React.FC = () => {
return () => window.clearTimeout(timer);
}, [inView, scenarioId, run]);
if (!scenario) return null;
const scenarioCopy = scenario ? copy.scenarios[scenario.id] : undefined;
if (!scenario || !scenarioCopy) return null;
const frame = FRAMES[frameIndex] ?? FRAMES[0];
if (!frame) return null;
@@ -177,7 +163,7 @@ export const PipelineRun: React.FC = () => {
const firstAttempt = frame.attempt === 1;
const failingIndex = SUITES.findIndex((s) => s.file === scenario.failingFile);
const failureSeen = firstAttempt && frame.suitesDone > failingIndex;
const finished = frame.stagesDone === STAGES.length;
const finished = frame.stagesDone === STAGE_COUNT;
const stageState = (index: number): StageState => {
if (index === 2 && firstAttempt && failureSeen && frame.stagesDone >= 3) {
@@ -200,20 +186,10 @@ export const PipelineRun: React.FC = () => {
// Which of the three explanations applies right now.
const explain = firstAttempt ? (finished ? 1 : 0) : finished ? 2 : 1;
const EXPLANATIONS = [
{
title: "A change comes in",
text: `Something is changed in ${scenario.label}. Before it can go live, the whole project is checked automatically.`,
},
{
title: "A test fails, the release is blocked",
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
},
{
title: "Fixed, checked again, released",
text: "I repair what broke, the run passes, and only then does the change go live.",
},
] as const;
const explanations = copy.explanations.map((item) => ({
title: item.title,
text: fill(item.text, { area: scenarioCopy.label }),
}));
return (
<div
@@ -221,7 +197,7 @@ export const PipelineRun: React.FC = () => {
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12"
>
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-500`}>Simulate a change in</p>
<p className={`${MONO} m-0 text-slate-500`}>{copy.simulate}</p>
<div className="mt-4 flex flex-wrap gap-2">
{SCENARIOS.map((item) => {
const selected = item.id === scenario.id;
@@ -240,14 +216,14 @@ export const PipelineRun: React.FC = () => {
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
}`}
>
{item.label}
{copy.scenarios[item.id]?.label}
</button>
);
})}
</div>
<ol className="m-0 mt-10 list-none p-0" aria-live="polite">
{EXPLANATIONS.map((item, index) => {
{explanations.map((item, index) => {
const active = index === explain;
return (
<li
@@ -280,134 +256,139 @@ export const PipelineRun: React.FC = () => {
onClick={() => setRun((n) => n + 1)}
className="mt-6 rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900"
>
Run again
{copy.runAgain}
</button>
</div>
<div className="md:col-span-8">
<div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]">
{/* Run header */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7">
<div className="flex items-center gap-3">
<span className="flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className="text-slate-400">
{scenario.branch}
<span className="text-slate-600"> → </span>main
<BitAura source="right" tone="light">
<div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]">
{/* Run header */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7">
<div className="flex items-center gap-3">
<span className="flex gap-1.5" aria-hidden="true">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span>
<span className="text-slate-400">
{scenario.branch}
<span className="text-slate-600"> → </span>main
</span>
</div>
<span className="text-slate-500">
{copy.run} #{frame.attempt === 1 ? 481 : 482}
{frame.attempt === 2 && (
<span className="text-slate-400"> · {copy.fixPushed}</span>
)}
</span>
</div>
<span className="text-slate-500">
run #{frame.attempt === 1 ? 481 : 482}
{frame.attempt === 2 && (
<span className="text-slate-400"> · fix pushed</span>
)}
</span>
</div>
{/* Stages */}
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
{STAGES.map((stage, index) => {
const state = stageState(index);
return (
<div
key={stage}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`}
>
<span>{stage}</span>
<span aria-hidden="true">{STAGE_MARK[state]}</span>
</div>
);
})}
</div>
{/* Test output */}
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
{frame.stagesDone < 2 && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
{frame.stagesDone === 0
? "$ lint: checking the code style…"
: "$ types: checking that the parts fit together…"}
</p>
)}
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
const failed = firstAttempt && index === failingIndex;
return (
<div key={suite.file}>
<div className="flex items-baseline gap-3">
<span
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
>
{failed ? "FAIL" : "PASS"}
</span>
<span className="min-w-0 flex-1 truncate text-slate-200">
{suite.file}
</span>
<span className="hidden text-slate-500 sm:inline">
{suite.tests} tests
</span>
<span className="w-14 shrink-0 text-right text-slate-600">
{suite.ms} ms
</span>
{/* Stages */}
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
{copy.stages.map((stage, index) => {
const state = stageState(index);
return (
<div
key={stage}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`}
>
<span>{stage}</span>
<span aria-hidden="true">{STAGE_MARK[state]}</span>
</div>
{failed && (
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
● {scenario.testName}
</p>
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
expected&nbsp; {scenario.expected}
</p>
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
received&nbsp; {scenario.received}
</p>
</div>
)}
</div>
);
})}
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
running {SUITES[frame.suitesDone]?.file}…
</p>
)}
</div>
);
})}
</div>
{/* Summary */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
<span>
<span className="text-slate-500">Tests </span>
{failedCount > 0 && (
<span className="text-rose-400">{failedCount} failed, </span>
{/* Test output */}
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
{frame.stagesDone < 2 && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
{frame.stagesDone === 0 ? copy.lint : copy.types}
</p>
)}
<span className="text-emerald-400">
{reportedTests - failedCount} passed
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
const failed = firstAttempt && index === failingIndex;
return (
<div key={suite.file}>
<div className="flex items-baseline gap-3">
<span
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
>
{failed ? "FAIL" : "PASS"}
</span>
<span className="min-w-0 flex-1 truncate text-slate-200">
{suite.file}
</span>
<span className="hidden text-slate-500 sm:inline">
{suite.tests} {copy.tests}
</span>
<span className="w-14 shrink-0 text-right text-slate-600">
{suite.ms} ms
</span>
</div>
{failed && (
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
● {scenarioCopy.testName}
</p>
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
{copy.expected}&nbsp; {scenarioCopy.expected}
</p>
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
{copy.received}&nbsp; {scenarioCopy.received}
</p>
</div>
)}
</div>
);
})}
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
{fill(copy.runningFile, {
file: SUITES[frame.suitesDone]?.file ?? "",
})}
</p>
)}
</div>
{/* Summary */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
<span>
<span className="text-slate-500">{copy.summaryLabel} </span>
{failedCount > 0 && (
<span className="text-rose-400">
{fill(copy.failed, { n: failedCount })},{" "}
</span>
)}
<span className="text-emerald-400">
{fill(copy.passed, { n: reportedTests - failedCount })}
</span>
<span className="text-slate-500">
{" "}
{fill(copy.of, { total: TOTAL_TESTS })}
</span>
</span>
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
</span>
<span
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
!finished
? "bg-slate-800 text-slate-400"
<span
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
!finished
? "bg-slate-800 text-slate-400"
: firstAttempt
? "bg-rose-500/15 text-rose-400"
: "bg-emerald-500/15 text-emerald-400"
}`}
>
{!finished
? copy.pending
: firstAttempt
? "bg-rose-500/15 text-rose-400"
: "bg-emerald-500/15 text-emerald-400"
}`}
>
{!finished
? "release pending"
: firstAttempt
? "release blocked"
: "released"}
</span>
? copy.blocked
: copy.released}
</span>
</div>
</div>
</div>
</BitAura>
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
An illustration with made-up file names and numbers, modelled on a
real test run. Tests do not catch every mistake, but they do catch
this kind.
{copy.footnote}
</p>
</div>
</div>
@@ -0,0 +1,622 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { useContent } from "@/src/components/ContentProvider";
import { useNarrow } from "./useNarrow";
/**
* WebPresentation: the drawings of the web presentations section. The main one
* shows the same presentation as a slide file and as a website on three
* screens; four small ones stand next to the reasons. Same line language as
* PathScenes.
*/
const INK = "#f8fafc";
const LINE = "#475569";
const RIM = "#64748b";
const SOFT = "#1e293b";
const MARK = "#fde68a";
const LIVE = "#10b981";
const TINT = "rgba(248, 250, 252, 0.14)";
const FINE = 1.1;
const STEP_MS = 3600;
const MOVE = { duration: 0.7, ease: [0.16, 1, 0.3, 1] as const };
const MONO: React.CSSProperties = {
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: "var(--scene-label, 10px)",
letterSpacing: "0.14em",
textTransform: "uppercase",
fill: "#94a3b8",
};
interface Size {
width: number;
height: number;
}
interface Rect extends Size {
x: number;
y: number;
}
/** Projector, laptop, phone. */
const SCREENS: readonly Size[] = [
{ width: 300, height: 170 },
{ width: 230, height: 150 },
{ width: 96, height: 190 },
];
const SLIDE: Size = { width: 160, height: 90 };
const PAD = 12;
const GAP = 8;
const centred = (size: Size, cx: number, cy: number): Rect => ({
...size,
x: cx - size.width / 2,
y: cy - size.height / 2,
});
/** The slide keeps its format: it is scaled down until it fits the screen. */
const fitSlide = (screen: Rect) => {
const scale = Math.min(
(screen.width - 2 * PAD) / SLIDE.width,
(screen.height - 2 * PAD) / SLIDE.height,
);
return {
scale,
x: screen.x + (screen.width - SLIDE.width * scale) / 2,
y: screen.y + (screen.height - SLIDE.height * scale) / 2,
};
};
/** The web page rearranges: three columns, two columns and a row, or one column. */
const webLayout = (screen: Rect, index: number) => {
const left = screen.x + PAD;
const inner = screen.width - 2 * PAD;
const header: Rect = { x: left, y: screen.y + PAD, width: inner, height: 12 };
const top = header.y + header.height + GAP;
const area = screen.y + screen.height - PAD - top;
if (index === 0) {
const width = (inner - 2 * GAP) / 3;
return {
header,
cards: [0, 1, 2].map((i) => ({
x: left + i * (width + GAP),
y: top,
width,
height: area,
})),
};
}
if (index === 1) {
const width = (inner - GAP) / 2;
const upper = (area - GAP) * 0.56;
return {
header,
cards: [
{ x: left, y: top, width, height: upper },
{ x: left + width + GAP, y: top, width, height: upper },
{
x: left,
y: top + upper + GAP,
width: inner,
height: area - upper - GAP,
},
],
};
}
const height = (area - 2 * GAP) / 3;
return {
header,
cards: [0, 1, 2].map((i) => ({
x: left,
y: top + i * (height + GAP),
width: inner,
height,
})),
};
};
const Frame: React.FC<{ rect: Rect }> = ({ rect }) => (
<motion.rect
rx={12}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
initial={false}
animate={{
attrX: rect.x,
attrY: rect.y,
width: rect.width,
height: rect.height,
}}
transition={MOVE}
/>
);
/** One 16:9 slide, drawn at its own size and scaled as a whole. */
const Slide: React.FC<{ screen: Rect }> = ({ screen }) => {
const fit = fitSlide(screen);
return (
<motion.g
style={{ originX: 0, originY: 0 }}
initial={false}
animate={{ x: fit.x, y: fit.y, scale: fit.scale }}
transition={MOVE}
>
<rect
width={SLIDE.width}
height={SLIDE.height}
rx={4}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<line
x1={12}
x2={84}
y1={18}
y2={18}
stroke={INK}
strokeWidth={4}
strokeLinecap="round"
/>
{[0, 1, 2].map((bullet) => (
<g key={bullet}>
<circle cx={14} cy={38 + bullet * 13} r={1.6} fill={RIM} />
<line
x1={20}
x2={20 + [58, 46, 52][bullet % 3]}
y1={38 + bullet * 13}
y2={38 + bullet * 13}
stroke={RIM}
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
))}
{[0, 1, 2, 3].map((bar) => {
const height = [18, 30, 24, 40][bar % 4];
return (
<rect
key={bar}
x={104 + bar * 11}
y={74 - height}
width={7}
height={height}
rx={2}
fill={bar === 3 ? MARK : RIM}
/>
);
})}
</motion.g>
);
};
const WebPage: React.FC<{ screen: Rect; index: number }> = ({
screen,
index,
}) => {
const { header, cards } = webLayout(screen, index);
return (
<g>
<motion.rect
rx={4}
fill={SOFT}
stroke={LINE}
strokeWidth={1}
initial={false}
animate={{
attrX: header.x,
attrY: header.y,
width: header.width,
height: header.height,
}}
transition={MOVE}
/>
{cards.map((card, i) => (
<g key={i}>
<motion.rect
rx={6}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
initial={false}
animate={{
attrX: card.x,
attrY: card.y,
width: card.width,
height: card.height,
}}
transition={MOVE}
/>
<motion.line
stroke={i === 2 ? MARK : INK}
strokeWidth={3}
strokeLinecap="round"
initial={false}
animate={{
x1: card.x + 9,
x2: card.x + Math.min(card.width - 9, 9 + 46),
y1: card.y + 12,
y2: card.y + 12,
}}
transition={MOVE}
/>
<motion.line
stroke={RIM}
strokeWidth={2.5}
strokeLinecap="round"
initial={false}
animate={{
x1: card.x + 9,
x2: card.x + Math.min(card.width - 9, 9 + 62),
y1: card.y + 23,
y2: card.y + 23,
}}
transition={MOVE}
/>
</g>
))}
</g>
);
};
/**
* PresentationCompare: the same presentation as a slide file and as a website,
* on a projector, a laptop and a phone. It steps through the screens on its
* own until the visitor picks one.
*/
export const PresentationCompare: React.FC = () => {
const { compare } = useContent().presentations;
const narrow = useNarrow();
const ref = React.useRef<HTMLDivElement>(null);
const [index, setIndex] = React.useState(0);
const [auto, setAuto] = React.useState(true);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
const node = ref.current;
if (!node) return;
const observer = new IntersectionObserver(
([entry]) => setInView(entry?.isIntersecting ?? false),
{ threshold: 0.3 },
);
observer.observe(node);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!auto || !inView) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = window.setInterval(
() => setIndex((current) => (current + 1) % SCREENS.length),
STEP_MS,
);
return () => window.clearInterval(timer);
}, [auto, inView]);
// Side by side on wide screens, one above the other on a phone.
const view = narrow
? { width: 380, height: 600 }
: { width: 760, height: 300 };
const stages = narrow
? [
{ cx: 190, cy: 150 },
{ cx: 190, cy: 450 },
]
: [
{ cx: 190, cy: 150 },
{ cx: 570, cy: 150 },
];
const size = SCREENS[index] ?? SCREENS[0];
if (!size) return null;
const slideScreen = centred(size, stages[0]?.cx ?? 0, stages[0]?.cy ?? 0);
const webScreen = centred(size, stages[1]?.cx ?? 0, stages[1]?.cy ?? 0);
const tooSmall = index === SCREENS.length - 1;
return (
<div ref={ref}>
<svg
viewBox={`0 0 ${view.width} ${view.height}`}
role="img"
aria-label={compare.description}
className="block h-auto w-full [--scene-label:13px] md:[--scene-label:10px]"
>
{[
{
stage: stages[0],
name: compare.slides,
note: compare.slideNotes[index],
colour: tooSmall ? MARK : "#94a3b8",
},
{
stage: stages[1],
name: compare.web,
note: compare.webNotes[index],
colour: LIVE,
},
].map(
(column) =>
column.stage && (
<g key={column.name}>
<text
x={column.stage.cx}
y={column.stage.cy - 118}
textAnchor="middle"
style={{ ...MONO, fill: INK }}
>
{column.name}
</text>
<motion.text
key={column.note}
x={column.stage.cx}
y={column.stage.cy + 128}
textAnchor="middle"
style={{ ...MONO, fill: column.colour }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.35 }}
>
{column.note}
</motion.text>
</g>
),
)}
<Frame rect={slideScreen} />
<Slide screen={slideScreen} />
<Frame rect={webScreen} />
<WebPage screen={webScreen} index={index} />
</svg>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2 md:mt-8">
<span className="mr-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
{compare.pickScreen}
</span>
{compare.screens.map((screen, i) => {
const selected = i === index;
return (
<button
key={screen}
type="button"
aria-pressed={selected}
onClick={() => {
setIndex(i);
setAuto(false);
}}
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-white bg-white text-slate-900"
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
}`}
>
{screen}
</button>
);
})}
</div>
</div>
);
};
const draw = (delay: number) => ({
initial: { pathLength: 0, opacity: 0 },
whileInView: { pathLength: 1, opacity: 1 },
viewport: { once: true, margin: "-10%" },
transition: { duration: 0.7, delay, ease: "easeOut" as const },
});
/** Every screen: a projector, a laptop and a phone drawn one after another. */
const ScreensDrawing: React.FC = () => (
<g fill="none" stroke={INK} strokeWidth={FINE} strokeLinejoin="round">
<motion.rect x={14} y={14} width={86} height={50} rx={5} {...draw(0)} />
<motion.path d="M 44 74 h 26 M 57 64 v 10" stroke={RIM} {...draw(0.2)} />
<motion.rect x={112} y={30} width={60} height={38} rx={4} {...draw(0.35)} />
<motion.path d="M 104 72 h 76" stroke={RIM} {...draw(0.5)} />
<motion.rect
x={190}
y={22}
width={28}
height={52}
rx={6}
fill={TINT}
{...draw(0.7)}
/>
</g>
);
/** One link: several copies of a file on one side, a single address on the other. */
const LinkDrawing: React.FC = () => (
<g>
{[0, 1, 2].map((copy) => (
<rect
key={copy}
x={18 + copy * 12}
y={18 + copy * 8}
width={38}
height={48}
rx={4}
fill="#0b1220"
stroke={LINE}
strokeWidth={1}
strokeDasharray="3 3"
/>
))}
<motion.path
d="M 96 46 h 30"
stroke={INK}
strokeWidth={FINE}
markerEnd="url(#reason-arrow)"
{...draw(0.2)}
/>
<rect
x={138}
y={32}
width={82}
height={28}
rx={14}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<motion.circle
cx={152}
cy={46}
r={3.5}
fill={LIVE}
animate={{ opacity: [1, 0.35, 1] }}
transition={{ duration: 1.8, repeat: Infinity }}
/>
<line
x1={162}
x2={208}
y1={46}
y2={46}
stroke={INK}
strokeWidth={2.5}
strokeLinecap="round"
/>
</g>
);
/** The real thing: a chart whose bars keep moving, because the figures are live. */
const LiveDrawing: React.FC = () => (
<g>
<rect
x={24}
y={12}
width={186}
height={66}
rx={8}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
/>
<line x1={38} x2={196} y1={66} y2={66} stroke={LINE} strokeWidth={1} />
{[0, 1, 2, 3, 4, 5].map((bar) => {
const low = [14, 22, 18, 30, 24, 36][bar % 6] ?? 20;
const high = [26, 16, 34, 22, 40, 28][bar % 6] ?? 30;
return (
<motion.rect
key={bar}
x={44 + bar * 25}
width={14}
rx={3}
fill={bar === 5 ? MARK : RIM}
initial={false}
animate={{
height: [low, high, low],
attrY: [66 - low, 66 - high, 66 - low],
}}
transition={{
duration: 3.6,
delay: bar * 0.25,
repeat: Infinity,
ease: "easeInOut",
}}
/>
);
})}
<motion.circle
cx={198}
cy={24}
r={3}
fill={LIVE}
animate={{ opacity: [1, 0.3, 1] }}
transition={{ duration: 1.6, repeat: Infinity }}
/>
</g>
);
/** Afterwards: a page that is read on its own and passed on. */
const AfterDrawing: React.FC = () => (
<g>
<rect
x={30}
y={10}
width={74}
height={70}
rx={6}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
{[0, 1, 2, 3].map((row) => (
<line
key={row}
x1={42}
x2={42 + [48, 40, 46, 30][row % 4]}
y1={26 + row * 12}
y2={26 + row * 12}
stroke={row === 0 ? INK : RIM}
strokeWidth={row === 0 ? 3 : 2.5}
strokeLinecap="round"
/>
))}
{[24, 46, 68].map((y, i) => (
<g key={y}>
<motion.path
d={`M 104 45 C 136 45 138 ${y} 168 ${y}`}
fill="none"
stroke={INK}
strokeWidth={FINE}
{...draw(0.2 + i * 0.2)}
/>
<circle
cx={180}
cy={y}
r={9}
fill="#0b1220"
stroke={RIM}
strokeWidth={FINE}
/>
<circle cx={180} cy={y - 2} r={2.6} fill={RIM} />
<path
d={`M 174.5 ${y + 5.5} q 5.5 -6 11 0`}
fill="none"
stroke={RIM}
strokeWidth={1.4}
strokeLinecap="round"
/>
</g>
))}
</g>
);
const REASON_DRAWINGS: readonly React.FC[] = [
ScreensDrawing,
LinkDrawing,
LiveDrawing,
AfterDrawing,
];
/** The small drawing next to one of the reasons, by its position in the list. */
export const ReasonDrawing: React.FC<{ index: number }> = ({ index }) => {
const Drawing = REASON_DRAWINGS[index];
if (!Drawing) return null;
return (
<svg
viewBox="0 0 234 90"
aria-hidden="true"
className="block h-auto w-full"
>
<defs>
<marker
id="reason-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
</marker>
</defs>
<Drawing />
</svg>
);
};
@@ -3,25 +3,10 @@
import * as React from "react";
import Image from "next/image";
import { motion, useMotionValue, useSpring } from "framer-motion";
export type WorkItem = {
readonly name: string;
readonly href: string;
readonly client: string;
readonly text: string;
readonly did: readonly string[];
readonly tech: readonly string[];
readonly desktop: {
readonly src: string;
readonly width: number;
readonly height: number;
};
readonly mobile: {
readonly src: string;
readonly width: number;
readonly height: number;
};
};
import { BitAura } from "@/src/components/bits/BitAura";
import { useContent } from "@/src/components/ContentProvider";
import { WORK_SITES, type WorkSite } from "@/src/content/work";
import { fill } from "@/src/domain/template";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
@@ -69,7 +54,8 @@ const ScrollingShot: React.FC<{
</motion.div>
);
const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
const WorkStage: React.FC<{ item: WorkSite }> = ({ item }) => {
const { work } = useContent();
const ref = React.useRef<HTMLDivElement>(null);
const [inView, setInView] = React.useState(false);
const [still, setStill] = React.useState(false);
@@ -107,7 +93,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
ref={ref}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
className="relative overflow-hidden rounded-3xl border border-slate-700 bg-slate-950 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
>
<div
className="pointer-events-none absolute inset-0 opacity-60"
@@ -146,7 +132,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
<ScrollingShot
shot={item.desktop}
frameAspect={16 / 10}
alt={`${item.name} on a desktop screen`}
alt={fill(work.desktopAlt, { name: item.name })}
sizes="(min-width: 1024px) 900px, 100vw"
active={active}
seconds={22}
@@ -166,7 +152,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
<ScrollingShot
shot={item.mobile}
frameAspect={9 / 17}
alt={`${item.name} on a phone`}
alt={fill(work.phoneAlt, { name: item.name })}
sizes="240px"
active={active}
seconds={26}
@@ -179,65 +165,78 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
};
/**
* WorkShowcase: each live site on a dark stage, as a desktop and a phone view
* WorkShowcase: for a dark section. Each live site on a lit stage, as a desktop and a phone view
* that scroll through the real pages.
*/
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
items,
}) => (
<div className="flex flex-col gap-20 md:gap-32">
{items.map((item, index) => (
<article
key={item.name}
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
>
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
<WorkStage item={item} />
</div>
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-500`}>
0{index + 1} · {item.client}
</p>
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
{item.name}
</h3>
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
{item.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
{item.did.map((line) => (
<li
key={line}
className="border-t border-slate-200 py-3 font-sans text-base text-slate-900 md:text-lg"
>
{line}
</li>
))}
</ul>
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
{item.tech.map((tech) => (
<li
key={tech}
className="rounded-full border border-slate-200 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-600"
>
{tech}
</li>
))}
</ul>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center gap-3 rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white no-underline transition-transform duration-300 hover:-translate-y-0.5"
export const WorkShowcase: React.FC = () => {
const { work } = useContent();
return (
<div className="flex flex-col gap-20 md:gap-32">
{WORK_SITES.map((item, index) => {
const copy = work.items[item.name];
if (copy === undefined) return null;
return (
<article
key={item.name}
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
>
Open the live site <span aria-hidden="true">↗</span>
</a>
</div>
</article>
))}
</div>
);
<div
className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}
>
<BitAura
source={index % 2 === 0 ? "bottom" : "top"}
tone="dark"
motion={index % 2 === 0 ? "sparks" : "rain"}
>
<WorkStage item={item} />
</BitAura>
</div>
<div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-400`}>
0{index + 1} · {copy.client}
</p>
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
{item.name}
</h3>
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
{copy.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
{copy.did.map((line) => (
<li
key={line}
className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
>
{line}
</li>
))}
</ul>
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
{copy.tech.map((tech) => (
<li
key={tech}
className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
>
{tech}
</li>
))}
</ul>
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
>
{work.openLive} <span aria-hidden="true">↗</span>
</a>
</div>
</article>
);
})}
</div>
);
};
@@ -0,0 +1,73 @@
"use client";
import type { Locale } from "@/src/domain/locale";
import type { ScrollAnchor } from "@/src/domain/scroll-anchor";
/**
* The state of a running language switch. It lives outside React on purpose:
* changing the language swaps the whole page below the root layout, and the
* curtain that hides the swap has to carry on across it.
*
* cover: the curtain closes over the old page. hold: the page is swapped behind
* it. reveal: the curtain opens on the new page.
*/
export type SwitchState =
| { readonly phase: "idle" }
| {
readonly phase: "cover" | "hold" | "reveal";
readonly from: Locale;
readonly target: Locale;
readonly href: string;
/** When the switch began and when the current phase began (performance.now). */
readonly startedAt: number;
readonly phaseAt: number;
/** Where the visitor was reading, to find the same place in the new page. */
readonly anchor: ScrollAnchor | null;
};
const IDLE: SwitchState = { phase: "idle" };
let state: SwitchState = IDLE;
const listeners = new Set<() => void>();
const set = (next: SwitchState) => {
state = next;
listeners.forEach((listener) => listener());
};
export const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
export const getSwitchState = (): SwitchState => state;
export const getIdleState = (): SwitchState => IDLE;
export const startSwitch = (from: Locale, target: Locale, href: string) => {
if (state.phase !== "idle") return;
const now = performance.now();
set({
phase: "cover",
from,
target,
href,
startedAt: now,
phaseAt: now,
anchor: null,
});
};
export const enterPhase = (
phase: "hold" | "reveal",
anchor?: ScrollAnchor | null,
) => {
if (state.phase === "idle") return;
set({
...state,
phase,
phaseAt: performance.now(),
anchor: anchor === undefined ? state.anchor : anchor,
});
};
export const endSwitch = () => set(IDLE);
-71
View File
@@ -1,71 +0,0 @@
import type { CareerStation } from "@/src/components/illustrations/CareerLine";
/** The career in four stations. Facts from the owner's CV, deliberately summarised. */
export const CAREER: readonly CareerStation[] = [
{ years: "2011", role: "Started in the web", place: "Apprenticeship" },
{
years: "2014 – 2020",
role: "Agencies",
place: "Frontend developer",
},
{
years: "2020 – 2023",
role: "Product companies",
place: "Senior software developer",
},
{
years: "Today",
role: "Independent",
place: "Working directly with clients",
},
];
/** Which projects I am a good fit for, and which not. */
export const FIT = [
{
title: "A good fit",
items: [
"Web apps: dashboards, portals, internal tools",
"Interfaces where design and usability matter",
"Company websites, also in several languages",
"Web3 frontends: wallets, live data, dashboards",
"Automation: connecting systems and their APIs",
],
},
{
title: "Also possible",
items: [
"The backend an app needs: APIs, login, integrations",
"Command-line tools",
"Taking over and tidying up existing code",
],
},
{
title: "Not my field",
items: [
"Mobile apps for the app stores",
"Windows software",
"Running servers, specialised database work",
],
},
] as const;
/** Technologies I work with. Only what I have actually used in projects. */
export const TECH = [
{
area: "Interface",
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
},
{
area: "Behind it",
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
},
{
area: "Web3 and systems",
items: ["Solidity", "Rust"],
},
{
area: "Delivery",
items: ["Automated tests", "Docker", "CI/CD"],
},
] as const;
+35 -113
View File
@@ -1,11 +1,12 @@
import type { FlowNodeCopy, NodeKind, WorkflowCopy } from "./types";
/**
* Example workflows for the automation section. They are illustrations of typical
* cases, not descriptions of specific client projects.
* cases, not descriptions of specific client projects. This file holds what the
* drawing needs (nodes, positions, runs); the texts are in en.ts and de.ts.
*/
export type NodeKind = "trigger" | "rule" | "ai" | "action" | "human";
export type FlowNode = {
type NodeLayout = {
readonly id: string;
readonly kind: NodeKind;
/** Position of the node centre on the wide 920 x 400 canvas. */
@@ -14,38 +15,28 @@ export type FlowNode = {
/** Position of the node centre on the tall 400 x 540 canvas for phones. */
readonly nx: number;
readonly ny: number;
readonly system: string;
readonly label: string;
/** What appears in the log when a run reaches this node. */
readonly log: string;
};
export type FlowNode = NodeLayout & FlowNodeCopy;
/** One step of a run: the connections travelled at the same time. */
export type FlowStep = readonly (readonly [from: string, to: string])[];
export type Workflow = {
type WorkflowLayout = {
readonly id: string;
readonly name: string;
readonly summary: string;
readonly byHand: string;
readonly automated: string;
readonly nodes: readonly FlowNode[];
readonly nodes: readonly NodeLayout[];
/** Every connection that exists, for drawing. */
readonly edges: readonly (readonly [from: string, to: string])[];
/** The runs that play in turn. Each run is a list of steps. */
readonly runs: readonly (readonly FlowStep[])[];
};
export const WORKFLOWS: readonly Workflow[] = [
export type Workflow = Omit<WorkflowLayout, "nodes"> &
Omit<WorkflowCopy, "nodes"> & { readonly nodes: readonly FlowNode[] };
const LAYOUTS: readonly WorkflowLayout[] = [
{
id: "order",
name: "Order to invoice",
summary:
"A new order arrives in the shop. The invoice, the customer record and the confirmation follow without anyone typing.",
byHand:
"Someone opens the order, writes the invoice, updates the customer record and sends an email.",
automated:
"The order triggers everything. People only see the finished result.",
nodes: [
{
id: "shop",
@@ -54,9 +45,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 60,
system: "Shop",
label: "New order",
log: "order received",
},
{
id: "stock",
@@ -65,9 +53,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 190,
system: "Rule",
label: "In stock?",
log: "stock checked: available",
},
{
id: "invoice",
@@ -76,9 +61,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 110,
nx: 105,
ny: 330,
system: "Accounting",
label: "Create invoice",
log: "invoice created",
},
{
id: "crm",
@@ -87,9 +69,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 290,
nx: 295,
ny: 330,
system: "CRM",
label: "Update customer",
log: "customer record updated",
},
{
id: "mail",
@@ -98,9 +77,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 470,
system: "Email",
label: "Send confirmation",
log: "confirmation sent",
},
],
edges: [
@@ -126,13 +102,6 @@ export const WORKFLOWS: readonly Workflow[] = [
},
{
id: "documents",
name: "Incoming documents",
summary:
"Invoices arrive as PDF by email. An AI step reads them, a rule checks the result, and a person only sees the unclear ones.",
byHand:
"Someone opens every PDF, reads supplier, amount and date, and types them into the accounting system.",
automated:
"Clear cases are booked directly. Unclear ones go to a person with the document attached.",
nodes: [
{
id: "inbox",
@@ -141,9 +110,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 60,
system: "Inbox",
label: "Email with PDF",
log: "document received",
},
{
id: "read",
@@ -152,9 +118,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 190,
system: "AI step",
label: "Read the document",
log: "supplier, amount and date extracted",
},
{
id: "check",
@@ -163,9 +126,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 320,
system: "Rule",
label: "Plausible?",
log: "values checked",
},
{
id: "book",
@@ -174,9 +134,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 110,
nx: 105,
ny: 460,
system: "Accounting",
label: "Book it",
log: "booked automatically",
},
{
id: "ask",
@@ -185,9 +142,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 290,
nx: 295,
ny: 460,
system: "A person",
label: "Review and decide",
log: "unclear: sent to a person for review",
},
],
edges: [
@@ -204,12 +158,6 @@ export const WORKFLOWS: readonly Workflow[] = [
},
{
id: "lead",
name: "Enquiry to follow-up",
summary:
"Someone fills in the contact form. The contact is created, the right person is told, and a reminder makes sure nobody forgets.",
byHand:
"Someone copies the enquiry into the CRM, forwards it by email and hopes the follow-up is not forgotten.",
automated: "The enquiry is filed, assigned and followed up on schedule.",
nodes: [
{
id: "form",
@@ -218,9 +166,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 60,
system: "Website",
label: "Contact form",
log: "enquiry received",
},
{
id: "contact",
@@ -229,9 +174,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 200,
nx: 200,
ny: 190,
system: "CRM",
label: "Create contact",
log: "contact created",
},
{
id: "notify",
@@ -240,9 +182,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 110,
nx: 105,
ny: 330,
system: "Team chat",
label: "Notify sales",
log: "sales notified",
},
{
id: "wait",
@@ -251,9 +190,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 290,
nx: 295,
ny: 330,
system: "Rule",
label: "No reply in 3 days?",
log: "follow-up scheduled",
},
{
id: "remind",
@@ -262,9 +198,6 @@ export const WORKFLOWS: readonly Workflow[] = [
y: 290,
nx: 295,
ny: 470,
system: "Tasks",
label: "Create reminder",
log: "reminder created",
},
],
edges: [
@@ -286,37 +219,26 @@ export const WORKFLOWS: readonly Workflow[] = [
},
];
export const KIND_LABEL: Readonly<Record<NodeKind, string>> = {
trigger: "Trigger: something happens",
rule: "Rule: a fixed check",
ai: "AI step: reads or sorts",
action: "Action: a system does something",
human: "Person: decides the unclear cases",
};
const NO_TEXT: FlowNodeCopy = { system: "", label: "", log: "" };
export const AUTOMATION_NOTES = [
{
title: "Worth automating when",
items: [
"The same steps repeat every day or every week",
"Data is typed from one tool into another",
"Mistakes happen because the work is monotonous",
],
},
{
title: "What stays with people",
items: [
"Decisions and exceptions",
"Anything a customer should hear from a person",
"The final say when the automation is unsure",
],
},
{
title: "What I need from you",
items: [
"Access to the tools and their interfaces (APIs)",
"Someone who knows the process as it is done today",
"Real examples to test with",
],
},
] as const;
/** The example workflows with their texts in one language. */
export const workflowsFor = (
copy: Readonly<Record<string, WorkflowCopy>>,
): readonly Workflow[] =>
LAYOUTS.flatMap((layout) => {
const text = copy[layout.id];
if (text === undefined) return [];
return [
{
...layout,
name: text.name,
summary: text.summary,
byHand: text.byHand,
automated: text.automated,
nodes: layout.nodes.map((node) => ({
...node,
...(text.nodes[node.id] ?? NO_TEXT),
})),
},
];
});
-57
View File
@@ -1,57 +0,0 @@
import type { MailDraft } from "@/src/domain/email";
/**
* Message starters for the contact section. The visitor picks what they need and
* gets a prepared email to finish in their own mail app. The square brackets are
* blanks for the visitor to fill in.
*/
export type ContactTopic = {
readonly id: string;
readonly label: string;
readonly draft: MailDraft;
};
const SIGN_OFF = "Best regards";
export const CONTACT_TOPICS: readonly ContactTopic[] = [
{
id: "developer",
label: "A developer",
draft: {
subject: "A developer for our product",
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
},
},
{
id: "rescue",
label: "A reliable app",
draft: {
subject: "Our app needs to become reliable",
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
},
},
{
id: "automation",
label: "Automation",
draft: {
subject: "We want to automate a process",
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
},
},
{
id: "website",
label: "A website",
draft: {
subject: "A website for my business",
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
},
},
{
id: "other",
label: "Something else",
draft: {
subject: "A question",
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
},
},
];
+787
View File
@@ -0,0 +1,787 @@
import type { Content } from "./types";
const SIGN_OFF = "Viele Grüße";
const GREETING = "Hallo Herr Mintel,";
const FIRST_CALL = "Ein erstes Gespräch, etwa 20 Minuten";
const GET_IN_TOUCH = "Kontakt aufnehmen";
/** The German texts of the site, in the formal "Sie". en.ts holds the same in English. */
export const de: Content = {
site: {
title: "Freelance-Webentwickler für Web-Apps & Websites | Marc Mintel",
description:
"Freelance-Webentwickler mit 15 Jahren Erfahrung. Ich baue Web-Apps, Oberflächen und Websites mit React, Next.js und TypeScript und automatisiere Abläufe.",
jobTitle: "Freelance-Webentwickler",
ogLocale: "de_DE",
shareImageAlt:
"Web-Apps und Websites. Marc Mintel, Freelance-Webentwickler mit 15 Jahren Erfahrung.",
knowsAbout: [
"Webanwendungen",
"Benutzeroberflächen",
"Websites",
"Prozessautomatisierung",
"Web3-Frontends",
],
catalogName: "Leistungen",
},
nav: {
contact: "Kontakt",
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, nach oben",
imprint: "Impressum",
privacy: "Datenschutz",
backToTop: "Nach oben",
language: "Sprache",
languageNames: { en: "English", de: "Deutsch" },
},
hero: {
headline: [
{ text: "WEB-APPS", tone: "solid" },
{ text: "& WEBSITES.", tone: "bits" },
],
lead: "Freelance-Webentwickler mit 15 Jahren Erfahrung.",
leadStrong: "Ich baue Web-Apps, Oberflächen und Websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "Was ich mache",
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
builtAndRunning: "Gebaut und in Betrieb:",
},
hello: {
label: "Hallo",
title: "Ich bin Marc.",
lead: "Ich baue seit 15 Jahren für das Web: zuerst in Agenturen, später als Senior-Entwickler und heute selbstständig.",
paragraphs: [
"Meine Stärke ist der Teil, den Menschen sehen und benutzen: Design und Oberfläche. Ich kenne mich auch mit dem aus, was dahinter liegt, und kann deshalb eine vollständige Anwendung bauen und nicht nur ihre Oberfläche.",
"Sie arbeiten direkt mit mir. Egal, ob Sie ein Produktteam leiten oder so etwas zum ersten Mal in Auftrag geben.",
],
portraitAlt: "Marc Mintel, Freelance-Webentwickler",
careerLabel: "Der Weg bisher",
career: [
{ years: "2011", role: "Einstieg ins Web", place: "Ausbildung" },
{ years: "2014 – 2020", role: "Agenturen", place: "Frontend-Entwickler" },
{
years: "2020 – 2023",
role: "Produktunternehmen",
place: "Senior-Softwareentwickler",
},
{
years: "Heute",
role: "Selbstständig",
place: "Direkte Zusammenarbeit mit Kunden",
},
],
},
services: {
label: "Leistungen",
title: { lead: "Was brauchen Sie?", rest: "Vier typische Fälle." },
tablistLabel: "Was brauchen Sie?",
youGet: "Das bekommen Sie",
howItRuns: "So läuft es ab",
whatItCosts: "Das kostet es",
paths: [
{
id: "developer",
service: "Entwicklung von Web-Apps und Frontends",
need: "Wir brauchen einen Entwickler für unser Produkt",
forWhom: "Start-ups, Produktteams, Agenturen, Web3-Teams",
headline: "Ein erfahrener Entwickler, der in Ihr Projekt einsteigt.",
intro:
"Sie haben ein Produkt und mehr Arbeit, als Ihr Team schafft. Ich arbeite an vereinbarten Wochentagen an dem, was als Nächstes auf Ihrer Liste steht: neue Funktionen, die Oberfläche oder die Teile, zu denen niemand kommt.",
youGet: [
"Web-Apps, Dashboards, Kundenportale und interne Werkzeuge",
"Oberflächen mit Blick für Design und Bedienbarkeit",
"Web3-Frontends: Wallet-Anbindung, Live-Daten, Dashboards",
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
],
steps: [
{
title: FIRST_CALL,
text: "Sie erzählen mir, was Sie bauen und wo Sie Unterstützung brauchen. Wenn ich dafür nicht der Richtige bin, sage ich das.",
},
{
title: "Wir vereinbaren die Tage",
text: "Wie viele Tage pro Woche ich für Sie arbeite und was zuerst drankommt.",
},
{
title: "Ich arbeite die Liste ab",
text: "Fertige Teile gehen zur Durchsicht an Sie, sobald sie fertig sind. Ich erfasse meine Stunden, und Sie sehen, wofür sie angefallen sind.",
},
{
title: "Zwei Wochen Kündigungsfrist",
text: "Die Vereinbarung kann mit zwei Wochen Frist zum Monatsende beendet werden.",
},
],
pricingModel: "Nach Aufwand, solange Ihr Projekt läuft",
pricingText:
"Ich arbeite an vereinbarten Wochentagen an Ihrem Projekt und rechne die Stunden ab, die ich tatsächlich gearbeitet habe. Es gibt kein Paket zu kaufen und keine Mindestlaufzeit.",
prices: [
{ label: "Pro Stunde", amount: "100 EUR", note: "netto" },
{ label: "Pro Tag", amount: "800 EUR", note: "8 Stunden, netto" },
],
smallPrint:
"Eine Rechnung pro Monat, mit den erfassten Stunden. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
cta: GET_IN_TOUCH,
},
{
id: "rescue",
service: "Code-Review und Stabilisierung von Web-Apps",
need: "Unsere App soll zuverlässig werden",
forWhom:
"Teams, deren App schnell gebaut wurde und sich jetzt schwer ändern lässt",
headline:
"Eine Woche, um Ihre App durchzusehen und das Dringendste zu beheben.",
intro:
"Die App läuft, aber Fehler kommen wieder, und kleine Änderungen dauern länger, als sie sollten. Ich nehme mir eine Woche, gehe den Code durch, behebe, was in dieser Zeit möglich ist, und schreibe auf, was ich als Nächstes tun würde.",
youGet: [
"Eine ehrliche Einschätzung: was solide ist und was wackelt",
"Automatisierte Tests für die wichtigsten Teile",
"Die dringendsten Probleme behoben, soweit eine Woche reicht",
"Eine schriftliche Liste der nächsten Schritte, in verständlicher Sprache",
],
steps: [
{
title: FIRST_CALL,
text: "Sie zeigen mir die App und erzählen, wo sie Ärger macht.",
},
{
title: "Eine Woche Arbeit",
text: "Ich lese den Code, ergänze Tests, wo sie am meisten bringen, und behebe die dringendsten Probleme.",
},
{
title: "Eine schriftliche Zusammenfassung",
text: "Was ich gefunden habe, was ich geändert habe und was ich empfehle. So geschrieben, dass Sie es nachvollziehen können, ohne selbst zu entwickeln.",
},
{
title: "Sie entscheiden, wie es weitergeht",
text: "Mit mir, mit Ihrem eigenen Team oder gar nicht.",
},
],
pricingModel: "Eine feste Woche zum festen Preis",
pricingText:
"Der Umfang ist eine Woche meiner Zeit. Sie kennen die Kosten, bevor ich anfange.",
prices: [
{
label: "Eine Woche, 35 Stunden",
amount: "3.500 EUR",
note: "netto",
},
],
smallPrint:
"Eine Woche behebt nicht alles. Wenn ich im ersten Gespräch sehe, dass sie nicht reicht, um Ihnen zu nützen, sage ich Ihnen das.",
cta: GET_IN_TOUCH,
},
{
id: "automation",
service: "Prozessautomatisierung und Systemintegration",
need: "Wir wollen einen Ablauf automatisieren",
forWhom:
"Unternehmen, in denen Menschen Daten zwischen Programmen kopieren oder dieselben Schritte wiederholen",
headline:
"Systeme, die Arbeit aneinander weitergeben, damit Menschen es nicht tun müssen.",
intro:
"Jemand exportiert hier eine Liste, tippt sie dort ein und schreibt eine E-Mail dazu. Ich verbinde die Programme, die Sie schon nutzen, sodass wiederkehrende Schritte von selbst laufen und Ihre Leute die Arbeit machen können, für die man sie braucht.",
youGet: [
"Verbindungen zwischen Ihren Systemen über deren Schnittstellen (APIs)",
"Wiederkehrende Abläufe, die ohne Handarbeit laufen",
"Wo es hilft, Schritte mit KI: sortieren, zusammenfassen, Dokumente lesen",
"Eine kurze Beschreibung, wie es funktioniert, damit es gewartet werden kann",
],
steps: [
{
title: FIRST_CALL,
text: "Sie beschreiben den Ablauf und welche Programme beteiligt sind.",
},
{
title: "Ich sehe mir an, wie es heute läuft",
text: "Schritt für Schritt, mit den Menschen, die es machen. Oft lässt sich ein Teil automatisieren, nicht alles.",
},
{
title: "Ich verbinde die Systeme",
text: "Einen Schritt nach dem anderen. Sie testen es mit echten Fällen, bevor es die Handarbeit ersetzt.",
},
{
title: "Es läuft von selbst",
text: "Sie bekommen eine Notiz dazu, was es tut und was zu prüfen ist, wenn sich eines der Programme ändert.",
},
],
pricingModel: "Erst eine Schätzung, dann ein Budget",
pricingText:
"Sobald ich den Ablauf gesehen habe, schätze ich die Stunden. Diese Schätzung wird zum Budget. Ich rechne die Stunden ab, die ich tatsächlich gebraucht habe, und sage früh Bescheid, wenn es nicht reicht.",
prices: [{ label: "Pro Stunde", amount: "100 EUR", note: "netto" }],
smallPrint:
"Wie lange es dauert, hängt vom Ablauf ab. Deshalb gibt es keinen Preis, bevor ich ihn gesehen habe. Gebühren für die Programme selbst zahlen Sie.",
cta: GET_IN_TOUCH,
},
{
id: "website",
service: "Gestaltung und Entwicklung von Websites",
need: "Ich brauche einfach eine Website",
forWhom:
"Unternehmen und Selbstständige, die sich nicht selbst darum kümmern wollen",
headline: "Eine Website, für Sie gebaut und betreut.",
intro:
"Sie wollen keinen Website-Baukasten lernen und sich nicht mit Hosting beschäftigen. Sie sagen mir, was Ihr Unternehmen macht und was die Seite erreichen soll. Ich gestalte sie, baue sie und bringe sie online.",
youGet: [
"Ein Design für Ihr Unternehmen, keine Vorlage",
"Funktioniert auf Handy, Tablet und Desktop",
"Um das Hosting kümmere ich mich, Sie betreuen keinen Server",
"Änderungen per E-Mail: Sie schreiben, was Sie brauchen, ich setze es um",
],
steps: [
{
title: FIRST_CALL,
text: "Sie erzählen mir von Ihrem Unternehmen und wofür die Website da ist.",
},
{
title: "Ein Entwurf zum Ansehen",
text: "Sie bekommen eine erste Version zum Durchklicken. Wir passen sie an, bis sie passt.",
},
{
title: "Ich baue sie und bringe sie online",
text: "Mit Ihren Texten und Bildern und einem Kontaktformular. Sie geben sie frei, bevor sie live geht.",
},
{
title: "Änderungen danach",
text: "Ein neues Foto oder ein geänderter Preis: Sie schreiben mir eine E-Mail.",
},
],
pricingModel: "Erst eine Schätzung, dann ein Budget",
pricingText:
"Nach dem ersten Gespräch bekommen Sie eine Schätzung. Sie wird Ihr Budget: Ich rechne die Stunden ab, die tatsächlich angefallen sind. Die Schätzung hält in der Regel, und wenn nicht, sage ich es Ihnen, bevor das Budget aufgebraucht ist.",
prices: [
{
label: "Eine typische Unternehmenswebsite",
amount: "ab 3.800 EUR",
note: "einmalig, netto",
},
{
label: "Alles Weitere, pro Stunde",
amount: "100 EUR",
note: "netto",
},
],
smallPrint:
"Hosting und kleine Änderungen danach kosten einen monatlichen Betrag, den wir vor dem Start vereinbaren. E-Mail-Postfächer verwalte ich nicht; sie bleiben bei Ihrem Anbieter.",
cta: GET_IN_TOUCH,
},
],
scenes: {
you: "Sie",
me: "Ich",
talkLength: "etwa 20 Minuten",
days: ["Mo", "Di", "Mi", "Do", "Fr"],
daysNote: "zum Beispiel drei Tage pro Woche",
review: "fertige Teile gehen an Sie zur Durchsicht",
week: "Woche {n}",
monthEnd: "Monatsende",
notice: "zwei Wochen Frist",
repair: "Wackliges wird getestet und behoben",
summaryParts: ["gefunden", "geändert", "als Nächstes"],
summaryNote: "so geschrieben, dass Sie folgen können",
options: ["weiter mit mir", "Ihr eigenes Team", "hier aufhören"],
online: "online",
exampleDomain: "ihrefirma.de",
firstVersion: "eine erste Version zum Durchklicken",
approve: "Sie geben frei, bevor es live geht",
changeByEmail: "Sie schreiben, ich setze es um",
tools: ["Shop", "Buchhaltung", "E-Mail", "CRM"],
byHand: "heute: von Hand kopiert",
wiring: "Schritt für Schritt verbunden",
running: "die Schritte laufen ohne Handarbeit",
},
},
automation: {
label: "Prozessautomatisierung",
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
intro:
"In den meisten Unternehmen tragen Menschen Informationen von einem Programm ins andere: hier lesen, dort eintippen, eine E-Mail dazu schreiben. Automatisierung verbindet diese Programme, sodass die Arbeit von selbst vom einen zum nächsten wandert. Drei typische Beispiele:",
examplesLabel: "Beispielabläufe",
running: "läuft",
runComplete: "Durchlauf fertig",
log: "Protokoll",
byHand: "Von Hand",
automated: "Automatisiert",
kinds: {
trigger: "Auslöser: etwas passiert",
rule: "Regel: eine feste Prüfung",
ai: "KI-Schritt: liest oder sortiert",
action: "Aktion: ein System tut etwas",
human: "Mensch: entscheidet die unklaren Fälle",
},
workflows: {
order: {
name: "Bestellung bis Rechnung",
summary:
"Im Shop geht eine neue Bestellung ein. Rechnung, Kundendatensatz und Bestätigung folgen, ohne dass jemand tippt.",
byHand:
"Jemand öffnet die Bestellung, schreibt die Rechnung, aktualisiert den Kundendatensatz und verschickt eine E-Mail.",
automated:
"Die Bestellung stößt alles an. Menschen sehen nur das fertige Ergebnis.",
nodes: {
shop: {
system: "Shop",
label: "Neue Bestellung",
log: "Bestellung eingegangen",
},
stock: {
system: "Regel",
label: "Auf Lager?",
log: "Bestand geprüft: verfügbar",
},
invoice: {
system: "Buchhaltung",
label: "Rechnung erstellen",
log: "Rechnung erstellt",
},
crm: {
system: "CRM",
label: "Kunde aktualisieren",
log: "Kundendatensatz aktualisiert",
},
mail: {
system: "E-Mail",
label: "Bestätigung senden",
log: "Bestätigung verschickt",
},
},
},
documents: {
name: "Eingehende Dokumente",
summary:
"Rechnungen kommen als PDF per E-Mail. Ein KI-Schritt liest sie, eine Regel prüft das Ergebnis, und ein Mensch sieht nur die unklaren Fälle.",
byHand:
"Jemand öffnet jedes PDF, liest Lieferant, Betrag und Datum ab und tippt sie in die Buchhaltung.",
automated:
"Klare Fälle werden direkt gebucht. Unklare gehen mit dem Dokument im Anhang an einen Menschen.",
nodes: {
inbox: {
system: "Posteingang",
label: "E-Mail mit PDF",
log: "Dokument eingegangen",
},
read: {
system: "KI-Schritt",
label: "Dokument lesen",
log: "Lieferant, Betrag und Datum ausgelesen",
},
check: { system: "Regel", label: "Plausibel?", log: "Werte geprüft" },
book: {
system: "Buchhaltung",
label: "Buchen",
log: "automatisch gebucht",
},
ask: {
system: "Ein Mensch",
label: "Prüfen, entscheiden",
log: "unklar: zur Prüfung an einen Menschen",
},
},
},
lead: {
name: "Anfrage bis Nachfassen",
summary:
"Jemand füllt das Kontaktformular aus. Der Kontakt wird angelegt, die richtige Person erfährt davon, und eine Erinnerung sorgt dafür, dass niemand es vergisst.",
byHand:
"Jemand kopiert die Anfrage ins CRM, leitet sie per E-Mail weiter und hofft, dass das Nachfassen nicht vergessen wird.",
automated:
"Die Anfrage wird abgelegt, zugewiesen und zum geplanten Zeitpunkt nachverfolgt.",
nodes: {
form: {
system: "Website",
label: "Kontaktformular",
log: "Anfrage eingegangen",
},
contact: {
system: "CRM",
label: "Kontakt anlegen",
log: "Kontakt angelegt",
},
notify: {
system: "Team-Chat",
label: "Vertrieb informieren",
log: "Vertrieb informiert",
},
wait: {
system: "Regel",
label: "Nach 3 Tagen offen?",
log: "Nachfassen eingeplant",
},
remind: {
system: "Aufgaben",
label: "Erinnerung anlegen",
log: "Erinnerung angelegt",
},
},
},
},
notes: [
{
title: "Wann es sich lohnt",
items: [
"Dieselben Schritte wiederholen sich täglich oder wöchentlich",
"Daten werden von einem Programm ins andere getippt",
"Fehler passieren, weil die Arbeit eintönig ist",
],
},
{
title: "Was bei Menschen bleibt",
items: [
"Entscheidungen und Ausnahmen",
"Alles, was ein Kunde von einem Menschen hören sollte",
"Das letzte Wort, wenn die Automatisierung unsicher ist",
],
},
{
title: "Was ich von Ihnen brauche",
items: [
"Zugang zu den Programmen und ihren Schnittstellen (APIs)",
"Jemanden, der den Ablauf so kennt, wie er heute gemacht wird",
"Echte Beispiele zum Testen",
],
},
],
},
presentations: {
label: "Web-Präsentationen",
title: { lead: "Eine Präsentation als Link.", rest: "Nicht als Anhang." },
intro:
"Die meisten Präsentationen sind noch Foliendateien: ein festes Format, verschickt als Anhang. Ich baue Präsentationen als Websites. Sie öffnen sich in jedem Browser, passen sich dem Bildschirm an, auf dem sie gezeigt werden, und können Dinge enthalten, die eine Folie nicht kann.",
compare: {
description:
"Eine Folie behält auf jedem Bildschirm ihr festes Format und wird auf dem Handy zu klein zum Lesen. Eine Web-Präsentation ordnet ihren Inhalt für jeden Bildschirm neu an.",
slides: "Foliendatei",
web: "Web-Präsentation",
pickScreen: "Gezeigt auf",
screens: ["Beamer", "Laptop", "Handy"],
slideNotes: [
"Für dieses Format gemacht",
"Passt noch",
"Verkleinert: zu klein zum Lesen",
],
webNotes: [
"Nutzt die volle Breite",
"Zwei Spalten",
"Eine Spalte, gut lesbar",
],
},
reasons: [
{
title: "Passt auf jeden Bildschirm",
text: "Eine Folie hat ein festes Format. Eine Web-Präsentation ordnet sich für Beamer, Laptop und Handy jeweils neu an.",
},
{
title: "Ein Link, ein Stand",
text: "Niemand muss fragen, welche Datei die aktuelle ist. Sie ändern es an einer Stelle, und jeder, der den Link öffnet, sieht diesen Stand.",
},
{
title: "Zeigt das Echte",
text: "Aktuelle Zahlen, eine funktionierende Demo oder ein klickbarer Prototyp können direkt in der Präsentation stehen, statt eines Screenshots davon.",
},
{
title: "Auch danach noch nützlich",
text: "Nach dem Termin ist sie eine Seite, die man allein lesen, weitergeben und wiederfinden kann.",
},
],
goodFor: {
title: "Typische Einsätze",
items: [
"Unternehmens- und Produktpräsentationen",
"Pitches und Angebote, die als Link verschickt werden",
"Berichte mit Zahlen, die sich ändern",
"Bildschirme auf Messen und Veranstaltungen",
],
},
honest: {
title: "Wann Folien reichen",
text: "Für einen einzelnen internen Termin ist eine Foliendatei schneller und günstiger. Eine Web-Präsentation lohnt sich, wenn sie oft gezeigt wird, an viele Menschen geht oder für Ihr Unternehmen steht.",
},
cta: "Nach einer Präsentation fragen",
},
technology: {
label: "Technik",
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
fit: [
{
title: "Passt gut",
items: [
"Web-Apps: Dashboards, Portale, interne Werkzeuge",
"Oberflächen, bei denen Design und Bedienbarkeit zählen",
"Unternehmenswebsites, auch in mehreren Sprachen",
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
"Automatisierung: Systeme und ihre APIs verbinden",
],
},
{
title: "Auch möglich",
items: [
"Das Backend, das eine App braucht: APIs, Login, Integrationen",
"Werkzeuge für die Kommandozeile",
"Bestehenden Code übernehmen und aufräumen",
],
},
{
title: "Nicht mein Gebiet",
items: [
"Mobile Apps für die App-Stores",
"Windows-Software",
"Serverbetrieb, spezialisierte Datenbankarbeit",
],
},
],
techLabel: "Womit ich arbeite",
tech: [
{
area: "Oberfläche",
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
},
{
area: "Dahinter",
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
},
{ area: "Web3 und Systeme", items: ["Solidity", "Rust"] },
{
area: "Auslieferung",
items: ["Automatisierte Tests", "Docker", "CI/CD"],
},
],
pipelineTitle: "Was passiert, bevor eine Änderung live geht.",
pipelineIntro:
"Software hängt zusammen: Eine Änderung an einer Stelle kann eine andere kaputt machen. Ich sichere mit automatisierten Tests ab, was ich baue, und jede Änderung muss sie bestehen, bevor sie veröffentlicht wird. So sieht ein solcher Durchlauf aus.",
pipeline: {
simulate: "Änderung simulieren in",
scenarios: {
orders: {
label: "Bestellungen",
testName: "Rechnungssumme passt zur rabattierten Bestellung",
expected: "119.00",
received: "125.00",
},
login: {
label: "Login",
testName: "Bestätigung geht an die E-Mail-Adresse des Kontos",
expected: '"anna@example.com"',
received: "undefined",
},
invoices: {
label: "Rechnungen",
testName: "Rechnungs-E-Mail hat das PDF im Anhang",
expected: "1 Anhang",
received: "0 Anhänge",
},
emails: {
label: "E-Mails",
testName:
"Bestellung ist bestätigt, sobald die E-Mail eingereiht ist",
expected: '"bestätigt"',
received: '"offen"',
},
},
stages: ["Lint", "Typen", "Tests", "Deploy"],
explanations: [
{
title: "Eine Änderung kommt herein",
text: "Im Bereich {area} wird etwas geändert. Bevor es live gehen kann, wird das ganze Projekt automatisch geprüft.",
},
{
title: "Ein Test schlägt fehl, die Freigabe ist gesperrt",
text: "Die Änderung hat an einer anderen Stelle etwas kaputt gemacht. Das fällt hier auf, bei mir, und nichts davon erreicht Ihre Nutzer.",
},
{
title: "Behoben, erneut geprüft, veröffentlicht",
text: "Ich repariere, was kaputtgegangen ist, der Durchlauf besteht, und erst dann geht die Änderung live.",
},
],
runAgain: "Erneut starten",
run: "Lauf",
fixPushed: "Korrektur eingespielt",
lint: "$ lint: prüft den Code-Stil…",
types: "$ types: prüft, ob die Teile zusammenpassen…",
tests: "Tests",
expected: "erwartet",
received: "erhalten",
runningFile: "läuft: {file}…",
summaryLabel: "Tests",
failed: "{n} fehlgeschlagen",
passed: "{n} bestanden",
of: "von {total}",
pending: "Freigabe offen",
blocked: "Freigabe gesperrt",
released: "veröffentlicht",
footnote:
"Eine Illustration mit erfundenen Dateinamen und Zahlen, einem echten Testlauf nachempfunden. Tests finden nicht jeden Fehler, aber diese Art schon.",
},
},
working: {
label: "Zusammenarbeit",
title: { lead: "So arbeite ich.", rest: "Nichts Ungewöhnliches." },
principles: [
{
title: "Ein Ansprechpartner",
text: "Sie sprechen mit mir, und ich bin derjenige, der die Arbeit macht.",
},
{
title: "Stunden, die Sie nachprüfen können",
text: "Ich erfasse meine Zeit. Die Monatsrechnung zeigt, wofür die Stunden angefallen sind.",
},
{
title: "Sorgfältige Arbeit",
text: "Ich teste, was ich baue, bevor ich es übergebe, und sage es, wenn etwas noch nicht fertig ist oder ich mir nicht sicher bin.",
},
{
title: "Was ich baue, gehört Ihnen",
text: "Code und Design gehören Ihnen. Sie können beides an einen anderen Entwickler übergeben.",
},
],
diagram: {
description:
"Oft geht eine Anfrage durch mehrere Hände, bevor sie beim Entwickler ankommt. Bei mir gibt es eine direkte Leitung zwischen Ihnen und dem, der die Arbeit macht.",
chain: ["Sie", "Vertrieb", "Manager", "Designer", "Entwickler"],
you: "Sie",
me: "Ich",
often: "oft",
withMe: "bei mir",
oftenLong: "oft: eine Anfrage wird weitergereicht",
withMeLong: "bei mir: eine direkte Leitung",
},
},
work: {
label: "Meine Arbeit",
title: {
lead: "Zwei Websites, die ich gebaut habe.",
rest: "Beide sind live.",
},
items: {
"klz-cables.com": {
client: "Kabellieferant",
text: "Die Unternehmenswebsite eines Lieferanten für Energiekabel, auf Deutsch und Englisch.",
did: [
"Produktkatalog mit Kategorien und Detailseiten",
"Datenblätter, erzeugt aus den Produktdaten",
"Inhalte, die der Kunde in einem CMS pflegen kann",
],
tech: ["Next.js", "TypeScript", "Headless CMS", "Zwei Sprachen"],
},
"e-tib.com": {
client: "Kabelnetzbau",
text: "Die Unternehmenswebsite einer Gruppe im Kabelnetzbau, auf Deutsch und Englisch.",
did: [
"Seiten für Leistungen, Referenzen, Karriere und Veranstaltungen",
"Dieselben Inhalte in zwei Sprachen",
"Funktioniert auf Handy, Tablet und Desktop",
],
tech: ["Next.js", "TypeScript", "Headless CMS", "Zwei Sprachen"],
},
},
openLive: "Live-Website öffnen",
desktopAlt: "{name} auf einem Desktop-Bildschirm",
phoneAlt: "{name} auf einem Handy",
note: "Neben der Arbeit für Kunden baue ich eigene Software, unter anderem ein System in Rust und Solidity, das auf sieben Blockchains läuft.",
},
faq: {
label: "Fragen",
title: { lead: "Gut zu wissen,", rest: "bevor wir sprechen." },
items: [
{
question: "Ich bin kein Technikmensch. Ist das ein Problem?",
answer:
"Nein. Ich erkläre in verständlicher Sprache, was ich tue, und Sie können jederzeit nachfragen. Code müssen Sie nicht lesen.",
},
{
question: "Wie lange bin ich gebunden?",
answer:
"Projektarbeit wird nach Aufwand abgerechnet. Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen.",
},
{
question: "Wem gehört, was Sie bauen?",
answer:
"Ihnen. Code und Design gehören Ihnen, und Sie können beides an einen anderen Entwickler übergeben.",
},
{
question: "Können Sie mit unserem bestehenden Team und Code arbeiten?",
answer:
"Ja. Ich habe jahrelang in Agenturen und Produktteams gearbeitet, der Einstieg in ein bestehendes Projekt ist mir vertraut.",
},
{
question: "Wie erreiche ich Sie?",
answer:
"Per E-Mail oder Videocall. Ich arbeite remote und bin zu europäischen Geschäftszeiten erreichbar.",
},
{
question: "Was machen Sie nicht?",
answer:
"Mobile Apps für die App-Stores, Windows-Software und den Betrieb Ihrer Server. Wenn Ihr Projekt das braucht, sage ich das im ersten Gespräch.",
},
],
},
contact: {
headline: [
{ text: "REDEN", tone: "bits" },
{ text: "WIR.", tone: "solid" },
],
lead: "Wählen Sie aus, was Sie brauchen, und die E-Mail schreibt sich von selbst. Oder schreiben Sie mir direkt:",
newMessage: "Neue Nachricht",
draft: "Entwurf",
need: "Ich brauche",
to: "an",
subject: "Betreff",
open: "Im E-Mail-Programm öffnen",
copy: "Adresse kopieren",
copied: "Kopiert",
// The square brackets are blanks for the visitor to fill in.
topics: [
{
id: "developer",
label: "Einen Entwickler",
draft: {
subject: "Ein Entwickler für unser Produkt",
body: `${GREETING}\n\nwir arbeiten an [was Sie bauen] und brauchen Unterstützung bei [was als Nächstes ansteht].\n\nEin erstes Gespräch würde uns um den [Datum oder Woche] passen.\n\n${SIGN_OFF}`,
},
},
{
id: "rescue",
label: "Eine zuverlässige App",
draft: {
subject: "Unsere App soll zuverlässig werden",
body: `${GREETING}\n\nunsere App [Name oder kurze Beschreibung] läuft, aber [was immer wieder schiefgeht].\n\nKönnten Sie sich das ansehen?\n\n${SIGN_OFF}`,
},
},
{
id: "automation",
label: "Automatisierung",
draft: {
subject: "Wir möchten einen Ablauf automatisieren",
body: `${GREETING}\n\nwir möchten [den Ablauf] automatisieren. Heute wird er von Hand in [beteiligte Programme] erledigt.\n\nKönnen wir darüber sprechen?\n\n${SIGN_OFF}`,
},
},
{
id: "website",
label: "Eine Website",
draft: {
subject: "Eine Website für mein Unternehmen",
body: `${GREETING}\n\nich brauche eine Website für [Ihr Unternehmen]. Sie soll [wofür sie da ist].\n\nWann könnten wir sprechen?\n\n${SIGN_OFF}`,
},
},
{
id: "other",
label: "Etwas anderes",
draft: {
subject: "Eine Frage",
body: `${GREETING}\n\n[was Sie vorhaben]\n\n${SIGN_OFF}`,
},
},
],
},
notFound: {
kicker: "Fehler 404",
title: "Seite nicht gefunden.",
text: "Diese Adresse gibt es nicht. Die Seite wurde vielleicht verschoben oder entfernt.",
home: "Zur Startseite",
},
error: {
kicker: "Fehler 500",
title: "Etwas ist schiefgelaufen.",
text: "Ein unerwartetes Problem ist aufgetreten. Es wurde protokolliert.",
retry: "Erneut versuchen",
home: "Zur Startseite",
},
};
+760
View File
@@ -0,0 +1,760 @@
import type { Content } from "./types";
const SIGN_OFF = "Best regards";
const FIRST_CALL = "A first call, about 20 minutes";
const GET_IN_TOUCH = "Get in touch";
/** The English texts of the site. de.ts holds the same in German. */
export const en: Content = {
site: {
title: "Freelance web developer for web apps & websites | Marc Mintel",
description:
"Freelance web developer with 15 years of experience. I build web apps, interfaces and websites with React, Next.js and TypeScript, and automate processes.",
jobTitle: "Freelance web developer",
ogLocale: "en_US",
shareImageAlt:
"Web apps and websites. Marc Mintel, freelance web developer with 15 years of experience.",
knowsAbout: [
"Web applications",
"User interfaces",
"Websites",
"Process automation",
"Web3 frontends",
],
catalogName: "Services",
},
nav: {
contact: GET_IN_TOUCH,
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, back to top",
imprint: "Imprint",
privacy: "Privacy",
backToTop: "Back to top",
language: "Language",
languageNames: { en: "English", de: "Deutsch" },
},
hero: {
headline: [
{ text: "WEB APPS", tone: "solid" },
{ text: "& WEBSITES.", tone: "bits" },
],
lead: "Freelance web developer with 15 years of experience.",
leadStrong: "I build web apps, interfaces and websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "What I do",
trust: ["15 years in web", "Remote, EU core hours"],
builtAndRunning: "Built and running:",
},
hello: {
label: "Hello",
title: "I am Marc.",
lead: "I have been building for the web for 15 years: first in agencies, later as a senior developer, and now on my own.",
paragraphs: [
"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.",
"You work with me directly. Whether you run a product team or are commissioning something like this for the first time.",
],
portraitAlt: "Marc Mintel, freelance web developer",
careerLabel: "The path so far",
career: [
{ years: "2011", role: "Started in the web", place: "Apprenticeship" },
{ years: "2014 – 2020", role: "Agencies", place: "Frontend developer" },
{
years: "2020 – 2023",
role: "Product companies",
place: "Senior software developer",
},
{
years: "Today",
role: "Independent",
place: "Working directly with clients",
},
],
},
services: {
label: "Services",
title: { lead: "What do you need?", rest: "Four typical cases." },
tablistLabel: "What do you need?",
youGet: "What you get",
howItRuns: "How it runs",
whatItCosts: "What it costs",
paths: [
{
id: "developer",
service: "Web app and frontend development",
need: "We need a developer for our product",
forWhom: "Startups, product teams, agencies, Web3 teams",
headline: "An experienced developer who joins your project.",
intro:
"You have a product and more work than your team can handle. I work on agreed days of the week on what is next on your list: new features, the interface, or the parts nobody gets around to.",
youGet: [
"Web apps, dashboards, customer portals and internal tools",
"Interfaces with attention to design and usability",
"Web3 frontends: wallet connection, live data, dashboards",
"Small tools and automation for recurring work",
],
steps: [
{
title: FIRST_CALL,
text: "You tell me what you are building and where you need help. If I am not the right person for it, I say so.",
},
{
title: "We agree on the days",
text: "How many days a week I work for you, and what should come first.",
},
{
title: "I work through the list",
text: "Finished parts go to you for review as they are done. I track my hours and you can see what they were spent on.",
},
{
title: "Two weeks notice",
text: "The agreement can be ended with two weeks notice to the end of a month.",
},
],
pricingModel: "By effort, for as long as your project runs",
pricingText:
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
prices: [
{ label: "Per hour", amount: "100 EUR", note: "net" },
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
],
smallPrint:
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
cta: GET_IN_TOUCH,
},
{
id: "rescue",
service: "Code review and stabilisation of web apps",
need: "Our app needs to become reliable",
forWhom: "Teams whose app was built quickly and is now hard to change",
headline: "A week to look at your app and fix what is most urgent.",
intro:
"The app works, but bugs come back and small changes take longer than they should. I take a week to go through the code, fix what I can in that time and write down what I would do next.",
youGet: [
"An honest assessment: what is solid and what is fragile",
"Automated tests for the most important parts",
"The most urgent problems fixed, as far as a week allows",
"A written list of next steps, in plain language",
],
steps: [
{
title: FIRST_CALL,
text: "You show me the app and tell me where it causes trouble.",
},
{
title: "One week of work",
text: "I read the code, add tests where they matter most and fix the most urgent problems.",
},
{
title: "A written summary",
text: "What I found, what I changed and what I recommend. Written so that you can follow it without being a developer.",
},
{
title: "You decide how to continue",
text: "With me, with your own team, or not at all.",
},
],
pricingModel: "A fixed week at a fixed price",
pricingText:
"The scope is one week of my time. You know the cost before I start.",
prices: [
{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" },
],
smallPrint:
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
cta: GET_IN_TOUCH,
},
{
id: "automation",
service: "Process automation and system integration",
need: "We want to automate a process",
forWhom:
"Companies where people copy data between tools or repeat the same steps",
headline:
"Systems that pass work on to each other, so people do not have to.",
intro:
"Someone exports a list here, types it in there and sends an email about it. I connect the tools you already use, so that recurring steps run on their own and your people can do the work that needs them.",
youGet: [
"Connections between your systems through their interfaces (APIs)",
"Recurring workflows that run without manual steps",
"Where it helps, steps handled with AI: sorting, summarising, reading documents",
"A short description of how it works, so it can be maintained",
],
steps: [
{
title: FIRST_CALL,
text: "You describe the process and which tools are involved.",
},
{
title: "I look at how it is done today",
text: "Step by step, with the people who do it. Often a part can be automated, not everything.",
},
{
title: "I connect the systems",
text: "One step at a time. You test it with real cases before it replaces the manual work.",
},
{
title: "It runs on its own",
text: "You get a note on what it does and what to check when one of the tools changes.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
smallPrint:
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
cta: GET_IN_TOUCH,
},
{
id: "website",
service: "Website design and development",
need: "I just need a website",
forWhom: "Companies and self-employed people who want it taken care of",
headline: "A website, built and looked after for you.",
intro:
"You do not want to learn a website builder or deal with hosting. You tell me what your business does and what the site should achieve. I design it, build it and put it online.",
youGet: [
"A design made for your business, not a template",
"Works on phone, tablet and desktop",
"Hosting taken care of, no server for you to manage",
"Changes by email: you write what you need, I make the change",
],
steps: [
{
title: FIRST_CALL,
text: "You tell me about your business and what the website is for.",
},
{
title: "A design to look at",
text: "You get a first version to click through. We adjust it until it fits.",
},
{
title: "I build it and put it online",
text: "With your texts and images and a contact form. You approve it before it goes live.",
},
{
title: "Changes afterwards",
text: "A new photo or a changed price: you send me an email.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
prices: [
{
label: "A typical company website",
amount: "from 3,800 EUR",
note: "one-off, net",
},
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
],
smallPrint:
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
cta: GET_IN_TOUCH,
},
],
scenes: {
you: "You",
me: "Me",
talkLength: "about 20 minutes",
days: ["Mon", "Tue", "Wed", "Thu", "Fri"],
daysNote: "for example three days a week",
review: "finished parts go to you for review",
week: "week {n}",
monthEnd: "end of month",
notice: "two weeks notice",
repair: "fragile parts get tests and fixes",
summaryParts: ["found", "changed", "next"],
summaryNote: "written so you can follow it",
options: ["continue with me", "your own team", "stop here"],
online: "online",
exampleDomain: "yourcompany.com",
firstVersion: "a first version to click through",
approve: "you approve it before it goes live",
changeByEmail: "you send an email, I make the change",
tools: ["Shop", "Accounting", "Email", "CRM"],
byHand: "today: copied by hand",
wiring: "connected one step at a time",
running: "the steps run without anyone copying",
},
},
automation: {
label: "Process automation",
title: { lead: "Let the systems", rest: "do the copying." },
intro:
"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:",
examplesLabel: "Example workflows",
running: "running",
runComplete: "run complete",
log: "Log",
byHand: "By hand",
automated: "Automated",
kinds: {
trigger: "Trigger: something happens",
rule: "Rule: a fixed check",
ai: "AI step: reads or sorts",
action: "Action: a system does something",
human: "Person: decides the unclear cases",
},
workflows: {
order: {
name: "Order to invoice",
summary:
"A new order arrives in the shop. The invoice, the customer record and the confirmation follow without anyone typing.",
byHand:
"Someone opens the order, writes the invoice, updates the customer record and sends an email.",
automated:
"The order triggers everything. People only see the finished result.",
nodes: {
shop: { system: "Shop", label: "New order", log: "order received" },
stock: {
system: "Rule",
label: "In stock?",
log: "stock checked: available",
},
invoice: {
system: "Accounting",
label: "Create invoice",
log: "invoice created",
},
crm: {
system: "CRM",
label: "Update customer",
log: "customer record updated",
},
mail: {
system: "Email",
label: "Send confirmation",
log: "confirmation sent",
},
},
},
documents: {
name: "Incoming documents",
summary:
"Invoices arrive as PDF by email. An AI step reads them, a rule checks the result, and a person only sees the unclear ones.",
byHand:
"Someone opens every PDF, reads supplier, amount and date, and types them into the accounting system.",
automated:
"Clear cases are booked directly. Unclear ones go to a person with the document attached.",
nodes: {
inbox: {
system: "Inbox",
label: "Email with PDF",
log: "document received",
},
read: {
system: "AI step",
label: "Read the document",
log: "supplier, amount and date extracted",
},
check: { system: "Rule", label: "Plausible?", log: "values checked" },
book: {
system: "Accounting",
label: "Book it",
log: "booked automatically",
},
ask: {
system: "A person",
label: "Review and decide",
log: "unclear: sent to a person for review",
},
},
},
lead: {
name: "Enquiry to follow-up",
summary:
"Someone fills in the contact form. The contact is created, the right person is told, and a reminder makes sure nobody forgets.",
byHand:
"Someone copies the enquiry into the CRM, forwards it by email and hopes the follow-up is not forgotten.",
automated:
"The enquiry is filed, assigned and followed up on schedule.",
nodes: {
form: {
system: "Website",
label: "Contact form",
log: "enquiry received",
},
contact: {
system: "CRM",
label: "Create contact",
log: "contact created",
},
notify: {
system: "Team chat",
label: "Notify sales",
log: "sales notified",
},
wait: {
system: "Rule",
label: "No reply in 3 days?",
log: "follow-up scheduled",
},
remind: {
system: "Tasks",
label: "Create reminder",
log: "reminder created",
},
},
},
},
notes: [
{
title: "Worth automating when",
items: [
"The same steps repeat every day or every week",
"Data is typed from one tool into another",
"Mistakes happen because the work is monotonous",
],
},
{
title: "What stays with people",
items: [
"Decisions and exceptions",
"Anything a customer should hear from a person",
"The final say when the automation is unsure",
],
},
{
title: "What I need from you",
items: [
"Access to the tools and their interfaces (APIs)",
"Someone who knows the process as it is done today",
"Real examples to test with",
],
},
],
},
presentations: {
label: "Web presentations",
title: { lead: "A presentation as a link.", rest: "Not as an attachment." },
intro:
"Most presentations are still slide files: one fixed format, sent around as an attachment. I build presentations as websites. They open in any browser, adapt to the screen they are shown on, and can contain things a slide cannot.",
compare: {
description:
"A slide keeps its fixed format on every screen and becomes too small to read on a phone. A web presentation rearranges its content for each screen.",
slides: "Slide file",
web: "Web presentation",
pickScreen: "Shown on",
screens: ["Projector", "Laptop", "Phone"],
slideNotes: [
"Made for this format",
"Still fits",
"Shrunk: too small to read",
],
webNotes: ["Uses the full width", "Two columns", "One column, readable"],
},
reasons: [
{
title: "Fits every screen",
text: "A slide has one fixed format. A web presentation rearranges itself for the projector, the laptop and the phone.",
},
{
title: "One link, one version",
text: "Nobody has to ask which file is the current one. You change it in one place, and everyone who opens the link sees that state.",
},
{
title: "Shows the real thing",
text: "Current figures, a working demo or a clickable prototype can sit inside the presentation, instead of a screenshot of them.",
},
{
title: "Still useful afterwards",
text: "After the meeting it is a page people can read on their own, pass on and find again.",
},
],
goodFor: {
title: "Typical uses",
items: [
"Company and product presentations",
"Pitches and offers sent as a link",
"Reports with figures that change",
"Screens at trade fairs and events",
],
},
honest: {
title: "When slides are enough",
text: "For a single internal meeting, a slide file is quicker and cheaper. A web presentation is worth it when it is shown often, sent to many people, or stands for your company.",
},
cta: "Ask about a presentation",
},
technology: {
label: "Technology",
title: { lead: "Which projects", rest: "I am right for." },
fit: [
{
title: "A good fit",
items: [
"Web apps: dashboards, portals, internal tools",
"Interfaces where design and usability matter",
"Company websites, also in several languages",
"Web3 frontends: wallets, live data, dashboards",
"Automation: connecting systems and their APIs",
],
},
{
title: "Also possible",
items: [
"The backend an app needs: APIs, login, integrations",
"Command-line tools",
"Taking over and tidying up existing code",
],
},
{
title: "Not my field",
items: [
"Mobile apps for the app stores",
"Windows software",
"Running servers, specialised database work",
],
},
],
techLabel: "What I work with",
tech: [
{
area: "Interface",
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
},
{
area: "Behind it",
items: ["Node.js", "REST APIs", "PostgreSQL", "Headless CMS"],
},
{ area: "Web3 and systems", items: ["Solidity", "Rust"] },
{ area: "Delivery", items: ["Automated tests", "Docker", "CI/CD"] },
],
pipelineTitle: "What happens before a change goes live.",
pipelineIntro:
"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.",
pipeline: {
simulate: "Simulate a change in",
scenarios: {
orders: {
label: "Orders",
testName: "invoice total matches the discounted order",
expected: "119.00",
received: "125.00",
},
login: {
label: "Login",
testName: "confirmation goes to the account's email address",
expected: '"anna@example.com"',
received: "undefined",
},
invoices: {
label: "Invoices",
testName: "invoice email attaches the PDF",
expected: "1 attachment",
received: "0 attachments",
},
emails: {
label: "Emails",
testName: "order is confirmed once the email is queued",
expected: '"confirmed"',
received: '"pending"',
},
},
stages: ["Lint", "Types", "Tests", "Deploy"],
explanations: [
{
title: "A change comes in",
text: "Something is changed in {area}. Before it can go live, the whole project is checked automatically.",
},
{
title: "A test fails, the release is blocked",
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
},
{
title: "Fixed, checked again, released",
text: "I repair what broke, the run passes, and only then does the change go live.",
},
],
runAgain: "Run again",
run: "run",
fixPushed: "fix pushed",
lint: "$ lint: checking the code style…",
types: "$ types: checking that the parts fit together…",
tests: "tests",
expected: "expected",
received: "received",
runningFile: "running {file}…",
summaryLabel: "Tests",
failed: "{n} failed",
passed: "{n} passed",
of: "of {total}",
pending: "release pending",
blocked: "release blocked",
released: "released",
footnote:
"An illustration with made-up file names and numbers, modelled on a real test run. Tests do not catch every mistake, but they do catch this kind.",
},
},
working: {
label: "Working with me",
title: { lead: "How I work.", rest: "Nothing unusual." },
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.",
},
],
diagram: {
description:
"Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.",
chain: ["You", "Sales", "Manager", "Designer", "Developer"],
you: "You",
me: "Me",
often: "often",
withMe: "with me",
oftenLong: "often: a request is passed along",
withMeLong: "with me: one direct line",
},
},
work: {
label: "My work",
title: { lead: "Two sites I built.", rest: "Both are live." },
items: {
"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"],
},
"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"],
},
},
openLive: "Open the live site",
desktopAlt: "{name} on a desktop screen",
phoneAlt: "{name} on a phone",
note: "Besides client work I build my own software, among other things a system in Rust and Solidity that runs on seven blockchains.",
},
faq: {
label: "Questions",
title: { lead: "Good to know", rest: "before we talk." },
items: [
{
question: "I am not technical. Is that a problem?",
answer:
"No. I explain what I am doing in plain language, and you can ask at any time. You do not need to read code.",
},
{
question: "How long am I committed?",
answer:
"Project work is billed by effort. Either side can end it with two weeks notice to the end of a month.",
},
{
question: "Who owns what you build?",
answer:
"You do. Code and design belong to you, and you can hand them to another developer.",
},
{
question: "Can you work with our existing team and code?",
answer:
"Yes. I worked in agencies and product teams for years, so joining an existing project is familiar to me.",
},
{
question: "How do I reach you?",
answer:
"By email or video call. I work remotely and am available during European business hours.",
},
{
question: "What do you not do?",
answer:
"Mobile apps for the app stores, Windows software and running your servers. If your project needs those, I say so in the first call.",
},
],
},
contact: {
headline: [
{ text: "LET'S", tone: "bits" },
{ text: "TALK.", tone: "solid" },
],
lead: "Pick what you need and the email writes itself. Or write to me directly:",
newMessage: "New message",
draft: "draft",
need: "I need",
to: "to",
subject: "subject",
open: "Open in my mail app",
copy: "Copy address",
copied: "Copied",
// The square brackets are blanks for the visitor to fill in.
topics: [
{
id: "developer",
label: "A developer",
draft: {
subject: "A developer for our product",
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
},
},
{
id: "rescue",
label: "A reliable app",
draft: {
subject: "Our app needs to become reliable",
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
},
},
{
id: "automation",
label: "Automation",
draft: {
subject: "We want to automate a process",
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
},
},
{
id: "website",
label: "A website",
draft: {
subject: "A website for my business",
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
},
},
{
id: "other",
label: "Something else",
draft: {
subject: "A question",
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
},
},
],
},
notFound: {
kicker: "Error 404",
title: "Page not found.",
text: "This address does not exist. The page may have been moved or removed.",
home: "Back to the homepage",
},
error: {
kicker: "Error 500",
title: "Something went wrong.",
text: "An unexpected problem occurred. It has been logged.",
retry: "Try again",
home: "Back to the homepage",
},
};
+9
View File
@@ -0,0 +1,9 @@
import type { Locale } from "@/src/domain/locale";
import { de } from "./de";
import { en } from "./en";
import type { Content } from "./types";
const CONTENT: Readonly<Record<Locale, Content>> = { en, de };
/** The texts of the site in one language. */
export const contentFor = (locale: Locale): Content => CONTENT[locale];
+93
View File
@@ -0,0 +1,93 @@
import { EmailLink } from "@/src/components/EmailLink";
import { LegalSection } from "@/src/components/LegalPage";
import { OWNER } from "@/src/content/site";
import type { Locale } from "@/src/domain/locale";
import type { LegalText } from "./types";
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
const Address: React.FC<{ country: string }> = ({ country }) => (
<p>
{OWNER.name}
<br />
{OWNER.street}
<br />
{OWNER.postalCode} {OWNER.locality}
<br />
{country}
</p>
);
/** The imprint (section 5 DDG) in both languages. */
export const IMPRINT: Readonly<Record<Locale, LegalText>> = {
de: {
title: "Impressum",
description: "Impressum von mintel.me.",
Body: () => (
<>
<LegalSection heading="Angaben gemäß § 5 DDG">
<Address country="Deutschland" />
</LegalSection>
<LegalSection heading="Kontakt">
<p>
E-Mail: <EmailLink className={LINK} />
</p>
</LegalSection>
<LegalSection heading="Umsatzsteuer-Identifikationsnummer">
<p>
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz:{" "}
{OWNER.vatId}
</p>
</LegalSection>
<LegalSection heading="Verantwortlich für den Inhalt">
<p>{OWNER.name}, Anschrift wie oben (§ 18 Abs. 2 MStV).</p>
</LegalSection>
<LegalSection heading="Verbraucherstreitbeilegung">
<p>
Ich bin weder bereit noch verpflichtet, an Streitbeilegungsverfahren
vor einer Verbraucherschlichtungsstelle teilzunehmen.
</p>
</LegalSection>
</>
),
},
en: {
title: "Imprint",
description: "Legal notice (Impressum) for mintel.me.",
Body: () => (
<>
<LegalSection heading="Information according to section 5 DDG">
<Address country="Germany" />
</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>
</>
),
},
};
+235
View File
@@ -0,0 +1,235 @@
import { EmailLink } from "@/src/components/EmailLink";
import { LegalSection } from "@/src/components/LegalPage";
import { OWNER } from "@/src/content/site";
import { LOCALE_COOKIE, type Locale } from "@/src/domain/locale";
import type { LegalText } from "./types";
const LINK = "font-bold text-slate-900 underline-offset-4 hover:underline";
const address = (country: string): string =>
`${OWNER.name}, ${OWNER.street}, ${OWNER.postalCode} ${OWNER.locality}, ${country}.`;
/**
* The privacy notice in both languages. It describes what the code actually
* processes; keep it in step with the code (the language cookie is set by
* LanguageSwitch, the detection happens in proxy.ts).
*/
export const PRIVACY: Readonly<Record<Locale, LegalText>> = {
de: {
title: "Datenschutz",
description: "Wie mintel.me mit personenbezogenen Daten umgeht.",
Body: () => (
<>
<LegalSection heading="Verantwortlicher">
<p>
{address("Deutschland")}
<br />
E-Mail: <EmailLink className={LINK} />
</p>
</LegalSection>
<LegalSection heading="Kurz gesagt">
<p>
Diese Website zeigt keine Werbung und bindet keine Inhalte von
Dritten ein. Schriften und Bilder werden von dieser Website selbst
ausgeliefert. Ein Cookie wird nur gesetzt, wenn Sie die Sprache
umschalten (siehe „Sprache“).
</p>
</LegalSection>
<LegalSection heading="Server-Protokolle">
<p>
Wenn Sie eine Seite öffnen, verarbeitet der Server technische Daten,
die Ihr Browser sendet: Ihre IP-Adresse, den Zeitpunkt, die
angeforderte Seite und den Browsertyp. Das ist nötig, um die Website
auszuliefern und sicher zu betreiben. Rechtsgrundlage ist mein
berechtigtes Interesse am Betrieb der Website (Art. 6 Abs. 1 lit. f
DSGVO).
</p>
</LegalSection>
<LegalSection heading="Sprache">
<p>
Die Website gibt es auf Deutsch und Englisch. Welche Sprache Sie
zuerst sehen, richtet sich nach der Spracheinstellung, die Ihr
Browser bei jedem Seitenaufruf mitsendet. Sie wird dafür nicht
gespeichert.
</p>
<p>
Wenn Sie die Sprache selbst umschalten, speichert die Website Ihre
Wahl in einem Cookie namens „{LOCALE_COOKIE}“, damit Sie nicht
wieder zurückgeleitet werden. Es enthält nur das Sprachkürzel („de“
oder „en“), gilt ein Jahr und wird für nichts anderes verwendet. Es
ist für die von Ihnen gewünschte Funktion erforderlich (§ 25 Abs. 2
Nr. 2 TDDDG).
</p>
</LegalSection>
<LegalSection heading="Besucherstatistik">
<p>
Ich zähle Seitenaufrufe mit Umami, das ich auf meinem eigenen Server
betreibe. Es arbeitet ohne Cookies und erstellt keine Profile
einzelner Besucher. Die Daten werden nicht an Dritte weitergegeben.
Rechtsgrundlage ist mein berechtigtes Interesse zu verstehen, wie
die Website genutzt wird (Art. 6 Abs. 1 lit. f DSGVO).
</p>
</LegalSection>
<LegalSection heading="Fehlerberichte">
<p>
Wenn auf dieser Website technisch etwas fehlschlägt, wird ein
Fehlerbericht an einen Dienst zur Fehlererfassung gesendet, den ich
auf meinem eigenen Server betreibe. Er enthält technische Angaben
wie die Seite, den Browser und die Fehlermeldung. Sitzungen werden
nicht aufgezeichnet. Rechtsgrundlage ist mein berechtigtes Interesse
an einer funktionierenden Website (Art. 6 Abs. 1 lit. f DSGVO).
</p>
</LegalSection>
<LegalSection heading="Speicherung in Ihrem Browser">
<p>
Wenn das Laden einer Seite fehlschlägt, speichert die Website einen
Zeitstempel im Session Storage Ihres Browsers, damit sie nur einmal
neu lädt. Er wird gelöscht, wenn Sie den Tab schließen, und
nirgendwohin übertragen.
</p>
</LegalSection>
<LegalSection heading="Wenn Sie mir schreiben">
<p>
Wenn Sie mir eine E-Mail senden, verarbeite ich Ihre Nachricht und
Ihre Adresse, um Ihnen zu antworten und gegebenenfalls einen Vertrag
vorzubereiten (Art. 6 Abs. 1 lit. b DSGVO). Ich bewahre die
Korrespondenz so lange auf, wie es dafür nötig ist oder das Gesetz
es verlangt.
</p>
</LegalSection>
<LegalSection heading="Ihre Rechte">
<p>Sie haben das Recht,</p>
<ul>
<li>
Auskunft darüber zu verlangen, welche Daten ich über Sie habe
</li>
<li>unrichtige Daten berichtigen zu lassen</li>
<li>
Ihre Daten löschen oder deren Verarbeitung einschränken zu lassen
</li>
<li>
einer Verarbeitung zu widersprechen, die auf berechtigtem
Interesse beruht
</li>
<li>Ihre Daten in einem übertragbaren Format zu erhalten</li>
<li>sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren</li>
</ul>
<p>
Um eines dieser Rechte auszuüben, schreiben Sie an{" "}
<EmailLink className={LINK} />.
</p>
</LegalSection>
</>
),
},
en: {
title: "Privacy",
description: "How mintel.me handles personal data.",
Body: () => (
<>
<LegalSection heading="Who is responsible">
<p>
{address("Germany")}
<br />
Email: <EmailLink className={LINK} />
</p>
</LegalSection>
<LegalSection heading="In short">
<p>
This website shows no advertising and includes no content from third
parties. Fonts and images are served from this website itself. A
cookie is only set when you switch the language (see
&quot;Language&quot;).
</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="Language">
<p>
The website exists in German and English. Which language you see
first depends on the language setting your browser sends with every
page request. It is not stored for that.
</p>
<p>
If you switch the language yourself, the website stores your choice
in a cookie named &quot;{LOCALE_COOKIE}&quot; so that you are not
sent back. It contains only the language code (&quot;de&quot; or
&quot;en&quot;), lasts one year and is used for nothing else. It is
necessary for the function you asked for (section 25(2) no. 2
TDDDG).
</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>
</>
),
},
};
+6
View File
@@ -0,0 +1,6 @@
/** A legal page in one language: its title, its description and its text. */
export type LegalText = {
readonly title: string;
readonly description: string;
readonly Body: React.FC;
};
-203
View File
@@ -1,203 +0,0 @@
/**
* The three ways a visitor can work with me, written for the visitor, not for engineers.
* Each path answers the same four questions: what you get, how it runs, what it costs, what to do next.
*/
export type PathStep = {
readonly title: string;
readonly text: string;
};
export type PathPrice = {
readonly label: string;
readonly amount: string;
readonly note: string;
};
export type ServicePath = {
readonly id: string;
/** How the visitor would say it. */
readonly need: string;
readonly forWhom: string;
readonly headline: string;
readonly intro: string;
readonly youGet: readonly string[];
readonly steps: readonly PathStep[];
/** How this kind of work is priced, in one line, and why. */
readonly pricingModel: string;
readonly pricingText: string;
readonly prices: readonly PathPrice[];
readonly smallPrint: string;
readonly cta: string;
};
export const SERVICE_PATHS: readonly ServicePath[] = [
{
id: "developer",
need: "We need a developer for our product",
forWhom: "Startups, product teams, agencies, Web3 teams",
headline: "An experienced developer who joins your project.",
intro:
"You have a product and more work than your team can handle. I work on agreed days of the week on what is next on your list: new features, the interface, or the parts nobody gets around to.",
youGet: [
"Web apps, dashboards, customer portals and internal tools",
"Interfaces with attention to design and usability",
"Web3 frontends: wallet connection, live data, dashboards",
"Small tools and automation for recurring work",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You tell me what you are building and where you need help. If I am not the right person for it, I say so.",
},
{
title: "We agree on the days",
text: "How many days a week I work for you, and what should come first.",
},
{
title: "I work through the list",
text: "Finished parts go to you for review as they are done. I track my hours and you can see what they were spent on.",
},
{
title: "Two weeks notice",
text: "The agreement can be ended with two weeks notice to the end of a month.",
},
],
pricingModel: "By effort, for as long as your project runs",
pricingText:
"I work on your project on agreed days of the week and bill the hours I actually worked. There is no package to buy and no minimum term.",
prices: [
{ label: "Per hour", amount: "100 EUR", note: "net" },
{ label: "Per day", amount: "800 EUR", note: "8 hours, net" },
],
smallPrint:
"One invoice per month, with the tracked hours. Either side can end it with two weeks notice to the end of a month.",
cta: "Get in touch",
},
{
id: "rescue",
need: "Our app needs to become reliable",
forWhom: "Teams whose app was built quickly and is now hard to change",
headline: "A week to look at your app and fix what is most urgent.",
intro:
"The app works, but bugs come back and small changes take longer than they should. I take a week to go through the code, fix what I can in that time and write down what I would do next.",
youGet: [
"An honest assessment: what is solid and what is fragile",
"Automated tests for the most important parts",
"The most urgent problems fixed, as far as a week allows",
"A written list of next steps, in plain language",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You show me the app and tell me where it causes trouble.",
},
{
title: "One week of work",
text: "I read the code, add tests where they matter most and fix the most urgent problems.",
},
{
title: "A written summary",
text: "What I found, what I changed and what I recommend. Written so that you can follow it without being a developer.",
},
{
title: "You decide how to continue",
text: "With me, with your own team, or not at all.",
},
],
pricingModel: "A fixed week at a fixed price",
pricingText:
"The scope is one week of my time. You know the cost before I start.",
prices: [{ label: "One week, 35 hours", amount: "3,500 EUR", note: "net" }],
smallPrint:
"A week does not fix everything. If I see in the first call that it will not be enough to be useful, I tell you.",
cta: "Get in touch",
},
{
id: "automation",
need: "We want to automate a process",
forWhom:
"Companies where people copy data between tools or repeat the same steps",
headline:
"Systems that pass work on to each other, so people do not have to.",
intro:
"Someone exports a list here, types it in there and sends an email about it. I connect the tools you already use, so that recurring steps run on their own and your people can do the work that needs them.",
youGet: [
"Connections between your systems through their interfaces (APIs)",
"Recurring workflows that run without manual steps",
"Where it helps, steps handled with AI: sorting, summarising, reading documents",
"A short description of how it works, so it can be maintained",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You describe the process and which tools are involved.",
},
{
title: "I look at how it is done today",
text: "Step by step, with the people who do it. Often a part can be automated, not everything.",
},
{
title: "I connect the systems",
text: "One step at a time. You test it with real cases before it replaces the manual work.",
},
{
title: "It runs on its own",
text: "You get a note on what it does and what to check when one of the tools changes.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"Once I have seen the process I estimate the hours. That estimate becomes the budget. I bill the hours I actually needed and tell you early if it will not be enough.",
prices: [{ label: "Per hour", amount: "100 EUR", note: "net" }],
smallPrint:
"How long it takes depends on the process, so there is no price before I have seen it. Fees for the tools themselves are paid by you.",
cta: "Get in touch",
},
{
id: "website",
need: "I just need a website",
forWhom: "Companies and self-employed people who want it taken care of",
headline: "A website, built and looked after for you.",
intro:
"You do not want to learn a website builder or deal with hosting. You tell me what your business does and what the site should achieve. I design it, build it and put it online.",
youGet: [
"A design made for your business, not a template",
"Works on phone, tablet and desktop",
"Hosting taken care of, no server for you to manage",
"Changes by email: you write what you need, I make the change",
],
steps: [
{
title: "A first call, about 20 minutes",
text: "You tell me about your business and what the website is for.",
},
{
title: "A design to look at",
text: "You get a first version to click through. We adjust it until it fits.",
},
{
title: "I build it and put it online",
text: "With your texts and images and a contact form. You approve it before it goes live.",
},
{
title: "Changes afterwards",
text: "A new photo or a changed price: you send me an email.",
},
],
pricingModel: "An estimate first, then a budget",
pricingText:
"After the first call you get an estimate. It becomes your budget: I bill the hours actually used. The estimate usually holds, and if it will not, I tell you before the budget is used up.",
prices: [
{
label: "A typical company website",
amount: "from 3,800 EUR",
note: "one-off, net",
},
{ label: "Anything extra, per hour", amount: "100 EUR", note: "net" },
],
smallPrint:
"Hosting and small changes afterwards cost a monthly fee that we agree on before we start. I do not manage email inboxes; they stay with your provider.",
cta: "Get in touch",
},
];
+15 -7
View File
@@ -1,13 +1,20 @@
import { parseEmailParts, type EmailParts } from "@/src/domain/email";
import { indexingFor } from "@/src/domain/indexing";
// What is the same in every language. Titles, descriptions and all other texts
// live in en.ts and de.ts.
export const SITE_NAME = "Marc Mintel";
export const SITE_URL = "https://mintel.me";
/** One wording for the browser title, search results and link previews. */
export const SITE_TITLE =
"Marc Mintel — Freelance developer for web apps and websites";
export const SITE_DESCRIPTION =
"Freelance developer with 15 years of experience. I build web apps, interfaces and websites, and automate processes between your systems.";
/**
* Public profiles of the owner (LinkedIn, GitHub, ...). Search engines use them to
* connect this site with the same person elsewhere. Only add profiles the owner confirmed.
*/
export const PROFILES: readonly string[] = [];
/** Only the production deployment may appear in search results. Set by the build. */
export const INDEXING = indexingFor(process.env.NEXT_PUBLIC_TARGET);
// The contact address, kept in parts on purpose: the complete address must never
// appear as one string in the page source (see src/components/EmailLink.tsx).
@@ -27,7 +34,8 @@ export const CONTACT_EMAIL_PARTS: EmailParts = parsedEmail.value;
export const OWNER = {
name: "Marc Mintel",
street: "Georg-Meistermann-Straße 7",
city: "54586 Schüller",
country: "Germany",
postalCode: "54586",
locality: "Schüller",
countryCode: "DE",
vatId: "DE367588065",
} as const;
+324
View File
@@ -0,0 +1,324 @@
import type { MailDraft } from "@/src/domain/email";
import type { Locale } from "@/src/domain/locale";
/**
* Everything a visitor can read, as plain data. There is one `Content` per
* language (see en.ts and de.ts); components take their texts from it and hold
* none of their own. Blanks such as "{name}" are filled with `fill` from
* src/domain/template.ts.
*/
/** A headline in two tones: the lead in full ink, the rest muted. */
export type SplitTitle = {
readonly lead: string;
readonly rest: string;
};
export type HeadlineLine = {
readonly text: string;
readonly tone: "solid" | "bits";
};
export type TitledList = {
readonly title: string;
readonly items: readonly string[];
};
export type TitledText = {
readonly title: string;
readonly text: string;
};
export type CareerStation = {
readonly years: string;
readonly role: string;
readonly place: string;
};
export type PathPrice = {
readonly label: string;
readonly amount: string;
readonly note: string;
};
export type ServicePathId = "developer" | "rescue" | "automation" | "website";
export type ServicePath = {
readonly id: ServicePathId;
/** The service in neutral words, as search engines list it. */
readonly service: string;
/** How the visitor would say it. */
readonly need: string;
readonly forWhom: string;
readonly headline: string;
readonly intro: string;
readonly youGet: readonly string[];
readonly steps: readonly TitledText[];
/** How this kind of work is priced, in one line, and why. */
readonly pricingModel: string;
readonly pricingText: string;
readonly prices: readonly PathPrice[];
readonly smallPrint: string;
readonly cta: string;
};
/** The short labels inside the drawings that explain the steps of a path. */
export type SceneLabels = {
readonly you: string;
readonly me: string;
readonly talkLength: string;
readonly days: readonly string[];
readonly daysNote: string;
readonly review: string;
/** Has the blank {n}. */
readonly week: string;
readonly monthEnd: string;
readonly notice: string;
readonly repair: string;
readonly summaryParts: readonly string[];
readonly summaryNote: string;
readonly options: readonly string[];
readonly online: string;
readonly exampleDomain: string;
readonly firstVersion: string;
readonly approve: string;
readonly changeByEmail: string;
readonly tools: readonly string[];
readonly byHand: string;
readonly wiring: string;
readonly running: string;
};
export type NodeKind = "trigger" | "rule" | "ai" | "action" | "human";
export type FlowNodeCopy = {
readonly system: string;
readonly label: string;
/** What appears in the log when a run reaches this node. */
readonly log: string;
};
export type WorkflowCopy = {
readonly name: string;
readonly summary: string;
readonly byHand: string;
readonly automated: string;
/** Texts of the nodes, by node id (see src/content/automation.ts). */
readonly nodes: Readonly<Record<string, FlowNodeCopy>>;
};
export type PipelineScenarioCopy = {
readonly label: string;
readonly testName: string;
readonly expected: string;
readonly received: string;
};
export type WorkCopy = {
readonly client: string;
readonly text: string;
readonly did: readonly string[];
readonly tech: readonly string[];
};
export type FaqItem = {
readonly question: string;
readonly answer: string;
};
export type ContactTopic = {
readonly id: string;
readonly label: string;
readonly draft: MailDraft;
};
export type Content = {
readonly site: {
/** One wording for the browser title, search results and link previews. */
readonly title: string;
readonly description: string;
readonly jobTitle: string;
/** Open Graph form of the language, such as en_US. */
readonly ogLocale: string;
readonly shareImageAlt: string;
readonly knowsAbout: readonly string[];
readonly catalogName: string;
};
readonly nav: {
readonly contact: string;
readonly contactLong: string;
readonly logoLabel: string;
readonly imprint: string;
readonly privacy: string;
readonly backToTop: string;
readonly language: string;
/** Each language in its own words, whatever the page language is. */
readonly languageNames: Readonly<Record<Locale, string>>;
};
readonly hero: {
readonly headline: readonly HeadlineLine[];
readonly lead: string;
readonly leadStrong: string;
readonly primaryCta: string;
readonly secondaryCta: string;
readonly trust: readonly string[];
readonly builtAndRunning: string;
};
readonly hello: {
readonly label: string;
readonly title: string;
readonly lead: string;
readonly paragraphs: readonly string[];
readonly portraitAlt: string;
readonly careerLabel: string;
readonly career: readonly CareerStation[];
};
readonly services: {
readonly label: string;
readonly title: SplitTitle;
readonly tablistLabel: string;
readonly youGet: string;
readonly howItRuns: string;
readonly whatItCosts: string;
readonly paths: readonly ServicePath[];
readonly scenes: SceneLabels;
};
readonly automation: {
readonly label: string;
readonly title: SplitTitle;
readonly intro: string;
readonly examplesLabel: string;
readonly running: string;
readonly runComplete: string;
readonly log: string;
readonly byHand: string;
readonly automated: string;
readonly kinds: Readonly<Record<NodeKind, string>>;
/** Texts of the example workflows, by workflow id. */
readonly workflows: Readonly<Record<string, WorkflowCopy>>;
readonly notes: readonly TitledList[];
};
readonly presentations: {
readonly label: string;
readonly title: SplitTitle;
readonly intro: string;
readonly compare: {
/** What the drawing shows, for screen readers. */
readonly description: string;
readonly slides: string;
readonly web: string;
readonly pickScreen: string;
/** Projector, laptop, phone: in this order. */
readonly screens: readonly string[];
/** What happens to the slide on each screen, in the same order. */
readonly slideNotes: readonly string[];
readonly webNotes: readonly string[];
};
/** Every screen, one link, real content, useful afterwards: in this order. */
readonly reasons: readonly TitledText[];
readonly goodFor: TitledList;
readonly honest: TitledText;
readonly cta: string;
};
readonly technology: {
readonly label: string;
readonly title: SplitTitle;
/** A good fit, also possible, not my field: in this order. */
readonly fit: readonly TitledList[];
readonly techLabel: string;
readonly tech: readonly {
readonly area: string;
readonly items: readonly string[];
}[];
readonly pipelineTitle: string;
readonly pipelineIntro: string;
readonly pipeline: {
readonly simulate: string;
/** Texts of the scenarios, by scenario id (see PipelineRun). */
readonly scenarios: Readonly<Record<string, PipelineScenarioCopy>>;
readonly stages: readonly string[];
/** Three explanations; the first has the blank {area}. */
readonly explanations: readonly TitledText[];
readonly runAgain: string;
readonly run: string;
readonly fixPushed: string;
readonly lint: string;
readonly types: string;
readonly tests: string;
readonly expected: string;
readonly received: string;
/** Has the blank {file}. */
readonly runningFile: string;
readonly summaryLabel: string;
/** Has the blank {n}. */
readonly failed: string;
/** Has the blank {n}. */
readonly passed: string;
/** Has the blank {total}. */
readonly of: string;
readonly pending: string;
readonly blocked: string;
readonly released: string;
readonly footnote: string;
};
};
readonly working: {
readonly label: string;
readonly title: SplitTitle;
readonly principles: readonly TitledText[];
readonly diagram: {
readonly description: string;
/** The hands a request passes through, starting with the client. */
readonly chain: readonly string[];
readonly you: string;
readonly me: string;
readonly often: string;
readonly withMe: string;
readonly oftenLong: string;
readonly withMeLong: string;
};
};
readonly work: {
readonly label: string;
readonly title: SplitTitle;
/** Texts of the shown sites, by their name (see src/content/work.ts). */
readonly items: Readonly<Record<string, WorkCopy>>;
readonly openLive: string;
/** Has the blank {name}. */
readonly desktopAlt: string;
/** Has the blank {name}. */
readonly phoneAlt: string;
readonly note: string;
};
readonly faq: {
readonly label: string;
readonly title: SplitTitle;
readonly items: readonly FaqItem[];
};
readonly contact: {
readonly headline: readonly HeadlineLine[];
readonly lead: string;
readonly newMessage: string;
readonly draft: string;
readonly need: string;
readonly to: string;
readonly subject: string;
readonly open: string;
readonly copy: string;
readonly copied: string;
readonly topics: readonly ContactTopic[];
};
readonly notFound: {
readonly kicker: string;
readonly title: string;
readonly text: string;
readonly home: string;
};
readonly error: {
readonly kicker: string;
readonly title: string;
readonly text: string;
readonly retry: string;
readonly home: string;
};
};
+30
View File
@@ -0,0 +1,30 @@
/** The sites shown in the work section. Their texts are in en.ts and de.ts, by name. */
export type WorkSite = {
readonly name: string;
readonly href: string;
readonly desktop: {
readonly src: string;
readonly width: number;
readonly height: number;
};
readonly mobile: {
readonly src: string;
readonly width: number;
readonly height: number;
};
};
export const WORK_SITES: readonly WorkSite[] = [
{
name: "klz-cables.com",
href: "https://klz-cables.com",
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",
desktop: { src: "/work/etib-long.webp", width: 1200, height: 4333 },
mobile: { src: "/work/etib-mobile.webp", width: 390, height: 3600 },
},
];
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { indexingFor, robotsFile, robotsMeta } from "./indexing";
describe("indexingFor", () => {
it("lets search engines index the production deployment", () => {
expect(indexingFor("production")).toBe("indexable");
});
it.each(["testing", "staging", "branch"])(
"blocks the %s deployment, which is a copy of the real site",
(target) => {
expect(indexingFor(target)).toBe("blocked");
},
);
// A production build that lost its target must not drop out of the search index.
it.each([
["a missing target", undefined],
["an empty target", ""],
["an unknown target", "preview"],
["a target in another case", "Testing"],
["a value that is not a string", 1],
])("stays indexable for %s", (_case, target) => {
expect(indexingFor(target)).toBe("indexable");
});
});
describe("robotsFile", () => {
it("allows everything except the analytics and error proxies, and names the sitemap", () => {
expect(robotsFile("indexable", "https://mintel.me")).toEqual({
rules: { userAgent: "*", allow: "/", disallow: ["/stats/", "/errors/"] },
sitemap: "https://mintel.me/sitemap.xml",
});
});
it("disallows everything on a blocked deployment and names no sitemap", () => {
expect(robotsFile("blocked", "https://mintel.me")).toEqual({
rules: { userAgent: "*", disallow: "/" },
});
});
});
describe("robotsMeta", () => {
it("allows indexing with full snippets and large image previews", () => {
expect(robotsMeta("indexable")).toEqual({
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-snippet": -1,
"max-image-preview": "large",
"max-video-preview": -1,
},
});
});
it("forbids indexing and following on a blocked deployment", () => {
expect(robotsMeta("blocked")).toEqual({ index: false, follow: false });
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Whether search engines may index a deployment. Only the production site belongs
* in search results; the testing, staging and branch deployments are copies of it.
*/
export type Indexing = "indexable" | "blocked";
export type RobotsFile = {
readonly rules: {
readonly userAgent: string;
readonly allow?: string;
readonly disallow: string | readonly string[];
};
readonly sitemap?: string;
};
export type RobotsMeta = {
readonly index: boolean;
readonly follow: boolean;
readonly googleBot?: {
readonly index: boolean;
readonly follow: boolean;
readonly "max-snippet": number;
readonly "max-image-preview": "large";
readonly "max-video-preview": number;
};
};
const BLOCKED_TARGETS: readonly unknown[] = ["testing", "staging", "branch"];
/** Paths that proxy analytics and error reports; nothing there is a page. */
const PROXY_PATHS: readonly string[] = ["/stats/", "/errors/"];
/**
* Only the deployments known to be copies are blocked. Anything else stays
* indexable, so a production build that lost its target does not drop out of
* the search index.
*/
export function indexingFor(target: unknown): Indexing {
return BLOCKED_TARGETS.includes(target) ? "blocked" : "indexable";
}
export function robotsFile(indexing: Indexing, siteUrl: string): RobotsFile {
if (indexing === "blocked") {
return { rules: { userAgent: "*", disallow: "/" } };
}
return {
rules: { userAgent: "*", allow: "/", disallow: PROXY_PATHS },
sitemap: `${siteUrl}/sitemap.xml`,
};
}
/** The robots meta tag. Google gets no limit on snippet length or image preview size. */
export function robotsMeta(indexing: Indexing): RobotsMeta {
if (indexing === "blocked") return { index: false, follow: false };
return {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-snippet": -1,
"max-image-preview": "large",
"max-video-preview": -1,
},
};
}
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_LOCALE,
LOCALES,
LOCALE_COOKIE,
localeCookie,
negotiateLocale,
parseLocale,
preferredLocale,
} from "./locale";
describe("the languages of the site", () => {
it("are German and English, with German as the default", () => {
expect(LOCALES).toEqual(["de", "en"]);
expect(DEFAULT_LOCALE).toBe("de");
});
});
describe("parseLocale", () => {
it.each(["en", "de"] as const)("accepts %s", (locale) => {
expect(parseLocale(locale)).toBe(locale);
});
it.each([
["another language", "fr"],
["a regional form", "de-DE"],
["another case", "DE"],
["an empty value", ""],
["a missing value", undefined],
["null", null],
["a value that is not a string", 1],
["a property every object has", "toString"],
])("rejects %s", (_case, raw) => {
expect(parseLocale(raw)).toBeNull();
});
});
describe("negotiateLocale", () => {
it.each([
["German", "de", "de"],
["English", "en", "en"],
["German of a region", "de-AT", "de"],
["English of a region", "en-GB", "en"],
["a language tag in capitals", "DE-de", "de"],
["a longer tag", "de-Latn-DE", "de"],
] as const)("understands %s", (_case, header, expected) => {
expect(negotiateLocale(header)).toBe(expected);
});
it("takes the language the browser lists first when no weights are given", () => {
expect(negotiateLocale("de-DE,de,en-US,en")).toBe("de");
expect(negotiateLocale("en-US,en,de")).toBe("en");
});
it("takes the language with the highest weight, wherever it stands", () => {
expect(negotiateLocale("en;q=0.7,de;q=0.9")).toBe("de");
expect(negotiateLocale("de;q=0.7,en;q=0.9")).toBe("en");
expect(negotiateLocale("de;q=0.8,en")).toBe("en");
});
it("reads weights with several decimals and the full weight written out", () => {
expect(negotiateLocale("en;q=0.8,de;q=0.85")).toBe("de");
expect(negotiateLocale("en;q=0.9,de;q=1")).toBe("de");
expect(negotiateLocale("en;q=0.9,de;q=1.0")).toBe("de");
expect(negotiateLocale("en;q=0.9,de;q=1.000")).toBe("de");
expect(negotiateLocale("en;q=0.9,de;q= 0.95 ")).toBe("de");
});
it("keeps the earlier language when two weights are equal", () => {
expect(negotiateLocale("de;q=0.8,en;q=0.8")).toBe("de");
expect(negotiateLocale("en;q=0.8,de;q=0.8")).toBe("en");
});
it("skips languages the site does not have", () => {
expect(negotiateLocale("fr-FR,fr;q=0.9,de;q=0.6,en;q=0.5")).toBe("de");
expect(negotiateLocale("fr,es;q=0.9,en;q=0.2")).toBe("en");
});
it("tolerates spaces and a typical browser header", () => {
expect(negotiateLocale(" de-DE , de;q=0.9 , en-US;q=0.8 , en;q=0.7 ")).toBe(
"de",
);
expect(negotiateLocale("en ; q=0.4, de ; q=0.6")).toBe("de");
});
it("does not offer a language the visitor refuses with weight zero", () => {
expect(negotiateLocale("de;q=0,en;q=0.5")).toBe("en");
expect(negotiateLocale("de;q=0")).toBeNull();
expect(negotiateLocale("de;q=0.0")).toBeNull();
});
it("ignores entries whose weight cannot be read", () => {
expect(negotiateLocale("de;q=high,en;q=0.1")).toBe("en");
expect(negotiateLocale("de;q=1.5,en;q=0.1")).toBe("en");
expect(negotiateLocale("de;q=-1,en;q=0.1")).toBe("en");
expect(negotiateLocale("de;q=,en;q=0.1")).toBe("en");
expect(negotiateLocale("de;q=0.5x")).toBeNull();
});
it("reads the weight even when another parameter comes first", () => {
expect(negotiateLocale("de;level=1;q=0.2,en;q=0.5")).toBe("en");
expect(negotiateLocale("de;level=1,en;q=0.5")).toBe("de");
});
it("reads a weight written in capitals", () => {
expect(negotiateLocale("de;Q=0.2,en;Q=0.5")).toBe("en");
});
it("matches the whole first part of a tag only", () => {
expect(negotiateLocale("den")).toBeNull();
expect(negotiateLocale("x-de")).toBeNull();
expect(negotiateLocale("nde-DE")).toBeNull();
});
it.each([
["no header", undefined],
["null", null],
["an empty header", ""],
["only a wildcard", "*"],
["only languages the site does not have", "fr,es;q=0.8"],
["only separators", " , ,"],
])("has no answer for %s", (_case, header) => {
expect(negotiateLocale(header)).toBeNull();
});
});
describe("preferredLocale", () => {
it("follows the choice the visitor made with the language switch", () => {
expect(preferredLocale({ cookie: "en", acceptLanguage: "de" })).toBe("en");
expect(preferredLocale({ cookie: "de", acceptLanguage: "en" })).toBe("de");
});
it("follows the browser language when no choice was made", () => {
expect(preferredLocale({ cookie: undefined, acceptLanguage: "de" })).toBe(
"de",
);
expect(preferredLocale({ acceptLanguage: "en-US,de;q=0.5" })).toBe("en");
});
it("ignores a stored choice that is not a language of the site", () => {
expect(preferredLocale({ cookie: "fr", acceptLanguage: "en" })).toBe("en");
expect(preferredLocale({ cookie: "", acceptLanguage: "en" })).toBe("en");
});
it("falls back to German when nothing is known", () => {
expect(preferredLocale({})).toBe("de");
expect(preferredLocale({ cookie: null, acceptLanguage: null })).toBe("de");
expect(preferredLocale({ acceptLanguage: "fr" })).toBe("de");
});
});
describe("localeCookie", () => {
it("is named lang", () => {
expect(LOCALE_COOKIE).toBe("lang");
});
it("stores the choice for a year, for the whole site, and is not sent to other sites", () => {
expect(localeCookie("de")).toBe(
"lang=de; Path=/; Max-Age=31536000; SameSite=Lax",
);
expect(localeCookie("en")).toBe(
"lang=en; Path=/; Max-Age=31536000; SameSite=Lax",
);
});
});
+77
View File
@@ -0,0 +1,77 @@
/**
* The languages of the site, and how the language for a visitor is found: an
* explicit choice (stored in a cookie by the language switch) wins over what the
* browser asks for in its Accept-Language header.
*/
export const LOCALES = ["de", "en"] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "de";
/** Holds the language the visitor picked with the language switch. */
export const LOCALE_COOKIE = "lang";
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
export function parseLocale(raw: unknown): Locale | null {
return LOCALES.find((locale) => locale === raw) ?? null;
}
type Weighted = { readonly locale: Locale; readonly weight: number };
/**
* The weight of one header entry: 1 when none is given, null when it cannot be
* read or is zero, which means the visitor refuses the language.
*/
const weightOf = (parameters: readonly string[]): number | null => {
const quality = parameters
.map((parameter) => parameter.trim().toLowerCase())
.find((parameter) => parameter.startsWith("q="));
if (quality === undefined) return 1;
const weight = Number(quality.slice(2));
return weight > 0 && weight <= 1 ? weight : null;
};
const parseEntry = (entry: string): Weighted | null => {
const [tag, ...parameters] = entry.split(";");
const [primary] = String(tag).trim().toLowerCase().split("-");
const locale = parseLocale(primary);
const weight = weightOf(parameters);
return locale === null || weight === null ? null : { locale, weight };
};
/**
* The language of the site that an Accept-Language header asks for most, or null
* when it asks for none of them. Regional forms count as their language.
*/
export function negotiateLocale(
acceptLanguage: string | null | undefined,
): Locale | null {
if (acceptLanguage == null) return null;
const best = acceptLanguage
.split(",")
.map(parseEntry)
.reduce<Weighted | null>(
(winner, entry) =>
entry !== null && (winner === null || entry.weight > winner.weight)
? entry
: winner,
null,
);
return best === null ? null : best.locale;
}
export function preferredLocale(signals: {
readonly cookie?: string | null;
readonly acceptLanguage?: string | null;
}): Locale {
return (
parseLocale(signals.cookie) ??
negotiateLocale(signals.acceptLanguage) ??
DEFAULT_LOCALE
);
}
/** The cookie that stores the visitor's choice, as written to document.cookie. */
export function localeCookie(locale: Locale): string {
return `${LOCALE_COOKIE}=${locale}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
}
+298
View File
@@ -0,0 +1,298 @@
import { describe, expect, it } from "vitest";
import {
languageAlternates,
localeOfPath,
pathFor,
routeRequest,
sitemapEntries,
switchPath,
} from "./routes";
describe("pathFor", () => {
it("serves German at the root, with German names for the legal pages", () => {
expect(pathFor("home", "de")).toBe("/");
expect(pathFor("imprint", "de")).toBe("/impressum");
expect(pathFor("privacy", "de")).toBe("/datenschutz");
});
it("serves English under /en", () => {
expect(pathFor("home", "en")).toBe("/en");
expect(pathFor("imprint", "en")).toBe("/en/imprint");
expect(pathFor("privacy", "en")).toBe("/en/privacy");
});
});
describe("languageAlternates", () => {
it("names both languages of a page and German as the fallback", () => {
expect(languageAlternates("home")).toEqual({
de: "/",
en: "/en",
"x-default": "/",
});
expect(languageAlternates("privacy")).toEqual({
de: "/datenschutz",
en: "/en/privacy",
"x-default": "/datenschutz",
});
});
});
describe("localeOfPath", () => {
it.each([
["/", "de"],
["/impressum", "de"],
["/en", "en"],
["/en/", "en"],
["/en/imprint", "en"],
["/en/anything/else", "en"],
// Only a whole first segment counts.
["/energy", "de"],
["/dev/en", "de"],
["", "de"],
] as const)("%s is %s", (pathname, locale) => {
expect(localeOfPath(pathname)).toBe(locale);
});
});
describe("switchPath", () => {
it("leads to the same page in the other language", () => {
expect(switchPath("/", "en")).toBe("/en");
expect(switchPath("/en", "de")).toBe("/");
expect(switchPath("/impressum", "en")).toBe("/en/imprint");
expect(switchPath("/en/imprint", "de")).toBe("/impressum");
expect(switchPath("/datenschutz", "en")).toBe("/en/privacy");
expect(switchPath("/en/privacy", "de")).toBe("/datenschutz");
});
it("stays on the page when the language is already the requested one", () => {
expect(switchPath("/en/imprint", "en")).toBe("/en/imprint");
expect(switchPath("/datenschutz", "de")).toBe("/datenschutz");
});
it("ignores a trailing slash", () => {
expect(switchPath("/en/", "de")).toBe("/");
expect(switchPath("/impressum/", "en")).toBe("/en/imprint");
});
it("leads to the homepage from an address that is not a page", () => {
expect(switchPath("/nothing-here", "en")).toBe("/en");
expect(switchPath("/en/nothing-here", "de")).toBe("/");
// A German page name under /en is not an English page.
expect(switchPath("/en/impressum", "de")).toBe("/");
});
});
const german = { cookie: undefined, acceptLanguage: "de-DE,de;q=0.9,en;q=0.8" };
const english = { cookie: undefined, acceptLanguage: "en-US,en;q=0.9" };
const unknown = { cookie: undefined, acceptLanguage: undefined };
describe("routeRequest", () => {
describe("addresses without a language", () => {
it.each([
["/", "/de"],
["/impressum", "/de/imprint"],
["/datenschutz", "/de/privacy"],
])("shows %s in German to a German browser", (pathname, to) => {
expect(routeRequest({ pathname, ...german })).toEqual({
kind: "rewrite",
to,
negotiated: true,
});
});
it("shows German when nothing is known about the visitor, as for a search engine", () => {
expect(routeRequest({ pathname: "/", ...unknown })).toEqual({
kind: "rewrite",
to: "/de",
negotiated: true,
});
});
it.each([
["/", "/en"],
["/impressum", "/en/imprint"],
["/datenschutz", "/en/privacy"],
])(
"sends an English browser from %s to %s, not permanently",
(pathname, to) => {
expect(routeRequest({ pathname, ...english })).toEqual({
kind: "redirect",
to,
permanent: false,
negotiated: true,
});
},
);
it("leaves an English browser on German once German was chosen", () => {
expect(routeRequest({ pathname: "/", ...english, cookie: "de" })).toEqual(
{ kind: "rewrite", to: "/de", negotiated: true },
);
});
it("sends a German browser to English once English was chosen", () => {
expect(routeRequest({ pathname: "/", ...german, cookie: "en" })).toEqual({
kind: "redirect",
to: "/en",
permanent: false,
negotiated: true,
});
});
it("treats a trailing slash like the page itself", () => {
expect(routeRequest({ pathname: "/impressum/", ...english })).toEqual({
kind: "redirect",
to: "/en/imprint",
permanent: false,
negotiated: true,
});
expect(routeRequest({ pathname: "/impressum/", ...german })).toEqual({
kind: "rewrite",
to: "/de/imprint",
negotiated: true,
});
expect(routeRequest({ pathname: "/impressum//", ...german })).toEqual({
kind: "rewrite",
to: "/de/imprint",
negotiated: true,
});
});
// The legal pages used to live here in English; search engines know those addresses.
it.each([
["/imprint", "/en/imprint"],
["/privacy", "/en/privacy"],
["/privacy/", "/en/privacy"],
])(
"moves the English page name %s to %s for good, whatever the browser says",
(pathname, to) => {
for (const visitor of [german, english, unknown]) {
expect(routeRequest({ pathname, ...visitor })).toEqual({
kind: "redirect",
to,
permanent: true,
negotiated: false,
});
}
},
);
it("shows the German error page for an unknown address, whatever the browser says", () => {
expect(routeRequest({ pathname: "/nothing-here", ...english })).toEqual({
kind: "rewrite",
to: "/de/nothing-here",
negotiated: false,
});
expect(routeRequest({ pathname: "/a/b", ...german })).toEqual({
kind: "rewrite",
to: "/de/a/b",
negotiated: false,
});
});
});
describe("English addresses", () => {
it.each([
["/en", "/en"],
["/en/", "/en"],
["/en/imprint", "/en/imprint"],
["/en/privacy", "/en/privacy"],
])("shows %s in English to everyone", (pathname, to) => {
for (const visitor of [german, english, { ...english, cookie: "de" }]) {
expect(routeRequest({ pathname, ...visitor })).toEqual({
kind: "rewrite",
to,
negotiated: false,
});
}
});
it.each([
["/en/impressum", "/en/imprint"],
["/en/datenschutz", "/en/privacy"],
["/en/datenschutz/", "/en/privacy"],
])(
"moves the German page name %s to the English one for good",
(pathname, to) => {
expect(routeRequest({ pathname, ...german })).toEqual({
kind: "redirect",
to,
permanent: true,
negotiated: false,
});
},
);
it("shows the English error page for an unknown English address", () => {
expect(routeRequest({ pathname: "/en/nothing-here", ...german })).toEqual(
{ kind: "rewrite", to: "/en/nothing-here", negotiated: false },
);
});
});
describe("addresses with the internal German prefix", () => {
it.each([
["/de", "/"],
["/de/", "/"],
["/de/impressum", "/impressum"],
["/de/datenschutz", "/datenschutz"],
["/de/imprint", "/impressum"],
["/de/privacy", "/datenschutz"],
["/de/nothing-here", "/nothing-here"],
])(
"moves %s to %s for good, so that each page has one address",
(pathname, to) => {
expect(routeRequest({ pathname, ...english })).toEqual({
kind: "redirect",
to,
permanent: true,
negotiated: false,
});
},
);
it("only treats a whole segment as a prefix", () => {
expect(routeRequest({ pathname: "/design", ...german })).toEqual({
kind: "rewrite",
to: "/de/design",
negotiated: false,
});
expect(routeRequest({ pathname: "/energy", ...german })).toEqual({
kind: "rewrite",
to: "/de/energy",
negotiated: false,
});
});
});
});
describe("sitemapEntries", () => {
it("lists every language of the given pages, each with all its alternates", () => {
const languages = {
de: "https://example.com/",
en: "https://example.com/en",
"x-default": "https://example.com/",
};
expect(sitemapEntries("https://example.com", ["home"])).toEqual([
{ url: "https://example.com/", alternates: { languages } },
{ url: "https://example.com/en", alternates: { languages } },
]);
});
it("keeps the order of the pages", () => {
expect(
sitemapEntries("https://example.com", ["privacy", "home"]).map(
(entry) => entry.url,
),
).toEqual([
"https://example.com/datenschutz",
"https://example.com/en/privacy",
"https://example.com/",
"https://example.com/en",
]);
});
it("is empty without pages", () => {
expect(sitemapEntries("https://example.com", [])).toEqual([]);
});
});
+151
View File
@@ -0,0 +1,151 @@
import { LOCALES, preferredLocale, type Locale } from "./locale";
/**
* The public addresses of the site. German is the main language and lives at the
* root, with German names for the legal pages; English lives under /en.
* Internally every page is rendered under its language (/de/..., /en/...);
* `routeRequest` maps one onto the other.
*/
export type PageId = "home" | "imprint" | "privacy";
type LanguageAlternates = Readonly<Record<Locale | "x-default", string>>;
export type RouteDecision =
| {
readonly kind: "redirect";
readonly to: string;
readonly permanent: boolean;
/** The answer depends on the visitor's language, so caches must not share it. */
readonly negotiated: boolean;
}
| {
readonly kind: "rewrite";
readonly to: string;
readonly negotiated: boolean;
};
export type SitemapEntry = {
readonly url: string;
readonly alternates: { readonly languages: LanguageAlternates };
};
type PagePaths = Readonly<Record<PageId, string>>;
const PAGES: readonly PageId[] = ["home", "imprint", "privacy"];
/** The path of a page below its language, as the app directory names it. */
const INTERNAL: PagePaths = {
home: "",
imprint: "/imprint",
privacy: "/privacy",
};
/** The path of a page below its language, as visitors see it. */
const PUBLIC: Readonly<Record<Locale, PagePaths>> = {
de: { home: "", imprint: "/impressum", privacy: "/datenschutz" },
en: INTERNAL,
};
const PREFIX: Readonly<Record<Locale, string>> = { de: "", en: "/en" };
const withoutTrailingSlash = (pathname: string): string =>
pathname.replace(/\/+$/, "");
/** What follows a prefix such as "/en", or null when the path is not below it. */
const restAfter = (pathname: string, prefix: string): string | null => {
const path = withoutTrailingSlash(pathname);
if (path === prefix) return "";
return path.startsWith(`${prefix}/`) ? path.slice(prefix.length) : null;
};
const pageAt = (rest: string, paths: PagePaths): PageId | null =>
PAGES.find((page) => paths[page] === rest) ?? null;
const redirect = (to: string, permanent: boolean): RouteDecision => ({
kind: "redirect",
to,
permanent,
negotiated: !permanent,
});
export function pathFor(page: PageId, locale: Locale): string {
return `${PREFIX[locale]}${PUBLIC[locale][page]}` || "/";
}
/** The addresses of one page in every language, for hreflang links. */
export function languageAlternates(page: PageId): LanguageAlternates {
return {
de: pathFor(page, "de"),
en: pathFor(page, "en"),
"x-default": pathFor(page, "de"),
};
}
export function localeOfPath(pathname: string): Locale {
return restAfter(pathname, PREFIX.en) === null ? "de" : "en";
}
/** Where the language switch leads from a given address. */
export function switchPath(pathname: string, target: Locale): string {
const locale = localeOfPath(pathname);
const rest = withoutTrailingSlash(pathname).slice(PREFIX[locale].length);
return pathFor(pageAt(rest, PUBLIC[locale]) ?? "home", target);
}
/**
* What to do with a request. Addresses without a language are German and follow
* the visitor's preference; English addresses are shown to everyone; every page
* has exactly one public address, so all other forms are moved to it.
*/
export function routeRequest(request: {
readonly pathname: string;
readonly cookie?: string | null;
readonly acceptLanguage?: string | null;
}): RouteDecision {
const internalGerman = restAfter(request.pathname, "/de");
if (internalGerman !== null) {
const page =
pageAt(internalGerman, PUBLIC.de) ?? pageAt(internalGerman, INTERNAL);
return redirect(page === null ? internalGerman : pathFor(page, "de"), true);
}
const english = restAfter(request.pathname, PREFIX.en);
if (english !== null) {
const misnamed = pageAt(english, PUBLIC.de);
return misnamed === null || misnamed === "home"
? { kind: "rewrite", to: `/en${english}`, negotiated: false }
: redirect(pathFor(misnamed, "en"), true);
}
const rest = withoutTrailingSlash(request.pathname);
const page = pageAt(rest, PUBLIC.de);
if (page === null) {
const formerEnglish = pageAt(rest, INTERNAL);
return formerEnglish === null
? { kind: "rewrite", to: `/de${rest}`, negotiated: false }
: redirect(pathFor(formerEnglish, "en"), true);
}
const locale = preferredLocale(request);
return locale === "de"
? { kind: "rewrite", to: `/de${INTERNAL[page]}`, negotiated: true }
: redirect(pathFor(page, locale), false);
}
/** Sitemap entries for the given pages: one per language, each naming all of them. */
export function sitemapEntries(
siteUrl: string,
pages: readonly PageId[],
): readonly SitemapEntry[] {
return pages.flatMap((page) => {
const alternates = languageAlternates(page);
const languages: LanguageAlternates = {
de: `${siteUrl}${alternates.de}`,
en: `${siteUrl}${alternates.en}`,
"x-default": `${siteUrl}${alternates["x-default"]}`,
};
return LOCALES.map((locale) => ({
url: languages[locale],
alternates: { languages },
}));
});
}
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { captureAnchor, restoreAnchor } from "./scroll-anchor";
// Three sections stacked on a page: 0-1000, 1000-1500, 1500-3500.
const english = [
{ top: 0, height: 1000 },
{ top: 1000, height: 500 },
{ top: 1500, height: 2000 },
];
// The same sections in a language with longer texts.
const german = [
{ top: 0, height: 1200 },
{ top: 1200, height: 800 },
{ top: 2000, height: 2400 },
];
describe("captureAnchor", () => {
it("names the section the reading position is in and how far into it", () => {
expect(captureAnchor(english, 0)).toEqual({ index: 0, ratio: 0 });
expect(captureAnchor(english, 250)).toEqual({ index: 0, ratio: 0.25 });
expect(captureAnchor(english, 1250)).toEqual({ index: 1, ratio: 0.5 });
expect(captureAnchor(english, 3000)).toEqual({ index: 2, ratio: 0.75 });
});
it("counts a section boundary as the start of the next section", () => {
expect(captureAnchor(english, 1000)).toEqual({ index: 1, ratio: 0 });
expect(captureAnchor(english, 1500)).toEqual({ index: 2, ratio: 0 });
});
it("holds on to the last section below the end of the page", () => {
expect(captureAnchor(english, 3500)).toEqual({ index: 2, ratio: 1 });
expect(captureAnchor(english, 9000)).toEqual({ index: 2, ratio: 1 });
});
// Above the first section there is only the header: the new page starts at its top too.
it("has no anchor above the first section", () => {
const offset = [{ top: 80, height: 600 }];
expect(captureAnchor(offset, 0)).toBeNull();
expect(captureAnchor(offset, 79)).toBeNull();
expect(captureAnchor(offset, 80)).toEqual({ index: 0, ratio: 0 });
expect(restoreAnchor(captureAnchor(offset, 0), offset)).toBe(0);
});
it("takes the nearest section above when the position falls into a gap", () => {
const gapped = [
{ top: 0, height: 100 },
{ top: 300, height: 100 },
];
expect(captureAnchor(gapped, 200)).toEqual({ index: 0, ratio: 1 });
});
it("does not divide by the height of an empty section", () => {
const empty = [
{ top: 0, height: 0 },
{ top: 0, height: 0 },
];
expect(captureAnchor(empty, 50)).toEqual({ index: 1, ratio: 0 });
});
it("has no anchor on a page without sections", () => {
expect(captureAnchor([], 120)).toBeNull();
});
});
describe("restoreAnchor", () => {
it("finds the same place in a page whose sections have other heights", () => {
expect(restoreAnchor({ index: 0, ratio: 0.25 }, german)).toBe(300);
expect(restoreAnchor({ index: 1, ratio: 0.5 }, german)).toBe(1600);
expect(restoreAnchor({ index: 2, ratio: 0.75 }, german)).toBe(3800);
});
it("returns a whole pixel", () => {
expect(restoreAnchor({ index: 1, ratio: 1 / 3 }, german)).toBe(1467);
});
it("goes to the top when there is no anchor or the section is gone", () => {
expect(restoreAnchor(null, german)).toBe(0);
expect(restoreAnchor({ index: 3, ratio: 0.5 }, german)).toBe(0);
expect(restoreAnchor({ index: 0, ratio: 0.5 }, [])).toBe(0);
});
it("round-trips within the same page", () => {
for (const y of [0, 10, 999, 1000, 1499, 2750]) {
expect(restoreAnchor(captureAnchor(english, y), english)).toBe(y);
}
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* Keeps the reading position when the page is swapped for its other language.
* The same sections have other heights there, so the position is remembered as
* "this far into that section" and found again in the new page.
*/
export type SectionBox = { readonly top: number; readonly height: number };
export type ScrollAnchor = {
/** Which section, counted from the top of the page. */
readonly index: number;
/** How far into the section: 0 at its top, 1 at its bottom. */
readonly ratio: number;
};
export function captureAnchor(
sections: readonly SectionBox[],
y: number,
): ScrollAnchor | null {
const index = sections.findLastIndex((section) => section.top <= y);
const section = sections[index];
if (section === undefined) return null;
const { top, height } = section;
return { index, ratio: height > 0 ? Math.min(1, (y - top) / height) : 0 };
}
/** The scroll position of the anchor in a page with these sections. */
export function restoreAnchor(
anchor: ScrollAnchor | null,
sections: readonly SectionBox[],
): number {
const section = anchor === null ? undefined : sections[anchor.index];
if (anchor === null || section === undefined) return 0;
return Math.round(section.top + anchor.ratio * section.height);
}
+212
View File
@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import {
buildStructuredData,
serializeJsonLd,
type StructuredDataInput,
} from "./structured-data";
const input: StructuredDataInput = {
siteUrl: "https://example.com",
pageUrl: "https://example.com/",
siteName: "Jane Doe",
language: "en",
description: "Freelance web developer.",
imageUrl: "https://example.com/jane.png",
person: {
name: "Jane Doe",
jobTitle: "Freelance web developer",
profiles: ["https://github.com/jane", "https://linkedin.com/in/jane"],
knowsAbout: ["React", "Websites"],
},
address: {
street: "Main Street 1",
postalCode: "12345",
locality: "Town",
countryCode: "DE",
},
catalogName: "Services",
services: [
{ name: "Web app development", description: "I join your project." },
{ name: "Website development", description: "A website, built for you." },
],
faq: [
{ question: "Who owns the code?", answer: "You do." },
{ question: "How do I reach you?", answer: "By email." },
],
};
const node = (data: ReturnType<typeof buildStructuredData>, type: string) =>
data["@graph"].find((entry) => entry["@type"] === type);
describe("buildStructuredData", () => {
it("is a schema.org graph of the website, the person, the business and the questions", () => {
const data = buildStructuredData(input);
expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"].map((entry) => entry["@type"])).toEqual([
"WebSite",
"Person",
"ProfessionalService",
"FAQPage",
]);
});
it("describes the website and names the person as its publisher", () => {
expect(node(buildStructuredData(input), "WebSite")).toEqual({
"@type": "WebSite",
"@id": "https://example.com/#website",
url: "https://example.com",
name: "Jane Doe",
description: "Freelance web developer.",
inLanguage: "en",
publisher: { "@id": "https://example.com/#person" },
});
});
it("describes the person with job title, portrait, topics and profiles", () => {
expect(node(buildStructuredData(input), "Person")).toEqual({
"@type": "Person",
"@id": "https://example.com/#person",
name: "Jane Doe",
url: "https://example.com",
image: "https://example.com/jane.png",
jobTitle: "Freelance web developer",
knowsAbout: ["React", "Websites"],
sameAs: ["https://github.com/jane", "https://linkedin.com/in/jane"],
});
});
it("leaves out the profile list when there are no profiles", () => {
const data = buildStructuredData({
...input,
person: { ...input.person, profiles: [] },
});
expect(node(data, "Person")).not.toHaveProperty("sameAs");
});
it("describes the business with its address, founder and one offer per service", () => {
expect(node(buildStructuredData(input), "ProfessionalService")).toEqual({
"@type": "ProfessionalService",
"@id": "https://example.com/#business",
name: "Jane Doe",
url: "https://example.com",
description: "Freelance web developer.",
image: "https://example.com/jane.png",
founder: { "@id": "https://example.com/#person" },
address: {
"@type": "PostalAddress",
streetAddress: "Main Street 1",
postalCode: "12345",
addressLocality: "Town",
addressCountry: "DE",
},
hasOfferCatalog: {
"@type": "OfferCatalog",
name: "Services",
itemListElement: [
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Web app development",
description: "I join your project.",
provider: { "@id": "https://example.com/#business" },
},
},
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Website development",
description: "A website, built for you.",
provider: { "@id": "https://example.com/#business" },
},
},
],
},
});
});
it("lists every question with its answer", () => {
expect(node(buildStructuredData(input), "FAQPage")).toEqual({
"@type": "FAQPage",
"@id": "https://example.com/#faq",
inLanguage: "en",
mainEntity: [
{
"@type": "Question",
name: "Who owns the code?",
acceptedAnswer: { "@type": "Answer", text: "You do." },
},
{
"@type": "Question",
name: "How do I reach you?",
acceptedAnswer: { "@type": "Answer", text: "By email." },
},
],
});
});
it("describes the page in its own language: questions, catalogue and website", () => {
const data = buildStructuredData({
...input,
pageUrl: "https://example.com/de",
language: "de",
catalogName: "Leistungen",
});
expect(node(data, "FAQPage")).toMatchObject({
"@id": "https://example.com/de#faq",
inLanguage: "de",
});
expect(node(data, "ProfessionalService")).toMatchObject({
hasOfferCatalog: { name: "Leistungen" },
});
expect(node(data, "WebSite")).toMatchObject({ inLanguage: "de" });
});
it("names the same person, business and website on the page of every language", () => {
const data = buildStructuredData({
...input,
pageUrl: "https://example.com/de",
language: "de",
});
expect(node(data, "WebSite")).toMatchObject({
"@id": "https://example.com/#website",
url: "https://example.com",
});
expect(node(data, "Person")).toMatchObject({
"@id": "https://example.com/#person",
url: "https://example.com",
});
expect(node(data, "ProfessionalService")).toMatchObject({
"@id": "https://example.com/#business",
url: "https://example.com",
});
});
it("leaves out the questions node when there are no questions", () => {
const data = buildStructuredData({ ...input, faq: [] });
expect(data["@graph"].map((entry) => entry["@type"])).toEqual([
"WebSite",
"Person",
"ProfessionalService",
]);
});
});
describe("serializeJsonLd", () => {
it("writes JSON that parses back to the same data", () => {
const data = buildStructuredData(input);
expect(JSON.parse(serializeJsonLd(data))).toEqual(data);
});
it("escapes every opening angle bracket so text cannot close the script tag", () => {
const data = buildStructuredData({
...input,
description: "</script><script>alert(1)</script>",
});
const json = serializeJsonLd(data);
expect(json).not.toContain("<");
expect(json).toContain("\\u003c/script>\\u003cscript>");
expect(JSON.parse(json)).toEqual(data);
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* Structured data (schema.org JSON-LD) that tells search engines who runs the site,
* what is offered and which questions the page answers. Built from the same content
* the page shows, so the two cannot drift apart.
*/
export type StructuredDataInput = {
readonly siteUrl: string;
/** The address of the page in this language. */
readonly pageUrl: string;
readonly siteName: string;
readonly language: string;
readonly description: string;
readonly imageUrl: string;
readonly person: {
readonly name: string;
readonly jobTitle: string;
/** Public profiles of the same person elsewhere on the web. */
readonly profiles: readonly string[];
readonly knowsAbout: readonly string[];
};
readonly address: {
readonly street: string;
readonly postalCode: string;
readonly locality: string;
readonly countryCode: string;
};
/** The heading of the list of services, in the language of the page. */
readonly catalogName: string;
readonly services: readonly {
readonly name: string;
readonly description: string;
}[];
readonly faq: readonly {
readonly question: string;
readonly answer: string;
}[];
};
export type StructuredDataNode = {
readonly "@type": string;
readonly [property: string]: unknown;
};
export type StructuredData = {
readonly "@context": "https://schema.org";
readonly "@graph": readonly StructuredDataNode[];
};
export function buildStructuredData(
input: StructuredDataInput,
): StructuredData {
const { siteUrl, siteName, description, imageUrl, person, address } = input;
const personRef = { "@id": `${siteUrl}/#person` };
const businessRef = { "@id": `${siteUrl}/#business` };
const website: StructuredDataNode = {
"@type": "WebSite",
"@id": `${siteUrl}/#website`,
url: siteUrl,
name: siteName,
description,
inLanguage: input.language,
publisher: personRef,
};
const personNode: StructuredDataNode = {
"@type": "Person",
...personRef,
name: person.name,
url: siteUrl,
image: imageUrl,
jobTitle: person.jobTitle,
knowsAbout: person.knowsAbout,
...(person.profiles.length > 0 ? { sameAs: person.profiles } : {}),
};
const business: StructuredDataNode = {
"@type": "ProfessionalService",
...businessRef,
name: siteName,
url: siteUrl,
description,
image: imageUrl,
founder: personRef,
address: {
"@type": "PostalAddress",
streetAddress: address.street,
postalCode: address.postalCode,
addressLocality: address.locality,
addressCountry: address.countryCode,
},
hasOfferCatalog: {
"@type": "OfferCatalog",
name: input.catalogName,
itemListElement: input.services.map((service) => ({
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: service.name,
description: service.description,
provider: businessRef,
},
})),
},
};
const faq: readonly StructuredDataNode[] =
input.faq.length > 0
? [
{
"@type": "FAQPage",
"@id": `${input.pageUrl}#faq`,
inLanguage: input.language,
mainEntity: input.faq.map((item) => ({
"@type": "Question",
name: item.question,
acceptedAnswer: { "@type": "Answer", text: item.answer },
})),
},
]
: [];
return {
"@context": "https://schema.org",
"@graph": [website, personNode, business, ...faq],
};
}
/** JSON for a script tag. "<" is escaped so that no text can close the tag. */
export function serializeJsonLd(data: StructuredData): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { fill } from "./template";
describe("fill", () => {
it("puts the values into their named blanks", () => {
expect(fill("{name} on a phone", { name: "example.com" })).toBe(
"example.com on a phone",
);
expect(fill("{done} of {total}", { done: 3, total: 53 })).toBe("3 of 53");
});
it("fills a blank every time it occurs", () => {
expect(fill("{n} + {n}", { n: 2 })).toBe("2 + 2");
});
it("leaves text without blanks as it is", () => {
expect(fill("Nothing to fill.", { name: "unused" })).toBe(
"Nothing to fill.",
);
expect(fill("", {})).toBe("");
});
it("keeps a blank that has no value, so the gap stays visible", () => {
expect(fill("{name} and {other}", { name: "A" })).toBe("A and {other}");
});
it("does not look up names every object has", () => {
expect(fill("{toString} {constructor}", {})).toBe(
"{toString} {constructor}",
);
});
it("fills in a zero and an empty text", () => {
expect(fill("{n} failed{suffix}", { n: 0, suffix: "" })).toBe("0 failed");
});
it("does not fill blanks inside the values again", () => {
expect(fill("{a}", { a: "{b}", b: "x" })).toBe("{b}");
});
it("only treats plain names as blanks", () => {
expect(fill("{} { name } {na me}", { name: "A", "": "B" })).toBe(
"{} { name } {na me}",
);
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Texts with named blanks, such as "{name} on a phone". The page texts are plain
* data in two languages, so a sentence with a variable part keeps a blank for it.
*/
export type TemplateValues = Readonly<Record<string, string | number>>;
const BLANK = /\{(\w+)\}/g;
export function fill(template: string, values: TemplateValues): string {
return template.replace(BLANK, (blank, name: string) =>
Object.hasOwn(values, name) ? String(values[name]) : blank,
);
}
+2
View File
@@ -2,6 +2,8 @@ import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */
export default {
// Hover styles only where a pointer can hover, so they do not stick after a tap.
future: { hoverOnlyWhenSupported: true },
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {