initial commit
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.astro
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
docs/design-system
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
89
Caddyfile
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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:
|
||||||
BIN
docs/design-system/.thumbnail
Normal file
|
After Width: | Height: | Size: 846 B |
25
docs/design-system/BUNDLE-README.md
Normal 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)
|
||||||
202
docs/design-system/Posted Graphic.html
Normal 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>
|
||||||
238
docs/design-system/README.md
Normal 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 **3–8% opacity**, watermarks at **4–10%**;
|
||||||
|
greebling sits **2–6% 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
|
||||||
|
**200–400ms** 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 (~10–18% 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.
|
||||||
59
docs/design-system/SKILL.md
Normal 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 **3–8%** opacity, watermarks at **4–10%**; sits
|
||||||
|
**2–6% 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:** 200–400ms, 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.
|
||||||
8
docs/design-system/assets/accent-concentric.svg
Normal 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 |
8
docs/design-system/assets/accent-dots.svg
Normal 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 |
9
docs/design-system/assets/accent-pipes.svg
Normal 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 |
6
docs/design-system/assets/accent-ringdots.svg
Normal 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 |
11
docs/design-system/assets/mark-rings-cascade.svg
Normal 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 |
11
docs/design-system/assets/mark-rings-drift.svg
Normal 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 |
11
docs/design-system/assets/mark-rings.svg
Normal 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 |
8
docs/design-system/assets/texture-grain.svg
Normal 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 |
236
docs/design-system/colors_and_type.css
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
/* ============================================================================
|
||||||
|
Josh Bairstow — Brand System
|
||||||
|
colors_and_type.css — foundational color + type tokens
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
One base aesthetic (quiet luxury, warm browns + ink) and one contained
|
||||||
|
accent (ochre / calligraffiti ink). Chiaroscuro is achieved through VALUE
|
||||||
|
in the warm ramp — never through cool grey. Keep shadows warm.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400;0,6..96,500;0,6..96,600;0,6..96,700;1,6..96,400;1,6..96,500&family=Hanken+Grotesk:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* WARM TONAL RAMP — the spine of the system. */
|
||||||
|
/* A single value ladder from bone (light) to warm near-black (ink). */
|
||||||
|
/* Use value, not hue, to build depth and chiaroscuro. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--bone-50: #F4EFE6; /* Ground — primary background, warm off-white */
|
||||||
|
--bone-100: #EDE6D9; /* Raised ground — cards, panels */
|
||||||
|
--bone-200: #E0D5C3; /* Sunk ground / quiet fills */
|
||||||
|
--sand-300: #C9B299; /* Light brown — hairlines on dark, muted detail */
|
||||||
|
--sepia-400: #A8855F; /* Sepia — secondary brand brown */
|
||||||
|
--tobacco-500: #7A5C3E;/* Tobacco — primary brand brown */
|
||||||
|
--brown-600: #5A4630; /* Mid-deep brown — secondary text on light */
|
||||||
|
--espresso-700: #3B2C1E;/* Espresso — depth, warm shadow tone */
|
||||||
|
--bark-800: #2E2720; /* Bark — dark panels */
|
||||||
|
--ink-900: #221E18; /* Ink — primary text / the ink gesture */
|
||||||
|
--ground-dark-950: #17130E; /* Inverted ground — DEFAULT theme, deep + moody */
|
||||||
|
--ground-dark-900: #1E1A14; /* Raised dark panel */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* ACCENT — single, restrained, rare. Ochre/amber. A deliberate event. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--accent: #C0883A; /* Muted ochre — hover ticks, rare highlights */
|
||||||
|
--accent-deep: #9A6A28; /* Deep amber — pressed / on-light accent text */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* SEMANTIC COLOR ROLES */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--ground: var(--bone-50);
|
||||||
|
--ground-raised: var(--bone-100);
|
||||||
|
--ground-sunk: var(--bone-200);
|
||||||
|
--ground-dark: var(--ground-dark-950);
|
||||||
|
|
||||||
|
--fg-1: var(--ink-900); /* primary text */
|
||||||
|
--fg-2: var(--brown-600); /* secondary text */
|
||||||
|
--fg-3: var(--sepia-400); /* tertiary / muted / metadata */
|
||||||
|
|
||||||
|
/* On dark grounds (the default theme) */
|
||||||
|
--fg-on-dark-1: #EDE4D5;
|
||||||
|
--fg-on-dark-2: #AE9B7F;
|
||||||
|
--fg-on-dark-3: #6F5E48;
|
||||||
|
|
||||||
|
/* Hairlines — warm, never grey. Used for §5 rules and dividers. */
|
||||||
|
--hairline: rgba(34, 30, 24, 0.12);
|
||||||
|
--hairline-strong: rgba(34, 30, 24, 0.22);
|
||||||
|
--hairline-on-dark: rgba(239, 231, 218, 0.14);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* TYPOGRAPHY FAMILIES */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--font-display: 'Bodoni Moda', 'Times New Roman', serif;
|
||||||
|
--font-body: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||||
|
|
||||||
|
/* OpenType "visit three" details: old-style figures + true small caps. */
|
||||||
|
--feat-oldstyle: "onum" 1, "pnum" 1;
|
||||||
|
--feat-smallcaps: "smcp" 1, "onum" 1;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* TYPE SCALE — editorial, large, confident. rem-based (16px root). */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--step--1: 0.833rem; /* 13.3px — captions, metadata */
|
||||||
|
--step-0: 1rem; /* 16px — base body */
|
||||||
|
--step-1: 1.2rem; /* 19.2px — lead body */
|
||||||
|
--step-2: 1.5rem; /* 24px — small headings */
|
||||||
|
--step-3: 2.0rem; /* 32px — h3 */
|
||||||
|
--step-4: 2.75rem; /* 44px — h2 */
|
||||||
|
--step-5: 3.75rem; /* 60px — h1 */
|
||||||
|
--step-6: 5.5rem; /* 88px — display */
|
||||||
|
--step-7: 8rem; /* 128px — hero wordmark */
|
||||||
|
|
||||||
|
--leading-tight: 1.04;
|
||||||
|
--leading-snug: 1.18;
|
||||||
|
--leading-body: 1.62;
|
||||||
|
--measure: 66ch;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* GREEBLING / TEXTURE TOKENS — honor the §5 hard guardrails. */
|
||||||
|
/* Texture opacity: 3–8%. Watermark: 4–10%. */
|
||||||
|
/* Contrast delta: 2–6% luminance. Hover lift: rest → active. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--greeble-opacity-rest: 0.04;
|
||||||
|
--greeble-opacity-active: 0.08;
|
||||||
|
--watermark-opacity-rest: 0.05;
|
||||||
|
--watermark-opacity-active:0.10;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* MOTION — slow, eased, short-travel. One signature moment per view. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--dur-fast: 200ms;
|
||||||
|
--dur-base: 300ms;
|
||||||
|
--dur-slow: 400ms;
|
||||||
|
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* gentle settle */
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* symmetric, calm */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* RADII + ELEVATION — minimal. Corners barely soften; shadows are warm. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--radius-xs: 2px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
/* Image cut-out — rounded, asymmetric (one big soft corner). Never a plain
|
||||||
|
rectangle. Used to clip imagery + watermark frames. */
|
||||||
|
--radius-cutout: 6px 92px 6px 6px;
|
||||||
|
--radius-cutout-sm: 4px 60px 4px 4px;
|
||||||
|
|
||||||
|
/* Warm, low shadows — espresso-tinted, never neutral black. */
|
||||||
|
--shadow-1: 0 1px 2px rgba(59, 44, 30, 0.06), 0 1px 1px rgba(59, 44, 30, 0.04);
|
||||||
|
--shadow-2: 0 6px 18px rgba(59, 44, 30, 0.08), 0 2px 6px rgba(59, 44, 30, 0.05);
|
||||||
|
--shadow-3: 0 18px 48px rgba(30, 26, 22, 0.14), 0 6px 16px rgba(30, 26, 22, 0.08);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* SPACING — 4px base, generous. Negative space is a feature. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 24px;
|
||||||
|
--space-6: 32px;
|
||||||
|
--space-7: 48px;
|
||||||
|
--space-8: 64px;
|
||||||
|
--space-9: 96px;
|
||||||
|
--space-10: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* SEMANTIC ELEMENT STYLES — opt in by adding these classes or scoping under */
|
||||||
|
/* a .jb-prose / .jb-type root. Kept as utility-ish classes so the system */
|
||||||
|
/* travels into any HTML mock without a framework. */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
.jb-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-6);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-5);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-4);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-3);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-eyebrow {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-lead {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: var(--step-1);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--fg-2);
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--step-0);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--fg-2);
|
||||||
|
max-width: var(--measure);
|
||||||
|
font-feature-settings: var(--feat-oldstyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-caption {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--fg-3);
|
||||||
|
font-feature-settings: var(--feat-oldstyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-smallcaps {
|
||||||
|
font-feature-settings: var(--feat-smallcaps);
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--dur-fast: 0ms;
|
||||||
|
--dur-base: 0ms;
|
||||||
|
--dur-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
docs/design-system/preview/accent-marks.html
Normal 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>
|
||||||
22
docs/design-system/preview/buttons.html
Normal 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>
|
||||||
19
docs/design-system/preview/card.html
Normal 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>
|
||||||
21
docs/design-system/preview/color-accent.html
Normal 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 / #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>
|
||||||
22
docs/design-system/preview/color-dark.html
Normal 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 · 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>
|
||||||
30
docs/design-system/preview/color-ramp.html
Normal 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 #221E18</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
19
docs/design-system/preview/color-roles.html
Normal 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>
|
||||||
23
docs/design-system/preview/cutout.html
Normal 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 4–10%. Warm chiaroscuro placeholder shown.</p>
|
||||||
|
<p style="margin-top:8px"><code>border-radius: 6px 92px 6px 6px</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
30
docs/design-system/preview/greebling-accents.html
Normal 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>
|
||||||
17
docs/design-system/preview/greebling-grain.html
Normal 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>
|
||||||
19
docs/design-system/preview/latest-line.html
Normal 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>
|
||||||
21
docs/design-system/preview/marks.html
Normal 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>
|
||||||
16
docs/design-system/preview/motion.html
Normal 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>
|
||||||
22
docs/design-system/preview/nav-index.html
Normal 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 & tools</span></a>
|
||||||
|
<a class="item" href="#"><span class="num">03</span><span class="name">Art</span><span class="desc">generative & 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>
|
||||||
17
docs/design-system/preview/radii.html
Normal 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>
|
||||||
16
docs/design-system/preview/shadows.html
Normal 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>
|
||||||
20
docs/design-system/preview/spacing.html
Normal 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>
|
||||||
15
docs/design-system/preview/type-body.html
Normal 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>
|
||||||
19
docs/design-system/preview/type-details.html
Normal 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>
|
||||||
14
docs/design-system/preview/type-display.html
Normal 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>
|
||||||
15
docs/design-system/preview/type-mono.html
Normal 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>
|
||||||
19
docs/design-system/preview/type-scale.html
Normal 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>
|
||||||
23
docs/design-system/preview/wordmark.html
Normal 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 · 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>
|
||||||
642
docs/design-system/templates/image-slot.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
145
docs/design-system/ui_kits/home/HeroZone.jsx
Normal 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 });
|
||||||
53
docs/design-system/ui_kits/home/LatestLine.jsx
Normal 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 2026</span>
|
||||||
|
<span style={ll.arrow}>→</span>
|
||||||
|
</a>
|
||||||
|
<div style={ll.rule} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Object.assign(window, { LatestLine });
|
||||||
41
docs/design-system/ui_kits/home/README.md
Normal 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.
|
||||||
79
docs/design-system/ui_kits/home/SubdomainIndex.jsx
Normal 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 });
|
||||||
25
docs/design-system/ui_kits/home/Wordmark.jsx
Normal 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 Bairstow<span style={wmStyles.dot}>.</span>
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Object.assign(window, { Wordmark });
|
||||||
230
docs/design-system/ui_kits/home/index.html
Normal 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
|
|
@ -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
25
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
public/assets/accent-concentric.svg
Normal 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 |
8
public/assets/accent-dots.svg
Normal 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 |
9
public/assets/accent-pipes.svg
Normal 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 |
6
public/assets/accent-ringdots.svg
Normal 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 |
11
public/assets/mark-rings-cascade.svg
Normal 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 |
11
public/assets/mark-rings-drift.svg
Normal 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 |
11
public/assets/mark-rings.svg
Normal 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 |
8
public/assets/texture-grain.svg
Normal 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 |
9
src/components/atoms/Eyebrow.astro
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class:list={["eyebrow", className]}><slot /></p>
|
||||||
5
src/components/atoms/InkArrow.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
// The ochre arrow that drifts on hover of its parent link. Pure typographic affordance.
|
||||||
|
---
|
||||||
|
|
||||||
|
<span class="inkarrow">→</span>
|
||||||
32
src/components/atoms/Mark.astro
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
// Sprinkled accent mark — crisp glyph, loose placement. Sits behind content (z-index 0).
|
||||||
|
// Position is set per-instance via style props (top/left/right/bottom/transform/width).
|
||||||
|
type MarkKind = "ringdots" | "pipes" | "dots" | "concentric";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kind: MarkKind;
|
||||||
|
width?: number;
|
||||||
|
top?: string;
|
||||||
|
left?: string;
|
||||||
|
right?: string;
|
||||||
|
bottom?: string;
|
||||||
|
rotate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { kind, width = 60, top, left, right, bottom, rotate } = Astro.props;
|
||||||
|
|
||||||
|
const src = `/assets/accent-${kind}.svg`;
|
||||||
|
const transform = rotate ? `rotate(${rotate}deg)` : undefined;
|
||||||
|
const style = [
|
||||||
|
`width:${width}px`,
|
||||||
|
top ? `top:${top}` : null,
|
||||||
|
left ? `left:${left}` : null,
|
||||||
|
right ? `right:${right}` : null,
|
||||||
|
bottom ? `bottom:${bottom}` : null,
|
||||||
|
transform ? `transform:${transform}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(";");
|
||||||
|
---
|
||||||
|
|
||||||
|
<img class="mark" src={src} alt="" style={style} />
|
||||||
11
src/components/atoms/Wordmark.astro
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
import { home } from "~/lib/urls";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { href = home("/") } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<a class="wordmark" href={href}>Josh Bairstow<span class="dot">.</span></a>
|
||||||
63
src/components/islands/HeroZone.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reserved interactive zone on the home page.
|
||||||
|
* Shows a warm chiaroscuro panel whose light source drifts toward the pointer —
|
||||||
|
* the one signature motion moment per the brand brief. Future interactive pieces
|
||||||
|
* drop into this same clipped frame.
|
||||||
|
*
|
||||||
|
* Respects prefers-reduced-motion: static gradient at the rest position.
|
||||||
|
*/
|
||||||
|
export default function HeroZone() {
|
||||||
|
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const accentRef = useRef<HTMLImageElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = frameRef.current;
|
||||||
|
const accent = accentRef.current;
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const rest = { x: 0.62, y: 0.28 };
|
||||||
|
|
||||||
|
function paint(x: number, y: number) {
|
||||||
|
if (!frame) return;
|
||||||
|
const lx = 30 + x * 50;
|
||||||
|
const ly = 8 + y * 40;
|
||||||
|
frame.style.background =
|
||||||
|
`radial-gradient(120% 95% at ${lx}% ${ly}%, ` +
|
||||||
|
`#C9B299 0%, #8A6A47 30%, #4A3826 60%, #221E18 92%)`;
|
||||||
|
if (accent) {
|
||||||
|
accent.style.transform = `translate(${(0.5 - x) * 26}px, ${(0.5 - y) * 16}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paint(rest.x, rest.y);
|
||||||
|
if (reduce) return;
|
||||||
|
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const r = frame.getBoundingClientRect();
|
||||||
|
paint(
|
||||||
|
Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
|
||||||
|
Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onLeave = () => paint(rest.x, rest.y);
|
||||||
|
|
||||||
|
frame.addEventListener("mousemove", onMove);
|
||||||
|
frame.addEventListener("mouseleave", onLeave);
|
||||||
|
return () => {
|
||||||
|
frame.removeEventListener("mousemove", onMove);
|
||||||
|
frame.removeEventListener("mouseleave", onLeave);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="hzframe" ref={frameRef}>
|
||||||
|
<div className="grain" />
|
||||||
|
<img ref={accentRef} className="hzaccent" src="/assets/accent-ringdots.svg" alt="" />
|
||||||
|
<img className="hzwm" src="/assets/mark-rings.svg" alt="" />
|
||||||
|
<span className="hztag">Interactive · soon</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/components/islands/TideStudyCanvas.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tide Study No. 7 — a slow generative field of warm tidal rings.
|
||||||
|
* Bone → espresso palette; one new ripple every ~2.6s; respects reduced-motion.
|
||||||
|
* Lives in its own island so each future art piece can ship its own dependencies
|
||||||
|
* without bloating the rest of the site.
|
||||||
|
*/
|
||||||
|
export default function TideStudyCanvas() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const frame = frameRef.current;
|
||||||
|
if (!canvas || !frame) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const DPR = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
let W = 0;
|
||||||
|
let H = 0;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const r = frame.getBoundingClientRect();
|
||||||
|
W = r.width;
|
||||||
|
H = r.height;
|
||||||
|
canvas.width = Math.round(W * DPR);
|
||||||
|
canvas.height = Math.round(H * DPR);
|
||||||
|
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const observer = new ResizeObserver(resize);
|
||||||
|
observer.observe(frame);
|
||||||
|
|
||||||
|
const inks = [
|
||||||
|
"rgba(233,221,201,",
|
||||||
|
"rgba(201,178,153,",
|
||||||
|
"rgba(168,133,95,",
|
||||||
|
];
|
||||||
|
|
||||||
|
const dots = Array.from({ length: 22 }, () => ({
|
||||||
|
x: Math.random(),
|
||||||
|
y: Math.random(),
|
||||||
|
r: 0.6 + Math.random() * 2.2,
|
||||||
|
a: 0.05 + Math.random() * 0.14,
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Ring = { cx: number; cy: number; r: number; born: number; ink: string; wob: number };
|
||||||
|
let rings: Ring[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let acc = 0;
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const center = () => ({ cx: W * 0.42, cy: H * 0.5 });
|
||||||
|
|
||||||
|
const drawDots = () => {
|
||||||
|
for (const d of dots) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(d.x * W, d.y * H, d.r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(233,221,201,${d.a})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const spawn = () => {
|
||||||
|
const { cx, cy } = center();
|
||||||
|
rings.push({
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
r: 0,
|
||||||
|
born: performance.now(),
|
||||||
|
ink: inks[Math.floor(Math.random() * inks.length)],
|
||||||
|
wob: 0.85 + Math.random() * 0.3,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const frameDraw = (now: number) => {
|
||||||
|
if (W <= 0 || H <= 0) return;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
drawDots();
|
||||||
|
const maxR = Math.hypot(W, H) * 0.62;
|
||||||
|
for (const ring of rings) {
|
||||||
|
const age = (now - ring.born) / 1000;
|
||||||
|
ring.r = Math.max(0, age * Math.min(W, H) * 0.085);
|
||||||
|
const t = ring.r / maxR;
|
||||||
|
const alpha = Math.max(0, 1 - t) * 0.5;
|
||||||
|
if (alpha <= 0) continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(
|
||||||
|
ring.cx,
|
||||||
|
ring.cy,
|
||||||
|
ring.r,
|
||||||
|
Math.max(0, ring.r * ring.wob),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
Math.PI * 2,
|
||||||
|
);
|
||||||
|
ctx.strokeStyle = `${ring.ink}${alpha.toFixed(3)})`;
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
rings = rings.filter((r) => r.r < maxR);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reduce) {
|
||||||
|
const { cx, cy } = center();
|
||||||
|
drawDots();
|
||||||
|
for (let i = 1; i <= 7; i++) {
|
||||||
|
const rr = i * Math.min(W, H) * 0.075;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rr, rr * 0.95, 0, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = `${inks[i % inks.length]}${(0.4 * (1 - i / 8)).toFixed(3)})`;
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the field so it isn't empty on load.
|
||||||
|
const seed = performance.now();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
rings.push({
|
||||||
|
cx: W * 0.42,
|
||||||
|
cy: H * 0.5,
|
||||||
|
r: 0,
|
||||||
|
born: seed - i * 1400,
|
||||||
|
ink: inks[i % inks.length],
|
||||||
|
wob: 0.9 + Math.random() * 0.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const loop = (now: number) => {
|
||||||
|
if (!last) last = now;
|
||||||
|
const dt = now - last;
|
||||||
|
last = now;
|
||||||
|
acc += dt;
|
||||||
|
if (acc > 2600) {
|
||||||
|
spawn();
|
||||||
|
acc = 0;
|
||||||
|
}
|
||||||
|
frameDraw(now);
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="artframe" ref={frameRef}>
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
<span className="live">
|
||||||
|
<i /> Generative · live
|
||||||
|
</span>
|
||||||
|
<div className="grain" />
|
||||||
|
<img className="wm" src="/assets/mark-rings.svg" alt="" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/components/molecules/IndexRow.astro
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
---
|
||||||
|
import InkArrow from "~/components/atoms/InkArrow.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
num: string;
|
||||||
|
name: string;
|
||||||
|
href?: string;
|
||||||
|
desc?: string;
|
||||||
|
soon?: boolean;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { num, name, href, desc, soon = false, external = false } = Astro.props;
|
||||||
|
|
||||||
|
const Tag = soon ? "div" : "a";
|
||||||
|
const props = soon
|
||||||
|
? { class: "idxrow soon" }
|
||||||
|
: {
|
||||||
|
class: "idxrow",
|
||||||
|
href,
|
||||||
|
...(external ? { rel: "noopener" } : {}),
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag {...props}>
|
||||||
|
<span class="num">{num}</span>
|
||||||
|
<span class="nm">{name}</span>
|
||||||
|
<span class="tick"></span>
|
||||||
|
{soon ? <span class="badge">soon</span> : desc && <span class="desc">{desc}</span>}
|
||||||
|
{!soon && <InkArrow />}
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.idxrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 13px 0;
|
||||||
|
border-top: 1px solid var(--hairline-on-dark);
|
||||||
|
transition: padding-left var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .idxrow {
|
||||||
|
border-top-color: var(--hairline);
|
||||||
|
}
|
||||||
|
.idxrow:hover {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.idxrow.soon {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.idxrow.soon:hover {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
width: 24px;
|
||||||
|
flex: none;
|
||||||
|
font-feature-settings: "onum" 1;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .num {
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.nm {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(19px, 1.9vw, 24px);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .nm {
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
.idxrow.soon .nm {
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .idxrow.soon .nm {
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.tick {
|
||||||
|
width: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--accent-deep);
|
||||||
|
align-self: center;
|
||||||
|
transition: width var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.idxrow:hover .tick {
|
||||||
|
width: 22px;
|
||||||
|
}
|
||||||
|
.idxrow.soon:hover .tick {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .desc {
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--hairline-on-dark);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .badge {
|
||||||
|
color: var(--fg-3);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
src/components/molecules/LatestLine.astro
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
---
|
||||||
|
import InkArrow from "~/components/atoms/InkArrow.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, href, date } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<a class="line" href={href}>
|
||||||
|
<span class="ttl">{title}</span>
|
||||||
|
<span class="meta">{date}</span>
|
||||||
|
<InkArrow />
|
||||||
|
</a>
|
||||||
|
<div class="rule"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .line {
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
.ttl {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(20px, 2.1vw, 27px);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
font-feature-settings: "onum" 1;
|
||||||
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .meta {
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.rule {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--hairline-on-dark);
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .rule {
|
||||||
|
background: var(--hairline);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
117
src/components/molecules/PostRow.astro
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
date: string;
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
deck?: string;
|
||||||
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { date, title, href, deck, tag } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<a class="postrow" href={href}>
|
||||||
|
<span class="pdate">{date}</span>
|
||||||
|
<span class="middle">
|
||||||
|
<span class="pttl">{title}<span class="tick"></span></span>
|
||||||
|
{deck && <span class="pdek">{deck}</span>}
|
||||||
|
</span>
|
||||||
|
{tag && <span class="ptag">{tag}</span>}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.postrow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr 150px;
|
||||||
|
gap: 22px;
|
||||||
|
align-items: baseline;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: clamp(16px, 2.6vh, 24px) 0;
|
||||||
|
border-top: 1px solid var(--hairline-on-dark);
|
||||||
|
transition: padding-left var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .postrow {
|
||||||
|
border-top-color: var(--hairline);
|
||||||
|
}
|
||||||
|
.postrow:hover {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
.pdate {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
font-feature-settings: "onum" 1;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .pdate {
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
.middle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.pttl {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(20px, 2vw, 27px);
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .pttl {
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
.tick {
|
||||||
|
width: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--accent-deep);
|
||||||
|
align-self: center;
|
||||||
|
flex: none;
|
||||||
|
transition: width var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.postrow:hover .tick {
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
.pdek {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--fg-on-dark-2);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .pdek {
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
.ptag {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
text-align: right;
|
||||||
|
align-self: start;
|
||||||
|
padding-top: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .ptag {
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.postrow {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ptag {
|
||||||
|
text-align: left;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/components/molecules/YearHead.astro
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
year: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { year, count } = Astro.props;
|
||||||
|
const formatted = String(count).padStart(2, "0");
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="yearhead">
|
||||||
|
<span class="yr">{year}</span>
|
||||||
|
<span class="ln"></span>
|
||||||
|
<span class="n">{formatted}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.yearhead {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 16px;
|
||||||
|
margin: clamp(28px, 5vh, 52px) 0 6px;
|
||||||
|
}
|
||||||
|
.yearhead:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.yr {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .yr {
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
.ln {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--hairline-on-dark);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .ln {
|
||||||
|
background: var(--hairline);
|
||||||
|
}
|
||||||
|
.n {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
font-feature-settings: "onum" 1;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .n {
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
src/components/organisms/SiteFoot.astro
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
copy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { copy = "© Josh Bairstow — Sydney" } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="sitefoot">
|
||||||
|
<img class="sig" data-watermark src="/assets/mark-rings.svg" alt="" />
|
||||||
|
<span class="copyline">{copy}</span>
|
||||||
|
</footer>
|
||||||
37
src/components/organisms/TopBar.astro
Normal file
|
|
@ -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
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { defineCollection, z } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
|
||||||
|
const posts = defineCollection({
|
||||||
|
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
date: z.coerce.date(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
standfirst: z.string().optional(),
|
||||||
|
tag: z.string().optional(),
|
||||||
|
series: z.string().optional(),
|
||||||
|
readingMinutes: z.number().optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { posts };
|
||||||
29
src/content/posts/notes-on-warm-light-and-cold-water.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
title: Notes on warm light and cold water
|
||||||
|
date: 2026-03-12
|
||||||
|
tag: Field notes
|
||||||
|
series: No. 19
|
||||||
|
standfirst: A study of value over hue, written before the sun is fully up.
|
||||||
|
description: A short essay on seeing in value — warm shadow against warmer light — and on the discipline the cold morning ocean brings to attention.
|
||||||
|
readingMinutes: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
<span class="leadin">There is a particular hour</span> before the sun fully clears the headland when everything is value and almost no hue — warm shadow against warmer light, and the whole world reduced to a single tonal ramp. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
||||||
|
{.lead}
|
||||||
|
|
||||||
|
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. I have been trying, for some years now, to see the way a camera does — to read a scene as a set of relationships rather than a list of things. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore.
|
||||||
|
|
||||||
|
### Seeing in value
|
||||||
|
|
||||||
|
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. The trick, if there is one, is to [squint until the colour drops away](#) and only the structure remains. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.
|
||||||
|
|
||||||
|
> "Warmth is not a colour. It is a direction the light is travelling."
|
||||||
|
> — Field notes, No. 19
|
||||||
|
|
||||||
|
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet.
|
||||||
|
|
||||||
|
### Cold water, slow craft
|
||||||
|
|
||||||
|
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident. The cold does something useful to attention — it narrows it.
|
||||||
|
|
||||||
|
Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.
|
||||||
13
src/content/posts/on-shipping-less.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
---
|
||||||
|
title: On shipping less
|
||||||
|
date: 2026-01-22
|
||||||
|
tag: Code
|
||||||
|
series: No. 17
|
||||||
|
standfirst: A short argument for fewer features, slower releases, and the luxury of restraint.
|
||||||
|
readingMinutes: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
Sed do eiusmod tempor incididunt ut labore. Most software wants to do too much. The discipline is in cutting, again, until what remains is small enough to keep.
|
||||||
|
{.lead}
|
||||||
|
|
||||||
|
Ut enim ad minim veniam, quis nostrud. The luxury of restraint is the cousin of the luxury of a single room with good light.
|
||||||
17
src/content/posts/the-weight-of-a-good-jotter.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
---
|
||||||
|
title: The weight of a good jotter
|
||||||
|
date: 2026-02-04
|
||||||
|
tag: Craft
|
||||||
|
series: No. 18
|
||||||
|
standfirst: On chalk, brass, and the small machines that make a hand feel deliberate.
|
||||||
|
readingMinutes: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. There is a particular pleasure in writing tools that resist a little — chalk that has to be broken in, brass that warms in the hand, paper with a tooth.
|
||||||
|
{.lead}
|
||||||
|
|
||||||
|
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. The weight of a good jotter, like the weight of a good knife, is felt as intention.
|
||||||
|
|
||||||
|
### Brass and breath
|
||||||
|
|
||||||
|
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||||
67
src/layouts/BaseLayout.astro
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
import "~/styles/colors_and_type.css";
|
||||||
|
import "~/styles/site.css";
|
||||||
|
import TopBar from "~/components/organisms/TopBar.astro";
|
||||||
|
import SiteFoot from "~/components/organisms/SiteFoot.astro";
|
||||||
|
import Mark from "~/components/atoms/Mark.astro";
|
||||||
|
|
||||||
|
type CurrentSection = "home" | "blog" | "art" | "code";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
current?: CurrentSection;
|
||||||
|
/**
|
||||||
|
* When true, the topbar renders a Sydney coord readout instead of the nav.
|
||||||
|
* Used on the apex home page (per the design — the home page is its own surface
|
||||||
|
* and doesn't need the section nav since the subdomain index lives in the body).
|
||||||
|
*/
|
||||||
|
showCoordInTopbar?: boolean;
|
||||||
|
/**
|
||||||
|
* Override the canonical URL when the page belongs to a subdomain.
|
||||||
|
* Defaults to apex (joshbairstow.com).
|
||||||
|
*/
|
||||||
|
canonical?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description = "Quiet writing, slow software, and the occasional mark in ink.",
|
||||||
|
current = "home",
|
||||||
|
showCoordInTopbar = false,
|
||||||
|
canonical,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const canonicalHref = canonical ?? new URL(Astro.url.pathname, Astro.site).toString();
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="canonical" href={canonicalHref} />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/mark-rings.svg" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={canonicalHref} />
|
||||||
|
</head>
|
||||||
|
<body class="dark">
|
||||||
|
<!-- sprinkled accent marks — loose, non-rigid placement, per page-of-page overrides via slot -->
|
||||||
|
<slot name="marks">
|
||||||
|
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
|
||||||
|
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
|
||||||
|
<Mark kind="dots" width={52} top="26%" right="3.5%" />
|
||||||
|
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<div class="stage">
|
||||||
|
<TopBar current={current} showCoord={showCoordInTopbar} />
|
||||||
|
<slot />
|
||||||
|
<SiteFoot />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
223
src/layouts/PostLayout.astro
Normal file
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,233 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from "~/layouts/BaseLayout.astro";
|
||||||
|
import Eyebrow from "~/components/atoms/Eyebrow.astro";
|
||||||
|
import Mark from "~/components/atoms/Mark.astro";
|
||||||
|
import PostRow from "~/components/molecules/PostRow.astro";
|
||||||
|
import YearHead from "~/components/molecules/YearHead.astro";
|
||||||
|
import InkArrow from "~/components/atoms/InkArrow.astro";
|
||||||
|
import { blog } from "~/lib/urls";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
||||||
|
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
|
||||||
|
const [featured, ...rest] = posts;
|
||||||
|
|
||||||
|
const dateFmtMonthYear = new Intl.DateTimeFormat("en-AU", { month: "short", year: "numeric" });
|
||||||
|
const dateFmtMonthYearLong = new Intl.DateTimeFormat("en-AU", { month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
// Group the archive by year.
|
||||||
|
const byYear = new Map<number, typeof rest>();
|
||||||
|
for (const post of rest) {
|
||||||
|
const year = post.data.date.getFullYear();
|
||||||
|
const bucket = byYear.get(year) ?? [];
|
||||||
|
bucket.push(post);
|
||||||
|
byYear.set(year, bucket);
|
||||||
|
}
|
||||||
|
const yearGroups = Array.from(byYear.entries()).sort(([a], [b]) => b - a);
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout
|
||||||
|
title="Writing — Josh Bairstow"
|
||||||
|
description="Short pieces on craft, the ocean, and the quiet discipline of making small software."
|
||||||
|
current="blog"
|
||||||
|
>
|
||||||
|
<Fragment slot="marks">
|
||||||
|
<Mark kind="dots" width={54} top="18%" right="4%" />
|
||||||
|
<Mark kind="concentric" width={40} top="64%" left="3%" rotate={6} />
|
||||||
|
<Mark kind="pipes" width={26} bottom="12%" right="9%" />
|
||||||
|
<Mark kind="ringdots" width={72} bottom="26%" left="46%" rotate={-4} />
|
||||||
|
</Fragment>
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<div>
|
||||||
|
<Eyebrow>Journal · field notes</Eyebrow>
|
||||||
|
<h1 class="title">Writing<span style="color:var(--accent-deep)">.</span></h1>
|
||||||
|
<p class="count">{posts.length} entries · since 2021</p>
|
||||||
|
</div>
|
||||||
|
<p class="blurb">
|
||||||
|
Short pieces on craft, the ocean, and the quiet discipline of making small
|
||||||
|
software. Mostly written early, before the light is up.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{
|
||||||
|
featured && (
|
||||||
|
<article class="feature">
|
||||||
|
<div class="cutframe img">
|
||||||
|
<div class="grain" />
|
||||||
|
<img class="wm" src="/assets/mark-rings.svg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="meta">
|
||||||
|
{featured.data.tag && <span class="tag">{featured.data.tag}</span>}
|
||||||
|
<span class="date">
|
||||||
|
{dateFmtMonthYearLong.format(featured.data.date)}
|
||||||
|
{featured.data.readingMinutes && ` · ${featured.data.readingMinutes} min`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2>
|
||||||
|
<a href={blog(`/posts/${featured.id}/`)}>{featured.data.title}</a>
|
||||||
|
</h2>
|
||||||
|
{featured.data.standfirst && <p>{featured.data.standfirst}</p>}
|
||||||
|
<a class="read" href={blog(`/posts/${featured.id}/`)}>
|
||||||
|
<span class="u">Read the piece</span>
|
||||||
|
<InkArrow />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="archive">
|
||||||
|
{
|
||||||
|
yearGroups.map(([year, items]) => (
|
||||||
|
<>
|
||||||
|
<YearHead year={String(year)} count={items.length} />
|
||||||
|
{items.map((post) => (
|
||||||
|
<PostRow
|
||||||
|
date={dateFmtMonthYear.format(post.data.date)}
|
||||||
|
title={post.data.title}
|
||||||
|
href={blog(`/posts/${post.id}/`)}
|
||||||
|
deck={post.data.standfirst}
|
||||||
|
tag={post.data.tag}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.masthead {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: clamp(30px, 6vw, 80px);
|
||||||
|
align-items: end;
|
||||||
|
padding: clamp(40px, 8vh, 96px) 0 clamp(30px, 5vh, 52px);
|
||||||
|
}
|
||||||
|
.masthead .title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(56px, 9vw, 132px);
|
||||||
|
line-height: 0.9;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .masthead .title { color: var(--fg-1); }
|
||||||
|
.masthead .blurb {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: clamp(15px, 1.4vw, 18px);
|
||||||
|
line-height: 1.62;
|
||||||
|
color: var(--fg-on-dark-2);
|
||||||
|
max-width: 38ch;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .masthead .blurb { color: var(--fg-2); }
|
||||||
|
.masthead .count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
margin-top: 16px;
|
||||||
|
font-feature-settings: "onum" 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.92fr 1.08fr;
|
||||||
|
gap: clamp(28px, 5vw, 64px);
|
||||||
|
align-items: center;
|
||||||
|
padding: clamp(26px, 4.5vh, 48px) 0;
|
||||||
|
border-top: 1px solid var(--hairline-on-dark);
|
||||||
|
border-bottom: 1px solid var(--hairline-on-dark);
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .feature { border-color: var(--hairline); }
|
||||||
|
.feature .img { aspect-ratio: 5 / 4; }
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .tag { color: var(--accent-deep); }
|
||||||
|
.date {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--fg-on-dark-3);
|
||||||
|
font-feature-settings: "onum" 1;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .date { color: var(--fg-3); }
|
||||||
|
.feature h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(28px, 3.4vw, 46px);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .feature h2 { color: var(--fg-1); }
|
||||||
|
.feature h2 a { color: inherit; text-decoration: none; }
|
||||||
|
.feature p {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 16.5px;
|
||||||
|
line-height: 1.62;
|
||||||
|
color: var(--fg-on-dark-2);
|
||||||
|
margin: 18px 0 0;
|
||||||
|
max-width: 48ch;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .feature p { color: var(--fg-2); }
|
||||||
|
.read {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .read { color: var(--fg-1); }
|
||||||
|
.read .u {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
}
|
||||||
|
.read .u::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive {
|
||||||
|
padding: clamp(34px, 6vh, 72px) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.masthead,
|
||||||
|
.feature { grid-template-columns: 1fr; }
|
||||||
|
.feature .img { order: -1; aspect-ratio: 16 / 9; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
27
src/pages/blog/posts/[...slug].astro
Normal file
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,242 @@
|
||||||
|
---
|
||||||
|
import BaseLayout from "~/layouts/BaseLayout.astro";
|
||||||
|
import Eyebrow from "~/components/atoms/Eyebrow.astro";
|
||||||
|
import Mark from "~/components/atoms/Mark.astro";
|
||||||
|
import LatestLine from "~/components/molecules/LatestLine.astro";
|
||||||
|
import IndexRow from "~/components/molecules/IndexRow.astro";
|
||||||
|
import HeroZone from "~/components/islands/HeroZone";
|
||||||
|
import { blog, art, code } from "~/lib/urls";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
// Pull the most recent published post for the "selected writing" line.
|
||||||
|
const posts = await getCollection("posts", ({ data }) => !data.draft);
|
||||||
|
posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||||
|
const latest = posts[0];
|
||||||
|
|
||||||
|
const dateFmt = new Intl.DateTimeFormat("en-AU", {
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subdomain index. Each row points at the right destination. Some entries are
|
||||||
|
// out-of-repo containers; flagging external just signals intent for now (rel attrs).
|
||||||
|
const indexItems = [
|
||||||
|
{ num: "01", name: "Blog", href: blog("/"), desc: "Writing", external: false, soon: false },
|
||||||
|
{ num: "02", name: "Code", href: code("/"), desc: "Experiments & tools", external: false, soon: false },
|
||||||
|
{ num: "03", name: "Art", href: art("/"), desc: "Generative & physical", external: false, soon: false },
|
||||||
|
{ num: "04", name: "Signage", href: undefined, desc: undefined, external: true, soon: true },
|
||||||
|
{ num: "05", name: "Keycaps", href: undefined, desc: undefined, external: true, soon: true },
|
||||||
|
{ num: "06", name: "Social", href: undefined, desc: undefined, external: true, soon: true },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title="Josh Bairstow" current="home" showCoordInTopbar={true}>
|
||||||
|
<Fragment slot="marks">
|
||||||
|
<Mark kind="ringdots" width={84} top="14%" left="3%" rotate={-5} />
|
||||||
|
<Mark kind="pipes" width={28} top="73%" left="6%" rotate={8} />
|
||||||
|
<Mark kind="dots" width={52} top="26%" right="3.5%" />
|
||||||
|
<Mark kind="concentric" width={42} bottom="9%" right="7%" />
|
||||||
|
<Mark kind="ringdots" width={66} bottom="20%" left="44%" rotate={-3} />
|
||||||
|
</Fragment>
|
||||||
|
|
||||||
|
<main class="home" data-layout="stack">
|
||||||
|
<section class="intro">
|
||||||
|
<Eyebrow>Engineer · Sydney</Eyebrow>
|
||||||
|
<h2 class="statement">
|
||||||
|
I make small,<br /><em>careful</em> things<span class="accent">.</span>
|
||||||
|
</h2>
|
||||||
|
<p class="lead">
|
||||||
|
Software, tools, and the occasional mark in ink. A quiet corner of the
|
||||||
|
internet — mostly writing, sometimes shipping.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<HeroZone client:load />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{
|
||||||
|
latest && (
|
||||||
|
<section class="latest">
|
||||||
|
<Eyebrow>Selected writing</Eyebrow>
|
||||||
|
<LatestLine
|
||||||
|
title={latest.data.title}
|
||||||
|
href={blog(`/posts/${latest.id}/`)}
|
||||||
|
date={dateFmt.format(latest.data.date)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="index">
|
||||||
|
<Eyebrow>Index</Eyebrow>
|
||||||
|
<nav>
|
||||||
|
{indexItems.map((item) => (
|
||||||
|
<IndexRow
|
||||||
|
num={item.num}
|
||||||
|
name={item.name}
|
||||||
|
href={item.href}
|
||||||
|
desc={item.desc}
|
||||||
|
soon={item.soon}
|
||||||
|
external={item.external}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Stack layout — editorial, centered. The canonical landing per docs/planning.md. */
|
||||||
|
.home {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 880px);
|
||||||
|
grid-template-areas: "intro" "hero" "latest" "index";
|
||||||
|
justify-content: center;
|
||||||
|
gap: clamp(28px, 4.6vh, 52px);
|
||||||
|
align-content: start;
|
||||||
|
padding-top: clamp(40px, 7vh, 96px);
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
grid-area: intro;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.intro :global(.eyebrow) {
|
||||||
|
margin-bottom: clamp(18px, 3.4vh, 30px);
|
||||||
|
}
|
||||||
|
.statement {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(40px, 5.4vw, 82px);
|
||||||
|
line-height: 0.97;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .statement {
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
.statement em {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.statement .accent {
|
||||||
|
color: var(--accent-deep);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: clamp(15px, 1.45vw, 18px);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--fg-on-dark-2);
|
||||||
|
max-width: 56ch;
|
||||||
|
margin: clamp(20px, 3.6vh, 32px) auto 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
:global(body:not(.dark)) .lead {
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
grid-area: hero;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
/* Styles for .hzframe + its children come from the React island's class names */
|
||||||
|
.hero :global(.hzframe) {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius-cutout);
|
||||||
|
background: radial-gradient(
|
||||||
|
120% 95% at 64% 18%,
|
||||||
|
#c9b299 0%,
|
||||||
|
#8a6a47 30%,
|
||||||
|
#4a3826 60%,
|
||||||
|
#221e18 92%
|
||||||
|
);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
cursor: crosshair;
|
||||||
|
transition: background 700ms var(--ease-out);
|
||||||
|
aspect-ratio: 16 / 7;
|
||||||
|
}
|
||||||
|
.hero :global(.hzframe .grain) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: url(/assets/texture-grain.svg);
|
||||||
|
background-size: 220px;
|
||||||
|
opacity: 0.12;
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hero :global(.hzframe .hzaccent) {
|
||||||
|
position: absolute;
|
||||||
|
right: 9%;
|
||||||
|
top: 12%;
|
||||||
|
width: 76px;
|
||||||
|
opacity: 0.18;
|
||||||
|
filter: invert(1);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform 900ms var(--ease-out);
|
||||||
|
}
|
||||||
|
.hero :global(.hzframe .hzwm) {
|
||||||
|
position: absolute;
|
||||||
|
left: 8%;
|
||||||
|
bottom: 9%;
|
||||||
|
width: 128px;
|
||||||
|
opacity: 0.1;
|
||||||
|
filter: invert(1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.hero :global(.hzframe .hztag) {
|
||||||
|
position: absolute;
|
||||||
|
right: 8%;
|
||||||
|
bottom: 8%;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(239, 231, 218, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.latest {
|
||||||
|
grid-area: latest;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.latest :global(.eyebrow) {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index {
|
||||||
|
grid-area: index;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.index :global(.eyebrow) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.home {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.hero :global(.hzframe) {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
src/styles/colors_and_type.css
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
/* ============================================================================
|
||||||
|
Josh Bairstow — Brand System
|
||||||
|
colors_and_type.css — foundational color + type tokens
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
One base aesthetic (quiet luxury, warm browns + ink) and one contained
|
||||||
|
accent (ochre / calligraffiti ink). Chiaroscuro is achieved through VALUE
|
||||||
|
in the warm ramp — never through cool grey. Keep shadows warm.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400;0,6..96,500;0,6..96,600;0,6..96,700;1,6..96,400;1,6..96,500&family=Hanken+Grotesk:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* WARM TONAL RAMP — the spine of the system. */
|
||||||
|
/* A single value ladder from bone (light) to warm near-black (ink). */
|
||||||
|
/* Use value, not hue, to build depth and chiaroscuro. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--bone-50: #F4EFE6; /* Ground — primary background, warm off-white */
|
||||||
|
--bone-100: #EDE6D9; /* Raised ground — cards, panels */
|
||||||
|
--bone-200: #E0D5C3; /* Sunk ground / quiet fills */
|
||||||
|
--sand-300: #C9B299; /* Light brown — hairlines on dark, muted detail */
|
||||||
|
--sepia-400: #A8855F; /* Sepia — secondary brand brown */
|
||||||
|
--tobacco-500: #7A5C3E;/* Tobacco — primary brand brown */
|
||||||
|
--brown-600: #5A4630; /* Mid-deep brown — secondary text on light */
|
||||||
|
--espresso-700: #3B2C1E;/* Espresso — depth, warm shadow tone */
|
||||||
|
--bark-800: #2E2720; /* Bark — dark panels */
|
||||||
|
--ink-900: #221E18; /* Ink — primary text / the ink gesture */
|
||||||
|
--ground-dark-950: #17130E; /* Inverted ground — DEFAULT theme, deep + moody */
|
||||||
|
--ground-dark-900: #1E1A14; /* Raised dark panel */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* ACCENT — single, restrained, rare. Ochre/amber. A deliberate event. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--accent: #C0883A; /* Muted ochre — hover ticks, rare highlights */
|
||||||
|
--accent-deep: #9A6A28; /* Deep amber — pressed / on-light accent text */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* SEMANTIC COLOR ROLES */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--ground: var(--bone-50);
|
||||||
|
--ground-raised: var(--bone-100);
|
||||||
|
--ground-sunk: var(--bone-200);
|
||||||
|
--ground-dark: var(--ground-dark-950);
|
||||||
|
|
||||||
|
--fg-1: var(--ink-900); /* primary text */
|
||||||
|
--fg-2: var(--brown-600); /* secondary text */
|
||||||
|
--fg-3: var(--sepia-400); /* tertiary / muted / metadata */
|
||||||
|
|
||||||
|
/* On dark grounds (the default theme) */
|
||||||
|
--fg-on-dark-1: #EDE4D5;
|
||||||
|
--fg-on-dark-2: #AE9B7F;
|
||||||
|
--fg-on-dark-3: #6F5E48;
|
||||||
|
|
||||||
|
/* Hairlines — warm, never grey. Used for §5 rules and dividers. */
|
||||||
|
--hairline: rgba(34, 30, 24, 0.12);
|
||||||
|
--hairline-strong: rgba(34, 30, 24, 0.22);
|
||||||
|
--hairline-on-dark: rgba(239, 231, 218, 0.14);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* TYPOGRAPHY FAMILIES */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--font-display: 'Bodoni Moda', 'Times New Roman', serif;
|
||||||
|
--font-body: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||||
|
|
||||||
|
/* OpenType "visit three" details: old-style figures + true small caps. */
|
||||||
|
--feat-oldstyle: "onum" 1, "pnum" 1;
|
||||||
|
--feat-smallcaps: "smcp" 1, "onum" 1;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* TYPE SCALE — editorial, large, confident. rem-based (16px root). */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--step--1: 0.833rem; /* 13.3px — captions, metadata */
|
||||||
|
--step-0: 1rem; /* 16px — base body */
|
||||||
|
--step-1: 1.2rem; /* 19.2px — lead body */
|
||||||
|
--step-2: 1.5rem; /* 24px — small headings */
|
||||||
|
--step-3: 2.0rem; /* 32px — h3 */
|
||||||
|
--step-4: 2.75rem; /* 44px — h2 */
|
||||||
|
--step-5: 3.75rem; /* 60px — h1 */
|
||||||
|
--step-6: 5.5rem; /* 88px — display */
|
||||||
|
--step-7: 8rem; /* 128px — hero wordmark */
|
||||||
|
|
||||||
|
--leading-tight: 1.04;
|
||||||
|
--leading-snug: 1.18;
|
||||||
|
--leading-body: 1.62;
|
||||||
|
--measure: 66ch;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* GREEBLING / TEXTURE TOKENS — honor the §5 hard guardrails. */
|
||||||
|
/* Texture opacity: 3–8%. Watermark: 4–10%. */
|
||||||
|
/* Contrast delta: 2–6% luminance. Hover lift: rest → active. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--greeble-opacity-rest: 0.04;
|
||||||
|
--greeble-opacity-active: 0.08;
|
||||||
|
--watermark-opacity-rest: 0.05;
|
||||||
|
--watermark-opacity-active:0.10;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* MOTION — slow, eased, short-travel. One signature moment per view. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--dur-fast: 200ms;
|
||||||
|
--dur-base: 300ms;
|
||||||
|
--dur-slow: 400ms;
|
||||||
|
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* gentle settle */
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* symmetric, calm */
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* RADII + ELEVATION — minimal. Corners barely soften; shadows are warm. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--radius-xs: 2px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
/* Image cut-out — rounded, asymmetric (one big soft corner). Never a plain
|
||||||
|
rectangle. Used to clip imagery + watermark frames. */
|
||||||
|
--radius-cutout: 6px 92px 6px 6px;
|
||||||
|
--radius-cutout-sm: 4px 60px 4px 4px;
|
||||||
|
|
||||||
|
/* Warm, low shadows — espresso-tinted, never neutral black. */
|
||||||
|
--shadow-1: 0 1px 2px rgba(59, 44, 30, 0.06), 0 1px 1px rgba(59, 44, 30, 0.04);
|
||||||
|
--shadow-2: 0 6px 18px rgba(59, 44, 30, 0.08), 0 2px 6px rgba(59, 44, 30, 0.05);
|
||||||
|
--shadow-3: 0 18px 48px rgba(30, 26, 22, 0.14), 0 6px 16px rgba(30, 26, 22, 0.08);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
/* SPACING — 4px base, generous. Negative space is a feature. */
|
||||||
|
/* ---------------------------------------------------------------------- */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 24px;
|
||||||
|
--space-6: 32px;
|
||||||
|
--space-7: 48px;
|
||||||
|
--space-8: 64px;
|
||||||
|
--space-9: 96px;
|
||||||
|
--space-10: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* SEMANTIC ELEMENT STYLES — opt in by adding these classes or scoping under */
|
||||||
|
/* a .jb-prose / .jb-type root. Kept as utility-ish classes so the system */
|
||||||
|
/* travels into any HTML mock without a framework. */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
.jb-display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-6);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-5);
|
||||||
|
line-height: var(--leading-tight);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-4);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step-3);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
color: var(--fg-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-eyebrow {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-lead {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: var(--step-1);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--fg-2);
|
||||||
|
max-width: var(--measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--step-0);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
color: var(--fg-2);
|
||||||
|
max-width: var(--measure);
|
||||||
|
font-feature-settings: var(--feat-oldstyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-caption {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--fg-3);
|
||||||
|
font-feature-settings: var(--feat-oldstyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--fg-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jb-smallcaps {
|
||||||
|
font-feature-settings: var(--feat-smallcaps);
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root {
|
||||||
|
--dur-fast: 0ms;
|
||||||
|
--dur-base: 0ms;
|
||||||
|
--dur-slow: 0ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/styles/site.css
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/* ============================================================================
|
||||||
|
Josh Bairstow — shared site chrome
|
||||||
|
Sits on top of colors_and_type.css. Holds the bits every page repeats:
|
||||||
|
the warm grounds, near-invisible grain, sprinkled accent marks, the top
|
||||||
|
bar, link affordances, the signature footer.
|
||||||
|
Dark ink ground is the default theme; body:not(.dark) is the bone alternate.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; }
|
||||||
|
html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background-color: var(--ground-dark);
|
||||||
|
color: var(--fg-on-dark-1);
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
body:not(.dark) { background-color: var(--ground); color: var(--fg-1); }
|
||||||
|
|
||||||
|
/* ---- page-wide film grain (§5: 3–8% opacity, no hard edges) ------------- */
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background-image: url(/assets/texture-grain.svg);
|
||||||
|
background-size: 240px;
|
||||||
|
opacity: 0.06;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
body:not(.dark)::before { opacity: 0.045; mix-blend-mode: normal; }
|
||||||
|
|
||||||
|
/* ---- sprinkled accent marks — crisp glyphs, loose placement ------------- */
|
||||||
|
:root {
|
||||||
|
--mark-filter-dark: invert(52%) sepia(16%) saturate(340%) brightness(88%);
|
||||||
|
--mark-filter-light: invert(34%) sepia(18%) saturate(280%) brightness(76%);
|
||||||
|
--mark-rest: 0.16;
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
position: fixed; pointer-events: none; z-index: 0;
|
||||||
|
filter: var(--mark-filter-dark);
|
||||||
|
opacity: var(--mark-rest);
|
||||||
|
transition: opacity var(--dur-slow) var(--ease-out);
|
||||||
|
}
|
||||||
|
body:not(.dark) .mark { filter: var(--mark-filter-light); opacity: 0.11; }
|
||||||
|
|
||||||
|
/* ---- shared content stage ----------------------------------------------- */
|
||||||
|
.stage {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(26px, 4.5vh, 52px) clamp(24px, 5vw, 64px) clamp(30px, 5vh, 56px);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- top bar ------------------------------------------------------------ */
|
||||||
|
.topbar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--hairline-on-dark);
|
||||||
|
}
|
||||||
|
body:not(.dark) .topbar { border-bottom-color: var(--hairline); }
|
||||||
|
|
||||||
|
.wordmark {
|
||||||
|
font-family: var(--font-display); font-weight: 500;
|
||||||
|
font-size: 21px; line-height: 1; letter-spacing: 0;
|
||||||
|
color: var(--fg-on-dark-1); margin: 0;
|
||||||
|
text-decoration: none; display: inline-flex; align-items: baseline;
|
||||||
|
}
|
||||||
|
body:not(.dark) .wordmark { color: var(--fg-1); }
|
||||||
|
.wordmark .dot { color: var(--accent-deep); }
|
||||||
|
|
||||||
|
.topnav { display: flex; align-items: center; gap: 26px; }
|
||||||
|
.topnav a {
|
||||||
|
font-family: var(--font-body); font-weight: 500; font-size: 11px;
|
||||||
|
letter-spacing: 0.2em; text-transform: uppercase; text-decoration: none;
|
||||||
|
color: var(--fg-on-dark-2);
|
||||||
|
position: relative; padding-bottom: 2px;
|
||||||
|
transition: color var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
body:not(.dark) .topnav a { color: var(--fg-3); }
|
||||||
|
.topnav a::after {
|
||||||
|
content: ""; position: absolute; left: 0; bottom: -2px; height: 1px; width: 0;
|
||||||
|
background: var(--accent); transition: width var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.topnav a:hover { color: var(--fg-on-dark-1); }
|
||||||
|
body:not(.dark) .topnav a:hover { color: var(--fg-1); }
|
||||||
|
.topnav a:hover::after { width: 100%; }
|
||||||
|
.topnav a.current { color: var(--fg-on-dark-1); }
|
||||||
|
body:not(.dark) .topnav a.current { color: var(--fg-1); }
|
||||||
|
.topnav a.current::after { width: 100%; background: var(--accent-deep); }
|
||||||
|
|
||||||
|
.coord {
|
||||||
|
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;
|
||||||
|
color: var(--fg-on-dark-3); font-feature-settings: "onum" 1;
|
||||||
|
}
|
||||||
|
body:not(.dark) .coord { color: var(--fg-3); }
|
||||||
|
|
||||||
|
/* ---- shared type helpers (on-dark aware) -------------------------------- */
|
||||||
|
.eyebrow {
|
||||||
|
font-family: var(--font-body); font-weight: 500; font-size: 11px;
|
||||||
|
letter-spacing: 0.24em; text-transform: uppercase; color: var(--fg-on-dark-2);
|
||||||
|
margin: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
body:not(.dark) .eyebrow { color: var(--fg-3); }
|
||||||
|
|
||||||
|
.serif { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.015em; }
|
||||||
|
|
||||||
|
/* ---- signature footer --------------------------------------------------- */
|
||||||
|
.sitefoot {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: clamp(22px, 3.5vh, 36px);
|
||||||
|
border-top: 1px solid var(--hairline-on-dark);
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between; gap: 24px;
|
||||||
|
}
|
||||||
|
body:not(.dark) .sitefoot { border-top-color: var(--hairline); }
|
||||||
|
.sitefoot .sig {
|
||||||
|
width: 74px; opacity: 0.24;
|
||||||
|
filter: var(--mark-filter-dark);
|
||||||
|
}
|
||||||
|
body:not(.dark) .sitefoot .sig { filter: var(--mark-filter-light); }
|
||||||
|
.sitefoot .copyline {
|
||||||
|
font-family: var(--font-body); font-size: 12px; color: var(--fg-on-dark-3);
|
||||||
|
font-feature-settings: "onum" 1; text-align: right; letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
body:not(.dark) .sitefoot .copyline { color: var(--fg-3); }
|
||||||
|
.sitefoot .footnav { display: flex; gap: 18px; }
|
||||||
|
.sitefoot .footnav a {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase; color: var(--fg-on-dark-3); text-decoration: none;
|
||||||
|
transition: color var(--dur-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
.sitefoot .footnav a:hover { color: var(--fg-on-dark-1); }
|
||||||
|
body:not(.dark) .sitefoot .footnav a { color: var(--fg-3); }
|
||||||
|
body:not(.dark) .sitefoot .footnav a:hover { color: var(--fg-1); }
|
||||||
|
|
||||||
|
/* ---- the latest / arrow link affordance --------------------------------- */
|
||||||
|
.inkarrow { color: var(--accent-deep); display: inline-block;
|
||||||
|
transition: transform var(--dur-base) var(--ease-out); }
|
||||||
|
a:hover .inkarrow { transform: translateX(5px); }
|
||||||
|
|
||||||
|
/* ---- warm chiaroscuro image frame (placeholder imagery) ----------------- */
|
||||||
|
.cutframe {
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
border-radius: var(--radius-cutout);
|
||||||
|
background: radial-gradient(120% 95% at 68% 16%,
|
||||||
|
#C9B299 0%, #8A6A47 30%, #4A3826 60%, #221E18 92%);
|
||||||
|
box-shadow: var(--shadow-2);
|
||||||
|
}
|
||||||
|
.cutframe .wm {
|
||||||
|
position: absolute; right: 7%; bottom: 7%; width: 30%; max-width: 150px;
|
||||||
|
opacity: 0.10; filter: invert(1); z-index: 4; pointer-events: none;
|
||||||
|
}
|
||||||
|
.cutframe .grain {
|
||||||
|
position: absolute; inset: 0; background-image: url(/assets/texture-grain.svg);
|
||||||
|
background-size: 220px; opacity: 0.12; mix-blend-mode: overlay;
|
||||||
|
pointer-events: none; z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.mark, .inkarrow { transition: none; }
|
||||||
|
}
|
||||||
13
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||