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

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
node_modules
dist
.astro
.git
.github
docs/design-system
.env
.env.*
*.log
.DS_Store

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules/
dist/
.astro/
.env
.env.*
!.env.example
.DS_Store
*.log

89
Caddyfile Normal file
View file

@ -0,0 +1,89 @@
# ----------------------------------------------------------------------------
# jb-website — Caddy site config.
#
# Routing model (see docs/planning.md §6.2):
# joshbairstow.com -> /srv/* (apex / home)
# blog.joshbairstow.com -> /srv/blog/* (writing archive + posts)
# art.joshbairstow.com -> /srv/art/* (generative art focus view)
# code.joshbairstow.com -> /srv/code/* (experiments & tools)
#
# Each block points at the same /srv directory but uses `root` with the path
# prefix appropriate to the subdomain. Caddy handles HTTPS via the automatic
# ACME / Let's Encrypt flow at the email address below.
#
# Local dev: see the `localhost` block at the bottom — Caddy listens on :8080
# with no TLS so `docker compose up` works out of the box on a laptop.
# ----------------------------------------------------------------------------
{
# Replace with the address you want ACME-challenge contact / cert-expiry mail at.
email josh@joshbairstow.com
}
# ---- apex ------------------------------------------------------------------
joshbairstow.com, www.joshbairstow.com {
root * /srv
encode zstd gzip
file_server
try_files {path} {path}/index.html /404.html
@www host www.joshbairstow.com
redir @www https://joshbairstow.com{uri} permanent
}
# ---- blog ------------------------------------------------------------------
blog.joshbairstow.com {
root * /srv/blog
encode zstd gzip
# Assets and the canvas-island JS live at /_astro and /assets on the apex build.
# Re-route them from the subdomain root by falling through to the parent /srv.
@asset path /_astro/* /assets/*
handle @asset {
root * /srv
file_server
}
handle {
file_server
try_files {path} {path}/index.html /srv/404.html
}
}
# ---- art -------------------------------------------------------------------
art.joshbairstow.com {
root * /srv/art
encode zstd gzip
@asset path /_astro/* /assets/*
handle @asset {
root * /srv
file_server
}
handle {
file_server
try_files {path} {path}/index.html /srv/404.html
}
}
# ---- code ------------------------------------------------------------------
code.joshbairstow.com {
root * /srv/code
encode zstd gzip
@asset path /_astro/* /assets/*
handle @asset {
root * /srv
file_server
}
handle {
file_server
try_files {path} {path}/index.html /srv/404.html
}
}
# ---- local dev (no TLS) ----------------------------------------------------
# Hit http://localhost:8080 to preview the built apex; the subdomain rewrites
# above only fire when accessed by their real hostnames.
:8080 {
root * /srv
encode zstd gzip
file_server
try_files {path} {path}/index.html /404.html
}

24
Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# ----------------------------------------------------------------------------
# jb-website — static build served by Caddy with automatic HTTPS.
# Multi-stage: node builds Astro, alpine Caddy serves the result.
# ----------------------------------------------------------------------------
# ---- build stage ------------------------------------------------------------
FROM node:24-alpine AS build
WORKDIR /app
# Install deps separately so they cache between source-only changes.
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# ---- runtime stage ----------------------------------------------------------
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
# Caddy listens on 80/443; the host's reverse proxy or docker compose maps these.
EXPOSE 80 443

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# jb-website
The first-draft live site for **joshbairstow.com** — Astro 5 + React islands, styled to a quiet-luxury brand system in warm browns and ink. Serves the apex and three personal subdomains (`blog.`, `art.`, `code.`) from a single build via host-aware reverse-proxy routing.
The planning doc — including tech rationale, architecture, decisions log, and open questions — lives at [`docs/planning.md`](docs/planning.md). Read that first if you want context. The canonical brand spec lives in [`docs/design-system/`](docs/design-system/) (imported from a Claude Design handoff bundle); the "one base + one accent" rule and the visit-one / visit-three litmus test are load-bearing for design decisions.
## What's here
```
src/
├── pages/
│ ├── index.astro apex home (stack layout)
│ ├── 404.astro on-brand missing-page
│ ├── blog/
│ │ ├── index.astro writing archive
│ │ └── posts/[...slug].astro post detail
│ ├── art/index.astro generative-art focus view
│ └── code/index.astro workshop placeholder
├── layouts/
│ ├── BaseLayout.astro topbar + grain + sprinkled marks + footer
│ └── PostLayout.astro editorial reading template
├── components/
│ ├── atoms/ Wordmark, Eyebrow, Mark, InkArrow
│ ├── molecules/ IndexRow, LatestLine, PostRow, YearHead
│ ├── organisms/ TopBar, SiteFoot
│ └── islands/ HeroZone, TideStudyCanvas (React)
├── content/posts/ markdown blog posts (Astro content collection)
├── styles/ colors_and_type.css + site.css
└── lib/urls.ts cross-subdomain URL helpers
```
## Dev
```sh
npm install
npm run dev # http://localhost:4321
```
The Astro dev server collapses subdomain links to relative paths so a single localhost preview shows the cross-subdomain navigation working. In a production build the same helpers emit absolute `https://blog.joshbairstow.com/...` URLs.
## Build
```sh
npm run build # outputs static site to dist/
npm run preview # serve the build locally
```
## Deploy (containerised, target = Vultr Sydney VPS)
The repo ships a Caddy-fronted container that serves the static build directly with automatic HTTPS.
```sh
docker compose build
docker compose up -d
```
Caddy's site config (`Caddyfile`) maps host headers → path prefixes:
- `joshbairstow.com``/srv/`
- `blog.joshbairstow.com``/srv/blog/`
- `art.joshbairstow.com``/srv/art/`
- `code.joshbairstow.com``/srv/code/`
Asset paths (`/_astro/*` and `/assets/*`) are re-rooted back to `/srv` from the subdomain blocks so they resolve regardless of which subdomain served the HTML.
To add another in-repo subdomain later, add a directory under `src/pages/` and a matching block in the Caddyfile — that's it.
## Writing a blog post
Add a `.md` file under `src/content/posts/`. Frontmatter schema (validated by Zod in `src/content.config.ts`):
```yaml
---
title: A short title
date: 2026-03-12
tag: Field notes # optional
series: No. 19 # optional
standfirst: One sentence in italic display type, under the title.
description: SEO/OG fallback when standfirst is absent.
readingMinutes: 6 # optional
draft: false # excludes from list when true
---
```
Lead with `{.lead}` after a paragraph to apply the small-caps lead-in styling. The Astro markdown renderer handles the rest against `PostLayout.astro`.
## What's deferred
- Blog search (Pagefind wiring) — see planning doc step 8.
- Auth middleware — needed when access-gated pages land.
- RSS, OG image generation, additional art pieces.
- Content for `code.` (currently a workshop placeholder).
- Side-hustle subdomains (signage, keycaps) — out of scope; live in their own containers behind the same proxy.
## License
All rights reserved unless stated otherwise. Brand marks, type pairings, and prose are personal; please don't redistribute.

23
astro.config.mjs Normal file
View file

@ -0,0 +1,23 @@
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
// joshbairstow.com is the apex. Subdomains (blog., art., code.) are served from this
// same build via the reverse proxy mapping host -> path prefix (see docs/planning.md §6.2).
// `site` here drives canonical URLs in generated metadata and the sitemap.
export default defineConfig({
site: "https://joshbairstow.com",
integrations: [react(), mdx(), sitemap()],
output: "static",
build: {
format: "directory",
},
vite: {
server: {
// Allow the dev server to be hit by the local subdomain hostnames if you alias
// them in /etc/hosts. Keeps testing the cross-subdomain link behavior easy.
host: true,
},
},
});

26
compose.yml Normal file
View file

@ -0,0 +1,26 @@
# ----------------------------------------------------------------------------
# jb-website — production compose file.
#
# Runs the Caddy-fronted static site. Caddy handles HTTPS via the automatic
# ACME flow at the email configured in Caddyfile. Side-hustle container
# services (signage, keycaps, etc.) would slot in alongside this one — Caddy
# in this container can proxy to them, or you can put a dedicated Caddy in
# front of all containers.
# ----------------------------------------------------------------------------
services:
web:
build: .
image: jb-website:latest
container_name: jb-website
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

View file

@ -0,0 +1,25 @@
# CODING AGENTS: READ THIS FIRST
This is a **handoff bundle** from Claude Design (claude.ai/design).
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
## What you should do — IMPORTANT
**Read the chat transcripts first.** There are 1 chat transcript(s) in `josh-bairstow-brand-system/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
**Find the primary design file under `josh-bairstow-brand-system/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
## About the design files
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
## Bundle contents
- `josh-bairstow-brand-system/README.md` — this file
- `josh-bairstow-brand-system/chats/` — conversation transcripts (read these!)
- `josh-bairstow-brand-system/project/` — the `Josh Bairstow — Brand System` project files (HTML prototypes, assets, components)

View file

@ -0,0 +1,202 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Posted Graphic — Josh Bairstow</title>
<link rel="stylesheet" href="colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: #16130F;
font-family: var(--font-body);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
min-height: 100vh; gap: 20px; padding: 24px;
}
/* ---- toolbar (outside the scaled canvas) ---- */
.toolbar {
display: flex; gap: 10px; align-items: center;
font-family: var(--font-mono); font-size: 11px; color: #9C8B72;
}
.toolbar .grp { display: flex; gap: 6px; align-items: center; }
.toolbar button {
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; cursor: pointer; color: #C9B89E;
background: transparent; border: 1px solid rgba(201,184,153,0.25);
border-radius: 3px; padding: 5px 10px;
transition: all var(--dur-base) var(--ease-out);
}
.toolbar button:hover { border-color: rgba(201,184,153,0.6); }
.toolbar button.on { background: rgba(201,184,153,0.14); border-color: rgba(201,184,153,0.6); color: #EFE7DA; }
.toolbar .lbl { opacity: 0.6; letter-spacing: 0.16em; text-transform: uppercase; }
/* ---- scaled stage ---- */
.viewport { display: flex; align-items: center; justify-content: center; }
.canvas {
width: 1080px; height: 1350px;
transform-origin: center center;
position: relative; overflow: hidden;
background: var(--ground);
box-shadow: 0 40px 100px rgba(0,0,0,0.55);
}
.canvas.dark { background: var(--ground-dark); }
/* page-wide grain greeble */
.canvas::before {
content: ""; position: absolute; inset: 0;
background-image: url(assets/texture-grain.svg);
background-size: 300px; opacity: 0.05; pointer-events: none; z-index: 5;
}
.canvas.dark::before { opacity: 0.07; mix-blend-mode: screen; }
/* sprinkled accents — simple crisp marks, loose placement */
.pgMark { position: absolute; pointer-events: none; z-index: 1;
filter: invert(54%) sepia(16%) saturate(340%) brightness(90%); opacity: 0.18; }
.canvas:not(.dark) .pgMark { filter: invert(36%) sepia(18%) saturate(280%) brightness(78%); opacity: 0.13; }
.pgMark.k1 { width: 150px; top: 250px; left: 60px; transform: rotate(-3deg); }
.pgMark.k2 { width: 70px; top: 980px; right: 70px; transform: rotate(4deg); }
.pgMark.k3 { width: 64px; top: 150px; right: 150px; }
.pad { position: absolute; inset: 76px 76px 72px; display: flex; flex-direction: column; z-index: 2; }
/* header row */
.head { display: flex; align-items: baseline; justify-content: space-between; }
.cat {
font-family: var(--font-body); font-weight: 500; font-size: 22px;
letter-spacing: 0.26em; text-transform: uppercase; color: var(--fg-3);
}
.canvas.dark .cat { color: var(--fg-on-dark-2); }
.handle {
font-family: var(--font-mono); font-size: 19px; color: var(--fg-3);
letter-spacing: 0.02em;
}
.canvas.dark .handle { color: var(--fg-on-dark-3); }
/* image cut-out */
.frameWrap { margin-top: 44px; position: relative; }
.frame {
position: relative; width: 100%; height: 620px;
border-radius: 8px 120px 8px 8px;
background: radial-gradient(120% 100% at 70% 14%, #C9B299 0%, #8A6A47 32%, #4A3826 64%, #1E1A16 96%);
overflow: hidden;
}
image-slot { position: absolute; inset: 0; width: 100%; height: 100%;
--is-bg: transparent; background: transparent; }
.frame .wm {
position: absolute; right: 30px; bottom: 26px; width: 150px;
opacity: 0.1; filter: invert(1); z-index: 4; pointer-events: none;
}
/* title block */
.body { margin-top: auto; }
.title {
font-family: var(--font-display); font-weight: 500;
font-size: 72px; line-height: 1.0; letter-spacing: -0.02em;
color: var(--fg-1); margin: 0; text-wrap: balance;
}
.canvas.dark .title { color: var(--fg-on-dark-1); }
.title em { font-style: italic; font-weight: 400; }
.dek {
font-family: var(--font-body); font-weight: 300; font-size: 25px;
line-height: 1.5; color: var(--fg-2); margin: 22px 0 0; max-width: 80%;
}
.canvas.dark .dek { color: var(--fg-on-dark-2); }
/* footer */
.foot {
margin-top: 40px; padding-top: 26px; border-top: 1px solid var(--hairline);
display: flex; align-items: flex-end; justify-content: space-between;
}
.canvas.dark .foot { border-color: var(--hairline-on-dark); }
.foot .sig { width: 90px; opacity: 0.28;
filter: invert(36%) sepia(18%) saturate(280%) brightness(78%); }
.canvas.dark .foot .sig { filter: invert(54%) sepia(16%) saturate(340%) brightness(90%); }
.foot .date {
font-family: var(--font-body); font-size: 21px; color: var(--fg-3);
font-feature-settings: "onum" 1; letter-spacing: 0.02em;
}
.canvas.dark .foot .date { color: var(--fg-on-dark-3); }
</style>
</head>
<body>
<div class="toolbar">
<span class="lbl">Theme</span>
<div class="grp">
<button id="t-light">Bone</button>
<button id="t-dark" class="on">Ink</button>
</div>
<span class="lbl" style="margin-left:14px">Watermark</span>
<div class="grp">
<button class="mk on" data-src="assets/mark-rings.svg">Orbit</button>
<button class="mk" data-src="assets/mark-rings-drift.svg">Drift</button>
<button class="mk" data-src="assets/mark-rings-cascade.svg">Cascade</button>
<button class="mk" data-src="assets/accent-concentric.svg">Concentric</button>
</div>
</div>
<div class="viewport">
<div class="canvas dark" id="canvas">
<img class="pgMark k1" src="assets/accent-ringdots.svg" alt="">
<img class="pgMark k2" src="assets/accent-pipes.svg" alt="">
<img class="pgMark k3" src="assets/accent-dots.svg" alt="">
<div class="pad">
<div class="head">
<span class="cat">Art</span>
<span class="handle">joshbairstow.com</span>
</div>
<div class="frameWrap">
<div class="frame">
<image-slot id="post-image" fit="cover"
placeholder="Drop a photo — warm, directional light"></image-slot>
<img class="wm" id="wm" src="assets/mark-rings.svg" alt="">
</div>
</div>
<div class="body">
<h1 class="title">Warm light,<br><em>cold water</em></h1>
<p class="dek">Notes on chiaroscuro, the ocean off Manly, and learning to see in value.</p>
<div class="foot">
<img class="sig" src="assets/accent-ringdots.svg" alt="">
<span class="date">March 2026</span>
</div>
</div>
</div>
</div>
</div>
<script src="templates/image-slot.js"></script>
<script>
// ---- fit-to-viewport scaling (canvas fixed at 1080x1350) ----
const canvas = document.getElementById('canvas');
const vp = document.querySelector('.viewport');
function fit() {
const availW = window.innerWidth - 48;
const availH = window.innerHeight - 120;
const s = Math.min(availW / 1080, availH / 1350);
canvas.style.transform = `scale(${s})`;
vp.style.width = (1080 * s) + 'px';
vp.style.height = (1350 * s) + 'px';
}
window.addEventListener('resize', fit); fit();
// ---- theme toggle ----
const tl = document.getElementById('t-light'), td = document.getElementById('t-dark');
tl.onclick = () => { canvas.classList.remove('dark'); tl.classList.add('on'); td.classList.remove('on'); };
td.onclick = () => { canvas.classList.add('dark'); td.classList.add('on'); tl.classList.remove('on'); };
// ---- watermark switch ----
const wm = document.getElementById('wm');
document.querySelectorAll('.mk').forEach(b => {
b.onclick = () => {
document.querySelectorAll('.mk').forEach(x => x.classList.remove('on'));
b.classList.add('on');
wm.src = b.dataset.src;
};
});
</script>
</body>
</html>

View file

@ -0,0 +1,238 @@
# Josh Bairstow — Brand System
A quiet-luxury personal brand system for **joshbairstow.com**. Warm browns and
ink; contemporary Nordic, "modern James Bond"; built on restraint, with a
*contained* street-art / calligraffiti "hand" that surfaces as an accent so
returning visitors notice craft the first visit never gave away.
> **The governing idea.** There is one base aesthetic and one accent, and they
> are not equal partners. **Base (~90% of the surface):** quiet luxury — muted
> warm-brown inks, generous negative space, chiaroscuro, precise typography,
> near-silent texture. **Accent (contained, occasional):** a gestural ink
> stroke / signature mark on dividers, hover states, transitions and the
> watermark only. It is the signature in the corner of the painting, never the
> painting.
>
> **Litmus test for every decision:** *Would a visitor barely register this on
> visit one, but feel a small delight noticing it on visit three?* Too loud on
> visit one → cut it. Invisible forever → not worth building.
---
## Sources
This system was built **from a written brand & styling brief**, not from an
existing codebase or Figma file. There is no upstream design source to link.
All palette hex, type pairings, marks, and templates in this folder are
proposals authored against the brief and are meant to be tuned with the owner.
---
## Context — what this brand is
Josh Bairstow is an individual: software engineer, runner/marathoner, ocean
person (freediving / surfing, Manly + WA roots), coffee, AFL — and, centrally
to the *aesthetic*, a follower of street art / calligraffiti (123Klan lineage),
manga and typography. **The interests inform flavour, not content.** Only the
art/design interest directly shapes the look (the accent layer + the signature
hand). Everything else stays as faint, optional nods on individual subdomain
pages later — never literal motifs in the core system.
**The site is a launchpad.** Home is a minimal hub that links out to a growing,
uneven set of subdomains:
| Subdomain | Purpose | Status |
|---|---|---|
| `blog` | Written content — home features its **latest post** as one line | live |
| `signage` | Chalk-on-easel signage side hustle (mechanical jotter) | side-project |
| `keycaps` | Custom keycaps for mechanical keyboards | side-project |
| `code` | Personal & public software experiments and utilities | live |
| `art` | Generative and physical art display | live |
| `social` | Links out — no presence yet | *coming online later* |
The navigation pattern must **read as an index** (not a button farm), tolerate a
growing/uneven number of items, and let a "not yet live" item exist without
looking broken.
---
## Content fundamentals — how copy is written
The voice is **editorial, sparse, and confident** — a gallery wall label, not a
marketing site. Restraint in words mirrors restraint in the visuals.
- **Person:** First person, understated ("I make things." / "Currently:
running, reading, shipping."). Never hype-y "we"-speak; this is one person.
- **Casing:** Sentence case for prose. **Small caps / uppercase tracking** for
eyebrows, labels and the subdomain index (e.g. `BLOG`, `CODE`, `ART`). The
wordmark is set in the display serif, title-styled.
- **Length:** Short. A latest-writing line is *one* line — title + a hairline
link, no panel, no thumbnail. Bait, not a billboard.
- **Punctuation:** The em-dash carries rhythm — used as a considered pause. True
small caps and old-style figures are deliberate "visit-three" details.
- **Emoji:** **None.** Not part of the brand. No exclamation-heavy energy.
- **Tone examples:**
- Eyebrow: `SELECTED WRITING`
- Latest line: `Notes on warm light and cold water — Mar 2026 →`
- Subdomain index item: `CODE — experiments & small tools`
- Coming-soon item: `SOCIAL — soon` (quiet, not "🚧 Under construction")
- Footer: `© Josh Bairstow — Sydney`
What to **avoid in copy:** exclamation marks, growth-marketing verbs ("Unlock",
"Supercharge"), feature lists, anything that shouts. If a line doesn't pass the
visit-one/visit-three test, cut it.
---
## Visual foundations
**Color.** Warm browns + cream, ink-forward, muted — tobacco, sepia, bone,
espresso. The whole system rides a **single warm tonal ramp** (`--bone-50`
`--ink-900`) plus one **muted ochre accent** used rarely. *Chiaroscuro is
achieved through value, not cool grey — shadows stay warm.* No more than **three
tones at once** on any view. Saturated color is essentially **banned** in
chrome/navigation; if color appears, it's a deliberate, rare event. See
`colors_and_type.css` for the full ramp + roles.
**Typography.** Editorial precision carries most of the "quiet luxury."
- **Display / name — Bodoni Moda.** High-contrast didone; fashion-editorial,
"modern Bond." Used large, sparing, confident. (Optical-size axis on; tight
negative tracking at display sizes.)
- **Body / UI — Hanken Grotesk.** Clean humanist grotesque, generous
line-height (1.62), comfortable measure (~66ch). Old-style figures enabled.
- **Mono — JetBrains Mono.** Reserved for the `code` subdomain nod / metadata.
- **The accent marks — `assets/accent-*.svg`.** Simple, crisp geometric glyphs
(ring-and-dots, a pipe cluster, a dot row, concentric rings) sprinkled with
**loose, non-rigid placement** throughout a view. Quiet but present — less
formal than the type, never a brush or a blob. Never a body typeface.
- **Visit-three type details:** true small caps (`--feat-smallcaps`), old-style
figures (`--feat-oldstyle`), refined wordmark kerning, em-dash rhythm.
**Default theme is the dark / ink ground** (`--ground-dark` #17130E) — deep,
warm, moody ("modern Bond"), light bone text. The bone ground is the alternate,
not the default. Same ramp, same rules either way.
**Backgrounds & texture (greebling).** Grounds are warm (ink or bone), never
flat. Fine, near-invisible surface detail rewards close/repeat inspection —
film grain plus **simple, crisp accent marks** (rings, dots, pipes) placed with
**loose, non-rigid positioning** throughout the view. **There is no
repeating/tiled pattern** — the marks are scattered and informal, not a
fashion-house tessellation, and not brushy blobs. **Hard guardrails
(enforced):** texture renders at **38% opacity**, watermarks at **410%**;
greebling sits **26% luminance** from its background; **no hard
edges** in the base layer — texture bleeds, it doesn't outline. The *delight*
version **lifts on interaction** (e.g. a pattern 4% → 8% on hover). Movement is
felt, not seen. Apply to: large empty grounds, dividers, card/panel
backgrounds, footer. **Keep greebling out of text areas** — legibility wins.
Assets: `texture-grain.svg`, `accent-ringdots.svg`, `accent-pipes.svg`,
`accent-dots.svg`, `accent-concentric.svg` (sprinkled, never tiled).
**Imagery.** Strong directional light, deep warm shadow — the black-and-white
architecture/fashion/automotive Instagram aesthetic, but warm-toned (sepia /
duotone toward espresso, not cold). Images are **clipped to a rounded,
asymmetric cut-out** (`--radius-cutout` — one big soft corner), **never a plain
rectangle**, and may carry the rings watermark per the opacity rules.
**Motion.** Slow, eased, short-travel — nothing bouncy or fast. Transitions
**200400ms** with generous easing (`--ease-out`). **One signature motion
moment max per view.** Examples: the greebling lift on hover, a hairline that
draws in along a divider, the ink stroke settling. Respect
`prefers-reduced-motion` — degrade to static (tokens collapse to 0ms).
**Interaction states.**
- *Hover:* texture/watermark lifts (4%→8%); a hairline draws/extends; link gets
a thin ochre tick or underline that grows from the start. No color floods.
- *Press:* a small value shift (text → `--ink-900`, surface → `--bone-200`); a
~1px nudge, never a bounce or scale-pop.
- *Focus:* warm 2px ochre outline at low opacity, offset — visible but quiet.
**Borders, radii, elevation.** Corners barely soften (`--radius-sm` 4px to
`--radius-lg` 14px); much of the chrome is **square-edged** with hairline rules.
Shadows are **warm** (espresso-tinted, low): `--shadow-1/2/3`. Cards are bone
panels with a hairline + a faint greeble, not heavy drop-shadow boxes. Avoid
rounded-corner + colored-left-border cards entirely.
**Layout.** Negative space is a feature. Editorial grid, generous margins, a
narrow measure for prose. The home page **fuses** its three jobs (hero +
latest-writing line + subdomain index) into one composition rather than stacking
three sections.
---
## Iconography
This brand is **near-iconless by design** — quiet luxury favors type and space
over UI furniture. The few marks that exist are **brand glyphs**, not a UI icon
set:
- **The brand marks** (`assets/mark-rings.svg`, `accent-ringdots.svg`,
`accent-pipes.svg`, `accent-dots.svg`, `accent-concentric.svg`) — authored
for this system; see § *Brand marks* below. None are initials; all are rings,
circles, dots and pipes.
- **Affordances** (links, "latest" line, nav) use **typographic** cues: a
trailing `→` (U+2192), a hairline underline that grows on hover, a thin ochre
tick. No filled UI icons, no icon font in the base brand.
- **Emoji / unicode:** emoji are **not used**. The em-dash (—), arrow (→), and
middot (·) are the only unicode "icons", used sparingly as typographic glue.
- **If a subdomain genuinely needs a UI icon set** (e.g. the `code`/tools pages),
use **Lucide** (https://lucide.dev) via CDN — 1.5px stroke, rounded caps,
set in `--fg-2` — as the closest match to the system's thin, warm,
unobtrusive line language. This is a documented substitution, not part of the
core brand. Keep icons out of the home page and marketing surfaces.
---
## Brand marks (not locked)
No initials. The watermark is a **rings-and-circles constellation** in the same
rounded language as the cut-out — circles of varied size at varied heights, not a
rigid row. The accents are **simple geometric marks** (rings, dots, pipes)
sprinkled with loose placement at a *visit-three* whisper. Three watermark
constellations are offered (not locked); the share template lets you switch live.
- **Orbit** (`mark-rings.svg`) — **primary watermark**. A larger open ring with
small circles orbiting at varied radii and heights (off the shared midline).
- **Drift** (`mark-rings-drift.svg`) / **Cascade** (`mark-rings-cascade.svg`) —
alternates: a loose ring-plus-satellites scatter, and graduated descending
circles anchored by a ring. All three are constellations, not a straight row.
- **Concentric** (`accent-concentric.svg`) — two concentric rings + a center dot.
- **Rounded cut-out** — imagery is clipped with `--radius-cutout`
(`6px 92px 6px 6px`: one big soft corner), never a plain rectangle.
- **Accent marks**`accent-ringdots.svg` (· ○ ·), `accent-pipes.svg` (a small
pipe cluster), `accent-dots.svg` (a dot row). Crisp shapes, **loose/informal
placement**, kept at a true *visit-three* whisper (~1018% in a muted tone) so
they read as craft, not grid-fill.
---
## Index — what's in this folder
| Path | What it is |
|---|---|
| `colors_and_type.css` | **Start here.** Color ramp, semantic roles, type families + scale, greebling/motion/spacing/elevation tokens, semantic type classes. |
| `assets/mark-rings.svg` | **Primary** watermark — "Orbit" ring constellation. |
| `assets/mark-rings-drift.svg` | Alternate watermark — "Drift". |
| `assets/mark-rings-cascade.svg` | Alternate watermark — "Cascade". |
| `assets/accent-concentric.svg` | Concentric-rings accent / alternate watermark. |
| `assets/accent-ringdots.svg` | Accent mark — ring with flanking dots. |
| `assets/accent-pipes.svg` | Accent mark — pipe cluster. |
| `assets/accent-dots.svg` | Accent mark — dot row. |
| `assets/texture-grain.svg` | Warm film-grain greebling tile. |
| `preview/` | Design-system cards (color, type, texture, marks, components) shown in the Design System tab. |
| `ui_kits/home/` | UI kit — the home page: fused hero + latest-writing line + subdomain index, with greebling in situ. |
| `Posted Graphic.html` | Reusable share/post template — image clipped to the cut-out + watermark + type system. Drop a photo into the slot. (At project root so the image-slot's drop persists.) |
| `templates/image-slot.js` | Drag-and-drop image component used by the posted-graphic template. |
| `SKILL.md` | Agent-Skill manifest for using this system in Claude Code / as a downloadable skill. |
---
## Caveats
- **Fonts are loaded from Google Fonts CDN**, not bundled into `fonts/`. Bodoni
Moda + Hanken Grotesk + JetBrains Mono are all Google-hosted. If you need an
offline/self-hosted build, drop the `.woff2` files into `fonts/` and swap the
`@import` for `@font-face`. (No font substitution was needed — these are the
intended families, not stand-ins.)
- The **brand marks are authored proposals.** Per the brief, the watermark is
deliberately *not* locked — the rings glyph is my recommended primary; pick
it or the concentric variant (or commission a refined draw) with the owner.

View file

@ -0,0 +1,59 @@
---
name: josh-bairstow-design
description: Use this skill to generate well-branded interfaces and assets for Josh Bairstow (joshbairstow.com), either for production or throwaway prototypes/mocks/etc. A quiet-luxury personal brand in warm browns and ink — contemporary Nordic, "modern Bond" — with a contained calligraffiti accent. Contains essential design guidelines, colors, type, fonts, signature marks, the greebling/texture spec, motion tokens, a home-page UI kit, and a posted-graphic template.
user-invocable: true
---
# Josh Bairstow — Brand System
Read **`README.md`** first — it carries the governing idea, the content
fundamentals, the full visual foundations, the iconography approach, and an
index of every file. Then explore the other files.
## The one rule to internalize
There is **one base aesthetic and one accent, and they are not equal partners.**
The **default theme is the dark / ink ground** (deep, warm, moody — "modern
Bond", light bone text; bone is the alternate). Base (~90%): quiet luxury — warm-brown inks, negative space, chiaroscuro through
*value*, precise type, near-silent texture. Accent (contained, rare): a gestural
ink stroke / signature mark on dividers, hover, transitions, and the watermark
only. Litmus test for every detail: *barely registered on visit one, a small
delight on visit three.* If it shouts on visit one, cut it.
## Foundations live in code
- `colors_and_type.css` — the warm tonal ramp, semantic color roles, type
families (Bodoni Moda / Hanken Grotesk / JetBrains Mono) + scale, and the
greebling / motion / spacing / elevation tokens. Import it; use the vars.
- `assets/` — the **rings & circles** watermark (`mark-rings.svg` primary,
`accent-concentric.svg` alt), the simple **accent marks** (`accent-ringdots`,
`accent-pipes`, `accent-dots`) sprinkled with loose placement, and
`texture-grain.svg`. No initials; rounded, calm geometry. Imagery is clipped
with `--radius-cutout` (one big soft corner), never a plain rectangle.
- `preview/` — design-system specimen cards (reference, not for shipping).
## Hard numbers — do not soften or skip
- **Greebling:** texture at **38%** opacity, watermarks at **410%**; sits
**26% luminance** from its background; no hard edges; lifts on hover
(e.g. 4%→8%). **Simple crisp marks (rings/dots/pipes), sprinkled with loose,
non-rigid placement — never a tiled pattern, never brushy blobs.** Keep them
out of text areas.
- **Color:** ≤ 3 tones per view; saturated color essentially banned in chrome;
the ochre accent is a rare, deliberate event.
- **Motion:** 200400ms, gentle ease, short-travel, one signature moment per
view; respect `prefers-reduced-motion`.
- **Imagery:** clipped to the **rounded cut-out** (`--radius-cutout`, one big
soft corner), never a plain rectangle; warm chiaroscuro (sepia/duotone toward
espresso), never cold.
## Working patterns
- **Home / launchpad layouts** → start from `ui_kits/home/` (fused hero +
one-line latest-writing + refined subdomain index, light/dark).
- **Share / post graphics** → start from `Posted Graphic.html` (image cut-out +
watermark + type system; drop a photo into the slot).
## How to deliver
If creating **visual artifacts** (slides, mocks, throwaway prototypes), copy the
assets out and produce static HTML files for the user to view. If working on
**production code**, copy the assets and read the rules here to design as an
expert in this brand. If invoked with no other guidance, ask what they want to
build, ask a few focused questions, then act as the brand's designer — output
HTML artifacts or production code as the need dictates.

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 70" width="70" height="70" role="img" aria-label="concentric rings accent">
<g fill="none" stroke="#221E18" stroke-width="2.4">
<circle cx="35" cy="35" r="30"></circle>
<circle cx="35" cy="35" r="18"></circle>
</g>
<circle cx="35" cy="35" r="5" fill="#221E18"></circle>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 16" width="64" height="16" role="img" aria-label="dot row accent">
<g fill="#221E18">
<circle cx="8" cy="8" r="3"></circle>
<circle cx="32" cy="8" r="3"></circle>
<circle cx="56" cy="8" r="3"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 288 B

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 30" width="44" height="30" role="img" aria-label="pipe accent">
<g fill="#221E18">
<rect x="6" y="4" width="2.4" height="22" rx="1.2"></rect>
<rect x="16" y="4" width="2.4" height="22" rx="1.2"></rect>
<rect x="26" y="4" width="2.4" height="22" rx="1.2"></rect>
<rect x="36" y="4" width="2.4" height="22" rx="1.2"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 412 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="ring-and-dots accent">
<circle cx="10" cy="12" r="3.4" fill="#221E18"></circle>
<circle cx="48" cy="12" r="9" fill="none" stroke="#221E18" stroke-width="2.4"></circle>
<circle cx="86" cy="12" r="3.4" fill="#221E18"></circle>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 92" width="180" height="92" role="img" aria-label="rings constellation D">
<circle cx="120" cy="44" r="24" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
<g fill="#221E18">
<circle cx="22" cy="30" r="9"></circle>
<circle cx="52" cy="48" r="6"></circle>
<circle cx="76" cy="62" r="4"></circle>
<circle cx="120" cy="44" r="4"></circle>
<circle cx="162" cy="74" r="3"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 80" width="180" height="80" role="img" aria-label="rings constellation A">
<g fill="#221E18">
<circle cx="28" cy="24" r="6.5"></circle>
<circle cx="156" cy="30" r="3.5"></circle>
<circle cx="132" cy="54" r="9" fill="#221E18"></circle>
</g>
<circle cx="84" cy="40" r="22" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
<circle cx="84" cy="40" r="4" fill="#221E18"></circle>
<circle cx="132" cy="54" r="9" fill="none" stroke="#221E18" stroke-width="3.2"></circle>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 90" width="180" height="90" role="img" aria-label="rings constellation B">
<circle cx="96" cy="46" r="26" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
<g fill="#221E18">
<circle cx="40" cy="30" r="5.5"></circle>
<circle cx="58" cy="68" r="3.5"></circle>
<circle cx="150" cy="34" r="7.5"></circle>
<circle cx="138" cy="72" r="3"></circle>
<circle cx="96" cy="46" r="3.5"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 490 B

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="160" height="160">
<filter id="g">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" stitchTiles="stitch"></feTurbulence>
<feColorMatrix type="matrix" values="0 0 0 0 0.13 0 0 0 0 0.12 0 0 0 0 0.09 0 0 0 1 0"></feColorMatrix>
</filter>
<rect width="160" height="160" filter="url(#g)"></rect>
</svg>

After

Width:  |  Height:  |  Size: 415 B

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;
}
}

View file

@ -0,0 +1,16 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground-dark);padding:30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:56px;align-items:center;justify-content:center}
.opt{display:flex;flex-direction:column;align-items:center;gap:14px}
.opt img{display:block;filter:invert(70%) sepia(18%) saturate(320%) brightness(95%)}
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-on-dark-2);letter-spacing:.06em}
</style></head><body>
<div class="wrap">
<div class="opt"><img src="../assets/accent-ringdots.svg" height="22"><span class="lab">ring-dots</span></div>
<div class="opt"><img src="../assets/accent-pipes.svg" height="28"><span class="lab">pipes</span></div>
<div class="opt"><img src="../assets/accent-dots.svg" height="14"><span class="lab">dots</span></div>
<div class="opt"><img src="../assets/accent-concentric.svg" height="40"><span class="lab">concentric</span></div>
</div>
</body></html>

View file

@ -0,0 +1,22 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:28px 30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:14px;align-items:center;flex-wrap:wrap}
button{font-family:var(--font-body);font-size:13px;letter-spacing:.02em;cursor:pointer;transition:all var(--dur-base) var(--ease-out)}
.solid{background:var(--ink-900);color:var(--bone-50);border:none;padding:11px 22px;border-radius:var(--radius-sm)}
.solid:hover{background:var(--espresso-700)}
.ghost{background:transparent;color:var(--ink-900);border:1px solid var(--hairline-strong);padding:10px 21px;border-radius:var(--radius-sm)}
.ghost:hover{border-color:var(--ink-900)}
.textlink{background:none;border:none;color:var(--ink-900);padding:6px 2px;position:relative;font-size:13px}
.textlink::after{content:"";position:absolute;left:2px;bottom:2px;height:1px;width:0;background:var(--accent-deep);transition:width var(--dur-base) var(--ease-out)}
.textlink:hover::after{width:calc(100% - 4px)}
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:100%}
</style></head><body>
<div class="wrap">
<button class="solid">Read the latest</button>
<button class="ghost">View work</button>
<button class="textlink">Get in touch →</button>
<span class="lab">solid · ghost · text-link (ochre underline grows on hover)</span>
</div>
</body></html>

