initial commit
This commit is contained in:
commit
3ba76b4f02
90 changed files with 12507 additions and 0 deletions
9
src/components/atoms/Eyebrow.astro
Normal file
9
src/components/atoms/Eyebrow.astro
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className } = Astro.props;
|
||||
---
|
||||
|
||||
<p class:list={["eyebrow", className]}><slot /></p>
|
||||
5
src/components/atoms/InkArrow.astro
Normal file
5
src/components/atoms/InkArrow.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
// The ochre arrow that drifts on hover of its parent link. Pure typographic affordance.
|
||||
---
|
||||
|
||||
<span class="inkarrow">→</span>
|
||||
32
src/components/atoms/Mark.astro
Normal file
32
src/components/atoms/Mark.astro
Normal 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} />
|
||||
11
src/components/atoms/Wordmark.astro
Normal file
11
src/components/atoms/Wordmark.astro
Normal 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 Bairstow<span class="dot">.</span></a>
|
||||
63
src/components/islands/HeroZone.tsx
Normal file
63
src/components/islands/HeroZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/components/islands/TideStudyCanvas.tsx
Normal file
164
src/components/islands/TideStudyCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/components/molecules/IndexRow.astro
Normal file
119
src/components/molecules/IndexRow.astro
Normal 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>
|
||||
56
src/components/molecules/LatestLine.astro
Normal file
56
src/components/molecules/LatestLine.astro
Normal 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>
|
||||
117
src/components/molecules/PostRow.astro
Normal file
117
src/components/molecules/PostRow.astro
Normal 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>
|
||||
54
src/components/molecules/YearHead.astro
Normal file
54
src/components/molecules/YearHead.astro
Normal 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>
|
||||
12
src/components/organisms/SiteFoot.astro
Normal file
12
src/components/organisms/SiteFoot.astro
Normal 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>
|
||||
37
src/components/organisms/TopBar.astro
Normal file
37
src/components/organisms/TopBar.astro
Normal 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
18
src/content.config.ts
Normal 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 };
|
||||
29
src/content/posts/notes-on-warm-light-and-cold-water.md
Normal file
29
src/content/posts/notes-on-warm-light-and-cold-water.md
Normal 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.
|
||||
13
src/content/posts/on-shipping-less.md
Normal file
13
src/content/posts/on-shipping-less.md
Normal 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.
|
||||
17
src/content/posts/the-weight-of-a-good-jotter.md
Normal file
17
src/content/posts/the-weight-of-a-good-jotter.md
Normal 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.
|
||||
67
src/layouts/BaseLayout.astro
Normal file
67
src/layouts/BaseLayout.astro
Normal 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>
|
||||
223
src/layouts/PostLayout.astro
Normal file
223
src/layouts/PostLayout.astro
Normal 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
53
src/lib/urls.ts
Normal 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
100
src/pages/404.astro
Normal 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
238
src/pages/art/index.astro
Normal 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
233
src/pages/blog/index.astro
Normal 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>
|
||||
27
src/pages/blog/posts/[...slug].astro
Normal file
27
src/pages/blog/posts/[...slug].astro
Normal 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
141
src/pages/code/index.astro
Normal 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
242
src/pages/index.astro
Normal 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>
|
||||
236
src/styles/colors_and_type.css
Normal file
236
src/styles/colors_and_type.css
Normal 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: 3–8%. Watermark: 4–10%. */
|
||||
/* Contrast delta: 2–6% 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
168
src/styles/site.css
Normal 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: 3–8% 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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue