feat: binary glow effects, refined drawings and calls to action

Dark surfaces now carry light: frames glow along their edge and shed binary
digits (BitAura), dark sections open with a lit seam (BitSeam), the career
path is a line of light and "My work" became a dark stage. Buttons carry a
travelling light and a spot that follows the pointer (BitGlow).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:07:09 +02:00
co-authored by Claude Opus 5.5
parent e9f05b02fc
commit e89a248ca5
21 changed files with 1532 additions and 606 deletions
+314 -21
View File
@@ -321,26 +321,6 @@
/* Additional global styles from BaseLayout */ /* 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 color */
::selection { ::selection {
background: #0f172a; background: #0f172a;
@@ -1079,8 +1059,72 @@ pre:has(code[class*="language-"]) {
transform: translate(0, 0); 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) { @media (prefers-reduced-motion: reduce) {
.bit-burst { .bit-burst,
.bit-glow {
display: none; display: none;
} }
.bit-card::before, .bit-card::before,
@@ -1088,3 +1132,252 @@ pre:has(code[class*="language-"]) {
transition: none; 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: "<angle>";
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);
}
}
+19 -2
View File
@@ -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 { Inter, JetBrains_Mono, Newsreader } from "next/font/google";
import { Analytics } from "@/src/components/Analytics"; import { Analytics } from "@/src/components/Analytics";
import { BitBurst } from "@/src/components/bits/BitBurst"; import { BitBurst } from "@/src/components/bits/BitBurst";
import { BitGlow } from "@/src/components/bits/BitGlow";
import { Footer } from "@/src/components/Footer"; import { Footer } from "@/src/components/Footer";
import { Header } from "@/src/components/Header"; import { Header } from "@/src/components/Header";
import { InteractiveElements } from "@/src/components/InteractiveElements"; import { InteractiveElements } from "@/src/components/InteractiveElements";
import { import {
INDEXING,
SITE_DESCRIPTION, SITE_DESCRIPTION,
SITE_LANGUAGE,
SITE_NAME, SITE_NAME,
SITE_TITLE, SITE_TITLE,
SITE_URL, SITE_URL,
} from "@/src/content/site"; } from "@/src/content/site";
import { robotsMeta } from "@/src/domain/indexing";
import "./globals.css"; import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); 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({ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-jetbrains-mono", variable: "--font-jetbrains-mono",
display: "swap", display: "swap",
adjustFontFallback: false,
fallback: ["ui-monospace", "Menlo", "Consolas", "monospace"],
}); });
const newsreader = Newsreader({ const newsreader = Newsreader({
subsets: ["latin"], subsets: ["latin"],
@@ -36,6 +45,7 @@ export const metadata: Metadata = {
applicationName: SITE_NAME, applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }], authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME, creator: SITE_NAME,
robots: robotsMeta(INDEXING),
icons: { icons: {
icon: "/favicon.svg", icon: "/favicon.svg",
apple: "/apple-touch-icon.png", 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({ export default function RootLayout({
children, children,
}: { }: {
@@ -63,7 +79,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<html <html
lang="en" lang={SITE_LANGUAGE}
className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`} className={`${inter.variable} ${newsreader.variable} ${jetbrainsMono.variable}`}
> >
<body className="min-h-screen bg-white"> <body className="min-h-screen bg-white">
@@ -72,6 +88,7 @@ export default function RootLayout({
<Footer /> <Footer />
<InteractiveElements /> <InteractiveElements />
<BitBurst /> <BitBurst />
<BitGlow />
<Analytics /> <Analytics />
</body> </body>
</html> </html>
+15 -10
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText"; import { DecodeText } from "@/src/components/bits/DecodeText";
import { Block } from "@/src/components/home/Block"; import { Block } from "@/src/components/home/Block";
import { ClosingCta } from "@/src/components/home/ClosingCta"; import { ClosingCta } from "@/src/components/home/ClosingCta";
@@ -109,9 +110,11 @@ export default function LandingPage() {
</p> </p>
</PortraitSection> </PortraitSection>
<section className="bg-white"> <section className="relative overflow-clip bg-slate-900">
<div className="mx-auto w-full max-w-[1400px] px-5 py-16 md:px-12 md:py-28"> <div className="mx-auto w-full max-w-[1400px] px-5 pb-20 pt-4 md:px-12 md:pb-36 md:pt-0">
<p className={`${MONO} m-0 mb-10`}>The path so far</p> <p className="m-0 mb-10 font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400">
The path so far
</p>
<CareerLine stations={CAREER} /> <CareerLine stations={CAREER} />
</div> </div>
</section> </section>
@@ -257,9 +260,11 @@ export default function LandingPage() {
} }
> >
<Reveal> <Reveal>
<div className="mb-16 overflow-hidden rounded-3xl bg-slate-900 p-4 md:mb-24 md:p-10"> <BitAura source="bottom" tone="light" className="mb-16 md:mb-24">
<ContactDiagram /> <div className="overflow-hidden rounded-3xl bg-slate-900 p-4 md:p-10">
</div> <ContactDiagram />
</div>
</BitAura>
</Reveal> </Reveal>
<div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16"> <div className="grid grid-cols-1 gap-x-12 gap-y-12 md:grid-cols-2 md:gap-y-16">
{PRINCIPLES.map((item, i) => ( {PRINCIPLES.map((item, i) => (
@@ -278,17 +283,17 @@ export default function LandingPage() {
<Block <Block
index="06" index="06"
label="My work" label="My work"
tone="dark"
title={ title={
<> <>
Two sites I built. <span className={MUTED}>Both are live.</span> Two sites I built.{" "}
<span className="text-slate-500">Both are live.</span>
</> </>
} }
> >
<WorkShowcase items={WORK} /> <WorkShowcase items={WORK} />
<Reveal> <Reveal>
<p <p className="m-0 mt-20 max-w-3xl border-t border-slate-800 pt-8 font-sans text-lg leading-relaxed text-slate-300 md:mt-28 md:text-xl">
className={`${BODY} mt-20 max-w-3xl border-t border-slate-200 pt-8 md:mt-28`}
>
Besides client work I build my own software, among other things a Besides client work I build my own software, among other things a
system in Rust and Solidity that runs on seven blockchains. system in Rust and Solidity that runs on seven blockchains.
</p> </p>
+57 -129
View File
@@ -1,29 +1,42 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { motion } from "framer-motion";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
type ButtonVariant = "primary" | "outline" | "ghost";
type ButtonSize = "normal" | "large";
interface ButtonProps { interface ButtonProps {
href: string; href: string;
children: React.ReactNode; children: React.ReactNode;
variant?: "primary" | "outline" | "ghost"; variant?: ButtonVariant;
size?: "normal" | "large"; size?: ButtonSize;
className?: string; className?: string;
showArrow?: boolean; showArrow?: boolean;
onClick?: (e: React.MouseEvent<any>) => void; onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
[key: string]: any;
} }
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<ButtonSize, string> = {
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<ButtonVariant, string> = {
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. * 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
* On hover: * follows the pointer (see BitGlow); the label itself stays still.
* - 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
*/ */
export const Button: React.FC<ButtonProps> = ({ export const Button: React.FC<ButtonProps> = ({
href, href,
@@ -33,135 +46,50 @@ export const Button: React.FC<ButtonProps> = ({
className = "", className = "",
showArrow = true, showArrow = true,
onClick, onClick,
...props
}) => { }) => {
const [hovered, setHovered] = React.useState(false);
const [displayText, setDisplayText] = React.useState<string | null>(null);
const contentRef = React.useRef<HTMLSpanElement>(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<string, string> = {
normal: "px-8 py-4 text-[10px]",
large: "px-10 py-5 text-[11px]",
};
const variants: Record<string, string> = {
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 = ( const inner = (
<motion.span <span
onMouseEnter={() => setHovered(true)} data-glow={variant === "primary" ? "" : undefined}
onMouseLeave={() => setHovered(false)} className={`${BASE} ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
className={`${base} ${sizes[size]} ${variants[variant]} ${className}`}
> >
{/* Binary stream hover overlay */} {variant === "primary" && (
<span <span className="bit-glow bit-glow-spot" aria-hidden="true" />
className="absolute inset-0 flex items-center pointer-events-none overflow-hidden" )}
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.3s ease" }} <span className="relative z-10 flex items-center gap-4">
> {children}
<motion.span
className="whitespace-nowrap font-mono text-xs tracking-[0.4em] select-none"
style={{ color: binaryColor }}
animate={hovered ? { x: [0, -200] } : { x: 0 }}
transition={
hovered ? { duration: 3, repeat: Infinity, ease: "linear" } : {}
}
>
{binaryStr}&nbsp;{binaryStr}
</motion.span>
</span>
{/* Shimmer line on hover (top edge) */}
<span
className="absolute top-0 left-0 right-0 h-px pointer-events-none"
style={{
opacity: hovered ? 1 : 0,
transition: "opacity 0.5s ease",
background:
variant === "primary"
? "linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)"
: "linear-gradient(90deg, transparent, rgba(59,130,246,0.15), transparent)",
}}
/>
{/* Content */}
<span
className="relative z-10 flex items-center gap-3"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<span ref={contentRef}>{displayText ?? children}</span>
{showArrow && ( {showArrow && (
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform duration-300" /> <span
className={`grid h-8 w-8 place-items-center rounded-full transition-colors duration-300 ${
variant === "primary"
? "bg-white/10 group-hover:bg-white group-hover:text-slate-900"
: "bg-slate-100 group-hover:bg-slate-900 group-hover:text-white"
}`}
aria-hidden="true"
>
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-300 group-hover:translate-x-0.5"
strokeWidth={2.25}
/>
</span>
)} )}
</span> </span>
</motion.span> </span>
); );
if (href.startsWith("#")) { if (href.startsWith("#")) {
return ( return (
<a <a
href={href} href={href}
onClick={(e) => { className="no-underline"
if (onClick) onClick(e); onClick={(event) => {
e.preventDefault(); onClick?.(event);
document.querySelector(href)?.scrollIntoView({ behavior: "smooth" }); event.preventDefault();
if (href.length > 1) {
document
.querySelector(href)
?.scrollIntoView({ behavior: "smooth" });
}
}} }}
{...props}
> >
{inner} {inner}
</a> </a>
@@ -169,7 +97,7 @@ export const Button: React.FC<ButtonProps> = ({
} }
return ( return (
<Link href={href} onClick={onClick} {...props}> <Link href={href} onClick={onClick} className="no-underline">
{inner} {inner}
</Link> </Link>
); );
+1 -1
View File
@@ -58,7 +58,7 @@ export const Header: React.FC = () => {
<Link <Link
href="/#contact" 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" className="bit-cta bit-cta-quiet font-sans text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full no-underline hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
style={{ style={{
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)", transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
}} }}
+440
View File
@@ -0,0 +1,440 @@
"use client";
import * as React from "react";
type AuraSource = "top" | "right" | "bottom" | "left" | "around";
type AuraTone = "dark" | "light";
/** How the energy behaves: `sparks` drift off the edge, `rain` falls onto it, `pulse` sends rings outward. */
type AuraMotion = "sparks" | "rain" | "pulse";
interface BitAuraProps {
/** The edge the energy sits on, or `around` to let it travel round the element. */
source: AuraSource;
/** The background the element stands on: `dark` adds light, `light` only ink digits. */
tone: AuraTone;
motion?: AuraMotion;
/** Share of the usual amount of digits (1 = default). */
density?: number;
/** Corner radius of the lit element in px. */
radius?: number;
className?: string;
children: React.ReactNode;
}
interface Box {
width: number;
height: number;
}
/** A bright zone on the outline, placed by its position along the perimeter (0..1). */
interface Spot {
at: number;
power: number;
}
interface Digit {
x: number;
y: number;
vx: number;
vy: number;
age: number;
life: number;
seed: number;
power: number;
large: boolean;
/** Outward normal at the point of the outline the digit belongs to. */
nx: number;
ny: number;
}
interface Palette {
blend: GlobalCompositeOperation;
glow: string;
core: string;
digit: string;
gain: number;
/** Opacity of the pulse rings at the edge. */
ring: number;
/** Share of the digit sparks that are kept (1 = all). */
density: number;
}
const MARGIN = 180;
const FRAME_MS = 1000 / 30;
const MAX_STEP_S = 0.1;
const MAX_PIXEL_RATIO = 2;
/** `rounded-3xl` in this project's Tailwind theme. */
const DEFAULT_RADIUS = 32;
const STILL_FRAME_STEPS = 90;
/** The glow is the outline stroked from wide and faint to thin and bright. */
const GLOW_LAYERS = [
{ width: 150, alpha: 0.035, hot: false },
{ width: 110, alpha: 0.05, hot: false },
{ width: 78, alpha: 0.07, hot: false },
{ width: 52, alpha: 0.1, hot: false },
{ width: 32, alpha: 0.15, hot: false },
{ width: 18, alpha: 0.22, hot: false },
{ width: 7, alpha: 0.45, hot: true },
{ width: 2, alpha: 0.95, hot: true },
] as const;
const LIGHT_PALETTE: Palette = {
blend: "source-over",
glow: "15, 23, 42",
core: "15, 23, 42",
digit: "15, 23, 42",
// No haze on light backgrounds: coloured light only reads on dark. Ink digits only.
gain: 0,
ring: 0.45,
density: 0.6,
};
const DARK_PALETTE: Palette = {
blend: "lighter",
glow: "99, 102, 241",
core: "224, 231, 255",
digit: "199, 210, 254",
gain: 1,
ring: 0.8,
density: 1,
};
const MOTION_DENSITY: Record<AuraMotion, number> = {
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<AuraSource, "around">,
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<BitAuraProps> = ({
source,
tone,
motion = "sparks",
density = 1,
radius = DEFAULT_RADIUS,
className = "",
children,
}) => {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(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 (
<div
ref={wrapperRef}
// The wrapper has to be positioned; keep the caller's own positioning if it brings one.
className={
className.split(" ").includes("absolute")
? className
: `relative ${className}`
}
>
<canvas
ref={canvasRef}
aria-hidden="true"
className="bit-aura pointer-events-none absolute font-mono"
style={{
left: -MARGIN,
top: -MARGIN,
width: `calc(100% + ${2 * MARGIN}px)`,
height: `calc(100% + ${2 * MARGIN}px)`,
}}
/>
<div className="relative h-full">{children}</div>
</div>
);
};
+49
View File
@@ -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<HTMLElement>(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<HTMLElement>(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;
};
+17
View File
@@ -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 }) => (
<div
className="pointer-events-none absolute inset-x-0 top-0"
aria-hidden="true"
>
<BitAura source="bottom" tone="dark" radius={0} density={density}>
<div className="h-0" />
</BitAura>
</div>
);
+10 -1
View File
@@ -91,6 +91,8 @@ export const BitHeadline: React.FC<{
const reducedMotion = window.matchMedia( const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)", "(prefers-reduced-motion: reduce)",
).matches; ).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 measured: Measured[] = [];
let width = 0; let width = 0;
@@ -306,6 +308,13 @@ export const BitHeadline: React.FC<{
if (disposed) return; if (disposed) return;
if (now - lastFrame >= FRAME_MS) { if (now - lastFrame >= FRAME_MS) {
lastFrame = now; 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); paint(now);
} }
raf = requestAnimationFrame(loop); raf = requestAnimationFrame(loop);
@@ -339,7 +348,7 @@ export const BitHeadline: React.FC<{
restart(); restart();
setPainted(true); setPainted(true);
resizeObserver.observe(wrap); resizeObserver.observe(wrap);
if (!reducedMotion) { if (!reducedMotion && !noHover) {
window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerleave", onPointerLeave); document.addEventListener("pointerleave", onPointerLeave);
} }
+60 -32
View File
@@ -1,5 +1,6 @@
import * as React from "react"; import * as React from "react";
import { BinaryTicker } from "@/src/components/bits/BinaryTicker"; import { BinaryTicker } from "@/src/components/bits/BinaryTicker";
import { BitSeam } from "@/src/components/bits/BitSeam";
import { BitWipe } from "@/src/components/bits/BitWipe"; import { BitWipe } from "@/src/components/bits/BitWipe";
import { DecodeText } from "@/src/components/bits/DecodeText"; import { DecodeText } from "@/src/components/bits/DecodeText";
import { Reveal } from "@/src/components/Reveal"; import { Reveal } from "@/src/components/Reveal";
@@ -10,6 +11,8 @@ interface BlockProps {
label: string; label: string;
title: React.ReactNode; title: React.ReactNode;
id?: string; id?: string;
/** `dark` turns the section into a dark stage. */
tone?: "light" | "dark";
children: React.ReactNode; children: React.ReactNode;
} }
@@ -22,36 +25,61 @@ export const Block: React.FC<BlockProps> = ({
label, label,
title, title,
id, id,
tone = "light",
children, children,
}) => ( }) => {
<section const dark = tone === "dark";
id={id} return (
className="relative scroll-mt-24 border-t border-slate-200 bg-white" <section
> id={id}
<div className="mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36"> className={`relative scroll-mt-24 border-t ${
<Reveal> dark
<div className="mb-8 flex items-center gap-4 md:mb-12"> ? "overflow-clip border-slate-800 bg-slate-900"
<span className="font-mono text-[11px] font-bold tracking-[0.2em] text-slate-900"> : "overflow-x-clip border-slate-200 bg-white"
{index} }`}
</span> >
<span className="h-px w-10 bg-slate-300" /> {dark && (
<DecodeText <div
text={label} className="pointer-events-none absolute inset-0 opacity-70"
className="font-mono text-[11px] uppercase tracking-[0.25em] text-slate-500" style={{
/> backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
<BinaryTicker backgroundSize: "20px 20px",
value={parseInt(index, 10)} }}
alive aria-hidden="true"
className="ml-auto hidden font-mono text-[10px] tracking-[0.3em] text-slate-300 md:block" />
/> )}
</div> {dark && <BitSeam />}
</Reveal> <div className="relative mx-auto w-full max-w-[1400px] px-5 py-20 md:px-12 md:py-36">
<BitWipe delay={0.1}> <Reveal>
<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]"> <div className="mb-8 flex items-center gap-4 md:mb-12">
{title} <span
</h2> className={`font-mono text-[11px] font-bold tracking-[0.2em] ${dark ? "text-white" : "text-slate-900"}`}
</BitWipe> >
<div className="mt-14 md:mt-24">{children}</div> {index}
</div> </span>
</section> <span
); className={`h-px w-10 ${dark ? "bg-slate-700" : "bg-slate-300"}`}
/>
<DecodeText
text={label}
className={`font-mono text-[11px] uppercase tracking-[0.25em] ${dark ? "text-slate-400" : "text-slate-500"}`}
/>
<BinaryTicker
value={parseInt(index, 10)}
alive
className={`ml-auto hidden font-mono text-[10px] tracking-[0.3em] md:block ${dark ? "text-slate-600" : "text-slate-300"}`}
/>
</div>
</Reveal>
<BitWipe delay={0.1} tone={dark ? "light" : "dark"}>
<h2
className={`m-0 max-w-6xl text-4xl font-bold leading-[0.95] tracking-tighter md:text-7xl lg:text-[6.5rem] ${dark ? "text-white" : "text-slate-900"}`}
>
{title}
</h2>
</BitWipe>
<div className="mt-14 md:mt-24">{children}</div>
</div>
</section>
);
};
+9 -2
View File
@@ -1,6 +1,8 @@
import * as React from "react"; import * as React from "react";
import { Reveal } from "@/src/components/Reveal"; import { Reveal } from "@/src/components/Reveal";
import { EmailLink } from "@/src/components/EmailLink"; 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 { BitHeadline, type BitLine } from "./BitHeadline";
import { ContactComposer } from "./ContactComposer"; import { ContactComposer } from "./ContactComposer";
@@ -13,7 +15,8 @@ const CLOSING: readonly BitLine[] = [
export const ClosingCta: React.FC = () => ( export const ClosingCta: React.FC = () => (
<section <section
id="contact" id="contact"
className="relative border-t border-slate-200 bg-slate-900 text-white" data-glow
className="relative overflow-clip border-t border-slate-200 bg-slate-900 text-white"
> >
<div <div
className="pointer-events-none absolute inset-0 opacity-70" className="pointer-events-none absolute inset-0 opacity-70"
@@ -23,6 +26,8 @@ export const ClosingCta: React.FC = () => (
}} }}
aria-hidden="true" aria-hidden="true"
/> />
<div className="bit-glow bit-glow-dots" aria-hidden="true" />
<BitSeam />
<div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-14 px-5 py-24 md:px-12 md:py-40 lg:grid-cols-12 lg:items-center lg:gap-12"> <div className="relative mx-auto grid w-full max-w-[1400px] grid-cols-1 gap-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"> <Reveal className="lg:col-span-6">
<BitHeadline <BitHeadline
@@ -40,7 +45,9 @@ export const ClosingCta: React.FC = () => (
</div> </div>
</Reveal> </Reveal>
<Reveal delay={0.15} className="lg:col-span-6"> <Reveal delay={0.15} className="lg:col-span-6">
<ContactComposer /> <BitAura source="around" tone="dark">
<ContactComposer />
</BitAura>
</Reveal> </Reveal>
</div> </div>
</section> </section>
@@ -122,7 +122,7 @@ export const ContactComposer: React.FC = () => {
<dl className="m-0 border-b border-slate-800 px-5 py-4 font-mono text-[12px] leading-relaxed md:px-7 md:text-[13px]"> <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"> <div className="flex gap-4">
<dt className="w-16 shrink-0 text-slate-500">to</dt> <dt className="w-16 shrink-0 text-slate-500">to</dt>
<dd className="m-0 text-slate-200"> <dd className="m-0 select-all text-slate-200">
{address ?? spellOutEmail(CONTACT_EMAIL_PARTS)} {address ?? spellOutEmail(CONTACT_EMAIL_PARTS)}
</dd> </dd>
</div> </div>
@@ -148,7 +148,7 @@ export const ContactComposer: React.FC = () => {
href={ href={
address ? mailtoHref(CONTACT_EMAIL_PARTS, topic.draft) : "#contact" 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 Open in my mail app
<span <span
+29 -18
View File
@@ -3,6 +3,7 @@
import * as React from "react"; import * as React from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Button } from "@/src/components/Button"; import { Button } from "@/src/components/Button";
import { BitAura } from "@/src/components/bits/BitAura";
import { DecodeText } from "@/src/components/bits/DecodeText"; import { DecodeText } from "@/src/components/bits/DecodeText";
import { PathScene } from "@/src/components/illustrations/PathScenes"; import { PathScene } from "@/src/components/illustrations/PathScenes";
import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths"; import { SERVICE_PATHS, type ServicePath } from "@/src/content/paths";
@@ -49,25 +50,33 @@ const PathStory: React.FC<{ path: ServicePath; active: boolean }> = ({
return ( return (
<div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12"> <div ref={ref} className="grid grid-cols-1 gap-8 md:grid-cols-12 md:gap-12">
<div className="md:order-2 md:col-span-7"> <div className="md:order-2 md:col-span-7">
<div className="relative overflow-hidden rounded-3xl bg-slate-900 md:sticky md:top-28"> <BitAura source="top" tone="light" className="md:sticky md:top-28">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7"> <div
<DecodeText data-glow
key={current?.title} className="relative overflow-hidden rounded-3xl bg-slate-900"
text={current?.title ?? ""} >
className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-300" <span className="bit-glow bit-glow-dots" aria-hidden="true" />
/> <div className="relative flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-500"> <DecodeText
{step + 1} / {path.steps.length} key={current?.title}
</span> 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>
</div>
<div className="relative px-2 py-4 md:px-10 md:py-8">
<PathScene
pathId={path.id}
step={step}
title={
current ? `${current.title}. ${current.text}` : path.need
}
/>
</div>
</div> </div>
<div className="px-2 py-4 md:px-10 md:py-8"> </BitAura>
<PathScene
pathId={path.id}
step={step}
title={current ? `${current.title}. ${current.text}` : path.need}
/>
</div>
</div>
</div> </div>
<ol className="m-0 list-none p-0 md:order-1 md:col-span-5"> <ol className="m-0 list-none p-0 md:order-1 md:col-span-5">
@@ -154,12 +163,14 @@ export const PathPicker: React.FC = () => {
aria-selected={selected} aria-selected={selected}
aria-controls={`path-panel-${path.id}`} aria-controls={`path-panel-${path.id}`}
onClick={() => setActiveId(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 ${ 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 selected
? "border-slate-900 bg-slate-900 text-white" ? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400" : "border-slate-200 bg-white text-slate-900 hover:-translate-y-1 hover:border-slate-400"
}`} }`}
> >
<span className="bit-glow bit-glow-edge" aria-hidden="true" />
<span className="font-mono text-[11px] tracking-[0.2em] text-slate-400"> <span className="font-mono text-[11px] tracking-[0.2em] text-slate-400">
0{index + 1} 0{index + 1}
</span> </span>
@@ -1,5 +1,6 @@
"use client"; "use client";
import { BitSeam } from "@/src/components/bits/BitSeam";
import * as React from "react"; import * as React from "react";
import Image from "next/image"; import Image from "next/image";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
@@ -32,12 +33,14 @@ export const PortraitSection: React.FC<{
> >
<Image <Image
src="/marc-mintel.png" src="/marc-mintel.png"
alt="Portrait of Marc Mintel" alt="Marc Mintel, freelance web developer"
fill fill
sizes="(min-width: 768px) 62vw, 100vw" sizes="(min-width: 768px) 62vw, 100vw"
className="object-cover object-[50%_18%] grayscale" className="object-cover object-[50%_18%] grayscale"
priority={false} priority={false}
/> />
{/* Slow light behind the fades, so the dark surface is not flat. */}
<div className="bit-aurora" aria-hidden="true" />
{/* Fades: into the surface on the left, and at top and bottom. */} {/* Fades: into the surface on the left, and at top and bottom. */}
<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-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 bottom-0 h-2/5 bg-gradient-to-t from-slate-900 to-transparent" />
@@ -72,5 +75,6 @@ export const PortraitSection: React.FC<{
<div className="mt-10 md:mt-14">{children}</div> <div className="mt-10 md:mt-14">{children}</div>
</motion.div> </motion.div>
</div> </div>
<BitSeam density={1} />
</section> </section>
); );
+1 -1
View File
@@ -37,7 +37,7 @@ export const StickyBook: React.FC = () => {
<a <a
href="#contact" href="#contact"
tabIndex={visible ? 0 : -1} tabIndex={visible ? 0 : -1}
className="flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5" className="bit-cta flex items-center gap-3 rounded-full bg-slate-900 px-7 py-4 font-sans text-[11px] font-bold uppercase tracking-[0.2em] text-white no-underline shadow-2xl shadow-slate-900/20 transition-transform duration-300 hover:-translate-y-0.5"
> >
Get in touch <span aria-hidden="true">→</span> Get in touch <span aria-hidden="true">→</span>
</a> </a>
@@ -1,5 +1,6 @@
"use client"; "use client";
import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react"; import * as React from "react";
import { useNarrow } from "./useNarrow"; import { useNarrow } from "./useNarrow";
import { import {
@@ -242,193 +243,197 @@ export const AutomationFlow: React.FC = () => {
{flow.summary} {flow.summary}
</p> </p>
<div className="mt-8 overflow-hidden rounded-3xl bg-slate-900"> <BitAura source="around" tone="light" motion="pulse" className="mt-8">
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7"> <div className="overflow-hidden rounded-3xl bg-slate-900">
<span className={`${MONO} text-slate-400`}>{flow.name}</span> <div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
<span className={`${MONO} flex items-center gap-2 text-slate-500`}> <span className={`${MONO} text-slate-400`}>{flow.name}</span>
<span <span className={`${MONO} flex items-center gap-2 text-slate-500`}>
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`} <span
/> className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
{finished ? "run complete" : "running"} />
</span> {finished ? "run complete" : "running"}
</div> </span>
<div>
<svg
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
role="img"
aria-label={`${flow.name}: ${flow.summary}`}
className="block h-auto w-full"
>
<defs>
<pattern
id="flow-grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="1" fill="#1e293b" />
</pattern>
</defs>
<rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
{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 (
<path
key={`${from}>${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 (
<g key={`packet-${from}>${to}`}>
{[0.16, 0.1, 0.05].map((lag, i) => {
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
return (
<circle
key={lag}
cx={p.x}
cy={p.y}
r={5 - i}
fill={MARK}
opacity={0.12 + i * 0.12}
/>
);
})}
<circle
cx={pointOn(a, b, eased, tall).x}
cy={pointOn(a, b, eased, tall).y}
r={7}
fill={MARK}
/>
</g>
);
})}
{placed.map((node) => {
const style = KIND_STYLE[node.kind];
const on = reached.has(node.id);
return (
<g key={node.id}>
<rect
x={node.cx - NODE_W / 2}
y={node.cy - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={14}
fill={on ? "#1e293b" : "#0f172a"}
stroke={on ? style.stroke : "#334155"}
strokeWidth={on ? 1.75 : 1.25}
strokeDasharray={style.dash}
style={{ transition: "fill 300ms, stroke 300ms" }}
/>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy - 8}
fill={on ? style.tag : "#64748b"}
style={{
fontFamily:
"var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
transition: "fill 300ms",
}}
>
{node.system}
</text>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy + 14}
fill={on ? "#f8fafc" : "#94a3b8"}
style={{
fontFamily:
"var(--font-inter), Inter, system-ui, sans-serif",
fontSize: 15,
fontWeight: 600,
transition: "fill 300ms",
}}
>
{node.label}
</text>
{on && (
<circle
cx={node.cx + NODE_W / 2 - 16}
cy={node.cy - NODE_H / 2 + 16}
r={4}
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
/>
)}
</g>
);
})}
</svg>
</div>
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
<ol
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
aria-live="off"
>
{logLines.map((line, index) => (
<li key={line} className="flex gap-3">
<span className="text-slate-600">
{String(index + 1).padStart(2, "0")}
</span>
<span
className={
line.startsWith("unclear") ? "text-amber-200" : undefined
}
>
{line}
</span>
</li>
))}
</ol>
</div> </div>
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
{(Object.keys(KIND_LABEL) as NodeKind[]) <div>
.filter((kind) => flow.nodes.some((node) => node.kind === kind)) <svg
.map((kind) => ( viewBox={`0 0 ${canvas.w} ${canvas.h}`}
<li role="img"
key={kind} aria-label={`${flow.name}: ${flow.summary}`}
className="flex items-center gap-3 font-mono text-[11px] text-slate-400" className="block h-auto w-full"
>
<defs>
<pattern
id="flow-grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
> >
<svg width="22" height="12" aria-hidden="true"> <circle cx="1" cy="1" r="1" fill="#1e293b" />
<rect </pattern>
x="1" </defs>
y="1" <rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
width="20"
height="10" {flow.edges.map(([from, to]) => {
rx="3" const a = nodeOf(from);
fill="none" const b = nodeOf(to);
stroke={KIND_STYLE[kind].stroke} if (!a || !b) return null;
strokeWidth="1.5" const done = still || travelled.has(`${from}>${to}`);
strokeDasharray={KIND_STYLE[kind].dash} return (
<path
key={`${from}>${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 (
<g key={`packet-${from}>${to}`}>
{[0.16, 0.1, 0.05].map((lag, i) => {
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
return (
<circle
key={lag}
cx={p.x}
cy={p.y}
r={5 - i}
fill={MARK}
opacity={0.12 + i * 0.12}
/>
);
})}
<circle
cx={pointOn(a, b, eased, tall).x}
cy={pointOn(a, b, eased, tall).y}
r={7}
fill={MARK}
/> />
</svg> </g>
{KIND_LABEL[kind]} );
</li> })}
))}
</ul> {placed.map((node) => {
const style = KIND_STYLE[node.kind];
const on = reached.has(node.id);
return (
<g key={node.id}>
<rect
x={node.cx - NODE_W / 2}
y={node.cy - NODE_H / 2}
width={NODE_W}
height={NODE_H}
rx={14}
fill={on ? "#1e293b" : "#0f172a"}
stroke={on ? style.stroke : "#334155"}
strokeWidth={on ? 1.75 : 1.25}
strokeDasharray={style.dash}
style={{ transition: "fill 300ms, stroke 300ms" }}
/>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy - 8}
fill={on ? style.tag : "#64748b"}
style={{
fontFamily:
"var(--font-jetbrains-mono), ui-monospace, monospace",
fontSize: 10,
letterSpacing: "0.14em",
textTransform: "uppercase",
transition: "fill 300ms",
}}
>
{node.system}
</text>
<text
x={node.cx - NODE_W / 2 + 16}
y={node.cy + 14}
fill={on ? "#f8fafc" : "#94a3b8"}
style={{
fontFamily:
"var(--font-inter), Inter, system-ui, sans-serif",
fontSize: 15,
fontWeight: 600,
transition: "fill 300ms",
}}
>
{node.label}
</text>
{on && (
<circle
cx={node.cx + NODE_W / 2 - 16}
cy={node.cy - NODE_H / 2 + 16}
r={4}
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
/>
)}
</g>
);
})}
</svg>
</div>
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
<ol
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
aria-live="off"
>
{logLines.map((line, index) => (
<li key={line} className="flex gap-3">
<span className="text-slate-600">
{String(index + 1).padStart(2, "0")}
</span>
<span
className={
line.startsWith("unclear")
? "text-amber-200"
: undefined
}
>
{line}
</span>
</li>
))}
</ol>
</div>
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
{(Object.keys(KIND_LABEL) as NodeKind[])
.filter((kind) => flow.nodes.some((node) => node.kind === kind))
.map((kind) => (
<li
key={kind}
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
>
<svg width="22" height="12" aria-hidden="true">
<rect
x="1"
y="1"
width="20"
height="10"
rx="3"
fill="none"
stroke={KIND_STYLE[kind].stroke}
strokeWidth="1.5"
strokeDasharray={KIND_STYLE[kind].dash}
/>
</svg>
{KIND_LABEL[kind]}
</li>
))}
</ul>
</div>
</div> </div>
</div> </BitAura>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="rounded-3xl border border-slate-200 p-6 md:p-8"> <div className="rounded-3xl border border-slate-200 p-6 md:p-8">
@@ -2,6 +2,7 @@
import * as React from "react"; import * as React from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { BitAura } from "@/src/components/bits/BitAura";
export type CareerStation = { export type CareerStation = {
readonly years: string; 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[] }> = ({ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
stations, stations,
}) => ( }) => (
<ol className="relative m-0 list-none p-0"> <ol className="relative m-0 list-none p-0">
<motion.span <BitAura
className="absolute left-[5px] top-2 w-px origin-top bg-slate-900 md:left-[7px]" source="left"
style={{ bottom: 8 }} tone="dark"
initial={{ scaleY: 0 }} radius={0}
whileInView={{ scaleY: 1 }} density={0.5}
viewport={{ once: true, margin: "-15%" }} className="absolute bottom-2 left-[5px] top-2 w-px md:left-[7px]"
transition={{ duration: 1.4, ease: "easeOut" }} >
aria-hidden="true" <motion.span
/> className="block h-full w-px origin-top bg-indigo-100"
initial={{ scaleY: 0 }}
whileInView={{ scaleY: 1 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 1.4, ease: "easeOut" }}
aria-hidden="true"
/>
</BitAura>
{stations.map((station, index) => ( {stations.map((station, index) => (
<motion.li <motion.li
key={station.years} key={station.years}
@@ -35,19 +44,21 @@ export const CareerLine: React.FC<{ stations: readonly CareerStation[] }> = ({
transition={{ duration: 0.4, delay: 0.15 + index * 0.18 }} transition={{ duration: 0.4, delay: 0.15 + index * 0.18 }}
> >
<span <span
className={`absolute left-0 top-1.5 h-[11px] w-[11px] rounded-full border-2 border-slate-900 md:h-[15px] md:w-[15px] ${ className={`absolute left-0 top-1.5 h-[11px] w-[11px] rounded-full border-2 border-indigo-100 md:h-[15px] md:w-[15px] ${
index === stations.length - 1 ? "bg-slate-900" : "bg-white" index === stations.length - 1
? "bit-node-live bg-white"
: "bg-slate-900"
}`} }`}
aria-hidden="true" aria-hidden="true"
/> />
<span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-500 md:col-span-3 md:pt-1.5"> <span className="font-mono text-[11px] uppercase tracking-[0.2em] text-slate-400 md:col-span-3 md:pt-1.5">
{station.years} {station.years}
</span> </span>
<span className="md:col-span-9"> <span className="md:col-span-9">
<span className="block font-sans text-xl font-bold tracking-tight text-slate-900 md:text-3xl"> <span className="block font-sans text-xl font-bold tracking-tight text-white md:text-3xl">
{station.role} {station.role}
</span> </span>
<span className="mt-1 block font-sans text-base text-slate-500 md:text-lg"> <span className="mt-1 block font-sans text-base text-slate-400 md:text-lg">
{station.place} {station.place}
</span> </span>
</span> </span>
@@ -15,6 +15,10 @@ const LINE = "#475569";
const SOFT = "#1e293b"; const SOFT = "#1e293b";
const MARK = "#fde68a"; const MARK = "#fde68a";
const LIVE = "#10b981"; 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 = { const MONO = {
fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace", fontFamily: "var(--font-jetbrains-mono), ui-monospace, monospace",
// Set on the svg: larger on phones, where the drawing is scaled down. // Set on the svg: larger on phones, where the drawing is scaled down.
@@ -70,8 +74,11 @@ const Packet: React.FC<{
pause?: number; pause?: number;
}> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => ( }> = ({ xs, ys, delay = 0, duration = 1.2, pause = 1.4 }) => (
<motion.circle <motion.circle
r={5} r={3.5}
fill={MARK} fill={MARK}
stroke={MARK}
strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: xs[0], cy: ys[0], opacity: 0 }} initial={{ cx: xs[0], cy: ys[0], opacity: 0 }}
animate={{ animate={{
cx: [...xs], cx: [...xs],
@@ -96,18 +103,29 @@ const Person: React.FC<{ x: number; label: string; filled?: boolean }> = ({
<g> <g>
<circle <circle
cx={x} cx={x}
cy={128} cy={130}
r={20} r={15}
fill={filled ? INK : PAPER} fill={filled ? TINT : "none"}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
<path <path
d={`M ${x - 34} 196 Q ${x - 34} 158 ${x} 158 Q ${x + 34} 158 ${x + 34} 196`} d={`M ${x - 28} 194 C ${x - 28} 172 ${x - 16} 160 ${x} 160 C ${x + 16} 160 ${x + 28} 172 ${x + 28} 194`}
fill={filled ? INK : PAPER} fill={filled ? TINT : "none"}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
strokeLinecap="round"
/> />
<line
x1={x - 36}
x2={x + 36}
y1={194}
y2={194}
stroke={LINE}
strokeWidth={1}
strokeLinecap="round"
/>
{filled && <circle cx={x + 12} cy={119} r={3.5} fill={MARK} />}
<text x={x} y={222} textAnchor="middle" style={MONO}> <text x={x} y={222} textAnchor="middle" style={MONO}>
{label} {label}
</text> </text>
@@ -129,31 +147,41 @@ const Talk: React.FC = () => (
x={b.x} x={b.x}
y={b.y} y={b.y}
width={b.w} width={b.w}
height={26} height={24}
rx={13} rx={12}
fill={b.side === "left" ? SOFT : INK} fill={b.side === "left" ? SOFT : TINT}
stroke={b.side === "left" ? LINE : INK} stroke={b.side === "left" ? LINE : INK}
strokeWidth={b.side === "left" ? 1 : FINE}
/> />
<line <line
x1={b.x + 14} x1={b.x + 14}
x2={b.x + b.w - 14} x2={b.x + b.w - 14}
y1={b.y + 13} y1={b.y + 12}
y2={b.y + 13} y2={b.y + 12}
stroke={b.side === "left" ? "#94a3b8" : PAPER} stroke={b.side === "left" ? "#94a3b8" : INK}
strokeWidth={2} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
/> />
</motion.g> </motion.g>
))} ))}
<motion.g {...appear(1.7)}> <motion.g {...appear(1.7)}>
<rect x={258} y={200} width={72} height={26} rx={13} fill={INK} /> <rect
x={270}
y={200}
width={60}
height={24}
rx={12}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
{[0, 1, 2].map((dot) => ( {[0, 1, 2].map((dot) => (
<motion.circle <motion.circle
key={dot} key={dot}
cx={280 + dot * 14} cx={288 + dot * 12}
cy={213} cy={212}
r={3} r={2.25}
fill={PAPER} fill={INK}
animate={{ opacity: [0.2, 1, 0.2] }} animate={{ opacity: [0.2, 1, 0.2] }}
transition={{ duration: 1.1, delay: dot * 0.18, repeat: Infinity }} transition={{ duration: 1.1, delay: dot * 0.18, repeat: Infinity }}
/> />
@@ -186,15 +214,16 @@ const WeeklyHours: React.FC = () => (
return ( return (
<motion.rect <motion.rect
key={h} key={h}
x={52 + d * 72} x={56 + d * 72}
y={64 + h * 22} y={66 + h * 21}
width={52} width={44}
height={16} height={10}
rx={4} rx={5}
strokeWidth={1}
initial={{ fill: PAPER, stroke: LINE }} initial={{ fill: PAPER, stroke: LINE }}
animate={{ animate={{
fill: reserved ? INK : PAPER, fill: reserved ? "#e2e8f0" : PAPER,
stroke: reserved ? INK : LINE, stroke: reserved ? "#e2e8f0" : LINE,
}} }}
transition={{ transition={{
duration: 0.25, duration: 0.25,
@@ -242,14 +271,14 @@ const WorkList: React.FC = () => (
rx={4} rx={4}
fill={done ? INK : PAPER} fill={done ? INK : PAPER}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
{done && ( {done && (
<motion.path <motion.path
d={`M ${76} ${y + 16} l 3.5 3.5 l 6 -7`} d={`M ${76} ${y + 16} l 3.5 3.5 l 6 -7`}
fill="none" fill="none"
stroke={PAPER} stroke={PAPER}
strokeWidth={2} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
{...draw(0.3 + row * 0.45)} {...draw(0.3 + row * 0.45)}
@@ -261,7 +290,7 @@ const WorkList: React.FC = () => (
y1={y + 16} y1={y + 16}
y2={y + 16} y2={y + 16}
stroke={done ? INK : LINE} stroke={done ? INK : LINE}
strokeWidth={2} strokeWidth={1.5}
strokeLinecap="round" strokeLinecap="round"
/> />
{done && ( {done && (
@@ -269,7 +298,7 @@ const WorkList: React.FC = () => (
<path <path
d={`M 322 ${y + 16} h 46`} d={`M 322 ${y + 16} h 46`}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
markerEnd="url(#arrow)" markerEnd="url(#arrow)"
/> />
<Packet <Packet
@@ -319,7 +348,7 @@ const WorkList: React.FC = () => (
/** Developer, step 4: two weeks notice to the end of a month. */ /** Developer, step 4: two weeks notice to the end of a month. */
const Notice: React.FC = () => ( const Notice: React.FC = () => (
<g> <g>
<line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={2} /> <line x1={50} x2={430} y1={150} y2={150} stroke={LINE} strokeWidth={1.5} />
{Array.from({ length: 5 }, (_, w) => ( {Array.from({ length: 5 }, (_, w) => (
<g key={w}> <g key={w}>
<line <line
@@ -328,7 +357,7 @@ const Notice: React.FC = () => (
y1={140} y1={140}
y2={160} y2={160}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
<text x={50 + w * 95} y={182} textAnchor="middle" style={MONO}> <text x={50 + w * 95} y={182} textAnchor="middle" style={MONO}>
{w === 4 ? "end of month" : `week ${w + 1}`} {w === 4 ? "end of month" : `week ${w + 1}`}
@@ -337,9 +366,9 @@ const Notice: React.FC = () => (
))} ))}
<motion.rect <motion.rect
x={240} x={240}
y={132} y={139}
height={36} height={22}
rx={6} rx={5}
fill={MARK} fill={MARK}
initial={{ width: 0, opacity: 0.35 }} initial={{ width: 0, opacity: 0.35 }}
animate={{ width: 190 }} animate={{ width: 190 }}
@@ -350,13 +379,13 @@ const Notice: React.FC = () => (
d="M 240 118 v -14 h 190 v 14" d="M 240 118 v -14 h 190 v 14"
fill="none" fill="none"
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
<text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}> <text x={335} y={92} textAnchor="middle" style={{ ...MONO, fill: INK }}>
two weeks notice two weeks notice
</text> </text>
</motion.g> </motion.g>
<motion.circle cx={430} cy={150} r={7} fill={INK} {...appear(1.4, 0)} /> <motion.circle cx={430} cy={150} r={4.5} fill={INK} {...appear(1.4, 0)} />
</g> </g>
); );
@@ -398,7 +427,7 @@ const RepairWeek: React.FC = () => (
x2={to[0]} x2={to[0]}
y2={to[1]} y2={to[1]}
stroke="#f59e0b" stroke="#f59e0b"
strokeWidth={1.5} strokeWidth={FINE}
strokeDasharray="3 5" strokeDasharray="3 5"
/> />
<motion.line <motion.line
@@ -407,7 +436,7 @@ const RepairWeek: React.FC = () => (
x2={to[0]} x2={to[0]}
y2={to[1]} y2={to[1]}
stroke={INK} stroke={INK}
strokeWidth={2} strokeWidth={1.5}
{...draw(0.4 + i * 0.22)} {...draw(0.4 + i * 0.22)}
/> />
</g> </g>
@@ -418,9 +447,9 @@ const RepairWeek: React.FC = () => (
key={i} key={i}
cx={x} cx={x}
cy={y} cy={y}
r={7} r={5}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
initial={{ fill: PAPER }} initial={{ fill: PAPER }}
animate={{ fill: LIVE }} animate={{ fill: LIVE }}
transition={{ duration: 0.4, delay: 1 + i * 0.28 }} transition={{ duration: 0.4, delay: 1 + i * 0.28 }}
@@ -449,7 +478,7 @@ const Summary: React.FC = () => (
rx={10} rx={10}
fill={PAPER} fill={PAPER}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
{["found", "changed", "next"].map((label, s) => ( {["found", "changed", "next"].map((label, s) => (
<g key={label}> <g key={label}>
@@ -469,7 +498,7 @@ const Summary: React.FC = () => (
y1={84 + s * 62 + l * 11} y1={84 + s * 62 + l * 11}
y2={84 + s * 62 + l * 11} y2={84 + s * 62 + l * 11}
stroke={LINE} stroke={LINE}
strokeWidth={3} strokeWidth={2}
strokeLinecap="round" strokeLinecap="round"
{...draw(0.35 + s * 0.6 + l * 0.12)} {...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. */ /** Rescue, step 4: the client decides how to continue. */
const Decide: React.FC = () => ( const Decide: React.FC = () => (
<g> <g>
<circle cx={90} cy={150} r={26} fill={INK} /> <circle
cx={90}
cy={150}
r={26}
fill={TINT}
stroke={INK}
strokeWidth={FINE}
/>
<text <text
x={90} x={90}
y={154} y={154}
textAnchor="middle" textAnchor="middle"
style={{ style={{
...MONO, ...MONO,
fill: PAPER, fill: INK,
fontSize: "calc(var(--scene-label, 10px) - 1px)", fontSize: "calc(var(--scene-label, 10px) - 1px)",
}} }}
> >
@@ -514,7 +550,7 @@ const Decide: React.FC = () => (
d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`} d={`M 116 150 C 190 150 180 ${option.y} 250 ${option.y}`}
fill="none" fill="none"
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
{...draw(0.3 + i * 0.4)} {...draw(0.3 + i * 0.4)}
/> />
<Packet <Packet
@@ -561,9 +597,9 @@ const Browser: React.FC<{
rx={12} rx={12}
fill={PAPER} fill={PAPER}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
<line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={1.5} /> <line x1={70} x2={410} y1={58} y2={58} stroke={INK} strokeWidth={FINE} />
{[0, 1, 2].map((d) => ( {[0, 1, 2].map((d) => (
<circle key={d} cx={88 + d * 12} cy={44} r={3} fill={LINE} /> <circle key={d} cx={88 + d * 12} cy={44} r={3} fill={LINE} />
))} ))}
@@ -617,6 +653,56 @@ const BLOCKS: readonly { x: number; y: number; w: number; h: number }[] = [
{ x: 298, y: 174, w: 94, h: 62 }, { 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 }) => (
<motion.g {...appear(delay, 0)}>
<line
x1={102}
x2={206}
y1={118}
y2={118}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
/>
<line
x1={102}
x2={176}
y1={129}
y2={129}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
/>
<rect x={102} y={140} width={40} height={11} rx={5.5} fill={INK} />
{[BLOCKS[2], BLOCKS[3], BLOCKS[4], BLOCKS[5]].map(
(block) =>
block && (
<g key={`${block.x}-${block.y}`}>
<line
x1={block.x + 10}
x2={block.x + block.w - 24}
y1={block.y + block.h - 20}
y2={block.y + block.h - 20}
stroke={RIM}
strokeWidth={2}
strokeLinecap="round"
/>
<line
x1={block.x + 10}
x2={block.x + block.w - 44}
y1={block.y + block.h - 12}
y2={block.y + block.h - 12}
stroke={LINE}
strokeWidth={2}
strokeLinecap="round"
/>
</g>
),
)}
</motion.g>
);
/** Website, step 2: a first design as outlines. */ /** Website, step 2: a first design as outlines. */
const Wireframe: React.FC = () => ( const Wireframe: React.FC = () => (
<g> <g>
@@ -662,10 +748,14 @@ const Built: React.FC = () => (
height={b.h} height={b.h}
rx={5} rx={5}
initial={{ fill: PAPER, stroke: LINE }} 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 }} transition={{ duration: 0.35, delay: 0.2 + i * 0.18 }}
/> />
))} ))}
<PageDetail delay={1.3} />
</Browser> </Browser>
<motion.text <motion.text
x={240} x={240}
@@ -691,10 +781,11 @@ const ChangeByEmail: React.FC = () => (
width={b.w} width={b.w}
height={b.h} height={b.h}
rx={5} rx={5}
fill={i === 1 ? INK : SOFT} fill={i === 1 ? TINT : SOFT}
stroke={i === 1 ? INK : LINE} stroke={i === 1 ? INK : LINE}
/> />
))} ))}
<PageDetail delay={0} />
<motion.rect <motion.rect
x={BLOCKS[4]!.x} x={BLOCKS[4]!.x}
y={BLOCKS[4]!.y} y={BLOCKS[4]!.y}
@@ -702,8 +793,6 @@ const ChangeByEmail: React.FC = () => (
height={BLOCKS[4]!.h} height={BLOCKS[4]!.h}
rx={5} rx={5}
fill={MARK} fill={MARK}
stroke={INK}
strokeWidth={1.5}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 1.3 }} transition={{ duration: 0.4, delay: 1.3 }}
@@ -718,20 +807,20 @@ const ChangeByEmail: React.FC = () => (
rx={5} rx={5}
fill={PAPER} fill={PAPER}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
<path <path
d="M 16 190 l 23 16 l 23 -16" d="M 16 190 l 23 16 l 23 -16"
fill="none" fill="none"
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
</motion.g> </motion.g>
<motion.path <motion.path
d="M 66 202 C 120 202 150 205 188 205" d="M 66 202 C 120 202 150 205 188 205"
fill="none" fill="none"
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
markerEnd="url(#arrow)" markerEnd="url(#arrow)"
{...draw(0.6)} {...draw(0.6)}
/> />
@@ -766,9 +855,9 @@ const Tool: React.FC<{ x: number; y: number; label: string }> = ({
width={112} width={112}
height={44} height={44}
rx={10} rx={10}
fill={PAPER} fill={SOFT}
stroke={INK} stroke={RIM}
strokeWidth={1.5} strokeWidth={FINE}
/> />
<text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}> <text x={x} y={y + 4} textAnchor="middle" style={{ ...MONO, fill: INK }}>
{label} {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)}`} 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" fill="none"
stroke="#f59e0b" stroke="#f59e0b"
strokeWidth={1.5} strokeWidth={FINE}
strokeDasharray="3 5" strokeDasharray="3 5"
{...draw(0.3 + i * 0.25)} {...draw(0.3 + i * 0.25)}
/> />
@@ -800,13 +889,13 @@ const ByHand: React.FC = () => (
r={13} r={13}
fill={PAPER} fill={PAPER}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
<path <path
d="M 218 172 Q 218 146 240 146 Q 262 146 262 172" d="M 218 172 Q 218 146 240 146 Q 262 146 262 172"
fill={PAPER} fill={PAPER}
stroke={INK} stroke={INK}
strokeWidth={1.5} strokeWidth={FINE}
/> />
</motion.g> </motion.g>
<motion.text <motion.text
@@ -837,7 +926,7 @@ const Wiring: React.FC = () => (
d={d} d={d}
fill="none" fill="none"
stroke={INK} stroke={INK}
strokeWidth={2} strokeWidth={1.5}
{...draw(0.3 + i * 0.4)} {...draw(0.3 + i * 0.4)}
/> />
))} ))}
@@ -860,7 +949,7 @@ const Wiring: React.FC = () => (
const Running: React.FC = () => ( const Running: React.FC = () => (
<g> <g>
{WIRES.map((d) => ( {WIRES.map((d) => (
<path key={d} d={d} fill="none" stroke={INK} strokeWidth={2} /> <path key={d} d={d} fill="none" stroke={INK} strokeWidth={1.5} />
))} ))}
{[ {[
{ from: [146, 80], to: [334, 80], delay: 0 }, { from: [146, 80], to: [334, 80], delay: 0 },
@@ -870,10 +959,11 @@ const Running: React.FC = () => (
].map((dot) => ( ].map((dot) => (
<motion.circle <motion.circle
key={dot.delay} key={dot.delay}
r={6} r={3.5}
fill={MARK} fill={MARK}
stroke={INK} stroke={MARK}
strokeWidth={1.5} strokeOpacity={0.28}
strokeWidth={6}
initial={{ cx: dot.from[0], cy: dot.from[1], opacity: 0 }} initial={{ cx: dot.from[0], cy: dot.from[1], opacity: 0 }}
animate={{ animate={{
cx: [dot.from[0]!, dot.to[0]!], cx: [dot.from[0]!, dot.to[0]!],
@@ -932,8 +1022,8 @@ export const PathScene: React.FC<{
viewBox="0 0 10 10" viewBox="0 0 10 10"
refX="8" refX="8"
refY="5" refY="5"
markerWidth="7" markerWidth="5"
markerHeight="7" markerHeight="5"
orient="auto-start-reverse" orient="auto-start-reverse"
> >
<path d="M 0 0 L 10 5 L 0 10 z" fill={INK} /> <path d="M 0 0 L 10 5 L 0 10 z" fill={INK} />
@@ -1,5 +1,6 @@
"use client"; "use client";
import { BitAura } from "@/src/components/bits/BitAura";
import * as React from "react"; import * as React from "react";
/** /**
@@ -285,125 +286,127 @@ export const PipelineRun: React.FC = () => {
</div> </div>
<div className="md:col-span-8"> <div className="md:col-span-8">
<div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"> <BitAura source="right" tone="light">
{/* Run header */} <div className="overflow-hidden rounded-3xl bg-slate-900 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7"> {/* Run header */}
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-800 px-5 py-4 md:px-7">
<span className="flex gap-1.5" aria-hidden="true"> <div className="flex items-center gap-3">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" /> <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 className="h-2.5 w-2.5 rounded-full bg-slate-700" />
</span> <span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="text-slate-400"> </span>
{scenario.branch} <span className="text-slate-400">
<span className="text-slate-600"> → </span>main {scenario.branch}
<span className="text-slate-600"> → </span>main
</span>
</div>
<span className="text-slate-500">
run #{frame.attempt === 1 ? 481 : 482}
{frame.attempt === 2 && (
<span className="text-slate-400"> · fix pushed</span>
)}
</span> </span>
</div> </div>
<span className="text-slate-500">
run #{frame.attempt === 1 ? 481 : 482}
{frame.attempt === 2 && (
<span className="text-slate-400"> · fix pushed</span>
)}
</span>
</div>
{/* Stages */} {/* Stages */}
<div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7"> <div className="grid grid-cols-2 gap-2 border-b border-slate-800 px-5 py-4 sm:grid-cols-4 md:px-7">
{STAGES.map((stage, index) => { {STAGES.map((stage, index) => {
const state = stageState(index); const state = stageState(index);
return ( return (
<div <div
key={stage} key={stage}
className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`} className={`flex items-center justify-between rounded-xl border px-3 py-2.5 transition-colors duration-200 ${STAGE_STYLE[state]}`}
> >
<span>{stage}</span> <span>{stage}</span>
<span aria-hidden="true">{STAGE_MARK[state]}</span> <span aria-hidden="true">{STAGE_MARK[state]}</span>
</div>
);
})}
</div>
{/* Test output */}
<div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
{frame.stagesDone < 2 && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
{frame.stagesDone === 0
? "$ lint: checking the code style…"
: "$ types: checking that the parts fit together…"}
</p>
)}
{SUITES.slice(0, frame.suitesDone).map((suite, index) => {
const failed = firstAttempt && index === failingIndex;
return (
<div key={suite.file}>
<div className="flex items-baseline gap-3">
<span
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
>
{failed ? "FAIL" : "PASS"}
</span>
<span className="min-w-0 flex-1 truncate text-slate-200">
{suite.file}
</span>
<span className="hidden text-slate-500 sm:inline">
{suite.tests} tests
</span>
<span className="w-14 shrink-0 text-right text-slate-600">
{suite.ms} ms
</span>
</div> </div>
{failed && ( );
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4"> })}
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]"> </div>
● {scenario.testName}
</p>
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
expected&nbsp; {scenario.expected}
</p>
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
received&nbsp; {scenario.received}
</p>
</div>
)}
</div>
);
})}
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
running {SUITES[frame.suitesDone]?.file}…
</p>
)}
</div>
{/* Summary */} {/* Test output */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7"> <div className="min-h-[21rem] px-5 py-5 md:px-7 md:py-6">
<span> {frame.stagesDone < 2 && (
<span className="text-slate-500">Tests </span> <p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
{failedCount > 0 && ( {frame.stagesDone === 0
<span className="text-rose-400">{failedCount} failed, </span> ? "$ lint: checking the code style…"
: "$ types: checking that the parts fit together…"}
</p>
)} )}
<span className="text-emerald-400"> {SUITES.slice(0, frame.suitesDone).map((suite, index) => {
{reportedTests - failedCount} passed const failed = firstAttempt && index === failingIndex;
return (
<div key={suite.file}>
<div className="flex items-baseline gap-3">
<span
className={`w-10 shrink-0 font-bold ${failed ? "text-rose-400" : "text-emerald-400"}`}
>
{failed ? "FAIL" : "PASS"}
</span>
<span className="min-w-0 flex-1 truncate text-slate-200">
{suite.file}
</span>
<span className="hidden text-slate-500 sm:inline">
{suite.tests} tests
</span>
<span className="w-14 shrink-0 text-right text-slate-600">
{suite.ms} ms
</span>
</div>
{failed && (
<div className="my-2 ml-[3.25rem] border-l border-rose-500/50 pl-4">
<p className="m-0 text-[12px] leading-relaxed text-slate-200 md:text-[13px]">
● {scenario.testName}
</p>
<p className="m-0 text-[12px] leading-relaxed text-emerald-400 md:text-[13px]">
expected&nbsp; {scenario.expected}
</p>
<p className="m-0 text-[12px] leading-relaxed text-rose-400 md:text-[13px]">
received&nbsp; {scenario.received}
</p>
</div>
)}
</div>
);
})}
{frame.stagesDone === 2 && frame.suitesDone < SUITES.length && (
<p className="m-0 text-[12px] leading-relaxed text-slate-500 md:text-[13px]">
running {SUITES[frame.suitesDone]?.file}…
</p>
)}
</div>
{/* Summary */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 px-5 py-4 md:px-7">
<span>
<span className="text-slate-500">Tests </span>
{failedCount > 0 && (
<span className="text-rose-400">{failedCount} failed, </span>
)}
<span className="text-emerald-400">
{reportedTests - failedCount} passed
</span>
<span className="text-slate-500"> of {TOTAL_TESTS}</span>
</span> </span>
<span className="text-slate-500"> of {TOTAL_TESTS}</span> <span
</span> className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${
<span !finished
className={`rounded-full px-3 py-1 font-bold uppercase tracking-[0.15em] ${ ? "bg-slate-800 text-slate-400"
!finished : firstAttempt
? "bg-slate-800 text-slate-400" ? "bg-rose-500/15 text-rose-400"
: "bg-emerald-500/15 text-emerald-400"
}`}
>
{!finished
? "release pending"
: firstAttempt : firstAttempt
? "bg-rose-500/15 text-rose-400" ? "release blocked"
: "bg-emerald-500/15 text-emerald-400" : "released"}
}`} </span>
> </div>
{!finished
? "release pending"
: firstAttempt
? "release blocked"
: "released"}
</span>
</div> </div>
</div> </BitAura>
<p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400"> <p className="m-0 mt-4 font-sans text-sm leading-relaxed text-slate-400">
An illustration with made-up file names and numbers, modelled on a 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 real test run. Tests do not catch every mistake, but they do catch
@@ -3,6 +3,7 @@
import * as React from "react"; import * as React from "react";
import Image from "next/image"; import Image from "next/image";
import { motion, useMotionValue, useSpring } from "framer-motion"; import { motion, useMotionValue, useSpring } from "framer-motion";
import { BitAura } from "@/src/components/bits/BitAura";
export type WorkItem = { export type WorkItem = {
readonly name: string; readonly name: string;
@@ -107,7 +108,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
ref={ref} ref={ref}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave} 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"
> >
<div <div
className="pointer-events-none absolute inset-0 opacity-60" className="pointer-events-none absolute inset-0 opacity-60"
@@ -179,7 +180,7 @@ const WorkStage: React.FC<{ item: WorkItem }> = ({ 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. * that scroll through the real pages.
*/ */
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({ 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" className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
> >
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}> <div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
<WorkStage item={item} /> <BitAura
source={index % 2 === 0 ? "bottom" : "top"}
tone="dark"
motion={index % 2 === 0 ? "sparks" : "rain"}
>
<WorkStage item={item} />
</BitAura>
</div> </div>
<div className="md:col-span-4"> <div className="md:col-span-4">
<p className={`${MONO} m-0 text-slate-500`}> <p className={`${MONO} m-0 text-slate-400`}>
0{index + 1} · {item.client} 0{index + 1} · {item.client}
</p> </p>
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl"> <h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-white md:text-5xl">
{item.name} {item.name}
</h3> </h3>
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-500 md:text-xl"> <p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-300 md:text-xl">
{item.text} {item.text}
</p> </p>
@@ -210,7 +217,7 @@ export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
{item.did.map((line) => ( {item.did.map((line) => (
<li <li
key={line} key={line}
className="border-t border-slate-200 py-3 font-sans text-base text-slate-900 md:text-lg" className="border-t border-slate-800 py-3 font-sans text-base text-slate-100 md:text-lg"
> >
{line} {line}
</li> </li>
@@ -221,7 +228,7 @@ export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
{item.tech.map((tech) => ( {item.tech.map((tech) => (
<li <li
key={tech} key={tech}
className="rounded-full border border-slate-200 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-600" className="rounded-full border border-slate-700 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-300"
> >
{tech} {tech}
</li> </li>
@@ -232,7 +239,7 @@ export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
href={item.href} href={item.href}
target="_blank" target="_blank"
rel="noopener noreferrer" 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 <span aria-hidden="true">↗</span> Open the live site <span aria-hidden="true">↗</span>
</a> </a>
+2
View File
@@ -2,6 +2,8 @@ import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { 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}"], content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: { theme: {
extend: { extend: {