View file

@ -0,0 +1,19 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;font-family:var(--font-body)}
.card{position:relative;background:var(--ground-raised);border:1px solid var(--hairline);border-radius:var(--radius-md);padding:22px 24px;overflow:hidden;max-width:340px}
.card::before{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:200px;opacity:.06;pointer-events:none}
.k{position:relative;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--fg-3);margin:0 0 8px}
.t{position:relative;font-family:var(--font-display);font-size:23px;color:var(--fg-1);margin:0 0 6px}
.d{position:relative;font-size:13px;color:var(--fg-2);line-height:1.55;margin:0}
</style></head><body>
<div class="wrap">
<div class="card">
<p class="k">Code</p>
<p class="t">Small tools</p>
<p class="d">Bone panel, hairline border, a greeble at 4.5%. No heavy shadow box.</p>
</div>
</div>
</body></html>

View file

@ -0,0 +1,21 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:24px;align-items:center}
.chip{width:96px;height:96px;border-radius:var(--radius-sm);flex:none}
.meta{display:flex;flex-direction:column;gap:6px}
.name{font-size:13px;color:var(--fg-1);font-weight:600}
.hex{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)}
.note{font-size:12px;color:var(--fg-2);line-height:1.5;max-width:300px;margin-top:4px}
</style></head><body>
<div class="wrap">
<div class="chip" style="background:var(--accent)"></div>
<div class="chip" style="background:var(--accent-deep)"></div>
<div class="meta">
<div class="name">accent · muted ochre</div>
<div class="hex">#C0883A&nbsp;&nbsp;/&nbsp;&nbsp;#9A6A28 deep</div>
<div class="note">The single restrained highlight. A deliberate, rare event — hover ticks, a drawn hairline, the occasional link. Never floods chrome or navigation.</div>
</div>
</div>
</body></html>

