initial commit

This commit is contained in:
Josh Bairstow 2026-06-01 19:35:56 +10:00
commit 3ba76b4f02
90 changed files with 12507 additions and 0 deletions

View file

@ -0,0 +1,9 @@
---
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<p class:list={["eyebrow", className]}><slot /></p>

View file

@ -0,0 +1,5 @@
---
// The ochre arrow that drifts on hover of its parent link. Pure typographic affordance.
---
<span class="inkarrow">→</span>

View file

@ -0,0 +1,32 @@
---
// Sprinkled accent mark — crisp glyph, loose placement. Sits behind content (z-index 0).
// Position is set per-instance via style props (top/left/right/bottom/transform/width).
type MarkKind = "ringdots" | "pipes" | "dots" | "concentric";
interface Props {
kind: MarkKind;
width?: number;
top?: string;
left?: string;
right?: string;
bottom?: string;
rotate?: number;
}
const { kind, width = 60, top, left, right, bottom, rotate } = Astro.props;
const src = `/assets/accent-${kind}.svg`;
const transform = rotate ? `rotate(${rotate}deg)` : undefined;
const style = [
`width:${width}px`,
top ? `top:${top}` : null,
left ? `left:${left}` : null,
right ? `right:${right}` : null,
bottom ? `bottom:${bottom}` : null,
transform ? `transform:${transform}` : null,
]
.filter(Boolean)
.join(";");
---
<img class="mark" src={src} alt="" style={style} />

View file

@ -0,0 +1,11 @@
---
import { home } from "~/lib/urls";
interface Props {
href?: string;
}
const { href = home("/") } = Astro.props;
---
<a class="wordmark" href={href}>Josh&nbsp;Bairstow<span class="dot">.</span></a>

View file

@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
/**
* The reserved interactive zone on the home page.
* Shows a warm chiaroscuro panel whose light source drifts toward the pointer
* the one signature motion moment per the brand brief. Future interactive pieces
* drop into this same clipped frame.
*
* Respects prefers-reduced-motion: static gradient at the rest position.
*/
export default function HeroZone() {
const frameRef = useRef<HTMLDivElement | null>(null);
const accentRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const frame = frameRef.current;
const accent = accentRef.current;
if (!frame) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const rest = { x: 0.62, y: 0.28 };
function paint(x: number, y: number) {
if (!frame) return;
const lx = 30 + x * 50;
const ly = 8 + y * 40;
frame.style.background =
`radial-gradient(120% 95% at ${lx}% ${ly}%, ` +
`#C9B299 0%, #8A6A47 30%, #4A3826 60%, #221E18 92%)`;
if (accent) {
accent.style.transform = `translate(${(0.5 - x) * 26}px, ${(0.5 - y) * 16}px)`;
}
}
paint(rest.x, rest.y);
if (reduce) return;
const onMove = (e: MouseEvent) => {
const r = frame.getBoundingClientRect();
paint(
Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)),
);
};
const onLeave = () => paint(rest.x, rest.y);
frame.addEventListener("mousemove", onMove);
frame.addEventListener("mouseleave", onLeave);
return () => {
frame.removeEventListener("mousemove", onMove);
frame.removeEventListener("mouseleave", onLeave);
};
}, []);
return (
<div className="hzframe" ref={frameRef}>
<div className="grain" />
<img ref={accentRef} className="hzaccent" src="/assets/accent-ringdots.svg" alt="" />
<img className="hzwm" src="/assets/mark-rings.svg" alt="" />
<span className="hztag">Interactive · soon</span>
</div>
);
}

View file

