Merge feat/scroll-film: scroll film, estimates, monthly booking, and the releases up to v2.5.0

Local main had only the video work; this brings in everything released since
(freelance page, story order, phone layout, share image) plus the two
unreleased commits, so the local site shows the current state.

Also fixes the pre-commit hook for this checkout: ESLint walked into the
worktrees under .claude/worktrees and found several tsconfig roots, so every
commit from here failed. The worktrees are now ignored and the parser has a
fixed tsconfig root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:13:33 +02:00
co-authored by Claude Opus 5.5
36 changed files with 2394 additions and 373 deletions
+3 -3
View File
@@ -477,8 +477,8 @@ jobs:
run: |
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
# The legal pages must be reachable.
for PAGE in /imprint /privacy; do
# The freelance page and the legal pages must be reachable in both languages.
for PAGE in /freelance /en/freelance /impressum /datenschutz /en/imprint /en/privacy; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$PAGE")
if [ "$STATUS" != "200" ]; then
echo "❌ $PAGE failed! (Status: $STATUS)"
@@ -488,7 +488,7 @@ jobs:
done
# URLs of the previous site must redirect instead of returning 404.
for OLD in /blog /contact /websites /case-studies/klz-cables /impressum; do
for OLD in /blog /contact /websites /case-studies/klz-cables; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$OLD")
if [ "$STATUS" != "308" ]; then
echo "❌ $OLD does not redirect! (Status: $STATUS)"
+19 -7
View File
@@ -25,9 +25,14 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta
- 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 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 `/`
- Two content pages. The homepage tells one story from top to bottom (who, what you need, examples, how
working together goes, results, questions, contact); each section opens with a `bridge` sentence from
`story` in the content files that leads over from the one before. The technical sections (project fit,
tools, how a change is secured) live on `/freelance` (`/en/freelance`), for people who develop themselves
or lead a team. Do not move technical detail back onto the homepage.
- The header has the link to the freelance page, 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
@@ -39,8 +44,8 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta
## 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
- German lives at the root (`/`, `/freelance`, `/impressum`, `/datenschutz`), English under `/en`
(`/en/freelance`, `/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.
@@ -55,8 +60,15 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta
- 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`, `BitCurtain` (the language switch transition), `BitAura` (light
along the edge of an element, digits drifting off it), `BitSeam` (lit top edge of a dark section), `BitGlow`
(light following the pointer on `data-glow` surfaces). Reuse them instead of inventing new effects.
along the edge of an element, digits drifting off it; glow only on dark surfaces, on light ones just a
few blue digits), `BitSeam` (lit top edge of a dark section), `BitGlow` (light following the pointer on
`data-glow` surfaces). Reuse them instead of inventing new effects.
- Scroll films live in `apps/web/src/components/film/`: `ScrollFilm` is a dark stage that stays in place
while the visitor scrolls; scrolling is the timeline (camera, statements), a second clock keeps things
alive when the visitor stops. They are the website form of the films in `apps/video` (same pattern: close
on something abstract, pull back, resolve) and use their approved statements. `OrderFilm` opens the
automation chapter. Coordinates rendered on the first frame must be rounded (see `motion.ts`), or server
and browser disagree in the last digit.
- `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`).
+194
View File
@@ -0,0 +1,194 @@
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 { TopicButton } from "@/src/components/home/TopicButton";
import { PipelineRun } from "@/src/components/illustrations/PipelineRun";
import { Reveal } from "@/src/components/Reveal";
import { SwipeRow } from "@/src/components/SwipeRow";
import { contentFor } from "@/src/content";
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 { freelance } = contentFor(locale);
return {
title: { absolute: freelance.metaTitle },
description: freelance.metaDescription,
alternates: {
canonical: pathFor("freelance", locale),
languages: languageAlternates("freelance"),
},
};
}
const MUTED = "text-slate-400";
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
const LIST_TITLE = "m-0 font-sans text-xl font-bold tracking-tight md:text-2xl";
/**
* The page for people who develop themselves or lead a team: which projects
* fit, the tools, and how a change is secured before it goes live.
*/
export default async function FreelancePage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { freelance, technology } = content;
const { booking } = freelance;
return (
<div className="relative flex flex-col bg-white">
<section className="bg-white">
<div className="mx-auto w-full max-w-[1400px] px-5 pb-4 pt-14 md:px-12 md:pb-10 md:pt-28">
<p className="m-0 font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
{freelance.label}
</p>
<h1 className="m-0 mt-8 max-w-6xl font-sans text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
{freelance.title.lead}{" "}
<span className={MUTED}>{freelance.title.rest}</span>
</h1>
<p className={`${BODY} mt-10 max-w-3xl md:mt-14`}>
{freelance.intro}
</p>
</div>
</section>
<Block
index="01"
label={technology.label}
title={
<>
{technology.title.lead}{" "}
<span className={MUTED}>{technology.title.rest}</span>
</>
}
>
<SwipeRow 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"}`}
>
<h3
className={`${LIST_TITLE} ${i === 2 ? "text-slate-400" : "text-slate-900"}`}
>
{group.title}
</h3>
<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>
))}
</SwipeRow>
<Reveal>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-24 md:p-10">
<h3 className={`${LIST_TITLE} text-slate-900`}>
{technology.techLabel}
</h3>
<div className="mt-6 grid grid-cols-1 gap-7 sm:grid-cols-2 md:mt-8 md:gap-10 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>
</Block>
<Block
index="02"
label={freelance.pipelineLabel}
title={technology.pipelineTitle}
>
<Reveal>
<p className={`${BODY} mb-12 max-w-3xl`}>
{technology.pipelineIntro}
</p>
</Reveal>
<Reveal>
<PipelineRun />
</Reveal>
</Block>
<Block
index="03"
label={booking.label}
title={
<>
{booking.title.lead}{" "}
<span className={MUTED}>{booking.title.rest}</span>
</>
}
>
<Reveal>
<div className="grid grid-cols-1 gap-12 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<p className={BODY}>{booking.text}</p>
<ul className="m-0 mt-8 list-none p-0">
{booking.terms.map((term) => (
<li
key={term}
className="border-t border-slate-200 py-4 font-sans text-lg text-slate-900 md:text-xl"
>
{term}
</li>
))}
</ul>
</div>
<div className="md:col-span-6">
<div className="rounded-3xl bg-slate-50 p-6 md:p-10">
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
{[booking.rate, booking.example].map((price) => (
<div key={price.label}>
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{price.label}
</p>
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
{price.amount}
</p>
<p className="m-0 mt-2 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500">
{price.note}
</p>
</div>
))}
</div>
<div className="mt-10 border-t border-slate-200 pt-8">
<TopicButton topic="developer">{booking.cta}</TopicButton>
</div>
</div>
</div>
</div>
</Reveal>
</Block>
<ClosingCta contact={content.contact} />
</div>
);
}
+122 -68
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { OrderFilm } from "@/src/components/film/OrderFilm";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
import { Faq } from "@/src/components/home/Faq";
@@ -12,9 +13,13 @@ 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 { SwipeRow } from "@/src/components/SwipeRow";
import { contentFor } from "@/src/content";
import type { SplitTitle } from "@/src/content/types";
import { DEFAULT_LOCALE, parseLocale } from "@/src/domain/locale";
@@ -37,7 +42,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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 LIST_TITLE =
"m-0 font-sans text-xl font-bold tracking-tight text-slate-900 md:text-2xl";
const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
title,
@@ -51,8 +57,17 @@ const Title: React.FC<{ title: SplitTitle; muted?: string }> = ({
export default async function LandingPage({ params }: Props) {
const locale = parseLocale((await params).locale) ?? DEFAULT_LOCALE;
const content = contentFor(locale);
const { hello, services, automation, technology, working, work, faq } =
content;
const {
hello,
services,
automation,
presentations,
story,
freelance,
working,
work,
faq,
} = content;
return (
<div className="relative flex flex-col bg-white">
@@ -92,6 +107,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="02"
label={services.label}
bridge={story.services}
title={<Title title={services.title} />}
>
<Reveal>
@@ -99,9 +115,12 @@ export default async function LandingPage({ params }: Props) {
</Reveal>
</Block>
<OrderFilm />
<Block
index="03"
label={automation.label}
bridge={story.automation}
title={<Title title={automation.title} />}
>
<Reveal>
@@ -110,11 +129,11 @@ export default async function LandingPage({ params }: Props) {
<Reveal>
<AutomationFlow />
</Reveal>
<div className="mt-16 grid grid-cols-1 gap-12 md:mt-24 md:grid-cols-3 md:gap-10">
<SwipeRow className="mt-12 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>
<h3 className={LIST_TITLE}>{group.title}</h3>
<ul className="m-0 mt-4 list-none p-0">
{group.items.map((item) => (
<li
@@ -128,74 +147,78 @@ export default async function LandingPage({ params }: Props) {
</div>
</Reveal>
))}
</div>
</SwipeRow>
</Block>
<Block
index="04"
label={technology.label}
title={<Title title={technology.title} />}
label={presentations.label}
bridge={story.presentations}
tone="dark"
title={<Title title={presentations.title} muted="text-slate-500" />}
>
<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>
))}
<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>
</div>
</BitAura>
</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>
<SwipeRow className="mt-12 grid grid-cols-1 gap-x-10 gap-y-14 md:mt-28 md:grid-cols-2 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>
))}
</SwipeRow>
<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">
<h3 className="m-0 font-sans text-xl font-bold tracking-tight text-white md:text-2xl">
{presentations.goodFor.title}
</h3>
<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>
<PipelineRun />
<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>
@@ -203,6 +226,7 @@ export default async function LandingPage({ params }: Props) {
<Block
index="05"
label={working.label}
bridge={story.working}
title={<Title title={working.title} />}
>
<Reveal>
@@ -212,7 +236,7 @@ export default async function LandingPage({ params }: Props) {
</div>
</BitAura>
</Reveal>
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
<SwipeRow 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">
@@ -223,12 +247,37 @@ export default async function LandingPage({ params }: Props) {
</div>
</Reveal>
))}
</div>
</SwipeRow>
<Reveal>
<Link
href={pathFor("freelance", locale)}
className="group mt-14 flex flex-col gap-5 rounded-3xl bg-slate-50 p-6 no-underline transition-colors duration-300 hover:bg-slate-100 md:mt-24 md:flex-row md:items-center md:justify-between md:p-10"
>
<span className="block max-w-2xl">
<span className="block font-sans text-2xl font-bold tracking-tight text-slate-900 md:text-3xl">
{freelance.teaser.title}
</span>
<span className="mt-3 block font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{freelance.teaser.text}
</span>
</span>
<span className="inline-flex shrink-0 items-center gap-3 self-start rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white md:self-auto">
{freelance.teaser.cta}
<span
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
→
</span>
</span>
</Link>
</Reveal>
</Block>
<Block
index="06"
label={work.label}
bridge={story.work}
tone="dark"
title={<Title title={work.title} muted="text-slate-500" />}
>
@@ -240,7 +289,12 @@ export default async function LandingPage({ params }: Props) {
</Reveal>
</Block>
<Block index="07" label={faq.label} title={<Title title={faq.title} />}>
<Block
index="07"
label={faq.label}
bridge={story.faq}
title={<Title title={faq.title} />}
>
<Reveal>
<Faq items={faq.items} />
</Reveal>
+113 -45
View File
@@ -1018,47 +1018,6 @@ pre:has(code[class*="language-"]) {
}
}
/* Corner marks that snap onto a card on hover or focus. */
.bit-card {
position: relative;
}
.bit-card::before,
.bit-card::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
border: 1.5px solid currentColor;
opacity: 0;
transition:
opacity 250ms ease,
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
pointer-events: none;
}
.bit-card::before {
top: 12px;
right: 12px;
border-left: 0;
border-bottom: 0;
transform: translate(-6px, 6px);
}
.bit-card::after {
bottom: 12px;
left: 12px;
border-right: 0;
border-top: 0;
transform: translate(6px, -6px);
}
.bit-card:hover::before,
.bit-card:hover::after,
.bit-card:focus-visible::before,
.bit-card:focus-visible::after,
.bit-card[aria-selected="true"]::before,
.bit-card[aria-selected="true"]::after {
opacity: 0.55;
transform: translate(0, 0);
}
/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
.bit-glow {
position: absolute;
@@ -1127,10 +1086,6 @@ pre:has(code[class*="language-"]) {
.bit-glow {
display: none;
}
.bit-card::before,
.bit-card::after {
transition: none;
}
}
/*
@@ -1224,6 +1179,87 @@ pre:has(code[class*="language-"]) {
}
}
/*
* 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;
@@ -1271,6 +1307,38 @@ pre:has(code[class*="language-"]) {
}
}
/*
* SwipeRow: on phones equal items stand side by side and are swiped through,
* with the next one peeking in. From tablet width on the slide wrappers
* dissolve and the items sit in the grid the row was given.
*/
@media (max-width: 767px) {
.swipe-row {
display: flex;
gap: 1rem;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
/* Bleed to the screen edges, but start in line with the text. */
margin-inline: -1.25rem;
padding-inline: 1.25rem;
scroll-padding-inline: 1.25rem;
scrollbar-width: none;
}
.swipe-row::-webkit-scrollbar {
display: none;
}
.swipe-slide {
flex: 0 0 82%;
scroll-snap-align: start;
}
}
@media (min-width: 768px) {
.swipe-slide {
display: contents;
}
}
/*
* Native feel: the page should behave like a well-made app on every input type.
*/
+3 -3
View File
@@ -3,13 +3,13 @@ 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
* The homepage and the freelance page, each 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) => ({
return sitemapEntries(SITE_URL, ["home", "freelance"]).map((entry) => ({
url: entry.url,
alternates: { languages: { ...entry.alternates.languages } },
}));
+67 -3
View File
@@ -1,6 +1,8 @@
// 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:
// language, so they always match the site. The hero is shown the way the dark
// sections of the site look: light type on a dark surface, a lit edge, and
// binary digits coming off it. 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://localhost:3000
@@ -37,7 +39,9 @@ for (const shot of SHOTS) {
main > div > section:first-of-type .grid { margin-top: 0 !important; display: block !important; }
main > div > section:first-of-type h1 span:nth-of-type(2) { color: rgba(15, 23, 42, 0.2) !important; }
main > div > section:first-of-type > div.relative { padding-top: 34px !important; }
html, body { overflow: hidden !important; }
html, body { overflow: hidden !important; background: #000 !important; }
/* Dark version of the hero: inverted, with the hues turned back. */
main > div > section:first-of-type { filter: invert(1) hue-rotate(180deg) !important; }
`,
});
// Let the page finish starting up first; it would discard the added labels.
@@ -54,6 +58,66 @@ for (const shot of SHOTS) {
"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);
// Everything below lies over the inverted hero and is not inverted itself.
const W = 1200;
const H = 630;
const layer = (css) => {
const el = document.createElement("div");
el.style.cssText = `position:absolute;left:0;top:0;width:${W}px;height:${H}px;z-index:60;pointer-events:none;${css}`;
document.body.append(el);
return el;
};
document.body.style.position = "relative";
// Black becomes the slate of the site's dark sections; light type stays light.
layer("background:#0f172a;mix-blend-mode:screen");
// Light coming from the lower right corner.
layer(
"mix-blend-mode:screen;background:radial-gradient(58% 62% at 92% 108%, rgba(99,102,241,.85), transparent 70%),radial-gradient(34% 40% at 104% -6%, rgba(129,140,248,.4), transparent 70%)",
);
// A frame whose lower right edge is lit, with a soft glow behind it.
const INSET = 22;
const edge = (blur, width, opacity) => {
const el = layer(
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;padding:${width}px;opacity:${opacity};filter:blur(${blur}px);` +
"background:conic-gradient(from 60deg at 50% 50%, transparent 0 8%, rgba(129,140,248,.95) 22%, #fff 30%, rgba(129,140,248,.95) 40%, transparent 56% 100%);" +
"-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)",
);
return el;
};
layer(
`left:${INSET}px;top:${INSET}px;width:${W - 2 * INSET}px;height:${H - 2 * INSET}px;box-sizing:border-box;border-radius:30px;border:1px solid rgba(165,180,252,.16)`,
);
edge(26, 14, 0.9);
edge(8, 5, 0.9);
edge(0, 2, 1);
// Digits coming off the lit edge. Seeded, so the picture is the same on every run.
let seed = 20261004;
const random = () => {
seed = (seed * 1664525 + 1013904223) % 4294967296;
return seed / 4294967296;
};
const digits = layer(
"font:500 15px var(--font-jetbrains-mono), ui-monospace, Menlo, monospace;color:#c7d2fe",
);
for (let i = 0; i < 150; i++) {
// Along the bottom edge (right half) and up the right edge (lower half).
const alongBottom = random() < 0.62;
const spread = Math.pow(random(), 1.8);
const x = alongBottom ? W * 0.42 + random() * (W * 0.58 - INSET - 30) : 0;
// Under the lead text the digits stay close to the edge.
const reach = spread * (alongBottom && x < 820 ? 54 : 120);
const left = alongBottom ? x : W - INSET - 14 - reach;
const y = alongBottom
? H - INSET - 16 - reach
: H * 0.3 + random() * (H * 0.7 - INSET - 30);
const digit = document.createElement("span");
digit.textContent = random() < 0.5 ? "0" : "1";
const big = random() < 0.2;
digit.style.cssText = `position:absolute;left:${left.toFixed(1)}px;top:${y.toFixed(1)}px;opacity:${((1 - spread) * (0.35 + random() * 0.65)).toFixed(2)};font-size:${big ? 19 : 14}px;font-weight:${big ? 700 : 500}`;
digits.append(digit);
}
window.scrollTo(0, 0);
});
await page.waitForTimeout(4500);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 278 KiB