View file

@ -0,0 +1,22 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground-dark);padding:30px 32px;box-sizing:border-box;font-family:var(--font-body)}
.head{font-family:var(--font-display);font-size:30px;color:var(--fg-on-dark-1);letter-spacing:-.01em;margin:0 0 6px}
.sub{font-size:13px;color:var(--fg-on-dark-2);margin:0 0 18px}
.meta{font-size:12px;color:var(--fg-on-dark-3);font-family:var(--font-mono)}
.chips{display:flex;gap:10px;margin-top:18px}
.chip{height:34px;flex:1;border-radius:var(--radius-xs);display:flex;align-items:center;padding-left:10px;font-family:var(--font-mono);font-size:10px}
</style></head><body>
<div class="wrap">
<p class="head">Ink ground — the default theme.</p>
<p class="sub">Deep, warm, moody. Chiaroscuro through value, never cool grey.</p>
<p class="meta">ground-dark #17130E &nbsp;·&nbsp; text on dark stays bone</p>
<div class="chips">
<div class="chip" style="background:#EDE4D5;color:#17130E">fg-on-dark-1</div>
<div class="chip" style="background:#AE9B7F;color:#17130E">fg-on-dark-2</div>
<div class="chip" style="background:#6F5E48;color:#17130E">fg-on-dark-3</div>
</div>
</div>
</body></html>

View file

@ -0,0 +1,30 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body)}
.ramp{display:flex;border-radius:var(--radius-sm);overflow:hidden;box-shadow:var(--shadow-1)}
.sw{flex:1;height:86px;display:flex;align-items:flex-end;padding:8px 6px;box-sizing:border-box}
.sw span{font-family:var(--font-mono);font-size:9.5px;letter-spacing:-.02em}
.row{display:flex;justify-content:space-between;margin-top:12px}
.row span{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);flex:1;text-align:center}
</style></head><body>
<div class="wrap">
<div class="ramp">
<div class="sw" style="background:#F4EFE6"><span style="color:#7A5C3E">50</span></div>
<div class="sw" style="background:#EDE6D9"><span style="color:#7A5C3E">100</span></div>
<div class="sw" style="background:#E0D5C3"><span style="color:#5A4630">200</span></div>
<div class="sw" style="background:#C9B299"><span style="color:#3B2C1E">300</span></div>
<div class="sw" style="background:#A8855F"><span style="color:#221E18">400</span></div>
<div class="sw" style="background:#7A5C3E"><span style="color:#F4EFE6">500</span></div>
<div class="sw" style="background:#5A4630"><span style="color:#F4EFE6">600</span></div>
<div class="sw" style="background:#3B2C1E"><span style="color:#EDE6D9">700</span></div>
<div class="sw" style="background:#2E2720"><span style="color:#EDE6D9">800</span></div>
<div class="sw" style="background:#221E18"><span style="color:#EDE6D9">900</span></div>
<div class="sw" style="background:#1E1A16"><span style="color:#B7A488">950</span></div>
</div>
<div class="row">
<span>#F4EFE6</span><span>bone</span><span>sand</span><span>sepia</span><span>tobacco</span><span>espresso</span><span>ink&nbsp;#221E18</span>
</div>
</div>
</body></html>

View file

@ -0,0 +1,19 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body);display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.role{display:flex;flex-direction:column;gap:8px}
.chip{height:54px;border-radius:var(--radius-sm)}
.name{font-size:12px;color:var(--fg-1);font-weight:500}
.hex{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
</style></head><body>
<div class="wrap">
<div class="role"><div class="chip" style="background:var(--ground);box-shadow:inset 0 0 0 1px var(--hairline)"></div><div class="name">ground</div><div class="hex">#F4EFE6</div></div>
<div class="role"><div class="chip" style="background:var(--ground-raised);box-shadow:inset 0 0 0 1px var(--hairline)"></div><div class="name">ground-raised</div><div class="hex">#EDE6D9</div></div>
<div class="role"><div class="chip" style="background:var(--ground-sunk);box-shadow:inset 0 0 0 1px var(--hairline)"></div><div class="name">ground-sunk</div><div class="hex">#E0D5C3</div></div>
<div class="role"><div class="chip" style="background:var(--fg-1)"></div><div class="name">fg-1 · primary</div><div class="hex">#221E18</div></div>
<div class="role"><div class="chip" style="background:var(--fg-2)"></div><div class="name">fg-2 · secondary</div><div class="hex">#5A4630</div></div>
<div class="role"><div class="chip" style="background:var(--fg-3)"></div><div class="name">fg-3 · muted</div><div class="hex">#A8855F</div></div>
</div>
</body></html>

View file

@ -0,0 +1,23 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:26px;align-items:center}
.cut{width:150px;height:184px;position:relative;flex:none;
border-radius:8px 80px 8px 8px;overflow:hidden;
background:radial-gradient(120% 90% at 72% 12%, #C9B299 0%, #7A5C3E 38%, #3B2C1E 72%, #1E1A16 100%);}
.cut::after{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:180px;opacity:.10;mix-blend-mode:overlay}
.mark{position:absolute;right:12px;bottom:12px;width:70px;opacity:.12;filter:invert(1)}
.meta h4{font-family:var(--font-display);font-size:21px;color:var(--fg-1);margin:0 0 8px}
.meta p{font-size:12.5px;color:var(--fg-2);line-height:1.55;margin:0;max-width:280px}
.meta code{font-family:var(--font-mono);font-size:11px;color:var(--accent-deep)}
</style></head><body>
<div class="wrap">
<div class="cut"><img class="mark" src="../assets/mark-rings.svg"></div>
<div class="meta">
<h4>Never a plain rectangle</h4>
<p>Imagery is clipped to a rounded cut-out and carries the rings watermark at 410%. Warm chiaroscuro placeholder shown.</p>
<p style="margin-top:8px"><code>border-radius: 6px 92px 6px 6px</code></p>
</div>
</div>
</body></html>

View file

@ -0,0 +1,30 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground-dark);padding:0;box-sizing:border-box;display:flex;font-family:var(--font-body)}
.panel{flex:1;height:180px;position:relative;overflow:hidden}
.panel::before{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:240px;opacity:.06;mix-blend-mode:screen}
.m{position:absolute;filter:invert(60%) sepia(18%) saturate(360%) brightness(95%);transition:opacity var(--dur-slow) var(--ease-out)}
.lab{position:absolute;left:16px;bottom:12px;font-family:var(--font-mono);font-size:11px;color:var(--fg-on-dark-2);z-index:2}
.divider{width:1px;background:var(--hairline-on-dark)}
/* loose, non-rigid placement */
.a1{width:96px;top:24px;left:34px;transform:rotate(-4deg);opacity:.26}
.a2{width:30px;top:104px;left:150px;transform:rotate(6deg);opacity:.3}
.a3{width:50px;top:54px;right:54px;opacity:.24}
.b1{width:80px;top:34px;left:60px;transform:rotate(3deg);opacity:.28}
.b2{width:34px;bottom:40px;left:40px;opacity:.28}
.b3{width:44px;top:30px;right:40px;opacity:.26}
</style></head><body>
<div class="wrap">
<div class="panel">
<img class="m a1" src="../assets/accent-ringdots.svg"><img class="m a2" src="../assets/accent-pipes.svg"><img class="m a3" src="../assets/accent-dots.svg">
<span class="lab">simple marks · loose placement · visit-three subtlety</span>
</div>
<div class="divider"></div>
<div class="panel">
<img class="m b1" src="../assets/accent-dots.svg"><img class="m b2" src="../assets/accent-concentric.svg"><img class="m b3" src="../assets/accent-ringdots.svg">
<span class="lab">non-rigid · throughout</span>
</div>
</div>
</body></html>