@ -0,0 +1,164 @@
import { useEffect, useRef } from "react";
/**
* Tide Study No. 7 a slow generative field of warm tidal rings.
* Bone espresso palette; one new ripple every ~2.6s; respects reduced-motion.
* Lives in its own island so each future art piece can ship its own dependencies
* without bloating the rest of the site.
*/
export default function TideStudyCanvas() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const frameRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
const frame = frameRef.current;
if (!canvas || !frame) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const DPR = Math.min(2, window.devicePixelRatio || 1);
let W = 0;
let H = 0;
const resize = () => {
const r = frame.getBoundingClientRect();
W = r.width;
H = r.height;
canvas.width = Math.round(W * DPR);
canvas.height = Math.round(H * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(frame);
const inks = [
"rgba(233,221,201,",
"rgba(201,178,153,",
"rgba(168,133,95,",
];
const dots = Array.from({ length: 22 }, () => ({
x: Math.random(),
y: Math.random(),
r: 0.6 + Math.random() * 2.2,
a: 0.05 + Math.random() * 0.14,
}));
type Ring = { cx: number; cy: number; r: number; born: number; ink: string; wob: number };
let rings: Ring[] = [];
let last = 0;
let acc = 0;
let raf = 0;
const center = () => ({ cx: W * 0.42, cy: H * 0.5 });
const drawDots = () => {
for (const d of dots) {
ctx.beginPath();
ctx.arc(d.x * W, d.y * H, d.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(233,221,201,${d.a})`;
ctx.fill();
}
};
const spawn = () => {
const { cx, cy } = center();
rings.push({
cx,
cy,
r: 0,
born: performance.now(),
ink: inks[Math.floor(Math.random() * inks.length)],
wob: 0.85 + Math.random() * 0.3,
});
};
const frameDraw = (now: number) => {
if (W <= 0 || H <= 0) return;
ctx.clearRect(0, 0, W, H);
drawDots();
const maxR = Math.hypot(W, H) * 0.62;
for (const ring of rings) {
const age = (now - ring.born) / 1000;
ring.r = Math.max(0, age * Math.min(W, H) * 0.085);
const t = ring.r / maxR;
const alpha = Math.max(0, 1 - t) * 0.5;
if (alpha <= 0) continue;
ctx.beginPath();
ctx.ellipse(
ring.cx,
ring.cy,
ring.r,
Math.max(0, ring.r * ring.wob),
0,
0,
Math.PI * 2,
);
ctx.strokeStyle = `${ring.ink}${alpha.toFixed(3)})`;
ctx.lineWidth = 1.4;
ctx.stroke();
}
rings = rings.filter((r) => r.r < maxR);
};
if (reduce) {
const { cx, cy } = center();
drawDots();
for (let i = 1; i <= 7; i++) {
const rr = i * Math.min(W, H) * 0.075;
ctx.beginPath();
ctx.ellipse(cx, cy, rr, rr * 0.95, 0, 0, Math.PI * 2);
ctx.strokeStyle = `${inks[i % inks.length]}${(0.4 * (1 - i / 8)).toFixed(3)})`;
ctx.lineWidth = 1.4;
ctx.stroke();
}
return () => observer.disconnect();
}
// Seed the field so it isn't empty on load.
const seed = performance.now();
for (let i = 0; i < 5; i++) {
rings.push({
cx: W * 0.42,
cy: H * 0.5,
r: 0,
born: seed - i * 1400,
ink: inks[i % inks.length],
wob: 0.9 + Math.random() * 0.2,
});
}
const loop = (now: number) => {
if (!last) last = now;
const dt = now - last;
last = now;
acc += dt;
if (acc > 2600) {
spawn();
acc = 0;
}
frameDraw(now);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
};
}, []);
return (
<div className="artframe" ref={frameRef}>
<canvas ref={canvasRef} />
<span className="live">
<i /> Generative · live
</span>
<div className="grain" />
<img className="wm" src="/assets/mark-rings.svg" alt="" />
</div>
);
}

View file

@ -0,0 +1,119 @@
---
import InkArrow from "~/components/atoms/InkArrow.astro";
interface Props {
num: string;
name: string;
href?: string;
desc?: string;
soon?: boolean;
external?: boolean;
}
const { num, name, href, desc, soon = false, external = false } = Astro.props;
const Tag = soon ? "div" : "a";
const props = soon
? { class: "idxrow soon" }
: {
class: "idxrow",
href,
...(external ? { rel: "noopener" } : {}),
};
---
<Tag {...props}>
<span class="num">{num}</span>
<span class="nm">{name}</span>
<span class="tick"></span>
{soon ? <span class="badge">soon</span> : desc && <span class="desc">{desc}</span>}
{!soon && <InkArrow />}
</Tag>
<style>
.idxrow {
display: flex;
align-items: baseline;
gap: 16px;
text-decoration: none;
padding: 13px 0;
border-top: 1px solid var(--hairline-on-dark);
transition: padding-left var(--dur-base) var(--ease-out);
}
:global(body:not(.dark)) .idxrow {
border-top-color: var(--hairline);
}
.idxrow:hover {
padding-left: 10px;
}
.idxrow.soon {
cursor: default;
}
.idxrow.soon:hover {
padding-left: 0;
}
.num {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fg-on-dark-3);
width: 24px;
flex: none;
font-feature-settings: "onum" 1;
}
:global(body:not(.dark)) .num {
color: var(--fg-3);
}
.nm {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(19px, 1.9vw, 24px);
letter-spacing: 0.01em;
color: var(--fg-on-dark-1);
}
:global(body:not(.dark)) .nm {
color: var(--fg-1);
}
.idxrow.soon .nm {
color: var(--fg-on-dark-3);
}
:global(body:not(.dark)) .idxrow.soon .nm {
color: var(--fg-3);
}
.tick {
width: 0;
height: 1px;
background: var(--accent-deep);
align-self: center;
transition: width var(--dur-base) var(--ease-out);
}
.idxrow:hover .tick {
width: 22px;
}
.idxrow.soon:hover .tick {
width: 0;
}
.desc {
font-family: var(--font-body);
font-size: 12px;
color: var(--fg-on-dark-3);
margin-left: auto;
}
:global(body:not(.dark)) .desc {
color: var(--fg-3);
}
.badge {
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-on-dark-3);
margin-left: auto;
border: 1px solid var(--hairline-on-dark);
border-radius: 2px;
padding: 2px 7px;
}
:global(body:not(.dark)) .badge {
color: var(--fg-3);
border-color: var(--hairline);
}
</style>

View file

@ -0,0 +1,56 @@
---
import InkArrow from "~/components/atoms/InkArrow.astro";
interface Props {
title: string;
href: string;
date: string;
}
const { title, href, date } = Astro.props;
---
<a class="line" href={href}>
<span class="ttl">{title}</span>
<span class="meta">{date}</span>
<InkArrow />
</a>
<div class="rule"></div>
<style>
.line {
display: flex;
align-items: baseline;
gap: 16px;
text-decoration: none;
color: var(--fg-on-dark-1);
}
:global(body:not(.dark)) .line {
color: var(--fg-1);
}
.ttl {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(20px, 2.1vw, 27px);
letter-spacing: -0.01em;
}
.meta {
font-family: var(--font-body);
font-size: 12.5px;
color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1;
margin-left: auto;
white-space: nowrap;
}
:global(body:not(.dark)) .meta {
color: var(--fg-3);
}
.rule {
height: 1px;
background: var(--hairline-on-dark);
margin-top: 14px;
}
:global(body:not(.dark)) .rule {
background: var(--hairline);
}
</style>

View file

@ -0,0 +1,117 @@
---
interface Props {
date: string;
title: string;
href: string;
deck?: string;
tag?: string;
}
const { date, title, href, deck, tag } = Astro.props;
---
<a class="postrow" href={href}>
<span class="pdate">{date}</span>
<span class="middle">
<span class="pttl">{title}<span class="tick"></span></span>
{deck && <span class="pdek">{deck}</span>}
</span>
{tag && <span class="ptag">{tag}</span>}
</a>
<style>
.postrow {
display: grid;
grid-template-columns: 96px 1fr 150px;
gap: 22px;
align-items: baseline;
text-decoration: none;
padding: clamp(16px, 2.6vh, 24px) 0;
border-top: 1px solid var(--hairline-on-dark);
transition: padding-left var(--dur-base) var(--ease-out);
}
:global(body:not(.dark)) .postrow {
border-top-color: var(--hairline);
}
.postrow:hover {
padding-left: 12px;
}
.pdate {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1;
letter-spacing: 0.03em;
white-space: nowrap;
}
:global(body:not(.dark)) .pdate {
color: var(--fg-3);
}
.middle {
display: block;
}
.pttl {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(20px, 2vw, 27px);
line-height: 1.12;
letter-spacing: -0.01em;
color: var(--fg-on-dark-1);
display: flex;
align-items: baseline;
gap: 14px;
}
:global(body:not(.dark)) .pttl {
color: var(--fg-1);
}
.tick {
width: 0;
height: 1px;
background: var(--accent-deep);
align-self: center;
flex: none;
transition: width var(--dur-base) var(--ease-out);
}
.postrow:hover .tick {
width: 24px;
}
.pdek {
display: block;
font-family: var(--font-body);
font-weight: 300;
font-size: 14px;
line-height: 1.55;
color: var(--fg-on-dark-2);
margin: 8px 0 0;
max-width: 60ch;
}
:global(body:not(.dark)) .pdek {
color: var(--fg-2);
}
.ptag {
font-family: var(--font-body);
font-weight: 500;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--fg-on-dark-3);
text-align: right;
align-self: start;
padding-top: 6px;
white-space: nowrap;
}
:global(body:not(.dark)) .ptag {
color: var(--fg-3);
}
@media (max-width: 800px) {
.postrow {
grid-template-columns: 1fr;
gap: 8px;
}
.ptag {
text-align: left;
padding-top: 0;
}
}
</style>

View file

@ -0,0 +1,54 @@
---
interface Props {
year: string;
count: number;
}
const { year, count } = Astro.props;
const formatted = String(count).padStart(2, "0");
---
<div class="yearhead">
<span class="yr">{year}</span>
<span class="ln"></span>
<span class="n">{formatted}</span>
</div>
<style>
.yearhead {
display: flex;
align-items: baseline;
gap: 16px;
margin: clamp(28px, 5vh, 52px) 0 6px;
}
.yearhead:first-child {
margin-top: 0;
}
.yr {
font-family: var(--font-display);
font-weight: 500;
font-size: 22px;
color: var(--fg-on-dark-1);
letter-spacing: 0.01em;
}
:global(body:not(.dark)) .yr {
color: var(--fg-1);
}
.ln {
flex: 1;
height: 1px;
background: var(--hairline-on-dark);
}
:global(body:not(.dark)) .ln {
background: var(--hairline);
}
.n {
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1;
}
:global(body:not(.dark)) .n {
color: var(--fg-3);
}
</style>

View file

@ -0,0 +1,12 @@
---
interface Props {
copy?: string;
}
const { copy = "© Josh Bairstow — Sydney" } = Astro.props;
---
<footer class="sitefoot">
<img class="sig" data-watermark src="/assets/mark-rings.svg" alt="" />
<span class="copyline">{copy}</span>
</footer>

View file

@ -0,0 +1,37 @@
---
import Wordmark from "~/components/atoms/Wordmark.astro";
import { home, blog, art, code } from "~/lib/urls";
type CurrentSection = "home" | "blog" | "art" | "code";
interface Props {
current?: CurrentSection;
showCoord?: boolean;
}
const { current = "home", showCoord = false } = Astro.props;
const items: { id: CurrentSection; label: string; href: string }[] = [
{ id: "home", label: "Index", href: home("/") },
{ id: "blog", label: "Blog", href: blog("/") },
{ id: "code", label: "Code", href: code("/") },
{ id: "art", label: "Art", href: art("/") },
];
---
<header class="topbar">
<Wordmark />
{
showCoord ? (
<span class="coord">33.7969° S · Sydney</span>
) : (
<nav class="topnav">
{items.map((item) => (
<a href={item.href} class={current === item.id ? "current" : undefined}>
{item.label}
</a>
))}
</nav>
)
}
</header>

18
src/content.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const posts = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
description: z.string().optional(),
standfirst: z.string().optional(),
tag: z.string().optional(),
series: z.string().optional(),
readingMinutes: z.number().optional(),
draft: z.boolean().default(false),
}),
});
export const collections = { posts };

View file

@ -0,0 +1,29 @@
---
title: Notes on warm light and cold water
date: 2026-03-12
tag: Field notes
series: No. 19
standfirst: A study of value over hue, written before the sun is fully up.
description: A short essay on seeing in value — warm shadow against warmer light — and on the discipline the cold morning ocean brings to attention.
readingMinutes: 6
---
<span class="leadin">There is a particular hour</span> before the sun fully clears the headland when everything is value and almost no hue — warm shadow against warmer light, and the whole world reduced to a single tonal ramp. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
{.lead}
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. I have been trying, for some years now, to see the way a camera does — to read a scene as a set of relationships rather than a list of things. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.
### Seeing in value
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. The trick, if there is one, is to [squint until the colour drops away](#) and only the structure remains. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.
> "Warmth is not a colour. It is a direction the light is travelling."
> — Field notes, No. 19
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.
### Cold water, slow craft
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident. The cold does something useful to attention — it narrows it.
Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.

View file

@ -0,0 +1,13 @@
---
title: On shipping less
date: 2026-01-22
tag: Code
series: No. 17
standfirst: A short argument for fewer features, slower releases, and the luxury of restraint.
readingMinutes: 5
---
Sed do eiusmod tempor incididunt ut labore. Most software wants to do too much. The discipline is in cutting, again, until what remains is small enough to keep.
{.lead}
Ut enim ad minim veniam, quis nostrud. The luxury of restraint is the cousin of the luxury of a single room with good light.

View file

@ -0,0 +1,17 @@
---
title: The weight of a good jotter
date: 2026-02-04
tag: Craft
series: No. 18
standfirst: On chalk, brass, and the small machines that make a hand feel deliberate.
readingMinutes: 4
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit. There is a particular pleasure in writing tools that resist a little — chalk that has to be broken in, brass that warms in the hand, paper with a tooth.
{.lead}
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. The weight of a good jotter, like the weight of a good knife, is felt as intention.
### Brass and breath
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

View file

@ -0,0 +1,67 @@
---
import "~/styles/colors_and_type.css";
import "~/styles/site.css";
import TopBar from "~/components/organisms/TopBar.astro";
import SiteFoot from "~/components/organisms/SiteFoot.astro";
import Mark from "~/components/atoms/Mark.astro";
type CurrentSection = "home" | "blog" | "art" | "code";
interface Props {
title: string;
description?: string;
current?: CurrentSection;
/**
* When true, the topbar renders a Sydney coord readout instead of the nav.
* Used on the apex home page (per the design — the home page is its own surface
* and doesn't need the section nav since the subdomain index lives in the body).
*/
showCoordInTopbar?: boolean;
/**
* Override the canonical URL when the page belongs to a subdomain.
* Defaults to apex (joshbairstow.com).
*/
canonical?: string;
}
const {
title,
description = "Quiet writing, slow software, and the occasional mark in ink.",
current = "home",
showCoordInTopbar = false,
canonical,
} = Astro.props;
const canonicalHref = canonical ?? new URL(Astro.url.pathname, Astro.site).toString();
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="canonical" href={canonicalHref} />
<link rel="icon" type="image/svg+xml" href="/assets/mark-rings.svg" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonicalHref} />
</head>
<body class="dark">
<!-- sprinkled accent marks — loose, non-rigid placement, per page-of-page overrides via slot -->
<slot name="marks">
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
<Mark kind="dots" width={52} top="26%" right="3.5%" />
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
</slot>
<div class="stage">
<TopBar current={current} showCoord={showCoordInTopbar} />
<slot />
<SiteFoot />
</div>
</body>
</html>

View file

@ -0,0 +1,223 @@
---
import BaseLayout from "./BaseLayout.astro";
import Mark from "~/components/atoms/Mark.astro";
import { blog } from "~/lib/urls";
interface Props {
title: string;
date: Date;
tag?: string;
series?: string;
standfirst?: string;
readingMinutes?: number;
description?: string;
}
const { title, date, tag, series, standfirst, readingMinutes, description } = Astro.props;
const dateFmt = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric" }).format(date);
---
<BaseLayout
title={`${title} — Josh Bairstow`}
description={description ?? standfirst}
current="blog"
>
<Fragment slot="marks">
<Mark kind="ringdots" width={70} top="16%" left="4%" rotate={-6} />
<Mark kind="dots" width={48} top="58%" right="3.5%" />
<Mark kind="pipes" width={26} bottom="14%" left="6%" rotate={5} />
</Fragment>
<article>
<header class="posthead col">
{(tag || series) && (
<span class="kicker">
{tag && <span class="tag">{tag}</span>}
{tag && series && <span class="dot"></span>}
{series && <span class="series">{series}</span>}
</span>
)}
<h1>{title}</h1>
{standfirst && <p class="standfirst">{standfirst}</p>}
<div class="byline">
<span>Josh Bairstow</span>
<span class="sep">—</span>
<span>{dateFmt}</span>
{readingMinutes && <><span class="sep">·</span><span>{readingMinutes} min</span></>}
</div>
</header>
<div class="prose col">
<slot />
</div>
<nav class="postnav col-wide">
<a class="back" href={blog("/")}>
<span class="lbl">← Back to writing</span>
</a>
</nav>
</article>
</BaseLayout>
<style>
article { width: 100%; }
.col { max-width: 720px; margin-left: auto; margin-right: auto; }
.col-wide { max-width: 940px; margin-left: auto; margin-right: auto; }
.posthead {
padding: clamp(40px, 8vh, 104px) 0 clamp(26px, 4vh, 44px);
text-align: center;
}
.kicker {
display: inline-flex;
gap: 12px;
align-items: center;
margin-bottom: clamp(22px, 4vh, 34px);
}
.tag {
font-family: var(--font-body);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
}
:global(body:not(.dark)) .tag { color: var(--accent-deep); }
.kicker .dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--fg-on-dark-3);
}
.series {
font-family: var(--font-body);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--fg-on-dark-3);
white-space: nowrap;
}
:global(body:not(.dark)) .series { color: var(--fg-3); }
.posthead h1 {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(38px, 5.4vw, 76px);
line-height: 1;
letter-spacing: -0.025em;
margin: 0;
color: var(--fg-on-dark-1);
text-wrap: balance;
}
:global(body:not(.dark)) .posthead h1 { color: var(--fg-1); }
.posthead h1 em { font-style: italic; font-weight: 400; }
.standfirst {
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: clamp(18px, 2vw, 24px);
line-height: 1.4;
color: var(--fg-on-dark-2);
margin: clamp(20px, 3vh, 30px) auto 0;
max-width: 30ch;
text-wrap: balance;
}
:global(body:not(.dark)) .standfirst { color: var(--fg-2); }
.byline {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: clamp(26px, 4vh, 38px);
font-family: var(--font-mono);
font-size: 11.5px;
letter-spacing: 0.04em;
color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1;
}
:global(body:not(.dark)) .byline { color: var(--fg-3); }
.byline .sep { opacity: 0.5; }
.prose {
padding: clamp(30px, 5vh, 56px) 0 0;
}
.prose :global(p) {
font-family: var(--font-body);
font-weight: 300;
font-size: 18.5px;
line-height: 1.72;
color: var(--fg-on-dark-1);
margin: 0 0 1.45em;
font-feature-settings: "onum" 1;
}
:global(body:not(.dark)) .prose :global(p) { color: var(--ink-900); }
.prose :global(p.lead) {
font-size: 21px;
line-height: 1.62;
color: var(--fg-on-dark-1);
}
.prose :global(p.lead .leadin) {
font-feature-settings: "smcp" 1, "onum" 1;
text-transform: lowercase;
letter-spacing: 0.04em;
font-weight: 400;
}
.prose :global(h2),
.prose :global(h3) {
font-family: var(--font-display);
font-weight: 500;
line-height: 1.16;
letter-spacing: -0.01em;
color: var(--fg-on-dark-1);
margin: 1.9em 0 0.7em;
}
:global(body:not(.dark)) .prose :global(h2),
:global(body:not(.dark)) .prose :global(h3) { color: var(--fg-1); }
.prose :global(h2) { font-size: clamp(26px, 2.8vw, 36px); }
.prose :global(h3) { font-size: clamp(24px, 2.4vw, 30px); }
.prose :global(a) {
color: var(--fg-on-dark-1);
text-decoration: none;
border-bottom: 1px solid var(--accent);
padding-bottom: 1px;
transition: border-color var(--dur-base) var(--ease-out);
}
:global(body:not(.dark)) .prose :global(a) { color: var(--ink-900); }
.prose :global(a:hover) { border-bottom-color: var(--fg-on-dark-1); }
.prose :global(blockquote) {
margin: clamp(34px, 5vh, 56px) 0;
padding: 0;
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: clamp(26px, 3.2vw, 38px);
line-height: 1.18;
letter-spacing: -0.015em;
color: var(--fg-on-dark-1);
text-wrap: balance;
text-align: center;
}
:global(body:not(.dark)) .prose :global(blockquote) { color: var(--fg-1); }
.postnav {
padding: clamp(40px, 6vh, 72px) 0 clamp(20px, 3vh, 32px);
margin-top: clamp(30px, 5vh, 56px);
border-top: 1px solid var(--hairline-on-dark);
}
:global(body:not(.dark)) .postnav { border-top-color: var(--hairline); }
.postnav a {
text-decoration: none;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg-on-dark-3);
transition: color var(--dur-base) var(--ease-out);
}
.postnav a:hover { color: var(--fg-on-dark-1); }
:global(body:not(.dark)) .postnav a { color: var(--fg-3); }
:global(body:not(.dark)) .postnav a:hover { color: var(--fg-1); }
</style>

53
src/lib/urls.ts Normal file
View file

@ -0,0 +1,53 @@
// Cross-subdomain URL helpers.
//
// In production, joshbairstow.com (apex), blog., art., and code. are all served from
// the same Astro build via the reverse proxy mapping host -> path prefix (see
// docs/planning.md §6.2). Internal navigation between subdomains must use absolute
// URLs so the browser actually swaps hosts; same-subdomain links can stay relative.
//
// In dev (a single localhost), the absolute hostnames don't resolve, so we collapse
// everything to relative paths against the path-prefix layout.
const PROD_APEX = "https://joshbairstow.com";
const SUBDOMAIN_HOSTS: Record<string, string> = {
apex: PROD_APEX,
blog: "https://blog.joshbairstow.com",
art: "https://art.joshbairstow.com",
code: "https://code.joshbairstow.com",
};
const SUBDOMAIN_PATH_PREFIXES: Record<string, string> = {
apex: "/",
blog: "/blog/",
art: "/art/",
code: "/code/",
};
export type Subdomain = keyof typeof SUBDOMAIN_HOSTS;
const isProd = import.meta.env.PROD;
/**
* Build a URL on a given subdomain. Use this for top-nav links and any
* cross-subdomain reference so the apex layout works whether viewed at the
* apex or at a subdomain.
*
* In dev, returns a relative path (so localhost works). In prod, returns an
* absolute https URL (so the reverse proxy can route by host).
*/
export function url(subdomain: Subdomain, path: string = "/"): string {
const normalized = path.startsWith("/") ? path : `/${path}`;
if (isProd) {
return `${SUBDOMAIN_HOSTS[subdomain]}${normalized === "/" ? "" : normalized}`;
}
const prefix = SUBDOMAIN_PATH_PREFIXES[subdomain];
if (normalized === "/") return prefix;
return `${prefix.replace(/\/$/, "")}${normalized}`;
}
// Convenience shortcuts so authors don't memorize the function signature.
export const home = (path: string = "/") => url("apex", path);
export const blog = (path: string = "/") => url("blog", path);
export const art = (path: string = "/") => url("art", path);
export const code = (path: string = "/") => url("code", path);

100
src/pages/404.astro Normal file
View file

@ -0,0 +1,100 @@
---
import BaseLayout from "~/layouts/BaseLayout.astro";
import Mark from "~/components/atoms/Mark.astro";
import InkArrow from "~/components/atoms/InkArrow.astro";
import { home } from "~/lib/urls";
---
<BaseLayout title="Not yet here — Josh Bairstow" description="The page you were looking for doesn't exist yet.">
<Fragment slot="marks">
<Mark kind="ringdots" width={92} top="18%" left="6%" rotate={-8} />
<Mark kind="dots" width={50} top="64%" right="6%" />
<Mark kind="pipes" width={28} bottom="18%" left="40%" rotate={10} />
<Mark kind="concentric" width={44} bottom="22%" right="14%" />
</Fragment>
<main class="missing">
<p class="num">404</p>
<h1>Not yet here<span class="accent">.</span></h1>
<p class="lead">
This page doesn't exist — or doesn't exist yet. Some corners of this site
are still being thought through.
</p>
<a class="back" href={home("/")}>
<span class="u">Back to the index</span>
<InkArrow />
</a>
</main>
</BaseLayout>
<style>
.missing {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: clamp(60px, 12vh, 140px) 0;
gap: clamp(16px, 2.4vh, 28px);
}
.num {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--fg-on-dark-3);
margin: 0;
font-feature-settings: "onum" 1;
}
:global(body:not(.dark)) .num { color: var(--fg-3); }
h1 {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(48px, 7vw, 100px);
line-height: 0.95;
letter-spacing: -0.025em;
color: var(--fg-on-dark-1);
margin: 0;
text-wrap: balance;
}
:global(body:not(.dark)) h1 { color: var(--fg-1); }
h1 .accent { color: var(--accent-deep); }
.lead {
font-family: var(--font-body);
font-weight: 300;
font-size: clamp(15px, 1.45vw, 18px);
line-height: 1.62;
color: var(--fg-on-dark-2);
margin: 0;
max-width: 46ch;
}
:global(body:not(.dark)) .lead { color: var(--fg-2); }
.back {
display: inline-flex;
align-items: baseline;
gap: 10px;
margin-top: 12px;
font-family: var(--font-body);
font-weight: 500;
font-size: 13px;
letter-spacing: 0.04em;
text-decoration: none;
color: var(--fg-on-dark-1);
white-space: nowrap;
}
:global(body:not(.dark)) .back { color: var(--fg-1); }
.back .u {
position: relative;
padding-bottom: 3px;
}
.back .u::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
height: 1px;
width: 100%;
background: var(--accent);
}
</style>

238
src/pages/art/index.astro Normal file
View file

@ -0,0 +1,238 @@
---
import BaseLayout from "~/layouts/BaseLayout.astro";
import Eyebrow from "~/components/atoms/Eyebrow.astro";
import Mark from "~/components/atoms/Mark.astro";
import TideStudyCanvas from "~/components/islands/TideStudyCanvas";
---
<BaseLayout
title="Tide Study No. 7 — Josh Bairstow"
description="A real-time field of warm rings, seeded each morning by the tide chart off Manly."
current="art"
>
<Fragment slot="marks">
<Mark kind="dots" width={50} top="20%" right="4%" />
<Mark kind="pipes" width={26} bottom="16%" left="4%" rotate={6} />
<Mark kind="concentric" width={38} top="70%" right="8%" />
</Fragment>
<main class="gallery">
<section class="work">
<TideStudyCanvas client:load />
</section>
<aside class="caption">
<span class="idx">Work 07 / 12</span>
<p class="eyebrow series-line">Tide studies · generative</p>
<h1>Tide Study <em>No. 7</em></h1>
<p class="desc">
A real-time field of warm rings, seeded each morning by the tide chart
off Manly — no two viewings render the same frame.
</p>
<dl class="spec">
<div class="r"><span class="k">Year</span><span class="v">2026</span></div>
<div class="r"><span class="k">Medium</span><span class="v">Real-time canvas</span></div>
<div class="r"><span class="k">Palette</span><span class="v">Bone → Espresso</span></div>
<div class="r"><span class="k">Format</span><span class="v">Endless</span></div>
<div class="r"><span class="k">Edition</span><span class="v">Open · 1 of ∞</span></div>
</dl>
<nav class="works">
<a href="#" aria-disabled="true"><span class="ar">←</span> No. 6</a>
<span class="mid">Manly · NSW</span>
<a href="#" aria-disabled="true">No. 8 <span class="ar">→</span></a>
</nav>
</aside>
</main>
</BaseLayout>
<style>
.gallery {
display: grid;
grid-template-columns: 1.5fr 0.85fr;
gap: clamp(32px, 5vw, 72px);
align-items: stretch;
flex: 1;
padding: clamp(28px, 5vh, 60px) 0 clamp(24px, 4vh, 44px);
}
.work { display: flex; }
.work :global(.artframe) {
position: relative;
width: 100%;
align-self: stretch;
overflow: hidden;
border-radius: var(--radius-cutout);
background: radial-gradient(
120% 100% at 62% 22%,
#c9b299 0%,
#8a6a47 28%,
#4a3826 58%,
#1b1712 94%
);
box-shadow: var(--shadow-3);
min-height: 420px;
}
.work :global(.artframe canvas) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.work :global(.artframe .grain) {
position: absolute;
inset: 0;
background-image: url(/assets/texture-grain.svg);
background-size: 240px;
opacity: 0.13;
mix-blend-mode: overlay;
pointer-events: none;
z-index: 2;
}
.work :global(.artframe .wm) {
position: absolute;
right: 5%;
bottom: 5%;
width: 130px;
max-width: 26%;
opacity: 0.1;
filter: invert(1);
z-index: 3;
pointer-events: none;
}
.work :global(.artframe .live) {
position: absolute;
left: 5%;
top: 5%;
z-index: 3;
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(239, 231, 218, 0.6);
}
.work :global(.artframe .live i) {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}
.caption {
display: flex;
flex-direction: column;
}
.caption .idx {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1;
}
:global(body:not(.dark)) .caption .idx { color: var(--fg-3); }
.series-line { margin: 26px 0 12px; }
.caption h1 {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(34px, 4vw, 58px);
line-height: 0.98;
letter-spacing: -0.025em;
color: var(--fg-on-dark-1);
margin: 0;
text-wrap: balance;
}
:global(body:not(.dark)) .caption h1 { color: var(--fg-1); }
.caption h1 em { font-style: italic; font-weight: 400; }
.caption .desc {
font-family: var(--font-body);
font-weight: 300;
font-size: 15.5px;
line-height: 1.62;
color: var(--fg-on-dark-2);
margin: 24px 0 0;
max-width: 40ch;
}
:global(body:not(.dark)) .caption .desc { color: var(--fg-2); }
.spec {
margin: clamp(26px, 4vh, 40px) 0 0;
border-top: 1px solid var(--hairline-on-dark);
}
:global(body:not(.dark)) .spec { border-top-color: var(--hairline); }
.spec .r {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--hairline-on-dark);
}
:global(body:not(.dark)) .spec .r { border-bottom-color: var(--hairline); }
.spec .k {
font-family: var(--font-body);
font-weight: 500;
font-size: 10.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--fg-on-dark-3);
}
:global(body:not(.dark)) .spec .k { color: var(--fg-3); }
.spec .v {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--fg-on-dark-1);
font-feature-settings: "onum" 1;
text-align: right;
letter-spacing: 0.01em;
}
:global(body:not(.dark)) .spec .v { color: var(--fg-1); }
.works {
margin-top: auto;
padding-top: clamp(26px, 4vh, 38px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.works a {
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-body);
font-weight: 500;
font-size: 12px;
letter-spacing: 0.04em;
color: var(--fg-on-dark-2);
transition: color var(--dur-base) var(--ease-out);
}
:global(body:not(.dark)) .works a { color: var(--fg-2); }
.works a:hover { color: var(--fg-on-dark-1); }
:global(body:not(.dark)) .works a:hover { color: var(--fg-1); }
.works a[aria-disabled="true"] {
opacity: 0.4;
pointer-events: none;
}
.works a .ar { color: var(--accent-deep); }
.works .mid {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.12em;
color: var(--fg-on-dark-3);
text-transform: uppercase;
}
@media (max-width: 860px) {
.gallery { grid-template-columns: 1fr; }
.work :global(.artframe) {
min-height: 360px;
aspect-ratio: 4 / 3;
}
}
</style>

233
src/pages/blog/index.astro Normal file
View file

@ -0,0 +1,233 @@
---
import BaseLayout from "~/layouts/BaseLayout.astro";
import Eyebrow from "~/components/atoms/Eyebrow.astro";
import Mark from "~/components/atoms/Mark.astro";
import PostRow from "~/components/molecules/PostRow.astro";
import YearHead from "~/components/molecules/YearHead.astro";
import InkArrow from "~/components/atoms/InkArrow.astro";
import { blog } from "~/lib/urls";
import { getCollection } from "astro:content";
const posts = await getCollection("posts", ({ data }) => !data.draft);
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const [featured, ...rest] = posts;
const dateFmtMonthYear = new Intl.DateTimeFormat("en-AU", { month: "short", year: "numeric" });
const dateFmtMonthYearLong = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric" });
// Group the archive by year.
const byYear = new Map<number, typeof rest>();
for (const post of rest) {
const year = post.data.date.getFullYear();
const bucket = byYear.get(year) ?? [];
bucket.push(post);
byYear.set(year, bucket);
}
const yearGroups = Array.from(byYear.entries()).sort(([a], [b]) => b - a);
---
<BaseLayout
title="Writing — Josh Bairstow"
description="Short pieces on craft, the ocean, and the quiet discipline of making small software."
current="blog"
>
<Fragment slot="marks">
<Mark kind="dots" width={54} top="18%" right="4%" />
<Mark kind="concentric" width={40} top="64%" left="3%" rotate={6} />
<Mark kind="pipes" width={26} bottom="12%" right="9%" />
<Mark kind="ringdots" width={72} bottom="26%" left="46%" rotate={-4} />
</Fragment>
<header class="masthead">
<div>
<Eyebrow>Journal · field notes</Eyebrow>
<h1 class="title">Writing<span style="color:var(--accent-deep)">.</span></h1>
<p class="count">{posts.length} entries · since 2021</p>
</div>
<p class="blurb">
Short pieces on craft, the ocean, and the quiet discipline of making small
software. Mostly written early, before the light is up.
</p>
</header>
{
featured && (
<article class="feature">
<div class="cutframe img">
<div class="grain" />
<img class="wm" src="/assets/mark-rings.svg" alt="" />
</div>
<div>
<div class="meta">
{featured.data.tag && <span class="tag">{featured.data.tag}</span>}
<span class="date">
{dateFmtMonthYearLong.format(featured.data.date)}
{featured.data.readingMinutes && ` · ${featured.data.readingMinutes} min`}
</span>
</div>
<h2>
<a href={blog(`/posts/${featured.id}/`)}>{featured.data.title}</a>
</h2>
{featured.data.standfirst && <p>{featured.data.standfirst}</p>}
<a class="read" href={blog(`/posts/${featured.id}/`)}>
<span class="u">Read the piece</span>
<InkArrow />
</a>
</div>
</article>
)
}
<section class="archive">
{
yearGroups.map(([year, items]) => (
<>
<YearHead year={String(year)} count={items.length} />
{items.map((post) => (
<PostRow
date={dateFmtMonthYear.format(post.data.date)}
title={post.data.title}
href={blog(`/posts/${post.id}/`)}
deck={post.data.standfirst}
tag={post.data.tag}
/>
))}
</>
))
}
</section>
</BaseLayout>
<style>
.masthead {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px);
align-items: end;
padding: clamp(40px, 8vh, 96px) 0 clamp(30px, 5vh, 52px);
}
.masthead .title {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(56px, 9vw, 132px);
line-height: 0.9;
letter-spacing: -0.03em;
margin: 12px 0 0;
color: var(--fg-on-dark-1);
}
:global(body:not(.dark)) .masthead .title { color: var(--fg-1); }
.masthead .blurb {
font-family: var(--font-body);
font-weight: 300;
font-size: clamp(15px, 1.4vw, 18px);
line-height: 1.62;
color: var(--fg-on-dark-2);
max-width: 38ch;
margin: 0 0 6px;
}
:global(body:not(.dark)) .masthead .blurb { color: var(--fg-2); }
.masthead .count {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--fg-on-dark-3);
margin-top: 16px;
font-feature-settings: "onum" 1;
}
.feature {
display: grid;
grid-template-columns: 0.92fr 1.08fr;
gap: clamp(28px, 5vw, 64px);
align-items: center;
padding: clamp(26px, 4.5vh, 48px) 0;
border-top: 1px solid var(--hairline-on-dark);
border-bottom: 1px solid var(--hairline-on-dark);
}
:global(body:not(.dark)) .feature { border-color: var(--hairline); }
.feature .img { aspect-ratio: 5 / 4; }
.meta {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 18px;
}
.tag {
font-family: var(--font-body);
font-weight: 500;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
white-space: nowrap;
}
:global(body:not(.dark)) .tag { color: var(--accent-deep); }
.date {
font-family: var(--font-mono);
font-size: 11px;
color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1;
letter-spacing: 0.04em;
}
:global(body:not(.dark)) .date { color: var(--fg-3); }
.feature h2 {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(28px, 3.4vw, 46px);
line-height: 1.04;
letter-spacing: -0.02em;
margin: 0;
color: var(--fg-on-dark-1);
text-wrap: balance;
}
:global(body:not(.dark)) .feature h2 { color: var(--fg-1); }
.feature h2 a { color: inherit; text-decoration: none; }
.feature p {
font-family: var(--font-body);
font-weight: 300;
font-size: 16.5px;
line-height: 1.62;
color: var(--fg-on-dark-2);
margin: 18px 0 0;
max-width: 48ch;
}
:global(body:not(.dark)) .feature p { color: var(--fg-2); }
.read {
display: inline-flex;
align-items: baseline;
gap: 10px;
margin-top: 24px;
font-family: var(--font-body);
font-weight: 500;
font-size: 13px;
letter-spacing: 0.04em;
text-decoration: none;
color: var(--fg-on-dark-1);
white-space: nowrap;
}
:global(body:not(.dark)) .read { color: var(--fg-1); }
.read .u {
position: relative;
padding-bottom: 3px;
}
.read .u::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
height: 1px;
width: 100%;
background: var(--accent);
}
.archive {
padding: clamp(34px, 6vh, 72px) 0 0;
}
@media (max-width: 800px) {
.masthead,
.feature { grid-template-columns: 1fr; }
.feature .img { order: -1; aspect-ratio: 16 / 9; }
}
</style>

View file

@ -0,0 +1,27 @@
---
import { getCollection, render } from "astro:content";
import PostLayout from "~/layouts/PostLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("posts", ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<PostLayout
title={post.data.title}
date={post.data.date}
tag={post.data.tag}
series={post.data.series}
standfirst={post.data.standfirst}
readingMinutes={post.data.readingMinutes}
description={post.data.description}
>
<Content />
</PostLayout>

141
src/pages/code/index.astro Normal file
View file

@ -0,0 +1,141 @@
---
import BaseLayout from "~/layouts/BaseLayout.astro";
import Eyebrow from "~/components/atoms/Eyebrow.astro";
import Mark from "~/components/atoms/Mark.astro";
import InkArrow from "~/components/atoms/InkArrow.astro";
import { blog, home } from "~/lib/urls";
---
<BaseLayout
title="Code — Josh Bairstow"
description="Experiments and small tools. Coming online piece by piece."
current="code"
>
<Fragment slot="marks">
<Mark kind="pipes" width={30} top="22%" left="6%" rotate={-4} />
<Mark kind="dots" width={48} top="68%" right="6%" />
<Mark kind="concentric" width={40} bottom="12%" left="44%" />
</Fragment>
<header class="masthead">
<div>
<Eyebrow>Workshop · in progress</Eyebrow>
<h1 class="title">Code<span class="accent">.</span></h1>
<p class="count">Coming online · piece by piece</p>
</div>
<p class="blurb">
Small utilities, experiments, and the occasional tool worth writing down.
Nothing here yet — the workshop is still being arranged.
</p>
</header>
<section class="placeholder">
<p>
In the meantime, the writing index is the closest thing to a working log:
</p>
<a class="cta" href={blog("/")}>
<span class="u">Read the writing</span>
<InkArrow />
</a>
<a class="cta secondary" href={home("/")}>
<span class="u">Back to the index</span>
</a>
</section>
</BaseLayout>
<style>
.masthead {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: clamp(30px, 6vw, 80px);
align-items: end;
padding: clamp(40px, 8vh, 96px) 0 clamp(30px, 5vh, 52px);
}
.masthead .title {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(56px, 9vw, 132px);
line-height: 0.9;
letter-spacing: -0.03em;
margin: 12px 0 0;
color: var(--fg-on-dark-1);
}
.masthead .title .accent { color: var(--accent-deep); }
:global(body:not(.dark)) .masthead .title { color: var(--fg-1); }
.masthead .count {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--fg-on-dark-3);
margin-top: 16px;
font-feature-settings: "onum" 1;
}
.masthead .blurb {
font-family: var(--font-body);
font-weight: 300;
font-size: clamp(15px, 1.4vw, 18px);
line-height: 1.62;
color: var(--fg-on-dark-2);
max-width: 38ch;
margin: 0 0 6px;
}
:global(body:not(.dark)) .masthead .blurb { color: var(--fg-2); }
.placeholder {
padding: clamp(34px, 6vh, 72px) 0 0;
border-top: 1px solid var(--hairline-on-dark);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 18px;
}
:global(body:not(.dark)) .placeholder { border-top-color: var(--hairline); }
.placeholder p {
font-family: var(--font-body);
font-weight: 300;
font-size: 16px;
line-height: 1.62;
color: var(--fg-on-dark-2);
max-width: 50ch;
margin: 0;
}
:global(body:not(.dark)) .placeholder p { color: var(--fg-2); }
.cta {
display: inline-flex;
align-items: baseline;
gap: 10px;
font-family: var(--font-body);
font-weight: 500;
font-size: 13px;
letter-spacing: 0.04em;
text-decoration: none;
color: var(--fg-on-dark-1);
white-space: nowrap;
}
:global(body:not(.dark)) .cta { color: var(--fg-1); }
.cta .u {
position: relative;
padding-bottom: 3px;
}
.cta .u::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
height: 1px;
width: 100%;
background: var(--accent);
}
.cta.secondary {
color: var(--fg-on-dark-2);
font-weight: 400;
}
.cta.secondary .u::after {
background: var(--hairline-on-dark);
}
@media (max-width: 800px) {
.masthead { grid-template-columns: 1fr; }
}
</style>

242
src/pages/index.astro Normal file
View file

@ -0,0 +1,242 @@
---
import BaseLayout from "~/layouts/BaseLayout.astro";
import Eyebrow from "~/components/atoms/Eyebrow.astro";
import Mark from "~/components/atoms/Mark.astro";
import LatestLine from "~/components/molecules/LatestLine.astro";
import IndexRow from "~/components/molecules/IndexRow.astro";
import HeroZone from "~/components/islands/HeroZone";
import { blog, art, code } from "~/lib/urls";
import { getCollection } from "astro:content";
// Pull the most recent published post for the "selected writing" line.
const posts = await getCollection("posts", ({ data }) => !data.draft);
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const latest = posts[0];
const dateFmt = new Intl.DateTimeFormat("en-AU", {
month: "short",
year: "numeric",
});
// Subdomain index. Each row points at the right destination. Some entries are
// out-of-repo containers; flagging external just signals intent for now (rel attrs).
const indexItems = [
{ num: "01", name: "Blog", href: blog("/"), desc: "Writing", external: false, soon: false },
{ num: "02", name: "Code", href: code("/"), desc: "Experiments & tools", external: false, soon: false },
{ num: "03", name: "Art", href: art("/"), desc: "Generative & physical", external: false, soon: false },
{ num: "04", name: "Signage", href: undefined, desc: undefined, external: true, soon: true },
{ num: "05", name: "Keycaps", href: undefined, desc: undefined, external: true, soon: true },
{ num: "06", name: "Social", href: undefined, desc: undefined, external: true, soon: true },
];
---
<BaseLayout title="Josh Bairstow" current="home" showCoordInTopbar={true}>
<Fragment slot="marks">
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
<Mark kind="dots" width={52} top="26%" right="3.5%" />
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
<Mark kind="ringdots" width={66} bottom="20%" left="44%" rotate={-3} />
</Fragment>
<main class="home" data-layout="stack">
<section class="intro">
<Eyebrow>Engineer · Sydney</Eyebrow>
<h2 class="statement">
I make small,<br /><em>careful</em> things<span class="accent">.</span>
</h2>
<p class="lead">
Software, tools, and the occasional mark in ink. A quiet corner of the
internet — mostly writing, sometimes shipping.
</p>
</section>
<section class="hero">
<HeroZone client:load />
</section>
{
latest && (
<section class="latest">
<Eyebrow>Selected writing</Eyebrow>
<LatestLine
title={latest.data.title}
href={blog(`/posts/${latest.id}/`)}
date={dateFmt.format(latest.data.date)}
/>
</section>
)
}
<section class="index">
<Eyebrow>Index</Eyebrow>
<nav>
{indexItems.map((item) => (
<IndexRow
num={item.num}
name={item.name}
href={item.href}
desc={item.desc}
soon={item.soon}
external={item.external}
/>
))}
</nav>
</section>
</main>
</BaseLayout>
<style>
/* Stack layout — editorial, centered. The canonical landing per docs/planning.md. */
.home {
display: grid;
grid-template-columns: minmax(0, 880px);
grid-template-areas: "intro" "hero" "latest" "index";
justify-content: center;
gap: clamp(28px, 4.6vh, 52px);
align-content: start;
padding-top: clamp(40px, 7vh, 96px);
text-align: center;
flex: 1;
}
.intro {
grid-area: intro;
display: flex;
flex-direction: column;
align-items: center;
}
.intro :global(.eyebrow) {
margin-bottom: clamp(18px, 3.4vh, 30px);
}
.statement {
font-family: var(--font-display);
font-weight: 500;
font-size: clamp(40px, 5.4vw, 82px);
line-height: 0.97;
letter-spacing: -0.02em;
margin: 0;
color: var(--fg-on-dark-1);
text-wrap: balance;
}
:global(body:not(.dark)) .statement {
color: var(--fg-1);
}
.statement em {
font-style: italic;
font-weight: 400;
}
.statement .accent {
color: var(--accent-deep);
}
.lead {
font-family: var(--font-body);
font-weight: 300;
font-size: clamp(15px, 1.45vw, 18px);
line-height: 1.6;
color: var(--fg-on-dark-2);
max-width: 56ch;
margin: clamp(20px, 3.6vh, 32px) auto 0;
text-align: center;
}
:global(body:not(.dark)) .lead {
color: var(--fg-2);
}
.hero {
grid-area: hero;
display: flex;
}
/* Styles for .hzframe + its children come from the React island's class names */
.hero :global(.hzframe) {
position: relative;
width: 100%;
overflow: hidden;
border-radius: var(--radius-cutout);
background: radial-gradient(
120% 95% at 64% 18%,
#c9b299 0%,
#8a6a47 30%,
#4a3826 60%,
#221e18 92%
);
box-shadow: var(--shadow-2);
cursor: crosshair;
transition: background 700ms var(--ease-out);
aspect-ratio: 16 / 7;
}
.hero :global(.hzframe .grain) {
position: absolute;
inset: 0;
background-image: url(/assets/texture-grain.svg);
background-size: 220px;
opacity: 0.12;
mix-blend-mode: overlay;
pointer-events: none;
}
.hero :global(.hzframe .hzaccent) {
position: absolute;
right: 9%;
top: 12%;
width: 76px;
opacity: 0.18;
filter: invert(1);
pointer-events: none;
transition: transform 900ms var(--ease-out);
}
.hero :global(.hzframe .hzwm) {
position: absolute;
left: 8%;
bottom: 9%;
width: 128px;
opacity: 0.1;
filter: invert(1);
pointer-events: none;
}
.hero :global(.hzframe .hztag) {
position: absolute;
right: 8%;
bottom: 8%;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(239, 231, 218, 0.5);
}
.latest {
grid-area: latest;
max-width: 640px;
margin: 0 auto;
width: 100%;
text-align: left;
}
.latest :global(.eyebrow) {
margin-bottom: 14px;
}
.index {
grid-area: index;
text-align: left;
}
.index :global(.eyebrow) {
margin-bottom: 4px;
}
@media (max-width: 860px) {
.home {
grid-template-columns: 1fr;
text-align: left;
}
.intro {
align-items: flex-start;
}
.lead {
text-align: left;
margin-left: 0;
margin-right: 0;
}
.hero :global(.hzframe) {
aspect-ratio: 16 / 9;
}
}
</style>

View file

@ -0,0 +1,236 @@
/* ============================================================================
Josh Bairstow Brand System
colors_and_type.css foundational color + type tokens
----------------------------------------------------------------------------
One base aesthetic (quiet luxury, warm browns + ink) and one contained
accent (ochre / calligraffiti ink). Chiaroscuro is achieved through VALUE
in the warm ramp never through cool grey. Keep shadows warm.
========================================================================== */
@import url('https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400;0,6..96,500;0,6..96,600;0,6..96,700;1,6..96,400;1,6..96,500&family=Hanken+Grotesk:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
/* ---------------------------------------------------------------------- */
/* WARM TONAL RAMP — the spine of the system. */
/* A single value ladder from bone (light) to warm near-black (ink). */
/* Use value, not hue, to build depth and chiaroscuro. */
/* ---------------------------------------------------------------------- */
--bone-50: #F4EFE6; /* Ground — primary background, warm off-white */
--bone-100: #EDE6D9; /* Raised ground — cards, panels */
--bone-200: #E0D5C3; /* Sunk ground / quiet fills */
--sand-300: #C9B299; /* Light brown — hairlines on dark, muted detail */
--sepia-400: #A8855F; /* Sepia — secondary brand brown */
--tobacco-500: #7A5C3E;/* Tobacco — primary brand brown */
--brown-600: #5A4630; /* Mid-deep brown — secondary text on light */
--espresso-700: #3B2C1E;/* Espresso — depth, warm shadow tone */
--bark-800: #2E2720; /* Bark — dark panels */
--ink-900: #221E18; /* Ink — primary text / the ink gesture */
--ground-dark-950: #17130E; /* Inverted ground — DEFAULT theme, deep + moody */
--ground-dark-900: #1E1A14; /* Raised dark panel */
/* ---------------------------------------------------------------------- */
/* ACCENT — single, restrained, rare. Ochre/amber. A deliberate event. */
/* ---------------------------------------------------------------------- */
--accent: #C0883A; /* Muted ochre — hover ticks, rare highlights */
--accent-deep: #9A6A28; /* Deep amber — pressed / on-light accent text */
/* ---------------------------------------------------------------------- */
/* SEMANTIC COLOR ROLES */
/* ---------------------------------------------------------------------- */
--ground: var(--bone-50);
--ground-raised: var(--bone-100);
--ground-sunk: var(--bone-200);
--ground-dark: var(--ground-dark-950);
--fg-1: var(--ink-900); /* primary text */
--fg-2: var(--brown-600); /* secondary text */
--fg-3: var(--sepia-400); /* tertiary / muted / metadata */
/* On dark grounds (the default theme) */
--fg-on-dark-1: #EDE4D5;
--fg-on-dark-2: #AE9B7F;
--fg-on-dark-3: #6F5E48;
/* Hairlines — warm, never grey. Used for §5 rules and dividers. */
--hairline: rgba(34, 30, 24, 0.12);
--hairline-strong: rgba(34, 30, 24, 0.22);
--hairline-on-dark: rgba(239, 231, 218, 0.14);
/* ---------------------------------------------------------------------- */
/* TYPOGRAPHY FAMILIES */
/* ---------------------------------------------------------------------- */
--font-display: 'Bodoni Moda', 'Times New Roman', serif;
--font-body: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
/* OpenType "visit three" details: old-style figures + true small caps. */
--feat-oldstyle: "onum" 1, "pnum" 1;
--feat-smallcaps: "smcp" 1, "onum" 1;
/* ---------------------------------------------------------------------- */
/* TYPE SCALE — editorial, large, confident. rem-based (16px root). */
/* ---------------------------------------------------------------------- */
--step--1: 0.833rem; /* 13.3px — captions, metadata */
--step-0: 1rem; /* 16px — base body */
--step-1: 1.2rem; /* 19.2px — lead body */
--step-2: 1.5rem; /* 24px — small headings */
--step-3: 2.0rem; /* 32px — h3 */
--step-4: 2.75rem; /* 44px — h2 */
--step-5: 3.75rem; /* 60px — h1 */
--step-6: 5.5rem; /* 88px — display */
--step-7: 8rem; /* 128px — hero wordmark */
--leading-tight: 1.04;
--leading-snug: 1.18;
--leading-body: 1.62;
--measure: 66ch;
/* ---------------------------------------------------------------------- */
/* GREEBLING / TEXTURE TOKENS — honor the §5 hard guardrails. */
/* Texture opacity: 38%. Watermark: 410%. */
/* Contrast delta: 26% luminance. Hover lift: rest → active. */
/* ---------------------------------------------------------------------- */
--greeble-opacity-rest: 0.04;
--greeble-opacity-active: 0.08;
--watermark-opacity-rest: 0.05;
--watermark-opacity-active:0.10;
/* ---------------------------------------------------------------------- */
/* MOTION — slow, eased, short-travel. One signature moment per view. */
/* ---------------------------------------------------------------------- */
--dur-fast: 200ms;
--dur-base: 300ms;
--dur-slow: 400ms;
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* gentle settle */
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* symmetric, calm */
/* ---------------------------------------------------------------------- */
/* RADII + ELEVATION — minimal. Corners barely soften; shadows are warm. */
/* ---------------------------------------------------------------------- */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 14px;
/* Image cut-out rounded, asymmetric (one big soft corner). Never a plain
rectangle. Used to clip imagery + watermark frames. */
--radius-cutout: 6px 92px 6px 6px;
--radius-cutout-sm: 4px 60px 4px 4px;
/* Warm, low shadows — espresso-tinted, never neutral black. */
--shadow-1: 0 1px 2px rgba(59, 44, 30, 0.06), 0 1px 1px rgba(59, 44, 30, 0.04);
--shadow-2: 0 6px 18px rgba(59, 44, 30, 0.08), 0 2px 6px rgba(59, 44, 30, 0.05);
--shadow-3: 0 18px 48px rgba(30, 26, 22, 0.14), 0 6px 16px rgba(30, 26, 22, 0.08);
/* ---------------------------------------------------------------------- */
/* SPACING — 4px base, generous. Negative space is a feature. */
/* ---------------------------------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 64px;
--space-9: 96px;
--space-10: 128px;
}
/* ========================================================================== */
/* SEMANTIC ELEMENT STYLES — opt in by adding these classes or scoping under */
/* a .jb-prose / .jb-type root. Kept as utility-ish classes so the system */
/* travels into any HTML mock without a framework. */
/* ========================================================================== */
.jb-display {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--step-6);
line-height: var(--leading-tight);
letter-spacing: -0.015em;
color: var(--fg-1);
}
.jb-h1 {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--step-5);
line-height: var(--leading-tight);
letter-spacing: -0.01em;
color: var(--fg-1);
}
.jb-h2 {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--step-4);
line-height: var(--leading-snug);
letter-spacing: -0.005em;
color: var(--fg-1);
}
.jb-h3 {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--step-3);
line-height: var(--leading-snug);
color: var(--fg-1);
}
.jb-eyebrow {
font-family: var(--font-body);
font-weight: 500;
font-size: var(--step--1);
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--fg-3);
}
.jb-lead {
font-family: var(--font-body);
font-weight: 300;
font-size: var(--step-1);
line-height: var(--leading-body);
color: var(--fg-2);
max-width: var(--measure);
}
.jb-body {
font-family: var(--font-body);
font-weight: 400;
font-size: var(--step-0);
line-height: var(--leading-body);
color: var(--fg-2);
max-width: var(--measure);
font-feature-settings: var(--feat-oldstyle);
}
.jb-caption {
font-family: var(--font-body);
font-weight: 400;
font-size: var(--step--1);
line-height: 1.4;
color: var(--fg-3);
font-feature-settings: var(--feat-oldstyle);
}
.jb-mono {
font-family: var(--font-mono);
font-size: var(--step--1);
letter-spacing: -0.01em;
color: var(--fg-2);
}
.jb-smallcaps {
font-feature-settings: var(--feat-smallcaps);
text-transform: lowercase;
letter-spacing: 0.04em;
}
@media (prefers-reduced-motion: reduce) {
:root {
--dur-fast: 0ms;
--dur-base: 0ms;
--dur-slow: 0ms;
}
}

168
src/styles/site.css Normal file
View file

@ -0,0 +1,168 @@
/* ============================================================================
Josh Bairstow shared site chrome
Sits on top of colors_and_type.css. Holds the bits every page repeats:
the warm grounds, near-invisible grain, sprinkled accent marks, the top
bar, link affordances, the signature footer.
Dark ink ground is the default theme; body:not(.dark) is the bone alternate.
========================================================================== */
* { box-sizing: border-box; }
html, body { margin: 0; }
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
body {
font-family: var(--font-body);
background-color: var(--ground-dark);
color: var(--fg-on-dark-1);
min-height: 100vh;
position: relative;
}
body:not(.dark) { background-color: var(--ground); color: var(--fg-1); }
/* ---- page-wide film grain (§5: 38% opacity, no hard edges) ------------- */
body::before {
content: "";
position: fixed; inset: 0;
background-image: url(/assets/texture-grain.svg);
background-size: 240px;
opacity: 0.06;
mix-blend-mode: screen;
pointer-events: none;
z-index: 0;
}
body:not(.dark)::before { opacity: 0.045; mix-blend-mode: normal; }
/* ---- sprinkled accent marks — crisp glyphs, loose placement ------------- */
:root {
--mark-filter-dark: invert(52%) sepia(16%) saturate(340%) brightness(88%);
--mark-filter-light: invert(34%) sepia(18%) saturate(280%) brightness(76%);
--mark-rest: 0.16;
}
.mark {
position: fixed; pointer-events: none; z-index: 0;
filter: var(--mark-filter-dark);
opacity: var(--mark-rest);
transition: opacity var(--dur-slow) var(--ease-out);
}
body:not(.dark) .mark { filter: var(--mark-filter-light); opacity: 0.11; }
/* ---- shared content stage ----------------------------------------------- */
.stage {
position: relative; z-index: 1;
max-width: 1180px;
margin: 0 auto;
padding: clamp(26px, 4.5vh, 52px) clamp(24px, 5vw, 64px) clamp(30px, 5vh, 56px);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ---- top bar ------------------------------------------------------------ */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 24px;
padding-bottom: 18px;
border-bottom: 1px solid var(--hairline-on-dark);
}
body:not(.dark) .topbar { border-bottom-color: var(--hairline); }
.wordmark {
font-family: var(--font-display); font-weight: 500;
font-size: 21px; line-height: 1; letter-spacing: 0;
color: var(--fg-on-dark-1); margin: 0;
text-decoration: none; display: inline-flex; align-items: baseline;
}
body:not(.dark) .wordmark { color: var(--fg-1); }
.wordmark .dot { color: var(--accent-deep); }
.topnav { display: flex; align-items: center; gap: 26px; }
.topnav a {
font-family: var(--font-body); font-weight: 500; font-size: 11px;
letter-spacing: 0.2em; text-transform: uppercase; text-decoration: none;
color: var(--fg-on-dark-2);
position: relative; padding-bottom: 2px;
transition: color var(--dur-base) var(--ease-out);
}
body:not(.dark) .topnav a { color: var(--fg-3); }
.topnav a::after {
content: ""; position: absolute; left: 0; bottom: -2px; height: 1px; width: 0;
background: var(--accent); transition: width var(--dur-base) var(--ease-out);
}
.topnav a:hover { color: var(--fg-on-dark-1); }
body:not(.dark) .topnav a:hover { color: var(--fg-1); }
.topnav a:hover::after { width: 100%; }
.topnav a.current { color: var(--fg-on-dark-1); }
body:not(.dark) .topnav a.current { color: var(--fg-1); }
.topnav a.current::after { width: 100%; background: var(--accent-deep); }
.coord {
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;
color: var(--fg-on-dark-3); font-feature-settings: "onum" 1;
}
body:not(.dark) .coord { color: var(--fg-3); }
/* ---- shared type helpers (on-dark aware) -------------------------------- */
.eyebrow {
font-family: var(--font-body); font-weight: 500; font-size: 11px;
letter-spacing: 0.24em; text-transform: uppercase; color: var(--fg-on-dark-2);
margin: 0; white-space: nowrap;
}
body:not(.dark) .eyebrow { color: var(--fg-3); }
.serif { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.015em; }
/* ---- signature footer --------------------------------------------------- */
.sitefoot {
position: relative; z-index: 1;
margin-top: auto;
padding-top: clamp(22px, 3.5vh, 36px);
border-top: 1px solid var(--hairline-on-dark);
display: flex; align-items: flex-end; justify-content: space-between; gap: 24px;
}
body:not(.dark) .sitefoot { border-top-color: var(--hairline); }
.sitefoot .sig {
width: 74px; opacity: 0.24;
filter: var(--mark-filter-dark);
}
body:not(.dark) .sitefoot .sig { filter: var(--mark-filter-light); }
.sitefoot .copyline {
font-family: var(--font-body); font-size: 12px; color: var(--fg-on-dark-3);
font-feature-settings: "onum" 1; text-align: right; letter-spacing: 0.02em;
}
body:not(.dark) .sitefoot .copyline { color: var(--fg-3); }
.sitefoot .footnav { display: flex; gap: 18px; }
.sitefoot .footnav a {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--fg-on-dark-3); text-decoration: none;
transition: color var(--dur-base) var(--ease-out);
}
.sitefoot .footnav a:hover { color: var(--fg-on-dark-1); }
body:not(.dark) .sitefoot .footnav a { color: var(--fg-3); }
body:not(.dark) .sitefoot .footnav a:hover { color: var(--fg-1); }
/* ---- the latest / arrow link affordance --------------------------------- */
.inkarrow { color: var(--accent-deep); display: inline-block;
transition: transform var(--dur-base) var(--ease-out); }
a:hover .inkarrow { transform: translateX(5px); }
/* ---- warm chiaroscuro image frame (placeholder imagery) ----------------- */
.cutframe {
position: relative; overflow: hidden;
border-radius: var(--radius-cutout);
background: radial-gradient(120% 95% at 68% 16%,
#C9B299 0%, #8A6A47 30%, #4A3826 60%, #221E18 92%);
box-shadow: var(--shadow-2);
}
.cutframe .wm {
position: absolute; right: 7%; bottom: 7%; width: 30%; max-width: 150px;
opacity: 0.10; filter: invert(1); z-index: 4; pointer-events: none;
}
.cutframe .grain {
position: absolute; inset: 0; background-image: url(/assets/texture-grain.svg);
background-size: 220px; opacity: 0.12; mix-blend-mode: overlay;
pointer-events: none; z-index: 3;
}
@media (prefers-reduced-motion: reduce) {
.mark, .inkarrow { transition: none; }
}