@@ -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),
});
+6
View File
@@ -50,6 +50,12 @@ export const Footer: React.FC = () => {
>
{nav.contactLong}
</Link>
<Link
href={pathFor("freelance", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
>
{nav.freelance}
</Link>
<Link
href={pathFor("imprint", locale)}
className="hover:text-slate-900 transition-colors duration-300 no-underline"
+10 -2
View File
@@ -10,10 +10,12 @@ import { ScrollBits } from "./bits/ScrollBits";
import { useContent, useLocale } from "./ContentProvider";
import { LanguageSwitch } from "./LanguageSwitch";
/** Header: the logo, the language switch and one action. Everything else lives on the homepage. */
/** Header: the logo, the link to the freelance page, the language switch and one action. */
export const Header: React.FC = () => {
const { nav } = useContent();
const home = pathFor("home", useLocale());
const locale = useLocale();
const home = pathFor("home", locale);
const freelance = pathFor("freelance", locale);
const [isScrolled, setIsScrolled] = React.useState(false);
React.useEffect(() => {
@@ -62,6 +64,12 @@ export const Header: React.FC = () => {
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
<div className="flex items-center gap-2.5 md:gap-4">
<Link
href={freelance}
className="hidden font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 no-underline transition-colors duration-300 hover:text-slate-900 md:block"
>
{nav.freelance}
</Link>
<LanguageSwitch />
<Link
href={`${home}#contact`}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
/**
* SwipeRow: on phones the items stand side by side and are swiped through,
* with the next one peeking in; from tablet width on they fall back into the
* grid given by `className`. It keeps long lists of equal items from
* stretching the page on a small screen. Styles: `swipe-row` in globals.css.
*/
export const SwipeRow: React.FC<{
className?: string;
children: React.ReactNode;
}> = ({ className = "", children }) => (
<div className={`swipe-row ${className}`}>
{React.Children.map(children, (child) => (
<div className="swipe-slide">{child}</div>
))}
</div>
);
+3 -37
View File
@@ -4,8 +4,8 @@ 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, `pulse` sends rings outward. */
type AuraMotion = "sparks" | "rain" | "pulse";
/** 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. */
@@ -53,8 +53,6 @@ interface Palette {
core: string;
digit: string;
gain: number;
/** Opacity of the pulse rings at the edge. */
ring: number;
/** Share of the digit sparks that are kept (1 = all). */
density: number;
}
@@ -83,10 +81,9 @@ const LIGHT_PALETTE: Palette = {
blend: "source-over",
glow: "15, 23, 42",
core: "15, 23, 42",
digit: "15, 23, 42",
digit: "79, 70, 229",
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
gain: 0,
ring: 0.45,
density: 0.6,
};
@@ -96,21 +93,16 @@ const DARK_PALETTE: Palette = {
core: "224, 231, 255",
digit: "199, 210, 254",
gain: 1,
ring: 0.8,
density: 1,
};
const MOTION_DENSITY: Record<AuraMotion, number> = {
sparks: 0.45,
rain: 0.3,
pulse: 0.2,
};
const PULSE_RINGS = 3;
const PULSE_PERIOD_S = 4.2;
/** How often a drifting digit switches between 0 and 1. */
const FLIPS_PER_S = 0.9;
const PULSE_REACH = 56;
const fraction = (value: number) => value - Math.floor(value);
@@ -150,12 +142,6 @@ const spotsAt = (
t: number,
box: Box,
): readonly Spot[] => {
if (motion === "pulse") {
return [0, 1, 2, 3].map((corner) => ({
at: (corner + 0.5) / 4,
power: 0.45 + 0.25 * Math.sin(t * 0.8 - corner * 1.57),
}));
}
if (source === "around") {
return [
{ at: fraction(t * 0.03), power: 1 },
@@ -327,26 +313,6 @@ export const BitAura: React.FC<BitAuraProps> = ({
}
}
if (motion === "pulse") {
context.setLineDash([3, 7]);
context.lineWidth = 1.5;
for (let ring = 0; ring < PULSE_RINGS; ring++) {
const phase = fraction(clock / PULSE_PERIOD_S + ring / PULSE_RINGS);
const grow = phase * PULSE_REACH;
context.strokeStyle = `rgba(${palette.core}, ${(1 - phase) ** 2 * palette.ring})`;
context.beginPath();
context.roundRect(
-grow,
-grow,
box.width + 2 * grow,
box.height + 2 * grow,
radius + grow,
);
context.stroke();
}
context.setLineDash([]);
}
context.fillStyle = `rgb(${palette.digit})`;
context.textAlign = "center";
context.textBaseline = "middle";
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
import { ORDER_SHOT, OrderShot } from "./OrderShot";
import { ScrollFilm, type FilmClock } from "./ScrollFilm";
import { progress } from "./motion";
/** While which frames each of the five statements stands: hook, reveal, problem, turn, calm. */
const SLOTS = [
{ from: 0, to: ORDER_SHOT.reveal + 20 },
{ from: ORDER_SHOT.reveal + 30, to: ORDER_SHOT.wide + 30 },
{ from: ORDER_SHOT.wide + 30, to: ORDER_SHOT.freeze + 20 },
{ from: ORDER_SHOT.sink, to: ORDER_SHOT.flow + 20 },
{ from: ORDER_SHOT.flow + 20, to: ORDER_SHOT.end },
] as const;
/** The walkers' own time: it runs with the clock and slows to a halt at the freeze. */
const Walking: React.FC<{ clock: FilmClock }> = ({ clock }) => {
const walk = React.useRef(0);
const seen = React.useRef(clock.live);
if (clock.live !== seen.current) {
seen.current = clock.live;
walk.current +=
clock.step *
(1 - progress(clock.shot, ORDER_SHOT.freeze, ORDER_SHOT.freeze + 30));
}
return <OrderShot clock={clock} walk={walk.current} />;
};
/** The opening of the automation chapter: from ping-pong to tools that talk to each other. */
export const OrderFilm: React.FC = () => {
const { film } = useContent().automation;
const captions = SLOTS.map((slot, index) => ({
...slot,
lines: film.statements[index] ?? [],
}));
return (
<ScrollFilm
frames={ORDER_SHOT.end}
captions={captions}
description={film.description}
stillFrame={ORDER_SHOT.flow + 60}
>
{(clock) => <Walking clock={clock} />}
</ScrollFilm>
);
};
+235
View File
@@ -0,0 +1,235 @@
import * as React from "react";
import type { FilmClock } from "./ScrollFilm";
import { hopAt, inOut, mix, out, progress, type Point } from "./motion";
/**
* The automation film as a scroll shot (twin of `OrderFilm` in apps/video).
* Close up, a dot hops between two squares: ping-pong. The camera pulls back:
* it is one of many, a whole office carrying things from tool to tool.
* Everything stops, the dots sink onto a line, and the lines carry the work.
*/
export const ORDER_SHOT = {
/** The camera starts to pull back. */
reveal: 75,
/** The whole picture is in view. */
wide: 195,
/** Everything slows to a halt. */
freeze: 310,
sink: 345,
lines: 365,
flow: 440,
end: 560,
} as const;
const T = ORDER_SHOT;
const INK = "#f8fafc";
const PAPER = "#0f172a";
const MARK = "#fde68a";
const LIGHT = "129, 140, 248";
const COLS = 4;
const HERO_ROW = 2;
const PITCH = { x: 220, y: 190 } as const;
const TILE = 96;
const ZOOM_IN = 2.6;
const ZOOM_OUT = 0.8;
/** Height of a hop as a share of its length. */
const ARC = 0.22;
const CLOSE_HOP_FRAMES = 30;
const PULSE_EVERY = 80;
const PULSE_FRAMES = 55;
const colX = (col: number): number => (col - (COLS - 1) / 2) * PITCH.x;
const rowY = (row: number): number => (row - HERO_ROW) * PITCH.y;
const stop = (row: number, col: number): Point => ({
x: colX(col),
y: rowY(row),
});
/** Each row has someone carrying things round its four tools, in their own order and pace. */
const WALKERS = [
{ route: [0, 1, 2, 3], hopFrames: 27, offset: 7 },
{ route: [2, 0, 3, 1], hopFrames: 32, offset: 40 },
{ route: [1, 2, 3, 0], hopFrames: 29, offset: 0 },
{ route: [3, 2, 1, 0], hopFrames: 25, offset: 19 },
{ route: [1, 3, 0, 2], hopFrames: 30, offset: 55 },
] as const;
const Dot: React.FC<{
at: (back: number) => Point;
radius: number;
zoom: number;
opacity: number;
}> = ({ at, radius, zoom, opacity }) => {
if (opacity <= 0) return null;
const point = at(0);
return (
<g opacity={Math.min(1, opacity)}>
{[1, 2, 3, 4, 5].map((k) => {
const ghost = at(k * 2.2);
return (
<circle
key={k}
cx={ghost.x}
cy={ghost.y}
r={radius * (1 - k * 0.13)}
fill={MARK}
opacity={0.3 - k * 0.05}
/>
);
})}
<circle
cx={point.x}
cy={point.y}
r={radius}
fill={MARK}
style={{ filter: `drop-shadow(0 0 ${12 / zoom}px ${MARK})` }}
/>
</g>
);
};
/** The picture at one moment. `walk` is the walkers' own time: it stops at the freeze. */
export const OrderShot: React.FC<{ clock: FilmClock; walk: number }> = ({
clock,
walk,
}) => {
const { shot, live } = clock;
const back = inOut(progress(shot, T.reveal, T.wide));
const zoom = ZOOM_IN * (ZOOM_OUT / ZOOM_IN) ** back;
/** Everything outside the opening picture fades in as the camera pulls back. */
const others = progress(back, 0.05, 0.5);
const hair = 2.2 / zoom;
const sink = inOut(progress(shot, T.sink, T.sink + 30));
const radius = 12 / Math.sqrt(zoom);
return (
<g transform={`scale(${zoom})`}>
{WALKERS.map((walker, row) => {
const hero = row === HERO_ROW;
const y = rowY(row);
const away = Math.abs(row - HERO_ROW);
// The line that replaces the walking.
const lineStart = T.lines + away * 12;
const drawn = out(progress(shot, lineStart, lineStart + 45));
const lineEnd = mix(colX(0), colX(COLS - 1), drawn);
// Work travelling along the line by itself, in its own time.
const flowing = progress(
shot,
T.flow + away * 10,
T.flow + away * 10 + 10,
);
const pulseT = ((live + away * 23) % PULSE_EVERY) / PULSE_FRAMES;
const pulseX =
flowing > 0 && pulseT <= 1
? mix(colX(0), colX(COLS - 1), inOut(pulseT))
: null;
const shown =
(hero ? 1 : others) * (1 - progress(shot, lineStart, lineStart + 18));
const settle = (point: Point): Point => ({
x: point.x,
y: mix(point.y, y, sink),
});
const onRoute = (ago: number): Point =>
settle(
hopAt(
walk - ago + walker.offset,
walker.route.map((col) => stop(row, col)),
walker.hopFrames,
ARC,
),
);
// While the camera is close, the one in the middle plays ping-pong between two squares.
const pingPong = (ago: number): Point =>
settle(
hopAt(
walk - ago,
[stop(row, 1), stop(row, 2)],
CLOSE_HOP_FRAMES,
ARC * 1.4,
),
);
const walkerNow = onRoute(0);
return (
<g key={row}>
{drawn > 0 && (
<path
d={`M${colX(0)} ${y}H${lineEnd}`}
stroke={INK}
strokeWidth={hair * 1.3}
strokeLinecap="round"
opacity={hero ? 1 : others}
style={{
filter: `drop-shadow(0 0 ${6 / zoom}px rgba(${LIGHT}, 0.9))`,
}}
/>
)}
{Array.from({ length: COLS }, (_unused, col) => {
const x = colX(col);
const opening = hero && (col === 1 || col === 2);
const touched =
shown > 0.5 &&
(!hero || others > 0.5) &&
Math.hypot(walkerNow.x - x, walkerNow.y - y) < TILE * 0.42;
const lit =
pulseX === null
? 0
: Math.max(0, 1 - Math.abs(pulseX - x) / 110) * flowing;
return (
<g key={col} opacity={opening ? 1 : others}>
<rect
x={x - TILE / 2}
y={y - TILE / 2}
width={TILE}
height={TILE}
rx={TILE * 0.24}
fill={PAPER}
/>
<rect
x={x - TILE / 2}
y={y - TILE / 2}
width={TILE}
height={TILE}
rx={TILE * 0.24}
fill={`rgba(248, 250, 252, ${0.1 + lit * 0.22})`}
stroke={touched ? MARK : INK}
strokeOpacity={touched ? 1 : 0.75 + lit * 0.25}
strokeWidth={touched ? hair * 1.6 : hair}
/>
</g>
);
})}
{pulseX !== null && (
<circle
cx={pulseX}
cy={y}
r={radius * 0.8}
fill={MARK}
opacity={(hero ? 1 : others) * flowing}
style={{ filter: `drop-shadow(0 0 ${10 / zoom}px ${MARK})` }}
/>
)}
{hero && (
<Dot
at={pingPong}
radius={radius}
zoom={zoom}
opacity={shown * (1 - others)}
/>
)}
<Dot
at={onRoute}
radius={radius}
zoom={zoom}
opacity={hero ? shown * others : shown}
/>
</g>
);
})}
</g>
);
};
+218
View File
@@ -0,0 +1,218 @@
"use client";
import * as React from "react";
import { progress } from "./motion";
/** One statement of a film: its lines, and while which frames of the shot it stands. */
export type FilmCaption = {
readonly lines: readonly string[];
readonly from: number;
readonly to: number;
};
/** Two clocks: where the shot is (set by scrolling) and time that simply passes. */
export type FilmClock = {
/** Frame of the shot. Scrolling moves it; it stands still when the visitor does. */
readonly shot: number;
/** Frames at 30 per second since the film came into view. Things that live keep moving on it. */
readonly live: number;
/** Frames since the last tick, for things that keep their own pace. */
readonly step: number;
};
interface ScrollFilmProps {
/** The last frame of the shot, reached when the film has been scrolled through. */
frames: number;
captions: readonly FilmCaption[];
/** What the film shows, for screen readers. */
description: string;
/** The frame shown without motion: when reduced motion is requested. */
stillFrame: number;
/** The picture, as SVG content in a 1000 x 700 stage with (0, 0) in the centre. */
children: (clock: FilmClock) => React.ReactNode;
}
const STAGE = { width: 1000, height: 700 } as const;
const FPS = 30;
const MAX_STEP_S = 0.1;
/** How quickly the shot follows the scroll position: lower is smoother. */
const FOLLOW = 0.14;
const IN_FRAMES = 14;
const OUT_FRAMES = 10;
const EDGE_FADE =
"linear-gradient(to bottom, transparent, #000 10%, #000 90%, transparent), linear-gradient(to right, transparent, #000 6%, #000 94%, transparent)";
/**
* ScrollFilm: one continuous shot on a dark stage that stays in place while the
* visitor scrolls through it. Scrolling is the timeline: it moves the camera and
* changes the statement. It is the website form of the social media films
* (apps/video, `FilmStage`): one picture, one statement at a time.
*/
export const ScrollFilm: React.FC<ScrollFilmProps> = ({
frames,
captions,
description,
stillFrame,
children,
}) => {
const trackRef = React.useRef<HTMLDivElement>(null);
const [clock, setClock] = React.useState<FilmClock>({
shot: 0,
live: 0,
step: 0,
});
const [still, setStill] = React.useState(false);
React.useEffect(() => {
const track = trackRef.current;
if (!track) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setStill(true);
setClock({ shot: stillFrame, live: 0, step: 0 });
return;
}
let frame = 0;
let last = 0;
let shot = 0;
let live = 0;
let visible = false;
const target = () => {
const rect = track.getBoundingClientRect();
const distance = rect.height - window.innerHeight;
return distance <= 0 ? 0 : progress(-rect.top, 0, distance) * frames;
};
const tick = (now: number) => {
frame = window.requestAnimationFrame(tick);
const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
last = now;
const step = seconds * FPS;
live += step;
shot += (target() - shot) * FOLLOW;
setClock({ shot, live, step });
};
const observer = new IntersectionObserver(([entry]) => {
const next = entry?.isIntersecting ?? false;
if (next === visible) return;
visible = next;
if (visible) {
last = performance.now();
frame = window.requestAnimationFrame(tick);
} else {
window.cancelAnimationFrame(frame);
}
});
observer.observe(track);
return () => {
observer.disconnect();
window.cancelAnimationFrame(frame);
};
}, [frames, stillFrame]);
const { shot } = clock;
return (
<div
ref={trackRef}
className={`relative bg-slate-900 ${still ? "" : "h-[340svh] md:h-[420svh]"}`}
>
<div className="sticky top-0 flex h-[100svh] flex-col items-center justify-center overflow-hidden">
<div
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
aria-hidden="true"
/>
<div className="bit-aurora opacity-60" aria-hidden="true" />
<svg
viewBox={`${-STAGE.width / 2} ${-STAGE.height / 2} ${STAGE.width} ${STAGE.height}`}
role="img"
aria-label={description}
className="relative mt-16 block max-h-[46svh] w-full max-w-[1100px] flex-1 md:mt-24 md:max-h-[50svh]"
style={{
maskImage: EDGE_FADE,
WebkitMaskImage: EDGE_FADE,
maskComposite: "intersect",
WebkitMaskComposite: "source-in",
}}
>
{children(clock)}
</svg>
{/* One statement at a time, in the type of the headlines. */}
<div
className="relative mt-4 h-[24svh] w-full px-5 text-center md:mt-8"
aria-hidden="true"
>
{captions.map((caption, index) => {
const arrive =
index === 0
? 1
: progress(shot, caption.from, caption.from + IN_FRAMES);
const leave =
index === captions.length - 1
? 1
: 1 - progress(shot, caption.to - OUT_FRAMES, caption.to);
const opacity = still
? stillFrame >= caption.from && stillFrame < caption.to
? 1
: 0
: Math.min(arrive, leave);
if (opacity <= 0) return null;
return (
<p
key={caption.lines.join(" ")}
className="absolute inset-x-5 top-0 m-0 font-sans text-[9.5vw] font-black leading-[1.04] tracking-tighter text-white md:text-6xl lg:text-7xl"
style={{
opacity,
transform: `translateY(${(1 - arrive) * 18}px)`,
}}
>
{caption.lines.map((line, i) => (
<span
key={line}
className={`block ${i > 0 && i === caption.lines.length - 1 ? "text-slate-400" : ""}`}
>
{line}
</span>
))}
</p>
);
})}
</div>
{/* Where the shot is. */}
<div
className="absolute bottom-6 left-1/2 flex -translate-x-1/2 gap-1.5"
aria-hidden="true"
>
{captions.map((caption) => (
<span
key={caption.from}
className="h-0.5 w-8 overflow-hidden rounded-full bg-slate-700"
>
<span
className="block h-full bg-white"
style={{
width: `${progress(shot, caption.from, caption.to) * 100}%`,
}}
/>
</span>
))}
</div>
</div>
{/* The statements as plain text, for screen readers and without JavaScript. */}
<p className="sr-only">
{captions.map((caption) => caption.lines.join(" ")).join(" ")}
</p>
</div>
);
};
+55
View File
@@ -0,0 +1,55 @@
/**
* Motion helpers for the scroll films. All pure functions of a frame or a time,
* the same ones the social media films use, so both move alike.
*/
export type Point = { readonly x: number; readonly y: number };
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
/** 0 before `from`, 1 after `to`, linear in between. */
export const progress = (frame: number, from: number, to: number): number =>
clamp01((frame - from) / (to - from));
export const mix = (from: number, to: number, t: number): number =>
from + (to - from) * t;
export const inOut = (t: number): number =>
t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
export const out = (t: number): number => 1 - (1 - t) ** 3;
const easeInOut = (t: number): number =>
t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
const round = (value: number): number => Math.round(value * 100) / 100;
const wrap = (index: number, length: number): number =>
((index % length) + length) % length;
/**
* Where something is that hops from stop to stop in an arc, round and round.
* It rests on `stops[0]` at time 0 and reaches the next stop every
* `framesPerHop`. The arc rises by `arcRatio` times the distance covered.
*/
export const hopAt = (
time: number,
stops: readonly Point[],
framesPerHop: number,
arcRatio: number,
): Point => {
const hop = Math.floor(time / framesPerHop);
const from = stops[wrap(hop, stops.length)];
const to = stops[wrap(hop + 1, stops.length)];
if (!from || !to) return { x: 0, y: 0 };
const t = easeInOut(time / framesPerHop - hop);
const distance = Math.hypot(to.x - from.x, to.y - from.y);
// Rounded: sine and square root differ in the last digit between the server
// and the browser, and the first frame is rendered by both.
return {
x: round(mix(from.x, to.x, t)),
y: round(
mix(from.y, to.y, t) - Math.sin(Math.PI * t) * distance * arcRatio,
),
};
};
+5 -2
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));
@@ -155,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);
+22 -2
View File
@@ -10,6 +10,8 @@ interface BlockProps {
index: string;
label: string;
title: React.ReactNode;
/** One sentence that leads over from the section before (the thread of the page). */
bridge?: string;
id?: string;
/** `dark` turns the section into a dark stage. */
tone?: "light" | "dark";
@@ -24,6 +26,7 @@ export const Block: React.FC<BlockProps> = ({
index,
label,
title,
bridge,
id,
tone = "light",
children,
@@ -49,7 +52,24 @@ export const Block: React.FC<BlockProps> = ({
/>
)}
{dark && <BitSeam />}
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<div className="relative mx-auto w-full max-w-[1400px] px-5 py-14 md:px-12 md:py-36">
{bridge && (
<Reveal>
<div className="-mt-14 mb-10 md:-mt-36 md:mb-20">
<span
className={`block h-14 w-px md:h-28 ${dark ? "bg-slate-600" : "bg-slate-300"}`}
aria-hidden="true"
/>
<p
className={`m-0 mt-5 max-w-2xl font-sans text-xl leading-snug md:text-3xl ${
dark ? "text-slate-300" : "text-slate-500"
}`}
>
{bridge}
</p>
</div>
</Reveal>
)}
<Reveal>
<div className="mb-8 flex items-center gap-4 md:mb-12">
<span
@@ -78,7 +98,7 @@ export const Block: React.FC<BlockProps> = ({
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
<div className="mt-10 md:mt-24">{children}</div>
</div>
</section>
);
+2 -2
View File
@@ -26,7 +26,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
/>
<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">
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-12 px-5 py-16 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"
@@ -38,7 +38,7 @@ export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
{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">
@@ -1,5 +1,6 @@
"use client";
import { onContactTopic } from "./contactTopic";
import * as React from "react";
import { useContent } from "@/src/components/ContentProvider";
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
@@ -61,6 +62,15 @@ export const ContactComposer: React.FC = () => {
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
}, []);
// A button elsewhere on the page can open the message with its topic chosen.
React.useEffect(
() =>
onContactTopic((id) => {
if (topics.some((item) => item.id === id)) setTopicId(id);
}),
[topics],
);
const topic = topics.find((item) => item.id === topicId) ?? topics[0];
const typed = useTyped(topic?.draft.body ?? "");
+59 -36
View File
@@ -2,11 +2,13 @@
import * as React from "react";
import { motion } from "framer-motion";
import { Button } from "@/src/components/Button";
import Link from "next/link";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes";
import { useContent } from "@/src/components/ContentProvider";
import { useContent, useLocale } from "@/src/components/ContentProvider";
import { pathFor } from "@/src/domain/routes";
import { TopicButton } from "./TopicButton";
import type { ServicePath } from "@/src/content/types";
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
@@ -92,7 +94,7 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
setStep(index);
setAuto(false);
}}
className={`relative flex w-full gap-5 border-t py-6 text-left transition-colors duration-300 md:gap-6 ${
className={`relative flex w-full gap-5 border-t py-4 text-left transition-colors duration-300 md:gap-6 md:py-6 ${
selected ? "border-slate-900" : "border-slate-200"
}`}
>
@@ -122,8 +124,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
{item.title}
</span>
<span
className={`mt-1.5 block font-sans text-base leading-relaxed transition-colors duration-300 ${
selected ? "text-slate-600" : "text-slate-400"
className={`mt-1.5 font-sans text-base leading-relaxed transition-colors duration-300 ${
// On phones only the current step is spelled out.
selected
? "block text-slate-600"
: "hidden text-slate-400 md:block"
}`}
>
{item.text}
@@ -145,6 +150,8 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
*/
export const PathPicker: React.FC = () => {
const { services } = useContent();
const { estimate } = services;
const locale = useLocale();
const paths = services.paths;
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
@@ -153,7 +160,7 @@ export const PathPicker: React.FC = () => {
<div
role="tablist"
aria-label={services.tablistLabel}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
className="grid grid-cols-2 gap-2 md:gap-3 lg:grid-cols-4 lg:gap-4"
>
{paths.map((path, index) => {
const selected = path.id === activeId;
@@ -167,7 +174,7 @@ export const PathPicker: React.FC = () => {
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 ${
className={`group flex h-full flex-col justify-between rounded-3xl border p-4 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"
@@ -177,11 +184,11 @@ export const PathPicker: React.FC = () => {
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1}
</span>
<span className="mt-3 block font-sans text-xl font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
<span className="mt-3 block font-sans text-base font-bold leading-tight tracking-tight md:mt-10 md:text-2xl">
{path.need}
</span>
<span
className={`mt-2 block font-sans text-sm leading-relaxed md:mt-4 md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
className={`mt-2 hidden font-sans text-sm leading-relaxed md:mt-4 md:block md:text-base ${selected ? "text-slate-300" : "text-slate-500"}`}
>
{path.forWhom}
</span>
@@ -199,7 +206,7 @@ export const PathPicker: React.FC = () => {
id={`path-panel-${path.id}`}
aria-labelledby={`path-tab-${path.id}`}
hidden={!selected}
className="mt-12 md:mt-20"
className="mt-8 md:mt-20"
>
<motion.div
key={selected ? "on" : "off"}
@@ -241,37 +248,53 @@ export const PathPicker: React.FC = () => {
</div>
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
<p className={`${MONO} m-0`}>{services.whatItCosts}</p>
<p className={`${MONO} m-0`}>{estimate.label}</p>
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
<div className="md:col-span-6">
<div className="md:col-span-7">
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
{path.pricingModel}
{estimate.title}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500 md:text-lg">
{path.pricingText}
<ol className="m-0 mt-6 list-none p-0">
{estimate.steps.map((item, index) => (
<li
key={item.title}
className="flex gap-5 border-t border-slate-200 py-4"
>
<span className="w-6 shrink-0 font-sans text-2xl font-black leading-none tracking-tighter text-slate-300">
{index + 1}
</span>
<span>
<span className="block font-sans text-lg font-bold tracking-tight text-slate-900">
{item.title}
</span>
<span className="mt-1 block font-sans text-base leading-relaxed text-slate-500">
{item.text}
</span>
</span>
</li>
))}
</ol>
</div>
<div className="flex flex-col md:col-span-5">
<p className={`${MONO} m-0`}>{estimate.orientationLabel}</p>
<p className="m-0 mt-3 font-sans text-2xl font-black leading-tight tracking-tighter text-slate-900 md:text-4xl">
{path.orientation}
</p>
<p className="m-0 mt-4 font-sans text-base leading-relaxed text-slate-500">
{path.orientationNote} {estimate.net}
</p>
<div className="mt-8 flex flex-col items-start gap-5 md:mt-auto md:pt-8">
<TopicButton topic={path.id}>{estimate.cta}</TopicButton>
{path.id === "developer" && (
<Link
href={pathFor("freelance", locale)}
className="border-b border-slate-900 pb-0.5 font-sans text-base font-bold text-slate-900 no-underline transition-opacity hover:opacity-60"
>
{estimate.freelance} →
</Link>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 md:col-span-6">
{path.prices.map((price) => (
<div key={price.label}>
<p className="m-0 font-sans text-base text-slate-500 md:text-lg">
{price.label}
</p>
<p className="m-0 mt-2 font-sans text-3xl font-black tracking-tighter text-slate-900 md:text-5xl">
{price.amount}
</p>
<p className={`${MONO} m-0 mt-2`}>{price.note}</p>
</div>
))}
</div>
</div>
<div className="mt-10 flex flex-col gap-6 border-t border-slate-200 pt-8 md:flex-row md:items-center md:justify-between">
<p className="m-0 max-w-2xl font-sans text-base leading-relaxed text-slate-500">
{path.smallPrint}
</p>
<Button href="#contact" size="large">
{path.cta}
</Button>
</div>
</div>
</motion.div>
@@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import { Button } from "@/src/components/Button";
import { chooseContactTopic } from "./contactTopic";
/** A call to action that jumps to the contact section with a topic already chosen. */
export const TopicButton: React.FC<{
topic: string;
children: React.ReactNode;
}> = ({ topic, children }) => (
<Button
href="#contact"
size="large"
onClick={() => chooseContactTopic(topic)}
>
{children}
</Button>
);
@@ -0,0 +1,19 @@
/** The contact section listens for this to open with a given topic chosen. */
const EVENT = "mintel:contact-topic";
/** Ask the contact section to choose a topic (see ContactComposer). */
export const chooseContactTopic = (topicId: string): void => {
window.dispatchEvent(new CustomEvent<string>(EVENT, { detail: topicId }));
};
export const onContactTopic = (
listener: (topicId: string) => void,
): (() => void) => {
const handle = (event: Event) => {
if (event instanceof CustomEvent && typeof event.detail === "string") {
listener(event.detail);
}
};
window.addEventListener(EVENT, handle);
return () => window.removeEventListener(EVENT, handle);
};
@@ -1,6 +1,7 @@
"use client";
import { BitAura } from "@/src/components/bits/BitAura";
import { SwipeRow } from "@/src/components/SwipeRow";
import * as React from "react";
import { useNarrow } from "./useNarrow";
import { useContent } from "@/src/components/ContentProvider";
@@ -222,40 +223,35 @@ export const AutomationFlow: React.FC = () => {
return (
<div ref={ref}>
<div
role="tablist"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setFlowId(item.id)}
className={`rounded-full border px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
selected
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
}`}
>
{item.name}
</button>
);
})}
</div>
<p className="m-0 mt-6 max-w-3xl font-sans text-lg leading-relaxed text-slate-600 md:text-xl">
{flow.summary}
</p>
<BitAura source="around" tone="light" motion="pulse" className="mt-8">
{/* The examples, their description and the run are one box: they belong together. */}
<BitAura source="left" tone="light">
<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>
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-slate-800 px-5 py-4 md:px-7">
<div
role="tablist"
aria-label={automation.examplesLabel}
className="flex flex-wrap gap-2"
>
{workflows.map((item) => {
const selected = item.id === flow.id;
return (
<button
key={item.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setFlowId(item.id)}
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"
}`}
>
{item.name}
</button>
);
})}
</div>
<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"}`}
@@ -263,6 +259,9 @@ export const AutomationFlow: React.FC = () => {
{finished ? automation.runComplete : automation.running}
</span>
</div>
<p className="m-0 max-w-4xl px-5 pb-2 pt-6 font-sans text-lg leading-relaxed text-slate-200 md:px-7 md:pt-8 md:text-xl">
{flow.summary}
</p>
<div>
<svg
@@ -391,7 +390,7 @@ export const AutomationFlow: React.FC = () => {
</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">
<div className="px-5 py-5 md:col-span-7 md:border-r md:border-slate-800 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]"
@@ -413,7 +412,7 @@ export const AutomationFlow: React.FC = () => {
))}
</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">
<ul className="m-0 hidden list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:flex md:px-7">
{KINDS.filter((kind) =>
flow.nodes.some((node) => node.kind === kind),
).map((kind) => (
@@ -439,23 +438,28 @@ export const AutomationFlow: React.FC = () => {
))}
</ul>
</div>
<div className="border-t border-slate-800 px-5 py-6 md:px-7 md:py-8">
<SwipeRow className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-10">
<div>
<p className={`${MONO} m-0 text-slate-500`}>
{automation.byHand}
</p>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-slate-400 md:text-lg">
{flow.byHand}
</p>
</div>
<div>
<p className={`${MONO} m-0 text-emerald-400`}>
{automation.automated}
</p>
<p className="m-0 mt-3 font-sans text-base leading-relaxed text-white md:text-lg">
{flow.automated}
</p>
</div>
</SwipeRow>
</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`}>{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`}>{automation.automated}</p>
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
{flow.automated}
</p>
</div>
</div>
</div>
);
};
@@ -46,7 +46,7 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
}`}
aria-hidden="true"
/>
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400 md:col-span-3 md:pt-1.5">
<span className="font-mono text-sm font-bold uppercase tracking-[0.14em] text-indigo-200 md:col-span-3 md:pt-1.5 md:text-base">
{station.years}
</span>
<span className="md:col-span-9">
@@ -241,7 +241,10 @@ export const PipelineRun: React.FC = () => {
</p>
<p
className={`m-0 mt-2 font-sans text-base leading-relaxed transition-colors duration-300 ${
active ? "text-slate-600" : "text-slate-400"
// On phones only the current step is spelled out.
active
? "block text-slate-600"
: "hidden text-slate-400 md:block"
}`}
>
{item.text}
@@ -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>
);
};
@@ -203,7 +203,7 @@ export const WorkShowcase: React.FC = () => {
{copy.text}
</p>
<ul className="m-0 mt-8 list-none p-0">
<ul className="m-0 mt-8 hidden list-none p-0 md:block">
{copy.did.map((line) => (
<li
key={line}
+153 -56
View File
@@ -10,14 +10,14 @@ 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.",
"Freelance-Webentwickler mit 15 Jahren Erfahrung. Ich baue Web-Apps, Interfaces 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",
"User Interfaces",
"Websites",
"Prozessautomatisierung",
"Web3-Frontends",
@@ -26,6 +26,7 @@ export const de: Content = {
},
nav: {
contact: "Kontakt",
freelance: "Freelance",
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, nach oben",
imprint: "Impressum",
@@ -40,7 +41,7 @@ export const de: Content = {
{ text: "& WEBSITES.", tone: "bits" },
],
lead: "Freelance-Webentwickler mit 15 Jahren Erfahrung.",
leadStrong: "Ich baue Web-Apps, Oberflächen und Websites.",
leadStrong: "Ich baue Web-Apps, Interfaces und Websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "Was ich mache",
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
@@ -51,7 +52,7 @@ export const de: Content = {
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.",
"Meine Stärke ist der Teil, den Menschen sehen und benutzen: Design und Interface. Ich kenne mich auch mit dem aus, was dahinter liegt, und kann deshalb eine vollständige Anwendung bauen und nicht nur ihr Interface.",
"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",
@@ -77,7 +78,28 @@ export const de: Content = {
tablistLabel: "Was brauchen Sie?",
youGet: "Das bekommen Sie",
howItRuns: "So läuft es ab",
whatItCosts: "Das kostet es",
estimate: {
label: "Was es kostet",
title: "So kommen Sie zu einem Preis",
steps: [
{
title: "Ein Gespräch, etwa 20 Minuten",
text: "Sie erzählen mir, was Sie brauchen.",
},
{
title: "Eine schriftliche Schätzung",
text: "Sie sehen die geschätzten Stunden und was darin enthalten ist.",
},
{
title: "Sie geben ein Budget frei",
text: "Ich rechne die tatsächlichen Stunden ab und sage früh Bescheid, wenn es nicht reicht.",
},
],
orientationLabel: "Zur Orientierung",
net: "Alle Preise netto.",
cta: "Schätzung anfragen",
freelance: "Buchung und Preise für Teams",
},
paths: [
{
id: "developer",
@@ -86,10 +108,10 @@ export const de: Content = {
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.",
"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, das Interface oder die Teile, zu denen niemand kommt.",
youGet: [
"Web-Apps, Dashboards, Kundenportale und interne Werkzeuge",
"Oberflächen mit Blick für Design und Bedienbarkeit",
"Interfaces mit Blick für Design und Bedienbarkeit",
"Web3-Frontends: Wallet-Anbindung, Live-Daten, Dashboards",
"Kleine Werkzeuge und Automatisierung für wiederkehrende Arbeit",
],
@@ -111,16 +133,9 @@ export const de: Content = {
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,
orientation: "100 EUR pro Stunde, monatsweise gebucht",
orientationNote:
"Sie buchen feste Wochentage für jeweils einen Monat. Abgerechnet werden die Stunden, die ich tatsächlich gearbeitet habe.",
},
{
id: "rescue",
@@ -156,19 +171,9 @@ export const de: Content = {
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,
orientation: "3.500 EUR für eine feste Woche",
orientationNote:
"Der Umfang ist eine Woche meiner Zeit, 35 Stunden. 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.",
},
{
id: "automation",
@@ -204,13 +209,9 @@ export const de: Content = {
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:
orientation: "100 EUR pro Stunde, nach Schätzung",
orientationNote:
"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",
@@ -245,24 +246,9 @@ export const de: Content = {
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,
orientation: "Ab 3.800 EUR, einmalig",
orientationNote:
"Das gilt für eine typische Unternehmenswebsite. Hosting und kleine Änderungen danach kosten einen monatlichen Betrag, den wir vor dem Start vereinbaren.",
},
],
scenes: {
@@ -291,6 +277,17 @@ export const de: Content = {
},
},
automation: {
film: {
description:
"Ein Punkt springt zwischen zwei Quadraten hin und her. Die Kamera fährt zurück: Es sind viele Reihen, in denen jemand Dinge von Programm zu Programm trägt. Dann verbinden Linien die Programme, und die Arbeit läuft von selbst.",
statements: [
["Sieht aus wie", "Pingpong."],
["Ist aber", "Ihr Büro."],
["Jede Bestellung.", "Von Hand."],
["Oder Ihre Tools", "reden miteinander."],
["Und Ihr Team", "hat Zeit."],
],
},
label: "Prozessautomatisierung",
title: { lead: "Lassen Sie die Systeme", rest: "das Kopieren übernehmen." },
intro:
@@ -441,6 +438,106 @@ export const de: Content = {
},
],
},
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",
},
story: {
services: "So viel zu mir. Jetzt zu Ihnen.",
automation: "Einen dieser Fälle zeige ich genauer.",
presentations: "Und manchmal ist das Richtige gar keine App.",
working: "Egal, was Sie brauchen: So läuft die Zusammenarbeit.",
work: "Und so sieht das Ergebnis aus.",
faq: "Bleiben die Fragen, die fast immer kommen.",
},
freelance: {
metaTitle: "Freelance-Webentwickler für Ihr Team | Marc Mintel",
metaDescription:
"Für welche Projekte ich passe, womit ich arbeite (React, Next.js, TypeScript) und wie ich Änderungen absichere, bevor sie live gehen.",
label: "Freelance",
title: {
lead: "Ein Entwickler für Ihr Team.",
rest: "Die technischen Details.",
},
intro:
"Diese Seite ist für alle, die selbst entwickeln oder ein Team leiten: für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, bevor sie live gehen.",
pipelineLabel: "Absicherung",
booking: {
label: "Buchung",
title: { lead: "Gebucht wird monatsweise.", rest: "Nicht tageweise." },
text: "Ich arbeite an festen Wochentagen an Ihrem Projekt, vereinbart für jeweils einen Monat. Es gibt kein Paket zu kaufen: Sie zahlen die Stunden, die ich tatsächlich gearbeitet habe.",
rate: { label: "Pro Stunde", amount: "100 EUR", note: "netto" },
example: {
label: "Beispiel: 2 Tage pro Woche",
amount: "ca. 6.400 EUR",
note: "64 Stunden in vier Wochen, netto",
},
terms: [
"Feste Wochentage, vereinbart für jeweils einen Monat",
"Eine Rechnung pro Monat, mit den erfassten Stunden",
"Beide Seiten können mit zwei Wochen Frist zum Monatsende kündigen",
"Einzelne Tage buche ich nicht",
],
cta: "Verfügbarkeit anfragen",
},
teaser: {
title: "Sie entwickeln selbst oder leiten ein Team?",
text: "Für welche Projekte ich passe, womit ich arbeite und wie ich Änderungen absichere, steht auf einer eigenen Seite.",
cta: "Zu den technischen Details",
},
},
technology: {
label: "Technik",
title: { lead: "Für welche Projekte", rest: "ich der Richtige bin." },
@@ -449,7 +546,7 @@ export const de: Content = {
title: "Passt gut",
items: [
"Web-Apps: Dashboards, Portale, interne Werkzeuge",
"Oberflächen, bei denen Design und Bedienbarkeit zählen",
"Interfaces, bei denen Design und Bedienbarkeit zählen",
"Unternehmenswebsites, auch in mehreren Sprachen",
"Web3-Frontends: Wallets, Live-Daten, Dashboards",
"Automatisierung: Systeme und ihre APIs verbinden",
@@ -475,7 +572,7 @@ export const de: Content = {
techLabel: "Womit ich arbeite",
tech: [
{
area: "Oberfläche",
area: "Interface",
items: ["TypeScript", "React", "Next.js", "Vue.js", "Tailwind CSS"],
},
{
+141 -40
View File
@@ -25,6 +25,7 @@ export const en: Content = {
},
nav: {
contact: GET_IN_TOUCH,
freelance: "Freelance",
contactLong: GET_IN_TOUCH,
logoLabel: "Marc Mintel, back to top",
imprint: "Imprint",
@@ -76,7 +77,28 @@ export const en: Content = {
tablistLabel: "What do you need?",
youGet: "What you get",
howItRuns: "How it runs",
whatItCosts: "What it costs",
estimate: {
label: "What it costs",
title: "How you get to a price",
steps: [
{
title: "A call, about 20 minutes",
text: "You tell me what you need.",
},
{
title: "A written estimate",
text: "You see the estimated hours and what they include.",
},
{
title: "You approve a budget",
text: "I bill the hours actually used and tell you early if it will not be enough.",
},
],
orientationLabel: "For orientation",
net: "All prices net.",
cta: "Ask for an estimate",
freelance: "Booking and rates for teams",
},
paths: [
{
id: "developer",
@@ -110,16 +132,9 @@ export const en: Content = {
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,
orientation: "100 EUR per hour, booked by the month",
orientationNote:
"You book fixed days of the week for one month at a time. I bill the hours I actually worked.",
},
{
id: "rescue",
@@ -153,15 +168,9 @@ export const en: Content = {
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,
orientation: "3,500 EUR for one fixed week",
orientationNote:
"The scope is one week of my time, 35 hours. 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.",
},
{
id: "automation",
@@ -197,13 +206,9 @@ export const en: Content = {
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:
orientation: "100 EUR per hour, after an estimate",
orientationNote:
"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",
@@ -237,20 +242,9 @@ export const en: Content = {
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,
orientation: "From 3,800 EUR, one-off",
orientationNote:
"This is for a typical company website. Hosting and small changes afterwards cost a monthly fee that we agree on before we start.",
},
],
scenes: {
@@ -279,6 +273,17 @@ export const en: Content = {
},
},
automation: {
film: {
description:
"A dot hops back and forth between two squares. The camera pulls back: there are many rows in which someone carries things from tool to tool. Then lines connect the tools and the work runs by itself.",
statements: [
["Looks like", "ping-pong."],
["It's your", "office."],
["Every order.", "By hand."],
["Or your tools", "talk to each other."],
["And your team", "has time."],
],
},
label: "Process automation",
title: { lead: "Let the systems", rest: "do the copying." },
intro:
@@ -425,6 +430,102 @@ export const en: Content = {
},
],
},
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",
},
story: {
services: "That is me. Now to you.",
automation: "One of these cases, shown in more detail.",
presentations: "And sometimes the right thing is not an app at all.",
working: "Whatever you need: this is how working together goes.",
work: "And this is what the result looks like.",
faq: "That leaves the questions that almost always come up.",
},
freelance: {
metaTitle: "Freelance web developer for your team | Marc Mintel",
metaDescription:
"Which projects I am right for, what I work with (React, Next.js, TypeScript) and how I secure changes before they go live.",
label: "Freelance",
title: {
lead: "A developer for your team.",
rest: "The technical details.",
},
intro:
"This page is for people who develop themselves or lead a team: which projects I am right for, what I work with, and how I secure changes before they go live.",
pipelineLabel: "Safeguards",
booking: {
label: "Booking",
title: { lead: "Booked by the month.", rest: "Not by the day." },
text: "I work on your project on fixed days of the week, agreed for one month at a time. There is no package to buy: you pay the hours I actually worked.",
rate: { label: "Per hour", amount: "100 EUR", note: "net" },
example: {
label: "Example: 2 days a week",
amount: "about 6,400 EUR",
note: "64 hours in four weeks, net",
},
terms: [
"Fixed days of the week, agreed for one month at a time",
"One invoice per month, with the tracked hours",
"Either side can end it with two weeks notice to the end of a month",
"I do not book single days",
],
cta: "Ask about availability",
},
teaser: {
title: "You develop yourself or lead a team?",
text: "Which projects I am right for, what I work with and how I secure changes is on a page of its own.",
cta: "To the technical details",
},
},
technology: {
label: "Technology",
title: { lead: "Which projects", rest: "I am right for." },
+76 -6
View File
@@ -55,11 +55,10 @@ export type ServicePath = {
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 order of magnitude in one line, such as "from 3,800 EUR, one-off". */
readonly orientation: string;
/** What that figure depends on or leaves out. */
readonly orientationNote: string;
};
/** The short labels inside the drawings that explain the steps of a path. */
@@ -146,6 +145,7 @@ export type Content = {
};
readonly nav: {
readonly contact: string;
readonly freelance: string;
readonly contactLong: string;
readonly logoLabel: string;
readonly imprint: string;
@@ -179,11 +179,26 @@ export type Content = {
readonly tablistLabel: string;
readonly youGet: string;
readonly howItRuns: string;
readonly whatItCosts: string;
/** How a visitor gets to a price: the homepage shows this instead of a price list. */
readonly estimate: {
readonly label: string;
readonly title: string;
readonly steps: readonly TitledText[];
readonly orientationLabel: string;
readonly net: string;
readonly cta: string;
/** The pointer to the booking terms on the freelance page. */
readonly freelance: string;
};
readonly paths: readonly ServicePath[];
readonly scenes: SceneLabels;
};
readonly automation: {
/** The film that opens the chapter: what it shows, and its five statements in order. */
readonly film: {
readonly description: string;
readonly statements: readonly (readonly string[])[];
};
readonly label: string;
readonly title: SplitTitle;
readonly intro: string;
@@ -198,6 +213,61 @@ export type Content = {
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;
};
/**
* The thread through the homepage: one sentence at the top of each section
* that leads over from the one before, so the page reads as one story.
*/
readonly story: {
readonly services: string;
readonly automation: string;
readonly presentations: string;
readonly working: string;
readonly work: string;
readonly faq: string;
};
/** The page for people who develop themselves or lead a team. */
readonly freelance: {
readonly metaTitle: string;
readonly metaDescription: string;
readonly label: string;
readonly title: SplitTitle;
readonly intro: string;
readonly pipelineLabel: string;
/** How the owner is booked as a developer: by the month, not by the day. */
readonly booking: {
readonly label: string;
readonly title: SplitTitle;
readonly text: string;
readonly rate: PathPrice;
readonly example: PathPrice;
readonly terms: readonly string[];
readonly cta: string;
};
/** The pointer to this page on the homepage. */
readonly teaser: TitledText & { readonly cta: string };
};
readonly technology: {
readonly label: string;
readonly title: SplitTitle;
+62
View File
@@ -296,3 +296,65 @@ describe("sitemapEntries", () => {
expect(sitemapEntries("https://example.com", [])).toEqual([]);
});
});
describe("the freelance page, which has the same name in both languages", () => {
it("lives at /freelance in German and /en/freelance in English", () => {
expect(pathFor("freelance", "de")).toBe("/freelance");
expect(pathFor("freelance", "en")).toBe("/en/freelance");
expect(languageAlternates("freelance")).toEqual({
de: "/freelance",
en: "/en/freelance",
"x-default": "/freelance",
});
});
it("switches between the languages", () => {
expect(switchPath("/freelance", "en")).toBe("/en/freelance");
expect(switchPath("/en/freelance", "de")).toBe("/freelance");
});
it("shows /freelance in German to a German browser", () => {
expect(routeRequest({ pathname: "/freelance", ...german })).toEqual({
kind: "rewrite",
to: "/de/freelance",
negotiated: true,
});
});
it("sends an English browser from /freelance to /en/freelance, not permanently", () => {
expect(routeRequest({ pathname: "/freelance", ...english })).toEqual({
kind: "redirect",
to: "/en/freelance",
permanent: false,
negotiated: true,
});
});
it("shows /en/freelance to everyone instead of redirecting it onto itself", () => {
expect(routeRequest({ pathname: "/en/freelance", ...german })).toEqual({
kind: "rewrite",
to: "/en/freelance",
negotiated: false,
});
});
it("moves the internal address /de/freelance to /freelance", () => {
expect(routeRequest({ pathname: "/de/freelance", ...german })).toEqual({
kind: "redirect",
to: "/freelance",
permanent: true,
negotiated: false,
});
});
it("is listed in the sitemap in both languages", () => {
expect(
sitemapEntries("https://example.com", ["freelance"]).map(
(entry) => entry.url,
),
).toEqual([
"https://example.com/freelance",
"https://example.com/en/freelance",
]);
});
});
+11 -4
View File
@@ -6,7 +6,7 @@ import { LOCALES, preferredLocale, type Locale } from "./locale";
* Internally every page is rendered under its language (/de/..., /en/...);
* `routeRequest` maps one onto the other.
*/
export type PageId = "home" | "imprint" | "privacy";
export type PageId = "home" | "freelance" | "imprint" | "privacy";
type LanguageAlternates = Readonly<Record<Locale | "x-default", string>>;
@@ -31,18 +31,24 @@ export type SitemapEntry = {
type PagePaths = Readonly<Record<PageId, string>>;
const PAGES: readonly PageId[] = ["home", "imprint", "privacy"];
const PAGES: readonly PageId[] = ["home", "freelance", "imprint", "privacy"];
/** The path of a page below its language, as the app directory names it. */
const INTERNAL: PagePaths = {
home: "",
freelance: "/freelance",
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" },
de: {
home: "",
freelance: "/freelance",
imprint: "/impressum",
privacy: "/datenschutz",
},
en: INTERNAL,
};
@@ -111,8 +117,9 @@ export function routeRequest(request: {
const english = restAfter(request.pathname, PREFIX.en);
if (english !== null) {
// A German page name under /en is moved, unless the page has the same name in both languages.
const misnamed = pageAt(english, PUBLIC.de);
return misnamed === null || misnamed === "home"
return misnamed === null || PUBLIC.de[misnamed] === INTERNAL[misnamed]
? { kind: "rewrite", to: `/en${english}`, negotiated: false }
: redirect(pathFor(misnamed, "en"), true);
}
+7
View File
@@ -27,10 +27,17 @@ export default tseslint.config(
"**/.stryker-tmp/**",
"**/reports/**",
"**/coverage/**",
// Worktrees are separate checkouts; each is linted from its own root.
"**/.claude/**",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
// Worktrees under .claude/worktrees are checkouts with their own tsconfig.
// Without a fixed root the parser cannot decide which one a file belongs to.
languageOptions: { parserOptions: { tsconfigRootDir: __dirname } },
},
{
files: ["apps/web/**/*.{js,jsx,ts,tsx}"],
plugins: {