View file

@ -0,0 +1,17 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:0;box-sizing:border-box;display:flex;font-family:var(--font-body)}
.panel{flex:1;height:160px;position:relative;display:flex;align-items:flex-end;padding:16px;box-sizing:border-box;overflow:hidden}
.panel::before{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:200px;opacity:.04;transition:opacity var(--dur-slow) var(--ease-out)}
.panel.hi::before{opacity:.08}
.lab{position:relative;font-family:var(--font-mono);font-size:11px;color:var(--fg-2)}
.divider{width:1px;background:var(--hairline)}
</style></head><body>
<div class="wrap">
<div class="panel"><span class="lab">grain · rest 4%</span></div>
<div class="divider"></div>
<div class="panel hi"><span class="lab">grain · hover 8%</span></div>
</div>
</body></html>

View file

@ -0,0 +1,19 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:28px 30px;box-sizing:border-box;font-family:var(--font-body)}
.eyebrow{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--fg-3);margin:0 0 12px}
.line{display:inline-flex;align-items:baseline;gap:14px;text-decoration:none;color:var(--fg-1)}
.title{font-family:var(--font-display);font-size:26px;letter-spacing:-.01em}
.meta{font-family:var(--font-body);font-size:12px;color:var(--fg-3);font-feature-settings:"onum" 1}
.arrow{color:var(--accent-deep);transition:transform var(--dur-base) var(--ease-out)}
.line:hover .arrow{transform:translateX(4px)}
.rule{height:1px;background:var(--hairline);margin-top:14px}
</style></head><body>
<div class="wrap">
<p class="eyebrow">Selected writing</p>
<a class="line" href="#"><span class="title">Notes on warm light and cold water</span><span class="meta">Mar 2026</span><span class="arrow"></span></a>
<div class="rule"></div>
</div>
</body></html>

View file

@ -0,0 +1,21 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground-dark);padding:24px 28px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:34px;align-items:center;justify-content:center}
.opt{display:flex;flex-direction:column;align-items:center;gap:11px}
.opt img{display:block;filter:invert(92%) sepia(10%) saturate(380%) brightness(98%)}
.cut{width:118px;height:90px;border-radius:6px 52px 6px 6px;position:relative;overflow:hidden;
background:radial-gradient(120% 100% at 70% 16%,#C9B299,#7A5C3E 40%,#3B2C1E 74%,#1E1A16)}
.cut img{position:absolute;right:9px;bottom:8px;width:58px;filter:invert(1);opacity:.13}
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-on-dark-2);letter-spacing:.04em;text-align:center}
.lab b{color:var(--fg-on-dark-1);font-weight:600}
</style></head><body>
<div class="wrap">
<div class="opt"><img src="../assets/mark-rings.svg" height="44"><span class="lab"><b>Orbit</b><br>primary</span></div>
<div class="opt"><img src="../assets/mark-rings-drift.svg" height="44"><span class="lab">Drift</span></div>
<div class="opt"><img src="../assets/mark-rings-cascade.svg" height="44"><span class="lab">Cascade</span></div>
<div class="opt"><img src="../assets/accent-concentric.svg" height="44"><span class="lab">Concentric</span></div>
<div class="opt"><div class="cut"><img src="../assets/mark-rings.svg"></div><span class="lab">rounded cut-out<br>+ watermark @13%</span></div>
</div>
</body></html>

View file

@ -0,0 +1,16 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body)}
.track{position:relative;height:6px;background:var(--ground-sunk);border-radius:3px;margin:18px 0 10px}
.dot{position:absolute;top:-5px;width:16px;height:16px;border-radius:50%;background:var(--ink-900);animation:slide 2.6s var(--ease-out) infinite alternate}
@keyframes slide{from{left:0}to{left:calc(100% - 16px)}}
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);line-height:1.7}
.acc{color:var(--accent-deep)}
</style></head><body>
<div class="wrap">
<div class="track"><div class="dot"></div></div>
<p class="meta">dur-fast <span class="acc">200ms</span> · dur-base <span class="acc">300ms</span> · dur-slow <span class="acc">400ms</span><br>ease-out cubic-bezier(.22, 1, .36, 1) — slow, eased, short-travel<br>one signature motion moment per view · respects reduced-motion</p>
</div>
</body></html>

View file

@ -0,0 +1,22 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;font-family:var(--font-body)}
.idx{display:flex;flex-direction:column}
.item{display:flex;align-items:baseline;gap:16px;text-decoration:none;padding:11px 0;border-top:1px solid var(--hairline);transition:padding-left var(--dur-base) var(--ease-out)}
.item:hover{padding-left:8px}
.num{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:24px;flex:none}
.name{font-family:var(--font-display);font-size:21px;color:var(--fg-1);letter-spacing:.02em}
.desc{font-size:12px;color:var(--fg-3);margin-left:auto}
.soon{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-3);border:1px solid var(--hairline);border-radius:2px;padding:2px 6px;margin-left:auto}
</style></head><body>
<div class="wrap">
<div class="idx">
<a class="item" href="#"><span class="num">01</span><span class="name">Blog</span><span class="desc">writing</span></a>
<a class="item" href="#"><span class="num">02</span><span class="name">Code</span><span class="desc">experiments &amp; tools</span></a>
<a class="item" href="#"><span class="num">03</span><span class="name">Art</span><span class="desc">generative &amp; physical</span></a>
<a class="item" href="#"><span class="num">04</span><span class="name" style="color:var(--fg-3)">Social</span><span class="soon">soon</span></a>
</div>
</div>
</body></html>

View file

@ -0,0 +1,17 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;display:flex;gap:22px;align-items:flex-end;font-family:var(--font-body)}
.item{display:flex;flex-direction:column;gap:8px;align-items:center}
.box{width:78px;height:60px;background:var(--ground-raised);box-shadow:inset 0 0 0 1px var(--hairline-strong)}
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
</style></head><body>
<div class="wrap">
<div class="item"><div class="box" style="border-radius:2px"></div><span class="lab">xs · 2</span></div>
<div class="item"><div class="box" style="border-radius:4px"></div><span class="lab">sm · 4</span></div>
<div class="item"><div class="box" style="border-radius:8px"></div><span class="lab">md · 8</span></div>
<div class="item"><div class="box" style="border-radius:14px"></div><span class="lab">lg · 14</span></div>
<div class="item" style="align-self:center"><span class="lab" style="max-width:150px;text-align:left;line-height:1.5">Corners barely soften — much of the chrome is square-edged with hairline rules.</span></div>
</div>
</body></html>

View file

@ -0,0 +1,16 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:34px 30px;box-sizing:border-box;display:flex;gap:30px;font-family:var(--font-body)}
.item{display:flex;flex-direction:column;gap:14px;align-items:center}
.box{width:96px;height:64px;background:var(--bone-50);border-radius:var(--radius-md)}
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
</style></head><body>
<div class="wrap">
<div class="item"><div class="box" style="box-shadow:var(--shadow-1)"></div><span class="lab">shadow-1</span></div>
<div class="item"><div class="box" style="box-shadow:var(--shadow-2)"></div><span class="lab">shadow-2</span></div>
<div class="item"><div class="box" style="box-shadow:var(--shadow-3)"></div><span class="lab">shadow-3</span></div>
<div class="item" style="align-self:center"><span class="lab" style="max-width:150px;text-align:left;line-height:1.55">Warm, espresso-tinted, low. Never neutral black — shadows stay warm.</span></div>
</div>
</body></html>

View file

@ -0,0 +1,20 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body)}
.row{display:flex;align-items:center;gap:12px;margin-bottom:9px}
.bar{height:14px;background:var(--tobacco-500);border-radius:2px}
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:64px;flex:none}
</style></head><body>
<div class="wrap">
<div class="row"><span class="lab">2 · 8</span><div class="bar" style="width:8px"></div></div>
<div class="row"><span class="lab">4 · 16</span><div class="bar" style="width:16px"></div></div>
<div class="row"><span class="lab">5 · 24</span><div class="bar" style="width:24px"></div></div>
<div class="row"><span class="lab">6 · 32</span><div class="bar" style="width:32px"></div></div>
<div class="row"><span class="lab">7 · 48</span><div class="bar" style="width:48px"></div></div>
<div class="row"><span class="lab">8 · 64</span><div class="bar" style="width:64px"></div></div>
<div class="row"><span class="lab">9 · 96</span><div class="bar" style="width:96px"></div></div>
<div class="row"><span class="lab">10 · 128</span><div class="bar" style="width:128px"></div></div>
</div>
</body></html>

View file

@ -0,0 +1,15 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box}
.lead{font-family:var(--font-body);font-weight:300;font-size:19px;line-height:1.6;color:var(--fg-1);margin:0 0 10px;max-width:58ch}
.body{font-family:var(--font-body);font-weight:400;font-size:15px;line-height:1.62;color:var(--fg-2);margin:0;max-width:62ch;font-feature-settings:"onum" 1,"pnum" 1}
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin-top:14px}
</style></head><body>
<div class="wrap">
<p class="lead">I make things — software, small tools, the occasional mark in ink.</p>
<p class="body">A clean humanist grotesque set with generous line-height and a comfortable measure. Old-style figures 0123456789 sit low and even in running text, the way they should.</p>
<p class="meta">Hanken Grotesk · body/UI · 300 lead · 400 body · 1.62 leading</p>
</div>
</body></html>

View file

@ -0,0 +1,19 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;display:flex;gap:34px;align-items:flex-start}
.col{display:flex;flex-direction:column;gap:4px}
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);letter-spacing:.04em}
.smc{font-family:var(--font-display);font-size:25px;color:var(--fg-1);font-feature-settings:"smcp" 1,"onum" 1;text-transform:lowercase;letter-spacing:.06em}
.osf{font-family:var(--font-display);font-size:25px;color:var(--fg-1);font-feature-settings:"onum" 1,"pnum" 1}
.em{font-family:var(--font-display);font-size:25px;color:var(--fg-1)}
.note{font-size:11px;color:var(--fg-2);margin-top:6px;max-width:200px;line-height:1.4}
</style></head><body>
<div class="wrap">
<div class="col"><span class="lab">TRUE SMALL CAPS</span><span class="smc">Selected Writing</span></div>
<div class="col"><span class="lab">OLD-STYLE FIGURES</span><span class="osf">1290 · 2026</span></div>
<div class="col"><span class="lab">EM-DASH RHYTHM</span><span class="em">light — and shadow</span></div>
</div>
<div class="wrap" style="padding-top:0"><div class="note">The "visit-three" details. Barely registered on visit one; quietly precise on closer reading.</div></div>
</body></html>

View file

@ -0,0 +1,14 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box}
.big{font-family:var(--font-display);font-weight:500;font-size:74px;line-height:.96;color:var(--fg-1);letter-spacing:-.02em;margin:0}
.ital{font-style:italic;font-weight:400}
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin-top:14px}
</style></head><body>
<div class="wrap">
<p class="big">Warm light,<br><span class="ital">cold water</span></p>
<p class="meta">Bodoni Moda · display · 500 / italic 400 · tracking 0.02em</p>
</div>
</body></html>

View file

@ -0,0 +1,15 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box}
.mono{font-family:var(--font-mono);font-size:15px;color:var(--fg-1);margin:0;line-height:1.7}
.dim{color:var(--fg-3)}
.acc{color:var(--accent-deep)}
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin-top:16px}
</style></head><body>
<div class="wrap">
<p class="mono"><span class="dim">$</span> jb deploy <span class="acc">--art</span><br><span class="dim"></span> shipped 0 regressions · 1,284ms</p>
<p class="meta">JetBrains Mono · code/tools subdomain only · 400 / 500</p>
</div>
</body></html>

View file

@ -0,0 +1,19 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:22px 30px;box-sizing:border-box;font-family:var(--font-display);color:var(--fg-1)}
.row{display:flex;align-items:baseline;gap:16px;border-bottom:1px solid var(--hairline);padding:5px 0}
.row:last-child{border-bottom:none}
.tok{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:78px;flex:none}
.spec{line-height:1;letter-spacing:-.01em;white-space:nowrap;overflow:hidden}
</style></head><body>
<div class="wrap">
<div class="row"><span class="tok">step-6 · 88</span><span class="spec" style="font-size:42px">Display</span></div>
<div class="row"><span class="tok">step-5 · 60</span><span class="spec" style="font-size:34px">Heading one</span></div>
<div class="row"><span class="tok">step-4 · 44</span><span class="spec" style="font-size:27px">Heading two</span></div>
<div class="row"><span class="tok">step-3 · 32</span><span class="spec" style="font-size:21px">Heading three</span></div>
<div class="row"><span class="tok">step-1 · 19</span><span class="spec" style="font-size:16px;font-family:var(--font-body);font-weight:300">Lead paragraph</span></div>
<div class="row"><span class="tok">step-0 · 16</span><span class="spec" style="font-size:14px;font-family:var(--font-body);font-weight:400">Body text</span></div>
</div>
</body></html>

View file

