diff --git a/apps/web/app/(site)/globals.css b/apps/web/app/(site)/globals.css
index 7bb419f..faa4d0d 100644
--- a/apps/web/app/(site)/globals.css
+++ b/apps/web/app/(site)/globals.css
@@ -321,26 +321,6 @@
/* Additional global styles from BaseLayout */
-/* Custom scrollbar */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-track {
- background: #f1f5f9;
-}
-
-::-webkit-scrollbar-thumb {
- background: #cbd5e1;
- border-radius: 4px;
- transition: background 0.2s ease;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background: #94a3b8;
-}
-
/* Selection color */
::selection {
background: #0f172a;
@@ -1079,8 +1059,72 @@ pre:has(code[class*="language-"]) {
transform: translate(0, 0);
}
+/* Light that follows the pointer on a `data-glow` surface (see BitGlow). */
+.bit-glow {
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 400ms ease;
+}
+@media (hover: hover) {
+ [data-glow]:hover > .bit-glow {
+ opacity: 1;
+ }
+}
+/* Dark surfaces: the dot grid lights up around the pointer. */
+.bit-glow-dots {
+ background-image:
+ radial-gradient(
+ 320px circle at var(--gx, 50%) var(--gy, 50%),
+ rgba(148, 163, 184, 0.22),
+ transparent 70%
+ ),
+ radial-gradient(#e2e8f0 1.25px, transparent 1.25px);
+ background-size:
+ auto,
+ 20px 20px;
+ -webkit-mask-image: radial-gradient(
+ 280px circle at var(--gx, 50%) var(--gy, 50%),
+ #000,
+ transparent 75%
+ );
+ mask-image: radial-gradient(
+ 280px circle at var(--gx, 50%) var(--gy, 50%),
+ #000,
+ transparent 75%
+ );
+}
+/* Cards: only the border lights up, in the card's text colour. */
+.bit-glow-edge {
+ inset: -1px;
+ padding: 2px;
+ background: radial-gradient(
+ 320px circle at var(--gx, 50%) var(--gy, 50%),
+ currentColor,
+ transparent 70%
+ );
+ -webkit-mask:
+ linear-gradient(#000 0 0) content-box,
+ linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask:
+ linear-gradient(#000 0 0) content-box exclude,
+ linear-gradient(#000 0 0);
+}
+/* Dark buttons: a spot of light under the pointer. */
+.bit-glow-spot {
+ background: radial-gradient(
+ 90px circle at var(--gx, 50%) var(--gy, 50%),
+ rgba(129, 140, 248, 0.5),
+ transparent 70%
+ );
+}
+
@media (prefers-reduced-motion: reduce) {
- .bit-burst {
+ .bit-burst,
+ .bit-glow {
display: none;
}
.bit-card::before,
@@ -1088,3 +1132,252 @@ pre:has(code[class*="language-"]) {
transition: none;
}
}
+
+/*
+ * Calls to action: a light travels round the edge of the pill and glows softly
+ * behind it; on hover the glow comes up. `bit-cta-light` is for white pills on
+ * dark surfaces, `bit-cta-quiet` only lights up on hover.
+ */
+@property --cta-angle {
+ syntax: "";
+ inherits: false;
+ initial-value: 0deg;
+}
+.bit-cta {
+ --cta-a: rgba(129, 140, 248, 0.95);
+ --cta-b: #ffffff;
+ position: relative;
+ isolation: isolate;
+}
+.bit-cta-light {
+ --cta-a: rgba(99, 102, 241, 0.95);
+ --cta-b: #312e81;
+}
+.bit-cta-quiet {
+ --cta-a: rgba(99, 102, 241, 0.9);
+ --cta-b: #0f172a;
+}
+.bit-cta::before,
+.bit-cta::after {
+ content: "";
+ position: absolute;
+ border-radius: inherit;
+ pointer-events: none;
+ background: conic-gradient(
+ from var(--cta-angle),
+ transparent 0 58%,
+ var(--cta-a) 76%,
+ var(--cta-b) 86%,
+ var(--cta-a) 93%,
+ transparent 100%
+ );
+ animation: bit-cta-turn 6s linear infinite;
+}
+/* The edge itself. */
+.bit-cta::before {
+ inset: -1px;
+ padding: 1.5px;
+ -webkit-mask:
+ linear-gradient(#000 0 0) content-box,
+ linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask:
+ linear-gradient(#000 0 0) content-box exclude,
+ linear-gradient(#000 0 0);
+}
+/* The glow behind the edge. */
+.bit-cta::after {
+ inset: -2px;
+ z-index: -1;
+ padding: 6px;
+ -webkit-mask:
+ linear-gradient(#000 0 0) content-box,
+ linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor;
+ mask:
+ linear-gradient(#000 0 0) content-box exclude,
+ linear-gradient(#000 0 0);
+ filter: blur(10px);
+ opacity: 0.55;
+ transition: opacity 300ms ease;
+}
+.bit-cta-quiet::before,
+.bit-cta-quiet::after {
+ opacity: 0;
+ transition: opacity 300ms ease;
+}
+@media (hover: hover) {
+ .bit-cta:hover::after,
+ .group:hover > .bit-cta::after {
+ opacity: 1;
+ }
+ .bit-cta-quiet:hover::before {
+ opacity: 1;
+ }
+ .bit-cta-quiet:hover::after {
+ opacity: 0.6;
+ }
+}
+@keyframes bit-cta-turn {
+ to {
+ --cta-angle: 360deg;
+ }
+}
+
+/* Slow light drifting over a dark picture (see PortraitSection). */
+.bit-aurora {
+ position: absolute;
+ inset: -10%;
+ pointer-events: none;
+ mix-blend-mode: screen;
+ background:
+ radial-gradient(
+ 38% 46% at 38% 42%,
+ rgba(99, 102, 241, 0.55),
+ transparent 70%
+ ),
+ radial-gradient(
+ 30% 40% at 64% 72%,
+ rgba(129, 140, 248, 0.35),
+ transparent 70%
+ );
+ filter: blur(40px);
+ animation: bit-aurora-drift 16s ease-in-out infinite alternate;
+}
+@keyframes bit-aurora-drift {
+ from {
+ transform: translate3d(-4%, -3%, 0) scale(1);
+ }
+ to {
+ transform: translate3d(5%, 4%, 0) scale(1.12);
+ }
+}
+
+/* The current station on the career line: a node that breathes light. */
+.bit-node-live {
+ animation: bit-node-breathe 3.2s ease-in-out infinite;
+}
+@keyframes bit-node-breathe {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 0 4px rgba(129, 140, 248, 0.22),
+ 0 0 18px 2px rgba(129, 140, 248, 0.55);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 8px rgba(129, 140, 248, 0.12),
+ 0 0 34px 8px rgba(129, 140, 248, 0.85);
+ }
+}
+
+/*
+ * Native feel: the page should behave like a well-made app on every input type.
+ */
+html {
+ color-scheme: light;
+ /* Anchor jumps stop below the fixed header. */
+ scroll-padding-top: 6rem;
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+ accent-color: #0f172a;
+ caret-color: #0f172a;
+}
+
+body {
+ text-rendering: optimizeLegibility;
+ font-kerning: normal;
+}
+
+/* Headlines break into even lines, running text avoids lonely last words. */
+h1,
+h2,
+h3,
+h4 {
+ text-wrap: balance;
+}
+p,
+li,
+dd,
+summary {
+ text-wrap: pretty;
+}
+
+/* Controls react at once to a tap and are not selected like text. */
+a,
+button,
+summary,
+[role="tab"],
+[role="button"] {
+ touch-action: manipulation;
+}
+button,
+summary,
+[role="tab"],
+[role="button"],
+a[href^="#"] {
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* Decoration is neither selectable nor draggable. */
+[aria-hidden="true"],
+canvas,
+img,
+svg {
+ -webkit-user-select: none;
+ user-select: none;
+}
+img,
+svg,
+a {
+ -webkit-user-drag: none;
+}
+
+/* One visible keyboard focus, none for mouse and touch. */
+:focus:not(:focus-visible) {
+ outline: none;
+}
+:focus-visible {
+ outline: 2px solid #0f172a;
+ outline-offset: 3px;
+}
+a:focus-visible,
+button:focus-visible,
+summary:focus-visible {
+ border-radius: 6px;
+}
+
+/* A short press-in on anything that can be pressed. */
+@media (prefers-reduced-motion: no-preference) {
+ button:active,
+ [role="tab"]:active,
+ a[class*="rounded-full"]:active,
+ a:active > [class*="rounded-full"] {
+ scale: 0.97;
+ transition-duration: 80ms;
+ }
+}
+
+/* Dark surfaces: light selection, light focus ring, dark system controls. */
+.bg-slate-900,
+.bg-slate-950 {
+ color-scheme: dark;
+}
+.bg-slate-900 ::selection,
+.bg-slate-950 ::selection {
+ background: #f8fafc;
+ color: #0f172a;
+}
+.bg-slate-900 :focus-visible,
+.bg-slate-950 :focus-visible {
+ outline-color: #f8fafc;
+}
+
+/* Fixed controls stay clear of the home indicator and rounded corners. */
+@supports (padding: env(safe-area-inset-bottom)) {
+ body {
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+ }
+}
diff --git a/apps/web/app/(site)/layout.tsx b/apps/web/app/(site)/layout.tsx
index 3ff5e85..ee78ffa 100644
--- a/apps/web/app/(site)/layout.tsx
+++ b/apps/web/app/(site)/layout.tsx
@@ -1,23 +1,32 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } 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 { BitGlow } from "@/src/components/bits/BitGlow";
import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements";
import {
+ INDEXING,
SITE_DESCRIPTION,
+ SITE_LANGUAGE,
SITE_NAME,
SITE_TITLE,
SITE_URL,
} from "@/src/content/site";
+import { robotsMeta } from "@/src/domain/indexing";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
+// A real monospaced fallback has almost the same letter width. The default
+// fallback (a resized Arial) wraps the mono labels differently, so the hero
+// jumped when the font arrived.
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
display: "swap",
+ adjustFontFallback: false,
+ fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
});
const newsreader = Newsreader({
subsets: ["latin"],
@@ -36,6 +45,7 @@ export const metadata: Metadata = {
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
+ robots: robotsMeta(INDEXING),
icons: {
icon: "/favicon.svg",
apple: "/apple-touch-icon.png",
@@ -56,6 +66,12 @@ export const metadata: Metadata = {
},
};
+export const viewport: Viewport = {
+ themeColor: "#ffffff",
+ colorScheme: "light",
+ viewportFit: "cover",
+};
+
export default function RootLayout({
children,
}: {
@@ -63,7 +79,7 @@ export default function RootLayout({
}) {
return (
@@ -72,6 +88,7 @@ export default function RootLayout({
+
diff --git a/apps/web/app/(site)/page.tsx b/apps/web/app/(site)/page.tsx
index dc61634..f7c6d88 100644
--- a/apps/web/app/(site)/page.tsx
+++ b/apps/web/app/(site)/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta";
@@ -109,9 +110,11 @@ export default function LandingPage() {
-
-
-
The path so far
+
@@ -257,9 +260,11 @@ export default function LandingPage() {
}
>
-
-
-
+
+
+
+
+
{PRINCIPLES.map((item, i) => (
@@ -278,17 +283,17 @@ export default function LandingPage() {
- Two sites I built. Both are live.
+ Two sites I built.{" "}
+ Both are live.
>
}
>
-
+
Besides client work I build my own software, among other things a
system in Rust and Solidity that runs on seven blockchains.
diff --git a/apps/web/src/components/Button.tsx b/apps/web/src/components/Button.tsx
index 0c7224f..5af035f 100644
--- a/apps/web/src/components/Button.tsx
+++ b/apps/web/src/components/Button.tsx
@@ -1,29 +1,42 @@
"use client";
import * as React from "react";
-import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
+type ButtonVariant = "primary" | "outline" | "ghost";
+type ButtonSize = "normal" | "large";
+
interface ButtonProps {
href: string;
children: React.ReactNode;
- variant?: "primary" | "outline" | "ghost";
- size?: "normal" | "large";
+ variant?: ButtonVariant;
+ size?: ButtonSize;
className?: string;
showArrow?: boolean;
- onClick?: (e: React.MouseEvent) => void;
- [key: string]: any;
+ onClick?: (event: React.MouseEvent) => void;
}
+const BASE =
+ "group relative inline-flex cursor-pointer items-center justify-center gap-3 rounded-full font-sans font-bold uppercase not-italic no-underline transition-all duration-300";
+
+const SIZES: Record = {
+ normal: "py-2.5 pl-7 pr-2.5 text-[11px] tracking-[0.18em]",
+ large: "py-3 pl-8 pr-3 text-[12px] tracking-[0.18em]",
+};
+
+const VARIANTS: Record = {
+ primary:
+ "bit-cta bg-slate-900 text-white shadow-lg shadow-slate-900/10 hover:-translate-y-0.5",
+ outline:
+ "bit-cta bit-cta-quiet border border-slate-300 bg-white text-slate-900 hover:-translate-y-0.5",
+ ghost: "bg-transparent text-slate-500 hover:text-slate-900",
+};
+
/**
- * Premium Button: Pill-shaped, binary-accent hover effect, unified design.
- *
- * On hover:
- * - A stream of binary characters scrolls across the button background
- * - Primary: white binary on dark bg
- * - Outline: blue binary on transparent bg
- * - Subtle, fast, and satisfying
+ * Button: the pill-shaped call to action. A light travels round its edge
+ * (`bit-cta` in globals.css) and the dark variant carries a spot of light that
+ * follows the pointer (see BitGlow); the label itself stays still.
*/
export const Button: React.FC = ({
href,
@@ -33,135 +46,50 @@ export const Button: React.FC = ({
className = "",
showArrow = true,
onClick,
- ...props
}) => {
- const [hovered, setHovered] = React.useState(false);
- const [displayText, setDisplayText] = React.useState(null);
- const contentRef = React.useRef(null);
-
- // Binary scramble on hover
- React.useEffect(() => {
- if (!hovered) {
- setDisplayText(null);
- return;
- }
- const original = contentRef.current?.textContent || "";
- if (!original) return;
- const chars = original.split("");
- const total = 20;
- let frame = 0;
- const iv = setInterval(() => {
- frame++;
- const s = chars.map((c, i) => {
- if (c === " ") return " ";
- const settle = (frame / total) * chars.length;
- if (i < settle) return c;
- return Math.random() > 0.5 ? "1" : "0";
- });
- setDisplayText(s.join(""));
- if (frame >= total) {
- setDisplayText(null);
- clearInterval(iv);
- }
- }, 1000 / 60);
- return () => clearInterval(iv);
- }, [hovered]);
-
- const [binaryStr, setBinaryStr] = React.useState(
- "0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1",
- );
-
- React.useEffect(() => {
- setBinaryStr(
- Array.from({ length: 60 }, () => (Math.random() > 0.5 ? "0" : "1")).join(
- " ",
- ),
- );
- }, []);
-
- const base =
- "relative inline-flex items-center justify-center gap-3 overflow-hidden rounded-full font-bold uppercase tracking-[0.15em] transition-all duration-300 group cursor-pointer";
-
- const sizes: Record = {
- normal: "px-8 py-4 text-[10px]",
- large: "px-10 py-5 text-[11px]",
- };
-
- const variants: Record = {
- primary:
- "bg-slate-900 text-white hover:shadow-xl hover:shadow-slate-900/20 hover:-translate-y-0.5",
- outline:
- "border border-slate-200 bg-transparent text-slate-900 hover:border-slate-400 hover:-translate-y-0.5",
- ghost: "bg-transparent text-slate-500 hover:text-slate-900",
- };
-
- // Binary stream overlay colors by variant
- const binaryColor =
- variant === "primary"
- ? "rgba(255,255,255,0.12)"
- : variant === "outline"
- ? "rgba(59,130,246,0.15)"
- : "rgba(148,163,184,0.15)";
-
const inner = (
- setHovered(true)}
- onMouseLeave={() => setHovered(false)}
- className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
+
- {/* Binary stream hover overlay */}
-
-
- {binaryStr} {binaryStr}
-
-
-
- {/* Shimmer line on hover (top edge) */}
-
-
- {/* Content */}
-
- {displayText ?? children}
+ {variant === "primary" && (
+
+ )}
+
+ {children}
{showArrow && (
-
+
+
+
)}
-
+
);
if (href.startsWith("#")) {
return (
{
- if (onClick) onClick(e);
- e.preventDefault();
- document.querySelector(href)?.scrollIntoView({ behavior: "smooth" });
+ className="no-underline"
+ onClick={(event) => {
+ onClick?.(event);
+ event.preventDefault();
+ if (href.length > 1) {
+ document
+ .querySelector(href)
+ ?.scrollIntoView({ behavior: "smooth" });
+ }
}}
- {...props}
>
{inner}
@@ -169,7 +97,7 @@ export const Button: React.FC = ({
}
return (
-
+
{inner}
);
diff --git a/apps/web/src/components/Header.tsx b/apps/web/src/components/Header.tsx
index c27e21f..ccc6ee5 100644
--- a/apps/web/src/components/Header.tsx
+++ b/apps/web/src/components/Header.tsx
@@ -58,7 +58,7 @@ export const Header: React.FC = () => {
= {
+ sparks: 0.45,
+ rain: 0.3,
+ pulse: 0.2,
+};
+
+const PULSE_RINGS = 3;
+const PULSE_PERIOD_S = 4.2;
+/** How often a drifting digit switches between 0 and 1. */
+const FLIPS_PER_S = 0.9;
+const PULSE_REACH = 56;
+
+const fraction = (value: number) => value - Math.floor(value);
+
+const perimeter = ({ width, height }: Box) => 2 * (width + height);
+
+/** Point and outward normal on the outline, walking clockwise from the top left corner. */
+const outlinePoint = (at: number, { width, height }: Box) => {
+ let distance = fraction(at) * 2 * (width + height);
+ if (distance < width) return { x: distance, y: 0, nx: 0, ny: -1 };
+ distance -= width;
+ if (distance < height) return { x: width, y: distance, nx: 1, ny: 0 };
+ distance -= height;
+ if (distance < width) return { x: width - distance, y: height, nx: 0, ny: 1 };
+ distance -= width;
+ return { x: 0, y: height - distance, nx: -1, ny: 0 };
+};
+
+/** The stretch of the perimeter (0..1) that one edge covers. */
+const edgeRange = (
+ source: Exclude,
+ box: Box,
+): readonly [number, number] => {
+ const total = perimeter(box);
+ const top = box.width / total;
+ const right = (box.width + box.height) / total;
+ const bottom = (2 * box.width + box.height) / total;
+ if (source === "top") return [0, top];
+ if (source === "right") return [top, right];
+ if (source === "bottom") return [right, bottom];
+ return [bottom, 1];
+};
+
+/** Where the energy is at time `t` (seconds): it wanders along one edge or circles the element. */
+const spotsAt = (
+ source: AuraSource,
+ motion: AuraMotion,
+ t: number,
+ box: Box,
+): readonly Spot[] => {
+ if (motion === "pulse") {
+ return [0, 1, 2, 3].map((corner) => ({
+ at: (corner + 0.5) / 4,
+ power: 0.45 + 0.25 * Math.sin(t * 0.8 - corner * 1.57),
+ }));
+ }
+ if (source === "around") {
+ return [
+ { at: fraction(t * 0.03), power: 1 },
+ { at: fraction(t * 0.03 + 0.5), power: 0.7 },
+ { at: fraction(1.2 - t * 0.018), power: 0.5 },
+ ];
+ }
+ const [from, to] = edgeRange(source, box);
+ const along = (share: number) => from + (to - from) * share;
+ return [
+ {
+ at: along(0.5 + 0.32 * Math.sin(t * 0.5)),
+ power: 0.85 + 0.15 * Math.sin(t * 0.9),
+ },
+ { at: along(0.5 + 0.4 * Math.sin(t * 0.31 + 2)), power: 0.6 },
+ {
+ at: along(0.5 + 0.45 * Math.sin(t * 0.83 + 4)),
+ power: 0.35 + 0.2 * Math.sin(t * 1.3),
+ },
+ ];
+};
+
+const spawnDigit = (
+ spots: readonly Spot[],
+ motion: AuraMotion,
+ box: Box,
+): Digit => {
+ const total = spots.reduce((sum, spot) => sum + spot.power, 0);
+ let pick = Math.random() * total;
+ const spot =
+ spots.find((candidate) => (pick -= candidate.power) <= 0) ?? spots[0];
+ const power = spot?.power ?? 1;
+ const base = {
+ age: 0,
+ seed: Math.random(),
+ power,
+ large: Math.random() < 0.22,
+ };
+
+ const wide = motion === "rain" ? 0.12 : 0.07;
+ const scatter = (Math.random() + Math.random() + Math.random() - 1.5) * wide;
+ const point = outlinePoint((spot?.at ?? 0) + scatter, box);
+
+ if (motion === "rain") {
+ const distance = 40 + Math.random() * 80;
+ const speed = 22 + Math.random() * 26;
+ return {
+ ...base,
+ x: point.x + point.nx * distance,
+ y: point.y + point.ny * distance,
+ vx: -point.nx * speed,
+ vy: -point.ny * speed,
+ nx: point.nx,
+ ny: point.ny,
+ life: distance / speed,
+ };
+ }
+
+ const speed = 10 + Math.random() * 24;
+ const slide = (Math.random() - 0.5) * 12;
+ return {
+ ...base,
+ x: point.x,
+ y: point.y,
+ vx: point.nx * speed - point.ny * slide,
+ vy: point.ny * speed + point.nx * slide,
+ nx: point.nx,
+ ny: point.ny,
+ life: 2.4 + Math.random() * 2.6,
+ };
+};
+
+/**
+ * BitAura: energy that hugs the outline of the element it wraps. The glow flows
+ * along the edge and binary digits drift off it like sparks. Decorative only:
+ * it runs while in view and stands still when reduced motion is requested.
+ */
+export const BitAura: React.FC = ({
+ source,
+ tone,
+ motion = "sparks",
+ density = 1,
+ radius = DEFAULT_RADIUS,
+ className = "",
+ children,
+}) => {
+ const wrapperRef = React.useRef(null);
+ const canvasRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const wrapper = wrapperRef.current;
+ const canvas = canvasRef.current;
+ const context = canvas?.getContext("2d");
+ if (!wrapper || !canvas || !context) return;
+
+ const palette = tone === "light" ? LIGHT_PALETTE : DARK_PALETTE;
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const fontFamily = window.getComputedStyle(canvas).fontFamily;
+ let box: Box = { width: 0, height: 0 };
+ let digits: Digit[] = [];
+ let owed = 0;
+ let clock = Math.random() * 40;
+ let last = 0;
+ let frame = 0;
+ let visible = false;
+
+ const step = (seconds: number) => {
+ clock += seconds;
+ const spots = spotsAt(source, motion, clock, box);
+ const rate =
+ Math.min(90, Math.max(28, perimeter(box) / 30)) *
+ palette.density *
+ MOTION_DENSITY[motion] *
+ density;
+ owed += rate * seconds;
+ while (owed >= 1) {
+ digits.push(spawnDigit(spots, motion, box));
+ owed -= 1;
+ }
+ const drag = 1 - 0.6 * seconds;
+ digits = digits.filter((digit) => {
+ digit.age += seconds;
+ if (motion !== "rain") {
+ digit.vx *= drag;
+ digit.vy = digit.vy * drag - 7 * seconds;
+ }
+ digit.x += digit.vx * seconds;
+ digit.y += digit.vy * seconds;
+ return digit.age < digit.life;
+ });
+ return spots;
+ };
+
+ const draw = (spots: readonly Spot[]) => {
+ const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
+ context.setTransform(ratio, 0, 0, ratio, MARGIN * ratio, MARGIN * ratio);
+ context.clearRect(
+ -MARGIN,
+ -MARGIN,
+ box.width + 2 * MARGIN,
+ box.height + 2 * MARGIN,
+ );
+ context.globalCompositeOperation = palette.blend;
+ context.globalAlpha = 1;
+
+ context.beginPath();
+ context.roundRect(0, 0, box.width, box.height, radius);
+ const reach = Math.min(560, Math.max(220, perimeter(box) * 0.2));
+ for (const spot of palette.gain > 0 ? spots : []) {
+ const point = outlinePoint(spot.at, box);
+ for (const layer of GLOW_LAYERS) {
+ const colour = layer.hot ? palette.core : palette.glow;
+ const light = context.createRadialGradient(
+ point.x,
+ point.y,
+ 0,
+ point.x,
+ point.y,
+ reach,
+ );
+ light.addColorStop(
+ 0,
+ `rgba(${colour}, ${layer.alpha * spot.power * palette.gain})`,
+ );
+ light.addColorStop(1, `rgba(${colour}, 0)`);
+ context.strokeStyle = light;
+ context.lineWidth = layer.width;
+ context.stroke();
+ }
+ }
+
+ if (motion === "pulse") {
+ context.setLineDash([3, 7]);
+ context.lineWidth = 1.5;
+ for (let ring = 0; ring < PULSE_RINGS; ring++) {
+ const phase = fraction(clock / PULSE_PERIOD_S + ring / PULSE_RINGS);
+ const grow = phase * PULSE_REACH;
+ context.strokeStyle = `rgba(${palette.core}, ${(1 - phase) ** 2 * palette.ring})`;
+ context.beginPath();
+ context.roundRect(
+ -grow,
+ -grow,
+ box.width + 2 * grow,
+ box.height + 2 * grow,
+ radius + grow,
+ );
+ context.stroke();
+ }
+ context.setLineDash([]);
+ }
+
+ context.fillStyle = `rgb(${palette.digit})`;
+ context.textAlign = "center";
+ context.textBaseline = "middle";
+ for (const large of [false, true]) {
+ context.font = `${large ? 700 : 500} ${large ? 14 : 11}px ${fontFamily}`;
+ for (const digit of digits) {
+ if (digit.large !== large) continue;
+ const fade = Math.sin((Math.PI * digit.age) / digit.life);
+ context.globalAlpha = Math.min(1, fade * digit.power * 1.1);
+ const one =
+ Math.floor(digit.age * FLIPS_PER_S + digit.seed * 10) % 2 === 0;
+ context.fillText(one ? "1" : "0", digit.x, digit.y);
+ }
+ }
+ };
+
+ const resize = () => {
+ const rect = wrapper.getBoundingClientRect();
+ box = { width: rect.width, height: rect.height };
+ const ratio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);
+ canvas.width = Math.round((box.width + 2 * MARGIN) * ratio);
+ canvas.height = Math.round((box.height + 2 * MARGIN) * ratio);
+ if (still) {
+ digits = [];
+ let spots = spotsAt(source, motion, clock, box);
+ for (let i = 0; i < STILL_FRAME_STEPS; i++)
+ spots = step(FRAME_MS / 1000);
+ draw(spots);
+ }
+ };
+
+ const tick = (now: number) => {
+ frame = window.requestAnimationFrame(tick);
+ if (now - last < FRAME_MS) return;
+ const seconds = Math.min(MAX_STEP_S, (now - last) / 1000);
+ last = now;
+ draw(step(seconds));
+ };
+
+ const sizeObserver = new ResizeObserver(resize);
+ sizeObserver.observe(wrapper);
+ resize();
+
+ const viewObserver = new IntersectionObserver(
+ ([entry]) => {
+ const next = entry?.isIntersecting ?? false;
+ if (next === visible || still) return;
+ visible = next;
+ if (visible) {
+ last = performance.now();
+ frame = window.requestAnimationFrame(tick);
+ } else {
+ window.cancelAnimationFrame(frame);
+ }
+ },
+ { rootMargin: `${MARGIN}px` },
+ );
+ viewObserver.observe(wrapper);
+
+ return () => {
+ sizeObserver.disconnect();
+ viewObserver.disconnect();
+ window.cancelAnimationFrame(frame);
+ };
+ }, [source, tone, motion, density, radius]);
+
+ return (
+
+ );
+};
diff --git a/apps/web/src/components/bits/BitGlow.tsx b/apps/web/src/components/bits/BitGlow.tsx
new file mode 100644
index 0000000..25046e0
--- /dev/null
+++ b/apps/web/src/components/bits/BitGlow.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import * as React from "react";
+
+const SURFACE = "[data-glow]";
+
+/**
+ * BitGlow: a light that follows the pointer across every `data-glow` surface.
+ * It only publishes the pointer position as `--gx` / `--gy` on those surfaces;
+ * the light itself is drawn in CSS by a `.bit-glow` child (see globals.css).
+ * Purely decorative, and off when reduced motion is requested.
+ */
+export const BitGlow: React.FC = () => {
+ React.useEffect(() => {
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+
+ let frame = 0;
+ let latest: PointerEvent | null = null;
+
+ const publish = () => {
+ frame = 0;
+ if (!latest) return;
+ const { clientX, clientY, target } = latest;
+ let surface =
+ target instanceof Element ? target.closest(SURFACE) : null;
+ // Surfaces nest (a button inside a dark section): each one gets its own position.
+ while (surface) {
+ const rect = surface.getBoundingClientRect();
+ surface.style.setProperty("--gx", `${clientX - rect.left}px`);
+ surface.style.setProperty("--gy", `${clientY - rect.top}px`);
+ surface =
+ surface.parentElement?.closest(SURFACE) ?? null;
+ }
+ };
+
+ const onPointerMove = (event: PointerEvent) => {
+ latest = event;
+ if (frame === 0) frame = window.requestAnimationFrame(publish);
+ };
+
+ window.addEventListener("pointermove", onPointerMove, { passive: true });
+ return () => {
+ window.removeEventListener("pointermove", onPointerMove);
+ if (frame !== 0) window.cancelAnimationFrame(frame);
+ };
+ }, []);
+
+ return null;
+};
diff --git a/apps/web/src/components/bits/BitSeam.tsx b/apps/web/src/components/bits/BitSeam.tsx
new file mode 100644
index 0000000..db542cf
--- /dev/null
+++ b/apps/web/src/components/bits/BitSeam.tsx
@@ -0,0 +1,17 @@
+import * as React from "react";
+import { BitAura } from "./BitAura";
+
+/**
+ * BitSeam: a lit top edge for a dark section, with digits drifting down from it.
+ * The section needs `relative` and has to clip its overflow.
+ */
+export const BitSeam: React.FC<{ density?: number }> = ({ density = 0.4 }) => (
+
+);
diff --git a/apps/web/src/components/home/BitHeadline.tsx b/apps/web/src/components/home/BitHeadline.tsx
index a3593d0..b8ba8f2 100644
--- a/apps/web/src/components/home/BitHeadline.tsx
+++ b/apps/web/src/components/home/BitHeadline.tsx
@@ -91,6 +91,8 @@ export const BitHeadline: React.FC<{
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
+ // Touch screens have no pointer to light the digits up, so a spot wanders on its own.
+ const noHover = window.matchMedia("(hover: none)").matches;
let measured: Measured[] = [];
let width = 0;
@@ -306,6 +308,13 @@ export const BitHeadline: React.FC<{
if (disposed) return;
if (now - lastFrame >= FRAME_MS) {
lastFrame = now;
+ if (noHover) {
+ const seconds = now / 1000;
+ pointer = {
+ x: width * (0.5 + 0.46 * Math.sin(seconds * 0.55)),
+ y: height * (0.5 + 0.4 * Math.sin(seconds * 0.9 + 1)),
+ };
+ }
paint(now);
}
raf = requestAnimationFrame(loop);
@@ -339,7 +348,7 @@ export const BitHeadline: React.FC<{
restart();
setPainted(true);
resizeObserver.observe(wrap);
- if (!reducedMotion) {
+ if (!reducedMotion && !noHover) {
window.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerleave", onPointerLeave);
}
diff --git a/apps/web/src/components/home/Block.tsx b/apps/web/src/components/home/Block.tsx
index 7d056bd..697f452 100644
--- a/apps/web/src/components/home/Block.tsx
+++ b/apps/web/src/components/home/Block.tsx
@@ -1,5 +1,6 @@
import * as React from "react";
import { BinaryTicker } from "@/src/components/bits/BinaryTicker";
+import { BitSeam } from "@/src/components/bits/BitSeam";
import { BitWipe } from "@/src/components/bits/BitWipe";
import { DecodeText } from "@/src/components/bits/DecodeText";
import { Reveal } from "@/src/components/Reveal";
@@ -10,6 +11,8 @@ interface BlockProps {
label: string;
title: React.ReactNode;
id?: string;
+ /** `dark` turns the section into a dark stage. */
+ tone?: "light" | "dark";
children: React.ReactNode;
}
@@ -22,36 +25,61 @@ export const Block: React.FC = ({
label,
title,
id,
+ tone = "light",
children,
-}) => (
-
-
-
-
-
- {index}
-
-
-
-
-
-
-
-
- {title}
-
-
-
{children}
-
-
-);
+}) => {
+ const dark = tone === "dark";
+ return (
+
+ {dark && (
+
+ )}
+ {dark && }
+
+
+
+
+ {index}
+
+
+
+
+
+
+
+
+ {title}
+
+
+
{children}
+
+
+ );
+};
diff --git a/apps/web/src/components/home/ClosingCta.tsx b/apps/web/src/components/home/ClosingCta.tsx
index 06dc831..6fab5f2 100644
--- a/apps/web/src/components/home/ClosingCta.tsx
+++ b/apps/web/src/components/home/ClosingCta.tsx
@@ -1,6 +1,8 @@
import * as React from "react";
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 { ContactComposer } from "./ContactComposer";
@@ -13,7 +15,8 @@ const CLOSING: readonly BitLine[] = [
export const ClosingCta: React.FC = () => (
diff --git a/apps/web/src/components/home/ContactComposer.tsx b/apps/web/src/components/home/ContactComposer.tsx
index d289310..d1212f7 100644
--- a/apps/web/src/components/home/ContactComposer.tsx
+++ b/apps/web/src/components/home/ContactComposer.tsx
@@ -122,7 +122,7 @@ export const ContactComposer: React.FC = () => {
to
-
+
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
@@ -148,7 +148,7 @@ export const ContactComposer: React.FC = () => {
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"
+ 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
= ({
return (
-
-
-
-
- {step + 1} / {path.steps.length}
-
+
+
+
+
+
+
+ {step + 1} / {path.steps.length}
+
+
+
-
-
+
@@ -154,12 +163,14 @@ export const PathPicker: React.FC = () => {
aria-selected={selected}
aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(path.id)}
+ data-glow
className={`bit-card group flex h-full flex-col justify-between rounded-3xl border p-5 text-left transition-all duration-300 md:p-7 ${
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"
}`}
>
+
0{index + 1}
diff --git a/apps/web/src/components/home/PortraitSection.tsx b/apps/web/src/components/home/PortraitSection.tsx
index 06dc83e..00fd541 100644
--- a/apps/web/src/components/home/PortraitSection.tsx
+++ b/apps/web/src/components/home/PortraitSection.tsx
@@ -1,5 +1,6 @@
"use client";
+import { BitSeam } from "@/src/components/bits/BitSeam";
import * as React from "react";
import Image from "next/image";
import { motion } from "framer-motion";
@@ -32,12 +33,14 @@ export const PortraitSection: React.FC<{
>
+ {/* Slow light behind the fades, so the dark surface is not flat. */}
+
{/* Fades: into the surface on the left, and at top and bottom. */}
@@ -72,5 +75,6 @@ export const PortraitSection: React.FC<{
{children}
+
);
diff --git a/apps/web/src/components/home/StickyBook.tsx b/apps/web/src/components/home/StickyBook.tsx
index 6acc11a..e5d1d73 100644
--- a/apps/web/src/components/home/StickyBook.tsx
+++ b/apps/web/src/components/home/StickyBook.tsx
@@ -37,7 +37,7 @@ export const StickyBook: React.FC = () => {
Get in touch →
diff --git a/apps/web/src/components/illustrations/AutomationFlow.tsx b/apps/web/src/components/illustrations/AutomationFlow.tsx
index 0206776..3c02dc7 100644
--- a/apps/web/src/components/illustrations/AutomationFlow.tsx
+++ b/apps/web/src/components/illustrations/AutomationFlow.tsx
@@ -1,5 +1,6 @@
"use client";
+import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react";
import { useNarrow } from "./useNarrow";
import {
@@ -242,193 +243,197 @@ export const AutomationFlow: React.FC = () => {
{flow.summary}
-
-
- {flow.name}
-
-
- {finished ? "run complete" : "running"}
-
-
-
-
-
-
-
-
-
-
-
-
- {flow.edges.map(([from, to]) => {
- const a = nodeOf(from);
- const b = nodeOf(to);
- if (!a || !b) return null;
- const done = still || travelled.has(`${from}>${to}`);
- return (
- ${to}`}
- d={pathOf(a, b, tall)}
- fill="none"
- stroke={done ? "#e2e8f0" : "#334155"}
- strokeWidth={done ? 2 : 1.5}
- style={{ transition: "stroke 300ms" }}
- />
- );
- })}
-
- {activeEdges.map(([from, to]) => {
- const a = nodeOf(from);
- const b = nodeOf(to);
- if (!a || !b || clock.t >= 1) return null;
- const eased = ease(clock.t);
- return (
- ${to}`}>
- {[0.16, 0.1, 0.05].map((lag, i) => {
- const p = pointOn(a, b, Math.max(0, eased - lag), tall);
- return (
-
- );
- })}
-
-
- );
- })}
-
- {placed.map((node) => {
- const style = KIND_STYLE[node.kind];
- const on = reached.has(node.id);
- return (
-
-
-
- {node.system}
-
-
- {node.label}
-
- {on && (
-
- )}
-
- );
- })}
-
-
-
-
-
-
Log
-
- {logLines.map((line, index) => (
-
-
- {String(index + 1).padStart(2, "0")}
-
-
- {line}
-
-
- ))}
-
+
+
+
+ {flow.name}
+
+
+ {finished ? "run complete" : "running"}
+
-
- {(Object.keys(KIND_LABEL) as NodeKind[])
- .filter((kind) => flow.nodes.some((node) => node.kind === kind))
- .map((kind) => (
-
+
+
+
-
-
+
+
+
+
+ {flow.edges.map(([from, to]) => {
+ const a = nodeOf(from);
+ const b = nodeOf(to);
+ if (!a || !b) return null;
+ const done = still || travelled.has(`${from}>${to}`);
+ return (
+ ${to}`}
+ d={pathOf(a, b, tall)}
+ fill="none"
+ stroke={done ? "#e2e8f0" : "#334155"}
+ strokeWidth={done ? 2 : 1.5}
+ style={{ transition: "stroke 300ms" }}
+ />
+ );
+ })}
+
+ {activeEdges.map(([from, to]) => {
+ const a = nodeOf(from);
+ const b = nodeOf(to);
+ if (!a || !b || clock.t >= 1) return null;
+ const eased = ease(clock.t);
+ return (
+ ${to}`}>
+ {[0.16, 0.1, 0.05].map((lag, i) => {
+ const p = pointOn(a, b, Math.max(0, eased - lag), tall);
+ return (
+
+ );
+ })}
+
-
- {KIND_LABEL[kind]}
-
- ))}
-
+
+ );
+ })}
+
+ {placed.map((node) => {
+ const style = KIND_STYLE[node.kind];
+ const on = reached.has(node.id);
+ return (
+
+
+
+ {node.system}
+
+
+ {node.label}
+
+ {on && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
Log
+
+ {logLines.map((line, index) => (
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
+ {line}
+
+
+ ))}
+
+
+
+ {(Object.keys(KIND_LABEL) as NodeKind[])
+ .filter((kind) => flow.nodes.some((node) => node.kind === kind))
+ .map((kind) => (
+
+
+
+
+ {KIND_LABEL[kind]}
+
+ ))}
+
+
-
+
diff --git a/apps/web/src/components/illustrations/CareerLine.tsx b/apps/web/src/components/illustrations/CareerLine.tsx
index 5b6ef1c..dc64e42 100644
--- a/apps/web/src/components/illustrations/CareerLine.tsx
+++ b/apps/web/src/components/illustrations/CareerLine.tsx
@@ -2,6 +2,7 @@
import * as React from "react";
import { motion } from "framer-motion";
+import { BitAura } from "@/src/components/bits/BitAura";
export type CareerStation = {
readonly years: string;
@@ -10,21 +11,29 @@ export type CareerStation = {
};
/**
- * CareerLine: the path so far as a line that draws itself, one station after another.
+ * CareerLine: the path so far as a line of light that draws itself, one station
+ * after another. Made for a dark surface.
*/
export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
stations,
}) => (
-
+
+
+
{stations.map((station, index) => (
= ({
transition={{ duration: 0.4, delay: 0.15 + index * 0.18 }}
>
-
+
{station.years}
-
+
{station.role}
-
+
{station.place}
diff --git a/apps/web/src/components/illustrations/PathScenes.tsx b/apps/web/src/components/illustrations/PathScenes.tsx
index 416b061..6aeb665 100644
--- a/apps/web/src/components/illustrations/PathScenes.tsx
+++ b/apps/web/src/components/illustrations/PathScenes.tsx
@@ -15,6 +15,10 @@ const LINE = "#475569";
const SOFT = "#1e293b";
const MARK = "#fde68a";
const LIVE = "#10b981";
+/** A lit surface: a light tint instead of a solid white block. */
+const TINT = "rgba(248, 250, 252, 0.14)";
+const RIM = "#64748b";
+const FINE = 1.1;
const MONO = {
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
// Set on the svg: larger on phones, where the drawing is scaled down.
@@ -70,8 +74,11 @@ const Packet: React.FC<{
pause?: number;
}> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => (
= ({
+
+ {filled && }
{label}
@@ -129,31 +147,41 @@ const Talk: React.FC = () => (
x={b.x}
y={b.y}
width={b.w}
- height={26}
- rx={13}
- fill={b.side === "left" ? SOFT : INK}
+ height={24}
+ rx={12}
+ fill={b.side === "left" ? SOFT : TINT}
stroke={b.side === "left" ? LINE : INK}
+ strokeWidth={b.side === "left" ? 1 : FINE}
/>
))}
-
+
{[0, 1, 2].map((dot) => (
@@ -186,15 +214,16 @@ const WeeklyHours: React.FC = () => (
return (
(
rx={4}
fill={done ? INK : PAPER}
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
/>
{done && (
(
y1={y + 16}
y2={y + 16}
stroke={done ? INK : LINE}
- strokeWidth={2}
+ strokeWidth={1.5}
strokeLinecap="round"
/>
{done && (
@@ -269,7 +298,7 @@ const WorkList: React.FC = () => (
(
/** Developer, step 4: two weeks notice to the end of a month. */
const Notice: React.FC = () => (
-
+
{Array.from({ length: 5 }, (_, w) => (
(
y1={140}
y2={160}
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
/>
{w === 4 ? "end of month" : `week ${w + 1}`}
@@ -337,9 +366,9 @@ const Notice: React.FC = () => (
))}
(
d="M 240 118 v -14 h 190 v 14"
fill="none"
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
/>
two weeks notice
-
+
);
@@ -398,7 +427,7 @@ const RepairWeek: React.FC = () => (
x2={to[0]}
y2={to[1]}
stroke="#f59e0b"
- strokeWidth={1.5}
+ strokeWidth={FINE}
strokeDasharray="3 5"
/>
(
x2={to[0]}
y2={to[1]}
stroke={INK}
- strokeWidth={2}
+ strokeWidth={1.5}
{...draw(0.4 + i * 0.22)}
/>
@@ -418,9 +447,9 @@ const RepairWeek: React.FC = () => (
key={i}
cx={x}
cy={y}
- r={7}
+ r={5}
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
initial={{ fill: PAPER }}
animate={{ fill: LIVE }}
transition={{ duration: 0.4, delay: 1 + i * 0.28 }}
@@ -449,7 +478,7 @@ const Summary: React.FC = () => (
rx={10}
fill={PAPER}
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
/>
{["found", "changed", "next"].map((label, s) => (
@@ -469,7 +498,7 @@ const Summary: React.FC = () => (
y1={84 + s * 62 + l * 11}
y2={84 + s * 62 + l * 11}
stroke={LINE}
- strokeWidth={3}
+ strokeWidth={2}
strokeLinecap="round"
{...draw(0.35 + s * 0.6 + l * 0.12)}
/>
@@ -491,14 +520,21 @@ const Summary: React.FC = () => (
/** Rescue, step 4: the client decides how to continue. */
const Decide: React.FC = () => (
-
+
@@ -514,7 +550,7 @@ const Decide: React.FC = () => (
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
fill="none"
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
{...draw(0.3 + i * 0.4)}
/>
-
+
{[0, 1, 2].map((d) => (
))}
@@ -617,6 +653,56 @@ const BLOCKS: readonly { x: number; y: number; w: number; h: number }[] = [
{ x: 298, y: 174, w: 94, h: 62 },
];
+/** What a finished page holds: a headline, a button, and a line of text per card. */
+const PageDetail: React.FC<{ delay: number }> = ({ delay }) => (
+
+
+
+
+ {[BLOCKS[2], BLOCKS[3], BLOCKS[4], BLOCKS[5]].map(
+ (block) =>
+ block && (
+
+
+
+
+ ),
+ )}
+
+);
+
/** Website, step 2: a first design as outlines. */
const Wireframe: React.FC = () => (
@@ -662,10 +748,14 @@ const Built: React.FC = () => (
height={b.h}
rx={5}
initial={{ fill: PAPER, stroke: LINE }}
- animate={{ fill: i === 1 ? INK : SOFT, stroke: i === 1 ? INK : LINE }}
+ animate={{
+ fill: i === 1 ? TINT : SOFT,
+ stroke: i === 1 ? INK : LINE,
+ }}
transition={{ duration: 0.35, delay: 0.2 + i * 0.18 }}
/>
))}
+
(
width={b.w}
height={b.h}
rx={5}
- fill={i === 1 ? INK : SOFT}
+ fill={i === 1 ? TINT : SOFT}
stroke={i === 1 ? INK : LINE}
/>
))}
+
(
height={BLOCKS[4]!.h}
rx={5}
fill={MARK}
- stroke={INK}
- strokeWidth={1.5}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 1.3 }}
@@ -718,20 +807,20 @@ const ChangeByEmail: React.FC = () => (
rx={5}
fill={PAPER}
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
/>
@@ -766,9 +855,9 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
width={112}
height={44}
rx={10}
- fill={PAPER}
- stroke={INK}
- strokeWidth={1.5}
+ fill={SOFT}
+ stroke={RIM}
+ strokeWidth={FINE}
/>
{label}
@@ -788,7 +877,7 @@ const ByHand: React.FC = () => (
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"
- strokeWidth={1.5}
+ strokeWidth={FINE}
strokeDasharray="3 5"
{...draw(0.3 + i * 0.25)}
/>
@@ -800,13 +889,13 @@ const ByHand: React.FC = () => (
r={13}
fill={PAPER}
stroke={INK}
- strokeWidth={1.5}
+ strokeWidth={FINE}
/>
(
d={d}
fill="none"
stroke={INK}
- strokeWidth={2}
+ strokeWidth={1.5}
{...draw(0.3 + i * 0.4)}
/>
))}
@@ -860,7 +949,7 @@ const Wiring: React.FC = () => (
const Running: React.FC = () => (
{WIRES.map((d) => (
-
+
))}
{[
{ from: [146, 80], to: [334, 80], delay: 0 },
@@ -870,10 +959,11 @@ const Running: React.FC = () => (
].map((dot) => (
diff --git a/apps/web/src/components/illustrations/PipelineRun.tsx b/apps/web/src/components/illustrations/PipelineRun.tsx
index c78207f..19962dd 100644
--- a/apps/web/src/components/illustrations/PipelineRun.tsx
+++ b/apps/web/src/components/illustrations/PipelineRun.tsx
@@ -1,5 +1,6 @@
"use client";
+import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react";
/**
@@ -285,125 +286,127 @@ export const PipelineRun: React.FC = () => {
-
- {/* Run header */}
-
-
-
-
-
-
-
-
- {scenario.branch}
- → main
+
+
+ {/* Run header */}
+
+
+
+
+
+
+
+
+ {scenario.branch}
+ → main
+
+
+
+ run #{frame.attempt === 1 ? 481 : 482}
+ {frame.attempt === 2 && (
+ · fix pushed
+ )}
-
- run #{frame.attempt === 1 ? 481 : 482}
- {frame.attempt === 2 && (
- · fix pushed
- )}
-
-
- {/* Stages */}
-
- {STAGES.map((stage, index) => {
- const state = stageState(index);
- return (
-
- {stage}
- {STAGE_MARK[state]}
-
- );
- })}
-
-
- {/* Test output */}
-
- {frame.stagesDone < 2 && (
-
- {frame.stagesDone === 0
- ? "$ lint: checking the code style…"
- : "$ types: checking that the parts fit together…"}
-
- )}
- {SUITES.slice(0, frame.suitesDone).map((suite, index) => {
- const failed = firstAttempt && index === failingIndex;
- return (
-
-
-
- {failed ? "FAIL" : "PASS"}
-
-
- {suite.file}
-
-
- {suite.tests} tests
-
-
- {suite.ms} ms
-
+ {/* Stages */}
+
+ {STAGES.map((stage, index) => {
+ const state = stageState(index);
+ return (
+
+ {stage}
+ {STAGE_MARK[state]}
- {failed && (
-
-
- ● {scenario.testName}
-
-
- expected {scenario.expected}
-
-
- received {scenario.received}
-
-
- )}
-
- );
- })}
- {frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
-
- running {SUITES[frame.suitesDone]?.file}…
-
- )}
-
+ );
+ })}
+
- {/* Summary */}
-
-
- Tests
- {failedCount > 0 && (
- {failedCount} failed,
+ {/* Test output */}
+
+ {frame.stagesDone < 2 && (
+
+ {frame.stagesDone === 0
+ ? "$ lint: checking the code style…"
+ : "$ types: checking that the parts fit together…"}
+
)}
-
- {reportedTests - failedCount} passed
+ {SUITES.slice(0, frame.suitesDone).map((suite, index) => {
+ const failed = firstAttempt && index === failingIndex;
+ return (
+
+
+
+ {failed ? "FAIL" : "PASS"}
+
+
+ {suite.file}
+
+
+ {suite.tests} tests
+
+
+ {suite.ms} ms
+
+
+ {failed && (
+
+
+ ● {scenario.testName}
+
+
+ expected {scenario.expected}
+
+
+ received {scenario.received}
+
+
+ )}
+
+ );
+ })}
+ {frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
+
+ running {SUITES[frame.suitesDone]?.file}…
+
+ )}
+
+
+ {/* Summary */}
+
+
+ Tests
+ {failedCount > 0 && (
+ {failedCount} failed,
+ )}
+
+ {reportedTests - failedCount} passed
+
+ of {TOTAL_TESTS}
- of {TOTAL_TESTS}
-
-
+ {!finished
+ ? "release pending"
: firstAttempt
- ? "bg-rose-500/15 text-rose-400"
- : "bg-emerald-500/15 text-emerald-400"
- }`}
- >
- {!finished
- ? "release pending"
- : firstAttempt
- ? "release blocked"
- : "released"}
-
+ ? "release blocked"
+ : "released"}
+
+
-
+
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
diff --git a/apps/web/src/components/illustrations/WorkShowcase.tsx b/apps/web/src/components/illustrations/WorkShowcase.tsx
index 3f3bf3a..9d5b697 100644
--- a/apps/web/src/components/illustrations/WorkShowcase.tsx
+++ b/apps/web/src/components/illustrations/WorkShowcase.tsx
@@ -3,6 +3,7 @@
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;
@@ -107,7 +108,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
ref={ref}
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"
+ className="relative overflow-hidden rounded-3xl border border-slate-700 bg-slate-950 px-5 pb-0 pt-8 [perspective:1400px] md:px-12 md:pt-14"
>
= ({ item }) => {
};
/**
- * WorkShowcase: each live site on a dark stage, as a desktop and a phone view
+ * 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[] }> = ({
@@ -192,17 +193,23 @@ export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
>
-
+
+
+
-
+
0{index + 1} · {item.client}
-
+
{item.name}
-
+
{item.text}
@@ -210,7 +217,7 @@ export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
{item.did.map((line) => (
{line}
@@ -221,7 +228,7 @@ export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
{item.tech.map((tech) => (
{tech}
@@ -232,7 +239,7 @@ export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
href={item.href}
target="_blank"
rel="noopener noreferrer"
- className="mt-8 inline-flex items-center gap-3 rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white no-underline transition-transform duration-300 hover:-translate-y-0.5"
+ 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
↗
diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js
index 8855c96..a9cf092 100644
--- a/apps/web/tailwind.config.js
+++ b/apps/web/tailwind.config.js
@@ -2,6 +2,8 @@ import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */
export default {
+ // Hover styles only where a pointer can hover, so they do not stick after a tap.
+ future: { hoverOnlyWhenSupported: true },
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {