first publish
This commit is contained in:
commit
b8e41b4d55
147 files changed
+19367
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
|
||||
export interface ButtonProps {
|
||||
href: string;
|
||||
label: string;
|
||||
/** Mono suffix that rises out of a mask inside the button on hover (≥ 1024); its width is reserved. */
|
||||
suffix?: string;
|
||||
/** Mono micro line under the button, e.g. „Telefonisch oder online · Deutsch oder Englisch“. */
|
||||
micro?: string;
|
||||
/** `primary` = 1 px accent outline + gold sheen fill on hover; `ghost` = neutral hairline, no fill. */
|
||||
variant?: "primary" | "ghost";
|
||||
/** `target="_blank" rel="noopener noreferrer"`. */
|
||||
external?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const BUTTON_BASE =
|
||||
"group/btn relative inline-flex items-center overflow-hidden whitespace-nowrap border px-[2.1em] py-[1.05em] font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-fg transition-[color,background-color,border-color] duration-300";
|
||||
|
||||
/* Sheen fill: --gold-sheen at 250 % width, background-position 0 % → 100 % in .6 s. Text turns ink. */
|
||||
export const BUTTON_PRIMARY =
|
||||
"border-accent hover:text-ink focus-visible:text-ink before:pointer-events-none before:absolute before:inset-0 before:bg-[image:var(--gold-sheen)] before:bg-[length:250%_100%] before:bg-[position:0%_50%] before:opacity-0 before:transition-[opacity,background-position] before:duration-[600ms] before:ease-[var(--ease-out-quart)] hover:before:bg-[position:100%_50%] hover:before:opacity-100 focus-visible:before:bg-[position:100%_50%] focus-visible:before:opacity-100";
|
||||
|
||||
const GHOST = "border-line hover:border-accent focus-visible:border-accent";
|
||||
|
||||
function isInternal(href: string) {
|
||||
return href.startsWith("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Outline button (radius 0, no shadow). Internal routes use the curtain
|
||||
* (`TransitionLink`); mailto/tel/http render a plain `<a>`.
|
||||
*/
|
||||
export function Button({ href, label, suffix, micro, variant = "primary", external = false, className }: ButtonProps) {
|
||||
const cls = cn(BUTTON_BASE, variant === "primary" ? BUTTON_PRIMARY : GHOST, className);
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<span className="relative z-10">{label}</span>
|
||||
{suffix && (
|
||||
/* The mask sits INSIDE the button and its width is reserved, so the
|
||||
control never resizes under the pointer – only the line rises. */
|
||||
<span aria-hidden="true" className="relative z-10 hidden overflow-hidden lg:block">
|
||||
<span className="block translate-y-full whitespace-nowrap pl-3 font-mono text-[11px] normal-case leading-[1.6] tracking-[0.06em] transition-transform duration-[350ms] ease-[var(--ease-out-quart)] group-hover/btn:translate-y-0 group-focus-visible/btn:translate-y-0">
|
||||
{suffix}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const link =
|
||||
isInternal(href) && !external ? (
|
||||
<TransitionLink href={href} label={label} className={cls}>
|
||||
{inner}
|
||||
</TransitionLink>
|
||||
) : (
|
||||
<a href={href} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined} className={cls}>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!micro) return link;
|
||||
return (
|
||||
<span className="inline-flex flex-col items-start gap-3">
|
||||
{link}
|
||||
<span className="font-mono text-[11px] tracking-[0.06em] text-fg-muted">{micro}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||
import type { ColumnItem } from "@/content/home";
|
||||
import { TextLink } from "./TextLink";
|
||||
|
||||
export type { ColumnItem };
|
||||
|
||||
export interface ColumnsProps {
|
||||
items: readonly ColumnItem[];
|
||||
/** Middle column offset 48 px downward (deliberate asymmetry, desktop). Default true. */
|
||||
offsetMiddle?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three editorial columns (gap 48 px): DM Mono label, Bodoni 28 px title,
|
||||
* Jost 17 px lines separated by hairlines (no bullets, no icons), one
|
||||
* specific text link. Stacks below 1024 px.
|
||||
*/
|
||||
export function Columns({ items, offsetMiddle = true, className }: ColumnsProps) {
|
||||
return (
|
||||
<RevealGroup stagger={0.12} className={cn("grid grid-cols-1 gap-x-12 gap-y-14 lg:grid-cols-3", className)}>
|
||||
{items.map((col, i) => (
|
||||
<RevealItem
|
||||
key={col.title}
|
||||
className={cn("flex flex-col", offsetMiddle && i === 1 && "lg:mt-12")}
|
||||
>
|
||||
{col.label && <p className="mb-4 font-mono text-xs tracking-[0.06em] text-accent">{col.label}</p>}
|
||||
<h3 className="font-display text-[1.75rem] font-normal leading-[1.15] text-fg">{col.title}</h3>
|
||||
{col.lines && col.lines.length > 0 && (
|
||||
<ul className="mt-6 border-t border-line">
|
||||
{col.lines.map((line) => (
|
||||
<li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg">
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{col.link && (
|
||||
<div className="mt-6">
|
||||
<TextLink href={col.link.href} label={col.link.label} external={col.link.external} />
|
||||
</div>
|
||||
)}
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default Columns;
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { site, routes } from "@/lib/site";
|
||||
import { contact as contactDe, type HomeContent } from "@/content/home";
|
||||
import { BUTTON_BASE, BUTTON_PRIMARY } from "@/components/ui/Button";
|
||||
import { TextLink } from "@/components/ui/TextLink";
|
||||
|
||||
/**
|
||||
* Kontaktformular der Startseite (Sektion 06).
|
||||
*
|
||||
* **Es gibt keinen Server.** Die Website ist ein statischer Export und sagt in der
|
||||
* Datenschutzerklärung zu, dass sie keine Drittanbieter einbindet und keine Daten
|
||||
* überträgt. Ein Formular, das irgendwohin „postet“, würde dieses Versprechen brechen
|
||||
* (und einen Auftragsverarbeitungsvertrag nötig machen). Deshalb baut das Formular aus
|
||||
* den Eingaben eine fertige E-Mail und übergibt sie dem Mailprogramm der Besucherin.
|
||||
* Die Daten verlassen den Rechner erst, wenn sie dort auf „Senden“ drückt.
|
||||
*
|
||||
* Soll später doch ein echtes Formular her (Eingaben landen direkt im Postfach, ohne
|
||||
* Mailprogramm), braucht es einen Endpunkt beim Hoster oder einen Formulardienst. Dann
|
||||
* sind zwei Dinge Pflicht: ein Auftragsverarbeitungsvertrag und ein neuer Absatz in
|
||||
* `src/content/legal.ts`. Der Umbau beträfe nur `submit()` hier in dieser Datei.
|
||||
*/
|
||||
export function ContactForm({
|
||||
className,
|
||||
content: form = contactDe.form,
|
||||
privacyHref = routes.datenschutz,
|
||||
}: {
|
||||
className?: string;
|
||||
/** Ziel des Datenschutz-Links – in der englischen Fassung /en/privacy/. */
|
||||
privacyHref?: string;
|
||||
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||
content?: HomeContent["contact"]["form"];
|
||||
}) {
|
||||
const uid = useId();
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const field = (name: string) => `${uid}-${name}`;
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const value = (key: string) => String(data.get(key) ?? "").trim();
|
||||
|
||||
const body = [
|
||||
`${form.fields.name}: ${value("name")}`,
|
||||
`${form.fields.email}: ${value("email")}`,
|
||||
value("company") ? `${form.fields.company}: ${value("company")}` : null,
|
||||
"",
|
||||
value("message"),
|
||||
]
|
||||
.filter((line) => line !== null)
|
||||
.join("\r\n");
|
||||
|
||||
const url = `mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`;
|
||||
setSent(true);
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const label = "mb-2 block font-mono text-[11px] uppercase tracking-[0.16em] text-fg-muted";
|
||||
const input =
|
||||
"w-full border-0 border-b border-line bg-transparent pb-3 font-sans text-[1.0625rem] text-fg outline-none transition-colors duration-300 placeholder:text-transparent focus:border-accent";
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} noValidate={false} className={cn("flex flex-col gap-8", className)}>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor={field("name")} className={label}>
|
||||
{form.fields.name}
|
||||
</label>
|
||||
<input id={field("name")} name="name" type="text" required autoComplete="name" className={input} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={field("email")} className={label}>
|
||||
{form.fields.email}
|
||||
</label>
|
||||
<input id={field("email")} name="email" type="email" required autoComplete="email" className={input} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={field("company")} className={label}>
|
||||
{form.fields.company}
|
||||
</label>
|
||||
<input id={field("company")} name="company" type="text" autoComplete="organization" className={input} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor={field("message")} className={label}>
|
||||
{form.fields.message}
|
||||
</label>
|
||||
<textarea id={field("message")} name="message" required rows={5} className={cn(input, "resize-y")} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<button type="submit" className={cn(BUTTON_BASE, BUTTON_PRIMARY, "self-start")} aria-describedby={field("note")}>
|
||||
<span className="relative z-10">{form.submit}</span>
|
||||
</button>
|
||||
<p id={field("note")} className="max-w-[52ch] font-mono text-[11px] leading-relaxed tracking-[0.04em] text-fg-muted">
|
||||
{sent ? form.opened : form.note}{" "}
|
||||
<TextLink href={privacyHref} label={form.privacy} className="!text-[11px] !tracking-[0.1em]" />
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContactForm;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { contact as contactDe, type HomeContent } from "@/content/home";
|
||||
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||
|
||||
export interface ContactLinksProps {
|
||||
className?: string;
|
||||
/** `lg:sticky lg:top-[30vh]` (homepage contact block). */
|
||||
sticky?: boolean;
|
||||
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||
content?: HomeContent["contact"]["links"];
|
||||
}
|
||||
|
||||
const UNDERLINE =
|
||||
"bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-1 transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] group-hover/cl:bg-[length:100%_1px] group-focus-visible/cl:bg-[length:100%_1px]";
|
||||
|
||||
/**
|
||||
* Schreib mir / Ruf an / Vernetzen – three large links (DM Mono 11 px label +
|
||||
* Bodoni value with a growing accent underline). Data from content/home.
|
||||
*/
|
||||
export function ContactLinks({ className, sticky = false, content = contactDe.links }: ContactLinksProps) {
|
||||
const items = [content.write, content.call, content.connect] as const;
|
||||
return (
|
||||
<RevealGroup as="ul" stagger={0.08} className={cn("flex flex-col gap-7", sticky && "lg:sticky lg:top-[30vh]", className)}>
|
||||
{items.map((item) => {
|
||||
const external = "external" in item && item.external;
|
||||
return (
|
||||
<RevealItem key={item.label} as="li" className="flex flex-col gap-2">
|
||||
<span className="font-mono text-[11px] tracking-[0.06em] text-fg-muted">{item.label}</span>
|
||||
<a
|
||||
href={item.href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className="group/cl inline-flex max-w-full items-baseline gap-2 self-start font-display text-[clamp(1.75rem,1.15rem+1vw,2.125rem)] font-normal leading-[1.15] text-fg [font-variant-numeric:lining-nums]"
|
||||
>
|
||||
<span className={cn(UNDERLINE, "min-w-0 text-accent")}>
|
||||
{/* An address broken at its hyphen reads like a typo – never wrap the value. */}
|
||||
<span className="whitespace-nowrap text-fg">{item.value}</span>
|
||||
</span>
|
||||
{external && (
|
||||
<span aria-hidden="true" className="text-accent transition-transform duration-300 group-hover/cl:translate-x-1">
|
||||
↗
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</RevealItem>
|
||||
);
|
||||
})}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContactLinks;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { GRID } from "@/components/layout/Section";
|
||||
import { Lines } from "@/components/motion/Lines";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Rule } from "@/components/motion/Rule";
|
||||
import type { Headline } from "@/content/home";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { ContactLinks } from "./ContactLinks";
|
||||
|
||||
export type { Headline };
|
||||
|
||||
export interface CtaBandProps {
|
||||
/** One italic word via `em` (may be empty). */
|
||||
h2: Headline;
|
||||
cta: ButtonProps;
|
||||
/** Mono micro line under the button (falls back to `cta.micro`). */
|
||||
micro?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Renders {before, em, after} with exactly one italic `<em>`. */
|
||||
export function HeadlineText({ before, em, after }: Headline) {
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
{em && <em className="italic">{em}</em>}
|
||||
{after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dark contact band (~70svh) before the footer: boundary hairline, page-
|
||||
* specific H2 (Bodoni, 44–96 px), the three contact links, the page's one
|
||||
* primary CTA. Paints its own ink ground; the seam to the ivory above is the
|
||||
* gold hairline alone (spec §2.3) – no fade, no gradient.
|
||||
*/
|
||||
export function CtaBand({ h2, cta, micro, id = "kontakt", className }: CtaBandProps) {
|
||||
return (
|
||||
<section id={id} data-theme="dark" className={cn("relative text-fg", className)}>
|
||||
<Rule variant="gold" className="relative z-[1]" />
|
||||
<Container className="relative z-[1] flex min-h-[70svh] flex-col justify-center py-[clamp(6rem,14vh,12rem)]">
|
||||
<div className={cn(GRID, "gap-y-14")}>
|
||||
<div className="col-span-12 lg:col-start-3 lg:col-span-6">
|
||||
<Lines
|
||||
as="h2"
|
||||
className="max-w-[14ch] font-display text-[clamp(2.75rem,1.6rem+4.4vw,6rem)] font-normal leading-[1.02] tracking-[-0.01em] text-ivory [text-wrap:balance]"
|
||||
>
|
||||
<HeadlineText {...h2} />
|
||||
</Lines>
|
||||
<Reveal delay={0.3} className="mt-12">
|
||||
<Button {...cta} micro={micro ?? cta.micro} />
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-start-9 lg:col-span-4">
|
||||
<ContactLinks />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CtaBand;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface EyebrowProps {
|
||||
as?: "p" | "span" | "h2" | "h3" | "div";
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Shared caps style (Jost 500, 11.5 px, ls .28em) – reused by sign-offs and labels. */
|
||||
export const EYEBROW_TEXT = "font-sans text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em]";
|
||||
|
||||
/**
|
||||
* Eyebrow label with a 24 × 1 px gold tick in front (`::before`).
|
||||
* Colour follows the live accent: `--gold` on dark, `--gold-deep` on light.
|
||||
*
|
||||
* `items-start` + `leading-[1.5]`: two of the three service heroes wrap this
|
||||
* label at 390 px, and with `items-center`/`leading-none` the two caps lines
|
||||
* sat 13 px apart with the tick floating in the gap. The tick now sits on the
|
||||
* optical centre of the FIRST line, where it belongs.
|
||||
*/
|
||||
export function Eyebrow({ as = "p", className, children }: EyebrowProps) {
|
||||
const Tag = as;
|
||||
return (
|
||||
<Tag
|
||||
className={cn(
|
||||
EYEBROW_TEXT,
|
||||
/* Long single words („Berufshaftpflichtversicherung“) must break, never widen the page. */
|
||||
"inline-flex max-w-full items-start gap-3 leading-[1.5] text-accent hyphens-auto [overflow-wrap:anywhere] before:mt-[0.75em] before:h-px before:w-6 before:shrink-0 before:bg-accent before:content-['']",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export default Eyebrow;
|
||||
@@ -0,0 +1,102 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { flags } from "@/lib/site";
|
||||
import { isPlaceholder, splitPlaceholders } from "@/lib/legal";
|
||||
import type { FactItem } from "@/content/home";
|
||||
|
||||
export type { FactItem };
|
||||
|
||||
export interface FactsProps {
|
||||
items: readonly FactItem[];
|
||||
/**
|
||||
* `stack` = mono lines with gold ticks (hero, top right).
|
||||
* `table` = two-column label / value table on bone with a hairline frame (Rahmen).
|
||||
*/
|
||||
variant?: "stack" | "table";
|
||||
/** Table heading (DM Mono caps), e.g. „Rahmen“. */
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders „[…]“ segments in the accent colour (`--gold-deep` on light, `--gold` on dark).
|
||||
*
|
||||
* Deliberately flag-agnostic: cutting a placeholder out of a running sentence leaves
|
||||
* fragments („Auswertungsgespräch .“), so the launch switch is a build guard, not a
|
||||
* strip – `assertNoPlaceholders` in every content module aborts the build while a
|
||||
* placeholder is still open (src/lib/legal.ts, list in CONTENT_TODO).
|
||||
*/
|
||||
export function PlaceholderText({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
{splitPlaceholders(text).map((seg, i) =>
|
||||
seg.placeholder ? (
|
||||
<span key={i} className="text-accent">
|
||||
{seg.text}
|
||||
</span>
|
||||
) : (
|
||||
<span key={i}>{seg.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Drops whole placeholder ROWS when `flags.showPlaceholders` is off (inline placeholders
|
||||
inside a value are the build guard's job, not this filter's – see PlaceholderText). */
|
||||
export function visibleFacts(items: readonly FactItem[]): FactItem[] {
|
||||
return items.filter((f) => flags.showPlaceholders || !(f.placeholder || isPlaceholder(f.value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Facts stack (Format · Sprache · Ort · Dauer): DM Mono 12 px, one gold tick
|
||||
* per line, label muted, value in the foreground colour. Both variants are
|
||||
* ledgers – tick / label / value are columns with one left edge each, not an
|
||||
* inline run that starts the value at a different x on every row.
|
||||
*/
|
||||
export function Facts({ items, variant = "stack", title, className }: FactsProps) {
|
||||
const rows = visibleFacts(items);
|
||||
if (!rows.length) return null;
|
||||
|
||||
if (variant === "table") {
|
||||
return (
|
||||
<div className={cn("border border-line bg-surface p-6 sm:p-10", className)}>
|
||||
{title && <p className="mb-6 font-mono text-xs uppercase tracking-[0.12em] text-accent">{title}</p>}
|
||||
<dl className="font-mono text-xs tracking-[0.06em] tabular-nums">
|
||||
{rows.map((f) => (
|
||||
/* Below sm the value column would be ~150 px inside the padded box and long
|
||||
values („Inhouse-Workshop 1–2 Tage / Trainingsreihe / Online“) break over four
|
||||
lines – so the label sits above the value there, identically on every page. */
|
||||
<div key={f.label} className="grid grid-cols-1 gap-x-6 gap-y-1 border-b border-line py-3 last:border-b-0 sm:grid-cols-[1fr_3fr]">
|
||||
<dt className="text-fg-muted">{f.label}</dt>
|
||||
<dd className="text-fg">
|
||||
<PlaceholderText text={f.value} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
/* Three rails: tick | label (max-content, so every label ends on one edge) | value. */
|
||||
<dl
|
||||
className={cn(
|
||||
"grid grid-cols-[1.5rem_max-content_1fr] gap-x-3 gap-y-3 font-mono text-xs tracking-[0.06em] tabular-nums",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{rows.map((f) => (
|
||||
<div key={f.label} className="contents">
|
||||
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 self-start bg-accent" />
|
||||
<dt className="text-fg-muted">{f.label}</dt>
|
||||
<dd className="text-fg">
|
||||
<PlaceholderText text={f.value} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export default Facts;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FactItem } from "@/content/home";
|
||||
import { PlaceholderText, visibleFacts } from "./Facts";
|
||||
|
||||
export interface FactsStripProps {
|
||||
items: readonly FactItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker facts: one full-width row of equal cells separated by 1 px
|
||||
* vertical hairlines. Each cell = DM Mono label (muted) + Jost value.
|
||||
* Wraps to 2 / 3 columns below 1024 px.
|
||||
*/
|
||||
export function FactsStrip({ items, className }: FactsStripProps) {
|
||||
const rows = visibleFacts(items);
|
||||
if (!rows.length) return null;
|
||||
return (
|
||||
<dl
|
||||
className={cn(
|
||||
"grid grid-cols-2 border-y border-line sm:grid-cols-3 lg:grid-cols-6",
|
||||
"[&>div]:border-l [&>div]:border-line [&>div:first-child]:border-l-0",
|
||||
"[&>div:nth-child(2n+1)]:max-sm:border-l-0 [&>div:nth-child(3n+1)]:sm:max-lg:border-l-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{rows.map((f) => (
|
||||
<div key={f.label} className="flex flex-col gap-2 px-5 py-6 lg:px-6 lg:py-7">
|
||||
<dt className="font-mono text-xs tracking-[0.06em] text-fg-muted">{f.label}</dt>
|
||||
<dd className="font-sans text-[1.0625rem] leading-snug text-fg">
|
||||
<PlaceholderText text={f.value} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export default FactsStrip;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FaqItem } from "@/content/home";
|
||||
|
||||
export type { FaqItem };
|
||||
|
||||
export interface FaqProps {
|
||||
items: readonly FaqItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** DM Mono „+“ that turns into „ד by rotating 45° when the `<details>` is open. */
|
||||
export const PLUS_GLYPH =
|
||||
"font-mono text-base leading-none text-accent transition-transform duration-300 ease-[var(--ease-out-quart)] group-open:rotate-45";
|
||||
|
||||
/**
|
||||
* Native `<details>/<summary>` ledger: question Bodoni 28 px, answer Jost 400,
|
||||
* hairline-separated, no accordion library. Fully usable without JS.
|
||||
*/
|
||||
export function Faq({ items, className }: FaqProps) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className={cn("border-t border-line", className)}>
|
||||
{items.map((item) => (
|
||||
<details key={item.q} className="group border-b border-line">
|
||||
<summary className="flex cursor-pointer list-none items-baseline justify-between gap-6 py-6 [&::-webkit-details-marker]:hidden">
|
||||
<span className="font-display text-[1.75rem] font-normal leading-[1.15] text-fg">{item.q}</span>
|
||||
<span aria-hidden="true" className={cn(PLUS_GLYPH, "shrink-0 self-center")}>
|
||||
+
|
||||
</span>
|
||||
</summary>
|
||||
<p className="max-w-[36rem] pb-8 font-sans text-[1.0625rem] leading-[1.6] text-fg">{item.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Faq;
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { gsap, DUR, MM } from "@/lib/gsap";
|
||||
import { STROKE_IN, WIPE_IN, primeMark } from "@/components/motion/Draw";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { GRID } from "@/components/layout/Section";
|
||||
import type { LinkItem } from "@/content/home";
|
||||
import { Eyebrow, EYEBROW_TEXT } from "./Eyebrow";
|
||||
import { Numeral } from "./Numeral";
|
||||
import { TextLink } from "./TextLink";
|
||||
|
||||
export interface FormulaLine {
|
||||
/** Operator vor der Zeile (DM Mono, 0.5em, gold). Genutzt werden nur „+“ und „=“;
|
||||
* der Typ ist `string`, weil die Inhaltsmodule für die zweite Sprache strukturell
|
||||
* typisiert sind und Literaltypen dort nicht durchgereicht werden. */
|
||||
op?: string;
|
||||
text: string;
|
||||
/** The one italic word; `goldEm` makes it the page's single `.gold-text` word. */
|
||||
em?: string;
|
||||
goldEm?: boolean;
|
||||
/** Zwei-Satz-Erläuterung rechts neben der Zeile. Optional. */
|
||||
definition?: string;
|
||||
/** „Liefert: …“ Optional, nur zusammen mit `definition` sinnvoll. */
|
||||
delivers?: string;
|
||||
}
|
||||
|
||||
export interface FormulaProps {
|
||||
lines: readonly FormulaLine[];
|
||||
/** Vorlesefassung der Operatoren, z. B. `{ "+": "plus ", "=": "gleich " }`. */
|
||||
opLabels?: Readonly<Record<string, string>>;
|
||||
/** „Unlock Your Potential — Next Level“ – optional, wird ohne Wert weggelassen. */
|
||||
signoff?: string;
|
||||
/** Mobile meta line above the eyebrow, e.g. „03 / 06“ (desktop shows the numeral). */
|
||||
meta?: string;
|
||||
link: LinkItem;
|
||||
/** „Das WOLF-Prinzip“ */
|
||||
eyebrow?: string;
|
||||
/** „03“ – Bodoni `.gold-text` numeral in cols 1–2. */
|
||||
numeral?: string;
|
||||
/** `<BrandMark/>` (Server Component) – drawn scrubbed 70–95 % on desktop, once at 80 % in view otherwise. */
|
||||
mark?: ReactNode;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* Positions on a 100-unit scrub timeline (spec §6.4). Line 1 starts at 0 and is
|
||||
fully revealed by 9 % – the 200 svh stage never opens on an empty screen. */
|
||||
const AT = { fades: 0, lines: [0, 36, 68], wolf: 70, wolfEnd: 95, signoff: 95 } as const;
|
||||
const LINE_DUR = 9;
|
||||
|
||||
/**
|
||||
* Das WOLF-Prinzip typeset as a formula. Desktop (≥ 1024, fine pointer, no
|
||||
* reduced motion): the inner 100svh container is CSS-sticky inside a 200svh section (scrub 0.4)
|
||||
* and reveals line 1/2/3 via clip-path at 0/36/68 %, definitions alongside,
|
||||
* a single sheen over the gold word, the wolf drawn 70–95 %, sign-off at 95 %.
|
||||
* Mobile / reduced motion: natural stacked flow (the 200 svh stage collapses to
|
||||
* its content height), wolf drawn once.
|
||||
* Ink only – the themed block around it paints the ground.
|
||||
*/
|
||||
export function Formula({ lines, opLabels, signoff, link, eyebrow, numeral, mark, meta, id = "prinzip", className }: FormulaProps) {
|
||||
const outer = useRef<HTMLElement>(null);
|
||||
const inner = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const outerEl = outer.current;
|
||||
const innerEl = inner.current;
|
||||
if (!outerEl || !innerEl) return;
|
||||
|
||||
const q = gsap.utils.selector(outerEl);
|
||||
const fades = q<HTMLElement>("[data-f='eyebrow'], [data-f='numeral']");
|
||||
const lineEls = q<HTMLElement>("[data-f='line']");
|
||||
const defs = q<HTMLElement>("[data-f='def']");
|
||||
const ops = q<HTMLElement>("[data-f='op']");
|
||||
const ems = q<HTMLElement>("[data-f='em']");
|
||||
const signoffEls = q<HTMLElement>("[data-f='signoff']");
|
||||
const markEl = outerEl.querySelector<HTMLElement>("[data-f='mark']");
|
||||
const paths = Array.from(outerEl.querySelectorAll<SVGPathElement>("path[data-draw]"));
|
||||
const markSvg = outerEl.querySelector<SVGSVGElement>("[data-f='mark'] svg");
|
||||
|
||||
const showAll = () => {
|
||||
gsap.set([...fades, ...defs, ...ops, ...signoffEls], { opacity: 1 });
|
||||
gsap.set(lineEls, { clipPath: "inset(0% 0% 0% 0%)" });
|
||||
};
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add({ reduce: MM.reduce, ok: MM.ok, desktop: MM.desktop }, (ctx) => {
|
||||
const { reduce, desktop } = ctx.conditions ?? {};
|
||||
|
||||
if (reduce || !desktop) {
|
||||
showAll();
|
||||
if (!reduce && paths.length && markEl) {
|
||||
const m = primeMark(markSvg, paths);
|
||||
gsap.to(m === "wipe" ? markSvg : paths, {
|
||||
...(m === "wipe" ? WIPE_IN : STROKE_IN),
|
||||
duration: DUR.draw,
|
||||
ease: "power2.inOut",
|
||||
...(m === "wipe" ? {} : { stagger: 0.12 }),
|
||||
scrollTrigger: { trigger: markEl, start: "top 80%", once: true },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: initial states, then one scrubbed, pinned timeline (0–100).
|
||||
/* Opacity only, never `autoAlpha` (spec A/D3): `visibility: hidden` would take the
|
||||
sign-off's TextLink – the section's only link – out of the tab order until the
|
||||
scrub reaches 95 %. It stays focusable; `focus-within` in the markup paints it. */
|
||||
gsap.set([...fades, ...defs, ...ops, ...signoffEls], { opacity: 0 });
|
||||
gsap.set(lineEls, { clipPath: "inset(0% 0% 100% 0%)" });
|
||||
if (ems.length) gsap.set(ems, { backgroundSize: "250% 100%", backgroundPosition: "0% 50%" });
|
||||
const markMode = primeMark(markSvg, paths);
|
||||
|
||||
let sheened = false;
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
scrollTrigger: {
|
||||
trigger: outerEl,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.4,
|
||||
invalidateOnRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(fades, { opacity: 1, duration: 12, stagger: 3 }, AT.fades);
|
||||
|
||||
lineEls.forEach((line, i) => {
|
||||
const at = AT.lines[i] ?? AT.lines[AT.lines.length - 1] + 25 * (i - AT.lines.length + 1);
|
||||
const op = line.querySelector<HTMLElement>("[data-f='op']");
|
||||
tl.to(line, { clipPath: "inset(0% 0% 0% 0%)", duration: LINE_DUR }, at);
|
||||
if (op) tl.to(op, { opacity: 1, duration: 6 }, at);
|
||||
if (defs[i]) tl.to(defs[i], { opacity: 1, duration: 10 }, at + LINE_DUR);
|
||||
});
|
||||
|
||||
if (ems.length) {
|
||||
tl.call(
|
||||
() => {
|
||||
if (sheened) return;
|
||||
sheened = true;
|
||||
gsap.fromTo(
|
||||
ems,
|
||||
{ backgroundPosition: "0% 50%" },
|
||||
{ backgroundPosition: "100% 50%", duration: 1.2, ease: "power2.inOut" },
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
AT.lines[2] + 8,
|
||||
);
|
||||
}
|
||||
|
||||
if (paths.length) {
|
||||
const span = AT.wolfEnd - AT.wolf;
|
||||
if (markMode === "wipe") {
|
||||
tl.to(markSvg, { ...WIPE_IN, duration: span }, AT.wolf);
|
||||
} else {
|
||||
const each = span / (1 + (paths.length - 1) * 0.35);
|
||||
paths.forEach((p, i) => {
|
||||
tl.to(p, { ...STROKE_IN, duration: each }, AT.wolf + i * each * 0.35);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tl.to(signoffEls, { opacity: 1, duration: 5 }, AT.signoff);
|
||||
// Keep the timeline exactly 100 units long so positions read as percent.
|
||||
if (tl.duration() < 100) tl.set({}, {}, 100);
|
||||
});
|
||||
|
||||
return () => mm.revert();
|
||||
}, [lines.length]);
|
||||
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
ref={outer}
|
||||
data-theme="dark"
|
||||
className={cn("relative text-fg lg:h-[200svh] motion-reduce:lg:h-auto", className)}
|
||||
>
|
||||
<div
|
||||
ref={inner}
|
||||
className="lg:sticky lg:top-0 lg:flex lg:h-svh lg:flex-col lg:overflow-hidden motion-reduce:lg:static motion-reduce:lg:h-auto motion-reduce:lg:overflow-visible"
|
||||
>
|
||||
<Container className="flex flex-1 flex-col py-24 lg:py-0 lg:pb-10 lg:pt-24">
|
||||
<div className={cn(GRID, "flex-1 gap-y-10")}>
|
||||
{numeral && (
|
||||
<div data-f="numeral" data-reveal="" className="hidden lg:col-span-2 lg:block [.js_&]:opacity-0">
|
||||
<Numeral value={numeral} theme="dark" sticky={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="col-span-12 flex flex-col lg:col-span-10 lg:col-start-3">
|
||||
{meta && (
|
||||
<p className="mb-8 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted lg:hidden">{meta}</p>
|
||||
)}
|
||||
{/* The section's heading. Without it the home outline jumps from 02 straight
|
||||
to 04; no `[data-f=line]` may take the role – the three terms are a
|
||||
formula, and one of them carries `.gold-text`. */}
|
||||
{eyebrow && (
|
||||
<div data-f="eyebrow" data-reveal="" className="[.js_&]:opacity-0">
|
||||
<Eyebrow as="h2">{eyebrow}</Eyebrow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="my-auto flex flex-col gap-12 py-14 lg:gap-8 lg:py-4">
|
||||
{lines.map((line, i) => (
|
||||
<div key={i} className="grid grid-cols-1 gap-y-4 lg:grid-cols-10 lg:items-end lg:gap-x-8">
|
||||
<p
|
||||
data-f="line"
|
||||
data-reveal=""
|
||||
className={cn(
|
||||
"font-display text-[clamp(2.5rem,1.2rem+5.4vw,7.5rem)] font-normal leading-[1.0] tracking-[-0.01em] text-ivory [.js_&]:[clip-path:inset(0_0_100%_0)]",
|
||||
/* Ohne Erläuterung nimmt die Zeile die volle Breite. */
|
||||
line.definition || line.delivers ? "lg:col-span-6" : "lg:col-span-10",
|
||||
)}
|
||||
>
|
||||
{line.op && (
|
||||
<span
|
||||
data-f="op"
|
||||
aria-hidden="true"
|
||||
className="mr-[0.3em] inline-block translate-y-[-0.15em] font-mono text-[0.5em] font-normal text-gold [.js_&]:opacity-0"
|
||||
>
|
||||
{line.op}
|
||||
</span>
|
||||
)}
|
||||
{line.op && opLabels?.[line.op] && <span className="sr-only">{opLabels[line.op]}</span>}
|
||||
{line.text}
|
||||
{line.em && (
|
||||
<em data-f={line.goldEm ? "em" : undefined} className={cn("italic", line.goldEm && "gold-text")}>
|
||||
{line.em}
|
||||
</em>
|
||||
)}
|
||||
</p>
|
||||
{(line.definition || line.delivers) && (
|
||||
<div data-f="def" data-reveal="" className="flex flex-col gap-3 lg:col-span-4 lg:pb-[0.35em] [.js_&]:opacity-0">
|
||||
{line.definition && (
|
||||
<p className="max-w-[30ch] font-sans text-lg font-light leading-[1.45] text-grey-warm lg:text-base">{line.definition}</p>
|
||||
)}
|
||||
{line.delivers && <p className="font-mono text-xs tracking-[0.06em] text-gold">{line.delivers}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between">
|
||||
{/* `focus-within:!opacity-100` outranks the scrub's inline opacity, so tabbing
|
||||
to the link always paints it (and its 2 px focus ring) while the scrub keeps
|
||||
the spec's 95 % sign-off (§6.4). */}
|
||||
<div data-f="signoff" data-reveal="" className="flex flex-col gap-5 focus-within:!opacity-100 [.js_&]:opacity-0">
|
||||
{/* Muted, not gold: the caps text link below must be the only gold caps line here. */}
|
||||
{signoff && <p className={cn(EYEBROW_TEXT, "text-fg-muted")}>{signoff}</p>}
|
||||
<TextLink href={link.href} label={link.label} external={link.external} />
|
||||
</div>
|
||||
{mark && (
|
||||
<div
|
||||
data-f="mark"
|
||||
aria-hidden="true"
|
||||
className="w-[40vw] max-w-[18rem] self-end text-ivory lg:h-[20vh] lg:w-auto lg:max-w-none [&_svg]:h-auto [&_svg]:w-full lg:[&_svg]:h-full lg:[&_svg]:w-auto"
|
||||
>
|
||||
{mark}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Formula;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { GRID } from "@/components/layout/Section";
|
||||
import { Lines } from "@/components/motion/Lines";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
|
||||
export interface InterruptionProps {
|
||||
/** „Mut zum Erfolg.“ */
|
||||
text: string;
|
||||
/** DM Mono line below, e.g. „Keynote · herCAREER Expo 2026 · 23.10. · 10:30 · Auditorium 2“. */
|
||||
mono?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typographic interruption (keynote page): 100svh ink, one giant Bodoni line
|
||||
* `clamp(4rem, 2rem + 12vw, 14rem)` in cols 2–12, mono meta in gold below.
|
||||
* Background stays transparent – <body> paints the ground.
|
||||
*/
|
||||
export function Interruption({ text, mono, id, className }: InterruptionProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
data-theme="dark"
|
||||
aria-label={text}
|
||||
className={cn("relative flex min-h-svh items-center bg-transparent py-24 text-fg", className)}
|
||||
>
|
||||
<Container className="w-full">
|
||||
<div className={GRID}>
|
||||
<div className="col-span-12 lg:col-start-2 lg:col-span-11">
|
||||
<Lines
|
||||
as="p"
|
||||
className="font-display text-[clamp(4rem,2rem+12vw,14rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
|
||||
>
|
||||
{text}
|
||||
</Lines>
|
||||
{mono && (
|
||||
<Reveal as="p" delay={0.3} className="mt-8 font-mono text-xs tracking-[0.06em] tabular-nums text-gold">
|
||||
{mono}
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Interruption;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||
import { termine as termineDe } from "@/content/home";
|
||||
import type { GoalRow, SituationRow, TermineRow, TocRow, WerdegangRow } from "@/content/home";
|
||||
import { TEXT_LINK } from "./TextLink";
|
||||
|
||||
export type { GoalRow, SituationRow, TermineRow, TocRow, WerdegangRow };
|
||||
|
||||
export type LedgerRow = TocRow | WerdegangRow | TermineRow | SituationRow | GoalRow;
|
||||
|
||||
interface LedgerBase {
|
||||
className?: string;
|
||||
/** Stagger between rows (default 0.1 s). */
|
||||
stagger?: number;
|
||||
}
|
||||
|
||||
export type LedgerProps =
|
||||
| (LedgerBase & { variant: "toc"; rows: readonly TocRow[] })
|
||||
| (LedgerBase & { variant: "werdegang"; rows: readonly WerdegangRow[] })
|
||||
| (LedgerBase & { variant: "termine"; rows: readonly TermineRow[]; heading?: string })
|
||||
| (LedgerBase & { variant: "situations"; rows: readonly SituationRow[] })
|
||||
| (LedgerBase & { variant: "goals"; rows: readonly GoalRow[] });
|
||||
|
||||
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
|
||||
|
||||
/* ---------------- toc ---------------- */
|
||||
|
||||
/**
|
||||
* Table-of-contents row = ONE <TransitionLink> (keyboard: one tab stop).
|
||||
* Hover: ground `--surface`, lower hairline turns gold (scaleX from the left,
|
||||
* 0.6 s), arrow +8 px. No italic flip, nothing reflows – everything is
|
||||
* always visible.
|
||||
*/
|
||||
function TocRowView({ row }: { row: TocRow }) {
|
||||
return (
|
||||
<TransitionLink
|
||||
href={row.link.href}
|
||||
label={row.title}
|
||||
className="group relative grid grid-cols-1 gap-y-4 py-7 transition-colors duration-300 hover:bg-surface focus-visible:bg-surface lg:grid-cols-12 lg:gap-x-[clamp(16px,2vw,32px)] lg:py-10"
|
||||
>
|
||||
<span className="flex items-baseline gap-4 lg:col-span-5 lg:contents">
|
||||
<span className={cn(MONO, "text-accent lg:col-span-1")}>{row.numeral}</span>
|
||||
<span className="font-display text-[clamp(1.75rem,1.4rem+1.6vw,3rem)] font-normal leading-[1.05] text-fg lg:col-span-4">
|
||||
{row.title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-col gap-2 lg:col-span-5">
|
||||
<span className="font-sans text-[1.0625rem] leading-snug text-fg">{row.oneLiner}</span>
|
||||
<span className={cn(MONO, "text-fg-muted")}>{row.keywords.join(" · ")}</span>
|
||||
</span>
|
||||
<span className={cn(TEXT_LINK, "whitespace-nowrap lg:col-span-2 lg:justify-self-end lg:self-start")}>
|
||||
<span>{row.link.label}</span>
|
||||
<span aria-hidden="true" className="transition-transform duration-300 ease-[var(--ease-out-quart)] group-hover:translate-x-2">
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
{/* Lower hairline: recolours to gold from the left on hover. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="rule-gold absolute inset-x-0 bottom-[-1px] origin-left scale-x-0 transition-transform duration-[600ms] ease-[var(--ease-out-expo)] group-hover:scale-x-100 group-focus-visible:scale-x-100"
|
||||
/>
|
||||
</TransitionLink>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- termine ---------------- */
|
||||
|
||||
function TermineRowView({ row }: { row: TermineRow }) {
|
||||
const inner = (
|
||||
<>
|
||||
<span className={cn(MONO, "text-[13px] text-fg")}>{row.date}</span>
|
||||
<span className="font-sans text-[0.8125rem] font-medium uppercase tracking-[0.16em] text-fg-muted">{row.format}</span>
|
||||
<span className="font-sans text-lg leading-snug text-fg lg:font-display lg:text-[1.75rem] lg:font-normal lg:leading-[1.1]">
|
||||
{row.title}
|
||||
</span>
|
||||
<span className={cn(MONO, "text-fg-muted")}>
|
||||
{row.venue}
|
||||
{row.href && (
|
||||
<>
|
||||
{" "}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const cls =
|
||||
"grid grid-cols-1 gap-y-1 py-5 lg:grid-cols-[1fr_1fr_2fr_1fr] lg:items-baseline lg:gap-x-8";
|
||||
if (row.href) {
|
||||
return (
|
||||
<a href={row.href} target="_blank" rel="noopener noreferrer" className={cn(cls, "group/row")}>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <div className={cls}>{inner}</div>;
|
||||
}
|
||||
|
||||
/* ---------------- Ledger ---------------- */
|
||||
|
||||
/**
|
||||
* Typographic ledgers instead of cards. Rows are hairline-separated and
|
||||
* stagger in via <RevealGroup/>; the toc variant's rows are links.
|
||||
*/
|
||||
export function Ledger(props: LedgerProps) {
|
||||
const { className, stagger = 0.1 } = props;
|
||||
|
||||
if (props.variant === "toc") {
|
||||
return (
|
||||
<RevealGroup as="ol" stagger={stagger} className={cn("border-t border-line", className)}>
|
||||
{props.rows.map((row) => (
|
||||
<RevealItem key={row.numeral} as="li" className="border-b border-line">
|
||||
<TocRowView row={row} />
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.variant === "werdegang") {
|
||||
return (
|
||||
<RevealGroup as="dl" stagger={stagger} className={cn("border-t border-line", className)}>
|
||||
{props.rows.map((row) => (
|
||||
<RevealItem
|
||||
key={row.keyword}
|
||||
className="grid grid-cols-1 gap-y-1 border-b border-line py-[18px] sm:grid-cols-4 sm:gap-x-6"
|
||||
>
|
||||
<dt className={cn(MONO, "uppercase text-accent sm:col-span-1")}>{row.keyword}</dt>
|
||||
<dd className="font-sans text-[1.0625rem] leading-snug text-fg sm:col-span-3">{row.text}</dd>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.variant === "termine") {
|
||||
const heading = props.heading ?? termineDe.heading;
|
||||
return (
|
||||
<div className={cn("border border-line bg-surface p-6 sm:p-10", className)}>
|
||||
<p className={cn(MONO, "mb-4 flex items-center gap-3 uppercase tracking-[0.12em] text-accent")}>
|
||||
<span aria-hidden="true" className="inline-block h-1.5 w-1.5 shrink-0 bg-accent" />
|
||||
{heading}
|
||||
</p>
|
||||
<RevealGroup as="ol" stagger={stagger} className="border-t border-line">
|
||||
{props.rows.map((row) => (
|
||||
<RevealItem key={`${row.date}-${row.title}`} as="li" className="border-b border-line">
|
||||
<TermineRowView row={row} />
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.variant === "situations") {
|
||||
return (
|
||||
<RevealGroup as="ol" stagger={stagger} className={cn("border-t border-line", className)}>
|
||||
{props.rows.map((row) => (
|
||||
<RevealItem
|
||||
key={row.numeral}
|
||||
as="li"
|
||||
className="grid grid-cols-[2.5rem_1fr] gap-x-4 border-b border-line py-5 sm:grid-cols-[3rem_1fr] sm:gap-x-8"
|
||||
>
|
||||
<span className={cn(MONO, "pt-[0.5em] text-accent")}>{row.numeral}</span>
|
||||
<span className="font-display text-[clamp(1.75rem,1.3rem+1.2vw,2rem)] font-normal italic leading-[1.2] text-fg">
|
||||
{row.text}
|
||||
</span>
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RevealGroup as="ul" stagger={stagger} className={cn("border-t border-line", className)}>
|
||||
{props.rows.map((row) => (
|
||||
<RevealItem key={row.text} as="li" className="border-b border-line py-4 font-sans text-[1.0625rem] leading-snug text-fg">
|
||||
{row.text}
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default Ledger;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MarginaliaItem {
|
||||
/** Optional mono label, e.g. „Praxis“ → „Praxis: B2B-Außendienst“. */
|
||||
label?: string;
|
||||
value: string;
|
||||
href?: string;
|
||||
/** `target="_blank"` + ↗ */
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export interface MarginaliaProps {
|
||||
items: readonly MarginaliaItem[];
|
||||
/** Optional caps heading above the list, e.g. „Was du bekommst“. */
|
||||
title?: string;
|
||||
/** `lg:sticky lg:top-[30vh]` */
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Shared mono meta style (DM Mono 12 px, ls .06em, tabular). */
|
||||
export const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
|
||||
|
||||
/**
|
||||
* Verifiable facts in the outer column: DM Mono 12 px, muted, one gold tick
|
||||
* per line. Links get the growing accent underline; external ones ↗.
|
||||
*/
|
||||
export function Marginalia({ items, title, sticky = false, className }: MarginaliaProps) {
|
||||
return (
|
||||
<aside className={cn(MONO, "text-fg-muted", sticky && "lg:sticky lg:top-[30vh]", className)}>
|
||||
{title && <p className="mb-4 uppercase tracking-[0.12em] text-accent">{title}</p>}
|
||||
<ul className="flex flex-col gap-3">
|
||||
{items.map((item, i) => {
|
||||
const text = item.label ? `${item.label}: ${item.value}` : item.value;
|
||||
return (
|
||||
<li key={`${item.value}-${i}`} className="flex items-start gap-3">
|
||||
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 shrink-0 bg-accent" />
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
target={item.external ? "_blank" : undefined}
|
||||
rel={item.external ? "noopener noreferrer" : undefined}
|
||||
/* Tap target via ::before – padding + negative margin on the flex child
|
||||
collapsed the <li> box and broke the 12 px tick rhythm of the list. */
|
||||
className="relative inline-block text-fg before:absolute before:inset-x-0 before:-inset-y-3 before:content-[''] bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-px transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] hover:bg-[length:100%_1px] focus-visible:bg-[length:100%_1px]"
|
||||
>
|
||||
{text}
|
||||
{item.external && (
|
||||
<>
|
||||
{" "}
|
||||
<span aria-hidden="true">↗</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
) : (
|
||||
<span>{text}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default Marginalia;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PlaceholderText } from "./Facts";
|
||||
|
||||
export interface MetaLinesProps {
|
||||
/** „Online · DE/EN · [Dauer]“ */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ledger meta string, one item per line. The DM Mono meta columns are narrow
|
||||
* (1.5–2 fr), so a single string breaks mid-separator („Online · DE/EN ·“ /
|
||||
* „[Dauer]“, or a line opening with a lone „·“). Splitting on „ · “ keeps every
|
||||
* item whole and the separator out of the line breaks.
|
||||
*/
|
||||
export function MetaLines({ text }: MetaLinesProps) {
|
||||
const parts = text.split(/\s+·\s+/).filter((p) => p.length > 0);
|
||||
if (parts.length < 2) return <PlaceholderText text={text} />;
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) => (
|
||||
<span key={`${part}-${i}`} className="block whitespace-nowrap">
|
||||
<PlaceholderText text={part} />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetaLines;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { moduleLabels } from "@/content/trainings";
|
||||
import type { ModuleItem } from "@/content/trainings";
|
||||
import { MetaLines } from "./MetaLines";
|
||||
import { PLUS_GLYPH } from "./Faq";
|
||||
|
||||
export type { ModuleItem };
|
||||
|
||||
export interface ModuleTableProps {
|
||||
modules: readonly ModuleItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MONO = "font-mono text-xs tracking-[0.06em]";
|
||||
const HEAD = cn(MONO, "uppercase tracking-[0.12em] text-accent");
|
||||
|
||||
/**
|
||||
* Trainings ledger table: header row „Modul | Ziel | Für wen“ – decorative, because
|
||||
* every cell carries its own label (visible below lg, `sr-only` from lg) – then
|
||||
* one native `<details>` per module whose `<summary>` is the full row
|
||||
* (Bodoni 30 px title, Jost goal + audience, mono meta). Opens to the
|
||||
* hairline-separated „Inhalte“ list; „+“ rotates to „ד via CSS.
|
||||
*/
|
||||
export function ModuleTable({ modules, className }: ModuleTableProps) {
|
||||
const cols = "grid grid-cols-[1fr_auto] gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_2rem] lg:gap-x-8";
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className={cn(cols, "hidden border-b border-line pb-3 lg:grid")} aria-hidden="true">
|
||||
<span className={HEAD}>{moduleLabels.module}</span>
|
||||
<span className={HEAD}>{moduleLabels.goal}</span>
|
||||
<span className={HEAD}>{moduleLabels.audience}</span>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{modules.map((m) => (
|
||||
<details key={m.title} className="group border-b border-line">
|
||||
<summary className={cn(cols, "cursor-pointer list-none py-7 lg:items-baseline lg:py-8 [&::-webkit-details-marker]:hidden")}>
|
||||
<span className="font-sans text-xl leading-snug text-fg lg:font-display lg:text-[1.875rem] lg:font-normal lg:leading-[1.1]">
|
||||
{m.title}
|
||||
</span>
|
||||
<span aria-hidden="true" className={cn(PLUS_GLYPH, "self-start lg:order-last lg:justify-self-end")}>
|
||||
+
|
||||
</span>
|
||||
{/* `lg:sr-only`, not `lg:hidden`: from 1024 the column headers above are the only
|
||||
labelling, and they are decorative (aria-hidden) – the goal and audience runs
|
||||
would be announced as unlabelled text. The label stays in the DOM at every
|
||||
width and is only visually suppressed where the header row takes over. */}
|
||||
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
|
||||
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.goal}</span>
|
||||
{m.goal}
|
||||
</span>
|
||||
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
|
||||
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.audience}</span>
|
||||
{m.audience}
|
||||
</span>
|
||||
<span className={cn(MONO, "col-span-2 tabular-nums text-fg-muted lg:col-span-1 lg:text-right")}>
|
||||
{/* The meta column has no visible header at any width. */}
|
||||
<span className="sr-only">{moduleLabels.meta}</span>
|
||||
<MetaLines text={m.meta} />
|
||||
</span>
|
||||
</summary>
|
||||
<div className="pb-8 lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(0,7fr)_2rem] lg:gap-x-8">
|
||||
<span className={cn(HEAD, "mb-3 block lg:mb-0")}>{moduleLabels.contents}</span>
|
||||
<ul className="border-t border-line">
|
||||
{m.contents.map((line) => (
|
||||
<li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg">
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModuleTable;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface NumeralProps {
|
||||
/** „01“ */
|
||||
value: string;
|
||||
/**
|
||||
* Ground the numeral sits on. Omit to resolve it from the nearest
|
||||
* `[data-theme]` ancestor (CSS only): `.gold-text` on dark, flat `--gold-700` on light.
|
||||
*/
|
||||
theme?: "dark" | "light";
|
||||
/** `position: sticky; top: calc(50vh − .45em)` (desktop). Default true. */
|
||||
sticky?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GOLD_TEXT_DARK =
|
||||
"[[data-theme=dark]_&]:bg-[image:var(--gold-gradient-text)] [[data-theme=dark]_&]:bg-clip-text [[data-theme=dark]_&]:text-gold [[data-theme=dark]_&]:[-webkit-text-fill-color:transparent]";
|
||||
|
||||
/**
|
||||
* Section numeral: Playfair 400, `clamp(4rem, 2rem + 8vw, 10rem)`,
|
||||
* lining numerals. Decorative – the accessible section number lives in the
|
||||
* Section's mono meta line, so this is `aria-hidden`.
|
||||
*/
|
||||
export function Numeral({ value, theme, sticky = true, className }: NumeralProps) {
|
||||
const colour =
|
||||
theme === "dark"
|
||||
? "gold-text"
|
||||
: theme === "light"
|
||||
? "text-gold-700"
|
||||
: cn("text-gold-700", GOLD_TEXT_DARK);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"block select-none font-display text-[clamp(4rem,2rem+8vw,10rem)] font-normal leading-[0.9] tracking-[-0.01em] [font-variant-numeric:lining-nums]",
|
||||
sticky && "lg:sticky lg:top-[calc(50vh_-_0.45em)]",
|
||||
colour,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Numeral;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Door } from "@/components/motion/Door";
|
||||
import { PlaceholderText } from "./Facts";
|
||||
|
||||
export type PortraitAspect = "4/5" | "3/5" | "3/4" | "16/10";
|
||||
|
||||
export interface PortraitProps {
|
||||
/** `null` = photo not delivered → renders nothing (documented variant B). */
|
||||
src: string | null;
|
||||
alt: string;
|
||||
aspect?: PortraitAspect;
|
||||
/** DM Mono 11 px caption with gold tick, e.g. „Anelia Wolf · Foto: [Name]“. */
|
||||
caption?: string;
|
||||
/** `lg:sticky lg:top-[120px]` – stays in view while reading. */
|
||||
sticky?: boolean;
|
||||
/** Eager load (hero). */
|
||||
priority?: boolean;
|
||||
/** 6 % scroll parallax (desktop, fine pointer). */
|
||||
parallax?: boolean;
|
||||
/** 1 px `--line` frame (ivory) – off on ink. Default true. */
|
||||
frame?: boolean;
|
||||
sizes?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Intrinsic dimensions per aspect (2000 px long edge). */
|
||||
const DIMS: Record<PortraitAspect, { width: number; height: number }> = {
|
||||
"4/5": { width: 1600, height: 2000 },
|
||||
"3/5": { width: 1200, height: 2000 },
|
||||
"3/4": { width: 1500, height: 2000 },
|
||||
"16/10": { width: 2000, height: 1250 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Magazine portrait in a `<Door/>` mask: warm grade, hard rectangle, hairline
|
||||
* frame, mono caption. Never scales on hover, never tilts.
|
||||
*/
|
||||
export function Portrait({
|
||||
src,
|
||||
alt,
|
||||
aspect = "4/5",
|
||||
caption,
|
||||
sticky = false,
|
||||
priority = false,
|
||||
parallax = false,
|
||||
frame = true,
|
||||
sizes,
|
||||
className,
|
||||
}: PortraitProps) {
|
||||
if (!src) return null;
|
||||
const { width, height } = DIMS[aspect];
|
||||
return (
|
||||
<figure className={cn(sticky && "lg:sticky lg:top-[120px]", className)}>
|
||||
<Door
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={height}
|
||||
sizes={sizes}
|
||||
priority={priority}
|
||||
parallax={parallax}
|
||||
play={priority ? "load" : "view"}
|
||||
className={cn(frame && "border border-line")}
|
||||
imgClassName="[filter:sepia(.12)_saturate(.9)_contrast(1.02)]"
|
||||
/>
|
||||
{caption && (
|
||||
/* Der Bildnachweis läuft über <PlaceholderText/>: „Foto: [Name der Fotografin]“ ist
|
||||
ein echter Platzhalter (§ 13 UrhG) und muss wie jeder andere gold auffallen und
|
||||
im Build-Guard auftauchen – als rohe eckige Klammer wäre er beim Launch untergegangen. */
|
||||
<figcaption className="mt-3 flex items-center gap-3 font-mono text-[11px] tracking-[0.06em] text-fg-muted">
|
||||
<span aria-hidden="true" className="h-px w-6 shrink-0 bg-accent" />
|
||||
<PlaceholderText text={caption} />
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export default Portrait;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface QuoteProps {
|
||||
text: string;
|
||||
author: string;
|
||||
role?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull quote: Bodoni 400 Italic `clamp(1.75rem, 1.2rem + 1.4vw, 2.5rem)`,
|
||||
* hanging „ via `text-indent: −0.4em`. Name in Jost 500 caps (Bodoni never
|
||||
* below 28 px), role in DM Mono. No stars, no avatar.
|
||||
*/
|
||||
export function Quote({ text, author, role, className }: QuoteProps) {
|
||||
return (
|
||||
<figure className={cn("max-w-[34ch]", className)}>
|
||||
<blockquote className="font-display text-[clamp(1.75rem,1.2rem+1.4vw,2.5rem)] font-normal italic leading-[1.25] text-fg [text-indent:-0.4em]">
|
||||
„{text}“
|
||||
</blockquote>
|
||||
<figcaption className="mt-6 flex flex-wrap items-baseline gap-x-4 gap-y-1">
|
||||
<span className="font-sans text-sm font-medium uppercase tracking-[0.08em] text-fg">{author}</span>
|
||||
{role && <span className="font-mono text-xs tracking-[0.06em] text-fg-muted">{role}</span>}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
export default Quote;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||
import { specLabels as specLabelsDe } from "@/content/coaching";
|
||||
import type { SpecBlock } from "@/content/coaching";
|
||||
import { PlaceholderText } from "./Facts";
|
||||
import { MetaLines } from "./MetaLines";
|
||||
|
||||
export type { SpecBlock };
|
||||
|
||||
/** Die drei Zeilenbeschriftungen – in der Sprache der Seite. */
|
||||
export type SpecLabels = { goal: string; contents: string; audience: string };
|
||||
|
||||
export interface SpecTableProps {
|
||||
labels?: SpecLabels;
|
||||
blocks: readonly SpecBlock[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MONO = "font-mono text-xs tracking-[0.06em]";
|
||||
const LABEL = cn(MONO, "text-accent");
|
||||
|
||||
/**
|
||||
* Leistungsbausteine as a spec table, not cards: per block one row
|
||||
* [Bodoni 30 px title, 30 %] [labelled paragraphs Ziel / Inhalte / Für wen, 55 %]
|
||||
* [mono meta right, 15 %]. Optional `tracks` render as full-width, hairline-
|
||||
* separated sub-rows below that row (1:1 Coaching). Paper stays paper – no
|
||||
* zebra fills.
|
||||
*/
|
||||
export function SpecTable({ blocks, className, labels = specLabelsDe }: SpecTableProps) {
|
||||
return (
|
||||
<RevealGroup stagger={0.12} className={cn("border-t border-line", className)}>
|
||||
{blocks.map((b) => (
|
||||
<RevealItem
|
||||
key={b.title}
|
||||
className="grid grid-cols-1 gap-y-6 border-b border-line py-10 lg:grid-cols-[3fr_5.5fr_2fr] lg:gap-x-8 lg:py-12"
|
||||
>
|
||||
<h3 className="font-sans text-xl leading-snug text-fg lg:font-display lg:text-[1.875rem] lg:font-normal lg:leading-[1.1]">
|
||||
{b.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{b.goal && (
|
||||
<p className="grid grid-cols-[5.5rem_1fr] gap-x-4">
|
||||
<span className={cn(LABEL, "pt-[0.35em]")}>{labels.goal}</span>
|
||||
<span className="font-sans text-[1.0625rem] leading-[1.55] text-fg">
|
||||
<PlaceholderText text={b.goal} />
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{b.contents && (
|
||||
<p className="grid grid-cols-[5.5rem_1fr] gap-x-4">
|
||||
<span className={cn(LABEL, "pt-[0.35em]")}>{labels.contents}</span>
|
||||
<span className="font-sans text-[1.0625rem] leading-[1.55] text-fg">
|
||||
<PlaceholderText text={b.contents} />
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{b.audience && (
|
||||
<p className="grid grid-cols-[5.5rem_1fr] gap-x-4">
|
||||
<span className={cn(LABEL, "pt-[0.35em]")}>{labels.audience}</span>
|
||||
<span className="font-sans text-[1.0625rem] leading-[1.55] text-fg">
|
||||
<PlaceholderText text={b.audience} />
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={cn(MONO, "tabular-nums text-fg-muted lg:text-right")}>
|
||||
<MetaLines text={b.meta} />
|
||||
</p>
|
||||
|
||||
{/* Sub-rows span the whole row, so their hairlines have the same length as the row's own
|
||||
and the last one is the row rule itself – one skeleton for every block. */}
|
||||
{b.tracks && b.tracks.length > 0 && (
|
||||
<ul className="border-t border-line lg:col-span-3">
|
||||
{b.tracks.map((t) => (
|
||||
<li
|
||||
key={t}
|
||||
className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg last:border-b-0"
|
||||
>
|
||||
{t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpecTable;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||
import type { StepItem } from "@/content/home";
|
||||
|
||||
export type { StepItem };
|
||||
|
||||
export interface StepsProps {
|
||||
items: readonly StepItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbered process rows: [DM Mono numeral] [title] [Jost 300 text],
|
||||
* hairline-separated, padding-block 22 px. Rows stagger in (0.1 s).
|
||||
* Empty `text` (service-page Ablauf) collapses to numeral + title and the
|
||||
* title column gets the extra width so four-word steps stay on one line.
|
||||
*
|
||||
* Type: Jost 18 px below lg, Bodoni 28 px from lg – Bodoni is never rendered
|
||||
* below 28 px (hard rule A1).
|
||||
*/
|
||||
export function Steps({ items, className }: StepsProps) {
|
||||
const hasText = items.some((step) => Boolean(step.text));
|
||||
return (
|
||||
<RevealGroup as="ol" stagger={0.1} className={cn("border-t border-line", className)}>
|
||||
{items.map((step) => (
|
||||
<RevealItem
|
||||
key={step.numeral}
|
||||
as="li"
|
||||
className={cn(
|
||||
"grid grid-cols-[2.5rem_1fr] gap-x-4 gap-y-2 border-b border-line py-[22px] sm:gap-x-8",
|
||||
hasText ? "sm:grid-cols-[3rem_minmax(0,16rem)_1fr]" : "sm:grid-cols-[3rem_minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
<span className="pt-[0.35em] font-mono text-xs tracking-[0.06em] tabular-nums text-accent">{step.numeral}</span>
|
||||
<span className="font-sans text-lg font-normal leading-[1.35] text-fg [overflow-wrap:anywhere] hyphens-auto lg:font-display lg:text-[1.75rem] lg:leading-[1.15]">
|
||||
{step.title}
|
||||
</span>
|
||||
{step.text && (
|
||||
<p className="col-start-2 font-sans text-[1.0625rem] font-light leading-[1.55] text-fg-muted sm:col-start-3 sm:pt-[0.3em]">
|
||||
{step.text}
|
||||
</p>
|
||||
)}
|
||||
</RevealItem>
|
||||
))}
|
||||
</RevealGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default Steps;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
|
||||
export interface TextLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
/** `target="_blank" rel="noopener noreferrer"` + ↗ instead of →. */
|
||||
external?: boolean;
|
||||
/** Anchor/arrow glyph override (default → / ↗ / ↓ for `#…`). */
|
||||
arrow?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Caps text link style (Jost 500, 13 px, ls .2em) – shared with ledger links.
|
||||
* `-my-4/py-4` grows the tap target to 45 px without moving the baseline. */
|
||||
export const TEXT_LINK =
|
||||
"group/tl -my-4 inline-flex items-baseline gap-2 py-4 font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-accent";
|
||||
|
||||
/** Underline grows from the left (`background-size 0 → 100%`, 0.4 s). */
|
||||
export const GROW_UNDERLINE =
|
||||
"bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-1 transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] group-hover/tl:bg-[length:100%_1px] group-focus-visible/tl:bg-[length:100%_1px]";
|
||||
|
||||
const ARROW = "transition-transform duration-300 ease-[var(--ease-out-quart)] group-hover/tl:translate-x-2";
|
||||
|
||||
function isInternal(href: string) {
|
||||
return href.startsWith("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps link with an arrow glyph. Internal routes go through the curtain
|
||||
* (`TransitionLink`); mailto/tel/http are plain anchors.
|
||||
*/
|
||||
export function TextLink({ href, label, external = false, arrow, className }: TextLinkProps) {
|
||||
const glyph = arrow ?? (external ? "↗" : href.startsWith("#") ? "↓" : "→");
|
||||
const inner = (
|
||||
<>
|
||||
<span className={GROW_UNDERLINE}>{label}</span>
|
||||
<span aria-hidden="true" className={ARROW}>
|
||||
{glyph}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const cls = cn(TEXT_LINK, className);
|
||||
|
||||
if (isInternal(href) && !external) {
|
||||
return (
|
||||
<TransitionLink href={href} label={label} className={cls}>
|
||||
{inner}
|
||||
</TransitionLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
rel={external ? "noopener noreferrer" : undefined}
|
||||
className={cls}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextLink;
|
||||
Reference in new issue
Block a user