@ -0,0 +1,23 @@
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../colors_and_type.css">
<style>
body{margin:0}
.wrap{background:var(--ground);padding:30px;box-sizing:border-box;font-family:var(--font-body);display:flex;align-items:center;gap:30px}
.mark{display:flex;flex-direction:column;line-height:1}
.name{font-family:var(--font-display);font-weight:500;font-size:42px;color:var(--fg-1);letter-spacing:-.015em}
.name .dot{color:var(--accent-deep)}
.role{font-family:var(--font-body);font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--fg-3);margin-top:8px;padding-left:2px}
.div{width:1px;height:60px;background:var(--hairline)}
.small{display:flex;align-items:center;gap:14px}
.small img{height:40px}
.small span{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
</style></head><body>
<div class="wrap">
<div class="mark">
<span class="name">Josh Bairstow<span class="dot">.</span></span>
<span class="role">Engineer &middot; Sydney</span>
</div>
<div class="div"></div>
<div class="small"><img src="../assets/mark-rings.svg"><span>favicon /<br>small mark</span></div>
</div>
</body></html>

View file

@ -0,0 +1,642 @@
/* BEGIN USAGE */
/**
* <image-slot> user-fillable image placeholder.
*
* Drop this into a deck, mockup, or page wherever you want the user to
* supply an image. You control the slot's shape and size; the user fills it
* by dragging an image file onto it (or clicking to browse). The dropped
* image persists across reloads via a .image-slots.state.json sidecar
* same read-via-fetch / write-via-window.omelette pattern as
* design_canvas.jsx, so the filled slot shows on share links, downloaded
* zips, and PPTX export. Outside the omelette runtime the slot is read-only.
*
* The host bridge only allows sidecar writes at the project root, so the
* HTML that uses this component is assumed to live at the project root too
* (same constraint as design_canvas.jsx).
*
* Attributes:
* id Persistence key. REQUIRED for the drop to survive reload
* every slot on the page needs a distinct id.
* shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded')
* 'circle' applies 50% border-radius; on a non-square slot
* that's an ellipse set equal width and height for a true
* circle.
* radius Corner radius in px for 'rounded'. (default 12)
* mask Any CSS clip-path value. Overrides `shape` use this for
* hexagons, blobs, arbitrary polygons.
* fit object-fit: cover | contain | fill. (default 'cover')
* With cover (the default) double-clicking the filled slot
* enters a reframe mode: the whole image spills past the mask
* (translucent outside, opaque inside), drag to reposition,
* corner-drag to scale. The crop persists alongside the image
* in the sidecar. contain/fill stay static.
* position object-position for fit=contain|fill. (default '50% 50%')
* placeholder Empty-state caption. (default 'Drop an image')
* src Optional initial/fallback image URL. A user drop overrides
* it; clearing the drop reveals src again.
*
* Size and layout come from ordinary CSS on the element width/height
* inline or from a parent grid so it composes with any layout.
*
* Usage:
* <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20"
* placeholder="Drop a hero image"></image-slot>
* <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
* <image-slot id="kite" style="width:300px;height:300px"
* mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
*/
/* END USAGE */
(() => {
const STATE_FILE = '.image-slots.state.json';
// 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the
// sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB.
const MAX_DIM = 1200;
// Raster formats only. SVG is excluded (can carry script; createImageBitmap
// on SVG blobs is inconsistent). GIF is excluded because the canvas
// re-encode keeps only the first frame, so an animated GIF would silently
// go still — better to reject than surprise.
const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
// ── Shared sidecar store ────────────────────────────────────────────────
// One fetch + immediate write-on-change for every <image-slot> on the
// page. Reads via fetch() so viewing works anywhere the HTML and sidecar
// are served together; writes go through window.omelette.writeFile, which
// the host allowlists to *.state.json basenames only.
const subs = new Set();
let slots = {};
// ids explicitly cleared before the sidecar fetch resolved — otherwise
// the merge below can't tell "never set" from "just deleted" and would
// resurrect the sidecar's stale value.
const tombstones = new Set();
let loaded = false;
let loadP = null;
function load() {
if (loadP) return loadP;
loadP = fetch(STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
// Merge: sidecar loses to any in-memory change that raced ahead of
// the fetch (drop or clear) so neither is clobbered by hydration.
if (j && typeof j === 'object') {
const merged = Object.assign({}, j, slots);
// A framing-only write that raced ahead of hydration must not
// drop a user image that's only on disk — inherit u from the
// sidecar for any in-memory entry that lacks one.
for (const k in slots) {
if (merged[k] && !merged[k].u && j[k]) {
merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u;
}
}
for (const id of tombstones) delete merged[id];
slots = merged;
}
tombstones.clear();
})
.catch(() => {})
.then(() => { loaded = true; subs.forEach((fn) => fn()); });
return loadP;
}
// Serialize writes so two near-simultaneous drops on different slots
// can't reorder at the backend and leave the sidecar with only the
// first. A save requested mid-flight just marks dirty and re-fires on
// completion with the then-current slots.
let saving = false;
let saveDirty = false;
function save() {
if (saving) { saveDirty = true; return; }
const w = window.omelette && window.omelette.writeFile;
if (!w) return;
saving = true;
Promise.resolve(w(STATE_FILE, JSON.stringify(slots)))
.catch(() => {})
.then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } });
}
const S_MAX = 5;
const clampS = (s) => Math.max(1, Math.min(S_MAX, s));
// Normalize a stored slot value. Pre-reframe sidecars stored a bare
// data-URL string; newer ones store {u, s, x, y}. Either shape is valid.
function getSlot(id) {
const v = slots[id];
if (!v) return null;
return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v;
}
function setSlot(id, val) {
if (!id) return;
if (val) { slots[id] = val; tombstones.delete(id); }
else { delete slots[id]; if (!loaded) tombstones.add(id); }
subs.forEach((fn) => fn());
// A drop is rare + high-value — write immediately so nav-away can't lose
// it. Gate on the initial read so we don't overwrite a sidecar we haven't
// merged yet; the merge in load() keeps this change once the read lands.
if (loaded) save(); else load().then(save);
}
// ── Image downscale ─────────────────────────────────────────────────────
// Encode through a canvas so the sidecar carries resized bytes, not the
// raw upload. Longest side is capped at 2× the slot's rendered width
// (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG
// for photos, so there's no need for per-image format picking.
async function toDataUrl(file, targetW) {
const bitmap = await createImageBitmap(file);
try {
const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM);
const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height));
const w = Math.max(1, Math.round(bitmap.width * scale));
const h = Math.max(1, Math.round(bitmap.height * scale));
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
return canvas.toDataURL('image/webp', 0.85);
} finally {
bitmap.close && bitmap.close();
}
}
// ── Custom element ──────────────────────────────────────────────────────
const stylesheet =
':host{display:inline-block;position:relative;vertical-align:top;' +
' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' +
'.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' +
// .frame img (clipped) and .spill (unclipped ghost + handles) share the
// same left/top/width/height in frame-%, computed by _applyView(), so the
// inside-mask crop and the outside-mask spill stay pixel-aligned.
'.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' +
' -webkit-user-drag:none;user-select:none;touch-action:none}' +
// Reframe mode (double-click): the full image spills past the mask. The
// spill layer is sized to the IMAGE bounds so its corners are where the
// resize handles belong. The ghost <img> inside is translucent; the real
// clipped <img> underneath shows the opaque in-mask crop.
'.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' +
' cursor:grab;touch-action:none}' +
':host([data-panning]) .spill{cursor:grabbing}' +
'.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' +
' pointer-events:none;-webkit-user-drag:none;user-select:none;' +
' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' +
'.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' +
' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' +
' transform:translate(-50%,-50%)}' +
'.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' +
'.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' +
'.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' +
'.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' +
':host([data-reframe]){z-index:10}' +
':host([data-reframe]) .spill{display:block}' +
':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' +
'.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
' cursor:pointer;user-select:none}' +
'.empty svg{opacity:.45}' +
'.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' +
'.empty .sub{font-size:11px}' +
'.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' +
'.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' +
':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' +
' background:rgba(201,100,66,.10)}' +
'.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' +
' transition:border-color .12s}' +
':host([data-over]) .ring{border-color:#c96442}' +
':host([data-filled]) .ring{display:none}' +
// Controls sit BELOW the mask (top:100%), absolutely positioned so the
// author-declared slot height is unaffected. The gap is padding, not a
// top offset, so the hover target stays contiguous with the frame.
'.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' +
' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
' white-space:nowrap}' +
':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' +
' {opacity:1;pointer-events:auto}' +
'.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' +
' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' +
' backdrop-filter:blur(6px)}' +
'.ctl button:hover{background:rgba(0,0,0,.8)}' +
'.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' +
' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}';
const icon =
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
'<path d="m21 15-5-5L5 21"/></svg>';
class ImageSlot extends HTMLElement {
static get observedAttributes() {
return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id'];
}
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
// .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
// on the frame (circle, pill, rounded) can't clip them.
root.innerHTML =
'<style>' + stylesheet + '</style>' +
'<div class="frame" part="frame">' +
' <img part="image" alt="" draggable="false" style="display:none">' +
' <div class="empty" part="empty">' + icon +
' <div class="cap"></div>' +
' <div class="sub">or <u>browse files</u></div></div>' +
' <div class="ring" part="ring"></div>' +
'</div>' +
'<div class="spill">' +
' <img class="ghost" alt="" draggable="false">' +
' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
'</div>' +
'<div class="ctl"><button data-act="replace" title="Replace image">Replace</button>' +
' <button data-act="clear" title="Remove image">Remove</button></div>' +
'<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
this._frame = root.querySelector('.frame');
this._ring = root.querySelector('.ring');
this._img = root.querySelector('.frame img');
this._empty = root.querySelector('.empty');
this._cap = root.querySelector('.cap');
this._sub = root.querySelector('.sub');
this._spill = root.querySelector('.spill');
this._ghost = root.querySelector('.ghost');
this._err = null;
this._input = root.querySelector('input');
this._depth = 0;
this._gen = 0;
this._view = { s: 1, x: 0, y: 0 };
this._subFn = () => this._render();
// Shadow-DOM listeners live with the shadow DOM — bound once here so
// disconnect/reconnect (e.g. React remount) doesn't stack handlers.
this._empty.addEventListener('click', () => this._input.click());
root.addEventListener('click', (e) => {
const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
if (act === 'replace') { this._exitReframe(true); this._input.click(); }
if (act === 'clear') {
this._exitReframe(false);
this._gen++;
this._local = null;
if (this.id) setSlot(this.id, null); else this._render();
}
});
this._input.addEventListener('change', () => {
const f = this._input.files && this._input.files[0];
if (f) this._ingest(f);
this._input.value = '';
});
// naturalWidth/Height aren't known until load — re-apply so the cover
// baseline is computed from real dimensions, not the 100%×100% fallback.
this._img.addEventListener('load', () => this._applyView());
// Gated on editable + fit=cover so share links and contain/fill slots
// stay static.
this.addEventListener('dblclick', (e) => {
if (!this.hasAttribute('data-editable') || !this._reframes()) return;
e.preventDefault();
if (this.hasAttribute('data-reframe')) this._exitReframe(true);
else this._enterReframe();
});
// Pan + resize both originate on the spill layer. A handle pointerdown
// drives an aspect-locked resize anchored at the opposite corner; any
// other pointerdown on the spill pans. Offsets are frame-% so a
// reframed slot survives responsive resize / PPTX export.
this._spill.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !this.hasAttribute('data-reframe')) return;
e.preventDefault();
e.stopPropagation();
this._spill.setPointerCapture(e.pointerId);
const rect = this.getBoundingClientRect();
const fw = rect.width || 1, fh = rect.height || 1;
const corner = e.target.getAttribute && e.target.getAttribute('data-c');
let move;
if (corner) {
// Resize about the OPPOSITE corner. Viewport-px throughout (rect
// fw/fh, not clientWidth) so the math survives a transform:scale()
// ancestor — deck_stage renders slides scaled-to-fit.
const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1;
const base = Math.max(fw / iw, fh / ih);
const sx = corner.includes('e') ? 1 : -1;
const sy = corner.includes('s') ? 1 : -1;
const s0 = this._view.s;
const w0 = iw * base * s0, h0 = ih * base * s0;
const cx0 = (50 + this._view.x) / 100 * fw;
const cy0 = (50 + this._view.y) / 100 * fh;
const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2;
const diag0 = Math.hypot(w0, h0);
const ux = sx * w0 / diag0, uy = sy * h0 / diag0;
move = (ev) => {
const proj = (ev.clientX - rect.left - ox) * ux +
(ev.clientY - rect.top - oy) * uy;
const s = clampS(s0 * proj / diag0);
const d = diag0 * s / s0;
this._view.s = s;
this._view.x = (ox + ux * d / 2) / fw * 100 - 50;
this._view.y = (oy + uy * d / 2) / fh * 100 - 50;
this._clampView();
this._applyView();
};
} else {
this.setAttribute('data-panning', '');
const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y };
move = (ev) => {
this._view.x = start.x + (ev.clientX - start.px) / fw * 100;
this._view.y = start.y + (ev.clientY - start.py) / fh * 100;
this._clampView();
this._applyView();
};
}
const up = () => {
try { this._spill.releasePointerCapture(e.pointerId); } catch {}
this._spill.removeEventListener('pointermove', move);
this._spill.removeEventListener('pointerup', up);
this._spill.removeEventListener('pointercancel', up);
this.removeAttribute('data-panning');
this._dragUp = null;
};
// Stashed so _exitReframe (Escape / outside-click mid-drag) can
// tear the capture + listeners down synchronously.
this._dragUp = up;
this._spill.addEventListener('pointermove', move);
this._spill.addEventListener('pointerup', up);
this._spill.addEventListener('pointercancel', up);
});
// Wheel zoom stays available inside reframe mode as a trackpad nicety —
// zooms toward the cursor (offset' = cursor·(1-k) + offset·k).
this.addEventListener('wheel', (e) => {
if (!this.hasAttribute('data-reframe')) return;
e.preventDefault();
const r = this.getBoundingClientRect();
const cx = (e.clientX - r.left) / r.width * 100 - 50;
const cy = (e.clientY - r.top) / r.height * 100 - 50;
const prev = this._view.s;
const next = clampS(prev * Math.pow(1.0015, -e.deltaY));
if (next === prev) return;
const k = next / prev;
this._view.s = next;
this._view.x = cx * (1 - k) + this._view.x * k;
this._view.y = cy * (1 - k) + this._view.y * k;
this._clampView();
this._applyView();
}, { passive: false });
}
connectedCallback() {
// Warn once per page — an id-less slot works for the session but
// cannot persist, and two id-less slots would share nothing.
if (!this.id && !ImageSlot._warned) {
ImageSlot._warned = true;
console.warn('<image-slot> without an id will not persist its dropped image.');
}
this.addEventListener('dragenter', this);
this.addEventListener('dragover', this);
this.addEventListener('dragleave', this);
this.addEventListener('drop', this);
subs.add(this._subFn);
// width%/height% in _applyView encode the frame aspect at call time —
// a host resize (responsive grid, pane divider) would stretch the
// image until the next _render. Re-render on size change: _render()
// re-seeds _view from stored before clamp/apply, so a shrink→grow
// cycle round-trips instead of ratcheting x/y toward the narrower
// frame's clamp range.
this._ro = new ResizeObserver(() => this._render());
this._ro.observe(this);
load();
this._render();
}
disconnectedCallback() {
subs.delete(this._subFn);
this.removeEventListener('dragenter', this);
this.removeEventListener('dragover', this);
this.removeEventListener('dragleave', this);
this.removeEventListener('drop', this);
if (this._ro) { this._ro.disconnect(); this._ro = null; }
this._exitReframe(false);
}
_enterReframe() {
if (this.hasAttribute('data-reframe')) return;
this.setAttribute('data-reframe', '');
this._applyView();
// Close on click outside (the spill handler stopPropagation()s so
// in-image drags don't reach this) and on Escape. Listeners are held
// on the instance so _exitReframe / disconnectedCallback can detach
// exactly what was attached.
this._outside = (e) => {
if (e.composedPath && e.composedPath().includes(this)) return;
this._exitReframe(true);
};
this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); };
document.addEventListener('pointerdown', this._outside, true);
document.addEventListener('keydown', this._esc, true);
}
_exitReframe(commit) {
if (!this.hasAttribute('data-reframe')) return;
if (this._dragUp) this._dragUp();
this.removeAttribute('data-reframe');
this.removeAttribute('data-panning');
if (this._outside) document.removeEventListener('pointerdown', this._outside, true);
if (this._esc) document.removeEventListener('keydown', this._esc, true);
this._outside = this._esc = null;
if (commit) this._commitView();
}
attributeChangedCallback() { if (this.shadowRoot) this._render(); }
// handleEvent — one listener object for all four drag events keeps the
// add/remove symmetric and the depth counter correct.
handleEvent(e) {
if (e.type === 'dragenter' || e.type === 'dragover') {
// Without preventDefault the browser never fires 'drop'.
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
if (e.type === 'dragenter') this._depth++;
this.setAttribute('data-over', '');
} else if (e.type === 'dragleave') {
// dragenter/leave fire for every descendant crossing — count depth
// so hovering the icon inside the empty state doesn't flicker.
if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); }
} else if (e.type === 'drop') {
e.preventDefault();
e.stopPropagation();
this._depth = 0;
this.removeAttribute('data-over');
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) this._ingest(f);
}
}
async _ingest(file) {
this._setError(null);
if (!file || ACCEPT.indexOf(file.type) < 0) {
this._setError('Drop a PNG, JPEG, WebP, or AVIF image.');
return;
}
// toDataUrl can take hundreds of ms on a large photo. A Clear or a
// newer drop during that window would be clobbered when this await
// resumes — bump + capture a generation so stale encodes bail.
const gen = ++this._gen;
try {
const w = this.clientWidth || this.offsetWidth || MAX_DIM;
const url = await toDataUrl(file, w);
if (gen !== this._gen) return;
// Only exit reframe once the new image is in hand — a rejected type
// or decode failure leaves the in-progress crop untouched.
this._exitReframe(false);
const val = { u: url, s: 1, x: 0, y: 0 };
setSlot(this.id || '', val);
// Keep a session-local copy for id-less slots so the drop still
// shows, even though it cannot persist.
if (!this.id) { this._local = val; this._render(); }
} catch (err) {
if (gen !== this._gen) return;
this._setError('Could not read that image.');
console.warn('<image-slot> ingest failed:', err);
}
}
_setError(msg) {
if (this._err) { this._err.remove(); this._err = null; }
if (!msg) return;
const d = document.createElement('div');
d.className = 'err'; d.textContent = msg;
this.shadowRoot.appendChild(d);
this._err = d;
setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000);
}
// Reframing (pan/resize) is only meaningful for fit=cover — contain/fill
// keep the old object-fit path and double-click is a no-op.
_reframes() {
return this.hasAttribute('data-filled') &&
(this.getAttribute('fit') || 'cover') === 'cover';
}
// Cover-baseline geometry, shared by clamp/apply/resize. Null until the
// img has loaded (naturalWidth is 0 before that) or when the slot has no
// layout box — ResizeObserver fires with a 0×0 rect under display:none,
// and clamping against a degenerate 1×1 frame would silently pull the
// stored pan toward zero.
_geom() {
const iw = this._img.naturalWidth, ih = this._img.naturalHeight;
const fw = this.clientWidth, fh = this.clientHeight;
if (!iw || !ih || !fw || !fh) return null;
return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) };
}
_clampView() {
// Pan range on each axis is half the overflow past the frame edge.
const g = this._geom();
if (!g) return;
const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50);
const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50);
this._view.x = Math.max(-mx, Math.min(mx, this._view.x));
this._view.y = Math.max(-my, Math.min(my, this._view.y));
}
_applyView() {
const g = this._geom();
const fit = this.getAttribute('fit') || 'cover';
if (fit !== 'cover' || !g) {
// Non-cover, or dimensions not known yet (before img load).
this._img.style.width = '100%';
this._img.style.height = '100%';
this._img.style.left = '50%';
this._img.style.top = '50%';
this._img.style.objectFit = fit;
this._img.style.objectPosition = this.getAttribute('position') || '50% 50%';
return;
}
// Cover baseline: img fills the frame on its tighter axis at s=1, so
// pan works immediately on the overflowing axis without zooming first.
// Width/height and left/top are all frame-% — depends only on the
// frame aspect ratio, so a responsive resize keeps the same crop. The
// spill layer mirrors the same box so its corners = image corners.
const k = g.base * this._view.s;
const w = (g.iw * k / g.fw * 100) + '%';
const h = (g.ih * k / g.fh * 100) + '%';
const l = (50 + this._view.x) + '%';
const t = (50 + this._view.y) + '%';
this._img.style.width = w; this._img.style.height = h;
this._img.style.left = l; this._img.style.top = t;
this._img.style.objectFit = '';
this._spill.style.width = w; this._spill.style.height = h;
this._spill.style.left = l; this._spill.style.top = t;
}
_commitView() {
const v = { s: this._view.s, x: this._view.x, y: this._view.y };
if (this._userUrl) v.u = this._userUrl;
// Framing-only (no u) persists too so an author-src slot remembers its
// crop; clearing the sidecar still falls through to src=.
if (this.id) setSlot(this.id, v);
else { this._local = v; }
}
_render() {
// Shape / mask. Presets use border-radius so the dashed ring can
// follow the rounded outline; clip-path is only applied for an
// explicit `mask` (the ring is hidden there since a rectangle
// dashed border chopped by an arbitrary polygon looks broken).
const mask = this.getAttribute('mask');
const shape = (this.getAttribute('shape') || 'rounded').toLowerCase();
let radius = '';
if (shape === 'circle') radius = '50%';
else if (shape === 'pill') radius = '9999px';
else if (shape === 'rounded') {
const n = parseFloat(this.getAttribute('radius'));
radius = (Number.isFinite(n) ? n : 12) + 'px';
}
this._frame.style.borderRadius = mask ? '' : radius;
this._frame.style.clipPath = mask || '';
this._ring.style.borderRadius = mask ? '' : radius;
this._ring.style.display = mask ? 'none' : '';
// Controls and reframe entry gate on this so share links stay read-only.
const editable = !!(window.omelette && window.omelette.writeFile);
this.toggleAttribute('data-editable', editable);
this._sub.style.display = editable ? '' : 'none';
// Content. The sidecar is also writable by the agent's write_file
// tool, so its value isn't guaranteed canvas-originated — only accept
// data:image/ URLs from it. The `src` attribute is author-controlled
// (Claude wrote it into the HTML) so it passes through unchanged.
let stored = this.id ? getSlot(this.id) : this._local;
if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null;
const srcAttr = this.getAttribute('src') || '';
this._userUrl = (stored && stored.u) || null;
const url = this._userUrl || srcAttr;
// Don't clobber an in-flight reframe with a store-triggered re-render.
if (!this.hasAttribute('data-reframe')) {
this._view = {
s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1,
x: stored && Number.isFinite(stored.x) ? stored.x : 0,
y: stored && Number.isFinite(stored.y) ? stored.y : 0,
};
}
this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image';
// Toggle via style.display — the [hidden] attribute alone loses to
// the display:flex / display:block rules in the stylesheet above.
if (url) {
if (this._img.getAttribute('src') !== url) {
this._img.src = url;
this._ghost.src = url;
}
this._img.style.display = 'block';
this._empty.style.display = 'none';
this.setAttribute('data-filled', '');
this._clampView();
this._applyView();
} else {
this._img.style.display = 'none';
this._img.removeAttribute('src');
this._ghost.removeAttribute('src');
this._empty.style.display = 'flex';
this.removeAttribute('data-filled');
}
}
}
if (!customElements.get('image-slot')) {
customElements.define('image-slot', ImageSlot);
}
})();

