feat: contact topics per audience, room above the last sentence, a hero line that says what a client gets
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Canceled after 4m30s
Build & Deploy / 🚀 Deploy (push) Canceled after 0s
Build & Deploy / 🩺 Smoke Test (push) Canceled after 0s
Build & Deploy / 🔔 Notify (push) Canceled after 0s

Contact: the message starter offered every topic on both pages. The homepage
now offers automation, a website, or something else; the freelance page a
developer, a reliable app, or something else.

Thread: where it crosses the page above the last sentence it ran too close
to the text. It keeps more distance now, on phones too.

Hero: the line under the hero listed years, working hours and two links,
which says nothing to a client (owner). It now names four things a client
gets: one contact who builds it himself, an offer before anything costs
money, changes by email, servers in Germany.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 12:32:33 +02:00
co-authored by Claude Opus 5.5
parent af66f61c26
commit 2cb15608dd
10 changed files with 51 additions and 37 deletions
+4 -1
View File
@@ -29,6 +29,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
/** What a team may ask for. Clients have their own on the homepage. */
const TEAM_TOPICS = ["developer", "rescue", "other"] as const;
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";
@@ -255,7 +258,7 @@ export default async function FreelancePage({ params }: Props) {
</Reveal>
</Block>
<ClosingCta contact={content.contact} />
<ClosingCta contact={content.contact} topics={TEAM_TOPICS} />
</div>
);
}
+8 -1
View File
@@ -39,6 +39,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}
/** What a client of the homepage may ask for. Teams have their own on the freelance page. */
const HOME_TOPICS = ["automation", "website", "other"] as const;
const MUTED = "text-slate-400";
const BODY = "m-0 font-sans text-lg leading-relaxed text-slate-500 md:text-xl";
const LIST_TITLE =
@@ -270,7 +273,11 @@ export default async function LandingPage({ params }: Props) {
</Reveal>
</Block>
<ClosingCta contact={content.contact} story={story.contact} />
<ClosingCta
contact={content.contact}
topics={HOME_TOPICS}
story={story.contact}
/>
<StoryThread />
</div>
);
+5 -3
View File
@@ -10,9 +10,11 @@ import { ContactComposer } from "./ContactComposer";
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
export const ClosingCta: React.FC<{
contact: Content["contact"];
/** What visitors of this page may want: the topics of the message starter. */
topics: readonly string[];
/** The last sentence of the story; the story thread ends in front of it. */
story?: string;
}> = ({ contact, story }) => (
}> = ({ contact, topics, story }) => (
<section
id="contact"
data-glow
@@ -28,7 +30,7 @@ export const ClosingCta: React.FC<{
/>
<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-12 px-5 py-16 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 pb-16 pt-28 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
<Reveal className="lg:col-span-6">
{story && (
<p className="relative m-0 mb-10 max-w-xl font-sans text-2xl font-bold leading-[1.1] tracking-tight text-white md:mb-14 md:text-5xl">
@@ -55,7 +57,7 @@ export const ClosingCta: React.FC<{
</Reveal>
<Reveal delay={0.15} className="lg:col-span-6">
<BitAura source="around" tone="dark">
<ContactComposer />
<ContactComposer topicIds={topics} />
</BitAura>
</Reveal>
</div>
@@ -51,9 +51,16 @@ const withBlanks = (text: string): React.ReactNode =>
* opens it in the visitor's own mail app, ready to finish and send. The address
* is assembled in the browser, so it never appears in the page source.
*/
export const ContactComposer: React.FC = () => {
export const ContactComposer: React.FC<{
/** The topics this page offers, in this order. */
topicIds: readonly string[];
}> = ({ topicIds }) => {
const { contact } = useContent();
const topics = contact.topics;
const topics = React.useMemo(
() =>
topicIds.flatMap((id) => contact.topics.filter((item) => item.id === id)),
[contact.topics, topicIds],
);
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
const [address, setAddress] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
+7 -22
View File
@@ -43,35 +43,20 @@ export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
</div>
</div>
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
{/* What a client gets, in a few words. */}
<ul className="m-0 mt-14 grid list-none grid-cols-1 gap-x-10 gap-y-3 border-t border-slate-200 p-0 pt-6 sm:grid-cols-2 md:mt-20 lg:grid-cols-4">
{hero.trust.map((item) => (
<li
key={item}
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
className="flex items-start gap-3 font-sans text-sm leading-snug text-slate-700 md:text-base"
>
<span
className="mt-[0.45em] h-1.5 w-1.5 shrink-0 bg-indigo-600"
aria-hidden="true"
/>
{item}
</li>
))}
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
{hero.builtAndRunning}{" "}
<a
href="https://klz-cables.com"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 underline-offset-4 hover:underline"
>
klz-cables.com
</a>
{" · "}
<a
href="https://e-tib.com"
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 underline-offset-4 hover:underline"
>
e-tib.com
</a>
</li>
</ul>
</div>
</section>
@@ -29,6 +29,8 @@ const CONTENT_WIDTH = 1400;
/** Offset of the second, parallel trace. */
const BUS_OFFSET = 5;
const PAD_RADIUS = 4;
/** Room between the last crossing of the trace and the sentence it ends at. */
const END_DROP = { wide: 120, narrow: 84 } as const;
/** The bit at the head of the trace flips after this much travel, in px. */
const FLIP_EVERY = 46;
/** The first chapter with a figure is the second of the page. */
@@ -127,6 +129,7 @@ export const StoryThread: React.FC = () => {
start: [left, boxOf(startSection).y + 1],
stops,
end: endBox ? [endBox.x, endBox.y] : null,
endDrop: origin.width >= 768 ? END_DROP.wide : END_DROP.narrow,
left,
right,
});
+3 -3
View File
@@ -72,8 +72,6 @@ const POLYGONS: Readonly<Record<Exclude<Figure, "circle">, readonly Point[]>> =
};
const CORNER = 36;
/** How far above its end the trace crosses the page when it comes from the other side. */
const END_DROP = 64;
const round = (value: number): number => Math.round(value * 10) / 10;
/** The figure as path commands. `direction` is 1 when the line arrives heading right, -1 heading left. */
@@ -111,6 +109,8 @@ export const threadPath = (thread: {
readonly start: Point;
readonly stops: readonly Stop[];
readonly end: Point | null;
/** How far above its end the trace crosses the page when it comes from the other side. */
readonly endDrop: number;
readonly left: number;
readonly right: number;
}): Thread => {
@@ -144,7 +144,7 @@ export const threadPath = (thread: {
// The end is on the other side: cross the page above it, then come
// straight down to it, beside the text. One long diagonal instead would start far up the page.
const inward = x < side ? -1 : 1;
const across = round(y - END_DROP);
const across = round(y - thread.endDrop);
d +=
`L${side} ${across - CORNER}L${side + inward * CORNER} ${across}` +
`L${round(x - inward * CORNER)} ${across}L${round(x)} ${across + CORNER}L${round(x)} ${round(y)}`;
+6 -2
View File
@@ -44,8 +44,12 @@ export const de: Content = {
leadStrong: "Ich baue Web-Apps, Interfaces und Websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "Was ich mache",
trust: ["15 Jahre im Web", "Remote, EU-Kernzeiten"],
builtAndRunning: "Gebaut und in Betrieb:",
trust: [
"Ein Ansprechpartner, der es selbst baut",
"Ein Angebot, bevor etwas kostet",
"Änderungen per E-Mail, kein System zu lernen",
"Server in Deutschland",
],
},
hello: {
label: "Hallo",
+6 -2
View File
@@ -43,8 +43,12 @@ export const en: Content = {
leadStrong: "I build web apps, interfaces and websites.",
primaryCta: GET_IN_TOUCH,
secondaryCta: "What I do",
trust: ["15 years in web", "Remote, EU core hours"],
builtAndRunning: "Built and running:",
trust: [
"One contact, who builds it himself",
"An offer before anything costs money",
"Changes by email, no system to learn",
"Servers in Germany",
],
},
hello: {
label: "Hello",
-1
View File
@@ -171,7 +171,6 @@ export type Content = {
readonly primaryCta: string;
readonly secondaryCta: string;
readonly trust: readonly string[];
readonly builtAndRunning: string;
};
readonly hello: {
readonly label: string;