initial commit
BIN
docs/design-system/.thumbnail
Normal file
|
After Width: | Height: | Size: 846 B |
25
docs/design-system/BUNDLE-README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# CODING AGENTS: READ THIS FIRST
|
||||
|
||||
This is a **handoff bundle** from Claude Design (claude.ai/design).
|
||||
|
||||
A user mocked up designs in HTML/CSS/JS using an AI design tool, then exported this bundle so a coding agent can implement the designs for real.
|
||||
|
||||
## What you should do — IMPORTANT
|
||||
|
||||
**Read the chat transcripts first.** There are 1 chat transcript(s) in `josh-bairstow-brand-system/chats/`. The transcripts show the full back-and-forth between the user and the design assistant — they tell you **what the user actually wants** and **where they landed** after iterating. Don't skip them. The final HTML files are the output, but the chat is where the intent lives.
|
||||
|
||||
**Find the primary design file under `josh-bairstow-brand-system/project/` and read it top to bottom.** The chat transcripts will tell you which file the user was last iterating on. Then **follow its imports**: open every file it pulls in (shared components, CSS, scripts) so you understand how the pieces fit together before you start implementing.
|
||||
|
||||
**If anything is ambiguous, ask the user to confirm before you start implementing.** It's much cheaper to clarify scope up front than to build the wrong thing.
|
||||
|
||||
## About the design files
|
||||
|
||||
The design medium is **HTML/CSS/JS** — these are prototypes, not production code. Your job is to **recreate them pixel-perfectly** in whatever technology makes sense for the target codebase (React, Vue, native, whatever fits). Match the visual output; don't copy the prototype's internal structure unless it happens to fit.
|
||||
|
||||
**Don't render these files in a browser or take screenshots unless the user asks you to.** Everything you need — dimensions, colors, layout rules — is spelled out in the source. Read the HTML and CSS directly; a screenshot won't tell you anything they don't.
|
||||
|
||||
## Bundle contents
|
||||
|
||||
- `josh-bairstow-brand-system/README.md` — this file
|
||||
- `josh-bairstow-brand-system/chats/` — conversation transcripts (read these!)
|
||||
- `josh-bairstow-brand-system/project/` — the `Josh Bairstow — Brand System` project files (HTML prototypes, assets, components)
|
||||
202
docs/design-system/Posted Graphic.html
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Posted Graphic — Josh Bairstow</title>
|
||||
<link rel="stylesheet" href="colors_and_type.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: #16130F;
|
||||
font-family: var(--font-body);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
min-height: 100vh; gap: 20px; padding: 24px;
|
||||
}
|
||||
|
||||
/* ---- toolbar (outside the scaled canvas) ---- */
|
||||
.toolbar {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
font-family: var(--font-mono); font-size: 11px; color: #9C8B72;
|
||||
}
|
||||
.toolbar .grp { display: flex; gap: 6px; align-items: center; }
|
||||
.toolbar button {
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; cursor: pointer; color: #C9B89E;
|
||||
background: transparent; border: 1px solid rgba(201,184,153,0.25);
|
||||
border-radius: 3px; padding: 5px 10px;
|
||||
transition: all var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.toolbar button:hover { border-color: rgba(201,184,153,0.6); }
|
||||
.toolbar button.on { background: rgba(201,184,153,0.14); border-color: rgba(201,184,153,0.6); color: #EFE7DA; }
|
||||
.toolbar .lbl { opacity: 0.6; letter-spacing: 0.16em; text-transform: uppercase; }
|
||||
|
||||
/* ---- scaled stage ---- */
|
||||
.viewport { display: flex; align-items: center; justify-content: center; }
|
||||
.canvas {
|
||||
width: 1080px; height: 1350px;
|
||||
transform-origin: center center;
|
||||
position: relative; overflow: hidden;
|
||||
background: var(--ground);
|
||||
box-shadow: 0 40px 100px rgba(0,0,0,0.55);
|
||||
}
|
||||
.canvas.dark { background: var(--ground-dark); }
|
||||
|
||||
/* page-wide grain greeble */
|
||||
.canvas::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background-image: url(assets/texture-grain.svg);
|
||||
background-size: 300px; opacity: 0.05; pointer-events: none; z-index: 5;
|
||||
}
|
||||
.canvas.dark::before { opacity: 0.07; mix-blend-mode: screen; }
|
||||
|
||||
/* sprinkled accents — simple crisp marks, loose placement */
|
||||
.pgMark { position: absolute; pointer-events: none; z-index: 1;
|
||||
filter: invert(54%) sepia(16%) saturate(340%) brightness(90%); opacity: 0.18; }
|
||||
.canvas:not(.dark) .pgMark { filter: invert(36%) sepia(18%) saturate(280%) brightness(78%); opacity: 0.13; }
|
||||
.pgMark.k1 { width: 150px; top: 250px; left: 60px; transform: rotate(-3deg); }
|
||||
.pgMark.k2 { width: 70px; top: 980px; right: 70px; transform: rotate(4deg); }
|
||||
.pgMark.k3 { width: 64px; top: 150px; right: 150px; }
|
||||
|
||||
.pad { position: absolute; inset: 76px 76px 72px; display: flex; flex-direction: column; z-index: 2; }
|
||||
|
||||
/* header row */
|
||||
.head { display: flex; align-items: baseline; justify-content: space-between; }
|
||||
.cat {
|
||||
font-family: var(--font-body); font-weight: 500; font-size: 22px;
|
||||
letter-spacing: 0.26em; text-transform: uppercase; color: var(--fg-3);
|
||||
}
|
||||
.canvas.dark .cat { color: var(--fg-on-dark-2); }
|
||||
.handle {
|
||||
font-family: var(--font-mono); font-size: 19px; color: var(--fg-3);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.canvas.dark .handle { color: var(--fg-on-dark-3); }
|
||||
|
||||
/* image cut-out */
|
||||
.frameWrap { margin-top: 44px; position: relative; }
|
||||
.frame {
|
||||
position: relative; width: 100%; height: 620px;
|
||||
border-radius: 8px 120px 8px 8px;
|
||||
background: radial-gradient(120% 100% at 70% 14%, #C9B299 0%, #8A6A47 32%, #4A3826 64%, #1E1A16 96%);
|
||||
overflow: hidden;
|
||||
}
|
||||
image-slot { position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
--is-bg: transparent; background: transparent; }
|
||||
.frame .wm {
|
||||
position: absolute; right: 30px; bottom: 26px; width: 150px;
|
||||
opacity: 0.1; filter: invert(1); z-index: 4; pointer-events: none;
|
||||
}
|
||||
|
||||
/* title block */
|
||||
.body { margin-top: auto; }
|
||||
.title {
|
||||
font-family: var(--font-display); font-weight: 500;
|
||||
font-size: 72px; line-height: 1.0; letter-spacing: -0.02em;
|
||||
color: var(--fg-1); margin: 0; text-wrap: balance;
|
||||
}
|
||||
.canvas.dark .title { color: var(--fg-on-dark-1); }
|
||||
.title em { font-style: italic; font-weight: 400; }
|
||||
.dek {
|
||||
font-family: var(--font-body); font-weight: 300; font-size: 25px;
|
||||
line-height: 1.5; color: var(--fg-2); margin: 22px 0 0; max-width: 80%;
|
||||
}
|
||||
.canvas.dark .dek { color: var(--fg-on-dark-2); }
|
||||
|
||||
/* footer */
|
||||
.foot {
|
||||
margin-top: 40px; padding-top: 26px; border-top: 1px solid var(--hairline);
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
}
|
||||
.canvas.dark .foot { border-color: var(--hairline-on-dark); }
|
||||
.foot .sig { width: 90px; opacity: 0.28;
|
||||
filter: invert(36%) sepia(18%) saturate(280%) brightness(78%); }
|
||||
.canvas.dark .foot .sig { filter: invert(54%) sepia(16%) saturate(340%) brightness(90%); }
|
||||
.foot .date {
|
||||
font-family: var(--font-body); font-size: 21px; color: var(--fg-3);
|
||||
font-feature-settings: "onum" 1; letter-spacing: 0.02em;
|
||||
}
|
||||
.canvas.dark .foot .date { color: var(--fg-on-dark-3); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="toolbar">
|
||||
<span class="lbl">Theme</span>
|
||||
<div class="grp">
|
||||
<button id="t-light">Bone</button>
|
||||
<button id="t-dark" class="on">Ink</button>
|
||||
</div>
|
||||
<span class="lbl" style="margin-left:14px">Watermark</span>
|
||||
<div class="grp">
|
||||
<button class="mk on" data-src="assets/mark-rings.svg">Orbit</button>
|
||||
<button class="mk" data-src="assets/mark-rings-drift.svg">Drift</button>
|
||||
<button class="mk" data-src="assets/mark-rings-cascade.svg">Cascade</button>
|
||||
<button class="mk" data-src="assets/accent-concentric.svg">Concentric</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewport">
|
||||
<div class="canvas dark" id="canvas">
|
||||
<img class="pgMark k1" src="assets/accent-ringdots.svg" alt="">
|
||||
<img class="pgMark k2" src="assets/accent-pipes.svg" alt="">
|
||||
<img class="pgMark k3" src="assets/accent-dots.svg" alt="">
|
||||
<div class="pad">
|
||||
<div class="head">
|
||||
<span class="cat">Art</span>
|
||||
<span class="handle">joshbairstow.com</span>
|
||||
</div>
|
||||
|
||||
<div class="frameWrap">
|
||||
<div class="frame">
|
||||
<image-slot id="post-image" fit="cover"
|
||||
placeholder="Drop a photo — warm, directional light"></image-slot>
|
||||
<img class="wm" id="wm" src="assets/mark-rings.svg" alt="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<h1 class="title">Warm light,<br><em>cold water</em></h1>
|
||||
<p class="dek">Notes on chiaroscuro, the ocean off Manly, and learning to see in value.</p>
|
||||
<div class="foot">
|
||||
<img class="sig" src="assets/accent-ringdots.svg" alt="">
|
||||
<span class="date">March 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="templates/image-slot.js"></script>
|
||||
<script>
|
||||
// ---- fit-to-viewport scaling (canvas fixed at 1080x1350) ----
|
||||
const canvas = document.getElementById('canvas');
|
||||
const vp = document.querySelector('.viewport');
|
||||
function fit() {
|
||||
const availW = window.innerWidth - 48;
|
||||
const availH = window.innerHeight - 120;
|
||||
const s = Math.min(availW / 1080, availH / 1350);
|
||||
canvas.style.transform = `scale(${s})`;
|
||||
vp.style.width = (1080 * s) + 'px';
|
||||
vp.style.height = (1350 * s) + 'px';
|
||||
}
|
||||
window.addEventListener('resize', fit); fit();
|
||||
|
||||
// ---- theme toggle ----
|
||||
const tl = document.getElementById('t-light'), td = document.getElementById('t-dark');
|
||||
tl.onclick = () => { canvas.classList.remove('dark'); tl.classList.add('on'); td.classList.remove('on'); };
|
||||
td.onclick = () => { canvas.classList.add('dark'); td.classList.add('on'); tl.classList.remove('on'); };
|
||||
|
||||
// ---- watermark switch ----
|
||||
const wm = document.getElementById('wm');
|
||||
document.querySelectorAll('.mk').forEach(b => {
|
||||
b.onclick = () => {
|
||||
document.querySelectorAll('.mk').forEach(x => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
wm.src = b.dataset.src;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
238
docs/design-system/README.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# Josh Bairstow — Brand System
|
||||
|
||||
A quiet-luxury personal brand system for **joshbairstow.com**. Warm browns and
|
||||
ink; contemporary Nordic, "modern James Bond"; built on restraint, with a
|
||||
*contained* street-art / calligraffiti "hand" that surfaces as an accent so
|
||||
returning visitors notice craft the first visit never gave away.
|
||||
|
||||
> **The governing idea.** There is one base aesthetic and one accent, and they
|
||||
> are not equal partners. **Base (~90% of the surface):** quiet luxury — muted
|
||||
> warm-brown inks, generous negative space, chiaroscuro, precise typography,
|
||||
> near-silent texture. **Accent (contained, occasional):** a gestural ink
|
||||
> stroke / signature mark on dividers, hover states, transitions and the
|
||||
> watermark only. It is the signature in the corner of the painting, never the
|
||||
> painting.
|
||||
>
|
||||
> **Litmus test for every decision:** *Would a visitor barely register this on
|
||||
> visit one, but feel a small delight noticing it on visit three?* Too loud on
|
||||
> visit one → cut it. Invisible forever → not worth building.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
This system was built **from a written brand & styling brief**, not from an
|
||||
existing codebase or Figma file. There is no upstream design source to link.
|
||||
All palette hex, type pairings, marks, and templates in this folder are
|
||||
proposals authored against the brief and are meant to be tuned with the owner.
|
||||
|
||||
---
|
||||
|
||||
## Context — what this brand is
|
||||
|
||||
Josh Bairstow is an individual: software engineer, runner/marathoner, ocean
|
||||
person (freediving / surfing, Manly + WA roots), coffee, AFL — and, centrally
|
||||
to the *aesthetic*, a follower of street art / calligraffiti (123Klan lineage),
|
||||
manga and typography. **The interests inform flavour, not content.** Only the
|
||||
art/design interest directly shapes the look (the accent layer + the signature
|
||||
hand). Everything else stays as faint, optional nods on individual subdomain
|
||||
pages later — never literal motifs in the core system.
|
||||
|
||||
**The site is a launchpad.** Home is a minimal hub that links out to a growing,
|
||||
uneven set of subdomains:
|
||||
|
||||
| Subdomain | Purpose | Status |
|
||||
|---|---|---|
|
||||
| `blog` | Written content — home features its **latest post** as one line | live |
|
||||
| `signage` | Chalk-on-easel signage side hustle (mechanical jotter) | side-project |
|
||||
| `keycaps` | Custom keycaps for mechanical keyboards | side-project |
|
||||
| `code` | Personal & public software experiments and utilities | live |
|
||||
| `art` | Generative and physical art display | live |
|
||||
| `social` | Links out — no presence yet | *coming online later* |
|
||||
|
||||
The navigation pattern must **read as an index** (not a button farm), tolerate a
|
||||
growing/uneven number of items, and let a "not yet live" item exist without
|
||||
looking broken.
|
||||
|
||||
---
|
||||
|
||||
## Content fundamentals — how copy is written
|
||||
|
||||
The voice is **editorial, sparse, and confident** — a gallery wall label, not a
|
||||
marketing site. Restraint in words mirrors restraint in the visuals.
|
||||
|
||||
- **Person:** First person, understated ("I make things." / "Currently:
|
||||
running, reading, shipping."). Never hype-y "we"-speak; this is one person.
|
||||
- **Casing:** Sentence case for prose. **Small caps / uppercase tracking** for
|
||||
eyebrows, labels and the subdomain index (e.g. `BLOG`, `CODE`, `ART`). The
|
||||
wordmark is set in the display serif, title-styled.
|
||||
- **Length:** Short. A latest-writing line is *one* line — title + a hairline
|
||||
link, no panel, no thumbnail. Bait, not a billboard.
|
||||
- **Punctuation:** The em-dash carries rhythm — used as a considered pause. True
|
||||
small caps and old-style figures are deliberate "visit-three" details.
|
||||
- **Emoji:** **None.** Not part of the brand. No exclamation-heavy energy.
|
||||
- **Tone examples:**
|
||||
- Eyebrow: `SELECTED WRITING`
|
||||
- Latest line: `Notes on warm light and cold water — Mar 2026 →`
|
||||
- Subdomain index item: `CODE — experiments & small tools`
|
||||
- Coming-soon item: `SOCIAL — soon` (quiet, not "🚧 Under construction")
|
||||
- Footer: `© Josh Bairstow — Sydney`
|
||||
|
||||
What to **avoid in copy:** exclamation marks, growth-marketing verbs ("Unlock",
|
||||
"Supercharge"), feature lists, anything that shouts. If a line doesn't pass the
|
||||
visit-one/visit-three test, cut it.
|
||||
|
||||
---
|
||||
|
||||
## Visual foundations
|
||||
|
||||
**Color.** Warm browns + cream, ink-forward, muted — tobacco, sepia, bone,
|
||||
espresso. The whole system rides a **single warm tonal ramp** (`--bone-50` →
|
||||
`--ink-900`) plus one **muted ochre accent** used rarely. *Chiaroscuro is
|
||||
achieved through value, not cool grey — shadows stay warm.* No more than **three
|
||||
tones at once** on any view. Saturated color is essentially **banned** in
|
||||
chrome/navigation; if color appears, it's a deliberate, rare event. See
|
||||
`colors_and_type.css` for the full ramp + roles.
|
||||
|
||||
**Typography.** Editorial precision carries most of the "quiet luxury."
|
||||
- **Display / name — Bodoni Moda.** High-contrast didone; fashion-editorial,
|
||||
"modern Bond." Used large, sparing, confident. (Optical-size axis on; tight
|
||||
negative tracking at display sizes.)
|
||||
- **Body / UI — Hanken Grotesk.** Clean humanist grotesque, generous
|
||||
line-height (1.62), comfortable measure (~66ch). Old-style figures enabled.
|
||||
- **Mono — JetBrains Mono.** Reserved for the `code` subdomain nod / metadata.
|
||||
- **The accent marks — `assets/accent-*.svg`.** Simple, crisp geometric glyphs
|
||||
(ring-and-dots, a pipe cluster, a dot row, concentric rings) sprinkled with
|
||||
**loose, non-rigid placement** throughout a view. Quiet but present — less
|
||||
formal than the type, never a brush or a blob. Never a body typeface.
|
||||
- **Visit-three type details:** true small caps (`--feat-smallcaps`), old-style
|
||||
figures (`--feat-oldstyle`), refined wordmark kerning, em-dash rhythm.
|
||||
|
||||
**Default theme is the dark / ink ground** (`--ground-dark` #17130E) — deep,
|
||||
warm, moody ("modern Bond"), light bone text. The bone ground is the alternate,
|
||||
not the default. Same ramp, same rules either way.
|
||||
|
||||
**Backgrounds & texture (greebling).** Grounds are warm (ink or bone), never
|
||||
flat. Fine, near-invisible surface detail rewards close/repeat inspection —
|
||||
film grain plus **simple, crisp accent marks** (rings, dots, pipes) placed with
|
||||
**loose, non-rigid positioning** throughout the view. **There is no
|
||||
repeating/tiled pattern** — the marks are scattered and informal, not a
|
||||
fashion-house tessellation, and not brushy blobs. **Hard guardrails
|
||||
(enforced):** texture renders at **3–8% opacity**, watermarks at **4–10%**;
|
||||
greebling sits **2–6% luminance** from its background; **no hard
|
||||
edges** in the base layer — texture bleeds, it doesn't outline. The *delight*
|
||||
version **lifts on interaction** (e.g. a pattern 4% → 8% on hover). Movement is
|
||||
felt, not seen. Apply to: large empty grounds, dividers, card/panel
|
||||
backgrounds, footer. **Keep greebling out of text areas** — legibility wins.
|
||||
Assets: `texture-grain.svg`, `accent-ringdots.svg`, `accent-pipes.svg`,
|
||||
`accent-dots.svg`, `accent-concentric.svg` (sprinkled, never tiled).
|
||||
|
||||
**Imagery.** Strong directional light, deep warm shadow — the black-and-white
|
||||
architecture/fashion/automotive Instagram aesthetic, but warm-toned (sepia /
|
||||
duotone toward espresso, not cold). Images are **clipped to a rounded,
|
||||
asymmetric cut-out** (`--radius-cutout` — one big soft corner), **never a plain
|
||||
rectangle**, and may carry the rings watermark per the opacity rules.
|
||||
|
||||
**Motion.** Slow, eased, short-travel — nothing bouncy or fast. Transitions
|
||||
**200–400ms** with generous easing (`--ease-out`). **One signature motion
|
||||
moment max per view.** Examples: the greebling lift on hover, a hairline that
|
||||
draws in along a divider, the ink stroke settling. Respect
|
||||
`prefers-reduced-motion` — degrade to static (tokens collapse to 0ms).
|
||||
|
||||
**Interaction states.**
|
||||
- *Hover:* texture/watermark lifts (4%→8%); a hairline draws/extends; link gets
|
||||
a thin ochre tick or underline that grows from the start. No color floods.
|
||||
- *Press:* a small value shift (text → `--ink-900`, surface → `--bone-200`); a
|
||||
~1px nudge, never a bounce or scale-pop.
|
||||
- *Focus:* warm 2px ochre outline at low opacity, offset — visible but quiet.
|
||||
|
||||
**Borders, radii, elevation.** Corners barely soften (`--radius-sm` 4px to
|
||||
`--radius-lg` 14px); much of the chrome is **square-edged** with hairline rules.
|
||||
Shadows are **warm** (espresso-tinted, low): `--shadow-1/2/3`. Cards are bone
|
||||
panels with a hairline + a faint greeble, not heavy drop-shadow boxes. Avoid
|
||||
rounded-corner + colored-left-border cards entirely.
|
||||
|
||||
**Layout.** Negative space is a feature. Editorial grid, generous margins, a
|
||||
narrow measure for prose. The home page **fuses** its three jobs (hero +
|
||||
latest-writing line + subdomain index) into one composition rather than stacking
|
||||
three sections.
|
||||
|
||||
---
|
||||
|
||||
## Iconography
|
||||
|
||||
This brand is **near-iconless by design** — quiet luxury favors type and space
|
||||
over UI furniture. The few marks that exist are **brand glyphs**, not a UI icon
|
||||
set:
|
||||
|
||||
- **The brand marks** (`assets/mark-rings.svg`, `accent-ringdots.svg`,
|
||||
`accent-pipes.svg`, `accent-dots.svg`, `accent-concentric.svg`) — authored
|
||||
for this system; see § *Brand marks* below. None are initials; all are rings,
|
||||
circles, dots and pipes.
|
||||
- **Affordances** (links, "latest" line, nav) use **typographic** cues: a
|
||||
trailing `→` (U+2192), a hairline underline that grows on hover, a thin ochre
|
||||
tick. No filled UI icons, no icon font in the base brand.
|
||||
- **Emoji / unicode:** emoji are **not used**. The em-dash (—), arrow (→), and
|
||||
middot (·) are the only unicode "icons", used sparingly as typographic glue.
|
||||
- **If a subdomain genuinely needs a UI icon set** (e.g. the `code`/tools pages),
|
||||
use **Lucide** (https://lucide.dev) via CDN — 1.5px stroke, rounded caps,
|
||||
set in `--fg-2` — as the closest match to the system's thin, warm,
|
||||
unobtrusive line language. This is a documented substitution, not part of the
|
||||
core brand. Keep icons out of the home page and marketing surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Brand marks (not locked)
|
||||
|
||||
No initials. The watermark is a **rings-and-circles constellation** in the same
|
||||
rounded language as the cut-out — circles of varied size at varied heights, not a
|
||||
rigid row. The accents are **simple geometric marks** (rings, dots, pipes)
|
||||
sprinkled with loose placement at a *visit-three* whisper. Three watermark
|
||||
constellations are offered (not locked); the share template lets you switch live.
|
||||
|
||||
- **Orbit** (`mark-rings.svg`) — **primary watermark**. A larger open ring with
|
||||
small circles orbiting at varied radii and heights (off the shared midline).
|
||||
- **Drift** (`mark-rings-drift.svg`) / **Cascade** (`mark-rings-cascade.svg`) —
|
||||
alternates: a loose ring-plus-satellites scatter, and graduated descending
|
||||
circles anchored by a ring. All three are constellations, not a straight row.
|
||||
- **Concentric** (`accent-concentric.svg`) — two concentric rings + a center dot.
|
||||
- **Rounded cut-out** — imagery is clipped with `--radius-cutout`
|
||||
(`6px 92px 6px 6px`: one big soft corner), never a plain rectangle.
|
||||
- **Accent marks** — `accent-ringdots.svg` (· ○ ·), `accent-pipes.svg` (a small
|
||||
pipe cluster), `accent-dots.svg` (a dot row). Crisp shapes, **loose/informal
|
||||
placement**, kept at a true *visit-three* whisper (~10–18% in a muted tone) so
|
||||
they read as craft, not grid-fill.
|
||||
|
||||
---
|
||||
|
||||
## Index — what's in this folder
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `colors_and_type.css` | **Start here.** Color ramp, semantic roles, type families + scale, greebling/motion/spacing/elevation tokens, semantic type classes. |
|
||||
| `assets/mark-rings.svg` | **Primary** watermark — "Orbit" ring constellation. |
|
||||
| `assets/mark-rings-drift.svg` | Alternate watermark — "Drift". |
|
||||
| `assets/mark-rings-cascade.svg` | Alternate watermark — "Cascade". |
|
||||
| `assets/accent-concentric.svg` | Concentric-rings accent / alternate watermark. |
|
||||
| `assets/accent-ringdots.svg` | Accent mark — ring with flanking dots. |
|
||||
| `assets/accent-pipes.svg` | Accent mark — pipe cluster. |
|
||||
| `assets/accent-dots.svg` | Accent mark — dot row. |
|
||||
| `assets/texture-grain.svg` | Warm film-grain greebling tile. |
|
||||
| `preview/` | Design-system cards (color, type, texture, marks, components) shown in the Design System tab. |
|
||||
| `ui_kits/home/` | UI kit — the home page: fused hero + latest-writing line + subdomain index, with greebling in situ. |
|
||||
| `Posted Graphic.html` | Reusable share/post template — image clipped to the cut-out + watermark + type system. Drop a photo into the slot. (At project root so the image-slot's drop persists.) |
|
||||
| `templates/image-slot.js` | Drag-and-drop image component used by the posted-graphic template. |
|
||||
| `SKILL.md` | Agent-Skill manifest for using this system in Claude Code / as a downloadable skill. |
|
||||
|
||||
---
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Fonts are loaded from Google Fonts CDN**, not bundled into `fonts/`. Bodoni
|
||||
Moda + Hanken Grotesk + JetBrains Mono are all Google-hosted. If you need an
|
||||
offline/self-hosted build, drop the `.woff2` files into `fonts/` and swap the
|
||||
`@import` for `@font-face`. (No font substitution was needed — these are the
|
||||
intended families, not stand-ins.)
|
||||
- The **brand marks are authored proposals.** Per the brief, the watermark is
|
||||
deliberately *not* locked — the rings glyph is my recommended primary; pick
|
||||
it or the concentric variant (or commission a refined draw) with the owner.
|
||||
59
docs/design-system/SKILL.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
name: josh-bairstow-design
|
||||
description: Use this skill to generate well-branded interfaces and assets for Josh Bairstow (joshbairstow.com), either for production or throwaway prototypes/mocks/etc. A quiet-luxury personal brand in warm browns and ink — contemporary Nordic, "modern Bond" — with a contained calligraffiti accent. Contains essential design guidelines, colors, type, fonts, signature marks, the greebling/texture spec, motion tokens, a home-page UI kit, and a posted-graphic template.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Josh Bairstow — Brand System
|
||||
|
||||
Read **`README.md`** first — it carries the governing idea, the content
|
||||
fundamentals, the full visual foundations, the iconography approach, and an
|
||||
index of every file. Then explore the other files.
|
||||
|
||||
## The one rule to internalize
|
||||
There is **one base aesthetic and one accent, and they are not equal partners.**
|
||||
The **default theme is the dark / ink ground** (deep, warm, moody — "modern
|
||||
Bond", light bone text; bone is the alternate). Base (~90%): quiet luxury — warm-brown inks, negative space, chiaroscuro through
|
||||
*value*, precise type, near-silent texture. Accent (contained, rare): a gestural
|
||||
ink stroke / signature mark on dividers, hover, transitions, and the watermark
|
||||
only. Litmus test for every detail: *barely registered on visit one, a small
|
||||
delight on visit three.* If it shouts on visit one, cut it.
|
||||
|
||||
## Foundations live in code
|
||||
- `colors_and_type.css` — the warm tonal ramp, semantic color roles, type
|
||||
families (Bodoni Moda / Hanken Grotesk / JetBrains Mono) + scale, and the
|
||||
greebling / motion / spacing / elevation tokens. Import it; use the vars.
|
||||
- `assets/` — the **rings & circles** watermark (`mark-rings.svg` primary,
|
||||
`accent-concentric.svg` alt), the simple **accent marks** (`accent-ringdots`,
|
||||
`accent-pipes`, `accent-dots`) sprinkled with loose placement, and
|
||||
`texture-grain.svg`. No initials; rounded, calm geometry. Imagery is clipped
|
||||
with `--radius-cutout` (one big soft corner), never a plain rectangle.
|
||||
- `preview/` — design-system specimen cards (reference, not for shipping).
|
||||
|
||||
## Hard numbers — do not soften or skip
|
||||
- **Greebling:** texture at **3–8%** opacity, watermarks at **4–10%**; sits
|
||||
**2–6% luminance** from its background; no hard edges; lifts on hover
|
||||
(e.g. 4%→8%). **Simple crisp marks (rings/dots/pipes), sprinkled with loose,
|
||||
non-rigid placement — never a tiled pattern, never brushy blobs.** Keep them
|
||||
out of text areas.
|
||||
- **Color:** ≤ 3 tones per view; saturated color essentially banned in chrome;
|
||||
the ochre accent is a rare, deliberate event.
|
||||
- **Motion:** 200–400ms, gentle ease, short-travel, one signature moment per
|
||||
view; respect `prefers-reduced-motion`.
|
||||
- **Imagery:** clipped to the **rounded cut-out** (`--radius-cutout`, one big
|
||||
soft corner), never a plain rectangle; warm chiaroscuro (sepia/duotone toward
|
||||
espresso), never cold.
|
||||
|
||||
## Working patterns
|
||||
- **Home / launchpad layouts** → start from `ui_kits/home/` (fused hero +
|
||||
one-line latest-writing + refined subdomain index, light/dark).
|
||||
- **Share / post graphics** → start from `Posted Graphic.html` (image cut-out +
|
||||
watermark + type system; drop a photo into the slot).
|
||||
|
||||
## How to deliver
|
||||
If creating **visual artifacts** (slides, mocks, throwaway prototypes), copy the
|
||||
assets out and produce static HTML files for the user to view. If working on
|
||||
**production code**, copy the assets and read the rules here to design as an
|
||||
expert in this brand. If invoked with no other guidance, ask what they want to
|
||||
build, ask a few focused questions, then act as the brand's designer — output
|
||||
HTML artifacts or production code as the need dictates.
|
||||
8
docs/design-system/assets/accent-concentric.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 70 70" width="70" height="70" role="img" aria-label="concentric rings accent">
|
||||
|
||||
<g fill="none" stroke="#221E18" stroke-width="2.4">
|
||||
<circle cx="35" cy="35" r="30"></circle>
|
||||
<circle cx="35" cy="35" r="18"></circle>
|
||||
</g>
|
||||
<circle cx="35" cy="35" r="5" fill="#221E18"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 349 B |
8
docs/design-system/assets/accent-dots.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 16" width="64" height="16" role="img" aria-label="dot row accent">
|
||||
|
||||
<g fill="#221E18">
|
||||
<circle cx="8" cy="8" r="3"></circle>
|
||||
<circle cx="32" cy="8" r="3"></circle>
|
||||
<circle cx="56" cy="8" r="3"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
9
docs/design-system/assets/accent-pipes.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 30" width="44" height="30" role="img" aria-label="pipe accent">
|
||||
|
||||
<g fill="#221E18">
|
||||
<rect x="6" y="4" width="2.4" height="22" rx="1.2"></rect>
|
||||
<rect x="16" y="4" width="2.4" height="22" rx="1.2"></rect>
|
||||
<rect x="26" y="4" width="2.4" height="22" rx="1.2"></rect>
|
||||
<rect x="36" y="4" width="2.4" height="22" rx="1.2"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
6
docs/design-system/assets/accent-ringdots.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 24" width="96" height="24" role="img" aria-label="ring-and-dots accent">
|
||||
|
||||
<circle cx="10" cy="12" r="3.4" fill="#221E18"></circle>
|
||||
<circle cx="48" cy="12" r="9" fill="none" stroke="#221E18" stroke-width="2.4"></circle>
|
||||
<circle cx="86" cy="12" r="3.4" fill="#221E18"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
11
docs/design-system/assets/mark-rings-cascade.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 92" width="180" height="92" role="img" aria-label="rings constellation D">
|
||||
|
||||
<circle cx="120" cy="44" r="24" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
|
||||
<g fill="#221E18">
|
||||
<circle cx="22" cy="30" r="9"></circle>
|
||||
<circle cx="52" cy="48" r="6"></circle>
|
||||
<circle cx="76" cy="62" r="4"></circle>
|
||||
<circle cx="120" cy="44" r="4"></circle>
|
||||
<circle cx="162" cy="74" r="3"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 483 B |
11
docs/design-system/assets/mark-rings-drift.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 80" width="180" height="80" role="img" aria-label="rings constellation A">
|
||||
|
||||
<g fill="#221E18">
|
||||
<circle cx="28" cy="24" r="6.5"></circle>
|
||||
<circle cx="156" cy="30" r="3.5"></circle>
|
||||
<circle cx="132" cy="54" r="9" fill="#221E18"></circle>
|
||||
</g>
|
||||
<circle cx="84" cy="40" r="22" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
|
||||
<circle cx="84" cy="40" r="4" fill="#221E18"></circle>
|
||||
<circle cx="132" cy="54" r="9" fill="none" stroke="#221E18" stroke-width="3.2"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 561 B |
11
docs/design-system/assets/mark-rings.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 90" width="180" height="90" role="img" aria-label="rings constellation B">
|
||||
|
||||
<circle cx="96" cy="46" r="26" fill="none" stroke="#221E18" stroke-width="4.5"></circle>
|
||||
<g fill="#221E18">
|
||||
<circle cx="40" cy="30" r="5.5"></circle>
|
||||
<circle cx="58" cy="68" r="3.5"></circle>
|
||||
<circle cx="150" cy="34" r="7.5"></circle>
|
||||
<circle cx="138" cy="72" r="3"></circle>
|
||||
<circle cx="96" cy="46" r="3.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 490 B |
8
docs/design-system/assets/texture-grain.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="160" height="160">
|
||||
|
||||
<filter id="g">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="7" stitchTiles="stitch"></feTurbulence>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.13 0 0 0 0 0.12 0 0 0 0 0.09 0 0 0 1 0"></feColorMatrix>
|
||||
</filter>
|
||||
<rect width="160" height="160" filter="url(#g)"></rect>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
236
docs/design-system/colors_and_type.css
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/* ============================================================================
|
||||
Josh Bairstow — Brand System
|
||||
colors_and_type.css — foundational color + type tokens
|
||||
----------------------------------------------------------------------------
|
||||
One base aesthetic (quiet luxury, warm browns + ink) and one contained
|
||||
accent (ochre / calligraffiti ink). Chiaroscuro is achieved through VALUE
|
||||
in the warm ramp — never through cool grey. Keep shadows warm.
|
||||
========================================================================== */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bodoni+Moda:ital,opsz,wght@0,6..96,400;0,6..96,500;0,6..96,600;0,6..96,700;1,6..96,400;1,6..96,500&family=Hanken+Grotesk:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* WARM TONAL RAMP — the spine of the system. */
|
||||
/* A single value ladder from bone (light) to warm near-black (ink). */
|
||||
/* Use value, not hue, to build depth and chiaroscuro. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--bone-50: #F4EFE6; /* Ground — primary background, warm off-white */
|
||||
--bone-100: #EDE6D9; /* Raised ground — cards, panels */
|
||||
--bone-200: #E0D5C3; /* Sunk ground / quiet fills */
|
||||
--sand-300: #C9B299; /* Light brown — hairlines on dark, muted detail */
|
||||
--sepia-400: #A8855F; /* Sepia — secondary brand brown */
|
||||
--tobacco-500: #7A5C3E;/* Tobacco — primary brand brown */
|
||||
--brown-600: #5A4630; /* Mid-deep brown — secondary text on light */
|
||||
--espresso-700: #3B2C1E;/* Espresso — depth, warm shadow tone */
|
||||
--bark-800: #2E2720; /* Bark — dark panels */
|
||||
--ink-900: #221E18; /* Ink — primary text / the ink gesture */
|
||||
--ground-dark-950: #17130E; /* Inverted ground — DEFAULT theme, deep + moody */
|
||||
--ground-dark-900: #1E1A14; /* Raised dark panel */
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* ACCENT — single, restrained, rare. Ochre/amber. A deliberate event. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--accent: #C0883A; /* Muted ochre — hover ticks, rare highlights */
|
||||
--accent-deep: #9A6A28; /* Deep amber — pressed / on-light accent text */
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* SEMANTIC COLOR ROLES */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--ground: var(--bone-50);
|
||||
--ground-raised: var(--bone-100);
|
||||
--ground-sunk: var(--bone-200);
|
||||
--ground-dark: var(--ground-dark-950);
|
||||
|
||||
--fg-1: var(--ink-900); /* primary text */
|
||||
--fg-2: var(--brown-600); /* secondary text */
|
||||
--fg-3: var(--sepia-400); /* tertiary / muted / metadata */
|
||||
|
||||
/* On dark grounds (the default theme) */
|
||||
--fg-on-dark-1: #EDE4D5;
|
||||
--fg-on-dark-2: #AE9B7F;
|
||||
--fg-on-dark-3: #6F5E48;
|
||||
|
||||
/* Hairlines — warm, never grey. Used for §5 rules and dividers. */
|
||||
--hairline: rgba(34, 30, 24, 0.12);
|
||||
--hairline-strong: rgba(34, 30, 24, 0.22);
|
||||
--hairline-on-dark: rgba(239, 231, 218, 0.14);
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* TYPOGRAPHY FAMILIES */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--font-display: 'Bodoni Moda', 'Times New Roman', serif;
|
||||
--font-body: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||
|
||||
/* OpenType "visit three" details: old-style figures + true small caps. */
|
||||
--feat-oldstyle: "onum" 1, "pnum" 1;
|
||||
--feat-smallcaps: "smcp" 1, "onum" 1;
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* TYPE SCALE — editorial, large, confident. rem-based (16px root). */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--step--1: 0.833rem; /* 13.3px — captions, metadata */
|
||||
--step-0: 1rem; /* 16px — base body */
|
||||
--step-1: 1.2rem; /* 19.2px — lead body */
|
||||
--step-2: 1.5rem; /* 24px — small headings */
|
||||
--step-3: 2.0rem; /* 32px — h3 */
|
||||
--step-4: 2.75rem; /* 44px — h2 */
|
||||
--step-5: 3.75rem; /* 60px — h1 */
|
||||
--step-6: 5.5rem; /* 88px — display */
|
||||
--step-7: 8rem; /* 128px — hero wordmark */
|
||||
|
||||
--leading-tight: 1.04;
|
||||
--leading-snug: 1.18;
|
||||
--leading-body: 1.62;
|
||||
--measure: 66ch;
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* GREEBLING / TEXTURE TOKENS — honor the §5 hard guardrails. */
|
||||
/* Texture opacity: 3–8%. Watermark: 4–10%. */
|
||||
/* Contrast delta: 2–6% luminance. Hover lift: rest → active. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--greeble-opacity-rest: 0.04;
|
||||
--greeble-opacity-active: 0.08;
|
||||
--watermark-opacity-rest: 0.05;
|
||||
--watermark-opacity-active:0.10;
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* MOTION — slow, eased, short-travel. One signature moment per view. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--dur-fast: 200ms;
|
||||
--dur-base: 300ms;
|
||||
--dur-slow: 400ms;
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* gentle settle */
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* symmetric, calm */
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* RADII + ELEVATION — minimal. Corners barely soften; shadows are warm. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--radius-xs: 2px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 14px;
|
||||
/* Image cut-out — rounded, asymmetric (one big soft corner). Never a plain
|
||||
rectangle. Used to clip imagery + watermark frames. */
|
||||
--radius-cutout: 6px 92px 6px 6px;
|
||||
--radius-cutout-sm: 4px 60px 4px 4px;
|
||||
|
||||
/* Warm, low shadows — espresso-tinted, never neutral black. */
|
||||
--shadow-1: 0 1px 2px rgba(59, 44, 30, 0.06), 0 1px 1px rgba(59, 44, 30, 0.04);
|
||||
--shadow-2: 0 6px 18px rgba(59, 44, 30, 0.08), 0 2px 6px rgba(59, 44, 30, 0.05);
|
||||
--shadow-3: 0 18px 48px rgba(30, 26, 22, 0.14), 0 6px 16px rgba(30, 26, 22, 0.08);
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* SPACING — 4px base, generous. Negative space is a feature. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
--space-6: 32px;
|
||||
--space-7: 48px;
|
||||
--space-8: 64px;
|
||||
--space-9: 96px;
|
||||
--space-10: 128px;
|
||||
}
|
||||
|
||||
/* ========================================================================== */
|
||||
/* SEMANTIC ELEMENT STYLES — opt in by adding these classes or scoping under */
|
||||
/* a .jb-prose / .jb-type root. Kept as utility-ish classes so the system */
|
||||
/* travels into any HTML mock without a framework. */
|
||||
/* ========================================================================== */
|
||||
|
||||
.jb-display {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: var(--step-6);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.jb-h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: var(--step-5);
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.jb-h2 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: var(--step-4);
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.jb-h3 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: var(--step-3);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
|
||||
.jb-eyebrow {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 500;
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
.jb-lead {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 300;
|
||||
font-size: var(--step-1);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--fg-2);
|
||||
max-width: var(--measure);
|
||||
}
|
||||
|
||||
.jb-body {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-body);
|
||||
color: var(--fg-2);
|
||||
max-width: var(--measure);
|
||||
font-feature-settings: var(--feat-oldstyle);
|
||||
}
|
||||
|
||||
.jb-caption {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 400;
|
||||
font-size: var(--step--1);
|
||||
line-height: 1.4;
|
||||
color: var(--fg-3);
|
||||
font-feature-settings: var(--feat-oldstyle);
|
||||
}
|
||||
|
||||
.jb-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.jb-smallcaps {
|
||||
font-feature-settings: var(--feat-smallcaps);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--dur-fast: 0ms;
|
||||
--dur-base: 0ms;
|
||||
--dur-slow: 0ms;
|
||||
}
|
||||
}
|
||||
16
docs/design-system/preview/accent-marks.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground-dark);padding:30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:56px;align-items:center;justify-content:center}
|
||||
.opt{display:flex;flex-direction:column;align-items:center;gap:14px}
|
||||
.opt img{display:block;filter:invert(70%) sepia(18%) saturate(320%) brightness(95%)}
|
||||
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-on-dark-2);letter-spacing:.06em}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="opt"><img src="../assets/accent-ringdots.svg" height="22"><span class="lab">ring-dots</span></div>
|
||||
<div class="opt"><img src="../assets/accent-pipes.svg" height="28"><span class="lab">pipes</span></div>
|
||||
<div class="opt"><img src="../assets/accent-dots.svg" height="14"><span class="lab">dots</span></div>
|
||||
<div class="opt"><img src="../assets/accent-concentric.svg" height="40"><span class="lab">concentric</span></div>
|
||||
</div>
|
||||
</body></html>
|
||||
22
docs/design-system/preview/buttons.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:28px 30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
||||
button{font-family:var(--font-body);font-size:13px;letter-spacing:.02em;cursor:pointer;transition:all var(--dur-base) var(--ease-out)}
|
||||
.solid{background:var(--ink-900);color:var(--bone-50);border:none;padding:11px 22px;border-radius:var(--radius-sm)}
|
||||
.solid:hover{background:var(--espresso-700)}
|
||||
.ghost{background:transparent;color:var(--ink-900);border:1px solid var(--hairline-strong);padding:10px 21px;border-radius:var(--radius-sm)}
|
||||
.ghost:hover{border-color:var(--ink-900)}
|
||||
.textlink{background:none;border:none;color:var(--ink-900);padding:6px 2px;position:relative;font-size:13px}
|
||||
.textlink::after{content:"";position:absolute;left:2px;bottom:2px;height:1px;width:0;background:var(--accent-deep);transition:width var(--dur-base) var(--ease-out)}
|
||||
.textlink:hover::after{width:calc(100% - 4px)}
|
||||
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:100%}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<button class="solid">Read the latest</button>
|
||||
<button class="ghost">View work</button>
|
||||
<button class="textlink">Get in touch →</button>
|
||||
<span class="lab">solid · ghost · text-link (ochre underline grows on hover)</span>
|
||||
</div>
|
||||
</body></html>
|
||||
19
docs/design-system/preview/card.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;font-family:var(--font-body)}
|
||||
.card{position:relative;background:var(--ground-raised);border:1px solid var(--hairline);border-radius:var(--radius-md);padding:22px 24px;overflow:hidden;max-width:340px}
|
||||
.card::before{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:200px;opacity:.06;pointer-events:none}
|
||||
.k{position:relative;font-family:var(--font-mono);font-size:10px;letter-spacing:.16em;text-transform:uppercase;color:var(--fg-3);margin:0 0 8px}
|
||||
.t{position:relative;font-family:var(--font-display);font-size:23px;color:var(--fg-1);margin:0 0 6px}
|
||||
.d{position:relative;font-size:13px;color:var(--fg-2);line-height:1.55;margin:0}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<p class="k">Code</p>
|
||||
<p class="t">Small tools</p>
|
||||
<p class="d">Bone panel, hairline border, a greeble at 4.5%. No heavy shadow box.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
21
docs/design-system/preview/color-accent.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:24px;align-items:center}
|
||||
.chip{width:96px;height:96px;border-radius:var(--radius-sm);flex:none}
|
||||
.meta{display:flex;flex-direction:column;gap:6px}
|
||||
.name{font-size:13px;color:var(--fg-1);font-weight:600}
|
||||
.hex{font-family:var(--font-mono);font-size:11px;color:var(--fg-3)}
|
||||
.note{font-size:12px;color:var(--fg-2);line-height:1.5;max-width:300px;margin-top:4px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="chip" style="background:var(--accent)"></div>
|
||||
<div class="chip" style="background:var(--accent-deep)"></div>
|
||||
<div class="meta">
|
||||
<div class="name">accent · muted ochre</div>
|
||||
<div class="hex">#C0883A / #9A6A28 deep</div>
|
||||
<div class="note">The single restrained highlight. A deliberate, rare event — hover ticks, a drawn hairline, the occasional link. Never floods chrome or navigation.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
22
docs/design-system/preview/color-dark.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground-dark);padding:30px 32px;box-sizing:border-box;font-family:var(--font-body)}
|
||||
.head{font-family:var(--font-display);font-size:30px;color:var(--fg-on-dark-1);letter-spacing:-.01em;margin:0 0 6px}
|
||||
.sub{font-size:13px;color:var(--fg-on-dark-2);margin:0 0 18px}
|
||||
.meta{font-size:12px;color:var(--fg-on-dark-3);font-family:var(--font-mono)}
|
||||
.chips{display:flex;gap:10px;margin-top:18px}
|
||||
.chip{height:34px;flex:1;border-radius:var(--radius-xs);display:flex;align-items:center;padding-left:10px;font-family:var(--font-mono);font-size:10px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<p class="head">Ink ground — the default theme.</p>
|
||||
<p class="sub">Deep, warm, moody. Chiaroscuro through value, never cool grey.</p>
|
||||
<p class="meta">ground-dark #17130E · text on dark stays bone</p>
|
||||
<div class="chips">
|
||||
<div class="chip" style="background:#EDE4D5;color:#17130E">fg-on-dark-1</div>
|
||||
<div class="chip" style="background:#AE9B7F;color:#17130E">fg-on-dark-2</div>
|
||||
<div class="chip" style="background:#6F5E48;color:#17130E">fg-on-dark-3</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
30
docs/design-system/preview/color-ramp.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body)}
|
||||
.ramp{display:flex;border-radius:var(--radius-sm);overflow:hidden;box-shadow:var(--shadow-1)}
|
||||
.sw{flex:1;height:86px;display:flex;align-items:flex-end;padding:8px 6px;box-sizing:border-box}
|
||||
.sw span{font-family:var(--font-mono);font-size:9.5px;letter-spacing:-.02em}
|
||||
.row{display:flex;justify-content:space-between;margin-top:12px}
|
||||
.row span{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);flex:1;text-align:center}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="ramp">
|
||||
<div class="sw" style="background:#F4EFE6"><span style="color:#7A5C3E">50</span></div>
|
||||
<div class="sw" style="background:#EDE6D9"><span style="color:#7A5C3E">100</span></div>
|
||||
<div class="sw" style="background:#E0D5C3"><span style="color:#5A4630">200</span></div>
|
||||
<div class="sw" style="background:#C9B299"><span style="color:#3B2C1E">300</span></div>
|
||||
<div class="sw" style="background:#A8855F"><span style="color:#221E18">400</span></div>
|
||||
<div class="sw" style="background:#7A5C3E"><span style="color:#F4EFE6">500</span></div>
|
||||
<div class="sw" style="background:#5A4630"><span style="color:#F4EFE6">600</span></div>
|
||||
<div class="sw" style="background:#3B2C1E"><span style="color:#EDE6D9">700</span></div>
|
||||
<div class="sw" style="background:#2E2720"><span style="color:#EDE6D9">800</span></div>
|
||||
<div class="sw" style="background:#221E18"><span style="color:#EDE6D9">900</span></div>
|
||||
<div class="sw" style="background:#1E1A16"><span style="color:#B7A488">950</span></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>#F4EFE6</span><span>bone</span><span>sand</span><span>sepia</span><span>tobacco</span><span>espresso</span><span>ink #221E18</span>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
19
docs/design-system/preview/color-roles.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body);display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
|
||||
.role{display:flex;flex-direction:column;gap:8px}
|
||||
.chip{height:54px;border-radius:var(--radius-sm)}
|
||||
.name{font-size:12px;color:var(--fg-1);font-weight:500}
|
||||
.hex{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="role"><div class="chip" style="background:var(--ground);box-shadow:inset 0 0 0 1px var(--hairline)"></div><div class="name">ground</div><div class="hex">#F4EFE6</div></div>
|
||||
<div class="role"><div class="chip" style="background:var(--ground-raised);box-shadow:inset 0 0 0 1px var(--hairline)"></div><div class="name">ground-raised</div><div class="hex">#EDE6D9</div></div>
|
||||
<div class="role"><div class="chip" style="background:var(--ground-sunk);box-shadow:inset 0 0 0 1px var(--hairline)"></div><div class="name">ground-sunk</div><div class="hex">#E0D5C3</div></div>
|
||||
<div class="role"><div class="chip" style="background:var(--fg-1)"></div><div class="name">fg-1 · primary</div><div class="hex">#221E18</div></div>
|
||||
<div class="role"><div class="chip" style="background:var(--fg-2)"></div><div class="name">fg-2 · secondary</div><div class="hex">#5A4630</div></div>
|
||||
<div class="role"><div class="chip" style="background:var(--fg-3)"></div><div class="name">fg-3 · muted</div><div class="hex">#A8855F</div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
23
docs/design-system/preview/cutout.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:26px;align-items:center}
|
||||
.cut{width:150px;height:184px;position:relative;flex:none;
|
||||
border-radius:8px 80px 8px 8px;overflow:hidden;
|
||||
background:radial-gradient(120% 90% at 72% 12%, #C9B299 0%, #7A5C3E 38%, #3B2C1E 72%, #1E1A16 100%);}
|
||||
.cut::after{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:180px;opacity:.10;mix-blend-mode:overlay}
|
||||
.mark{position:absolute;right:12px;bottom:12px;width:70px;opacity:.12;filter:invert(1)}
|
||||
.meta h4{font-family:var(--font-display);font-size:21px;color:var(--fg-1);margin:0 0 8px}
|
||||
.meta p{font-size:12.5px;color:var(--fg-2);line-height:1.55;margin:0;max-width:280px}
|
||||
.meta code{font-family:var(--font-mono);font-size:11px;color:var(--accent-deep)}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="cut"><img class="mark" src="../assets/mark-rings.svg"></div>
|
||||
<div class="meta">
|
||||
<h4>Never a plain rectangle</h4>
|
||||
<p>Imagery is clipped to a rounded cut-out and carries the rings watermark at 4–10%. Warm chiaroscuro placeholder shown.</p>
|
||||
<p style="margin-top:8px"><code>border-radius: 6px 92px 6px 6px</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
30
docs/design-system/preview/greebling-accents.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground-dark);padding:0;box-sizing:border-box;display:flex;font-family:var(--font-body)}
|
||||
.panel{flex:1;height:180px;position:relative;overflow:hidden}
|
||||
.panel::before{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:240px;opacity:.06;mix-blend-mode:screen}
|
||||
.m{position:absolute;filter:invert(60%) sepia(18%) saturate(360%) brightness(95%);transition:opacity var(--dur-slow) var(--ease-out)}
|
||||
.lab{position:absolute;left:16px;bottom:12px;font-family:var(--font-mono);font-size:11px;color:var(--fg-on-dark-2);z-index:2}
|
||||
.divider{width:1px;background:var(--hairline-on-dark)}
|
||||
/* loose, non-rigid placement */
|
||||
.a1{width:96px;top:24px;left:34px;transform:rotate(-4deg);opacity:.26}
|
||||
.a2{width:30px;top:104px;left:150px;transform:rotate(6deg);opacity:.3}
|
||||
.a3{width:50px;top:54px;right:54px;opacity:.24}
|
||||
.b1{width:80px;top:34px;left:60px;transform:rotate(3deg);opacity:.28}
|
||||
.b2{width:34px;bottom:40px;left:40px;opacity:.28}
|
||||
.b3{width:44px;top:30px;right:40px;opacity:.26}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="panel">
|
||||
<img class="m a1" src="../assets/accent-ringdots.svg"><img class="m a2" src="../assets/accent-pipes.svg"><img class="m a3" src="../assets/accent-dots.svg">
|
||||
<span class="lab">simple marks · loose placement · visit-three subtlety</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="panel">
|
||||
<img class="m b1" src="../assets/accent-dots.svg"><img class="m b2" src="../assets/accent-concentric.svg"><img class="m b3" src="../assets/accent-ringdots.svg">
|
||||
<span class="lab">non-rigid · throughout</span>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
17
docs/design-system/preview/greebling-grain.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:0;box-sizing:border-box;display:flex;font-family:var(--font-body)}
|
||||
.panel{flex:1;height:160px;position:relative;display:flex;align-items:flex-end;padding:16px;box-sizing:border-box;overflow:hidden}
|
||||
.panel::before{content:"";position:absolute;inset:0;background:url(../assets/texture-grain.svg);background-size:200px;opacity:.04;transition:opacity var(--dur-slow) var(--ease-out)}
|
||||
.panel.hi::before{opacity:.08}
|
||||
.lab{position:relative;font-family:var(--font-mono);font-size:11px;color:var(--fg-2)}
|
||||
.divider{width:1px;background:var(--hairline)}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="panel"><span class="lab">grain · rest 4%</span></div>
|
||||
<div class="divider"></div>
|
||||
<div class="panel hi"><span class="lab">grain · hover 8%</span></div>
|
||||
</div>
|
||||
</body></html>
|
||||
19
docs/design-system/preview/latest-line.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:28px 30px;box-sizing:border-box;font-family:var(--font-body)}
|
||||
.eyebrow{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--fg-3);margin:0 0 12px}
|
||||
.line{display:inline-flex;align-items:baseline;gap:14px;text-decoration:none;color:var(--fg-1)}
|
||||
.title{font-family:var(--font-display);font-size:26px;letter-spacing:-.01em}
|
||||
.meta{font-family:var(--font-body);font-size:12px;color:var(--fg-3);font-feature-settings:"onum" 1}
|
||||
.arrow{color:var(--accent-deep);transition:transform var(--dur-base) var(--ease-out)}
|
||||
.line:hover .arrow{transform:translateX(4px)}
|
||||
.rule{height:1px;background:var(--hairline);margin-top:14px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">Selected writing</p>
|
||||
<a class="line" href="#"><span class="title">Notes on warm light and cold water</span><span class="meta">Mar 2026</span><span class="arrow">→</span></a>
|
||||
<div class="rule"></div>
|
||||
</div>
|
||||
</body></html>
|
||||
21
docs/design-system/preview/marks.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground-dark);padding:24px 28px;box-sizing:border-box;font-family:var(--font-body);display:flex;gap:34px;align-items:center;justify-content:center}
|
||||
.opt{display:flex;flex-direction:column;align-items:center;gap:11px}
|
||||
.opt img{display:block;filter:invert(92%) sepia(10%) saturate(380%) brightness(98%)}
|
||||
.cut{width:118px;height:90px;border-radius:6px 52px 6px 6px;position:relative;overflow:hidden;
|
||||
background:radial-gradient(120% 100% at 70% 16%,#C9B299,#7A5C3E 40%,#3B2C1E 74%,#1E1A16)}
|
||||
.cut img{position:absolute;right:9px;bottom:8px;width:58px;filter:invert(1);opacity:.13}
|
||||
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-on-dark-2);letter-spacing:.04em;text-align:center}
|
||||
.lab b{color:var(--fg-on-dark-1);font-weight:600}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="opt"><img src="../assets/mark-rings.svg" height="44"><span class="lab"><b>Orbit</b><br>primary</span></div>
|
||||
<div class="opt"><img src="../assets/mark-rings-drift.svg" height="44"><span class="lab">Drift</span></div>
|
||||
<div class="opt"><img src="../assets/mark-rings-cascade.svg" height="44"><span class="lab">Cascade</span></div>
|
||||
<div class="opt"><img src="../assets/accent-concentric.svg" height="44"><span class="lab">Concentric</span></div>
|
||||
<div class="opt"><div class="cut"><img src="../assets/mark-rings.svg"></div><span class="lab">rounded cut-out<br>+ watermark @13%</span></div>
|
||||
</div>
|
||||
</body></html>
|
||||
16
docs/design-system/preview/motion.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body)}
|
||||
.track{position:relative;height:6px;background:var(--ground-sunk);border-radius:3px;margin:18px 0 10px}
|
||||
.dot{position:absolute;top:-5px;width:16px;height:16px;border-radius:50%;background:var(--ink-900);animation:slide 2.6s var(--ease-out) infinite alternate}
|
||||
@keyframes slide{from{left:0}to{left:calc(100% - 16px)}}
|
||||
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);line-height:1.7}
|
||||
.acc{color:var(--accent-deep)}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="track"><div class="dot"></div></div>
|
||||
<p class="meta">dur-fast <span class="acc">200ms</span> · dur-base <span class="acc">300ms</span> · dur-slow <span class="acc">400ms</span><br>ease-out cubic-bezier(.22, 1, .36, 1) — slow, eased, short-travel<br>one signature motion moment per view · respects reduced-motion</p>
|
||||
</div>
|
||||
</body></html>
|
||||
22
docs/design-system/preview/nav-index.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;font-family:var(--font-body)}
|
||||
.idx{display:flex;flex-direction:column}
|
||||
.item{display:flex;align-items:baseline;gap:16px;text-decoration:none;padding:11px 0;border-top:1px solid var(--hairline);transition:padding-left var(--dur-base) var(--ease-out)}
|
||||
.item:hover{padding-left:8px}
|
||||
.num{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:24px;flex:none}
|
||||
.name{font-family:var(--font-display);font-size:21px;color:var(--fg-1);letter-spacing:.02em}
|
||||
.desc{font-size:12px;color:var(--fg-3);margin-left:auto}
|
||||
.soon{font-family:var(--font-mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--fg-3);border:1px solid var(--hairline);border-radius:2px;padding:2px 6px;margin-left:auto}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="idx">
|
||||
<a class="item" href="#"><span class="num">01</span><span class="name">Blog</span><span class="desc">writing</span></a>
|
||||
<a class="item" href="#"><span class="num">02</span><span class="name">Code</span><span class="desc">experiments & tools</span></a>
|
||||
<a class="item" href="#"><span class="num">03</span><span class="name">Art</span><span class="desc">generative & physical</span></a>
|
||||
<a class="item" href="#"><span class="num">04</span><span class="name" style="color:var(--fg-3)">Social</span><span class="soon">soon</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
17
docs/design-system/preview/radii.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;display:flex;gap:22px;align-items:flex-end;font-family:var(--font-body)}
|
||||
.item{display:flex;flex-direction:column;gap:8px;align-items:center}
|
||||
.box{width:78px;height:60px;background:var(--ground-raised);box-shadow:inset 0 0 0 1px var(--hairline-strong)}
|
||||
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="item"><div class="box" style="border-radius:2px"></div><span class="lab">xs · 2</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:4px"></div><span class="lab">sm · 4</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:8px"></div><span class="lab">md · 8</span></div>
|
||||
<div class="item"><div class="box" style="border-radius:14px"></div><span class="lab">lg · 14</span></div>
|
||||
<div class="item" style="align-self:center"><span class="lab" style="max-width:150px;text-align:left;line-height:1.5">Corners barely soften — much of the chrome is square-edged with hairline rules.</span></div>
|
||||
</div>
|
||||
</body></html>
|
||||
16
docs/design-system/preview/shadows.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:34px 30px;box-sizing:border-box;display:flex;gap:30px;font-family:var(--font-body)}
|
||||
.item{display:flex;flex-direction:column;gap:14px;align-items:center}
|
||||
.box{width:96px;height:64px;background:var(--bone-50);border-radius:var(--radius-md)}
|
||||
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="item"><div class="box" style="box-shadow:var(--shadow-1)"></div><span class="lab">shadow-1</span></div>
|
||||
<div class="item"><div class="box" style="box-shadow:var(--shadow-2)"></div><span class="lab">shadow-2</span></div>
|
||||
<div class="item"><div class="box" style="box-shadow:var(--shadow-3)"></div><span class="lab">shadow-3</span></div>
|
||||
<div class="item" style="align-self:center"><span class="lab" style="max-width:150px;text-align:left;line-height:1.55">Warm, espresso-tinted, low. Never neutral black — shadows stay warm.</span></div>
|
||||
</div>
|
||||
</body></html>
|
||||
20
docs/design-system/preview/spacing.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:26px 30px;box-sizing:border-box;font-family:var(--font-body)}
|
||||
.row{display:flex;align-items:center;gap:12px;margin-bottom:9px}
|
||||
.bar{height:14px;background:var(--tobacco-500);border-radius:2px}
|
||||
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:64px;flex:none}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="row"><span class="lab">2 · 8</span><div class="bar" style="width:8px"></div></div>
|
||||
<div class="row"><span class="lab">4 · 16</span><div class="bar" style="width:16px"></div></div>
|
||||
<div class="row"><span class="lab">5 · 24</span><div class="bar" style="width:24px"></div></div>
|
||||
<div class="row"><span class="lab">6 · 32</span><div class="bar" style="width:32px"></div></div>
|
||||
<div class="row"><span class="lab">7 · 48</span><div class="bar" style="width:48px"></div></div>
|
||||
<div class="row"><span class="lab">8 · 64</span><div class="bar" style="width:64px"></div></div>
|
||||
<div class="row"><span class="lab">9 · 96</span><div class="bar" style="width:96px"></div></div>
|
||||
<div class="row"><span class="lab">10 · 128</span><div class="bar" style="width:128px"></div></div>
|
||||
</div>
|
||||
</body></html>
|
||||
15
docs/design-system/preview/type-body.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box}
|
||||
.lead{font-family:var(--font-body);font-weight:300;font-size:19px;line-height:1.6;color:var(--fg-1);margin:0 0 10px;max-width:58ch}
|
||||
.body{font-family:var(--font-body);font-weight:400;font-size:15px;line-height:1.62;color:var(--fg-2);margin:0;max-width:62ch;font-feature-settings:"onum" 1,"pnum" 1}
|
||||
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin-top:14px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<p class="lead">I make things — software, small tools, the occasional mark in ink.</p>
|
||||
<p class="body">A clean humanist grotesque set with generous line-height and a comfortable measure. Old-style figures 0123456789 sit low and even in running text, the way they should.</p>
|
||||
<p class="meta">Hanken Grotesk · body/UI · 300 lead · 400 body · 1.62 leading</p>
|
||||
</div>
|
||||
</body></html>
|
||||
19
docs/design-system/preview/type-details.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box;display:flex;gap:34px;align-items:flex-start}
|
||||
.col{display:flex;flex-direction:column;gap:4px}
|
||||
.lab{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);letter-spacing:.04em}
|
||||
.smc{font-family:var(--font-display);font-size:25px;color:var(--fg-1);font-feature-settings:"smcp" 1,"onum" 1;text-transform:lowercase;letter-spacing:.06em}
|
||||
.osf{font-family:var(--font-display);font-size:25px;color:var(--fg-1);font-feature-settings:"onum" 1,"pnum" 1}
|
||||
.em{font-family:var(--font-display);font-size:25px;color:var(--fg-1)}
|
||||
.note{font-size:11px;color:var(--fg-2);margin-top:6px;max-width:200px;line-height:1.4}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="col"><span class="lab">TRUE SMALL CAPS</span><span class="smc">Selected Writing</span></div>
|
||||
<div class="col"><span class="lab">OLD-STYLE FIGURES</span><span class="osf">1290 · 2026</span></div>
|
||||
<div class="col"><span class="lab">EM-DASH RHYTHM</span><span class="em">light — and shadow</span></div>
|
||||
</div>
|
||||
<div class="wrap" style="padding-top:0"><div class="note">The "visit-three" details. Barely registered on visit one; quietly precise on closer reading.</div></div>
|
||||
</body></html>
|
||||
14
docs/design-system/preview/type-display.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box}
|
||||
.big{font-family:var(--font-display);font-weight:500;font-size:74px;line-height:.96;color:var(--fg-1);letter-spacing:-.02em;margin:0}
|
||||
.ital{font-style:italic;font-weight:400}
|
||||
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin-top:14px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<p class="big">Warm light,<br><span class="ital">cold water</span></p>
|
||||
<p class="meta">Bodoni Moda · display · 500 / italic 400 · tracking −0.02em</p>
|
||||
</div>
|
||||
</body></html>
|
||||
15
docs/design-system/preview/type-mono.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:24px 30px;box-sizing:border-box}
|
||||
.mono{font-family:var(--font-mono);font-size:15px;color:var(--fg-1);margin:0;line-height:1.7}
|
||||
.dim{color:var(--fg-3)}
|
||||
.acc{color:var(--accent-deep)}
|
||||
.meta{font-family:var(--font-mono);font-size:11px;color:var(--fg-3);margin-top:16px}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<p class="mono"><span class="dim">$</span> jb deploy <span class="acc">--art</span><br><span class="dim">→</span> shipped 0 regressions · 1,284ms</p>
|
||||
<p class="meta">JetBrains Mono · code/tools subdomain only · 400 / 500</p>
|
||||
</div>
|
||||
</body></html>
|
||||
19
docs/design-system/preview/type-scale.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:22px 30px;box-sizing:border-box;font-family:var(--font-display);color:var(--fg-1)}
|
||||
.row{display:flex;align-items:baseline;gap:16px;border-bottom:1px solid var(--hairline);padding:5px 0}
|
||||
.row:last-child{border-bottom:none}
|
||||
.tok{font-family:var(--font-mono);font-size:10px;color:var(--fg-3);width:78px;flex:none}
|
||||
.spec{line-height:1;letter-spacing:-.01em;white-space:nowrap;overflow:hidden}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="row"><span class="tok">step-6 · 88</span><span class="spec" style="font-size:42px">Display</span></div>
|
||||
<div class="row"><span class="tok">step-5 · 60</span><span class="spec" style="font-size:34px">Heading one</span></div>
|
||||
<div class="row"><span class="tok">step-4 · 44</span><span class="spec" style="font-size:27px">Heading two</span></div>
|
||||
<div class="row"><span class="tok">step-3 · 32</span><span class="spec" style="font-size:21px">Heading three</span></div>
|
||||
<div class="row"><span class="tok">step-1 · 19</span><span class="spec" style="font-size:16px;font-family:var(--font-body);font-weight:300">Lead paragraph</span></div>
|
||||
<div class="row"><span class="tok">step-0 · 16</span><span class="spec" style="font-size:14px;font-family:var(--font-body);font-weight:400">Body text</span></div>
|
||||
</div>
|
||||
</body></html>
|
||||
23
docs/design-system/preview/wordmark.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!doctype html><html><head><meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../colors_and_type.css">
|
||||
<style>
|
||||
body{margin:0}
|
||||
.wrap{background:var(--ground);padding:30px;box-sizing:border-box;font-family:var(--font-body);display:flex;align-items:center;gap:30px}
|
||||
.mark{display:flex;flex-direction:column;line-height:1}
|
||||
.name{font-family:var(--font-display);font-weight:500;font-size:42px;color:var(--fg-1);letter-spacing:-.015em}
|
||||
.name .dot{color:var(--accent-deep)}
|
||||
.role{font-family:var(--font-body);font-size:10.5px;letter-spacing:.28em;text-transform:uppercase;color:var(--fg-3);margin-top:8px;padding-left:2px}
|
||||
.div{width:1px;height:60px;background:var(--hairline)}
|
||||
.small{display:flex;align-items:center;gap:14px}
|
||||
.small img{height:40px}
|
||||
.small span{font-family:var(--font-mono);font-size:10px;color:var(--fg-3)}
|
||||
</style></head><body>
|
||||
<div class="wrap">
|
||||
<div class="mark">
|
||||
<span class="name">Josh Bairstow<span class="dot">.</span></span>
|
||||
<span class="role">Engineer · Sydney</span>
|
||||
</div>
|
||||
<div class="div"></div>
|
||||
<div class="small"><img src="../assets/mark-rings.svg"><span>favicon /<br>small mark</span></div>
|
||||
</div>
|
||||
</body></html>
|
||||
642
docs/design-system/templates/image-slot.js
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
/* BEGIN USAGE */
|
||||
/**
|
||||
* <image-slot> — user-fillable image placeholder.
|
||||
*
|
||||
* Drop this into a deck, mockup, or page wherever you want the user to
|
||||
* supply an image. You control the slot's shape and size; the user fills it
|
||||
* by dragging an image file onto it (or clicking to browse). The dropped
|
||||
* image persists across reloads via a .image-slots.state.json sidecar —
|
||||
* same read-via-fetch / write-via-window.omelette pattern as
|
||||
* design_canvas.jsx, so the filled slot shows on share links, downloaded
|
||||
* zips, and PPTX export. Outside the omelette runtime the slot is read-only.
|
||||
*
|
||||
* The host bridge only allows sidecar writes at the project root, so the
|
||||
* HTML that uses this component is assumed to live at the project root too
|
||||
* (same constraint as design_canvas.jsx).
|
||||
*
|
||||
* Attributes:
|
||||
* id Persistence key. REQUIRED for the drop to survive reload —
|
||||
* every slot on the page needs a distinct id.
|
||||
* shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded')
|
||||
* 'circle' applies 50% border-radius; on a non-square slot
|
||||
* that's an ellipse — set equal width and height for a true
|
||||
* circle.
|
||||
* radius Corner radius in px for 'rounded'. (default 12)
|
||||
* mask Any CSS clip-path value. Overrides `shape` — use this for
|
||||
* hexagons, blobs, arbitrary polygons.
|
||||
* fit object-fit: cover | contain | fill. (default 'cover')
|
||||
* With cover (the default) double-clicking the filled slot
|
||||
* enters a reframe mode: the whole image spills past the mask
|
||||
* (translucent outside, opaque inside), drag to reposition,
|
||||
* corner-drag to scale. The crop persists alongside the image
|
||||
* in the sidecar. contain/fill stay static.
|
||||
* position object-position for fit=contain|fill. (default '50% 50%')
|
||||
* placeholder Empty-state caption. (default 'Drop an image')
|
||||
* src Optional initial/fallback image URL. A user drop overrides
|
||||
* it; clearing the drop reveals src again.
|
||||
*
|
||||
* Size and layout come from ordinary CSS on the element — width/height
|
||||
* inline or from a parent grid — so it composes with any layout.
|
||||
*
|
||||
* Usage:
|
||||
* <image-slot id="hero" style="width:800px;height:450px" shape="rounded" radius="20"
|
||||
* placeholder="Drop a hero image"></image-slot>
|
||||
* <image-slot id="avatar" style="width:120px;height:120px" shape="circle"></image-slot>
|
||||
* <image-slot id="kite" style="width:300px;height:300px"
|
||||
* mask="polygon(50% 0, 100% 50%, 50% 100%, 0 50%)"></image-slot>
|
||||
*/
|
||||
/* END USAGE */
|
||||
|
||||
(() => {
|
||||
const STATE_FILE = '.image-slots.state.json';
|
||||
// 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the
|
||||
// sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB.
|
||||
const MAX_DIM = 1200;
|
||||
// Raster formats only. SVG is excluded (can carry script; createImageBitmap
|
||||
// on SVG blobs is inconsistent). GIF is excluded because the canvas
|
||||
// re-encode keeps only the first frame, so an animated GIF would silently
|
||||
// go still — better to reject than surprise.
|
||||
const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif'];
|
||||
|
||||
// ── Shared sidecar store ────────────────────────────────────────────────
|
||||
// One fetch + immediate write-on-change for every <image-slot> on the
|
||||
// page. Reads via fetch() so viewing works anywhere the HTML and sidecar
|
||||
// are served together; writes go through window.omelette.writeFile, which
|
||||
// the host allowlists to *.state.json basenames only.
|
||||
const subs = new Set();
|
||||
let slots = {};
|
||||
// ids explicitly cleared before the sidecar fetch resolved — otherwise
|
||||
// the merge below can't tell "never set" from "just deleted" and would
|
||||
// resurrect the sidecar's stale value.
|
||||
const tombstones = new Set();
|
||||
let loaded = false;
|
||||
let loadP = null;
|
||||
|
||||
function load() {
|
||||
if (loadP) return loadP;
|
||||
loadP = fetch(STATE_FILE)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => {
|
||||
// Merge: sidecar loses to any in-memory change that raced ahead of
|
||||
// the fetch (drop or clear) so neither is clobbered by hydration.
|
||||
if (j && typeof j === 'object') {
|
||||
const merged = Object.assign({}, j, slots);
|
||||
// A framing-only write that raced ahead of hydration must not
|
||||
// drop a user image that's only on disk — inherit u from the
|
||||
// sidecar for any in-memory entry that lacks one.
|
||||
for (const k in slots) {
|
||||
if (merged[k] && !merged[k].u && j[k]) {
|
||||
merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u;
|
||||
}
|
||||
}
|
||||
for (const id of tombstones) delete merged[id];
|
||||
slots = merged;
|
||||
}
|
||||
tombstones.clear();
|
||||
})
|
||||
.catch(() => {})
|
||||
.then(() => { loaded = true; subs.forEach((fn) => fn()); });
|
||||
return loadP;
|
||||
}
|
||||
|
||||
// Serialize writes so two near-simultaneous drops on different slots
|
||||
// can't reorder at the backend and leave the sidecar with only the
|
||||
// first. A save requested mid-flight just marks dirty and re-fires on
|
||||
// completion with the then-current slots.
|
||||
let saving = false;
|
||||
let saveDirty = false;
|
||||
function save() {
|
||||
if (saving) { saveDirty = true; return; }
|
||||
const w = window.omelette && window.omelette.writeFile;
|
||||
if (!w) return;
|
||||
saving = true;
|
||||
Promise.resolve(w(STATE_FILE, JSON.stringify(slots)))
|
||||
.catch(() => {})
|
||||
.then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } });
|
||||
}
|
||||
|
||||
const S_MAX = 5;
|
||||
const clampS = (s) => Math.max(1, Math.min(S_MAX, s));
|
||||
|
||||
// Normalize a stored slot value. Pre-reframe sidecars stored a bare
|
||||
// data-URL string; newer ones store {u, s, x, y}. Either shape is valid.
|
||||
function getSlot(id) {
|
||||
const v = slots[id];
|
||||
if (!v) return null;
|
||||
return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v;
|
||||
}
|
||||
|
||||
function setSlot(id, val) {
|
||||
if (!id) return;
|
||||
if (val) { slots[id] = val; tombstones.delete(id); }
|
||||
else { delete slots[id]; if (!loaded) tombstones.add(id); }
|
||||
subs.forEach((fn) => fn());
|
||||
// A drop is rare + high-value — write immediately so nav-away can't lose
|
||||
// it. Gate on the initial read so we don't overwrite a sidecar we haven't
|
||||
// merged yet; the merge in load() keeps this change once the read lands.
|
||||
if (loaded) save(); else load().then(save);
|
||||
}
|
||||
|
||||
// ── Image downscale ─────────────────────────────────────────────────────
|
||||
// Encode through a canvas so the sidecar carries resized bytes, not the
|
||||
// raw upload. Longest side is capped at 2× the slot's rendered width
|
||||
// (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG
|
||||
// for photos, so there's no need for per-image format picking.
|
||||
async function toDataUrl(file, targetW) {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM);
|
||||
const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height));
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w; canvas.height = h;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h);
|
||||
return canvas.toDataURL('image/webp', 0.85);
|
||||
} finally {
|
||||
bitmap.close && bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom element ──────────────────────────────────────────────────────
|
||||
const stylesheet =
|
||||
':host{display:inline-block;position:relative;vertical-align:top;' +
|
||||
' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' +
|
||||
'.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' +
|
||||
// .frame img (clipped) and .spill (unclipped ghost + handles) share the
|
||||
// same left/top/width/height in frame-%, computed by _applyView(), so the
|
||||
// inside-mask crop and the outside-mask spill stay pixel-aligned.
|
||||
'.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' +
|
||||
' -webkit-user-drag:none;user-select:none;touch-action:none}' +
|
||||
// Reframe mode (double-click): the full image spills past the mask. The
|
||||
// spill layer is sized to the IMAGE bounds so its corners are where the
|
||||
// resize handles belong. The ghost <img> inside is translucent; the real
|
||||
// clipped <img> underneath shows the opaque in-mask crop.
|
||||
'.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' +
|
||||
' cursor:grab;touch-action:none}' +
|
||||
':host([data-panning]) .spill{cursor:grabbing}' +
|
||||
'.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' +
|
||||
' pointer-events:none;-webkit-user-drag:none;user-select:none;' +
|
||||
' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' +
|
||||
'.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' +
|
||||
' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' +
|
||||
' transform:translate(-50%,-50%)}' +
|
||||
'.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' +
|
||||
'.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' +
|
||||
'.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' +
|
||||
'.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' +
|
||||
':host([data-reframe]){z-index:10}' +
|
||||
':host([data-reframe]) .spill{display:block}' +
|
||||
':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' +
|
||||
'.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' +
|
||||
' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' +
|
||||
' cursor:pointer;user-select:none}' +
|
||||
'.empty svg{opacity:.45}' +
|
||||
'.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' +
|
||||
'.empty .sub{font-size:11px}' +
|
||||
'.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' +
|
||||
'.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' +
|
||||
':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' +
|
||||
' background:rgba(201,100,66,.10)}' +
|
||||
'.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' +
|
||||
' transition:border-color .12s}' +
|
||||
':host([data-over]) .ring{border-color:#c96442}' +
|
||||
':host([data-filled]) .ring{display:none}' +
|
||||
// Controls sit BELOW the mask (top:100%), absolutely positioned so the
|
||||
// author-declared slot height is unaffected. The gap is padding, not a
|
||||
// top offset, so the hover target stays contiguous with the frame.
|
||||
'.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' +
|
||||
' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' +
|
||||
' white-space:nowrap}' +
|
||||
':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' +
|
||||
' {opacity:1;pointer-events:auto}' +
|
||||
'.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' +
|
||||
' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' +
|
||||
' backdrop-filter:blur(6px)}' +
|
||||
'.ctl button:hover{background:rgba(0,0,0,.8)}' +
|
||||
'.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' +
|
||||
' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}';
|
||||
|
||||
const icon =
|
||||
'<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>' +
|
||||
'<path d="m21 15-5-5L5 21"/></svg>';
|
||||
|
||||
class ImageSlot extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const root = this.attachShadow({ mode: 'open' });
|
||||
// .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius
|
||||
// on the frame (circle, pill, rounded) can't clip them.
|
||||
root.innerHTML =
|
||||
'<style>' + stylesheet + '</style>' +
|
||||
'<div class="frame" part="frame">' +
|
||||
' <img part="image" alt="" draggable="false" style="display:none">' +
|
||||
' <div class="empty" part="empty">' + icon +
|
||||
' <div class="cap"></div>' +
|
||||
' <div class="sub">or <u>browse files</u></div></div>' +
|
||||
' <div class="ring" part="ring"></div>' +
|
||||
'</div>' +
|
||||
'<div class="spill">' +
|
||||
' <img class="ghost" alt="" draggable="false">' +
|
||||
' <div class="handle" data-c="nw"></div><div class="handle" data-c="ne"></div>' +
|
||||
' <div class="handle" data-c="sw"></div><div class="handle" data-c="se"></div>' +
|
||||
'</div>' +
|
||||
'<div class="ctl"><button data-act="replace" title="Replace image">Replace</button>' +
|
||||
' <button data-act="clear" title="Remove image">Remove</button></div>' +
|
||||
'<input type="file" accept="' + ACCEPT.join(',') + '" hidden>';
|
||||
this._frame = root.querySelector('.frame');
|
||||
this._ring = root.querySelector('.ring');
|
||||
this._img = root.querySelector('.frame img');
|
||||
this._empty = root.querySelector('.empty');
|
||||
this._cap = root.querySelector('.cap');
|
||||
this._sub = root.querySelector('.sub');
|
||||
this._spill = root.querySelector('.spill');
|
||||
this._ghost = root.querySelector('.ghost');
|
||||
this._err = null;
|
||||
this._input = root.querySelector('input');
|
||||
this._depth = 0;
|
||||
this._gen = 0;
|
||||
this._view = { s: 1, x: 0, y: 0 };
|
||||
this._subFn = () => this._render();
|
||||
// Shadow-DOM listeners live with the shadow DOM — bound once here so
|
||||
// disconnect/reconnect (e.g. React remount) doesn't stack handlers.
|
||||
this._empty.addEventListener('click', () => this._input.click());
|
||||
root.addEventListener('click', (e) => {
|
||||
const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
|
||||
if (act === 'replace') { this._exitReframe(true); this._input.click(); }
|
||||
if (act === 'clear') {
|
||||
this._exitReframe(false);
|
||||
this._gen++;
|
||||
this._local = null;
|
||||
if (this.id) setSlot(this.id, null); else this._render();
|
||||
}
|
||||
});
|
||||
this._input.addEventListener('change', () => {
|
||||
const f = this._input.files && this._input.files[0];
|
||||
if (f) this._ingest(f);
|
||||
this._input.value = '';
|
||||
});
|
||||
// naturalWidth/Height aren't known until load — re-apply so the cover
|
||||
// baseline is computed from real dimensions, not the 100%×100% fallback.
|
||||
this._img.addEventListener('load', () => this._applyView());
|
||||
// Gated on editable + fit=cover so share links and contain/fill slots
|
||||
// stay static.
|
||||
this.addEventListener('dblclick', (e) => {
|
||||
if (!this.hasAttribute('data-editable') || !this._reframes()) return;
|
||||
e.preventDefault();
|
||||
if (this.hasAttribute('data-reframe')) this._exitReframe(true);
|
||||
else this._enterReframe();
|
||||
});
|
||||
// Pan + resize both originate on the spill layer. A handle pointerdown
|
||||
// drives an aspect-locked resize anchored at the opposite corner; any
|
||||
// other pointerdown on the spill pans. Offsets are frame-% so a
|
||||
// reframed slot survives responsive resize / PPTX export.
|
||||
this._spill.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || !this.hasAttribute('data-reframe')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._spill.setPointerCapture(e.pointerId);
|
||||
const rect = this.getBoundingClientRect();
|
||||
const fw = rect.width || 1, fh = rect.height || 1;
|
||||
const corner = e.target.getAttribute && e.target.getAttribute('data-c');
|
||||
let move;
|
||||
if (corner) {
|
||||
// Resize about the OPPOSITE corner. Viewport-px throughout (rect
|
||||
// fw/fh, not clientWidth) so the math survives a transform:scale()
|
||||
// ancestor — deck_stage renders slides scaled-to-fit.
|
||||
const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1;
|
||||
const base = Math.max(fw / iw, fh / ih);
|
||||
const sx = corner.includes('e') ? 1 : -1;
|
||||
const sy = corner.includes('s') ? 1 : -1;
|
||||
const s0 = this._view.s;
|
||||
const w0 = iw * base * s0, h0 = ih * base * s0;
|
||||
const cx0 = (50 + this._view.x) / 100 * fw;
|
||||
const cy0 = (50 + this._view.y) / 100 * fh;
|
||||
const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2;
|
||||
const diag0 = Math.hypot(w0, h0);
|
||||
const ux = sx * w0 / diag0, uy = sy * h0 / diag0;
|
||||
move = (ev) => {
|
||||
const proj = (ev.clientX - rect.left - ox) * ux +
|
||||
(ev.clientY - rect.top - oy) * uy;
|
||||
const s = clampS(s0 * proj / diag0);
|
||||
const d = diag0 * s / s0;
|
||||
this._view.s = s;
|
||||
this._view.x = (ox + ux * d / 2) / fw * 100 - 50;
|
||||
this._view.y = (oy + uy * d / 2) / fh * 100 - 50;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
};
|
||||
} else {
|
||||
this.setAttribute('data-panning', '');
|
||||
const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y };
|
||||
move = (ev) => {
|
||||
this._view.x = start.x + (ev.clientX - start.px) / fw * 100;
|
||||
this._view.y = start.y + (ev.clientY - start.py) / fh * 100;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
};
|
||||
}
|
||||
const up = () => {
|
||||
try { this._spill.releasePointerCapture(e.pointerId); } catch {}
|
||||
this._spill.removeEventListener('pointermove', move);
|
||||
this._spill.removeEventListener('pointerup', up);
|
||||
this._spill.removeEventListener('pointercancel', up);
|
||||
this.removeAttribute('data-panning');
|
||||
this._dragUp = null;
|
||||
};
|
||||
// Stashed so _exitReframe (Escape / outside-click mid-drag) can
|
||||
// tear the capture + listeners down synchronously.
|
||||
this._dragUp = up;
|
||||
this._spill.addEventListener('pointermove', move);
|
||||
this._spill.addEventListener('pointerup', up);
|
||||
this._spill.addEventListener('pointercancel', up);
|
||||
});
|
||||
// Wheel zoom stays available inside reframe mode as a trackpad nicety —
|
||||
// zooms toward the cursor (offset' = cursor·(1-k) + offset·k).
|
||||
this.addEventListener('wheel', (e) => {
|
||||
if (!this.hasAttribute('data-reframe')) return;
|
||||
e.preventDefault();
|
||||
const r = this.getBoundingClientRect();
|
||||
const cx = (e.clientX - r.left) / r.width * 100 - 50;
|
||||
const cy = (e.clientY - r.top) / r.height * 100 - 50;
|
||||
const prev = this._view.s;
|
||||
const next = clampS(prev * Math.pow(1.0015, -e.deltaY));
|
||||
if (next === prev) return;
|
||||
const k = next / prev;
|
||||
this._view.s = next;
|
||||
this._view.x = cx * (1 - k) + this._view.x * k;
|
||||
this._view.y = cy * (1 - k) + this._view.y * k;
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Warn once per page — an id-less slot works for the session but
|
||||
// cannot persist, and two id-less slots would share nothing.
|
||||
if (!this.id && !ImageSlot._warned) {
|
||||
ImageSlot._warned = true;
|
||||
console.warn('<image-slot> without an id will not persist its dropped image.');
|
||||
}
|
||||
this.addEventListener('dragenter', this);
|
||||
this.addEventListener('dragover', this);
|
||||
this.addEventListener('dragleave', this);
|
||||
this.addEventListener('drop', this);
|
||||
subs.add(this._subFn);
|
||||
// width%/height% in _applyView encode the frame aspect at call time —
|
||||
// a host resize (responsive grid, pane divider) would stretch the
|
||||
// image until the next _render. Re-render on size change: _render()
|
||||
// re-seeds _view from stored before clamp/apply, so a shrink→grow
|
||||
// cycle round-trips instead of ratcheting x/y toward the narrower
|
||||
// frame's clamp range.
|
||||
this._ro = new ResizeObserver(() => this._render());
|
||||
this._ro.observe(this);
|
||||
load();
|
||||
this._render();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
subs.delete(this._subFn);
|
||||
this.removeEventListener('dragenter', this);
|
||||
this.removeEventListener('dragover', this);
|
||||
this.removeEventListener('dragleave', this);
|
||||
this.removeEventListener('drop', this);
|
||||
if (this._ro) { this._ro.disconnect(); this._ro = null; }
|
||||
this._exitReframe(false);
|
||||
}
|
||||
|
||||
_enterReframe() {
|
||||
if (this.hasAttribute('data-reframe')) return;
|
||||
this.setAttribute('data-reframe', '');
|
||||
this._applyView();
|
||||
// Close on click outside (the spill handler stopPropagation()s so
|
||||
// in-image drags don't reach this) and on Escape. Listeners are held
|
||||
// on the instance so _exitReframe / disconnectedCallback can detach
|
||||
// exactly what was attached.
|
||||
this._outside = (e) => {
|
||||
if (e.composedPath && e.composedPath().includes(this)) return;
|
||||
this._exitReframe(true);
|
||||
};
|
||||
this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); };
|
||||
document.addEventListener('pointerdown', this._outside, true);
|
||||
document.addEventListener('keydown', this._esc, true);
|
||||
}
|
||||
|
||||
_exitReframe(commit) {
|
||||
if (!this.hasAttribute('data-reframe')) return;
|
||||
if (this._dragUp) this._dragUp();
|
||||
this.removeAttribute('data-reframe');
|
||||
this.removeAttribute('data-panning');
|
||||
if (this._outside) document.removeEventListener('pointerdown', this._outside, true);
|
||||
if (this._esc) document.removeEventListener('keydown', this._esc, true);
|
||||
this._outside = this._esc = null;
|
||||
if (commit) this._commitView();
|
||||
}
|
||||
|
||||
attributeChangedCallback() { if (this.shadowRoot) this._render(); }
|
||||
|
||||
// handleEvent — one listener object for all four drag events keeps the
|
||||
// add/remove symmetric and the depth counter correct.
|
||||
handleEvent(e) {
|
||||
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||
// Without preventDefault the browser never fires 'drop'.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
if (e.type === 'dragenter') this._depth++;
|
||||
this.setAttribute('data-over', '');
|
||||
} else if (e.type === 'dragleave') {
|
||||
// dragenter/leave fire for every descendant crossing — count depth
|
||||
// so hovering the icon inside the empty state doesn't flicker.
|
||||
if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); }
|
||||
} else if (e.type === 'drop') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._depth = 0;
|
||||
this.removeAttribute('data-over');
|
||||
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (f) this._ingest(f);
|
||||
}
|
||||
}
|
||||
|
||||
async _ingest(file) {
|
||||
this._setError(null);
|
||||
if (!file || ACCEPT.indexOf(file.type) < 0) {
|
||||
this._setError('Drop a PNG, JPEG, WebP, or AVIF image.');
|
||||
return;
|
||||
}
|
||||
// toDataUrl can take hundreds of ms on a large photo. A Clear or a
|
||||
// newer drop during that window would be clobbered when this await
|
||||
// resumes — bump + capture a generation so stale encodes bail.
|
||||
const gen = ++this._gen;
|
||||
try {
|
||||
const w = this.clientWidth || this.offsetWidth || MAX_DIM;
|
||||
const url = await toDataUrl(file, w);
|
||||
if (gen !== this._gen) return;
|
||||
// Only exit reframe once the new image is in hand — a rejected type
|
||||
// or decode failure leaves the in-progress crop untouched.
|
||||
this._exitReframe(false);
|
||||
const val = { u: url, s: 1, x: 0, y: 0 };
|
||||
setSlot(this.id || '', val);
|
||||
// Keep a session-local copy for id-less slots so the drop still
|
||||
// shows, even though it cannot persist.
|
||||
if (!this.id) { this._local = val; this._render(); }
|
||||
} catch (err) {
|
||||
if (gen !== this._gen) return;
|
||||
this._setError('Could not read that image.');
|
||||
console.warn('<image-slot> ingest failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_setError(msg) {
|
||||
if (this._err) { this._err.remove(); this._err = null; }
|
||||
if (!msg) return;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'err'; d.textContent = msg;
|
||||
this.shadowRoot.appendChild(d);
|
||||
this._err = d;
|
||||
setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000);
|
||||
}
|
||||
|
||||
// Reframing (pan/resize) is only meaningful for fit=cover — contain/fill
|
||||
// keep the old object-fit path and double-click is a no-op.
|
||||
_reframes() {
|
||||
return this.hasAttribute('data-filled') &&
|
||||
(this.getAttribute('fit') || 'cover') === 'cover';
|
||||
}
|
||||
|
||||
// Cover-baseline geometry, shared by clamp/apply/resize. Null until the
|
||||
// img has loaded (naturalWidth is 0 before that) or when the slot has no
|
||||
// layout box — ResizeObserver fires with a 0×0 rect under display:none,
|
||||
// and clamping against a degenerate 1×1 frame would silently pull the
|
||||
// stored pan toward zero.
|
||||
_geom() {
|
||||
const iw = this._img.naturalWidth, ih = this._img.naturalHeight;
|
||||
const fw = this.clientWidth, fh = this.clientHeight;
|
||||
if (!iw || !ih || !fw || !fh) return null;
|
||||
return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) };
|
||||
}
|
||||
|
||||
_clampView() {
|
||||
// Pan range on each axis is half the overflow past the frame edge.
|
||||
const g = this._geom();
|
||||
if (!g) return;
|
||||
const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50);
|
||||
const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50);
|
||||
this._view.x = Math.max(-mx, Math.min(mx, this._view.x));
|
||||
this._view.y = Math.max(-my, Math.min(my, this._view.y));
|
||||
}
|
||||
|
||||
_applyView() {
|
||||
const g = this._geom();
|
||||
const fit = this.getAttribute('fit') || 'cover';
|
||||
if (fit !== 'cover' || !g) {
|
||||
// Non-cover, or dimensions not known yet (before img load).
|
||||
this._img.style.width = '100%';
|
||||
this._img.style.height = '100%';
|
||||
this._img.style.left = '50%';
|
||||
this._img.style.top = '50%';
|
||||
this._img.style.objectFit = fit;
|
||||
this._img.style.objectPosition = this.getAttribute('position') || '50% 50%';
|
||||
return;
|
||||
}
|
||||
// Cover baseline: img fills the frame on its tighter axis at s=1, so
|
||||
// pan works immediately on the overflowing axis without zooming first.
|
||||
// Width/height and left/top are all frame-% — depends only on the
|
||||
// frame aspect ratio, so a responsive resize keeps the same crop. The
|
||||
// spill layer mirrors the same box so its corners = image corners.
|
||||
const k = g.base * this._view.s;
|
||||
const w = (g.iw * k / g.fw * 100) + '%';
|
||||
const h = (g.ih * k / g.fh * 100) + '%';
|
||||
const l = (50 + this._view.x) + '%';
|
||||
const t = (50 + this._view.y) + '%';
|
||||
this._img.style.width = w; this._img.style.height = h;
|
||||
this._img.style.left = l; this._img.style.top = t;
|
||||
this._img.style.objectFit = '';
|
||||
this._spill.style.width = w; this._spill.style.height = h;
|
||||
this._spill.style.left = l; this._spill.style.top = t;
|
||||
}
|
||||
|
||||
_commitView() {
|
||||
const v = { s: this._view.s, x: this._view.x, y: this._view.y };
|
||||
if (this._userUrl) v.u = this._userUrl;
|
||||
// Framing-only (no u) persists too so an author-src slot remembers its
|
||||
// crop; clearing the sidecar still falls through to src=.
|
||||
if (this.id) setSlot(this.id, v);
|
||||
else { this._local = v; }
|
||||
}
|
||||
|
||||
_render() {
|
||||
// Shape / mask. Presets use border-radius so the dashed ring can
|
||||
// follow the rounded outline; clip-path is only applied for an
|
||||
// explicit `mask` (the ring is hidden there since a rectangle
|
||||
// dashed border chopped by an arbitrary polygon looks broken).
|
||||
const mask = this.getAttribute('mask');
|
||||
const shape = (this.getAttribute('shape') || 'rounded').toLowerCase();
|
||||
let radius = '';
|
||||
if (shape === 'circle') radius = '50%';
|
||||
else if (shape === 'pill') radius = '9999px';
|
||||
else if (shape === 'rounded') {
|
||||
const n = parseFloat(this.getAttribute('radius'));
|
||||
radius = (Number.isFinite(n) ? n : 12) + 'px';
|
||||
}
|
||||
this._frame.style.borderRadius = mask ? '' : radius;
|
||||
this._frame.style.clipPath = mask || '';
|
||||
this._ring.style.borderRadius = mask ? '' : radius;
|
||||
this._ring.style.display = mask ? 'none' : '';
|
||||
|
||||
// Controls and reframe entry gate on this so share links stay read-only.
|
||||
const editable = !!(window.omelette && window.omelette.writeFile);
|
||||
this.toggleAttribute('data-editable', editable);
|
||||
this._sub.style.display = editable ? '' : 'none';
|
||||
|
||||
// Content. The sidecar is also writable by the agent's write_file
|
||||
// tool, so its value isn't guaranteed canvas-originated — only accept
|
||||
// data:image/ URLs from it. The `src` attribute is author-controlled
|
||||
// (Claude wrote it into the HTML) so it passes through unchanged.
|
||||
let stored = this.id ? getSlot(this.id) : this._local;
|
||||
if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null;
|
||||
const srcAttr = this.getAttribute('src') || '';
|
||||
this._userUrl = (stored && stored.u) || null;
|
||||
const url = this._userUrl || srcAttr;
|
||||
// Don't clobber an in-flight reframe with a store-triggered re-render.
|
||||
if (!this.hasAttribute('data-reframe')) {
|
||||
this._view = {
|
||||
s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1,
|
||||
x: stored && Number.isFinite(stored.x) ? stored.x : 0,
|
||||
y: stored && Number.isFinite(stored.y) ? stored.y : 0,
|
||||
};
|
||||
}
|
||||
this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image';
|
||||
// Toggle via style.display — the [hidden] attribute alone loses to
|
||||
// the display:flex / display:block rules in the stylesheet above.
|
||||
if (url) {
|
||||
if (this._img.getAttribute('src') !== url) {
|
||||
this._img.src = url;
|
||||
this._ghost.src = url;
|
||||
}
|
||||
this._img.style.display = 'block';
|
||||
this._empty.style.display = 'none';
|
||||
this.setAttribute('data-filled', '');
|
||||
this._clampView();
|
||||
this._applyView();
|
||||
} else {
|
||||
this._img.style.display = 'none';
|
||||
this._img.removeAttribute('src');
|
||||
this._ghost.removeAttribute('src');
|
||||
this._empty.style.display = 'flex';
|
||||
this.removeAttribute('data-filled');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('image-slot')) {
|
||||
customElements.define('image-slot', ImageSlot);
|
||||
}
|
||||
})();
|
||||
145
docs/design-system/ui_kits/home/HeroZone.jsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// HeroZone — the reserved interactive hero. For now a constrained placeholder:
|
||||
// a chamfer-clipped panel of warm chiaroscuro whose light source drifts toward
|
||||
// the pointer (the one signature motion moment), with the ink stroke parallaxing
|
||||
// and the watermark beneath. A toggle reveals the zone spec + safe area so any
|
||||
// future interactive element inherits the same frame.
|
||||
const ASSET = '../../assets';
|
||||
|
||||
function HeroZone() {
|
||||
const [p, setP] = React.useState({ x: 0.62, y: 0.28 });
|
||||
const [showSpec, setShowSpec] = React.useState(false);
|
||||
const ref = React.useRef(null);
|
||||
const reduce = React.useRef(
|
||||
typeof matchMedia !== 'undefined' &&
|
||||
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
function onMove(e) {
|
||||
if (reduce.current) return;
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
setP({
|
||||
x: Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
|
||||
y: Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)),
|
||||
});
|
||||
}
|
||||
function onLeave() {
|
||||
setP({ x: 0.62, y: 0.28 });
|
||||
}
|
||||
|
||||
// light follows pointer; ink drifts opposite for depth (short travel).
|
||||
const lx = 30 + p.x * 50;
|
||||
const ly = 8 + p.y * 40;
|
||||
const inkShift = `translate(${(0.5 - p.x) * 26}px, ${(0.5 - p.y) * 16}px)`;
|
||||
|
||||
const hz = {
|
||||
frame: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '4 / 5',
|
||||
borderRadius: 'var(--radius-cutout)',
|
||||
background: `radial-gradient(120% 95% at ${lx}% ${ly}%, #C9B299 0%, #8A6A47 30%, #4A3826 60%, #221E18 92%)`,
|
||||
transition: 'background 700ms var(--ease-out)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'crosshair',
|
||||
boxShadow: 'var(--shadow-2)',
|
||||
},
|
||||
accent: {
|
||||
position: 'absolute',
|
||||
right: '9%',
|
||||
top: '12%',
|
||||
width: '76px',
|
||||
opacity: 0.16,
|
||||
filter: 'invert(1)',
|
||||
transform: inkShift,
|
||||
transition: 'transform 900ms var(--ease-out)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
grain: {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `url(${ASSET}/texture-grain.svg)`,
|
||||
backgroundSize: '220px',
|
||||
opacity: 0.12,
|
||||
mixBlendMode: 'overlay',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
watermark: {
|
||||
position: 'absolute',
|
||||
left: '8%',
|
||||
bottom: '8%',
|
||||
width: '128px',
|
||||
opacity: 0.1,
|
||||
filter: 'invert(1)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
toggle: {
|
||||
position: 'absolute',
|
||||
right: '8%',
|
||||
bottom: '7%',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(239,231,218,0.55)',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(239,231,218,0.22)',
|
||||
borderRadius: '2px',
|
||||
padding: '5px 9px',
|
||||
cursor: 'pointer',
|
||||
zIndex: 3,
|
||||
},
|
||||
safe: {
|
||||
position: 'absolute',
|
||||
inset: '9% 9% 9% 9%',
|
||||
border: '1px dashed rgba(239,231,218,0.4)',
|
||||
pointerEvents: 'none',
|
||||
display: showSpec ? 'block' : 'none',
|
||||
},
|
||||
safeLabel: {
|
||||
position: 'absolute',
|
||||
top: '-9px',
|
||||
left: '10px',
|
||||
background: '#221E18',
|
||||
padding: '0 6px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '9px',
|
||||
letterSpacing: '0.1em',
|
||||
color: 'rgba(239,231,218,0.7)',
|
||||
},
|
||||
placeholderTag: {
|
||||
position: 'absolute',
|
||||
left: '7%',
|
||||
top: '8%',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '0.14em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(239,231,218,0.45)',
|
||||
display: showSpec ? 'block' : 'none',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={hz.frame}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
<img src={`${ASSET}/accent-ringdots.svg`} style={hz.accent} alt="" />
|
||||
<div style={hz.grain} />
|
||||
<span style={hz.placeholderTag}>Hero zone — interactive · TBD</span>
|
||||
<div style={hz.safe}>
|
||||
<span style={hz.safeLabel}>SAFE AREA · 1:1.25 · pad 9%</span>
|
||||
</div>
|
||||
<img src={`${ASSET}/mark-rings.svg`} style={hz.watermark} alt="" />
|
||||
<button
|
||||
style={hz.toggle}
|
||||
onClick={() => setShowSpec((s) => !s)}
|
||||
>
|
||||
{showSpec ? 'Hide spec' : 'Zone spec'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Object.assign(window, { HeroZone });
|
||||
53
docs/design-system/ui_kits/home/LatestLine.jsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// LatestLine — the most recent post as ONE typographic line. Bait to move into
|
||||
// the site. No panel, no thumbnail. Hairline rule + arrow that drifts on hover.
|
||||
function LatestLine({ onDark = false }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const c = onDark
|
||||
? { fg: 'var(--fg-on-dark-1)', meta: 'var(--fg-on-dark-3)', rule: 'var(--hairline-on-dark)' }
|
||||
: { fg: 'var(--fg-1)', meta: 'var(--fg-3)', rule: 'var(--hairline)' };
|
||||
|
||||
const ll = {
|
||||
eyebrow: {
|
||||
fontFamily: 'var(--font-body)', fontWeight: 500, fontSize: '11px',
|
||||
letterSpacing: '0.22em', textTransform: 'uppercase',
|
||||
color: onDark ? 'var(--fg-on-dark-2)' : 'var(--fg-3)', margin: '0 0 14px',
|
||||
},
|
||||
line: {
|
||||
display: 'flex', alignItems: 'baseline', gap: '16px',
|
||||
textDecoration: 'none', color: c.fg,
|
||||
},
|
||||
title: {
|
||||
fontFamily: 'var(--font-display)', fontWeight: 500,
|
||||
fontSize: 'clamp(20px, 2.2vw, 27px)', letterSpacing: '-0.01em',
|
||||
},
|
||||
meta: {
|
||||
fontFamily: 'var(--font-body)', fontSize: '12.5px', color: c.meta,
|
||||
fontFeatureSettings: '"onum" 1', marginLeft: 'auto', whiteSpace: 'nowrap',
|
||||
},
|
||||
arrow: {
|
||||
color: 'var(--accent-deep)',
|
||||
transform: hover ? 'translateX(5px)' : 'translateX(0)',
|
||||
transition: 'transform var(--dur-base) var(--ease-out)',
|
||||
},
|
||||
rule: { height: '1px', background: c.rule, marginTop: '14px' },
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={ll.eyebrow}>Selected writing</p>
|
||||
<a
|
||||
href="#"
|
||||
style={ll.line}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<span style={ll.title}>Notes on warm light and cold water</span>
|
||||
<span style={ll.meta}>Mar 2026</span>
|
||||
<span style={ll.arrow}>→</span>
|
||||
</a>
|
||||
<div style={ll.rule} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Object.assign(window, { LatestLine });
|
||||
41
docs/design-system/ui_kits/home/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# UI Kit — Home
|
||||
|
||||
A high-fidelity recreation of the **joshbairstow.com** home page: a minimal hub
|
||||
that *fuses* its three jobs into one composition rather than stacking three
|
||||
sections.
|
||||
|
||||
- **Interactive hero** (`HeroZone.jsx`) — the reserved zone, shown as a
|
||||
constrained placeholder: a **rounded** cut-out panel of warm chiaroscuro whose
|
||||
light source drifts toward the pointer (the one signature motion moment),
|
||||
with the **rings watermark** and a ring-dots accent. A **Zone spec** toggle
|
||||
reveals the safe area (9% pad, 1:1.25) so any future element inherits the
|
||||
frame. Respects `prefers-reduced-motion`.
|
||||
- **Latest-writing line** (`LatestLine.jsx`) — the newest post as *one*
|
||||
typographic line; bait, not a billboard. Arrow drifts on hover.
|
||||
- **Subdomain index** (`SubdomainIndex.jsx`) — navigation as a refined index;
|
||||
tolerates a growing/uneven set; a not-yet-live item shows a quiet `soon`
|
||||
badge, never looks broken. Hover indents the row + draws an ochre tick.
|
||||
- **Wordmark** (`Wordmark.jsx`) — Bodoni lockup; the only color event is a
|
||||
single ochre period.
|
||||
- **Light / dark** — **dark is the default** (deep warm ink ground); the header
|
||||
toggle swaps to the bone alternate. Grain, the rings watermark, and simple
|
||||
accent marks (rings/dots/pipes) sprinkled loosely carry through both.
|
||||
|
||||
## Run
|
||||
Open `index.html`. React + Babel load from CDN; components are separate
|
||||
`.jsx` files exported to `window`. Tokens come from `../../colors_and_type.css`;
|
||||
marks/texture from `../../assets/`.
|
||||
|
||||
## Files
|
||||
| File | Component |
|
||||
|---|---|
|
||||
| `index.html` | App shell, theming, layout grid |
|
||||
| `Wordmark.jsx` | Name lockup |
|
||||
| `HeroZone.jsx` | Interactive hero placeholder |
|
||||
| `LatestLine.jsx` | Latest-post line |
|
||||
| `SubdomainIndex.jsx` | Refined subdomain index nav |
|
||||
|
||||
## Notes
|
||||
This is a **cosmetic recreation** for prototyping — interactions are faked
|
||||
(links are inert). The hero's interactive element is a deliberate placeholder
|
||||
per the brief; drop the real piece into the same clipped frame + safe area.
|
||||
79
docs/design-system/ui_kits/home/SubdomainIndex.jsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// SubdomainIndex — the navigation as a refined index, not a button farm.
|
||||
// Tolerates a growing, uneven set; a "not yet live" item is quiet, not broken.
|
||||
// Hover: row indents a touch and a hairline tick draws in (felt, not seen).
|
||||
const SUBDOMAINS = [
|
||||
{ n: '01', name: 'Blog', desc: 'Writing', live: true },
|
||||
{ n: '02', name: 'Code', desc: 'Experiments & tools', live: true },
|
||||
{ n: '03', name: 'Art', desc: 'Generative & physical', live: true },
|
||||
{ n: '04', name: 'Signage', desc: 'Chalk on easel', live: true },
|
||||
{ n: '05', name: 'Keycaps', desc: 'Mechanical', live: true },
|
||||
{ n: '06', name: 'Social', desc: '', live: false },
|
||||
];
|
||||
|
||||
function IndexRow({ item, onDark }) {
|
||||
const [hover, setHover] = React.useState(false);
|
||||
const live = item.live;
|
||||
const c = onDark
|
||||
? { fg: 'var(--fg-on-dark-1)', dim: 'var(--fg-on-dark-3)', rule: 'var(--hairline-on-dark)' }
|
||||
: { fg: 'var(--fg-1)', dim: 'var(--fg-3)', rule: 'var(--hairline)' };
|
||||
|
||||
const row = {
|
||||
display: 'flex', alignItems: 'baseline', gap: '16px',
|
||||
textDecoration: 'none', padding: '13px 0',
|
||||
borderTop: `1px solid ${c.rule}`,
|
||||
paddingLeft: hover && live ? '10px' : '0',
|
||||
transition: 'padding-left var(--dur-base) var(--ease-out)',
|
||||
cursor: live ? 'pointer' : 'default',
|
||||
};
|
||||
const num = {
|
||||
fontFamily: 'var(--font-mono)', fontSize: '10px',
|
||||
color: c.dim, width: '24px', flex: 'none', fontFeatureSettings: '"onum" 1',
|
||||
};
|
||||
const name = {
|
||||
fontFamily: 'var(--font-display)', fontWeight: 500,
|
||||
fontSize: 'clamp(19px, 2vw, 24px)', letterSpacing: '0.01em',
|
||||
color: live ? c.fg : c.dim,
|
||||
};
|
||||
const tick = {
|
||||
width: hover && live ? '22px' : '0px', height: '1px',
|
||||
background: 'var(--accent-deep)', alignSelf: 'center',
|
||||
transition: 'width var(--dur-base) var(--ease-out)',
|
||||
};
|
||||
const desc = {
|
||||
fontFamily: 'var(--font-body)', fontSize: '12px', color: c.dim,
|
||||
marginLeft: 'auto',
|
||||
};
|
||||
const soon = {
|
||||
fontFamily: 'var(--font-mono)', fontSize: '9.5px', letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase', color: c.dim, marginLeft: 'auto',
|
||||
border: `1px solid ${c.rule}`, borderRadius: '2px', padding: '2px 7px',
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
style={row}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<span style={num}>{item.n}</span>
|
||||
<span style={name}>{item.name}</span>
|
||||
<span style={tick} />
|
||||
{live
|
||||
? <span style={desc}>{item.desc}</span>
|
||||
: <span style={soon}>soon</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SubdomainIndex({ onDark = false }) {
|
||||
return (
|
||||
<nav>
|
||||
{SUBDOMAINS.map((it) => (
|
||||
<IndexRow key={it.n} item={it} onDark={onDark} />
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Object.assign(window, { SubdomainIndex });
|
||||
25
docs/design-system/ui_kits/home/Wordmark.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Wordmark — the name lockup. Bodoni display, a single ochre period as the
|
||||
// only color event. Sits at the top-left of the composition.
|
||||
function Wordmark({ size = 'header', onDark = false }) {
|
||||
const isHeader = size === 'header';
|
||||
const wmStyles = {
|
||||
name: {
|
||||
fontFamily: 'var(--font-display)',
|
||||
fontWeight: 500,
|
||||
fontSize: isHeader ? '21px' : 'clamp(56px, 9vw, 132px)',
|
||||
lineHeight: isHeader ? 1 : 0.92,
|
||||
letterSpacing: isHeader ? '0' : '-0.02em',
|
||||
color: onDark ? 'var(--fg-on-dark-1)' : 'var(--fg-1)',
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
dot: { color: 'var(--accent-deep)' },
|
||||
};
|
||||
return (
|
||||
<h1 style={wmStyles.name}>
|
||||
Josh Bairstow<span style={wmStyles.dot}>.</span>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
Object.assign(window, { Wordmark });
|
||||
230
docs/design-system/ui_kits/home/index.html
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Josh Bairstow</title>
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--ground-dark);
|
||||
color: var(--fg-1);
|
||||
min-height: 100vh;
|
||||
}
|
||||
body.dark { background-color: var(--ground-dark); }
|
||||
body:not(.dark) { background-color: var(--ground); }
|
||||
|
||||
/* page-wide grain greeble — static, barely-there (§5) */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed; inset: 0;
|
||||
background-image: url(../../assets/texture-grain.svg);
|
||||
background-size: 240px;
|
||||
opacity: 0.045;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
body.dark::before { opacity: 0.06; mix-blend-mode: screen; }
|
||||
|
||||
/* sprinkled accents — simple crisp marks (rings, dots, pipes) placed with
|
||||
loose, non-rigid positioning across the page. Quiet but present. */
|
||||
.mark {
|
||||
position: fixed; pointer-events: none; z-index: 0;
|
||||
filter: invert(52%) sepia(16%) saturate(340%) brightness(88%);
|
||||
opacity: 0.16;
|
||||
}
|
||||
body:not(.dark) .mark { filter: invert(34%) sepia(18%) saturate(280%) brightness(76%); opacity: 0.11; }
|
||||
.mark.m1 { width: 84px; top: 15%; left: 3%; transform: rotate(-5deg); }
|
||||
.mark.m2 { width: 28px; top: 72%; left: 6.5%; transform: rotate(8deg); }
|
||||
.mark.m3 { width: 50px; top: 28%; right: 3.5%; }
|
||||
.mark.m4 { width: 40px; bottom: 8%; right: 8%; }
|
||||
.mark.m5 { width: 66px; bottom: 18%; left: 45%; transform: rotate(-3deg); }
|
||||
|
||||
#root { position: relative; z-index: 1; }
|
||||
|
||||
.stage {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(28px, 5vh, 56px) clamp(24px, 5vw, 64px) clamp(32px, 5vh, 56px);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* header */
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
.dark .topbar { border-color: var(--hairline-on-dark); }
|
||||
.topmeta { display: flex; align-items: center; gap: 22px; }
|
||||
.coord {
|
||||
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;
|
||||
color: var(--fg-3); font-feature-settings: "onum" 1;
|
||||
}
|
||||
.toggle {
|
||||
font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; cursor: pointer;
|
||||
background: transparent; color: var(--fg-2);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: var(--radius-xs); padding: 6px 11px;
|
||||
transition: border-color var(--dur-base) var(--ease-out), color var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.toggle:hover { border-color: var(--fg-1); color: var(--fg-1); }
|
||||
.dark .toggle { color: var(--fg-on-dark-2); border-color: var(--hairline-on-dark); }
|
||||
.dark .toggle:hover { color: var(--fg-on-dark-1); border-color: var(--fg-on-dark-1); }
|
||||
|
||||
/* hero composition grid */
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: clamp(32px, 6vw, 80px);
|
||||
align-items: stretch;
|
||||
padding: clamp(36px, 7vh, 84px) 0 clamp(28px, 5vh, 56px);
|
||||
flex: 1;
|
||||
}
|
||||
.heroLeft { display: flex; flex-direction: column; }
|
||||
.eyebrow {
|
||||
font-family: var(--font-body); font-weight: 500; font-size: 11px;
|
||||
letter-spacing: 0.24em; text-transform: uppercase; color: var(--fg-3);
|
||||
margin: 0 0 clamp(20px, 4vh, 34px);
|
||||
}
|
||||
.dark .eyebrow { color: var(--fg-on-dark-2); }
|
||||
.statement {
|
||||
font-family: var(--font-display); font-weight: 500;
|
||||
font-size: clamp(40px, 6.4vw, 92px); line-height: 0.96;
|
||||
letter-spacing: -0.02em; margin: 0; color: var(--fg-1);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.dark .statement { color: var(--fg-on-dark-1); }
|
||||
.statement em { font-style: italic; font-weight: 400; }
|
||||
.statement .accent { color: var(--accent-deep); }
|
||||
.leadline {
|
||||
font-family: var(--font-body); font-weight: 300;
|
||||
font-size: clamp(15px, 1.5vw, 18px); line-height: 1.6;
|
||||
color: var(--fg-2); max-width: 42ch;
|
||||
margin: clamp(22px, 4vh, 34px) 0 0;
|
||||
}
|
||||
.dark .leadline { color: var(--fg-on-dark-2); }
|
||||
.heroLeftFoot { margin-top: auto; padding-top: clamp(28px, 5vh, 48px); }
|
||||
|
||||
.heroRight { display: flex; align-items: stretch; }
|
||||
.heroRight > * { width: 100%; align-self: center; }
|
||||
|
||||
/* lower band: index + signature */
|
||||
.lower {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: clamp(32px, 6vw, 80px) clamp(32px, 6vw, 80px);
|
||||
align-items: end;
|
||||
padding-top: clamp(22px, 3.5vh, 36px);
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
.dark .lower { border-top-color: var(--hairline-on-dark); }
|
||||
.lowerInk {
|
||||
grid-column: 1 / -1; width: 50px; opacity: 0.22;
|
||||
margin: 0 0 clamp(6px, 1.4vh, 12px);
|
||||
filter: invert(52%) sepia(16%) saturate(340%) brightness(88%);
|
||||
}
|
||||
body:not(.dark) .lowerInk { filter: invert(34%) sepia(18%) saturate(280%) brightness(76%); }
|
||||
.indexHead {
|
||||
font-family: var(--font-body); font-weight: 500; font-size: 11px;
|
||||
letter-spacing: 0.24em; text-transform: uppercase; color: var(--fg-3);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.dark .indexHead { color: var(--fg-on-dark-2); }
|
||||
.sigBlock { display: flex; flex-direction: column; align-items: flex-end; gap: 12px; }
|
||||
.sigBlock img { width: 74px; opacity: 0.24;
|
||||
filter: invert(52%) sepia(16%) saturate(340%) brightness(88%); }
|
||||
body:not(.dark) .sigBlock img { filter: invert(34%) sepia(18%) saturate(280%) brightness(76%); }
|
||||
.copyline {
|
||||
font-family: var(--font-body); font-size: 12px; color: var(--fg-3);
|
||||
font-feature-settings: "onum" 1; text-align: right;
|
||||
}
|
||||
.dark .copyline { color: var(--fg-on-dark-3); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero, .lower { grid-template-columns: 1fr; }
|
||||
.heroRight { max-width: 420px; }
|
||||
.sigBlock { align-items: flex-start; }
|
||||
.copyline { text-align: left; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="dark">
|
||||
<img class="mark m1" src="../../assets/accent-ringdots.svg" alt="">
|
||||
<img class="mark m2" src="../../assets/accent-pipes.svg" alt="">
|
||||
<img class="mark m3" src="../../assets/accent-dots.svg" alt="">
|
||||
<img class="mark m4" src="../../assets/accent-concentric.svg" alt="">
|
||||
<img class="mark m5" src="../../assets/accent-ringdots.svg" alt="">
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script type="text/babel" src="Wordmark.jsx"></script>
|
||||
<script type="text/babel" src="HeroZone.jsx"></script>
|
||||
<script type="text/babel" src="LatestLine.jsx"></script>
|
||||
<script type="text/babel" src="SubdomainIndex.jsx"></script>
|
||||
|
||||
<script type="text/babel">
|
||||
function App() {
|
||||
const [dark, setDark] = React.useState(true);
|
||||
React.useEffect(() => {
|
||||
document.body.classList.toggle('dark', dark);
|
||||
}, [dark]);
|
||||
|
||||
return (
|
||||
<div className="stage">
|
||||
<header className="topbar">
|
||||
<Wordmark size="header" onDark={dark} />
|
||||
<div className="topmeta">
|
||||
<span className="coord">33.7969° S · Sydney</span>
|
||||
<button className="toggle" onClick={() => setDark(d => !d)}>
|
||||
{dark ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="hero">
|
||||
<div className="heroLeft">
|
||||
<p className="eyebrow">Engineer · Sydney</p>
|
||||
<h2 className="statement">
|
||||
I make small,<br /><em>careful</em> things<span className="accent">.</span>
|
||||
</h2>
|
||||
<p className="leadline">
|
||||
Software, tools, and the occasional mark in ink. A quiet corner of the
|
||||
internet — mostly writing, sometimes shipping.
|
||||
</p>
|
||||
<div className="heroLeftFoot">
|
||||
<LatestLine onDark={dark} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="heroRight">
|
||||
<HeroZone />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="lower">
|
||||
<img className="lowerInk" src="../../assets/accent-dots.svg" alt="" />
|
||||
<div>
|
||||
<p className="indexHead">Index</p>
|
||||
<SubdomainIndex onDark={dark} />
|
||||
</div>
|
||||
<div className="sigBlock">
|
||||
<img src="../../assets/accent-ringdots.svg" alt="" />
|
||||
<span className="copyline">© Josh Bairstow — Sydney</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||