View file

@ -0,0 +1,145 @@
// HeroZone the reserved interactive hero. For now a constrained placeholder:
// a chamfer-clipped panel of warm chiaroscuro whose light source drifts toward
// the pointer (the one signature motion moment), with the ink stroke parallaxing
// and the watermark beneath. A toggle reveals the zone spec + safe area so any
// future interactive element inherits the same frame.
const ASSET = '../../assets';
function HeroZone() {
const [p, setP] = React.useState({ x: 0.62, y: 0.28 });
const [showSpec, setShowSpec] = React.useState(false);
const ref = React.useRef(null);
const reduce = React.useRef(
typeof matchMedia !== 'undefined' &&
matchMedia('(prefers-reduced-motion: reduce)').matches
);
function onMove(e) {
if (reduce.current) return;
const r = ref.current.getBoundingClientRect();
setP({
x: Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
y: Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)),
});
}
function onLeave() {
setP({ x: 0.62, y: 0.28 });
}
// light follows pointer; ink drifts opposite for depth (short travel).
const lx = 30 + p.x * 50;
const ly = 8 + p.y * 40;
const inkShift = `translate(${(0.5 - p.x) * 26}px, ${(0.5 - p.y) * 16}px)`;
const hz = {
frame: {
position: 'relative',
width: '100%',
aspectRatio: '4 / 5',
borderRadius: 'var(--radius-cutout)',
background: `radial-gradient(120% 95% at ${lx}% ${ly}%, #C9B299 0%, #8A6A47 30%, #4A3826 60%, #221E18 92%)`,
transition: 'background 700ms var(--ease-out)',
overflow: 'hidden',
cursor: 'crosshair',
boxShadow: 'var(--shadow-2)',
},
accent: {
position: 'absolute',
right: '9%',
top: '12%',
width: '76px',
opacity: 0.16,
filter: 'invert(1)',
transform: inkShift,
transition: 'transform 900ms var(--ease-out)',
pointerEvents: 'none',
},
grain: {
position: 'absolute',
inset: 0,
backgroundImage: `url(${ASSET}/texture-grain.svg)`,
backgroundSize: '220px',
opacity: 0.12,
mixBlendMode: 'overlay',
pointerEvents: 'none',
},
watermark: {
position: 'absolute',
left: '8%',
bottom: '8%',
width: '128px',
opacity: 0.1,
filter: 'invert(1)',
pointerEvents: 'none',
},
toggle: {
position: 'absolute',
right: '8%',
bottom: '7%',
fontFamily: 'var(--font-mono)',
fontSize: '10px',
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: 'rgba(239,231,218,0.55)',
background: 'transparent',
border: '1px solid rgba(239,231,218,0.22)',
borderRadius: '2px',
padding: '5px 9px',
cursor: 'pointer',
zIndex: 3,
},
safe: {
position: 'absolute',
inset: '9% 9% 9% 9%',
border: '1px dashed rgba(239,231,218,0.4)',
pointerEvents: 'none',
display: showSpec ? 'block' : 'none',
},
safeLabel: {
position: 'absolute',
top: '-9px',
left: '10px',
background: '#221E18',
padding: '0 6px',
fontFamily: 'var(--font-mono)',
fontSize: '9px',
letterSpacing: '0.1em',
color: 'rgba(239,231,218,0.7)',
},
placeholderTag: {
position: 'absolute',
left: '7%',
top: '8%',
fontFamily: 'var(--font-mono)',
fontSize: '10px',
letterSpacing: '0.14em',
textTransform: 'uppercase',
color: 'rgba(239,231,218,0.45)',
display: showSpec ? 'block' : 'none',
},
};
return (
<div
ref={ref}
style={hz.frame}
onMouseMove={onMove}
onMouseLeave={onLeave}
>
<img src={`${ASSET}/accent-ringdots.svg`} style={hz.accent} alt="" />
<div style={hz.grain} />
<span style={hz.placeholderTag}>Hero zone interactive · TBD</span>
<div style={hz.safe}>
<span style={hz.safeLabel}>SAFE AREA · 1:1.25 · pad 9%</span>
</div>
<img src={`${ASSET}/mark-rings.svg`} style={hz.watermark} alt="" />
<button
style={hz.toggle}
onClick={() => setShowSpec((s) => !s)}
>
{showSpec ? 'Hide spec' : 'Zone spec'}
</button>
</div>
);
}
Object.assign(window, { HeroZone });

View file

@ -0,0 +1,53 @@
// LatestLine the most recent post as ONE typographic line. Bait to move into
// the site. No panel, no thumbnail. Hairline rule + arrow that drifts on hover.
function LatestLine({ onDark = false }) {
const [hover, setHover] = React.useState(false);
const c = onDark
? { fg: 'var(--fg-on-dark-1)', meta: 'var(--fg-on-dark-3)', rule: 'var(--hairline-on-dark)' }
: { fg: 'var(--fg-1)', meta: 'var(--fg-3)', rule: 'var(--hairline)' };
const ll = {
eyebrow: {
fontFamily: 'var(--font-body)', fontWeight: 500, fontSize: '11px',
letterSpacing: '0.22em', textTransform: 'uppercase',
color: onDark ? 'var(--fg-on-dark-2)' : 'var(--fg-3)', margin: '0 0 14px',
},
line: {
display: 'flex', alignItems: 'baseline', gap: '16px',
textDecoration: 'none', color: c.fg,
},
title: {
fontFamily: 'var(--font-display)', fontWeight: 500,
fontSize: 'clamp(20px, 2.2vw, 27px)', letterSpacing: '-0.01em',
},
meta: {
fontFamily: 'var(--font-body)', fontSize: '12.5px', color: c.meta,
fontFeatureSettings: '"onum" 1', marginLeft: 'auto', whiteSpace: 'nowrap',
},
arrow: {
color: 'var(--accent-deep)',
transform: hover ? 'translateX(5px)' : 'translateX(0)',
transition: 'transform var(--dur-base) var(--ease-out)',
},
rule: { height: '1px', background: c.rule, marginTop: '14px' },
};
return (
<div>
<p style={ll.eyebrow}>Selected writing</p>
<a
href="#"
style={ll.line}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onClick={(e) => e.preventDefault()}
>
<span style={ll.title}>Notes on warm light and cold water</span>
<span style={ll.meta}>Mar&nbsp;2026</span>
<span style={ll.arrow}></span>
</a>
<div style={ll.rule} />
</div>
);
}
Object.assign(window, { LatestLine });

View file

@ -0,0 +1,41 @@
# UI Kit — Home
A high-fidelity recreation of the **joshbairstow.com** home page: a minimal hub
that *fuses* its three jobs into one composition rather than stacking three
sections.
- **Interactive hero** (`HeroZone.jsx`) — the reserved zone, shown as a
constrained placeholder: a **rounded** cut-out panel of warm chiaroscuro whose
light source drifts toward the pointer (the one signature motion moment),
with the **rings watermark** and a ring-dots accent. A **Zone spec** toggle
reveals the safe area (9% pad, 1:1.25) so any future element inherits the
frame. Respects `prefers-reduced-motion`.
- **Latest-writing line** (`LatestLine.jsx`) — the newest post as *one*
typographic line; bait, not a billboard. Arrow drifts on hover.
- **Subdomain index** (`SubdomainIndex.jsx`) — navigation as a refined index;
tolerates a growing/uneven set; a not-yet-live item shows a quiet `soon`
badge, never looks broken. Hover indents the row + draws an ochre tick.
- **Wordmark** (`Wordmark.jsx`) — Bodoni lockup; the only color event is a
single ochre period.
- **Light / dark****dark is the default** (deep warm ink ground); the header
toggle swaps to the bone alternate. Grain, the rings watermark, and simple
accent marks (rings/dots/pipes) sprinkled loosely carry through both.
## Run
Open `index.html`. React + Babel load from CDN; components are separate
`.jsx` files exported to `window`. Tokens come from `../../colors_and_type.css`;
marks/texture from `../../assets/`.
## Files
| File | Component |
|---|---|
| `index.html` | App shell, theming, layout grid |
| `Wordmark.jsx` | Name lockup |
| `HeroZone.jsx` | Interactive hero placeholder |
| `LatestLine.jsx` | Latest-post line |
| `SubdomainIndex.jsx` | Refined subdomain index nav |
## Notes
This is a **cosmetic recreation** for prototyping — interactions are faked
(links are inert). The hero's interactive element is a deliberate placeholder
per the brief; drop the real piece into the same clipped frame + safe area.

