Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390936856c | ||
|
|
dc4a01cbe4 |
@@ -36,6 +36,16 @@ App lives in `apps/web`. Concept and scope: `apps/web/plans/freelance-relaunch.m
|
||||
- The hero design (binary-digit headline) is approved: do not change its design.
|
||||
- Look at the result before reporting: take screenshots (desktop and mobile), do not ship blind.
|
||||
|
||||
## Motion (binary micro-interactions)
|
||||
|
||||
- Building blocks live in `apps/web/src/components/bits/`: `DecodeText` (mono labels resolving from digits),
|
||||
`BitWipe` (headline revealed behind a column of digits), `BitReveal` (picture assembling from bits),
|
||||
`BinaryTicker`, `ScrollBits`, `BitBurst`. Reuse them instead of inventing new effects.
|
||||
- `DecodeText` is for monospaced text only; in proportional type the changing glyph widths make text jump.
|
||||
- Never put an in-view trigger on an element that clips itself away (clip-path): a fully clipped element
|
||||
never counts as visible. Observe an unclipped wrapper (see `BitWipe`).
|
||||
- Every effect must respect `prefers-reduced-motion` and leave content readable without JavaScript.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- Functional core, imperative shell. Pure logic goes in `apps/web/src/domain/` (no I/O, no clock, no
|
||||
|
||||
@@ -1004,3 +1004,87 @@ pre:has(code[class*="language-"]) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Binary micro-interactions ─────────────────────────────────────────────── */
|
||||
|
||||
/* Digits that fly out from a pressed link or button (see BitBurst). */
|
||||
.bit-burst {
|
||||
position: fixed;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
/* On the layer, not the digits: a fixed layer blends as a whole with the page. */
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
.bit-burst span {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font-family: var(--font-jetbrains-mono), ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
opacity: 0;
|
||||
animation: bit-burst-fly 620ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
@keyframes bit-burst-fly {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy)))
|
||||
scale(0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Corner marks that snap onto a card on hover or focus. */
|
||||
.bit-card {
|
||||
position: relative;
|
||||
}
|
||||
.bit-card::before,
|
||||
.bit-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid currentColor;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 250ms ease,
|
||||
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.bit-card::before {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
border-left: 0;
|
||||
border-bottom: 0;
|
||||
transform: translate(-6px, 6px);
|
||||
}
|
||||
.bit-card::after {
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
transform: translate(6px, -6px);
|
||||
}
|
||||
.bit-card:hover::before,
|
||||
.bit-card:hover::after,
|
||||
.bit-card:focus-visible::before,
|
||||
.bit-card:focus-visible::after,
|
||||
.bit-card[aria-selected="true"]::before,
|
||||
.bit-card[aria-selected="true"]::after {
|
||||
opacity: 0.55;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bit-burst {
|
||||
display: none;
|
||||
}
|
||||
.bit-card::before,
|
||||
.bit-card::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
|
||||
import { Analytics } from "@/src/components/Analytics";
|
||||
import { BitBurst } from "@/src/components/bits/BitBurst";
|
||||
import { Footer } from "@/src/components/Footer";
|
||||
import { Header } from "@/src/components/Header";
|
||||
import { InteractiveElements } from "@/src/components/InteractiveElements";
|
||||
@@ -70,6 +71,7 @@ export default function RootLayout({
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
<InteractiveElements />
|
||||
<BitBurst />
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Block } from "@/src/components/home/Block";
|
||||
import { ClosingCta } from "@/src/components/home/ClosingCta";
|
||||
import { Faq } from "@/src/components/home/Faq";
|
||||
@@ -218,7 +219,7 @@ export default function LandingPage() {
|
||||
key={item}
|
||||
className="rounded-full border border-slate-200 bg-white px-4 py-2 font-mono text-[11px] uppercase tracking-[0.12em] text-slate-700"
|
||||
>
|
||||
{item}
|
||||
<DecodeText text={item} trigger="both" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import LogoBlack from "../assets/logo/Logo-Black-Transparent.svg";
|
||||
import { BinaryTicker } from "./bits/BinaryTicker";
|
||||
|
||||
export const Footer: React.FC = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -56,11 +57,10 @@ export const Footer: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
{/* Subtle binary decoration */}
|
||||
<span
|
||||
className="text-[8px] text-slate-200 tracking-[0.5em] select-none font-mono"
|
||||
aria-hidden="true"
|
||||
>
|
||||
01001101 01001001 01001110
|
||||
<span className="flex gap-3 text-[8px] text-slate-300 tracking-[0.5em] select-none font-mono">
|
||||
{[77, 73, 78].map((code) => (
|
||||
<BinaryTicker key={code} value={code} alive />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
||||
import { DecodeText } from "./bits/DecodeText";
|
||||
import { ScrollBits } from "./bits/ScrollBits";
|
||||
|
||||
/** Header: the logo and one action. Everything else lives on the homepage. */
|
||||
export const Header: React.FC = () => {
|
||||
@@ -52,6 +54,8 @@ export const Header: React.FC = () => {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<ScrollBits className="hidden font-mono text-[10px] tracking-[0.3em] text-slate-400 md:block" />
|
||||
|
||||
<Link
|
||||
href="/#contact"
|
||||
className="font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
@@ -59,7 +63,7 @@ export const Header: React.FC = () => {
|
||||
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
||||
}}
|
||||
>
|
||||
Get in touch
|
||||
<DecodeText text="Get in touch" trigger="hover" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useInViewOnce } from "./useInViewOnce";
|
||||
|
||||
const toBits = (value: number, width: number): string =>
|
||||
value.toString(2).padStart(width, "0").slice(-width);
|
||||
|
||||
/**
|
||||
* BinaryTicker: a number shown in binary. The bits rattle when it scrolls into
|
||||
* view and then settle on the value; with `alive` a single bit keeps flipping.
|
||||
*/
|
||||
export const BinaryTicker: React.FC<{
|
||||
value: number;
|
||||
width?: number;
|
||||
className?: string;
|
||||
alive?: boolean;
|
||||
}> = ({ value, width = 8, className = "", alive = false }) => {
|
||||
const settled = toBits(value, width);
|
||||
const { ref, seen, still } = useInViewOnce<HTMLSpanElement>(0.6);
|
||||
const [shown, setShown] = React.useState(settled);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!seen || still) return;
|
||||
let tick = 0;
|
||||
const timer = window.setInterval(() => {
|
||||
tick += 1;
|
||||
if (tick > 14) {
|
||||
window.clearInterval(timer);
|
||||
setShown(settled);
|
||||
return;
|
||||
}
|
||||
setShown(
|
||||
Array.from(settled)
|
||||
.map((bit, i) =>
|
||||
i < tick - 6 ? bit : Math.random() < 0.5 ? "0" : "1",
|
||||
)
|
||||
.join(""),
|
||||
);
|
||||
}, 45);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [seen, still, settled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!alive || !seen || still) return;
|
||||
const timer = window.setInterval(() => {
|
||||
const index = Math.floor(Math.random() * width);
|
||||
setShown((current) =>
|
||||
Array.from(current)
|
||||
.map((bit, i) => (i === index ? (bit === "0" ? "1" : "0") : bit))
|
||||
.join(""),
|
||||
);
|
||||
window.setTimeout(() => setShown(settled), 260);
|
||||
}, 2400);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [alive, seen, still, width, settled]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className} aria-hidden="true">
|
||||
{shown}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
const COUNT = 10;
|
||||
const LIFE_MS = 620;
|
||||
|
||||
/**
|
||||
* BitBurst: a handful of binary digits fly out from the point where a link or
|
||||
* button is pressed. Purely decorative, and off when reduced motion is requested.
|
||||
*/
|
||||
export const BitBurst: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Element | null;
|
||||
if (!target?.closest("a, button")) return;
|
||||
|
||||
const layer = document.createElement("div");
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
layer.className = "bit-burst";
|
||||
layer.style.left = `${event.clientX}px`;
|
||||
layer.style.top = `${event.clientY}px`;
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const angle = (i / COUNT) * Math.PI * 2 + Math.random() * 0.5;
|
||||
const distance = 26 + Math.random() * 34;
|
||||
const digit = document.createElement("span");
|
||||
digit.textContent = Math.random() < 0.5 ? "0" : "1";
|
||||
digit.style.setProperty("--dx", `${Math.cos(angle) * distance}px`);
|
||||
digit.style.setProperty("--dy", `${Math.sin(angle) * distance}px`);
|
||||
digit.style.animationDelay = `${Math.random() * 60}ms`;
|
||||
layer.appendChild(digit);
|
||||
}
|
||||
document.body.appendChild(layer);
|
||||
window.setTimeout(() => layer.remove(), LIFE_MS + 120);
|
||||
};
|
||||
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useInViewOnce } from "./useInViewOnce";
|
||||
|
||||
const CELL = 16;
|
||||
const DURATION_MS = 1700;
|
||||
|
||||
/**
|
||||
* BitReveal: covers whatever lies beneath it with a field of binary digits that
|
||||
* dissolves cell by cell once it scrolls into view, so the picture underneath
|
||||
* appears to assemble itself out of bits. Without JavaScript nothing is covered.
|
||||
*/
|
||||
export const BitReveal: React.FC<{
|
||||
/** Colour of the covering cells; should match the surface behind the picture. */
|
||||
surface?: string;
|
||||
}> = ({ surface = "#0f172a" }) => {
|
||||
const { ref, seen, still } = useInViewOnce<HTMLCanvasElement>(0.25);
|
||||
const [done, setDone] = React.useState(false);
|
||||
|
||||
// Cover the picture as soon as the script runs, before it is scrolled into view.
|
||||
React.useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!canvas || !ctx) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setDone(true);
|
||||
return;
|
||||
}
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
ctx.fillStyle = surface;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}, [ref, surface]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
if (!seen || still || !canvas || !ctx) return;
|
||||
|
||||
const width = (canvas.width = canvas.offsetWidth);
|
||||
const height = (canvas.height = canvas.offsetHeight);
|
||||
const cols = Math.ceil(width / CELL);
|
||||
const rows = Math.ceil(height / CELL);
|
||||
// Each cell disappears at its own moment; cells further right go a little later.
|
||||
const when = new Float32Array(cols * rows);
|
||||
const bit = new Uint8Array(cols * rows);
|
||||
for (let i = 0; i < when.length; i++) {
|
||||
const col = i % cols;
|
||||
when[i] = Math.random() * 0.7 + (col / cols) * 0.3;
|
||||
bit[i] = Math.random() < 0.5 ? 0 : 1;
|
||||
}
|
||||
ctx.font = `11px ui-monospace, Menlo, monospace`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
let raf = 0;
|
||||
const start = performance.now();
|
||||
const frame = (now: number) => {
|
||||
const progress = Math.min(1, (now - start) / DURATION_MS);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
for (let i = 0; i < when.length; i++) {
|
||||
const left = (when[i] ?? 0) - progress;
|
||||
if (left <= 0) continue;
|
||||
const x = (i % cols) * CELL;
|
||||
const y = Math.floor(i / cols) * CELL;
|
||||
ctx.fillStyle = surface;
|
||||
ctx.fillRect(x, y, CELL, CELL);
|
||||
// Cells about to vanish light up first.
|
||||
ctx.fillStyle = left < 0.08 ? "#f8fafc" : "#475569";
|
||||
if (Math.random() < 0.02) bit[i] = bit[i] === 1 ? 0 : 1;
|
||||
ctx.fillText(String(bit[i] ?? 0), x + CELL / 2, y + CELL / 2);
|
||||
}
|
||||
if (progress < 1) raf = requestAnimationFrame(frame);
|
||||
else setDone(true);
|
||||
};
|
||||
raf = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [ref, seen, still, surface]);
|
||||
|
||||
if (done) return null;
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useInViewOnce } from "./useInViewOnce";
|
||||
|
||||
const DIGITS = "0110100110010110011010011001011001101001";
|
||||
const DURATION = 0.95;
|
||||
const EASE = [0.65, 0, 0.35, 1] as const;
|
||||
|
||||
/**
|
||||
* BitWipe: reveals its content from left to right behind a narrow column of
|
||||
* binary digits, as if the text were being written out bit by bit. The content
|
||||
* keeps its place the whole time, so nothing reflows.
|
||||
*
|
||||
* Visibility is observed on the outer wrapper: the inner element is clipped away
|
||||
* completely at first, and a fully clipped element never counts as "in view".
|
||||
*/
|
||||
export const BitWipe: React.FC<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
/** Colour of the digit column: dark on light surfaces, light on dark ones. */
|
||||
tone?: "dark" | "light";
|
||||
}> = ({ children, className = "", delay = 0, tone = "dark" }) => {
|
||||
const { ref, seen, still } = useInViewOnce<HTMLDivElement>(0.35);
|
||||
const open = seen || still;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`relative ${className}`}>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
clipPath: open
|
||||
? "inset(-12% -2% -12% -2%)"
|
||||
: "inset(-12% 100% -12% -2%)",
|
||||
}}
|
||||
transition={
|
||||
still ? { duration: 0 } : { duration: DURATION, delay, ease: EASE }
|
||||
}
|
||||
// Server render and no-JS: content is simply visible.
|
||||
style={{ clipPath: "none" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
{seen && !still && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-y-0 w-[1.1em] overflow-hidden break-all font-mono text-[11px] leading-[1.05] tracking-normal [writing-mode:vertical-rl] md:text-[13px] ${
|
||||
tone === "light" ? "text-white" : "text-slate-900"
|
||||
}`}
|
||||
initial={{ left: "0%", opacity: 0 }}
|
||||
animate={{ left: ["0%", "100%"], opacity: [0, 1, 1, 0] }}
|
||||
transition={{
|
||||
duration: DURATION,
|
||||
delay,
|
||||
ease: EASE,
|
||||
opacity: { duration: DURATION, delay, times: [0, 0.08, 0.86, 1] },
|
||||
}}
|
||||
>
|
||||
{DIGITS}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useInViewOnce } from "./useInViewOnce";
|
||||
|
||||
const FRAME_MS = 32;
|
||||
|
||||
const scramble = (text: string, revealed: number): string =>
|
||||
Array.from(text)
|
||||
.map((char, index) => {
|
||||
if (index < revealed || !/[A-Za-z0-9]/.test(char)) return char;
|
||||
return Math.random() < 0.5 ? "0" : "1";
|
||||
})
|
||||
.join("");
|
||||
|
||||
/**
|
||||
* DecodeText: text that resolves from binary digits, left to right. Meant for
|
||||
* monospaced labels, where digits and letters have the same width and nothing shifts.
|
||||
* Screen readers and no-JS visitors get the plain text.
|
||||
*/
|
||||
export const DecodeText: React.FC<{
|
||||
text: string;
|
||||
className?: string;
|
||||
/** Run when scrolled into view, on hover, or both. */
|
||||
trigger?: "view" | "hover" | "both";
|
||||
}> = ({ text, className = "", trigger = "view" }) => {
|
||||
const { ref, seen, still } = useInViewOnce<HTMLSpanElement>(0.6);
|
||||
const [shown, setShown] = React.useState(text);
|
||||
const timer = React.useRef(0);
|
||||
|
||||
const run = React.useCallback(() => {
|
||||
if (still) return;
|
||||
window.clearInterval(timer.current);
|
||||
const length = Array.from(text).length;
|
||||
const frames = Math.min(26, Math.max(12, length));
|
||||
let frame = 0;
|
||||
timer.current = window.setInterval(() => {
|
||||
frame += 1;
|
||||
if (frame >= frames) {
|
||||
window.clearInterval(timer.current);
|
||||
setShown(text);
|
||||
return;
|
||||
}
|
||||
setShown(scramble(text, Math.floor((frame / frames) * length)));
|
||||
}, FRAME_MS);
|
||||
}, [still, text]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (seen && trigger !== "hover") run();
|
||||
return () => window.clearInterval(timer.current);
|
||||
}, [seen, trigger, run]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onPointerEnter={trigger === "view" ? undefined : run}
|
||||
>
|
||||
<span className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">{shown}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/** ScrollBits: how far the page is scrolled, as an eight-bit binary number. */
|
||||
export const ScrollBits: React.FC<{ className?: string }> = ({
|
||||
className = "",
|
||||
}) => {
|
||||
const [percent, setPercent] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const update = () => {
|
||||
const max = document.documentElement.scrollHeight - window.innerHeight;
|
||||
setPercent(max > 0 ? Math.round((window.scrollY / max) * 100) : 0);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", update, { passive: true });
|
||||
return () => window.removeEventListener("scroll", update);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className={className} aria-hidden="true">
|
||||
{percent.toString(2).padStart(8, "0")}
|
||||
<span className="ml-3 text-slate-300">
|
||||
{String(percent).padStart(3, "0")}%
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/** True once the element has entered the viewport. Also reports reduced-motion preference. */
|
||||
export const useInViewOnce = <T extends Element>(
|
||||
threshold = 0.4,
|
||||
): { ref: React.RefObject<T | null>; seen: boolean; still: boolean } => {
|
||||
const ref = React.useRef<T | null>(null);
|
||||
const [seen, setSeen] = React.useState(false);
|
||||
const [still, setStill] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setStill(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setSeen(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [threshold]);
|
||||
|
||||
return { ref, seen, still };
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { BinaryTicker } from "@/src/components/bits/BinaryTicker";
|
||||
import { BitWipe } from "@/src/components/bits/BitWipe";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
|
||||
interface BlockProps {
|
||||
@@ -32,22 +35,22 @@ export const Block: React.FC<BlockProps> = ({
|
||||
{index}
|
||||
</span>
|
||||
<span className="h-px w-10 bg-slate-300" />
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
<DecodeText
|
||||
text={label}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500"
|
||||
/>
|
||||
<BinaryTicker
|
||||
value={parseInt(index, 10)}
|
||||
alive
|
||||
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{parseInt(index, 10).toString(2).padStart(8, "0")}
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.1}>
|
||||
<BitWipe delay={0.1}>
|
||||
<h2 className="m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter text-slate-900 md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
</Reveal>
|
||||
</BitWipe>
|
||||
<div className="mt-14 md:mt-24">{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2,34 +2,46 @@ import * as React from "react";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { EmailLink } from "@/src/components/EmailLink";
|
||||
import { BitHeadline, type BitLine } 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, no forms, no calendar. */
|
||||
/** ClosingCta: the contact section. One address and a message starter, no form to submit. */
|
||||
export const ClosingCta: React.FC = () => (
|
||||
<section
|
||||
id="contact"
|
||||
className="relative border-t border-slate-200 bg-slate-900 text-white"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1400px] px-5 py-24 md:px-12 md:py-44">
|
||||
<Reveal>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-70"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12">
|
||||
<Reveal className="lg:col-span-6">
|
||||
<BitHeadline
|
||||
as="h2"
|
||||
invert
|
||||
lines={CLOSING}
|
||||
className="font-sans text-[19vw] font-black leading-[0.86] tracking-tighter md:text-[13vw] xl:text-[12rem]"
|
||||
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-2xl font-sans text-xl leading-snug text-slate-300 md:text-3xl">
|
||||
Write me a few lines about what you need. We then arrange a short
|
||||
call.
|
||||
<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:
|
||||
</p>
|
||||
<div className="mt-12">
|
||||
<EmailLink className="inline-block border-b-2 border-white pb-2 font-sans text-3xl font-bold tracking-tight text-white no-underline transition-opacity hover:opacity-70 md:text-6xl" />
|
||||
<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" />
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.15} className="lg:col-span-6">
|
||||
<ContactComposer />
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CONTACT_TOPICS } from "@/src/content/contact";
|
||||
import { CONTACT_EMAIL_PARTS } from "@/src/content/site";
|
||||
import { assembleEmail, mailtoHref, spellOutEmail } from "@/src/domain/email";
|
||||
|
||||
const TYPE_MS = 9;
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
/** The text typed out so far. Jumps to the full text when motion is reduced. */
|
||||
const useTyped = (text: string): string => {
|
||||
const [length, setLength] = React.useState(text.length);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setLength(text.length);
|
||||
return;
|
||||
}
|
||||
setLength(0);
|
||||
const timer = window.setInterval(() => {
|
||||
setLength((current) => {
|
||||
if (current >= text.length) {
|
||||
window.clearInterval(timer);
|
||||
return current;
|
||||
}
|
||||
return Math.min(text.length, current + 3);
|
||||
});
|
||||
}, TYPE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [text]);
|
||||
|
||||
return text.slice(0, length);
|
||||
};
|
||||
|
||||
/** Blanks the visitor fills in are set apart from the prepared wording. */
|
||||
const withBlanks = (text: string): React.ReactNode =>
|
||||
text.split(/(\[[^\]]*\]?)/).map((part, index) =>
|
||||
part.startsWith("[") ? (
|
||||
<span key={index} className="rounded-sm bg-amber-200/15 text-amber-200">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* ContactComposer: pick what you need and the email writes itself. One press
|
||||
* opens it in the visitor's own mail app, ready to finish and send. The address
|
||||
* is assembled in the browser, so it never appears in the page source.
|
||||
*/
|
||||
export const ContactComposer: React.FC = () => {
|
||||
const [topicId, setTopicId] = React.useState(CONTACT_TOPICS[0]?.id ?? "");
|
||||
const [address, setAddress] = React.useState<string | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAddress(assembleEmail(CONTACT_EMAIL_PARTS));
|
||||
}, []);
|
||||
|
||||
const topic =
|
||||
CONTACT_TOPICS.find((item) => item.id === topicId) ?? CONTACT_TOPICS[0];
|
||||
const typed = useTyped(topic?.draft.body ?? "");
|
||||
|
||||
if (!topic) return null;
|
||||
|
||||
const done = typed.length === topic.draft.body.length;
|
||||
|
||||
const copy = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// Clipboard access can be refused; the address stays visible to copy by hand.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-3xl border border-slate-700 bg-slate-950/60 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex gap-1.5" aria-hidden="true">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
</span>
|
||||
<span className={`${MONO} text-slate-400`}>New message</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
|
||||
</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>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{CONTACT_TOPICS.map((item) => {
|
||||
const selected = item.id === topic.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => setTopicId(item.id)}
|
||||
className={`rounded-full border px-4 py-2 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
||||
selected
|
||||
? "border-white bg-white text-slate-900"
|
||||
: "border-slate-700 text-slate-300 hover:border-slate-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]">
|
||||
<div className="flex gap-4">
|
||||
<dt className="w-16 shrink-0 text-slate-500">to</dt>
|
||||
<dd className="m-0 text-slate-200">
|
||||
{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>
|
||||
<dd className="m-0 text-white">{topic.draft.subject}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
className="min-h-[15.5rem] whitespace-pre-wrap px-5 py-5 font-mono text-[12px] leading-relaxed text-slate-300 md:min-h-[14rem] md:px-7 md:text-[13px]"
|
||||
aria-live="off"
|
||||
>
|
||||
{withBlanks(typed)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`ml-0.5 inline-block h-[1.05em] w-[0.55em] translate-y-[0.18em] bg-amber-200 ${done ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-slate-800 p-5 sm:flex-row sm:items-center md:px-7">
|
||||
<a
|
||||
href={
|
||||
address ? mailtoHref(CONTACT_EMAIL_PARTS, topic.draft) : "#contact"
|
||||
}
|
||||
className="group inline-flex flex-1 items-center justify-between gap-4 rounded-full bg-white px-7 py-4 font-sans text-[12px] font-bold uppercase tracking-[0.18em] text-slate-900 no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Open in my mail app
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
import { PathScene } from "@/src/components/illustrations/PathScenes";
|
||||
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
|
||||
|
||||
@@ -50,9 +51,11 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
|
||||
<div className="md:order-2 md:col-span-7">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300">
|
||||
{current?.title}
|
||||
</span>
|
||||
<DecodeText
|
||||
key={current?.title}
|
||||
text={current?.title ?? ""}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300"
|
||||
/>
|
||||
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500">
|
||||
{step + 1} / {path.steps.length}
|
||||
</span>
|
||||
@@ -151,7 +154,7 @@ export const PathPicker: React.FC = () => {
|
||||
aria-selected={selected}
|
||||
aria-controls={`path-panel-${path.id}`}
|
||||
onClick={() => setActiveId(path.id)}
|
||||
className={`group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
|
||||
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
|
||||
selected
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { BitReveal } from "@/src/components/bits/BitReveal";
|
||||
import { BitWipe } from "@/src/components/bits/BitWipe";
|
||||
import { DecodeText } from "@/src/components/bits/DecodeText";
|
||||
|
||||
/**
|
||||
* PortraitSection: the introduction, with the portrait fading out of a dark surface
|
||||
@@ -22,8 +25,8 @@ export const PortraitSection: React.FC<{
|
||||
{/* Portrait: right side on wide screens, top on small ones. */}
|
||||
<motion.div
|
||||
className="relative h-[110vw] w-full md:absolute md:inset-y-0 md:right-0 md:h-auto md:w-[62%]"
|
||||
initial={{ opacity: 0, scale: 1.06 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
initial={{ scale: 1.06 }}
|
||||
whileInView={{ scale: 1 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{ duration: 1.4, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
@@ -39,6 +42,8 @@ export const PortraitSection: React.FC<{
|
||||
<div className="absolute inset-0 hidden bg-gradient-to-r from-slate-900 via-slate-900/75 to-slate-900/10 md:block" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
|
||||
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-slate-900 to-transparent" />
|
||||
{/* The portrait assembles itself out of binary digits. */}
|
||||
<BitReveal />
|
||||
</motion.div>
|
||||
|
||||
<div className="relative z-10 mx-auto w-full max-w-[1400px] px-5 pb-20 md:px-12 md:py-40">
|
||||
@@ -54,13 +59,16 @@ export const PortraitSection: React.FC<{
|
||||
{index}
|
||||
</span>
|
||||
<span className="h-px w-10 bg-slate-600" />
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400">
|
||||
{label}
|
||||
</span>
|
||||
<DecodeText
|
||||
text={label}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
<BitWipe tone="light" delay={0.35}>
|
||||
<h2 className="m-0 text-5xl font-bold leading-[0.95] tracking-tighter text-white md:text-7xl lg:text-[6.5rem]">
|
||||
{title}
|
||||
</h2>
|
||||
</BitWipe>
|
||||
<div className="mt-10 md:mt-14">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useMotionValue, useSpring } from "framer-motion";
|
||||
|
||||
export type WorkItem = {
|
||||
readonly name: string;
|
||||
@@ -88,10 +88,26 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
|
||||
const active = inView && !still;
|
||||
|
||||
// The frames lean a few degrees towards the pointer.
|
||||
const tiltX = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
|
||||
const tiltY = useSpring(useMotionValue(0), { stiffness: 120, damping: 18 });
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (still || event.pointerType !== "mouse") return;
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
tiltY.set(((event.clientX - box.left) / box.width - 0.5) * 7);
|
||||
tiltX.set(-((event.clientY - box.top) / box.height - 0.5) * 5);
|
||||
};
|
||||
const onPointerLeave = () => {
|
||||
tiltX.set(0);
|
||||
tiltY.set(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 md:px-12 md:pt-14"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerLeave={onPointerLeave}
|
||||
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-60"
|
||||
@@ -102,7 +118,14 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
<motion.div
|
||||
className="relative mx-auto max-w-4xl"
|
||||
style={{
|
||||
rotateX: tiltX,
|
||||
rotateY: tiltY,
|
||||
transformStyle: "preserve-3d",
|
||||
}}
|
||||
>
|
||||
{/* Desktop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
@@ -150,7 +173,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { MailDraft } from "@/src/domain/email";
|
||||
|
||||
/**
|
||||
* Message starters for the contact section. The visitor picks what they need and
|
||||
* gets a prepared email to finish in their own mail app. The square brackets are
|
||||
* blanks for the visitor to fill in.
|
||||
*/
|
||||
export type ContactTopic = {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly draft: MailDraft;
|
||||
};
|
||||
|
||||
const SIGN_OFF = "Best regards";
|
||||
|
||||
export const CONTACT_TOPICS: readonly ContactTopic[] = [
|
||||
{
|
||||
id: "developer",
|
||||
label: "A developer",
|
||||
draft: {
|
||||
subject: "A developer for our product",
|
||||
body: `Hi Marc,\n\nwe are working on [what you are building] and need help with [what is next on your list].\n\nA first call would suit us around [date or week].\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rescue",
|
||||
label: "A reliable app",
|
||||
draft: {
|
||||
subject: "Our app needs to become reliable",
|
||||
body: `Hi Marc,\n\nour app [name or short description] works, but [what keeps going wrong].\n\nCould you take a look?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "automation",
|
||||
label: "Automation",
|
||||
draft: {
|
||||
subject: "We want to automate a process",
|
||||
body: `Hi Marc,\n\nwe would like to automate [the process]. Today it is done by hand in [the tools involved].\n\nCan we talk about it?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "website",
|
||||
label: "A website",
|
||||
draft: {
|
||||
subject: "A website for my business",
|
||||
body: `Hi Marc,\n\nI need a website for [your business]. It should [what it is for].\n\nWhen could we talk?\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
label: "Something else",
|
||||
draft: {
|
||||
subject: "A question",
|
||||
body: `Hi Marc,\n\n[what you have in mind]\n\n${SIGN_OFF}`,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assembleEmail, parseEmailParts, spellOutEmail } from "./email";
|
||||
import {
|
||||
assembleEmail,
|
||||
mailtoHref,
|
||||
parseEmailParts,
|
||||
spellOutEmail,
|
||||
} from "./email";
|
||||
|
||||
const parts = { user: "hello", domain: "mintel", tld: "me" } as const;
|
||||
|
||||
@@ -120,3 +125,47 @@ describe("spellOutEmail", () => {
|
||||
).toBe("hello [at] mail [dot] site [dot] org");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mailtoHref", () => {
|
||||
it("opens a message to the address with subject and body", () => {
|
||||
expect(mailtoHref(parts, { subject: "Hello", body: "Hi" })).toBe(
|
||||
"mailto:hello@mintel.me?subject=Hello&body=Hi",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes spaces as %20, not as plus signs", () => {
|
||||
expect(mailtoHref(parts, { subject: "A website", body: "Hi Marc" })).toBe(
|
||||
"mailto:hello@mintel.me?subject=A%20website&body=Hi%20Marc",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes line breaks so the mail app keeps the paragraphs", () => {
|
||||
expect(mailtoHref(parts, { subject: "S", body: "one\n\ntwo" })).toBe(
|
||||
"mailto:hello@mintel.me?subject=S&body=one%0D%0A%0D%0Atwo",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes characters that would break the link", () => {
|
||||
expect(mailtoHref(parts, { subject: "Q&A: 50%?", body: "a=b #1" })).toBe(
|
||||
"mailto:hello@mintel.me?subject=Q%26A%3A%2050%25%3F&body=a%3Db%20%231",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps umlauts readable for the mail app", () => {
|
||||
expect(mailtoHref(parts, { subject: "Grüße", body: "ä" })).toBe(
|
||||
"mailto:hello@mintel.me?subject=Gr%C3%BC%C3%9Fe&body=%C3%A4",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out an empty subject or body instead of sending empty fields", () => {
|
||||
expect(mailtoHref(parts, { subject: "", body: "" })).toBe(
|
||||
"mailto:hello@mintel.me",
|
||||
);
|
||||
expect(mailtoHref(parts, { subject: "Only subject", body: "" })).toBe(
|
||||
"mailto:hello@mintel.me?subject=Only%20subject",
|
||||
);
|
||||
expect(mailtoHref(parts, { subject: "", body: "Only body" })).toBe(
|
||||
"mailto:hello@mintel.me?body=Only%20body",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,3 +43,22 @@ export function spellOutEmail(parts: EmailParts): string {
|
||||
const domain = parts.domain.split(".").join(" [dot] ");
|
||||
return `${parts.user} [at] ${domain} [dot] ${parts.tld}`;
|
||||
}
|
||||
|
||||
export type MailDraft = {
|
||||
readonly subject: string;
|
||||
readonly body: string;
|
||||
};
|
||||
|
||||
/** Mail apps expect CRLF line breaks in a mailto body. */
|
||||
const encodeField = (value: string): string =>
|
||||
encodeURIComponent(value.replace(/\r?\n/g, "\r\n"));
|
||||
|
||||
/** A mailto link that opens a prepared message. For use in the browser only. */
|
||||
export function mailtoHref(parts: EmailParts, draft: MailDraft): string {
|
||||
const query = [
|
||||
draft.subject === "" ? "" : `subject=${encodeField(draft.subject)}`,
|
||||
draft.body === "" ? "" : `body=${encodeField(draft.body)}`,
|
||||
].filter((field) => field !== "");
|
||||
const address = `mailto:${assembleEmail(parts)}`;
|
||||
return query.length === 0 ? address : `${address}?${query.join("&")}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user