Add project previews and race replay

This commit is contained in:
2026-09-22 20:38:43 -04:00
parent 8b0caae14f
commit 5519480058
27 changed files with 1124 additions and 140 deletions
+1 -1
View File
@@ -1 +1 @@
Manyangles portfolio card uses the existing study-abroad group dinner photo at /trips/engr290/insta290.jpg, also shown on the travel page. This is illustrative portfolio imagery, not a claim that the trip used Manyangles.
The Manyangles portfolio card uses an illustrative sample gallery built with CSS. It contains no event-specific photos or personal details.
Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2009-2011 by Accademia di Belle Arti di Urbino and students of MA course of Visual design. Some rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+218
View File
@@ -0,0 +1,218 @@
"""Build the compact 2024 São Paulo replay snapshot from public race data.
Run from the repository root: python3 scripts/generate-race-replay.py
The website reads the checked-in snapshot and makes no live API requests.
"""
from __future__ import annotations
import json
import math
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen
JOLPICA = "https://api.jolpi.ca/ergast/f1/2024/21"
OPENF1 = "https://api.openf1.org/v1"
OUTPUT = Path(__file__).resolve().parents[1] / "src/lib/race-replay-data.json"
def fetch(url: str):
for attempt in range(5):
try:
with urlopen(Request(url, headers={"User-Agent": "SeanOConnorRaceReplay/1.0"}), timeout=30) as response:
return json.load(response)
except Exception:
if attempt == 4:
raise
time.sleep(2 ** attempt)
def seconds(value: str) -> float:
total = 0.0
for part in value.split(":"):
total = total * 60 + float(part)
return round(total, 3)
def jolpica(endpoint: str):
payload = fetch(f"{JOLPICA}/{endpoint}/?limit=100")
return payload["MRData"]
first_page = jolpica("laps")
total = int(first_page["total"])
offsets = range(100, total, 100)
with ThreadPoolExecutor(max_workers=4) as pool:
more_pages = list(pool.map(lambda offset: fetch(f"{JOLPICA}/laps/?limit=100&offset={offset}")["MRData"], offsets))
all_laps = {}
for page in [first_page, *more_pages]:
for lap in page["RaceTable"]["Races"][0]["Laps"]:
all_laps.setdefault(int(lap["number"]), []).extend(lap["Timings"])
results = jolpica("results")["RaceTable"]["Races"][0]
pits = jolpica("pitstops")["RaceTable"]["Races"][0]["PitStops"]
openf1_drivers = fetch(f"{OPENF1}/drivers?session_key=9636")
driver_colors = {int(driver["driver_number"]): f"#{driver['team_colour']}" for driver in openf1_drivers}
drivers = []
for result in results["Results"]:
driver = result["Driver"]
number = int(result["number"])
drivers.append({
"id": driver["driverId"],
"number": number,
"code": driver.get("code", driver["familyName"][:3].upper()),
"name": f"{driver['givenName']} {driver['familyName']}",
"team": result["Constructor"]["name"],
"color": driver_colors.get(number, "#ffffff"),
"grid": int(result["grid"]),
"finish": int(result["position"]),
"status": result["status"],
})
laps = []
for number, timings in sorted(all_laps.items()):
laps.append({
"number": number,
"timings": [
{"id": timing["driverId"], "position": int(timing["position"]), "seconds": seconds(timing["time"])}
for timing in timings
],
})
pit_stops = [
{"id": pit["driverId"], "lap": int(pit["lap"]), "seconds": seconds(pit["duration"])}
for pit in pits
]
# A complete lap from Verstappen's on-car coordinates, rather than an
# unrelated circuit image or generated illustration.
lap_eight = next(item for item in fetch(f"{OPENF1}/laps?session_key=9636&driver_number=1") if item["lap_number"] == 8)
from datetime import datetime, timedelta
start = datetime.fromisoformat(lap_eight["date_start"])
end = start + timedelta(seconds=lap_eight["lap_duration"])
params = urlencode({
"session_key": 9636,
"driver_number": 1,
"date>": start.isoformat(timespec="milliseconds"),
"date<": end.isoformat(timespec="milliseconds"),
})
location = fetch(f"{OPENF1}/location?{params}")
points = [(row["x"], row["y"]) for row in location if row["x"] is not None and row["y"] is not None]
if len(points) < 100:
raise RuntimeError("Not enough location samples for the circuit trace")
if math.dist(points[0], points[-1]) > 400:
raise RuntimeError("The sampled track lap does not close")
points.append(points[0])
# Mirror the telemetry Y axis to match the FIA's 2024 circuit map: the
# start/finish straight is on the left and the back straight is on the right.
min_x, max_x = min(x for x, _ in points), max(x for x, _ in points)
min_y, max_y = min(y for _, y in points), max(y for _, y in points)
scale = min(650 / (max_x - min_x), 630 / (max_y - min_y))
offset_x = (1000 - (max_x - min_x) * scale) / 2
offset_y = (740 - (max_y - min_y) * scale) / 2
def map_point(x: float, y: float) -> list[float]:
return [round(offset_x + (x - min_x) * scale, 1), round(offset_y + (max_y - y) * scale, 1)]
track = [map_point(x, y) for x, y in points]
circuit = fetch("https://api.multiviewer.app/api/v1/circuits/14/2024")
corners = []
for corner in circuit["corners"]:
x, y = map_point(corner["trackPosition"]["x"], corner["trackPosition"]["y"])
corners.append({"number": corner["number"], "x": x, "y": y})
def nearest_track_distance(x: float, y: float) -> float:
"""Project a mapped circuit marker onto the same polyline as the cars."""
best_distance = float("inf")
best_progress = 0.0
progress = 0.0
for (ax, ay), (bx, by) in zip(track, track[1:]):
length_sq = (bx - ax) ** 2 + (by - ay) ** 2
fraction = max(0.0, min(1.0, ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / length_sq)) if length_sq else 0.0
px, py = ax + fraction * (bx - ax), ay + fraction * (by - ay)
distance = math.dist((x, y), (px, py))
if distance < best_distance:
best_distance = distance
best_progress = progress + math.sqrt(length_sq) * fraction
progress += math.sqrt(length_sq)
return best_progress
track_length = sum(math.dist(a, b) for a, b in zip(track, track[1:]))
corner_distance = {corner["number"]: nearest_track_distance(corner["x"], corner["y"]) for corner in corners}
def point_at(distance: float) -> list[float]:
remaining = distance % track_length
for (ax, ay), (bx, by) in zip(track, track[1:]):
segment = math.dist((ax, ay), (bx, by))
if remaining <= segment:
fraction = remaining / segment if segment else 0
return [round(ax + (bx - ax) * fraction, 1), round(ay + (by - ay) * fraction, 1)]
remaining -= segment
return track[0]
def zone_path(start: float, end: float) -> list[list[float]]:
if end < start:
end += track_length
# Follow the same polyline as the animated dots, including the lap seam.
samples = max(2, math.ceil((end - start) / 3))
return [point_at(start + (end - start) * index / samples) for index in range(samples + 1)]
# FIA 2024 São Paulo Circuit Map: activation 1 is 30m after T3;
# activation 2 is 160m before T15. Zones end at the next braking turn.
metres_to_track = track_length / 4309
drs_zones = [
{"number": 1, "track": zone_path(corner_distance[3] + 30 * metres_to_track, corner_distance[4] - 30 * metres_to_track)},
{"number": 2, "track": zone_path(corner_distance[15] - 160 * metres_to_track, corner_distance[1] - 30 * metres_to_track)},
]
control = fetch(f"{OPENF1}/race_control?session_key=9636")
event_specs = [
(1, "start", "A race before the race", "An aborted start delays lights out on a wet Interlagos grid.", "ABORTED START"),
(28, "vsc", "Virtual safety car", "A VSC changes the pit-stop calculation as the rain intensifies.", "VIRTUAL SAFETY CAR DEPLOYED"),
(30, "safety", "Safety car deployed", "The field bunches up in difficult conditions.", "SAFETY CAR DEPLOYED"),
(32, "red", "Red flag", "The race stops, and the order is reset for a rolling restart.", "RED FLAG"),
(33, "restart", "Rolling restart", "The field gets going again behind Ocon.", "ROLLING START"),
(39, "safety", "Safety car returns", "Another interruption bunches the field.", "SAFETY CAR DEPLOYED"),
(42, "restart", "Safety car in", "The race returns to green-flag running.", "SAFETY CAR IN THIS LAP"),
(69, "finish", "Chequered flag", "Verstappen wins from P17; Alpine finishes second and third.", "CHEQUERED FLAG"),
]
events = []
for lap, kind, title, description, message in event_specs:
if not any(item.get("lap_number") == lap and message in item["message"] for item in control):
raise RuntimeError(f"Race-control event not found: lap {lap}, {message}")
events.append({"lap": lap, "kind": kind, "title": title, "description": description})
data = {
"race": {"title": "São Paulo Grand Prix", "year": 2024, "date": "3 November 2024", "circuit": "Autódromo José Carlos Pace", "location": "Interlagos · Brazil", "laps": 69},
"drivers": drivers,
"laps": laps,
"pitStops": pit_stops,
"events": events,
"track": track,
"corners": corners,
"drsZones": drs_zones,
"sources": {
"laps": f"{JOLPICA}/laps/",
"results": f"{JOLPICA}/results/",
"pitStops": f"{JOLPICA}/pitstops/",
"raceControl": f"{OPENF1}/race_control?session_key=9636",
"track": f"{OPENF1}/location?session_key=9636&driver_number=1",
"corners": "https://api.multiviewer.app/api/v1/circuits/14/2024",
"circuitMap": "https://www.fia.com/sites/default/files/decision-document/2024%20S%C3%A3o%20Paulo%20Grand%20Prix%20-%20Event%20Notes%20-%20Circuit%20Map,%20Pit%20Lane%20Drawing%20and%20Red%20Zones.pdf",
},
}
OUTPUT.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n")
print(f"Wrote {OUTPUT}: {len(drivers)} drivers, {len(laps)} laps, {len(track)} track points")
+10 -14
View File
@@ -3,6 +3,8 @@ import Image from "next/image";
import { JusticeParagraph } from "~/components/JusticeParagraph";
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
import { PuterPreview } from "~/components/PuterPreview";
import { ProjectBrand } from "~/components/ProjectBrand";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
@@ -32,7 +34,7 @@ const work = [
{
name: "Puter",
category: "Native macOS app",
image: "/images/puter.jpg",
image: "/portfolio/puter/01-processes-light.png",
alt: "Puters macOS performance dashboard",
tone: "puter",
href: "https://git.soconnor.dev/soconnor/Puter",
@@ -44,7 +46,7 @@ const work = [
name: "Manyangles",
category: "Event photo sharing",
image: "/portfolio/manyangles.svg",
alt: "Manyangles event photo sharing",
alt: "Manyangles shared albums",
tone: "manyangles",
href: "https://ma.hadlock.tech",
description:
@@ -122,6 +124,8 @@ export default function HomePage() {
>
{project.name === "beenvoice" ? (
<BeenvoicePreview priority />
) : project.name === "Puter" ? (
<PuterPreview />
) : project.name === "Manyangles" ? (
<ManyanglesPreview />
) : (
@@ -133,21 +137,11 @@ export default function HomePage() {
sizes="(max-width: 700px) 100vw, 50vw"
/>
)}
{(project.name === "Racetix" || project.name === "beenvoice") && (
<div className={`studio-product-logo ${project.name === "beenvoice" ? "beenvoice-logo" : ""}`}>
<Image
src={project.name === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
alt=""
width={project.name === "Racetix" ? 1614 : 2970}
height={project.name === "Racetix" ? 304 : 436}
/>
</div>
)}
</a>
<div className="studio-project-title">
<h3>
<a href={project.href}>
{project.name} <ArrowUpRight size={20} />
<ProjectBrand name={project.name} /> <ArrowUpRight size={20} />
</a>
</h3>
<span>{project.category}</span>
@@ -202,7 +196,9 @@ export default function HomePage() {
<JusticeParagraph text="At Bucknell, I studied Computer Science and Engineering, built tools for robotics research, and helped other students learn to build things. Now Im at BU, aiming to finish my masters in a year while continuing my own projects." />
<p>
Outside of software, Im into specialty coffee, Formula One, and
travel. I cofounded Bucknells Coffee Societyand, naturally, made a{" "}
travel. I built a{" "}
<a className="coffee-link" href="/race-replay">lap-by-lap race replay</a>
{" "}for the 2024 São Paulo Grand Prix. I also cofounded Bucknells Coffee Societyand, naturally, made a{" "}
<a className="coffee-link" href="/coffee">
coffee map for Lewisburg and Boston
</a>
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { RaceReplay } from "~/components/RaceReplay";
import "./race-replay.css";
export const metadata: Metadata = {
title: "Race Replay — São Paulo 2024",
description: "Explore the 2024 São Paulo Grand Prix lap by lap on an interactive Interlagos circuit map.",
};
export default function RaceReplayPage() {
return <RaceReplay />;
}
+224
View File
@@ -0,0 +1,224 @@
@font-face {
font-family: "Race Titillium";
src: url("/race/fonts/TitilliumWeb-Regular.ttf") format("truetype");
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: "Race Titillium";
src: url("/race/fonts/TitilliumWeb-Bold.ttf") format("truetype");
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: "Race Titillium";
src: url("/race/fonts/TitilliumWeb-Black.ttf") format("truetype");
font-weight: 900;
font-display: swap;
}
.race-site-main { width: 100%; max-width: none; padding: 0; }
.race-replay {
--race-bg: #f2f2f4;
--race-surface: #fff;
--race-ink: #15151e;
--race-muted: #62626b;
--race-line: #d8d8dc;
--race-red: #e10600;
--race-red-soft: #ffe9e8;
min-height: 100vh;
background: var(--race-bg);
color: var(--race-ink);
font-family: "Race Titillium", "Helvetica Neue", Arial, sans-serif;
}
.race-replay * { box-sizing: border-box; }
.race-replay button { font: inherit; }
.race-replay button:focus-visible, .race-replay a:focus-visible, .race-replay input:focus-visible { outline: 2px solid var(--race-red); outline-offset: 3px; }
.replay-wrap { width: min(1500px, 100%); margin: auto; padding: 40px clamp(18px, 3.4vw, 54px) 34px; }
.replay-intro { display: flex; justify-content: space-between; gap: 48px; align-items: end; min-height: 232px; margin-bottom: 34px; }
.replay-intro-copy { min-width: 0; }
.replay-eyebrow, .replay-intro-meta, .replay-panel-heading p, .replay-panel-heading .replay-track-tag, .replay-stage-top, .replay-map-legend, .replay-playback-top span, .replay-sidebar-head, .replay-driver-focus > p, .replay-focus-stats span, .replay-moments-title p, .replay-moments-title > span, .replay-moment > span:first-child, .replay-sources { font-size: 10px; font-weight: 720; letter-spacing: .15em; }
.replay-eyebrow { display: flex; align-items: center; gap: 12px; color: var(--race-red); }
.replay-live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--race-red); box-shadow: 0 0 0 4px color-mix(in srgb, var(--race-red) 14%, transparent); }
.replay-eyebrow-divider { color: var(--race-muted); font-weight: 400; }
.replay-intro h1 { margin: 18px 0 10px; font-family: "Race Titillium", "Helvetica Neue", sans-serif; font-weight: 900; font-size: clamp(58px, 7.5vw, 118px); line-height: .94; letter-spacing: -.04em; text-transform: uppercase; }
.replay-intro h1 em { color: var(--race-red); font-style: italic; }
.replay-title-period { color: var(--race-red); }
.replay-intro-copy > p { max-width: 650px; margin: 20px 0 20px; color: var(--race-muted); font-size: clamp(13px, 1.15vw, 16px); line-height: 1.6; }
.replay-intro-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 23px; color: var(--race-muted); }
.replay-intro-meta span { display: inline-flex; align-items: center; gap: 7px; }
.replay-headline-stat { flex: 0 0 254px; padding: 16px 0 7px 24px; border-left: 2px solid var(--race-red); }
.replay-headline-stat > span { color: var(--race-muted); font-size: 10px; font-weight: 760; letter-spacing: .18em; }
.replay-headline-stat strong { display: block; margin-top: 7px; font-size: clamp(44px, 4.2vw, 70px); font-weight: 850; line-height: 1; letter-spacing: -.095em; white-space: nowrap; }
.replay-headline-stat small { font-size: .45em; letter-spacing: -.03em; }
.replay-headline-stat i { color: var(--race-red); font-size: .7em; font-style: normal; }
.replay-headline-stat p { margin: 9px 0 0; color: var(--race-muted); font-size: 11px; }
.replay-dashboard { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 340px); gap: 16px; align-items: start; }
.replay-map-panel, .replay-sidebar { min-width: 0; border: 1px solid var(--race-line); background: var(--race-surface); box-shadow: 0 8px 35px #0a1e2a08; }
.replay-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 19px 24px 17px; }
.replay-panel-heading p, .replay-moments-title p { margin: 0 0 7px; color: var(--race-red); }
.replay-panel-heading h2 { margin: 0; font-size: clamp(16px, 1.5vw, 22px); font-weight: 700; letter-spacing: -.02em; }
.replay-track-tag { padding: 8px 9px; border: 1px solid var(--race-line); color: var(--race-muted); white-space: nowrap; }
.replay-map-stage { position: relative; height: clamp(540px, 54vw, 700px); overflow: hidden; background: radial-gradient(ellipse at 50% 48%, #252530, #15151e 64%, #0d0d14 100%); color: #f5f5f6; }
.replay-map-stage::before { content: ""; position: absolute; inset: 0; opacity: .46; background-image: linear-gradient(#75849217 1px, transparent 1px), linear-gradient(90deg, #75849217 1px, transparent 1px); background-size: 38px 38px; mask-image: linear-gradient(120deg, transparent, #000 20%, #000 80%, transparent); }
.replay-map-stage::after { content: ""; position: absolute; inset: 0; pointer-events: none; border: 1px solid #eaf3fd14; box-shadow: inset 0 0 130px #07090d60; }
.replay-stage-top { position: absolute; top: 20px; left: 24px; right: 24px; z-index: 2; display: flex; justify-content: space-between; gap: 12px; color: #8da2b5; }
.replay-circuit { position: absolute; inset: 10px 0 11px; width: 100%; height: calc(100% - 21px); overflow: visible; }
.replay-track-shadow, .replay-track-outer, .replay-drs-zone path { fill: none; stroke-linecap: round; stroke-linejoin: round; }
.replay-track-shadow { stroke: #79818a; stroke-width: 40; opacity: .25; filter: blur(12px); }
.replay-track-outer { stroke: #e0e2e6; stroke-width: 24; }
.replay-drs-zone path { stroke: #23d160; stroke-width: 24; }
.replay-drs-zone text { fill: #40e57b; font-family: "Race Titillium", Arial, sans-serif; font-size: 17px; font-weight: 900; letter-spacing: .12em; dominant-baseline: middle; paint-order: stroke; stroke: #15151e; stroke-width: 5px; }
.replay-car text { fill: #fff; font-family: "Race Titillium", Arial, sans-serif; font-size: 15px; font-weight: 900; letter-spacing: .04em; paint-order: stroke; stroke: #091017; stroke-width: 4px; }
.replay-corner circle { fill: #dfe8f0; stroke: #1d2833; stroke-width: 2; }
.replay-corner text { fill: #b3b7bf; font-family: "Race Titillium", Arial, sans-serif; font-size: 14px; font-weight: 700; letter-spacing: .06em; paint-order: stroke; stroke: #10151d; stroke-width: 4px; }
.replay-start-marker rect { fill: #fafafa; stroke: #151b20; stroke-width: 2; }
.replay-start-marker text { fill: #c1c3c8; font-family: "Race Titillium", Arial, sans-serif; font-size: 14px; font-weight: 700; letter-spacing: .07em; }
.replay-map-legend { position: absolute; bottom: 18px; left: 24px; right: 24px; z-index: 3; display: flex; align-items: center; gap: 18px; color: #94a5b4; }
.replay-map-legend span { display: inline-flex; align-items: center; gap: 7px; }
.replay-map-legend i { display: inline-block; width: 9px; height: 9px; border: 2px solid #c0cede; border-radius: 50%; }
.replay-map-legend .replay-drs-legend i { width: 14px; height: 4px; border: 0; border-radius: 0; background: #23d160; }
.replay-map-legend button { margin-left: auto; border: 1px solid #63718177; background: #121b24b8; padding: 7px 10px; color: #d0dae2; font-size: 9px; font-weight: 760; letter-spacing: .1em; cursor: pointer; }
.replay-map-legend button:hover { background: #263342; }
.replay-stage-event { position: absolute; top: 60px; right: 24px; z-index: 3; display: flex; align-items: center; gap: 9px; max-width: 220px; padding: 10px 12px; background: #121923d9; border-left: 3px solid #eb3545; box-shadow: 0 8px 20px #0005; }
.replay-stage-event span { font-size: 9px; letter-spacing: .12em; color: #a5b1bd; white-space: nowrap; }
.replay-stage-event strong { font-size: 11px; font-weight: 720; }
.replay-stage-event--vsc, .replay-stage-event--safety { border-left-color: #f5c445; }
.replay-stage-event--restart { border-left-color: #70ce9a; }
.replay-playback { padding: 20px 24px 17px; }
.replay-playback-top { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 13px; }
.replay-playback-top span { display: block; margin-bottom: 4px; color: var(--race-muted); }
.replay-playback-top strong { font-size: 28px; line-height: 1; letter-spacing: -.06em; font-variant-numeric: tabular-nums; }
.replay-lap-readout { text-align: right; }
.replay-lap-readout strong { font-size: 32px; }
.replay-lap-readout small { color: var(--race-muted); font-size: 16px; }
.replay-transport { display: flex; align-items: center; gap: 5px; }
.replay-transport button { display: grid; place-items: center; height: 38px; flex: none; cursor: pointer; }
.replay-play-button { width: 43px; border: none; background: var(--race-red); color: #fff; }
.replay-play-button:hover { filter: brightness(1.15); }
.replay-step-button, .replay-reset-button { width: 34px; border: none; background: transparent; color: var(--race-muted); }
.replay-step-button:hover, .replay-reset-button:hover { color: var(--race-ink); background: var(--race-bg); }
.replay-scrubber { position: relative; flex: 1; min-width: 60px; height: 35px; display: flex; align-items: center; }
.replay-scrubber input { width: 100%; height: 24px; margin: 0; appearance: none; background: transparent; cursor: pointer; position: relative; z-index: 2; }
.replay-scrubber input::-webkit-slider-runnable-track { height: 4px; border-radius: 0; background: linear-gradient(90deg, var(--race-red) 0 var(--replay-progress), var(--race-line) var(--replay-progress) 100%); }
.replay-scrubber input::-moz-range-track { height: 4px; background: var(--race-line); }
.replay-scrubber input::-moz-range-progress { height: 4px; background: var(--race-red); }
.replay-scrubber input::-webkit-slider-thumb { width: 14px; height: 14px; margin-top: -5px; appearance: none; border: 3px solid var(--race-surface); border-radius: 50%; background: var(--race-red); box-shadow: 0 0 0 1px var(--race-red); }
.replay-scrubber input::-moz-range-thumb { width: 10px; height: 10px; border: 3px solid var(--race-surface); border-radius: 50%; background: var(--race-red); box-shadow: 0 0 0 1px var(--race-red); }
.replay-timeline-ticks { position: absolute; top: 26px; inset-inline: 7px; height: 6px; pointer-events: none; }
.replay-timeline-ticks span { position: absolute; top: 0; width: 3px; height: 6px; background: var(--race-red); transform: translateX(-50%); }
.replay-timeline-ticks .is-safety, .replay-timeline-ticks .is-vsc { background: #d9ab25; }
.replay-speed { display: flex; align-items: center; margin-left: 3px; border: 1px solid var(--race-line); }
.replay-speed button { width: 30px; height: 30px; border: none; background: transparent; color: var(--race-muted); font-size: 10px; font-weight: 760; }
.replay-speed button[aria-pressed="true"] { color: #fff; background: var(--race-ink); }
.replay-playback > p { margin: 12px 0 0; color: var(--race-muted); font-size: 10px; line-height: 1.5; }
.replay-sidebar-head { display: flex; justify-content: space-between; padding: 19px 18px 14px; border-bottom: 1px solid var(--race-line); color: var(--race-muted); }
.replay-sidebar-head strong { color: var(--race-red); }
.replay-leaderboard { height: 506px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--race-line) transparent; }
.replay-driver-row { display: flex; align-items: center; width: 100%; height: 45px; padding: 0 13px; border: none; border-bottom: 1px solid var(--race-line); background: transparent; color: var(--race-ink); text-align: left; cursor: pointer; }
.replay-driver-row:hover { background: var(--race-bg); }
.replay-driver-row.is-selected { background: var(--race-red-soft); }
.replay-driver-row.is-out { opacity: .48; }
.replay-row-position { width: 28px; color: var(--race-muted); font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; }
.replay-row-color { width: 4px; height: 24px; margin-right: 10px; }
.replay-row-identity { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 0; }
.replay-row-identity strong { font-size: 13px; font-weight: 830; letter-spacing: .015em; }
.replay-row-identity small { overflow: hidden; color: var(--race-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.replay-row-change { color: var(--race-muted); font-size: 11px; font-weight: 780; font-variant-numeric: tabular-nums; }
.replay-row-change.is-up { color: #18895f; }
.replay-row-change.is-down { color: var(--race-red); }
.replay-driver-focus { padding: 18px; border-top: 2px solid var(--race-ink); }
.replay-driver-focus > p { display: flex; justify-content: space-between; margin: 0 0 12px; color: var(--race-muted); }
.replay-focus-head { display: flex; align-items: start; justify-content: space-between; gap: 8px; }
.replay-focus-head h3 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -.02em; }
.replay-focus-head span:not(.replay-focus-position) { color: var(--race-muted); font-size: 11px; }
.replay-focus-position { font-size: 35px; font-weight: 900; letter-spacing: -.07em; line-height: 1; }
.replay-focus-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; margin-top: 17px; }
.replay-focus-stats div { padding: 8px 0; border-top: 1px solid var(--race-line); }
.replay-focus-stats span { display: block; margin-bottom: 4px; color: var(--race-muted); font-size: 8px; }
.replay-focus-stats strong { font-size: 14px; font-variant-numeric: tabular-nums; }
.replay-chart { position: relative; margin-top: 9px; padding-left: 23px; }
.replay-chart svg { display: block; width: 100%; height: 74px; overflow: visible; }
.replay-chart line { stroke: var(--race-line); stroke-width: 1; stroke-dasharray: 3 4; }
.replay-chart-axis { position: absolute; top: 0; bottom: 16px; left: 0; display: flex; flex-direction: column; justify-content: space-between; color: var(--race-muted); font-size: 9px; }
.replay-chart-bottom { display: flex; justify-content: space-between; margin-top: 4px; color: var(--race-muted); font-size: 8px; font-weight: 750; letter-spacing: .12em; }
.replay-no-laps { margin: 16px 0 0; color: var(--race-muted); font-size: 11px; }
.replay-moments { margin-top: 34px; }
.replay-moments-title { display: flex; align-items: end; justify-content: space-between; gap: 15px; margin-bottom: 15px; }
.replay-moments-title h2 { margin: 0; font-size: clamp(26px, 2.6vw, 38px); font-weight: 700; letter-spacing: -.02em; }
.replay-moments-title > span { color: var(--race-muted); white-space: nowrap; }
.replay-moments-track { display: grid; grid-auto-columns: minmax(215px, 1fr); grid-auto-flow: column; gap: 10px; overflow-x: auto; padding-bottom: 10px; scrollbar-width: thin; }
.replay-moment { position: relative; min-height: 148px; padding: 15px 35px 15px 16px; border: 1px solid var(--race-line); border-top: 3px solid var(--race-line); background: var(--race-surface); color: var(--race-ink); text-align: left; cursor: pointer; }
.replay-moment:hover, .replay-moment.is-active { border-top-color: var(--race-red); background: var(--race-red-soft); }
.replay-moment > span:first-child { display: flex; align-items: center; gap: 8px; color: var(--race-red); }
.replay-moment i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.replay-moment i.is-safety, .replay-moment i.is-vsc { color: #dbac1b; }
.replay-moment strong { display: block; margin-top: 12px; font-size: 15px; letter-spacing: -.035em; }
.replay-moment p { margin: 7px 0 0; color: var(--race-muted); font-size: 10px; line-height: 1.45; }
.replay-moment-arrow { position: absolute; right: 13px; bottom: 13px; color: var(--race-red); font-size: 15px; }
.replay-sources { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 14px; margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--race-line); color: var(--race-muted); font-size: 9px; }
.replay-sources div { display: flex; align-items: center; gap: 12px; }
.replay-sources a { display: inline-flex; align-items: center; gap: 2px; color: inherit; text-decoration: none; }
.replay-sources a:hover { color: var(--race-red); }
@media (prefers-color-scheme: dark) {
.race-replay { --race-bg: #0d0d14; --race-surface: #15151e; --race-ink: #f2f2f4; --race-muted: #a6a6ae; --race-line: #34343e; --race-red: #ff322b; --race-red-soft: #371c20; }
.replay-map-panel, .replay-sidebar { box-shadow: none; }
.replay-row-change.is-up { color: #78d7a8; }
.replay-speed button[aria-pressed="true"] { background: #e5edf2; color: #111820; }
}
@media (max-width: 1050px) {
.replay-dashboard { grid-template-columns: minmax(0, 1fr) 300px; }
.replay-headline-stat { flex-basis: 210px; }
.replay-track-tag { display: none; }
.replay-map-stage { height: 640px; }
}
@media (max-width: 800px) {
.replay-wrap { padding-top: 28px; }
.replay-intro { align-items: start; gap: 14px; margin-bottom: 24px; }
.replay-headline-stat { flex-basis: 160px; padding-left: 13px; }
.replay-headline-stat strong { font-size: 40px; }
.replay-headline-stat p { font-size: 9px; }
.replay-dashboard { grid-template-columns: 1fr; }
.replay-map-stage { height: min(85vw, 650px); min-height: 500px; }
.replay-sidebar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, .85fr); }
.replay-sidebar-head { grid-column: 1 / -1; }
.replay-leaderboard { height: 510px; }
.replay-driver-focus { border-top: 0; border-left: 1px solid var(--race-line); }
}
@media (max-width: 580px) {
.replay-wrap { padding: 22px 12px 30px; }
.replay-intro { display: block; padding-inline: 5px; }
.replay-intro h1 { font-size: clamp(51px, 12vw, 70px); }
.replay-intro-copy > p { margin: 15px 0; font-size: 12px; }
.replay-intro-meta { gap: 8px 15px; font-size: 9px; }
.replay-headline-stat { display: none; }
.replay-panel-heading { padding: 16px; }
.replay-map-stage { height: 500px; min-height: 0; }
.replay-circuit { inset: 22px 0 5px; height: calc(100% - 27px); }
.replay-stage-top { top: 14px; left: 13px; right: 13px; font-size: 8px; }
.replay-stage-top span:last-child { display: none; }
.replay-stage-event { top: 38px; right: 13px; padding: 7px; max-width: 130px; flex-direction: column; align-items: start; gap: 2px; }
.replay-stage-event strong { font-size: 9px; }
.replay-map-legend { left: 13px; right: 13px; bottom: 10px; gap: 6px; font-size: 8px; }
.replay-map-legend span:nth-child(2) { display: none; }
.replay-map-legend button { font-size: 8px; }
.replay-playback { padding: 15px 12px; }
.replay-transport { gap: 0; }
.replay-play-button { width: 38px; }
.replay-step-button { width: 27px; }
.replay-reset-button { display: none !important; }
.replay-speed { margin-left: 5px; }
.replay-speed button { width: 25px; }
.replay-sidebar { display: block; }
.replay-leaderboard { height: 368px; }
.replay-driver-focus { border-left: 0; border-top: 2px solid var(--race-ink); }
.replay-moments-title { align-items: start; }
.replay-moments-title > span { display: none; }
.replay-moments-track { grid-auto-columns: minmax(215px, 72%); }
.replay-sources { line-height: 1.7; }
}
@media (prefers-reduced-motion: reduce) {
.replay-car { transition: none; }
}
+1
View File
@@ -33,6 +33,7 @@ export function Footer() {
<Link href="/publications">Research</Link>
<Link href="/articles">In the press</Link>
<Link href="/coffee">Coffee map</Link>
<Link href="/race-replay">Race Replay</Link>
<Link href="/travel">Trips & interests</Link>
<Link href="/october">October</Link>
<Link href="/cv">Résumé & CV</Link>
+28 -33
View File
@@ -1,39 +1,34 @@
import Image from "next/image";
import localFont from "next/font/local";
const geologica = localFont({
src: "../../public/portfolio/fonts/geologica.woff2",
display: "swap",
weight: "100 900",
});
import { ArrowUpRight, ImagePlus, Images } from "lucide-react";
export function ManyanglesPreview() {
return (
<div className="manyangles-preview">
<Image
src="/trips/engr290/insta290.jpg"
alt="Seans study-abroad group gathered for dinner in Paris"
fill
sizes="(max-width: 700px) 100vw, 50vw"
/>
<div className="manyangles-lockup">
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
/>
<circle cx="21" cy="11" r="2.25" fill="currentColor" />
<path
d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={geologica.className}>Manyangles</span>
<div
className="manyangles-preview"
role="img"
aria-label="Illustration of a generic Manyangles shared album with guest photo tiles"
>
<div className="manyangles-demo" aria-hidden="true">
<div className="manyangles-demo-bar">
<span className="manyangles-demo-bar-title"><Images size={15} /> Shared album</span>
<span className="manyangles-demo-share">Share album <ArrowUpRight size={13} /></span>
</div>
<div className="manyangles-demo-content">
<div className="manyangles-demo-intro">
<span>GUEST GALLERY</span>
<strong>Every angle, in one place.</strong>
<p>A shared home for the moments everyone captured.</p>
</div>
<div className="manyangles-demo-photos">
<div className="manyangles-demo-photo manyangles-demo-photo--one"><span /></div>
<div className="manyangles-demo-photo manyangles-demo-photo--two"><span /></div>
<div className="manyangles-demo-photo manyangles-demo-photo--three"><span /></div>
<div className="manyangles-demo-photo manyangles-demo-photo--four"><span /></div>
</div>
<div className="manyangles-demo-bottom">
<span><ImagePlus size={15} /> Photos from everyone, together</span>
<span>Original quality</span>
</div>
</div>
</div>
</div>
);
+2 -1
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Menu, X, Home, Book, Newspaper, FolderGit2, BookOpenText, Briefcase, Plane, FileText } from "lucide-react";
import { ChevronDown, Menu, X, Home, Book, Newspaper, FolderGit2, BookOpenText, Briefcase, Plane, FileText, FlagTriangleRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Button } from "~/components/ui/button";
import {
@@ -35,6 +35,7 @@ const mobilePages = [
{ href: "/publications", label: "Publications", icon: BookOpenText },
{ href: "/experience", label: "Experience", icon: Briefcase },
{ href: "/travel", label: "Travel", icon: Plane },
{ href: "/race-replay", label: "Race Replay", icon: FlagTriangleRight },
{ href: "/cv", label: "Résumé & CV", icon: FileText },
];
+47
View File
@@ -0,0 +1,47 @@
import Image from "next/image";
import localFont from "next/font/local";
const geologica = localFont({
src: "../../public/portfolio/fonts/geologica.woff2",
display: "swap",
weight: "100 900",
});
export function ProjectBrand({ name }: { name: string }) {
if (name === "Racetix" || name === "beenvoice") {
return (
<span className={`project-brand project-brand--${name.toLowerCase()}`}>
<Image
src={name === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
alt={name}
width={name === "Racetix" ? 1614 : 2970}
height={name === "Racetix" ? 304 : 436}
/>
</span>
);
}
if (name === "Puter") {
return (
<span className="project-brand project-brand--puter">
<Image src="/portfolio/puter/icon.png" alt="" width={30} height={30} />
<span>Puter</span>
</span>
);
}
if (name === "Manyangles") {
return (
<span className={`project-brand project-brand--manyangles ${geologica.className}`}>
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<circle cx="21" cy="11" r="2.25" fill="currentColor" />
<path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>Manyangles</span>
</span>
);
}
return <span>{name}</span>;
}
+5 -11
View File
@@ -6,6 +6,8 @@ import Link from "next/link";
import { ArrowUpRight, Search } from "lucide-react";
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
import { PuterPreview } from "~/components/PuterPreview";
import { ProjectBrand } from "~/components/ProjectBrand";
import { JusticeParagraph } from "~/components/JusticeParagraph";
import type { Project } from "~/lib/data";
@@ -32,6 +34,8 @@ function ProjectCard({
{project.image ? (
project.title === "beenvoice" ? (
<BeenvoicePreview priority={index < 2} />
) : project.title === "Puter" ? (
<PuterPreview interactive={!compact} priority={index < 2} />
) : project.title === "Manyangles" ? (
<ManyanglesPreview />
) : (
@@ -46,20 +50,10 @@ function ProjectCard({
) : (
<span aria-hidden="true">{project.title}</span>
)}
{(project.title === "Racetix" || project.title === "beenvoice") && (
<div className={`studio-product-logo ${project.title === "beenvoice" ? "beenvoice-logo" : ""}`}>
<Image
src={project.title === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
alt=""
width={project.title === "Racetix" ? 1614 : 2970}
height={project.title === "Racetix" ? 304 : 436}
/>
</div>
)}
</div>
<div className="archive-body">
<div className="archive-card-heading">
<h2>{project.title}</h2>
<h2><ProjectBrand name={project.title} /></h2>
<span aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
</div>
<p>{project.description}</p>
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useState } from "react";
import Image from "next/image";
const screenshots = [
{ src: "/portfolio/puter/01-processes-light.png", label: "Processes", alt: "Puter showing grouped processes and system activity in light mode" },
{ src: "/portfolio/puter/02-cpu-light.png", label: "CPU", alt: "Puter CPU performance summary with a live utilization graph" },
{ src: "/portfolio/puter/03-cpu-cores-light.png", label: "Cores", alt: "Puter showing individual CPU core utilization graphs" },
{ src: "/portfolio/puter/04-memory-light.png", label: "Memory", alt: "Puter memory performance and pressure overview" },
{ src: "/portfolio/puter/05-processes-dark.png", label: "Dark mode", alt: "Puter grouped process view in dark mode" },
] as const;
export function PuterPreview({ interactive = false, priority = false }: { interactive?: boolean; priority?: boolean }) {
const [active, setActive] = useState(0);
const screenshot = screenshots[active]!;
return (
<div className={`puter-preview ${active === 4 ? "puter-preview--dark" : ""} ${interactive ? "puter-preview--interactive" : ""}`}>
<Image
className="puter-preview-screen"
src={screenshot.src}
alt={screenshot.alt}
fill
sizes="(max-width: 700px) 100vw, 50vw"
priority={priority}
/>
{interactive && (
<div className="puter-preview-controls" aria-label="Puter screenshots">
{screenshots.map((item, index) => (
<button
key={item.src}
type="button"
aria-label={`Show ${item.label} screenshot`}
aria-pressed={active === index}
onClick={() => setActive(index)}
>
{item.label}
</button>
))}
</div>
)}
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowUpRight, ChevronLeft, ChevronRight, CloudRain, FlagTriangleRight, Pause, Play, RotateCcw } from "lucide-react";
import rawData from "~/lib/race-replay-data.json";
type Driver = {
id: string;
number: number;
code: string;
name: string;
team: string;
color: string;
grid: number;
finish: number;
status: string;
};
type Timing = { id: string; position: number; seconds: number };
type RaceData = {
race: { title: string; year: number; date: string; circuit: string; location: string; laps: number };
drivers: Driver[];
laps: { number: number; timings: Timing[] }[];
pitStops: { id: string; lap: number; seconds: number }[];
events: { lap: number; kind: string; title: string; description: string }[];
track: [number, number][];
corners: { number: number; x: number; y: number }[];
drsZones: { number: number; track: [number, number][] }[];
sources: Record<string, string>;
};
const data = rawData as unknown as RaceData;
const driverById = new Map(data.drivers.map((driver) => [driver.id, driver]));
const trackSegments = data.track.slice(1).map((point, index) => Math.hypot(point[0] - data.track[index]![0], point[1] - data.track[index]![1]));
const trackDistances = [0];
for (const segment of trackSegments) trackDistances.push(trackDistances.at(-1)! + segment);
const trackLength = trackDistances.at(-1)!;
const trackPath = data.track.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" ");
const drsPaths = data.drsZones.map((zone) => ({
number: zone.number,
path: zone.track.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" "),
middle: zone.track[Math.floor(zone.track.length / 2)]!,
}));
const elapsedByDriver = new Map<string, number[]>();
for (const driver of data.drivers) elapsedByDriver.set(driver.id, [0]);
for (const lap of data.laps) {
for (const timing of lap.timings) {
const elapsed = elapsedByDriver.get(timing.id)!;
elapsed.push(Math.round((elapsed.at(-1)! + timing.seconds) * 1000) / 1000);
}
}
const leaderCrossings = [0];
for (let lap = 1; lap <= data.race.laps; lap++) {
leaderCrossings.push(Math.min(...[...elapsedByDriver.values()].map((times) => times[lap] ?? Infinity)));
}
function pointOnTrack(progress: number): [number, number] {
const distance = (((progress % 1) + 1) % 1) * trackLength;
let low = 0;
let high = trackSegments.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (trackDistances[middle + 1]! < distance) low = middle + 1;
else high = middle;
}
const fraction = (distance - trackDistances[low]!) / (trackSegments[low] ?? 1);
const start = data.track[low]!;
const end = data.track[low + 1]!;
return [start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction];
}
function formatTime(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${hours ? `${hours}:` : ""}${String(minutes).padStart(hours ? 2 : 1, "0")}:${String(secs).padStart(2, "0")}`;
}
function formatLapTime(seconds: number) {
return `${Math.floor(seconds / 60)}:${(seconds % 60).toFixed(3).padStart(6, "0")}`;
}
function inactiveLabel(driver: Driver) {
if (driver.status === "Did not start") return "DNS";
if (driver.status === "Disqualified") return "DSQ";
return "OUT";
}
function positionAtLap(driverId: string, lap: number) {
if (lap === 0) {
const driver = driverById.get(driverId);
return driver?.status === "Did not start" ? null : driver?.grid ?? null;
}
return data.laps[lap - 1]?.timings.find((timing) => timing.id === driverId)?.position ?? null;
}
function carProgress(driverId: string, seconds: number, grid: number) {
const times = elapsedByDriver.get(driverId)!;
if (seconds > times.at(-1)! + 2) return null;
let low = 0;
let high = times.length - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if (times[middle]! <= seconds) low = middle;
else high = middle - 1;
}
if (low >= times.length - 1) return seconds >= times.at(-1)! ? 0 : null;
const fraction = (seconds - times[low]!) / (times[low + 1]! - times[low]!);
const gridOffset = Math.max(0, 1 - seconds / 12) * grid * 0.008;
return fraction - gridOffset;
}
function PositionChart({ driver }: { driver: Driver }) {
const positions = [driver.grid, ...data.laps.map((lap) => lap.timings.find((timing) => timing.id === driver.id)?.position).filter((position): position is number => position !== undefined)];
if (positions.length === 1) return <p className="replay-no-laps">No race laps recorded.</p>;
const points = positions.map((position, index) => `${(index / Math.max(positions.length - 1, 1)) * 284 + 8},${((position - 1) / 19) * 73 + 8}`).join(" ");
return <div className="replay-chart" aria-label={`${driver.name} race position by lap, from P${driver.grid} to P${driver.finish}`} role="img">
<div className="replay-chart-axis"><span>P1</span><span>P20</span></div>
<svg viewBox="0 0 300 90" preserveAspectRatio="none" aria-hidden="true">
<line x1="8" x2="292" y1="8" y2="8" />
<line x1="8" x2="292" y1="81" y2="81" />
<polyline points={points} fill="none" stroke={driver.color} strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div className="replay-chart-bottom"><span>START</span><span>FINISH</span></div>
</div>;
}
export function RaceReplay() {
const [progress, setProgress] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(1);
const [selectedId, setSelectedId] = useState("max_verstappen");
const [showAllCars, setShowAllCars] = useState(true);
const progressRef = useRef(0);
useEffect(() => {
if (!playing) return;
let frame = 0;
let last = 0;
function tick(now: number) {
if (last && now - last >= 35) {
const elapsed = Math.min(now - last, 100);
progressRef.current = Math.min(data.race.laps, progressRef.current + elapsed / 1000 * 0.8 * speed);
setProgress(progressRef.current);
if (progressRef.current >= data.race.laps) setPlaying(false);
last = now;
} else if (!last) last = now;
frame = requestAnimationFrame(tick);
}
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [playing, speed]);
const completedLap = Math.min(data.race.laps, Math.floor(progress));
const displayLap = Math.min(data.race.laps, completedLap + (progress < data.race.laps ? 1 : 0));
const lapFraction = progress - completedLap;
const elapsed = completedLap === data.race.laps
? leaderCrossings.at(-1)!
: leaderCrossings[completedLap]! + (leaderCrossings[completedLap + 1]! - leaderCrossings[completedLap]!) * lapFraction;
const selected = driverById.get(selectedId)!;
const selectedStatus = inactiveLabel(selected);
const selectedPosition = positionAtLap(selectedId, completedLap);
const activeEvent = [...data.events].reverse().find((event) => event.lap <= displayLap);
const lapTimings = completedLap ? data.laps[completedLap - 1]!.timings : [];
const timingById = new Map(lapTimings.map((timing) => [timing.id, timing]));
const orderedDrivers = [...data.drivers].sort((a, b) => {
if (!completedLap && a.status === "Did not start") return b.status === "Did not start" ? a.grid - b.grid : 1;
if (!completedLap && b.status === "Did not start") return -1;
const aPosition = timingById.get(a.id)?.position;
const bPosition = timingById.get(b.id)?.position;
if (aPosition !== undefined && bPosition !== undefined) return aPosition - bPosition;
if (aPosition !== undefined) return -1;
if (bPosition !== undefined) return 1;
return completedLap ? a.finish - b.finish : a.grid - b.grid;
});
const visibleCars = useMemo(() => data.drivers.filter((driver) => driver.status !== "Did not start" && (showAllCars || driver.id === selectedId)), [showAllCars, selectedId]);
const selectedPits = data.pitStops.filter((pit) => pit.id === selectedId);
const validLaps = data.laps.flatMap((lap) => lap.timings.filter((timing) => timing.id === selectedId && timing.seconds < 200).map((timing) => timing.seconds));
const bestLap = validLaps.length ? Math.min(...validLaps) : null;
function jumpTo(lap: number) {
setPlaying(false);
progressRef.current = Math.max(0, Math.min(data.race.laps, lap));
setProgress(progressRef.current);
}
return <div className="race-replay">
<div className="replay-wrap">
<header className="replay-intro">
<div className="replay-intro-copy">
<div className="replay-eyebrow"><span className="replay-live-dot" /> RACE REPLAY <span className="replay-eyebrow-divider">/</span> 2024 ARCHIVE <span className="replay-eyebrow-divider">/</span> ROUND 21</div>
<h1>São Paulo <em>24</em><span className="replay-title-period">.</span></h1>
<p>One very wet Sunday at Interlagos. Scrub the race, follow a driver, and watch the order change lap by lap.</p>
<div className="replay-intro-meta"><span><FlagTriangleRight size={15} /> INTERLAGOS, BRAZIL</span><span><CloudRain size={16} /> WET RACE</span><span>03 NOV 2024</span></div>
</div>
<div className="replay-headline-stat"><span>THE COMEBACK</span><strong><small>P</small>17 <i></i> <small>P</small>1</strong><p>Max Verstappen · Red Bull Racing</p></div>
</header>
<div className="replay-dashboard">
<section className="replay-map-panel" aria-labelledby="map-title">
<div className="replay-panel-heading"><div><p>01 / CIRCUIT VIEW</p><h2 id="map-title">Autódromo José Carlos Pace</h2></div><span className="replay-track-tag">4.309 KM · ANTI-CLOCKWISE</span></div>
<div className="replay-map-stage">
<div className="replay-stage-top"><span>INTERLAGOS / SÃO PAULO</span><span>24° 42 S &nbsp; 46° 41 W</span></div>
<svg className="replay-circuit" viewBox="210 0 580 740" role="img" aria-label={`FIA-oriented Interlagos circuit map with two DRS zones and cars at lap ${displayLap}`}>
<defs><filter id="car-glow" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="8" /></filter></defs>
<path d={trackPath} className="replay-track-shadow" />
<path d={trackPath} className="replay-track-outer" />
{drsPaths.map((zone) => <g key={zone.number} className="replay-drs-zone">
<path d={zone.path} />
<text x={zone.middle[0] + (zone.number === 1 ? 29 : -29)} y={zone.middle[1]} textAnchor={zone.number === 1 ? "start" : "end"}>DRS {zone.number}</text>
</g>)}
{data.corners.filter((corner) => [1, 3, 4, 6, 8, 12, 13, 15].includes(corner.number)).map((corner) => <g key={corner.number} className="replay-corner" transform={`translate(${corner.x} ${corner.y})`}><circle r="4" /><text x="12" y="-10">T{corner.number}</text></g>)}
{visibleCars.map((driver) => {
const onTrack = carProgress(driver.id, elapsed, driver.grid);
if (onTrack === null) return null;
const [x, y] = pointOnTrack(onTrack);
const focused = driver.id === selectedId;
return <g key={driver.id} transform={`translate(${x} ${y})`} className={focused ? "replay-car is-focused" : "replay-car"}>
{focused && <circle r="19" fill={driver.color} opacity=".35" filter="url(#car-glow)" />}
<circle r={focused ? 9 : 5.5} fill={driver.color} stroke="#15151e" strokeWidth={focused ? 2 : 1.5} />
{focused && <text y="-23" textAnchor="middle">{driver.code}</text>}
</g>;
})}
<g transform={`translate(${data.track[0]![0]} ${data.track[0]![1]})`} className="replay-start-marker"><rect x="-3" y="-14" width="6" height="28" /><text x="20" y="-15">START / FINISH</text></g>
</svg>
<div className="replay-map-legend"><span><i /> SELECTED DRIVER</span><span className="replay-drs-legend"><i /> DRS ZONES · DISABLED IN WET RACE</span><button type="button" onClick={() => setShowAllCars((value) => !value)} aria-pressed={showAllCars}>{showAllCars ? "SHOWING ALL CARS" : "SHOW ALL CARS"}</button></div>
{activeEvent && <div className={`replay-stage-event replay-stage-event--${activeEvent.kind}`}><span>LAP {String(activeEvent.lap).padStart(2, "0")}</span><strong>{activeEvent.title}</strong></div>}
</div>
<div className="replay-playback">
<div className="replay-playback-top"><div><span>RACE CLOCK</span><strong>{formatTime(elapsed)}</strong></div><div className="replay-lap-readout"><span>CURRENT LAP</span><strong>{String(displayLap).padStart(2, "0")} <small>/ {data.race.laps}</small></strong></div></div>
<div className="replay-transport">
<button className="replay-play-button" type="button" onClick={() => { if (progress >= data.race.laps) { progressRef.current = 0; setProgress(0); } setPlaying((value) => !value); }} aria-label={playing ? "Pause replay" : "Play replay"}>{playing ? <Pause fill="currentColor" size={20} /> : <Play fill="currentColor" size={20} />}</button>
<button className="replay-step-button" type="button" onClick={() => jumpTo(Math.floor(progress) - 1)} aria-label="Previous lap"><ChevronLeft size={21} /></button>
<div className="replay-scrubber"><input type="range" min="0" max={data.race.laps} step="0.05" value={progress} onChange={(event) => { setPlaying(false); progressRef.current = Number(event.target.value); setProgress(progressRef.current); }} aria-label="Replay lap" style={{ "--replay-progress": `${progress / data.race.laps * 100}%` } as React.CSSProperties} /><div className="replay-timeline-ticks" aria-hidden="true">{data.events.map((event) => <span key={`${event.lap}-${event.kind}`} className={`is-${event.kind}`} style={{ left: `${event.lap / data.race.laps * 100}%` }} />)}</div></div>
<button className="replay-step-button" type="button" onClick={() => jumpTo(Math.floor(progress) + 1)} aria-label="Next lap"><ChevronRight size={21} /></button>
<button className="replay-reset-button" type="button" onClick={() => jumpTo(0)} aria-label="Reset replay"><RotateCcw size={18} /></button>
<div className="replay-speed" aria-label="Playback speed">{[1, 2, 4].map((option) => <button key={option} type="button" aria-pressed={speed === option} onClick={() => setSpeed(option)}>{option}×</button>)}</div>
</div>
<p>Cars are interpolated from recorded lap times on an OpenF1 circuit trace. Order updates at each timing line; dots are not live GPS.</p>
</div>
</section>
<aside className="replay-sidebar" aria-label="Race classification and driver details">
<div className="replay-sidebar-head"><span>02 / LIVE ORDER</span><strong>LAP {String(displayLap).padStart(2, "0")}</strong></div>
<div className="replay-leaderboard">
{orderedDrivers.map((driver) => {
const position = driver.status === "Did not start" ? undefined : completedLap ? timingById.get(driver.id)?.position : driver.grid;
const change = position ? driver.grid - position : 0;
return <button key={driver.id} type="button" className={`replay-driver-row ${driver.id === selectedId ? "is-selected" : ""} ${!position ? "is-out" : ""}`} onClick={() => setSelectedId(driver.id)} aria-pressed={driver.id === selectedId}>
<span className="replay-row-position">{position ? String(position).padStart(2, "0") : "—"}</span>
<span className="replay-row-color" style={{ backgroundColor: driver.color }} />
<span className="replay-row-identity"><strong>{driver.code}</strong><small>{driver.name}</small></span>
<span className={`replay-row-change ${change > 0 ? "is-up" : change < 0 ? "is-down" : ""}`}>{position ? (change > 0 ? `+${change}` : change) : inactiveLabel(driver)}</span>
</button>;
})}
</div>
<div className="replay-driver-focus"><p>DRIVER FOCUS <span>#{selected.number}</span></p><div className="replay-focus-head"><div><h3>{selected.name}</h3><span>{selected.team}</span></div><span className="replay-focus-position" style={{ color: selected.color }}>{selectedPosition ? `P${selectedPosition}` : selectedStatus}</span></div><div className="replay-focus-stats"><div><span>GRID</span><strong>P{selected.grid}</strong></div><div><span>NOW</span><strong>{selectedPosition ? `P${selectedPosition}` : selectedStatus}</strong></div><div><span>BEST LAP</span><strong>{bestLap === null ? "—" : formatLapTime(bestLap)}</strong></div><div><span>PIT STOPS</span><strong>{selectedPits.length}</strong></div></div><PositionChart driver={selected} /></div>
</aside>
</div>
<section className="replay-moments" aria-labelledby="moments-title"><div className="replay-moments-title"><div><p>03 / RACE CONTROL</p><h2 id="moments-title">The moments that moved it.</h2></div><span>SELECT A MOMENT TO JUMP</span></div><div className="replay-moments-track">{data.events.map((event) => <button type="button" key={`${event.lap}-${event.title}`} className={`replay-moment ${displayLap === event.lap ? "is-active" : ""}`} onClick={() => jumpTo(event.kind === "finish" ? data.race.laps : event.lap - 0.01)}><span>LAP {String(event.lap).padStart(2, "0")} <i className={`is-${event.kind}`} /></span><strong>{event.title}</strong><p>{event.description}</p><span className="replay-moment-arrow"></span></button>)}</div></section>
<div className="replay-sources"><span>RACE REPLAY / AN INDEPENDENT EXPERIMENT BY SEAN OCONNOR</span><div>DATA: <a href={data.sources.laps} target="_blank" rel="noreferrer">JOLPICA <ArrowUpRight size={12} /></a> <a href={data.sources.track} target="_blank" rel="noreferrer">OPENF1 <ArrowUpRight size={12} /></a> <a href={data.sources.corners} target="_blank" rel="noreferrer">MULTIVIEWER <ArrowUpRight size={12} /></a> <a href={data.sources.circuitMap} target="_blank" rel="noreferrer">FIA MAP <ArrowUpRight size={12} /></a></div></div>
</div>
</div>;
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function SiteFrame({ children }: React.PropsWithChildren) {
<>
<main
id="main-content"
className={pathname === "/coffee" ? "coffee-site-main" : pathname === "/" ? "site-main" : "site-main interior-page"}
className={pathname === "/coffee" ? "coffee-site-main" : pathname === "/race-replay" ? "race-site-main" : pathname === "/" ? "site-main" : "site-main interior-page"}
>
{children}
</main>
+3 -3
View File
@@ -482,7 +482,7 @@ export const projects: Project[] = [
tags: ["Next.js", "TypeScript", "S3", "Image processing"],
websiteLink: "https://ma.hadlock.tech",
image: "/portfolio/manyangles.svg",
imageAlt: "Manyangles event photo sharing",
imageAlt: "Manyangles shared albums",
featured: true,
},
{
@@ -521,9 +521,9 @@ export const projects: Project[] = [
"Hardware Telemetry",
],
gitLink: "https://git.soconnor.dev/soconnor/Puter",
image: "/images/puter.jpg",
image: "/portfolio/puter/01-processes-light.png",
imageAlt:
"Puter's macOS performance dashboard showing CPU, memory, and disk utilization",
"Puter's macOS process manager with live CPU, memory, disk, and network activity",
featured: true,
},
{
File diff suppressed because one or more lines are too long
+1
View File
@@ -20,6 +20,7 @@ export const siteGroups = [
label: "Beyond work",
items: [
{ href: "/coffee", label: "Coffee map" },
{ href: "/race-replay", label: "Race Replay" },
{ href: "/travel", label: "Trips & interests" },
{ href: "/october", label: "October, forever" },
],
+167 -76
View File
@@ -489,17 +489,6 @@ button {
height: 100% !important;
object-fit: cover;
}
.studio-product-logo {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #0004;
}
.studio-product-logo img {
width: 43%;
height: auto;
}
.studio-project-title {
display: flex;
align-items: center;
@@ -1002,33 +991,119 @@ button {
position: absolute;
inset: 0;
overflow: hidden;
padding: 4.5%;
background: radial-gradient(circle at 85% 10%, #313d65 0, #1d2339 42%, #151923 100%);
}
.manyangles-preview > img {
object-fit: cover;
object-position: 50% 40%;
}
.manyangles-lockup {
position: absolute;
inset: 0;
.manyangles-demo {
display: flex;
align-items: flex-end;
gap: 10px;
padding: 6%;
color: white;
background: linear-gradient(transparent 45%, #101e33b3);
flex-direction: column;
height: 100%;
overflow: hidden;
border: 1px solid #ffffff22;
border-radius: 10px;
background: #1b1e2a;
box-shadow: 0 14px 35px #05091460;
color: #f5f5f7;
font-family: Arial, Helvetica, sans-serif;
}
.manyangles-lockup svg {
width: 9%;
max-width: 42px;
flex-shrink: 0;
.manyangles-demo-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 13%;
padding: 0 4%;
border-bottom: 1px solid #ffffff1a;
font-size: clamp(8px, .9vw, 12px);
}
.manyangles-lockup span {
.manyangles-demo-bar-title,
.manyangles-demo-share,
.manyangles-demo-bottom > span:first-child {
display: inline-flex;
align-items: center;
gap: 7px;
}
.manyangles-demo-bar-title { font-weight: 700; }
.manyangles-demo-share { color: #b8c6ff; }
.manyangles-demo-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 5% 6% 4%;
}
.manyangles-demo-intro > span {
display: block;
margin-bottom: 2.5%;
color: #a7b7f3;
font-size: clamp(7px, .7vw, 10px);
font-weight: 700;
font-variation-settings: "SHRP" 70;
font-size: clamp(24px, 3vw, 36px);
letter-spacing: -0.04em;
letter-spacing: .18em;
}
.manyangles-demo-intro strong {
display: block;
font-size: clamp(17px, 2.1vw, 31px);
letter-spacing: -.035em;
line-height: 1.1;
}
.manyangles-demo-intro p {
margin: 2.2% 0 0;
color: #b9bdca;
font-size: clamp(8px, .9vw, 12px);
line-height: 1.3;
}
.manyangles-demo-photos {
display: grid;
flex: 1;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 2%;
min-height: 0;
margin-top: 5%;
}
.manyangles-demo-photo {
position: relative;
overflow: hidden;
border-radius: 5px;
background: linear-gradient(165deg, var(--tile-sky), var(--tile-horizon));
}
.manyangles-demo-photo::before,
.manyangles-demo-photo::after {
position: absolute;
content: "";
inset: auto -20% -12%;
height: 65%;
border-radius: 48% 52% 0 0;
background: var(--tile-hill);
transform: rotate(-11deg);
}
.manyangles-demo-photo::after {
inset: auto -25% -35%;
height: 70%;
background: var(--tile-foreground);
transform: rotate(14deg);
}
.manyangles-demo-photo span {
position: absolute;
top: 18%;
right: 19%;
width: 17%;
aspect-ratio: 1;
border-radius: 50%;
background: var(--tile-sun);
box-shadow: 0 0 20px 5px var(--tile-sun);
}
.manyangles-demo-photo--one { --tile-sky: #f0baa3; --tile-horizon: #e5d8c6; --tile-hill: #769b99; --tile-foreground: #3d646f; --tile-sun: #fff0b8; }
.manyangles-demo-photo--two { --tile-sky: #a7bbd9; --tile-horizon: #d8d2bb; --tile-hill: #81948c; --tile-foreground: #576d6d; --tile-sun: #f8e0ae; }
.manyangles-demo-photo--three { --tile-sky: #d9b5be; --tile-horizon: #e8cdb6; --tile-hill: #9f998c; --tile-foreground: #6b797a; --tile-sun: #fff2ce; }
.manyangles-demo-photo--four { --tile-sky: #9eb6cf; --tile-horizon: #cddbd5; --tile-hill: #6c958c; --tile-foreground: #3f6c70; --tile-sun: #f6e9bf; }
.manyangles-demo-bottom {
display: flex;
justify-content: space-between;
gap: 10px;
margin-top: 4%;
color: #b9bdca;
font-size: clamp(7px, .75vw, 10px);
}
.type-study-controls {
padding: 22px 0;
@@ -2745,17 +2820,6 @@ body {
}
}
/* Shared product branding over the existing product imagery. */
.studio-product-logo {
background: radial-gradient(ellipse at center, #071c2660, #071c261c 75%);
}
.studio-product-logo img {
filter: drop-shadow(0 2px 3px #0009) drop-shadow(0 8px 18px #0008);
}
.beenvoice-logo img {
width: 55%;
filter: brightness(0) invert(1) drop-shadow(0 2px 3px #0009) drop-shadow(0 8px 18px #0008);
}
.mobile-menu-label,
.primary-links .header-dropdown .mobile-menu-link {
display: none;
@@ -2794,18 +2858,6 @@ body {
.nav-inner, .primary-links { gap: 8px; }
}
/* Brand marks share a centered, shadowed treatment. */
.manyangles-lockup {
inset: 0;
justify-content: center;
align-items: center;
padding: 0;
background: radial-gradient(ellipse at center, #071c2660, #071c261c 75%);
}
.manyangles-lockup svg,
.manyangles-lockup span {
filter: drop-shadow(0 2px 3px #0009) drop-shadow(0 8px 18px #0008);
}
.hadlock-brand img { width: min(100%, 340px); height: auto; }
/* Personal postcards: photographs, editorial captions, a playful calendar. */
@@ -2942,28 +2994,17 @@ body {
--accent-foreground: 199 47% 20%;
}
/* One proportional logo height, independent of each wordmark's width. */
.studio-project-image {
container-type: inline-size;
--project-logo-height: 8cqw;
}
.studio-product-logo img,
.beenvoice-logo img {
width: auto;
height: var(--project-logo-height);
max-width: 84%;
object-fit: contain;
}
.manyangles-lockup { gap: 2cqw; }
.manyangles-lockup svg {
width: var(--project-logo-height);
height: var(--project-logo-height);
max-width: none;
}
.manyangles-lockup span {
/* Match visible capital height to the SVG wordmarks. */
font-size: 10.5cqw;
line-height: 1;
/* Brand identity sits in the caption below each unobstructed project image. */
.project-brand { display: inline-flex; align-items: center; gap: 8px; min-height: 30px; max-width: 100%; line-height: 1; }
.project-brand > img { display: block; flex: none; width: auto; height: 27px; max-width: min(45vw, 190px); object-fit: contain; }
.project-brand--racetix > img { filter: brightness(0); }
.project-brand--beenvoice > img { height: 25px; filter: brightness(0); }
.project-brand--puter > img { width: 30px; height: 30px; border-radius: 7px; }
.project-brand--manyangles { font-size: 24px; font-weight: 700; letter-spacing: -.04em; }
.project-brand--manyangles svg { flex: none; width: 29px; height: 29px; }
@media (prefers-color-scheme: dark) {
.project-brand--racetix > img,
.project-brand--beenvoice > img { filter: brightness(0) invert(1); }
}
/* Contact and footer use a single shared backdrop. */
@@ -2977,6 +3018,56 @@ body {
.archive-image > img { object-fit: contain; object-position: center; }
.archive-image--photo > img { object-fit: cover; }
/* Puter's real macOS captures, paired with its compiled app icon. */
.puter .studio-project-image { background: #d7e8df; }
.puter-preview {
position: absolute;
inset: 0;
overflow: hidden;
background: radial-gradient(circle at 80% 5%, #eff9f1, #c6dfd0 85%);
}
.puter-preview--dark { background: radial-gradient(circle at 80% 5%, #31574a, #11332a 85%); }
.puter-preview .puter-preview-screen,
.archive-image .puter-preview .puter-preview-screen {
position: absolute !important;
inset: 7% 5% auto !important;
width: 90% !important;
height: 86% !important;
margin: 0 !important;
border: 1px solid #28564222;
border-radius: 9px;
object-fit: cover !important;
object-position: top center;
box-shadow: 0 15px 28px #15352830;
}
.puter-preview--interactive .puter-preview-screen,
.archive-image .puter-preview--interactive .puter-preview-screen { height: 76% !important; }
.puter-preview-controls {
position: absolute;
right: 4%;
bottom: 4%;
left: 4%;
display: flex;
justify-content: center;
gap: 5px;
overflow-x: auto;
scrollbar-width: none;
}
.puter-preview-controls button {
flex: none;
padding: 4px 8px;
border: 1px solid #52756755;
border-radius: 999px;
background: #ffffffb8;
color: #174634;
font-size: 10px;
line-height: 1.2;
cursor: pointer;
}
.puter-preview-controls button[aria-pressed="true"] { background: #0c5d40; border-color: #0c5d40; color: #fff; }
.puter-preview--dark .puter-preview-controls button { background: #173a2f; color: #effcf3; }
.puter-preview--dark .puter-preview-controls button[aria-pressed="true"] { background: #c0efd3; color: #0a3021; }
/* September 10's full-width mobile header panel. */
.restored-header-menu {
width: 100vw;