View file

@ -0,0 +1,79 @@
// SubdomainIndex the navigation as a refined index, not a button farm.
// Tolerates a growing, uneven set; a "not yet live" item is quiet, not broken.
// Hover: row indents a touch and a hairline tick draws in (felt, not seen).
const SUBDOMAINS = [
{ n: '01', name: 'Blog', desc: 'Writing', live: true },
{ n: '02', name: 'Code', desc: 'Experiments & tools', live: true },
{ n: '03', name: 'Art', desc: 'Generative & physical', live: true },
{ n: '04', name: 'Signage', desc: 'Chalk on easel', live: true },
{ n: '05', name: 'Keycaps', desc: 'Mechanical', live: true },
{ n: '06', name: 'Social', desc: '', live: false },
];
function IndexRow({ item, onDark }) {
const [hover, setHover] = React.useState(false);
const live = item.live;
const c = onDark
? { fg: 'var(--fg-on-dark-1)', dim: 'var(--fg-on-dark-3)', rule: 'var(--hairline-on-dark)' }
: { fg: 'var(--fg-1)', dim: 'var(--fg-3)', rule: 'var(--hairline)' };
const row = {
display: 'flex', alignItems: 'baseline', gap: '16px',
textDecoration: 'none', padding: '13px 0',
borderTop: `1px solid ${c.rule}`,
paddingLeft: hover && live ? '10px' : '0',
transition: 'padding-left var(--dur-base) var(--ease-out)',
cursor: live ? 'pointer' : 'default',
};
const num = {
fontFamily: 'var(--font-mono)', fontSize: '10px',
color: c.dim, width: '24px', flex: 'none', fontFeatureSettings: '"onum" 1',
};
const name = {
fontFamily: 'var(--font-display)', fontWeight: 500,
fontSize: 'clamp(19px, 2vw, 24px)', letterSpacing: '0.01em',
color: live ? c.fg : c.dim,
};
const tick = {
width: hover && live ? '22px' : '0px', height: '1px',
background: 'var(--accent-deep)', alignSelf: 'center',
transition: 'width var(--dur-base) var(--ease-out)',
};
const desc = {
fontFamily: 'var(--font-body)', fontSize: '12px', color: c.dim,
marginLeft: 'auto',
};
const soon = {
fontFamily: 'var(--font-mono)', fontSize: '9.5px', letterSpacing: '0.12em',
textTransform: 'uppercase', color: c.dim, marginLeft: 'auto',
border: `1px solid ${c.rule}`, borderRadius: '2px', padding: '2px 7px',
};
return (
<a
href="#"
style={row}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onClick={(e) => e.preventDefault()}
>
<span style={num}>{item.n}</span>
<span style={name}>{item.name}</span>
<span style={tick} />
{live
? <span style={desc}>{item.desc}</span>
: <span style={soon}>soon</span>}
</a>
);
}
function SubdomainIndex({ onDark = false }) {
return (
<nav>
{SUBDOMAINS.map((it) => (
<IndexRow key={it.n} item={it} onDark={onDark} />
))}
</nav>
);
}
Object.assign(window, { SubdomainIndex });

View file

@ -0,0 +1,25 @@
// Wordmark the name lockup. Bodoni display, a single ochre period as the
// only color event. Sits at the top-left of the composition.
function Wordmark({ size = 'header', onDark = false }) {
const isHeader = size === 'header';
const wmStyles = {
name: {
fontFamily: 'var(--font-display)',
fontWeight: 500,
fontSize: isHeader ? '21px' : 'clamp(56px, 9vw, 132px)',
lineHeight: isHeader ? 1 : 0.92,
letterSpacing: isHeader ? '0' : '-0.02em',
color: onDark ? 'var(--fg-on-dark-1)' : 'var(--fg-1)',
margin: 0,
display: 'flex',
alignItems: 'baseline',
},
dot: { color: 'var(--accent-deep)' },
};
return (
<h1 style={wmStyles.name}>
Josh&nbsp;Bairstow<span style={wmStyles.dot}>.</span>
</h1>
);
}
Object.assign(window, { Wordmark });

View file

