initial commit

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 349 B

View file

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

After

Width:  |  Height:  |  Size: 288 B

View file

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

After

Width:  |  Height:  |  Size: 412 B

View file

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

After

Width:  |  Height:  |  Size: 346 B

View file

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

After

Width:  |  Height:  |  Size: 483 B

View file

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

After

Width:  |  Height:  |  Size: 561 B

View file

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

After

Width:  |  Height:  |  Size: 490 B

View file

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

After

Width:  |  Height:  |  Size: 415 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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