feat: German as the main language, English under /en, with a language switch
The site now exists in German (at the root, with /impressum and /datenschutz) and English (/en). A request without a language follows the visitor's choice from the language switch, then the browser language, then German; the old English legal addresses move to /en for good. All visible texts are data in src/content/de.ts and en.ts, so components hold none of their own. The header carries a DE/EN switch whose bit curtain covers the page while it is swapped and reopens at the same reading position. Title, description, canonical, hreflang, Open Graph, the share image, the sitemap and the structured data are per language. The privacy notice now describes the cookie that remembers the chosen language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { Content } from "@/src/content/types";
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
|
||||
type Value = { readonly locale: Locale; readonly content: Content };
|
||||
|
||||
const ContentContext = React.createContext<Value | null>(null);
|
||||
|
||||
/**
|
||||
* ContentProvider: hands the language of the page and its texts to the client
|
||||
* components. The layout picks both; only the texts of that one language are
|
||||
* sent to the browser.
|
||||
*/
|
||||
export const ContentProvider: React.FC<
|
||||
Value & { children: React.ReactNode }
|
||||
> = ({ locale, content, children }) => {
|
||||
const value = React.useMemo(() => ({ locale, content }), [locale, content]);
|
||||
return (
|
||||
<ContentContext.Provider value={value}>{children}</ContentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useValue = (): Value => {
|
||||
const value = React.useContext(ContentContext);
|
||||
if (value === null) {
|
||||
throw new Error("useContent and useLocale need a ContentProvider above.");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const useContent = (): Content => useValue().content;
|
||||
export const useLocale = (): Locale => useValue().locale;
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import { BinaryTicker } from "./bits/BinaryTicker";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import { LanguageSwitch } from "./LanguageSwitch";
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const locale = useLocale();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
@@ -38,24 +45,25 @@ export const Footer: React.FC = () => {
|
||||
<span>© {currentYear}</span>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-3 md:justify-end">
|
||||
<Link
|
||||
href="/#contact"
|
||||
href={`${pathFor("home", locale)}#contact`}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Get in touch
|
||||
{nav.contactLong}
|
||||
</Link>
|
||||
<Link
|
||||
href="/imprint"
|
||||
href={pathFor("imprint", locale)}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Imprint
|
||||
{nav.imprint}
|
||||
</Link>
|
||||
<Link
|
||||
href="/privacy"
|
||||
href={pathFor("privacy", locale)}
|
||||
className="hover:text-slate-900 transition-colors duration-300 no-underline"
|
||||
>
|
||||
Privacy
|
||||
{nav.privacy}
|
||||
</Link>
|
||||
</div>
|
||||
<LanguageSwitch variant="text" />
|
||||
{/* Subtle binary decoration */}
|
||||
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
|
||||
{[77, 73, 78].map((code) => (
|
||||
|
||||
@@ -3,12 +3,17 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { ScrollBits } from "./bits/ScrollBits";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import { LanguageSwitch } from "./LanguageSwitch";
|
||||
|
||||
/** Header: the logo and one action. Everything else lives on the homepage. */
|
||||
/** Header: the logo, the language switch and one action. Everything else lives on the homepage. */
|
||||
export const Header: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const home = pathFor("home", useLocale());
|
||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -32,11 +37,11 @@ export const Header: React.FC = () => {
|
||||
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 md:px-12 py-4 flex items-center justify-between relative z-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Marc Mintel, back to top"
|
||||
href={home}
|
||||
aria-label={nav.logoLabel}
|
||||
onClick={(event) => {
|
||||
// Already on the homepage: scroll up instead of reloading the route.
|
||||
if (window.location.pathname === "/") {
|
||||
if (window.location.pathname === home) {
|
||||
event.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
@@ -56,15 +61,18 @@ export const Header: React.FC = () => {
|
||||
|
||||
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
|
||||
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
style={{
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<DecodeText text="Get in touch" trigger="hover" />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2.5 md:gap-4">
|
||||
<LanguageSwitch />
|
||||
<Link
|
||||
href={`${home}#contact`}
|
||||
className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
style={{
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
<DecodeText text={nav.contact} trigger="hover" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useContent } from "./ContentProvider";
|
||||
|
||||
export const InteractiveElements: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||||
|
||||
@@ -42,7 +44,7 @@ export const InteractiveElements: React.FC = () => {
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className={`floating-back-to-top ${showBackToTop ? "visible" : ""}`}
|
||||
aria-label="Back to top"
|
||||
aria-label={nav.backToTop}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { LOCALES, localeCookie, type Locale } from "@/src/domain/locale";
|
||||
import { switchPath } from "@/src/domain/routes";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { useContent, useLocale } from "./ContentProvider";
|
||||
import {
|
||||
getIdleState,
|
||||
getSwitchState,
|
||||
startSwitch,
|
||||
subscribe,
|
||||
} from "./locale-switch-store";
|
||||
|
||||
const EASE = "cubic-bezier(0.23, 1, 0.32, 1)";
|
||||
|
||||
/**
|
||||
* LanguageSwitch: the two languages side by side, with a marker that slides to
|
||||
* the chosen one. Choosing a language closes the bit curtain (see BitCurtain),
|
||||
* swaps the page behind it and keeps the reading position. The choice is stored
|
||||
* in a cookie so that the language detection does not undo it.
|
||||
*
|
||||
* The options are plain links to the other language's address, so the switch
|
||||
* also works without JavaScript and for search engines.
|
||||
*/
|
||||
export const LanguageSwitch: React.FC<{
|
||||
/** `pill` for the header, `text` for the footer. */
|
||||
variant?: "pill" | "text";
|
||||
}> = ({ variant = "pill" }) => {
|
||||
const locale = useLocale();
|
||||
const { nav } = useContent();
|
||||
const pathname = usePathname();
|
||||
const state = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSwitchState,
|
||||
getIdleState,
|
||||
);
|
||||
// The marker moves as soon as a language is chosen, before the page follows.
|
||||
const shown = state.phase === "idle" ? locale : state.target;
|
||||
|
||||
const choose = (target: Locale) => (event: React.MouseEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (target === locale) return;
|
||||
document.cookie = localeCookie(target);
|
||||
startSwitch(locale, target, switchPath(pathname, target));
|
||||
};
|
||||
|
||||
if (variant === "text") {
|
||||
return (
|
||||
<nav aria-label={nav.language} className="flex gap-x-4">
|
||||
{LOCALES.map((option) => (
|
||||
<a
|
||||
key={option}
|
||||
href={switchPath(pathname, option)}
|
||||
hrefLang={option}
|
||||
lang={option}
|
||||
aria-current={option === locale ? "true" : undefined}
|
||||
onClick={choose(option)}
|
||||
className={`no-underline transition-colors duration-300 hover:text-slate-900 ${
|
||||
option === locale ? "text-slate-900" : "text-slate-400"
|
||||
}`}
|
||||
>
|
||||
{nav.languageNames[option]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={nav.language}
|
||||
className="group/lang relative flex items-center rounded-full border border-slate-200 bg-white/70 p-[3px] font-mono text-[10px] font-bold tracking-[0.2em] transition-colors duration-500 hover:border-slate-400"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-[3px] left-[3px] w-[calc(50%-3px)] rounded-full bg-slate-900 shadow-sm transition-transform duration-500"
|
||||
style={{
|
||||
transform: `translateX(${LOCALES.indexOf(shown) * 100}%)`,
|
||||
transitionTimingFunction: EASE,
|
||||
}}
|
||||
/>
|
||||
{LOCALES.map((option) => (
|
||||
<a
|
||||
key={option}
|
||||
href={switchPath(pathname, option)}
|
||||
hrefLang={option}
|
||||
lang={option}
|
||||
aria-label={nav.languageNames[option]}
|
||||
aria-current={option === locale ? "true" : undefined}
|
||||
title={nav.languageNames[option]}
|
||||
onClick={choose(option)}
|
||||
className={`relative z-10 grid h-8 w-10 place-items-center rounded-full pl-[0.2em] no-underline transition-colors duration-300 md:h-9 md:w-11 ${
|
||||
option === shown
|
||||
? "text-white"
|
||||
: "text-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
<DecodeText text={option.toUpperCase()} trigger="hover" />
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { useContent, useLocale } from "@/src/components/ContentProvider";
|
||||
import {
|
||||
endSwitch,
|
||||
enterPhase,
|
||||
getIdleState,
|
||||
getSwitchState,
|
||||
subscribe,
|
||||
type SwitchState,
|
||||
} from "@/src/components/locale-switch-store";
|
||||
import {
|
||||
captureAnchor,
|
||||
restoreAnchor,
|
||||
type SectionBox,
|
||||
} from "@/src/domain/scroll-anchor";
|
||||
|
||||
const COVER_MS = 460;
|
||||
const REVEAL_MS = 560;
|
||||
/** The curtain stays closed at least this long, so the language name can be read. */
|
||||
const HOLD_MS = 320;
|
||||
const LABEL_DELAY_MS = 140;
|
||||
const LABEL_MS = 420;
|
||||
/** If the new page has not arrived by then, load it the plain way. */
|
||||
const GIVE_UP_MS = 6000;
|
||||
|
||||
const CELL = 16;
|
||||
/** How ragged the edge of the curtain is, as a share of the screen width. */
|
||||
const SPREAD = 0.24;
|
||||
const TRAIL = 11;
|
||||
|
||||
const INK = "#0f172a"; // slate-900
|
||||
const MARK = "#fde68a";
|
||||
|
||||
const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
|
||||
const easeInOut = (t: number): number =>
|
||||
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
|
||||
/** A fixed pseudo-random number per row, so the edge looks the same after the page swap. */
|
||||
const rowLag = (row: number): number => {
|
||||
const x = Math.sin(row * 12.9898) * 43758.5453;
|
||||
return x - Math.floor(x);
|
||||
};
|
||||
|
||||
const sectionBoxes = (): SectionBox[] =>
|
||||
Array.from(document.querySelectorAll("main section")).map((section) => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
return { top: rect.top + window.scrollY, height: rect.height };
|
||||
});
|
||||
|
||||
/** A tile of faint digits that textures the closed curtain. */
|
||||
const makeTile = (dpr: number, mono: string): HTMLCanvasElement => {
|
||||
const cells = 8;
|
||||
const tile = document.createElement("canvas");
|
||||
tile.width = cells * CELL * dpr;
|
||||
tile.height = cells * CELL * dpr;
|
||||
const ctx = tile.getContext("2d");
|
||||
if (!ctx) return tile;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.fillStyle = INK;
|
||||
ctx.fillRect(0, 0, cells * CELL, cells * CELL);
|
||||
ctx.font = `500 11px ${mono}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = "rgba(148, 163, 184, 0.16)";
|
||||
for (let row = 0; row < cells; row++) {
|
||||
for (let col = 0; col < cells; col++) {
|
||||
const bit = rowLag(row * cells + col + 1) < 0.5 ? "0" : "1";
|
||||
ctx.fillText(bit, col * CELL + CELL / 2, row * CELL + CELL / 2);
|
||||
}
|
||||
}
|
||||
return tile;
|
||||
};
|
||||
|
||||
/**
|
||||
* BitCurtain: the transition of the language switch. A curtain of binary digits
|
||||
* closes over the page from left to right, the name of the new language resolves
|
||||
* out of bits on it, the page is swapped behind it, and the curtain opens again
|
||||
* on the same spot of the page in the other language.
|
||||
*
|
||||
* With reduced motion there is no curtain: the page is swapped at once.
|
||||
*/
|
||||
export const BitCurtain: React.FC = () => {
|
||||
const locale = useLocale();
|
||||
const { nav } = useContent();
|
||||
const router = useRouter();
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const state = React.useSyncExternalStore(
|
||||
subscribe,
|
||||
getSwitchState,
|
||||
getIdleState,
|
||||
);
|
||||
const active = state.phase !== "idle";
|
||||
|
||||
// The loop below reads these through refs, so it survives re-renders.
|
||||
const localeRef = React.useRef(locale);
|
||||
localeRef.current = locale;
|
||||
const namesRef = React.useRef(nav.languageNames);
|
||||
namesRef.current = nav.languageNames;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext("2d") ?? null;
|
||||
|
||||
const mono =
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--font-jetbrains-mono")
|
||||
.trim() || "ui-monospace";
|
||||
const font = `${mono}, ui-monospace, Menlo, monospace`;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let pattern: CanvasPattern | null = null;
|
||||
|
||||
const resize = () => {
|
||||
if (!canvas || !ctx) return;
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
canvas.width = Math.ceil(width * dpr);
|
||||
canvas.height = Math.ceil(height * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
pattern = ctx.createPattern(makeTile(dpr, font), "repeat");
|
||||
// The tile is drawn at device resolution; scale it back to CSS pixels.
|
||||
pattern?.setTransform(new DOMMatrix().scale(1 / dpr));
|
||||
};
|
||||
|
||||
const paint = (current: Exclude<SwitchState, { phase: "idle" }>) => {
|
||||
if (!ctx) return;
|
||||
const now = performance.now();
|
||||
const elapsed = now - current.phaseAt;
|
||||
const opening = current.phase === "reveal";
|
||||
const progress =
|
||||
current.phase === "hold"
|
||||
? 1
|
||||
: easeInOut(clamp01(elapsed / (opening ? REVEAL_MS : COVER_MS)));
|
||||
const rows = Math.ceil(height / CELL);
|
||||
|
||||
// The edge of the curtain in each row. Closing, the curtain is left of it;
|
||||
// opening, right of it.
|
||||
const edge = (row: number): number =>
|
||||
current.phase === "hold"
|
||||
? width
|
||||
: clamp01(progress * (1 + SPREAD) - rowLag(row) * SPREAD) * width;
|
||||
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const x = edge(row);
|
||||
if (opening) ctx.rect(x, row * CELL, width - x, CELL);
|
||||
else ctx.rect(0, row * CELL, x, CELL);
|
||||
}
|
||||
ctx.clip();
|
||||
ctx.fillStyle = pattern ?? INK;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// The name of the language, resolving out of bits. Clipped to the curtain.
|
||||
const sinceStart = now - current.startedAt - LABEL_DELAY_MS;
|
||||
const name = namesRef.current[current.target].toUpperCase();
|
||||
const shown = Math.floor(clamp01(sinceStart / LABEL_MS) * name.length);
|
||||
const text = Array.from(name)
|
||||
.map((char, i) => (i < shown ? char : Math.random() < 0.5 ? "0" : "1"))
|
||||
.join("");
|
||||
const size = Math.min(width * 0.15, 168);
|
||||
ctx.globalAlpha = clamp01(sinceStart / 160);
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.font = `800 ${size}px ${font}`;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillText(text, width / 2, height / 2 + size * 0.2);
|
||||
const textWidth = ctx.measureText(text).width;
|
||||
ctx.fillStyle = MARK;
|
||||
ctx.fillRect(
|
||||
width / 2 - textWidth / 2,
|
||||
height / 2 + size * 0.42,
|
||||
textWidth * clamp01(sinceStart / LABEL_MS),
|
||||
Math.max(3, size * 0.035),
|
||||
);
|
||||
ctx.font = `500 12px ${font}`;
|
||||
ctx.fillStyle = "#94a3b8";
|
||||
ctx.fillText(
|
||||
`${current.from.toUpperCase()} → ${current.target.toUpperCase()}`,
|
||||
width / 2,
|
||||
height / 2 - size * 0.78,
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.restore();
|
||||
|
||||
// Loose digits along the moving edge.
|
||||
if (current.phase !== "hold") {
|
||||
ctx.font = `700 12px ${font}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const x = edge(row);
|
||||
if (x <= 0 || x >= width) continue;
|
||||
for (let k = -2; k < TRAIL; k++) {
|
||||
// k counts cells from the edge into the curtain; negative is on the page.
|
||||
const cx = x + (opening ? 1 : -1) * (k + 0.5) * CELL;
|
||||
const onPage = k < 0;
|
||||
ctx.globalAlpha = onPage ? 0.5 : (1 - k / TRAIL) * 0.9;
|
||||
ctx.fillStyle = onPage ? INK : k < 2 ? MARK : "#ffffff";
|
||||
ctx.fillText(
|
||||
Math.random() < 0.5 ? "0" : "1",
|
||||
cx,
|
||||
row * CELL + CELL / 2,
|
||||
);
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
};
|
||||
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
const current = getSwitchState();
|
||||
if (current.phase === "idle") return;
|
||||
const elapsed = performance.now() - current.phaseAt;
|
||||
|
||||
if (current.phase === "cover" && (still || elapsed >= COVER_MS)) {
|
||||
enterPhase("hold", captureAnchor(sectionBoxes(), window.scrollY));
|
||||
router.push(current.href, { scroll: false });
|
||||
} else if (current.phase === "hold") {
|
||||
const arrived = localeRef.current === current.target;
|
||||
if (arrived && (still || elapsed >= HOLD_MS)) {
|
||||
window.scrollTo({
|
||||
top: restoreAnchor(current.anchor, sectionBoxes()),
|
||||
behavior: "instant",
|
||||
});
|
||||
if (still) endSwitch();
|
||||
else enterPhase("reveal");
|
||||
} else if (elapsed > GIVE_UP_MS) {
|
||||
endSwitch();
|
||||
window.location.assign(current.href);
|
||||
return;
|
||||
}
|
||||
} else if (current.phase === "reveal" && elapsed >= REVEAL_MS) {
|
||||
endSwitch();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = getSwitchState();
|
||||
if (next.phase === "idle") return;
|
||||
if (!still) paint(next);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const started = getSwitchState();
|
||||
if (started.phase !== "idle") router.prefetch(started.href);
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, [active, router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`fixed inset-0 z-[300] motion-reduce:hidden ${active ? "block" : "hidden"}`}
|
||||
style={{
|
||||
// Closed: solid behind the canvas, so the page swap never shows through.
|
||||
backgroundColor: state.phase === "hold" ? INK : "transparent",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} className="block h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,16 +3,14 @@ import { Reveal } from "@/src/components/Reveal";
|
||||
import { EmailLink } from "@/src/components/EmailLink";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { BitSeam } from "@/src/components/bits/BitSeam";
|
||||
import { BitHeadline, type BitLine } from "./BitHeadline";
|
||||
import type { Content } from "@/src/content/types";
|
||||
import { BitHeadline } from "./BitHeadline";
|
||||
import { ContactComposer } from "./ContactComposer";
|
||||
|
||||
const CLOSING: readonly BitLine[] = [
|
||||
{ text: "LET'S", tone: "bits" },
|
||||
{ text: "TALK.", tone: "solid" },
|
||||
];
|
||||
|
||||
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
|
||||
export const ClosingCta: React.FC = () => (
|
||||
export const ClosingCta: React.FC<{ contact: Content["contact"] }> = ({
|
||||
contact,
|
||||
}) => (
|
||||
<section
|
||||
id="contact"
|
||||
data-glow
|
||||
@@ -33,12 +31,11 @@ export const ClosingCta: React.FC = () => (
|
||||
<BitHeadline
|
||||
as="h2"
|
||||
invert
|
||||
lines={CLOSING}
|
||||
lines={contact.headline}
|
||||
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] lg:text-[8.5vw] xl:text-[9.5rem]"
|
||||
/>
|
||||
<p className="m-0 mt-10 max-w-xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
|
||||
Pick what you need and the email writes itself. Or write to me
|
||||
directly:
|
||||
{contact.lead}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-2xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-4xl" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CONTACT_TOPICS } from "@/src/content/contact";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
|
||||
import { assembleEmail, mailtoHref, spellOutEmail } from "@/src/domain/email";
|
||||
|
||||
@@ -51,7 +51,9 @@ const withBlanks = (text: string): React.ReactNode =>
|
||||
* is assembled in the browser, so it never appears in the page source.
|
||||
*/
|
||||
export const ContactComposer: React.FC = () => {
|
||||
const [topicId, setTopicId] = React.useState(CONTACT_TOPICS[0]?.id ?? "");
|
||||
const { contact } = useContent();
|
||||
const topics = contact.topics;
|
||||
const [topicId, setTopicId] = React.useState(topics[0]?.id ?? "");
|
||||
const [address, setAddress] = React.useState<string | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
@@ -59,8 +61,7 @@ export const ContactComposer: React.FC = () => {
|
||||
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
|
||||
}, []);
|
||||
|
||||
const topic =
|
||||
CONTACT_TOPICS.find((item) => item.id === topicId) ?? CONTACT_TOPICS[0];
|
||||
const topic = topics.find((item) => item.id === topicId) ?? topics[0];
|
||||
const typed = useTyped(topic?.draft.body ?? "");
|
||||
|
||||
if (!topic) return null;
|
||||
@@ -87,18 +88,18 @@ export const ContactComposer: React.FC = () => {
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
</span>
|
||||
<span className={`${MONO} text-slate-400`}>New message</span>
|
||||
<span className={`${MONO} text-slate-400`}>{contact.newMessage}</span>
|
||||
</div>
|
||||
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||
draft
|
||||
{contact.draft}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-slate-800 px-5 py-5 md:px-7">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>I need</p>
|
||||
<p className={`${MONO} m-0 text-slate-500`}>{contact.need}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{CONTACT_TOPICS.map((item) => {
|
||||
{topics.map((item) => {
|
||||
const selected = item.id === topic.id;
|
||||
return (
|
||||
<button
|
||||
@@ -121,13 +122,13 @@ export const ContactComposer: React.FC = () => {
|
||||
|
||||
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]">
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-16 shrink-0 text-slate-500">to</dt>
|
||||
<dt className="w-16 shrink-0 text-slate-500">{contact.to}</dt>
|
||||
<dd className="m-0 select-all text-slate-200">
|
||||
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4">
|
||||
<dt className="w-16 shrink-0 text-slate-500">subject</dt>
|
||||
<dt className="w-16 shrink-0 text-slate-500">{contact.subject}</dt>
|
||||
<dd className="m-0 text-white">{topic.draft.subject}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -150,7 +151,7 @@ export const ContactComposer: React.FC = () => {
|
||||
}
|
||||
className="bit-cta bit-cta-light group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Open in my mail app
|
||||
{contact.open}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-300 group-hover:translate-x-1"
|
||||
@@ -163,7 +164,7 @@ export const ContactComposer: React.FC = () => {
|
||||
onClick={copy}
|
||||
className="rounded-full border border-slate-700 px-6 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-300 transition-colors duration-200 hover:border-slate-400 hover:text-white"
|
||||
>
|
||||
{copied ? "Copied" : "Copy address"}
|
||||
{copied ? contact.copied : contact.copy}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { FAQ } from "@/src/content/faq";
|
||||
import type { FaqItem } from "@/src/content/types";
|
||||
|
||||
export const Faq: React.FC = () => (
|
||||
export const Faq: React.FC<{ items: readonly FaqItem[] }> = ({ items }) => (
|
||||
<div className="grid grid-cols-1 gap-x-12 md:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
{items.map((item) => (
|
||||
<details
|
||||
key={item.question}
|
||||
className="group border-t border-slate-200 py-6 open:pb-8"
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import * as React from "react";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { AbstractCircuit } from "@/src/components/Effects";
|
||||
import { BitHeadline, type BitLine } from "./BitHeadline";
|
||||
import type { Content } from "@/src/content/types";
|
||||
import { BitHeadline } from "./BitHeadline";
|
||||
|
||||
const HEADLINE: readonly BitLine[] = [
|
||||
{ text: "WEB APPS", tone: "solid" },
|
||||
{ text: "& WEBSITES.", tone: "bits" },
|
||||
];
|
||||
|
||||
const TRUST = ["15 years in web", "Remote, EU core hours"] as const;
|
||||
|
||||
export const HeroStatement: React.FC = () => (
|
||||
export const HeroStatement: React.FC<{ hero: Content["hero"] }> = ({
|
||||
hero,
|
||||
}) => (
|
||||
<section className="relative overflow-hidden bg-white">
|
||||
<div className="absolute inset-0 opacity-70">
|
||||
<AbstractCircuit />
|
||||
@@ -18,16 +14,13 @@ export const HeroStatement: React.FC = () => (
|
||||
|
||||
<div className="relative z-10 mx-auto flex min-h-[calc(100dvh-5rem)] w-full max-w-[1400px] flex-col justify-center px-5 py-16 md:px-12 md:py-24">
|
||||
<BitHeadline
|
||||
lines={HEADLINE}
|
||||
lines={hero.headline}
|
||||
className="font-sans text-[12.5vw] font-black leading-[0.88] tracking-tighter md:text-[11.5vw] xl:text-[10.5rem]"
|
||||
/>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-10 md:mt-14 md:grid-cols-12 md:items-end md:gap-12">
|
||||
<p className="m-0 max-w-2xl font-sans text-xl leading-snug text-slate-500 md:col-span-7 md:text-3xl">
|
||||
Freelance web developer with 15 years of experience.{" "}
|
||||
<span className="text-slate-900">
|
||||
I build web apps, interfaces and websites.
|
||||
</span>
|
||||
{hero.lead} <span className="text-slate-900">{hero.leadStrong}</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 md:col-span-5 md:items-end">
|
||||
@@ -36,7 +29,7 @@ export const HeroStatement: React.FC = () => (
|
||||
className="flex w-full flex-col gap-4 sm:flex-row md:w-auto"
|
||||
>
|
||||
<Button href="#contact" size="large" className="w-full sm:w-auto">
|
||||
Get in touch
|
||||
{hero.primaryCta}
|
||||
</Button>
|
||||
<Button
|
||||
href="#hello"
|
||||
@@ -44,14 +37,14 @@ export const HeroStatement: React.FC = () => (
|
||||
size="large"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
What I do
|
||||
{hero.secondaryCta}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="m-0 mt-14 flex list-none flex-wrap gap-x-8 gap-y-3 border-t border-slate-200 p-0 pt-6 md:mt-20">
|
||||
{TRUST.map((item) => (
|
||||
{hero.trust.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]"
|
||||
@@ -60,7 +53,7 @@ export const HeroStatement: React.FC = () => (
|
||||
</li>
|
||||
))}
|
||||
<li className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500 md:text-[11px]">
|
||||
Built and running:{" "}
|
||||
{hero.builtAndRunning}{" "}
|
||||
<a
|
||||
href="https://klz-cables.com"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,58 +1,49 @@
|
||||
import { TECH } from "@/src/content/about";
|
||||
import { FAQ } from "@/src/content/faq";
|
||||
import { SERVICE_PATHS } from "@/src/content/paths";
|
||||
import {
|
||||
JOB_TITLE,
|
||||
OWNER,
|
||||
PROFILES,
|
||||
SITE_DESCRIPTION,
|
||||
SITE_LANGUAGE,
|
||||
SITE_NAME,
|
||||
SITE_URL,
|
||||
} from "@/src/content/site";
|
||||
import { contentFor } from "@/src/content";
|
||||
import { OWNER, PROFILES, SITE_NAME, SITE_URL } from "@/src/content/site";
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
import { pathFor } from "@/src/domain/routes";
|
||||
import {
|
||||
buildStructuredData,
|
||||
serializeJsonLd,
|
||||
} from "@/src/domain/structured-data";
|
||||
|
||||
const [postalCode = "", ...locality] = OWNER.city.split(" ");
|
||||
|
||||
const STRUCTURED_DATA = buildStructuredData({
|
||||
siteUrl: SITE_URL,
|
||||
siteName: SITE_NAME,
|
||||
language: SITE_LANGUAGE,
|
||||
description: SITE_DESCRIPTION,
|
||||
imageUrl: `${SITE_URL}/marc-mintel.png`,
|
||||
person: {
|
||||
name: OWNER.name,
|
||||
jobTitle: JOB_TITLE,
|
||||
profiles: PROFILES,
|
||||
knowsAbout: [
|
||||
"Web applications",
|
||||
"User interfaces",
|
||||
"Websites",
|
||||
"Process automation",
|
||||
"Web3 frontends",
|
||||
...TECH.flatMap((group) => group.items),
|
||||
],
|
||||
},
|
||||
address: {
|
||||
street: OWNER.street,
|
||||
postalCode,
|
||||
locality: locality.join(" "),
|
||||
countryCode: "DE",
|
||||
},
|
||||
services: SERVICE_PATHS.map((path) => ({
|
||||
name: path.service,
|
||||
description: path.intro,
|
||||
})),
|
||||
faq: FAQ,
|
||||
});
|
||||
|
||||
/** Built server-side from the page content; see src/domain/structured-data.ts. */
|
||||
export const HomeJsonLd: React.FC = () => (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(STRUCTURED_DATA) }}
|
||||
/>
|
||||
);
|
||||
export const HomeJsonLd: React.FC<{ locale: Locale }> = ({ locale }) => {
|
||||
const { site, services, technology, faq } = contentFor(locale);
|
||||
const data = buildStructuredData({
|
||||
siteUrl: SITE_URL,
|
||||
pageUrl: `${SITE_URL}${pathFor("home", locale)}`,
|
||||
siteName: SITE_NAME,
|
||||
language: locale,
|
||||
description: site.description,
|
||||
imageUrl: `${SITE_URL}/marc-mintel.png`,
|
||||
person: {
|
||||
name: OWNER.name,
|
||||
jobTitle: site.jobTitle,
|
||||
profiles: PROFILES,
|
||||
knowsAbout: [
|
||||
...site.knowsAbout,
|
||||
...technology.tech.flatMap((group) => group.items),
|
||||
],
|
||||
},
|
||||
address: {
|
||||
street: OWNER.street,
|
||||
postalCode: OWNER.postalCode,
|
||||
locality: OWNER.locality,
|
||||
countryCode: OWNER.countryCode,
|
||||
},
|
||||
catalogName: site.catalogName,
|
||||
services: services.paths.map((path) => ({
|
||||
name: path.service,
|
||||
description: path.intro,
|
||||
})),
|
||||
faq: faq.items,
|
||||
});
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Button } from "@/src/components/Button";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
||||
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import type { ServicePath } from "@/src/content/types";
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500";
|
||||
const STEP_MS = 4800;
|
||||
@@ -143,16 +144,18 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
||||
* All three panels stay in the DOM so the content is readable without JavaScript.
|
||||
*/
|
||||
export const PathPicker: React.FC = () => {
|
||||
const [activeId, setActiveId] = React.useState(SERVICE_PATHS[0]?.id ?? "");
|
||||
const { services } = useContent();
|
||||
const paths = services.paths;
|
||||
const [activeId, setActiveId] = React.useState(paths[0]?.id ?? "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="What do you need?"
|
||||
aria-label={services.tablistLabel}
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 lg:gap-4"
|
||||
>
|
||||
{SERVICE_PATHS.map((path, index) => {
|
||||
{paths.map((path, index) => {
|
||||
const selected = path.id === activeId;
|
||||
return (
|
||||
<button
|
||||
@@ -187,7 +190,7 @@ export const PathPicker: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{SERVICE_PATHS.map((path) => {
|
||||
{paths.map((path) => {
|
||||
const selected = path.id === activeId;
|
||||
return (
|
||||
<div
|
||||
@@ -214,7 +217,7 @@ export const PathPicker: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-5">
|
||||
<p className={`${MONO} m-0`}>What you get</p>
|
||||
<p className={`${MONO} m-0`}>{services.youGet}</p>
|
||||
<ul className="m-0 mt-4 list-none p-0">
|
||||
{path.youGet.map((item) => (
|
||||
<li
|
||||
@@ -233,12 +236,12 @@ export const PathPicker: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="mt-14 md:mt-24">
|
||||
<p className={`${MONO} m-0 mb-6`}>How it runs</p>
|
||||
<p className={`${MONO} m-0 mb-6`}>{services.howItRuns}</p>
|
||||
<PathStory path={path} active={selected} />
|
||||
</div>
|
||||
|
||||
<div className="mt-12 rounded-3xl bg-slate-50 p-6 md:mt-16 md:p-10">
|
||||
<p className={`${MONO} m-0`}>What it costs</p>
|
||||
<p className={`${MONO} m-0`}>{services.whatItCosts}</p>
|
||||
<div className="mt-6 grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12">
|
||||
<div className="md:col-span-6">
|
||||
<p className="m-0 font-sans text-2xl font-bold leading-tight tracking-tight text-slate-900 md:text-4xl">
|
||||
|
||||
@@ -17,8 +17,9 @@ export const PortraitSection: React.FC<{
|
||||
index: string;
|
||||
label: string;
|
||||
title: React.ReactNode;
|
||||
portraitAlt: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, index, label, title, children }) => (
|
||||
}> = ({ id, index, label, title, portraitAlt, children }) => (
|
||||
<section
|
||||
id={id}
|
||||
className="relative scroll-mt-24 overflow-hidden bg-slate-900 text-white"
|
||||
@@ -33,7 +34,7 @@ export const PortraitSection: React.FC<{
|
||||
>
|
||||
<Image
|
||||
src="/marc-mintel.png"
|
||||
alt="Marc Mintel, freelance web developer"
|
||||
alt={portraitAlt}
|
||||
fill
|
||||
sizes="(min-width: 768px) 62vw, 100vw"
|
||||
className="object-cover object-[50%_18%] grayscale"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
|
||||
/**
|
||||
* StickyBook: a quiet contact button (wide screens only; on phones the sticky header
|
||||
@@ -8,6 +9,7 @@ import * as React from "react";
|
||||
* scrolled away, and steps aside again when the closing section is in view.
|
||||
*/
|
||||
export const StickyBook: React.FC = () => {
|
||||
const { nav } = useContent();
|
||||
const [visible, setVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -39,7 +41,7 @@ export const StickyBook: React.FC = () => {
|
||||
tabIndex={visible ? 0 : -1}
|
||||
className="bit-cta flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Get in touch <span aria-hidden="true">→</span>
|
||||
{nav.contactLong} <span aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,9 @@
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import * as React from "react";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
import {
|
||||
KIND_LABEL,
|
||||
WORKFLOWS,
|
||||
type FlowNode,
|
||||
type NodeKind,
|
||||
type Workflow,
|
||||
} from "@/src/content/automation";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { workflowsFor, type FlowNode } from "@/src/content/automation";
|
||||
import type { NodeKind } from "@/src/content/types";
|
||||
|
||||
/**
|
||||
* AutomationFlow: a workflow drawn as connected systems, with the work visibly
|
||||
@@ -95,6 +91,9 @@ const KIND_STYLE: Readonly<
|
||||
human: { stroke: "#f8fafc", dash: "2 4", tag: "#f8fafc" },
|
||||
};
|
||||
|
||||
/** The order of the legend below the drawing. */
|
||||
const KINDS: readonly NodeKind[] = ["trigger", "rule", "ai", "action", "human"];
|
||||
|
||||
type Clock = {
|
||||
/** Which run of the workflow is playing. */
|
||||
readonly run: number;
|
||||
@@ -107,7 +106,12 @@ type Clock = {
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const AutomationFlow: React.FC = () => {
|
||||
const [flowId, setFlowId] = React.useState(WORKFLOWS[0]?.id ?? "");
|
||||
const { automation } = useContent();
|
||||
const workflows = React.useMemo(
|
||||
() => workflowsFor(automation.workflows),
|
||||
[automation.workflows],
|
||||
);
|
||||
const [flowId, setFlowId] = React.useState(workflows[0]?.id ?? "");
|
||||
const [clock, setClock] = React.useState<Clock>({ run: 0, step: 0, t: 0 });
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
@@ -116,7 +120,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
const tall = useNarrow();
|
||||
const canvas = tall ? TALL : WIDE;
|
||||
|
||||
const flow = WORKFLOWS.find((item) => item.id === flowId) ?? WORKFLOWS[0];
|
||||
const flow = workflows.find((item) => item.id === flowId) ?? workflows[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
@@ -195,10 +199,15 @@ export const AutomationFlow: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const logLines: string[] = [];
|
||||
const logLines: { text: string; kind: NodeKind }[] = [];
|
||||
const log = (node: Placed) => {
|
||||
if (logLines.every((line) => line.text !== node.log)) {
|
||||
logLines.push({ text: node.log, kind: node.kind });
|
||||
}
|
||||
};
|
||||
if (firstNode) {
|
||||
const first = nodeOf(firstNode);
|
||||
if (first) logLines.push(first.log);
|
||||
if (first) log(first);
|
||||
}
|
||||
steps.forEach((edges, index) => {
|
||||
const arrived =
|
||||
@@ -206,7 +215,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
if (!arrived) return;
|
||||
edges.forEach(([, to]) => {
|
||||
const node = nodeOf(to);
|
||||
if (node && !logLines.includes(node.log)) logLines.push(node.log);
|
||||
if (node) log(node);
|
||||
});
|
||||
});
|
||||
const finished = clock.step >= steps.length;
|
||||
@@ -215,10 +224,10 @@ export const AutomationFlow: React.FC = () => {
|
||||
<div ref={ref}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Example workflows"
|
||||
aria-label={automation.examplesLabel}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{WORKFLOWS.map((item) => {
|
||||
{workflows.map((item) => {
|
||||
const selected = item.id === flow.id;
|
||||
return (
|
||||
<button
|
||||
@@ -251,7 +260,7 @@ export const AutomationFlow: React.FC = () => {
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
|
||||
/>
|
||||
{finished ? "run complete" : "running"}
|
||||
{finished ? automation.runComplete : automation.running}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -383,53 +392,51 @@ export const AutomationFlow: React.FC = () => {
|
||||
|
||||
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
|
||||
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
|
||||
<p className={`${MONO} m-0 text-slate-500`}>{automation.log}</p>
|
||||
<ol
|
||||
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
|
||||
aria-live="off"
|
||||
>
|
||||
{logLines.map((line, index) => (
|
||||
<li key={line} className="flex gap-3">
|
||||
<li key={line.text} className="flex gap-3">
|
||||
<span className="text-slate-600">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
line.startsWith("unclear")
|
||||
? "text-amber-200"
|
||||
: undefined
|
||||
line.kind === "human" ? "text-amber-200" : undefined
|
||||
}
|
||||
>
|
||||
{line}
|
||||
{line.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
|
||||
{(Object.keys(KIND_LABEL) as NodeKind[])
|
||||
.filter((kind) => flow.nodes.some((node) => node.kind === kind))
|
||||
.map((kind) => (
|
||||
<li
|
||||
key={kind}
|
||||
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
|
||||
>
|
||||
<svg width="22" height="12" aria-hidden="true">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="20"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke={KIND_STYLE[kind].stroke}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={KIND_STYLE[kind].dash}
|
||||
/>
|
||||
</svg>
|
||||
{KIND_LABEL[kind]}
|
||||
</li>
|
||||
))}
|
||||
{KINDS.filter((kind) =>
|
||||
flow.nodes.some((node) => node.kind === kind),
|
||||
).map((kind) => (
|
||||
<li
|
||||
key={kind}
|
||||
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
|
||||
>
|
||||
<svg width="22" height="12" aria-hidden="true">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="20"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke={KIND_STYLE[kind].stroke}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={KIND_STYLE[kind].dash}
|
||||
/>
|
||||
</svg>
|
||||
{automation.kinds[kind]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,13 +444,13 @@ export const AutomationFlow: React.FC = () => {
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>By hand</p>
|
||||
<p className={`${MONO} m-0 text-slate-400`}>{automation.byHand}</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
||||
{flow.byHand}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-900 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-900`}>Automated</p>
|
||||
<p className={`${MONO} m-0 text-slate-900`}>{automation.automated}</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
|
||||
{flow.automated}
|
||||
</p>
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
|
||||
export type CareerStation = {
|
||||
readonly years: string;
|
||||
readonly role: string;
|
||||
readonly place: string;
|
||||
};
|
||||
import type { CareerStation } from "@/src/content/types";
|
||||
|
||||
/**
|
||||
* CareerLine: the path so far as a line of light that draws itself, one station
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
|
||||
const INK = "#f8fafc";
|
||||
@@ -10,8 +11,6 @@ const MARK = "#fde68a";
|
||||
const NODE_W = 112;
|
||||
const NODE_H = 38;
|
||||
|
||||
const CHAIN = ["You", "Sales", "Manager", "Designer", "Developer"] as const;
|
||||
|
||||
const Node: React.FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -78,12 +77,13 @@ const Caption: React.FC<{
|
||||
*/
|
||||
export const ContactDiagram: React.FC = () => {
|
||||
const tall = useNarrow();
|
||||
const label =
|
||||
"Often a request passes through several people before it reaches the developer. With me there is one direct line between you and the person who does the work.";
|
||||
const copy = useContent().working.diagram;
|
||||
const chain = copy.chain;
|
||||
const label = copy.description;
|
||||
|
||||
if (tall) {
|
||||
// Two columns: the chain on the left, the direct line on the right.
|
||||
const ys = CHAIN.map((_, i) => 96 + i * 84);
|
||||
const ys = chain.map((_, i) => 96 + i * 84);
|
||||
const first = ys[0] ?? 96;
|
||||
const last = ys[ys.length - 1] ?? 432;
|
||||
const stops = ys.flatMap((y) => [y, y]);
|
||||
@@ -95,10 +95,10 @@ export const ContactDiagram: React.FC = () => {
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<Caption x={24} y={38} size={12}>
|
||||
often
|
||||
{copy.often}
|
||||
</Caption>
|
||||
<Caption x={196} y={38} size={12} strong>
|
||||
with me
|
||||
{copy.withMe}
|
||||
</Caption>
|
||||
<line
|
||||
x1={90}
|
||||
@@ -109,7 +109,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
{CHAIN.map((name, i) => (
|
||||
{chain.map((name, i) => (
|
||||
<Node
|
||||
key={name}
|
||||
x={90}
|
||||
@@ -143,8 +143,8 @@ export const ContactDiagram: React.FC = () => {
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Node x={250} y={first} label="You" size={12} />
|
||||
<Node x={250} y={last} label="Me" size={12} solid />
|
||||
<Node x={250} y={first} label={copy.you} size={12} />
|
||||
<Node x={250} y={last} label={copy.me} size={12} solid />
|
||||
<motion.circle
|
||||
r={5}
|
||||
cx={250}
|
||||
@@ -159,7 +159,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const xs = CHAIN.map((_, i) => 76 + i * 132);
|
||||
const xs = chain.map((_, i) => 76 + i * 132);
|
||||
const first = xs[0] ?? 76;
|
||||
const last = xs[xs.length - 1] ?? 604;
|
||||
const stops = xs.flatMap((x) => [x, x]);
|
||||
@@ -183,7 +183,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
<rect width="680" height="250" fill="url(#contact-grid)" />
|
||||
|
||||
<Caption x={20} y={30} size={10}>
|
||||
often: a request is passed along
|
||||
{copy.oftenLong}
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
@@ -194,7 +194,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
{CHAIN.map((name, i) => (
|
||||
{chain.map((name, i) => (
|
||||
<Node
|
||||
key={name}
|
||||
x={xs[i] ?? 0}
|
||||
@@ -222,7 +222,7 @@ export const ContactDiagram: React.FC = () => {
|
||||
/>
|
||||
|
||||
<Caption x={20} y={160} size={10} strong>
|
||||
with me: one direct line
|
||||
{copy.withMeLong}
|
||||
</Caption>
|
||||
<line
|
||||
x1={first}
|
||||
@@ -232,8 +232,8 @@ export const ContactDiagram: React.FC = () => {
|
||||
stroke={INK}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Node x={first} y={204} label="You" size={10} />
|
||||
<Node x={last} y={204} label="Me" size={10} solid />
|
||||
<Node x={first} y={204} label={copy.you} size={10} />
|
||||
<Node x={last} y={204} label={copy.me} size={10} solid />
|
||||
<motion.circle
|
||||
r={5}
|
||||
cy={204}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import type { SceneLabels } from "@/src/content/types";
|
||||
import { fill } from "@/src/domain/template";
|
||||
|
||||
/**
|
||||
* PathScenes: one small animated drawing per step of a service path.
|
||||
@@ -40,6 +43,9 @@ const appear = (delay: number, y = 6) => ({
|
||||
transition: { duration: 0.4, delay, ease: "easeOut" as const },
|
||||
});
|
||||
|
||||
/** A drawing of one step. `l` holds its labels in the language of the page. */
|
||||
type Scene = React.FC<{ l: SceneLabels }>;
|
||||
|
||||
type Pt = readonly [number, number];
|
||||
|
||||
/** Points along a cubic curve, for packets that travel on it. */
|
||||
@@ -133,10 +139,10 @@ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
|
||||
);
|
||||
|
||||
/** Step 1 of every path: a short conversation. */
|
||||
const Talk: React.FC = () => (
|
||||
const Talk: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Person x={84} label="You" />
|
||||
<Person x={396} label="Me" filled />
|
||||
<Person x={84} label={l.you} />
|
||||
<Person x={396} label={l.me} filled />
|
||||
{[
|
||||
{ x: 150, y: 86, w: 120, side: "left", delay: 0.2 },
|
||||
{ x: 210, y: 124, w: 120, side: "right", delay: 0.7 },
|
||||
@@ -194,17 +200,15 @@ const Talk: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.6, 0)}
|
||||
>
|
||||
about 20 minutes
|
||||
{l.talkLength}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"] as const;
|
||||
|
||||
/** Developer, step 2: agreed days of the week. */
|
||||
const WeeklyHours: React.FC = () => (
|
||||
const WeeklyHours: Scene = ({ l }) => (
|
||||
<g>
|
||||
{DAYS.map((day, d) => (
|
||||
{l.days.map((day, d) => (
|
||||
<g key={day}>
|
||||
<text x={78 + d * 72} y={52} textAnchor="middle" style={MONO}>
|
||||
{day}
|
||||
@@ -241,13 +245,13 @@ const WeeklyHours: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.3, 0)}
|
||||
>
|
||||
for example three days a week
|
||||
{l.daysNote}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Developer, step 3: the list gets worked through and comes back for review. */
|
||||
const WorkList: React.FC = () => (
|
||||
const WorkList: Scene = ({ l }) => (
|
||||
<g>
|
||||
{[0, 1, 2, 3].map((row) => {
|
||||
const done = row < 3;
|
||||
@@ -326,7 +330,7 @@ const WorkList: React.FC = () => (
|
||||
fontSize: "calc(var(--scene-label, 10px) - 1px)",
|
||||
}}
|
||||
>
|
||||
you
|
||||
{l.you}
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
@@ -340,13 +344,13 @@ const WorkList: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.8, 0)}
|
||||
>
|
||||
finished parts go to you for review
|
||||
{l.review}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Developer, step 4: two weeks notice to the end of a month. */
|
||||
const Notice: React.FC = () => (
|
||||
const Notice: Scene = ({ l }) => (
|
||||
<g>
|
||||
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={1.5} />
|
||||
{Array.from({ length: 5 }, (_, w) => (
|
||||
@@ -360,7 +364,7 @@ const Notice: React.FC = () => (
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<text x={50 + w * 95} y={182} textAnchor="middle" style={MONO}>
|
||||
{w === 4 ? "end of month" : `week ${w + 1}`}
|
||||
{w === 4 ? l.monthEnd : fill(l.week, { n: w + 1 })}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
@@ -382,7 +386,7 @@ const Notice: React.FC = () => (
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
<text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}>
|
||||
two weeks notice
|
||||
{l.notice}
|
||||
</text>
|
||||
</motion.g>
|
||||
<motion.circle cx={430} cy={150} r={4.5} fill={INK} {...appear(1.4, 0)} />
|
||||
@@ -414,7 +418,7 @@ const EDGES: readonly (readonly [number, number])[] = [
|
||||
];
|
||||
|
||||
/** Rescue, step 2: fragile connections become solid over the week. */
|
||||
const RepairWeek: React.FC = () => (
|
||||
const RepairWeek: Scene = ({ l }) => (
|
||||
<g>
|
||||
{EDGES.map(([a, b], i) => {
|
||||
const from = NODES[a]!;
|
||||
@@ -462,13 +466,13 @@ const RepairWeek: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(2.6, 0)}
|
||||
>
|
||||
fragile parts get tests and fixes
|
||||
{l.repair}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Rescue, step 3: a written summary. */
|
||||
const Summary: React.FC = () => (
|
||||
const Summary: Scene = ({ l }) => (
|
||||
<g>
|
||||
<rect
|
||||
x={150}
|
||||
@@ -480,7 +484,7 @@ const Summary: React.FC = () => (
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
/>
|
||||
{["found", "changed", "next"].map((label, s) => (
|
||||
{l.summaryParts.map((label, s) => (
|
||||
<g key={label}>
|
||||
<motion.text
|
||||
x={170}
|
||||
@@ -512,13 +516,13 @@ const Summary: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(2.2, 0)}
|
||||
>
|
||||
written so you can follow it
|
||||
{l.summaryNote}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Rescue, step 4: the client decides how to continue. */
|
||||
const Decide: React.FC = () => (
|
||||
const Decide: Scene = ({ l }) => (
|
||||
<g>
|
||||
<circle
|
||||
cx={90}
|
||||
@@ -538,56 +542,55 @@ const Decide: React.FC = () => (
|
||||
fontSize: "calc(var(--scene-label, 10px) - 1px)",
|
||||
}}
|
||||
>
|
||||
you
|
||||
{l.you}
|
||||
</text>
|
||||
{[
|
||||
{ y: 70, label: "continue with me" },
|
||||
{ y: 150, label: "your own team" },
|
||||
{ y: 230, label: "stop here" },
|
||||
].map((option, i) => (
|
||||
<g key={option.label}>
|
||||
<motion.path
|
||||
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
<Packet
|
||||
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
|
||||
delay={1.6 + i * 1.3}
|
||||
duration={1.1}
|
||||
pause={2.8}
|
||||
/>
|
||||
<motion.g {...appear(0.7 + i * 0.4, 0)}>
|
||||
<rect
|
||||
x={250}
|
||||
y={option.y - 16}
|
||||
width={180}
|
||||
height={32}
|
||||
rx={16}
|
||||
fill={PAPER}
|
||||
stroke={LINE}
|
||||
{[70, 150, 230]
|
||||
.map((y, i) => ({ y, label: l.options[i] ?? "" }))
|
||||
.map((option, i) => (
|
||||
<g key={option.label}>
|
||||
<motion.path
|
||||
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
|
||||
fill="none"
|
||||
stroke={INK}
|
||||
strokeWidth={FINE}
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
<text
|
||||
x={340}
|
||||
y={option.y + 4}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
</g>
|
||||
))}
|
||||
<Packet
|
||||
{...along([116, 150], [190, 150], [180, option.y], [250, option.y])}
|
||||
delay={1.6 + i * 1.3}
|
||||
duration={1.1}
|
||||
pause={2.8}
|
||||
/>
|
||||
<motion.g {...appear(0.7 + i * 0.4, 0)}>
|
||||
<rect
|
||||
x={250}
|
||||
y={option.y - 16}
|
||||
width={180}
|
||||
height={32}
|
||||
rx={16}
|
||||
fill={PAPER}
|
||||
stroke={LINE}
|
||||
/>
|
||||
<text
|
||||
x={340}
|
||||
y={option.y + 4}
|
||||
textAnchor="middle"
|
||||
style={{ ...MONO, fill: INK }}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
</motion.g>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
|
||||
const Browser: React.FC<{
|
||||
children: React.ReactNode;
|
||||
url?: string;
|
||||
live?: boolean;
|
||||
}> = ({ children, url, live = false }) => (
|
||||
/** Shown next to a pulsing dot once the page is live. */
|
||||
onlineLabel?: string;
|
||||
}> = ({ children, url, onlineLabel }) => (
|
||||
<g>
|
||||
<rect
|
||||
x={70}
|
||||
@@ -617,7 +620,7 @@ const Browser: React.FC<{
|
||||
{url}
|
||||
</text>
|
||||
)}
|
||||
{live && (
|
||||
{onlineLabel !== undefined && (
|
||||
<motion.g {...appear(1.6, 0)}>
|
||||
<motion.circle
|
||||
cx={352}
|
||||
@@ -636,7 +639,7 @@ const Browser: React.FC<{
|
||||
fill: LIVE,
|
||||
}}
|
||||
>
|
||||
online
|
||||
{onlineLabel}
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
@@ -704,7 +707,7 @@ const PageDetail: React.FC<{ delay: number }> = ({ delay }) => (
|
||||
);
|
||||
|
||||
/** Website, step 2: a first design as outlines. */
|
||||
const Wireframe: React.FC = () => (
|
||||
const Wireframe: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Browser>
|
||||
{BLOCKS.map((b, i) => (
|
||||
@@ -730,15 +733,15 @@ const Wireframe: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.5, 0)}
|
||||
>
|
||||
a first version to click through
|
||||
{l.firstVersion}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Website, step 3: built and online. */
|
||||
const Built: React.FC = () => (
|
||||
const Built: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Browser url="yourcompany.com" live>
|
||||
<Browser url={l.exampleDomain} onlineLabel={l.online}>
|
||||
{BLOCKS.map((b, i) => (
|
||||
<motion.rect
|
||||
key={i}
|
||||
@@ -764,15 +767,15 @@ const Built: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.8, 0)}
|
||||
>
|
||||
you approve it before it goes live
|
||||
{l.approve}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Website, step 4: a change request by email. */
|
||||
const ChangeByEmail: React.FC = () => (
|
||||
const ChangeByEmail: Scene = ({ l }) => (
|
||||
<g>
|
||||
<Browser url="yourcompany.com" live>
|
||||
<Browser url={l.exampleDomain} onlineLabel={l.online}>
|
||||
{BLOCKS.map((b, i) => (
|
||||
<rect
|
||||
key={i}
|
||||
@@ -831,16 +834,17 @@ const ChangeByEmail: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.7, 0)}
|
||||
>
|
||||
you send an email, I make the change
|
||||
{l.changeByEmail}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const TOOLS: readonly { x: number; y: number; label: string }[] = [
|
||||
{ x: 90, y: 80, label: "Shop" },
|
||||
{ x: 390, y: 80, label: "Accounting" },
|
||||
{ x: 90, y: 200, label: "Email" },
|
||||
{ x: 390, y: 200, label: "CRM" },
|
||||
/** Where the four tools stand; their names are `tools` in the labels, in this order. */
|
||||
const TOOLS: readonly { x: number; y: number }[] = [
|
||||
{ x: 90, y: 80 },
|
||||
{ x: 390, y: 80 },
|
||||
{ x: 90, y: 200 },
|
||||
{ x: 390, y: 200 },
|
||||
];
|
||||
|
||||
const Tool: React.FC<{ x: number; y: number; label: string }> = ({
|
||||
@@ -866,14 +870,14 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
|
||||
);
|
||||
|
||||
/** Automation, step 2: today a person carries the data from tool to tool. */
|
||||
const ByHand: React.FC = () => (
|
||||
const ByHand: Scene = ({ l }) => (
|
||||
<g>
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
{TOOLS.map((tool, i) => (
|
||||
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
|
||||
))}
|
||||
{TOOLS.map((tool, i) => (
|
||||
<motion.path
|
||||
key={tool.label}
|
||||
key={i}
|
||||
d={`M ${tool.x + (tool.x < 240 ? 56 : -56)} ${tool.y} L ${tool.x < 240 ? 214 : 266} ${140 + (tool.y < 140 ? -12 : 12)}`}
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
@@ -905,7 +909,7 @@ const ByHand: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(1.5, 0)}
|
||||
>
|
||||
today: copied by hand
|
||||
{l.byHand}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
@@ -918,7 +922,7 @@ const WIRES: readonly string[] = [
|
||||
];
|
||||
|
||||
/** Automation, step 3: the systems are connected one step at a time. */
|
||||
const Wiring: React.FC = () => (
|
||||
const Wiring: Scene = ({ l }) => (
|
||||
<g>
|
||||
{WIRES.map((d, i) => (
|
||||
<motion.path
|
||||
@@ -930,8 +934,8 @@ const Wiring: React.FC = () => (
|
||||
{...draw(0.3 + i * 0.4)}
|
||||
/>
|
||||
))}
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
{TOOLS.map((tool, i) => (
|
||||
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
@@ -940,13 +944,13 @@ const Wiring: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(2, 0)}
|
||||
>
|
||||
connected one step at a time
|
||||
{l.wiring}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
/** Automation, step 4: the work travels between the systems on its own. */
|
||||
const Running: React.FC = () => (
|
||||
const Running: Scene = ({ l }) => (
|
||||
<g>
|
||||
{WIRES.map((d) => (
|
||||
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} />
|
||||
@@ -979,8 +983,8 @@ const Running: React.FC = () => (
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{TOOLS.map((tool) => (
|
||||
<Tool key={tool.label} {...tool} />
|
||||
{TOOLS.map((tool, i) => (
|
||||
<Tool key={i} {...tool} label={l.tools[i] ?? ""} />
|
||||
))}
|
||||
<motion.text
|
||||
x={240}
|
||||
@@ -989,12 +993,12 @@ const Running: React.FC = () => (
|
||||
style={MONO}
|
||||
{...appear(0.6, 0)}
|
||||
>
|
||||
the steps run without anyone copying
|
||||
{l.running}
|
||||
</motion.text>
|
||||
</g>
|
||||
);
|
||||
|
||||
const SCENES: Readonly<Record<string, readonly React.FC[]>> = {
|
||||
const SCENES: Readonly<Record<string, readonly Scene[]>> = {
|
||||
developer: [Talk, WeeklyHours, WorkList, Notice],
|
||||
rescue: [Talk, RepairWeek, Summary, Decide],
|
||||
automation: [Talk, ByHand, Wiring, Running],
|
||||
@@ -1006,6 +1010,7 @@ export const PathScene: React.FC<{
|
||||
step: number;
|
||||
title: string;
|
||||
}> = ({ pathId, step, title }) => {
|
||||
const labels = useContent().services.scenes;
|
||||
const Scene = SCENES[pathId]?.[step];
|
||||
if (!Scene) return null;
|
||||
|
||||
@@ -1047,7 +1052,7 @@ export const PathScene: React.FC<{
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<Scene />
|
||||
<Scene l={labels} />
|
||||
</motion.g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
import * as React from "react";
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { fill } from "@/src/domain/template";
|
||||
|
||||
/**
|
||||
* PipelineRun: what happens between a change and its release, shown as the test run it is.
|
||||
@@ -24,56 +26,37 @@ const SUITES: readonly Suite[] = [
|
||||
{ file: "emails/invoice-mail.spec.ts", tests: 5, ms: 290 },
|
||||
];
|
||||
|
||||
/** A change and the test it breaks elsewhere. The texts are in en.ts and de.ts, by id. */
|
||||
type Scenario = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly branch: string;
|
||||
readonly failingFile: string;
|
||||
readonly testName: string;
|
||||
readonly expected: string;
|
||||
readonly received: string;
|
||||
};
|
||||
|
||||
const SCENARIOS: readonly Scenario[] = [
|
||||
{
|
||||
id: "orders",
|
||||
label: "Orders",
|
||||
branch: "change/order-discounts",
|
||||
failingFile: "invoices/total.spec.ts",
|
||||
testName: "invoice total matches the discounted order",
|
||||
expected: "119.00",
|
||||
received: "125.00",
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
label: "Login",
|
||||
branch: "change/login-with-username",
|
||||
failingFile: "emails/confirmation.spec.ts",
|
||||
testName: "confirmation goes to the account's email address",
|
||||
expected: '"anna@example.com"',
|
||||
received: "undefined",
|
||||
},
|
||||
{
|
||||
id: "invoices",
|
||||
label: "Invoices",
|
||||
branch: "change/invoice-layout",
|
||||
failingFile: "emails/invoice-mail.spec.ts",
|
||||
testName: "invoice email attaches the PDF",
|
||||
expected: "1 attachment",
|
||||
received: "0 attachments",
|
||||
},
|
||||
{
|
||||
id: "emails",
|
||||
label: "Emails",
|
||||
branch: "change/email-queue",
|
||||
failingFile: "orders/status.spec.ts",
|
||||
testName: "order is confirmed once the email is queued",
|
||||
expected: '"confirmed"',
|
||||
received: '"pending"',
|
||||
},
|
||||
];
|
||||
|
||||
const STAGES = ["Lint", "Types", "Tests", "Deploy"] as const;
|
||||
const STAGE_COUNT = 4;
|
||||
const TOTAL_TESTS = SUITES.reduce((sum, suite) => sum + suite.tests, 0);
|
||||
|
||||
/**
|
||||
@@ -129,6 +112,7 @@ const STAGE_MARK: Readonly<Record<StageState, string>> = {
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const PipelineRun: React.FC = () => {
|
||||
const copy = useContent().technology.pipeline;
|
||||
const [scenarioId, setScenarioId] = React.useState(SCENARIOS[0]?.id ?? "");
|
||||
const [frameIndex, setFrameIndex] = React.useState(0);
|
||||
const [run, setRun] = React.useState(0);
|
||||
@@ -170,7 +154,8 @@ export const PipelineRun: React.FC = () => {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [inView, scenarioId, run]);
|
||||
|
||||
if (!scenario) return null;
|
||||
const scenarioCopy = scenario ? copy.scenarios[scenario.id] : undefined;
|
||||
if (!scenario || !scenarioCopy) return null;
|
||||
|
||||
const frame = FRAMES[frameIndex] ?? FRAMES[0];
|
||||
if (!frame) return null;
|
||||
@@ -178,7 +163,7 @@ export const PipelineRun: React.FC = () => {
|
||||
const firstAttempt = frame.attempt === 1;
|
||||
const failingIndex = SUITES.findIndex((s) => s.file === scenario.failingFile);
|
||||
const failureSeen = firstAttempt && frame.suitesDone > failingIndex;
|
||||
const finished = frame.stagesDone === STAGES.length;
|
||||
const finished = frame.stagesDone === STAGE_COUNT;
|
||||
|
||||
const stageState = (index: number): StageState => {
|
||||
if (index === 2 && firstAttempt && failureSeen && frame.stagesDone >= 3) {
|
||||
@@ -201,20 +186,10 @@ export const PipelineRun: React.FC = () => {
|
||||
// Which of the three explanations applies right now.
|
||||
const explain = firstAttempt ? (finished ? 1 : 0) : finished ? 2 : 1;
|
||||
|
||||
const EXPLANATIONS = [
|
||||
{
|
||||
title: "A change comes in",
|
||||
text: `Something is changed in ${scenario.label}. Before it can go live, the whole project is checked automatically.`,
|
||||
},
|
||||
{
|
||||
title: "A test fails, the release is blocked",
|
||||
text: "The change broke something in a different place. It is caught here, on my side, and nothing reaches your users.",
|
||||
},
|
||||
{
|
||||
title: "Fixed, checked again, released",
|
||||
text: "I repair what broke, the run passes, and only then does the change go live.",
|
||||
},
|
||||
] as const;
|
||||
const explanations = copy.explanations.map((item) => ({
|
||||
title: item.title,
|
||||
text: fill(item.text, { area: scenarioCopy.label }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -222,7 +197,7 @@ export const PipelineRun: React.FC = () => {
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:gap-12"
|
||||
>
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Simulate a change in</p>
|
||||
<p className={`${MONO} m-0 text-slate-500`}>{copy.simulate}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{SCENARIOS.map((item) => {
|
||||
const selected = item.id === scenario.id;
|
||||
@@ -241,14 +216,14 @@ export const PipelineRun: React.FC = () => {
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{copy.scenarios[item.id]?.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ol className="m-0 mt-10 list-none p-0" aria-live="polite">
|
||||
{EXPLANATIONS.map((item, index) => {
|
||||
{explanations.map((item, index) => {
|
||||
const active = index === explain;
|
||||
return (
|
||||
<li
|
||||
@@ -281,7 +256,7 @@ export const PipelineRun: React.FC = () => {
|
||||
onClick={() => setRun((n) => n + 1)}
|
||||
className="mt-6 rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] text-slate-600 transition-colors hover:border-slate-400 hover:text-slate-900"
|
||||
>
|
||||
Run again
|
||||
{copy.runAgain}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -302,16 +277,16 @@ export const PipelineRun: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
run #{frame.attempt === 1 ? 481 : 482}
|
||||
{copy.run} #{frame.attempt === 1 ? 481 : 482}
|
||||
{frame.attempt === 2 && (
|
||||
<span className="text-slate-400"> · fix pushed</span>
|
||||
<span className="text-slate-400"> · {copy.fixPushed}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stages */}
|
||||
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
|
||||
{STAGES.map((stage, index) => {
|
||||
{copy.stages.map((stage, index) => {
|
||||
const state = stageState(index);
|
||||
return (
|
||||
<div
|
||||
@@ -329,9 +304,7 @@ export const PipelineRun: React.FC = () => {
|
||||
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
|
||||
{frame.stagesDone < 2 && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
{frame.stagesDone === 0
|
||||
? "$ lint: checking the code style…"
|
||||
: "$ types: checking that the parts fit together…"}
|
||||
{frame.stagesDone === 0 ? copy.lint : copy.types}
|
||||
</p>
|
||||
)}
|
||||
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
|
||||
@@ -348,7 +321,7 @@ export const PipelineRun: React.FC = () => {
|
||||
{suite.file}
|
||||
</span>
|
||||
<span className="hidden text-slate-500 sm:inline">
|
||||
{suite.tests} tests
|
||||
{suite.tests} {copy.tests}
|
||||
</span>
|
||||
<span className="w-14 shrink-0 text-right text-slate-600">
|
||||
{suite.ms} ms
|
||||
@@ -357,13 +330,13 @@ export const PipelineRun: React.FC = () => {
|
||||
{failed && (
|
||||
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
|
||||
● {scenario.testName}
|
||||
● {scenarioCopy.testName}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
|
||||
expected {scenario.expected}
|
||||
{copy.expected} {scenarioCopy.expected}
|
||||
</p>
|
||||
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
|
||||
received {scenario.received}
|
||||
{copy.received} {scenarioCopy.received}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -372,7 +345,9 @@ export const PipelineRun: React.FC = () => {
|
||||
})}
|
||||
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
|
||||
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
|
||||
running {SUITES[frame.suitesDone]?.file}…
|
||||
{fill(copy.runningFile, {
|
||||
file: SUITES[frame.suitesDone]?.file ?? "",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -380,14 +355,19 @@ export const PipelineRun: React.FC = () => {
|
||||
{/* Summary */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
|
||||
<span>
|
||||
<span className="text-slate-500">Tests </span>
|
||||
<span className="text-slate-500">{copy.summaryLabel} </span>
|
||||
{failedCount > 0 && (
|
||||
<span className="text-rose-400">{failedCount} failed, </span>
|
||||
<span className="text-rose-400">
|
||||
{fill(copy.failed, { n: failedCount })},{" "}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-emerald-400">
|
||||
{reportedTests - failedCount} passed
|
||||
{fill(copy.passed, { n: reportedTests - failedCount })}
|
||||
</span>
|
||||
<span className="text-slate-500">
|
||||
{" "}
|
||||
{fill(copy.of, { total: TOTAL_TESTS })}
|
||||
</span>
|
||||
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
|
||||
@@ -399,18 +379,16 @@ export const PipelineRun: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
{!finished
|
||||
? "release pending"
|
||||
? copy.pending
|
||||
: firstAttempt
|
||||
? "release blocked"
|
||||
: "released"}
|
||||
? copy.blocked
|
||||
: copy.released}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BitAura>
|
||||
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
|
||||
An illustration with made-up file names and numbers, modelled on a
|
||||
real test run. Tests do not catch every mistake, but they do catch
|
||||
this kind.
|
||||
{copy.footnote}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,25 +4,9 @@ import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, useMotionValue, useSpring } from "framer-motion";
|
||||
import { BitAura } from "@/src/components/bits/BitAura";
|
||||
|
||||
export type WorkItem = {
|
||||
readonly name: string;
|
||||
readonly href: string;
|
||||
readonly client: string;
|
||||
readonly text: string;
|
||||
readonly did: readonly string[];
|
||||
readonly tech: readonly string[];
|
||||
readonly desktop: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
readonly mobile: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
};
|
||||
import { useContent } from "@/src/components/ContentProvider";
|
||||
import { WORK_SITES, type WorkSite } from "@/src/content/work";
|
||||
import { fill } from "@/src/domain/template";
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
@@ -70,7 +54,8 @@ const ScrollingShot: React.FC<{
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
const WorkStage: React.FC<{ item: WorkSite }> = ({ item }) => {
|
||||
const { work } = useContent();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
const [still, setStill] = React.useState(false);
|
||||
@@ -147,7 +132,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
<ScrollingShot
|
||||
shot={item.desktop}
|
||||
frameAspect={16 / 10}
|
||||
alt={`${item.name} on a desktop screen`}
|
||||
alt={fill(work.desktopAlt, { name: item.name })}
|
||||
sizes="(min-width: 1024px) 900px, 100vw"
|
||||
active={active}
|
||||
seconds={22}
|
||||
@@ -167,7 +152,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
<ScrollingShot
|
||||
shot={item.mobile}
|
||||
frameAspect={9 / 17}
|
||||
alt={`${item.name} on a phone`}
|
||||
alt={fill(work.phoneAlt, { name: item.name })}
|
||||
sizes="240px"
|
||||
active={active}
|
||||
seconds={26}
|
||||
@@ -183,68 +168,75 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
* WorkShowcase: for a dark section. Each live site on a lit stage, as a desktop and a phone view
|
||||
* that scroll through the real pages.
|
||||
*/
|
||||
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
|
||||
items,
|
||||
}) => (
|
||||
<div className="flex flex-col gap-20 md:gap-32">
|
||||
{items.map((item, index) => (
|
||||
<article
|
||||
key={item.name}
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
|
||||
>
|
||||
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
|
||||
<BitAura
|
||||
source={index % 2 === 0 ? "bottom" : "top"}
|
||||
tone="dark"
|
||||
motion={index % 2 === 0 ? "sparks" : "rain"}
|
||||
export const WorkShowcase: React.FC = () => {
|
||||
const { work } = useContent();
|
||||
return (
|
||||
<div className="flex flex-col gap-20 md:gap-32">
|
||||
{WORK_SITES.map((item, index) => {
|
||||
const copy = work.items[item.name];
|
||||
if (copy === undefined) return null;
|
||||
return (
|
||||
<article
|
||||
key={item.name}
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
|
||||
>
|
||||
<WorkStage item={item} />
|
||||
</BitAura>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>
|
||||
0{index + 1} · {item.client}
|
||||
</p>
|
||||
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
{item.text}
|
||||
</p>
|
||||
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
{item.did.map((line) => (
|
||||
<li
|
||||
key={line}
|
||||
className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
|
||||
<div
|
||||
className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}
|
||||
>
|
||||
<BitAura
|
||||
source={index % 2 === 0 ? "bottom" : "top"}
|
||||
tone="dark"
|
||||
motion={index % 2 === 0 ? "sparks" : "rain"}
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<WorkStage item={item} />
|
||||
</BitAura>
|
||||
</div>
|
||||
|
||||
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
|
||||
{item.tech.map((tech) => (
|
||||
<li
|
||||
key={tech}
|
||||
className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>
|
||||
0{index + 1} · {copy.client}
|
||||
</p>
|
||||
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
|
||||
{copy.text}
|
||||
</p>
|
||||
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
{copy.did.map((line) => (
|
||||
<li
|
||||
key={line}
|
||||
className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
|
||||
{copy.tech.map((tech) => (
|
||||
<li
|
||||
key={tech}
|
||||
className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
|
||||
>
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bit-cta bit-cta-light mt-8 inline-flex items-center gap-3 rounded-full bg-white px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Open the live site <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
{work.openLive} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import type { Locale } from "@/src/domain/locale";
|
||||
import type { ScrollAnchor } from "@/src/domain/scroll-anchor";
|
||||
|
||||
/**
|
||||
* The state of a running language switch. It lives outside React on purpose:
|
||||
* changing the language swaps the whole page below the root layout, and the
|
||||
* curtain that hides the swap has to carry on across it.
|
||||
*
|
||||
* cover: the curtain closes over the old page. hold: the page is swapped behind
|
||||
* it. reveal: the curtain opens on the new page.
|
||||
*/
|
||||
export type SwitchState =
|
||||
| { readonly phase: "idle" }
|
||||
| {
|
||||
readonly phase: "cover" | "hold" | "reveal";
|
||||
readonly from: Locale;
|
||||
readonly target: Locale;
|
||||
readonly href: string;
|
||||
/** When the switch began and when the current phase began (performance.now). */
|
||||
readonly startedAt: number;
|
||||
readonly phaseAt: number;
|
||||
/** Where the visitor was reading, to find the same place in the new page. */
|
||||
readonly anchor: ScrollAnchor | null;
|
||||
};
|
||||
|
||||
const IDLE: SwitchState = { phase: "idle" };
|
||||
|
||||
let state: SwitchState = IDLE;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const set = (next: SwitchState) => {
|
||||
state = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const subscribe = (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
export const getSwitchState = (): SwitchState => state;
|
||||
export const getIdleState = (): SwitchState => IDLE;
|
||||
|
||||
export const startSwitch = (from: Locale, target: Locale, href: string) => {
|
||||
if (state.phase !== "idle") return;
|
||||
const now = performance.now();
|
||||
set({
|
||||
phase: "cover",
|
||||
from,
|
||||
target,
|
||||
href,
|
||||
startedAt: now,
|
||||
phaseAt: now,
|
||||
anchor: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const enterPhase = (
|
||||
phase: "hold" | "reveal",
|
||||
anchor?: ScrollAnchor | null,
|
||||
) => {
|
||||
if (state.phase === "idle") return;
|
||||
set({
|
||||
...state,
|
||||
phase,
|
||||
phaseAt: performance.now(),
|
||||
anchor: anchor === undefined ? state.anchor : anchor,
|
||||
});
|
||||
};
|
||||
|
||||
export const endSwitch = () => set(IDLE);
|
||||
Reference in New Issue
Block a user