@ -0,0 +1,230 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Josh Bairstow</title>
<link rel="stylesheet" href="../../colors_and_type.css">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: var(--font-body);
background-color: var(--ground-dark);
color: var(--fg-1);
min-height: 100vh;
}
body.dark { background-color: var(--ground-dark); }
body:not(.dark) { background-color: var(--ground); }
/* page-wide grain greeble — static, barely-there (§5) */
body::before {
content: "";
position: fixed; inset: 0;
background-image: url(../../assets/texture-grain.svg);
background-size: 240px;
opacity: 0.045;
pointer-events: none;
z-index: 0;
}
body.dark::before { opacity: 0.06; mix-blend-mode: screen; }
/* sprinkled accents — simple crisp marks (rings, dots, pipes) placed with
loose, non-rigid positioning across the page. Quiet but present. */
.mark {
position: fixed; pointer-events: none; z-index: 0;
filter: invert(52%) sepia(16%) saturate(340%) brightness(88%);
opacity: 0.16;
}
body:not(.dark) .mark { filter: invert(34%) sepia(18%) saturate(280%) brightness(76%); opacity: 0.11; }
.mark.m1 { width: 84px; top: 15%; left: 3%; transform: rotate(-5deg); }
.mark.m2 { width: 28px; top: 72%; left: 6.5%; transform: rotate(8deg); }
.mark.m3 { width: 50px; top: 28%; right: 3.5%; }
.mark.m4 { width: 40px; bottom: 8%; right: 8%; }
.mark.m5 { width: 66px; bottom: 18%; left: 45%; transform: rotate(-3deg); }
#root { position: relative; z-index: 1; }
.stage {
max-width: 1180px;
margin: 0 auto;
padding: clamp(28px, 5vh, 56px) clamp(24px, 5vw, 64px) clamp(32px, 5vh, 56px);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* header */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 18px;
border-bottom: 1px solid var(--hairline);
}
.dark .topbar { border-color: var(--hairline-on-dark); }
.topmeta { display: flex; align-items: center; gap: 22px; }
.coord {
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;
color: var(--fg-3); font-feature-settings: "onum" 1;
}
.toggle {
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.14em;
text-transform: uppercase; cursor: pointer;
background: transparent; color: var(--fg-2);
border: 1px solid var(--hairline-strong);
border-radius: var(--radius-xs); padding: 6px 11px;
transition: border-color var(--dur-base) var(--ease-out), color var(--dur-base) var(--ease-out);
}
.toggle:hover { border-color: var(--fg-1); color: var(--fg-1); }
.dark .toggle { color: var(--fg-on-dark-2); border-color: var(--hairline-on-dark); }
.dark .toggle:hover { color: var(--fg-on-dark-1); border-color: var(--fg-on-dark-1); }
/* hero composition grid */
.hero {
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: clamp(32px, 6vw, 80px);
align-items: stretch;
padding: clamp(36px, 7vh, 84px) 0 clamp(28px, 5vh, 56px);
flex: 1;
}
.heroLeft { display: flex; flex-direction: column; }
.eyebrow {
font-family: var(--font-body); font-weight: 500; font-size: 11px;
letter-spacing: 0.24em; text-transform: uppercase; color: var(--fg-3);
margin: 0 0 clamp(20px, 4vh, 34px);
}
.dark .eyebrow { color: var(--fg-on-dark-2); }
.statement {
font-family: var(--font-display); font-weight: 500;
font-size: clamp(40px, 6.4vw, 92px); line-height: 0.96;
letter-spacing: -0.02em; margin: 0; color: var(--fg-1);
text-wrap: balance;
}
.dark .statement { color: var(--fg-on-dark-1); }
.statement em { font-style: italic; font-weight: 400; }
.statement .accent { color: var(--accent-deep); }
.leadline {
font-family: var(--font-body); font-weight: 300;
font-size: clamp(15px, 1.5vw, 18px); line-height: 1.6;
color: var(--fg-2); max-width: 42ch;
margin: clamp(22px, 4vh, 34px) 0 0;
}
.dark .leadline { color: var(--fg-on-dark-2); }
.heroLeftFoot { margin-top: auto; padding-top: clamp(28px, 5vh, 48px); }
.heroRight { display: flex; align-items: stretch; }
.heroRight > * { width: 100%; align-self: center; }
/* lower band: index + signature */
.lower {
display: grid;
grid-template-columns: 1.15fr 0.85fr;
gap: clamp(32px, 6vw, 80px) clamp(32px, 6vw, 80px);
align-items: end;
padding-top: clamp(22px, 3.5vh, 36px);
border-top: 1px solid var(--hairline);
}
.dark .lower { border-top-color: var(--hairline-on-dark); }
.lowerInk {
grid-column: 1 / -1; width: 50px; opacity: 0.22;
margin: 0 0 clamp(6px, 1.4vh, 12px);
filter: invert(52%) sepia(16%) saturate(340%) brightness(88%);
}
body:not(.dark) .lowerInk { filter: invert(34%) sepia(18%) saturate(280%) brightness(76%); }
.indexHead {
font-family: var(--font-body); font-weight: 500; font-size: 11px;
letter-spacing: 0.24em; text-transform: uppercase; color: var(--fg-3);
margin: 0 0 6px;
}
.dark .indexHead { color: var(--fg-on-dark-2); }
.sigBlock { display: flex; flex-direction: column; align-items: flex-end; gap: 12px; }
.sigBlock img { width: 74px; opacity: 0.24;
filter: invert(52%) sepia(16%) saturate(340%) brightness(88%); }
body:not(.dark) .sigBlock img { filter: invert(34%) sepia(18%) saturate(280%) brightness(76%); }
.copyline {
font-family: var(--font-body); font-size: 12px; color: var(--fg-3);
font-feature-settings: "onum" 1; text-align: right;
}
.dark .copyline { color: var(--fg-on-dark-3); }
@media (max-width: 820px) {
.hero, .lower { grid-template-columns: 1fr; }
.heroRight { max-width: 420px; }
.sigBlock { align-items: flex-start; }
.copyline { text-align: left; }
}
</style>
</head>
<body class="dark">
<img class="mark m1" src="../../assets/accent-ringdots.svg" alt="">
<img class="mark m2" src="../../assets/accent-pipes.svg" alt="">
<img class="mark m3" src="../../assets/accent-dots.svg" alt="">
<img class="mark m4" src="../../assets/accent-concentric.svg" alt="">
<img class="mark m5" src="../../assets/accent-ringdots.svg" alt="">
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="Wordmark.jsx"></script>
<script type="text/babel" src="HeroZone.jsx"></script>
<script type="text/babel" src="LatestLine.jsx"></script>
<script type="text/babel" src="SubdomainIndex.jsx"></script>
<script type="text/babel">
function App() {
const [dark, setDark] = React.useState(true);
React.useEffect(() => {
document.body.classList.toggle('dark', dark);
}, [dark]);
return (
<div className="stage">
<header className="topbar">
<Wordmark size="header" onDark={dark} />
<div className="topmeta">
<span className="coord">33.7969° S · Sydney</span>
<button className="toggle" onClick={() => setDark(d => !d)}>
{dark ? 'Light' : 'Dark'}
</button>
</div>
</header>
<section className="hero">
<div className="heroLeft">
<p className="eyebrow">Engineer · Sydney</p>
<h2 className="statement">
I make small,<br /><em>careful</em> things<span className="accent">.</span>
</h2>
<p className="leadline">
Software, tools, and the occasional mark in ink. A quiet corner of the
internet — mostly writing, sometimes shipping.
</p>
<div className="heroLeftFoot">
<LatestLine onDark={dark} />
</div>
</div>
<div className="heroRight">
<HeroZone />
</div>
</section>
<section className="lower">
<img className="lowerInk" src="../../assets/accent-dots.svg" alt="" />
<div>
<p className="indexHead">Index</p>
<SubdomainIndex onDark={dark} />
</div>
<div className="sigBlock">
<img src="../../assets/accent-ringdots.svg" alt="" />
<span className="copyline">© Josh Bairstow — Sydney</span>
</div>
</section>
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>

216
docs/planning.md Normal file
View file

@ -0,0 +1,216 @@
# jb-website — Live Planning Document
> Living doc. Updated as decisions are made. The goal is to capture intent, constraints, and the reasoning behind tech choices so we don't re-litigate them and don't accidentally box ourselves in.
## 1. Project intent
A personal website that serves as the canonical home for Josh Bairstow's writing, generative/digital art, and a public index pointing out to side-hustle businesses. First draft of what will actually go live — not a throwaway mockup. Must leave room for the work in §3 without major rewrites.
The design system, voice, and four reference pages (Home, Blog index, Post, Art focus view) are captured in the Claude Design handoff bundle. The visual target is locked; the implementation is open.
## 2. Confirmed requirements
### 2.1 Pages (initial scope)
- **Home / landing** — fused composition (intro + hero zone + selected writing + subdomain index). Three layout variants exist in the design (`split`, `stack`, `index`); only one ships as the canonical URL at launch.
- **Blog index** — typographic post archive grouped by year, with featured lead post.
- **Blog post** — reading template with editorial prose rhythm, pull-quote, inline figure, prev/next nav.
- **Art focus view** — single-piece immersive view with caption and spec rail; the work itself is a live generative canvas.
- **404 / missing-asset page** — on-brand "this doesn't exist yet" or "coming soon" page that links back home. Used for any nav target that hasn't been built yet.
### 2.2 Navigation
- All top-nav links must resolve: existing page if built, otherwise the 404/missing page; never a dead link.
- Subdomain index rows on the home page link out to side-hustle subdomains (see §2.5), not to internal routes.
### 2.3 Blog mechanics
- **Content source:** static markdown files committed to the repo (likely `content/posts/*.md` with frontmatter). No CMS.
- **Rendering:** the framework translates markdown → the design-system styling (Bodoni headings, Hanken body, small-caps lead-in, ochre links, etc.). Authors write content; layout is automatic.
- **Browsing:** archive view (already designed) groups by year with hover affordances.
- **Search:** basic — title + body + tag substring match is enough for v1. Not Algolia-grade.
- **Out of scope for v1:** comments, RSS bells & whistles, multi-author, drafts UI. RSS feed itself is cheap and worth shipping.
### 2.4 Art pages
- Heavy client-side JS is expected (canvas / WebGL / WebGPU / shaders).
- Each art piece should be able to ship its own dependencies without bloating other pages.
- The framework must allow per-page islands of JS without forcing the rest of the site to load them.
### 2.5 Subdomain side-hustles
- Distinct businesses (Code experiments, Signage, Keycaps, etc.) live on their own subdomains.
- Served by a **reverse proxy + container per subdomain** at the infrastructure layer.
- **Out of scope for this repo.** This codebase only needs to link out to them; it does not host or build them.
### 2.6 Auth (future, plan-for-not-build)
- Goal: gate certain pages or sections to me + named collaborators.
- Likely shape: low-friction login (passkey, magic link, or GitHub OAuth — TBD), middleware-protected routes, an allowlist of authorized identities.
- Not a v1 feature. Tech choice should not preclude adding this later.
### 2.7 Design system
- Source files from the handoff bundle (`colors_and_type.css`, `site.css`, SVG marks, type tokens) become the foundation.
- Components built in an **atomic design** style: atoms (typography, marks, hairlines) → molecules (post row, index row, eyebrow + title pair) → organisms (topbar, sitefoot, masthead, feature card) → templates (page layouts) → pages.
- **Brand system docs imported** to `docs/design-system/` (2026-06-01). Includes `colors_and_type.css`, `README.md` (governing idea, hard guardrails, voice, marks), `SKILL.md`, `assets/` (SVG marks + grain), `preview/` (specimen cards), and `ui_kits/home/` (reference JSX components — Wordmark, HeroZone, LatestLine, SubdomainIndex). Treat as the canonical spec.
- **One rule to internalise** (from the brand README): *one base aesthetic and one accent, not equal partners.* Base ~90% — quiet luxury, warm browns + ink. Accent — rare, contained, ochre. Litmus test for any decision: *barely noticed on visit one, a small delight on visit three.*
## 3. Future work that must not be hamstrung
These are not v1, but the v1 choices must leave room for them:
1. Heavier JS art pieces — multiple pieces, each potentially with its own libraries.
2. Auth-gated areas.
3. RSS, sitemap, OG cards.
4. Maybe per-post comments or a guestbook (low priority).
5. Possible analytics (privacy-respecting).
6. View transitions / page-to-page animation polish.
## 4. Tech decision
### 4.1 Choice: **Astro 5 + React islands**
Astro is a static-first site builder that:
- Ships zero JS by default — matters for the home/blog/post pages which are content, not apps.
- Allows **islands** of any framework (React, Svelte, vanilla) on a per-component basis, hydrated independently. The art pages can be React (or vanilla canvas) without forcing React onto the blog.
- Has first-class markdown / MDX content collections with typed frontmatter — the blog requirement maps to it directly.
- Has file-based routing, `404.astro`, and middleware for future auth.
- Supports SSR per-route when needed (for auth-gated pages later), while keeping the rest SSG.
- Works with [Pagefind](https://pagefind.app) for build-time blog search at near-zero runtime cost.
### 4.2 Alternatives considered
| Option | Why not |
|---|---|
| Plain HTML/CSS/JS | Forces hand-rolling the markdown pipeline, search index, component reuse, future auth middleware. Looks "simple" but pays for that simplicity later. |
| Next.js | Heavier baseline; React-everywhere assumption taxes every page. Worth it if most pages were app-shaped, but here they're document-shaped. Adds friction for markdown content collections. |
| SvelteKit | Solid technically; user's note about "larger react framework" suggests React is the preferred ecosystem for the heavy bits. Astro lets us still use React where it matters. |
| Remix / React Router 7 | SSR-first, optimized for app-style data flows. Mismatch with the document-heavy nature of this site. |
### 4.3 Implications of the Astro choice
- **Hosting:** flexible — static build works on Cloudflare Pages, Netlify, Vercel, or self-hosted Nginx behind the reverse proxy. SSR (for auth later) wants a Node/edge runtime; Cloudflare Pages + Workers or self-hosted Node both work.
- **Auth path:** Astro middleware + Auth.js or Lucia when the time comes. Adds SSR mode for protected routes; rest of site stays SSG.
- **Search path:** Pagefind runs at build time; outputs a static index the client fetches lazily. No server needed.
- **Subdomain wiring:** unchanged — this repo only needs to render external links with the correct hrefs. Reverse proxy does the rest.
## 5. Proposed repo shape (draft, will evolve)
```
jb-website/
├── docs/
│ ├── planning.md # this file
│ └── design-system/ # imported brand system docs
├── public/
│ ├── assets/ # SVG marks, grain texture, favicons
│ └── og/ # generated OG images
├── src/
│ ├── components/
│ │ ├── atoms/ # Eyebrow, Mark, Hairline, Wordmark, Coord
│ │ ├── molecules/ # PostRow, IndexRow, MetaLine, FeatureCard
│ │ ├── organisms/ # TopBar, SiteFoot, Masthead, HeroZone
│ │ └── islands/ # React/JS-heavy: ArtCanvas, HeroPointer, Search
│ ├── layouts/
│ │ ├── BaseLayout.astro # html, head, grain, marks, topbar, footer
│ │ ├── PostLayout.astro # reading template; used by markdown posts
│ │ └── ArtLayout.astro # immersive focus view
│ ├── content/
│ │ ├── config.ts # collection schemas (zod)
│ │ ├── posts/ # markdown blog posts
│ │ └── art/ # markdown for art pieces; canvas in islands/
│ ├── pages/
│ │ ├── index.astro # home
│ │ ├── blog/
│ │ │ ├── index.astro # archive
│ │ │ └── [slug].astro # post detail (renders content/posts)
│ │ ├── art/
│ │ │ ├── index.astro # listing (future)
│ │ │ └── [slug].astro # focus view
│ │ └── 404.astro # missing / coming-soon page
│ ├── styles/
│ │ ├── colors_and_type.css # from handoff bundle
│ │ └── site.css # shared chrome
│ └── middleware.ts # placeholder for future auth
└── astro.config.mjs
```
## 6. Domain & deployment architecture
### 6.1 Domain layout (confirmed)
- **Apex:** `joshbairstow.com` — the home / landing page (this repo's primary surface).
- **Personal subdomains (this repo serves them — "in-repo paths"):**
- `blog.joshbairstow.com` — writing archive + posts
- `art.joshbairstow.com` — generative & digital art
- `code.joshbairstow.com` — code experiments & small tools (confirmed in-repo)
- The architecture must remain open to adding more in-repo subdomains later without restructuring (e.g. a `notes.`, `talks.`, or similar). The host-aware routing model in §6.2 generalises to any number of additional paths.
- **External-container subdomains (separate repos / containers, out of scope here):**
- At least one side-hustle is confirmed for a separate container; signage and/or keycaps may use brand-name apex domains rather than `*.joshbairstow.com`.
- Each external container plugs into the same reverse proxy.
### 6.2 Multi-subdomain serving — decision needed
Three subdomains (`apex`, `blog.`, `art.`) need to be served by *this* repo. Two viable shapes:
**Option A — One Astro project, host-aware routing (recommended).** One codebase, one container. Routes live under `/`, `/blog`, `/art`. The reverse proxy maps host headers:
```
joshbairstow.com/* → backend /*
blog.joshbairstow.com/* → backend /blog/*
art.joshbairstow.com/* → backend /art/*
```
Pros: shared design system at compile time, single build, single deploy, single search index, no duplication. Internal cross-subdomain links use absolute URLs.
Cons: a little proxy config; you must remember to use absolute URLs when crossing subdomains in author-facing markdown.
**Option B — One Astro project per subdomain.** Three containers, three builds, design system imported as a shared package. More isolation; more deployment overhead. Worth it only if the subdomains diverge structurally (which they shouldn't).
**Default plan:** Option A. The routing rule generalises — any new in-repo subdomain (e.g. `notes.joshbairstow.com`) becomes a new path in the Astro project plus one extra block in the Caddyfile. The `code.` subdomain confirmed in-repo joins `blog.` and `art.` under the same scheme.
### 6.3 Hosting target — confirmed VPS + containers
Recommendation for a project of this size:
**Confirmed:** Vultr (Sydney region) — $6/mo droplet (1 vCPU / 1 GB) sufficient for a static site + a few containerised side-hustles; bumps to $12 / 2 GB if memory pressure arises. Sydney PoP keeps latency tight for AU readers and still serves global traffic acceptably.
**Suggested stack on the VPS:**
- **Caddy** as the reverse proxy — automatic HTTPS via Let's Encrypt, dead-simple Caddyfile, handles the host-header → path-prefix routing cleanly.
- **Docker + docker compose** — the Astro static output baked into a tiny container (Caddy + the `dist/` files, or just nginx-alpine + static files); side-hustle containers added alongside under the same compose file.
- **Deployment** options:
- Build locally → push image to a registry (GHCR or Docker Hub) → SSH to VPS → `compose pull && up -d`. Fully manual, very transparent.
- Or GitHub Actions: build + push image on tag, then a webhook or SSH step triggers the VPS pull. Slightly more setup, far less ceremony per deploy. Recommended once the workflow is steady.
### 6.4 Other open questions
- **Tweaks panel:** drop it (it's a design-time tool), or keep a slimmed runtime theme toggle (Ink / Bone)? Default: drop for v1; theme is dark-ink only.
- **Auth shape:** passkey vs. magic link vs. GitHub OAuth. Deferrable.
- **Analytics:** if any, which (Plausible self-hosted, Umami self-hosted, none).
- **`code.` subdomain content:** ~~is this also served from this repo~~ — confirmed in-repo (2026-06-01).
- **Design system docs source:** ~~URL 404'd~~ — imported successfully (2026-06-01); see `docs/design-system/`.
## 7. Decisions log
| Date | Decision | Rationale |
|---|---|---|
| 2026-06-01 | Adopt Astro 5 with React islands as the framework | Best fit for static-content + heavy-JS-on-some-pages mix; doesn't preclude future React, auth, or interactivity work |
| 2026-06-01 | Blog posts are markdown files in `src/content/posts/` rendered via Astro content collections | Matches stated requirement; gives typed frontmatter and design-system templating for free |
| 2026-06-01 | Side-hustle subdomains are out of scope for this repo; only outbound links are wired here | Confirmed — served by separate containers behind a reverse proxy |
| 2026-06-01 | Home page ships the `stack` (editorial, centered) layout as the canonical landing | User selection — leaning into the editorial feel for v1 |
| 2026-06-01 | Apex `joshbairstow.com` + `blog.` + `art.` (+ likely `code.`) served from this single Astro project; subdomain routing handled at the reverse proxy | Avoids duplicating the design system; one build, one deploy |
| 2026-06-01 | Hosting target: VPS + Docker + Caddy as reverse proxy, containerised deploy workflow | Per user direction; matches the side-hustle container pattern; Caddy gives us automatic HTTPS for free |
| 2026-06-01 | VPS provider: Vultr (Sydney region) | Best AU latency for the smallest tier; modern hardware; easy Docker setup |
| 2026-06-01 | `code.` subdomain is in-repo (third in-repo section alongside `blog.` and `art.`) | User confirmed; routing model already supports it |
| 2026-06-01 | Architecture must remain open to adding further in-repo subdomain paths later | User flagged this as a constraint; host-aware routing in §6.2 scales to N paths without rework |
| 2026-06-01 | Brand-system docs imported to `docs/design-system/` from a fresh handoff bundle | Establishes the canonical brand spec in-repo so future work can cite it |
## 8. Next steps
Approved & queued:
1. Scaffold the Astro project; port `colors_and_type.css` and `site.css` from the handoff into `src/styles/`; copy SVG assets into `public/assets/`.
2. Build `BaseLayout.astro` (topbar + footer + grain + marks). Topbar links resolve to absolute subdomain URLs so they work whether viewed at the apex or a subdomain.
3. Implement Home page in the `stack` layout (canonical landing). Hero zone keeps the pointer-tracked light gradient as the one signature motion moment.
4. Implement on-brand 404 / missing-asset page (any unbuilt internal route falls here).
5. Wire the markdown content collection + Post layout; port the reference post as the first real entry.
6. Implement Blog index against the collection.
7. Implement Art focus view; port the generative tide-study canvas as a React (or vanilla) island.
8. Wire Pagefind for blog search.
9. Containerise (Caddyfile + `dist/` in an alpine image). Write a sample `compose.yml` for the VPS with the host-header routing for `apex`, `blog.`, `art.`.
10. Defer: auth middleware, additional art pieces, RSS, OG images, `code.` subdomain content.
Blocked / waiting on user input:
- *(none — all blockers cleared as of 2026-06-01; ready to scaffold on user's go-ahead.)*

6919
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "jb-website",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^4.0.0",
"@astrojs/react": "^4.0.0",
"@astrojs/sitemap": "^3.2.1",
"astro": "^5.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"typescript": "^5.6.3"
}
}

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 70" width="70" height="70" role="img" aria-label="concentric rings accent">
<g fill="none" stroke="#221E18" stroke-width="2.4">
<circle cx="35" cy="35" r="30"></circle>
<circle cx="35" cy="35" r="18"></circle>
</g>
<circle cx="35" cy="35" r="5" fill="#221E18"></circle>
</svg>

After

Width:  |  Height:  |  Size: 349 B

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 16" width="64" height="16" role="img" aria-label="dot row accent">
<g fill="#221E18">
<circle cx="8" cy="8" r="3"></circle>
<circle cx="32" cy="8" r="3"></circle>
<circle cx="56" cy="8" r="3"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 288 B

View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 30" width="44" height="30" role="img" aria-label="pipe accent">
<g fill="#221E18">
<rect x="6" y="4" width="2.4" height="22" rx="1.2"></rect>
<rect x="16" y="4" width="2.4" height="22" rx="1.2"></rect>
<rect x="26" y="4" width="2.4" height="22" rx="1.2"></rect>
<rect x="36" y="4" width="2.4" height="22" rx="1.2"></rect>
</g>
</svg>

After

Width:  |  Height:  |  Size: 412 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="ring-and-dots accent">
<circle cx="10" cy="12" r="3.4" fill="#221E18"></circle>
<circle cx="48" cy="12" r="9" fill="none" stroke="#221E18" stroke-width="2.4"></circle>
<circle cx="86" cy="12" r="3.4" fill="#221E18"></circle>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 92" width="180" height="92" role="img" aria-label="rings constellation D">
<circle cx="120" cy="44" r="24" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
<g fill="#221E18">
<circle cx="22" cy="30" r="9"></circle>
<circle cx="52" cy="48" r="6"></circle>
<circle cx="76" cy="62" r="4"></circle>
<circle cx="120" cy="44" r="4"></circle>
<circle cx="162" cy="74" r="3"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 80" width="180" height="80" role="img" aria-label="rings constellation A">
<g fill="#221E18">
<circle cx="28" cy="24" r="6.5"></circle>
<circle cx="156" cy="30" r="3.5"></circle>
<circle cx="132" cy="54" r="9" fill="#221E18"></circle>
</g>
<circle cx="84" cy="40" r="22" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
<circle cx="84" cy="40" r="4" fill="#221E18"></circle>
<circle cx="132" cy="54" r="9" fill="none" stroke="#221E18" stroke-width="3.2"></circle>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 90" width="180" height="90" role="img" aria-label="rings constellation B">
<circle cx="96" cy="46" r="26" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
<g fill="#221E18">
<circle cx="40" cy="30" r="5.5"></circle>
<circle cx="58" cy="68" r="3.5"></circle>
<circle cx="150" cy="34" r="7.5"></circle>
<circle cx="138" cy="72" r="3"></circle>
<circle cx="96" cy="46" r="3.5"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 490 B

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="160" height="160">
<filter id="g">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" stitchTiles="stitch"></feTurbulence>
<feColorMatrix type="matrix" values="0 0 0 0 0.13 0 0 0 0 0.12 0 0 0 0 0.09 0 0 0 1 0"></feColorMatrix>
</filter>
<rect width="160" height="160" filter="url(#g)"></rect>
</svg>

After

Width:  |  Height:  |  Size: 415 B

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; }
}

13
tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"baseUrl": ".",
"paths": {
"~/*": ["src/*"]
}
},
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "docs/design-system"]
}