diff --git a/_config.yml b/_config.yml index 49540eb..7ab5f45 100755 --- a/_config.yml +++ b/_config.yml @@ -15,6 +15,13 @@ liquid: error_mode: strict # Build settings +exclude: + - docs + - Gemfile + - Gemfile.lock + - vendor + - README.md + permalink: /:title/ markdown: kramdown feed: diff --git a/_layouts/default.html b/_layouts/default.html index 8d6ef5f..c9a41f0 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -3,6 +3,7 @@ + {% if page.title %}{{ page.title }} | {{ site.title }}{% else %}{{ site.title }}{% endif %} @@ -16,46 +17,93 @@ {% feed_meta %} -
- - - -
- {{ content }} -
- - {% if site.google_analytics %} - - {% endif %} + +
+

{{ site.description }}. I spend most of my time on distributed systems and + data infrastructure — pipelines that have to stay up, queries that have to + stay fast, and the failure modes nobody warns you about.

+

This site is where I write that down: notes from things I have built, papers + worth re-reading, and books that shaped how I think. Nothing here is a tutorial + so much as a record of working it out.

+
+ + +
+ + + {% if is_home %}{% endif %} + + {% if site.google_analytics %} + + {% endif %} diff --git a/_layouts/home.html b/_layouts/home.html index 9e717c5..613810e 100644 --- a/_layouts/home.html +++ b/_layouts/home.html @@ -1,27 +1,101 @@ --- layout: default --- -
-
Learning & adventures in CS and life
-

Hey, I'm Nitin.

-

{{ site.description }} — distributed systems, algorithms, the occasional detour into whatever has my attention this month. This is where I think out loud.

+
+ -
+ -
-

Recent writing

- All blogs → +
+
Learning & adventures in CS and life
+

Hey, I'm
Nitin Singh.

+

{{ site.description }} — distributed systems, algorithms, and + the occasional detour into whatever has my attention this month. This is where I think + out loud.

+
+ Read the writing + +
+
+
+ +
+
+ + +
+ {% for post in site.posts limit: 4 %} + +
{{ post.date | date: "%b %d, %Y" }}
+
+
{{ post.title }}
+
{{ post.description }}
+
+
+ {% endfor %} +
+
+
+ +
+
+
+

On the shelf

+ Everything I'm reading → +
+ +
+
+
+

Books

+ All → +
+

A running log of what I'm reading — technical and otherwise.

+
+ {% for book in site.data.books limit: 4 %} +
+
+
{{ book.title }}
+
{{ book.author }}
+
+
{{ book.status }}
+
+ {% endfor %} +
+
-
- {% for post in site.posts limit: 4 %} - -
{{ post.date | date: "%b %d, %Y" }}
-
-
{{ post.title }}
-
{{ post.description }}
+
+ +

Research that shaped how I think about systems and computation.

+
+ {% for paper in site.data.papers limit: 4 %} +
+
+
{{ paper.title }}
+
{{ paper.authors }}
+
+
{{ paper.year }}
+
+ {% endfor %} +
- - {% endfor %} +
diff --git a/docs/superpowers/specs/2026-08-12-chimes-redesign-design.md b/docs/superpowers/specs/2026-08-12-chimes-redesign-design.md new file mode 100644 index 0000000..b62c79a --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-chimes-redesign-design.md @@ -0,0 +1,120 @@ +# Chimes-inspired redesign — design + +**Date:** 2026-08-12 +**Repo:** nitinitleen1.github.io +**Reference:** https://marinabudarina.github.io/chimes/ + +## Goal + +Rebuild the visual language of the personal site around a tactile, interactive hero — +verlet-simulated hanging strands the visitor can part with cursor or touch — while keeping +the existing Jekyll content model, warm-paper palette, and archive pages intact. + +This is a presentation-layer rewrite. No content migration, no plugin changes, no build-step +additions, no framework. + +## Decisions (agreed with user) + +| Question | Decision | +|---|---| +| Scope | Full redesign, chimes-inspired | +| Palette | Keep warm paper, add depth | +| Home | Hero + Writing + Shelves, archives kept as separate pages | +| Hero canvas | Hanging strings / chimes (verlet physics) | +| About | Overlay modal | +| Audio | Chime on strand strike, muted by default | +| Mobile / reduced motion | Keep physics, touch-driven (hard-disabled under `prefers-reduced-motion`) | + +## Architecture + +| File | Change | +|---|---| +| `_layouts/default.html` | Sidebar → sticky top nav. Adds canvas mount, About modal markup, JS includes. | +| `_layouts/home.html` | Rewritten: anchored sections `#home`, `#writing`, `#shelf`. | +| `_layouts/post.html` | Restyled; TOC script kept, structure unchanged. | +| `blogs.html`, `bookshelf.html`, `papershelf.html`, `courses.html`, `talks.html` | Restyled via CSS only. | +| `static/css/site.css` | Rewritten. Existing tokens extended, not replaced. | +| `static/js/chimes.js` | **New.** Verlet strands + WebAudio chime. | +| `static/js/site.js` | **New.** Scroll reveals, About modal, nav scrollspy. | + +Navigation on inner pages links back to home anchors (`/#writing`). "About" is a button +that opens the modal, available on every page. + +## Hero canvas — `chimes.js` + +Strands hang from the top edge of the hero, full-bleed. + +``` +strand = chain of ~18 points, point[0] pinned to top +verlet: v = (p - p.old) * damping + p.old = p + p += v + gravity +constraint: 6 relaxation passes, fixed segment length +pointer: points within radius R pushed by pointer delta, + falloff = 1 - (d/R) +``` + +- Strand count scales with viewport width (~28 desktop, ~14 phone). +- Pointer and touch both drive the simulation. +- Rendered as thin tapered ink lines at low alpha, with a single bead near each strand's + lower third — reads as a beaded curtain without per-bead rigid bodies. +- `IntersectionObserver` pauses the RAF loop when the hero leaves the viewport. +- `prefers-reduced-motion` → simulation disabled, strands drawn once at rest. + +### Audio + +WebAudio synthesis, zero audio files. + +- Strike detected when the pointer crosses a strand's rest-x with velocity above threshold. +- Triangle oscillator → exponential decay envelope, ~1.2s tail. +- Pitch from a pentatonic scale indexed by strand position, so sweeping left-to-right + plays a run. +- **Muted by default.** Speaker toggle bottom-right; state persisted in `localStorage`. +- `AudioContext` created lazily on first unmute, avoiding browser autoplay warnings. + +## Palette — warm paper + depth + +Existing tokens stay. Added: + +```css +--accent-amber: #c8922f; /* golden courtyard */ +--accent-lacquer: #9c3b28; /* sparing — active nav, strand beads */ +--paper-raise: #faf7f0; /* lifted surfaces above --bg */ +--shadow-1: 0 1px 2px rgba(36,34,27,.06); +--shadow-2: 0 8px 24px rgba(36,34,27,.09); +--shadow-3: 0 24px 60px rgba(36,34,27,.12); +``` + +Tactility comes from layering: post rows and shelf cards sit on `--paper-raise` with +`--shadow-1`, lifting to `--shadow-2` and rising 2px on hover. Hero headline uses +display-scale Newsreader (`clamp(3.5rem, 9vw, 7rem)`) with tight tracking. + +## Motion — `site.js` + +- **Scroll reveal:** one `IntersectionObserver` adds `.in` → staggered fade-up, 60ms per + child; elements unobserved after firing. +- **About modal:** full-screen overlay, fade + scale-in, focus trap, Esc to close, + `aria-modal`, body scroll lock. +- **Scrollspy:** active nav item tracks the visible section on home. +- **Hover:** post rows and nav items get spring-eased transforms. No custom cursor. + +All motion respects `prefers-reduced-motion`: reveals become instant, transforms drop. + +## Out of scope + +- Country-selector metaphor, per-section audio, beaded curtains on every section — these + fight a text-heavy technical blog. +- Any framework or build-step addition. Vanilla JS, two files, no dependencies. +- Post reading experience stays conservative; the drama lives on the home page. + +## Acceptance criteria + +1. `bundle exec jekyll build` succeeds with no new warnings. +2. Home renders hero with animated strands; dragging the cursor parts them and they settle. +3. Audio is silent until the speaker toggle is clicked; preference survives reload. +4. `#writing` and `#shelf` sections reveal on scroll; nav highlights the visible section. +5. About modal opens from any page, traps focus, closes on Esc and on backdrop click. +6. `/blogs/`, `/bookshelf/`, `/papershelf/`, `/courses/`, `/talks/` and a post page all + render correctly under the new nav and palette. +7. With `prefers-reduced-motion: reduce`, no simulation runs and no fade-up animations play. +8. Layout holds at 375px, 768px, and 1440px widths. diff --git a/static/css/site.css b/static/css/site.css index 42089bd..8c27a16 100644 --- a/static/css/site.css +++ b/static/css/site.css @@ -1,7 +1,12 @@ -/* Design tokens — warm paper palette, Newsreader + IBM Plex Mono */ +/* ========================================================================== + Design tokens — warm paper, with depth + ========================================================================== */ + :root { --bg: #f4f1e9; - --sidebar-bg: #f0ece1; + --bg-sunk: #efebe0; + --paper-raise: #faf7f0; + --text: #24221b; --text-soft: #4b4839; --text-body: #2e2c22; @@ -9,22 +14,51 @@ --text-faint: #9a937f; --text-faint-2: #8a8474; --text-faint-3: #a49d8a; + --border: rgba(36, 34, 27, 0.13); - --border-light: rgba(36, 34, 27, 0.1); + --border-light: rgba(36, 34, 27, 0.09); + --link: #2f5c8f; --link-hover: #1c3f68; + --accent: #b9a878; + --accent-amber: #c8922f; + --accent-lacquer: #9c3b28; + + --strand-ink: rgba(36, 34, 27, 0.26); + --strand-bead: rgba(36, 34, 27, 0.4); + --note-bg: #efe9da; --note-text: #514c3c; --code-bg: #272822; --code-text: #f8f8f2; --selection-bg: #dcd6c4; - --mono: "IBM Plex Mono", monospace; - --serif: "Newsreader", Georgia, serif; + + --shadow-1: 0 1px 2px rgba(36, 34, 27, 0.06); + --shadow-2: 0 8px 24px rgba(36, 34, 27, 0.09); + --shadow-3: 0 24px 60px rgba(36, 34, 27, 0.12); + + --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --serif: "Newsreader", Georgia, "Times New Roman", serif; + + --ease: cubic-bezier(0.2, 0.7, 0.2, 1); + --nav-h: 66px; + --measure: 720px; + --shell: 1120px; } +/* ========================================================================== + Base + ========================================================================== */ + * { box-sizing: border-box; } +html { + scroll-behavior: smooth; + scroll-padding-top: calc(var(--nav-h) + 16px); + overflow-x: hidden; +} + html, body { margin: 0; padding: 0; } body { @@ -32,21 +66,50 @@ body { color: var(--text); font-family: var(--serif); font-size: 17px; + line-height: 1.6; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; } +body.is-locked { overflow: hidden; } + ::selection { background: var(--selection-bg); } -a { color: var(--link); text-decoration: none; transition: color 0.2s ease; } -a:hover { color: var(--link-hover); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; } +a { + color: var(--link); + text-decoration: none; + transition: color 0.2s var(--ease); +} +a:hover { + color: var(--link-hover); + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-thickness: 1px; +} + +:focus-visible { + outline: 2px solid var(--accent-amber); + outline-offset: 3px; + border-radius: 2px; +} -@keyframes fadeUp { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } -@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } -@keyframes noteIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } } -@keyframes barGrow { from { transform: scaleX(0); } to { transform: scaleX(1); } } +[hidden] { display: none !important; } .mono { font-family: var(--mono); } +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + .eyebrow { font-family: var(--mono); font-size: 11px; @@ -54,112 +117,362 @@ a:hover { color: var(--link-hover); text-decoration: underline; text-underline-o text-transform: uppercase; color: var(--text-faint); margin-bottom: 22px; - animation: fadeUp 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both; } -/* Layout: fixed sidebar + main column */ -.site-shell { min-height: 100vh; display: flex; align-items: stretch; } +.divider { + height: 1px; + background: var(--border); + margin: 56px 0 34px; +} -.sidebar { - width: 272px; - flex: none; +/* ========================================================================== + Entrance — CSS only, so above-the-fold content never waits on JS + ========================================================================== */ + +@keyframes fadeUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: none; } +} + +.hero-inner > *, +.page-section > * { + animation: fadeUp 0.6s var(--ease) both; +} +.hero-inner > *:nth-child(1), .page-section > *:nth-child(1) { animation-delay: 0.04s; } +.hero-inner > *:nth-child(2), .page-section > *:nth-child(2) { animation-delay: 0.12s; } +.hero-inner > *:nth-child(3), .page-section > *:nth-child(3) { animation-delay: 0.2s; } +.hero-inner > *:nth-child(4), .page-section > *:nth-child(4) { animation-delay: 0.28s; } +.hero-inner > *:nth-child(5), .page-section > *:nth-child(5) { animation-delay: 0.36s; } + +.page-section .post-row, +.page-section .shelf-row { + animation: fadeUp 0.5s var(--ease) both; +} + +/* ========================================================================== + Reveal on scroll + ========================================================================== */ + +[data-reveal] { + opacity: 0; + transform: translateY(18px); + transition: opacity 0.7s var(--ease), transform 0.7s var(--ease); +} +[data-reveal].is-in { + opacity: 1; + transform: none; +} + +/* Stagger containers stay put; their children do the moving. */ +[data-reveal-stagger] { + opacity: 1; + transform: none; +} +[data-reveal-stagger] > * { + opacity: 0; + transform: translateY(18px); + transition: opacity 0.65s var(--ease), transform 0.65s var(--ease); +} +[data-reveal-stagger].is-in > * { + opacity: 1; + transform: none; +} + +/* ========================================================================== + Top bar + ========================================================================== */ + +.topbar { position: fixed; top: 0; left: 0; - height: 100vh; - padding: 46px 30px 34px 38px; - border-right: 1px solid var(--border); + right: 0; + z-index: 120; + height: var(--nav-h); display: flex; - flex-direction: column; - background: var(--sidebar-bg); - overflow-y: auto; + align-items: center; + gap: 28px; + padding: 0 clamp(18px, 4vw, 46px); + background: rgba(244, 241, 233, 0.78); + backdrop-filter: saturate(160%) blur(12px); + -webkit-backdrop-filter: saturate(160%) blur(12px); + border-bottom: 1px solid transparent; + transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease), + background 0.3s var(--ease); +} +.topbar.is-stuck { + border-bottom-color: var(--border-light); + box-shadow: var(--shadow-1); + background: rgba(244, 241, 233, 0.92); } -.sidebar-brand { cursor: pointer; } -.sidebar-eyebrow { +.topbar-brand { + font-size: 17px; + letter-spacing: -0.01em; + color: var(--text); + white-space: nowrap; +} +.topbar-brand:hover { text-decoration: none; color: var(--text); } +.topbar-brand .mark { font-family: var(--mono); font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--text-faint-3); -} -.sidebar-title { - font-size: 25px; - line-height: 1.12; - margin-top: 9px; - font-weight: 500; - letter-spacing: -0.01em; - color: var(--text); + display: block; + line-height: 1; + margin-bottom: 4px; } -.sidebar-nav { margin-top: 42px; display: flex; flex-direction: column; gap: 1px; } -.sidebar-nav-item { +.topbar-nav { display: flex; align-items: center; - gap: 10px; - padding: 9px 11px; - margin: 0 -11px; + gap: 2px; + margin-left: auto; + flex-wrap: wrap; + justify-content: flex-end; +} + +.nav-link { + position: relative; + font-size: 15px; + color: var(--text-muted); + padding: 8px 12px; border-radius: 5px; - font-size: 17px; + background: none; + border: 0; + font-family: inherit; cursor: pointer; - transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; - color: var(--text-muted); + transition: color 0.2s var(--ease), background 0.2s var(--ease), + transform 0.25s var(--ease); +} +.nav-link:hover { + color: var(--text); + background: rgba(36, 34, 27, 0.055); + text-decoration: none; + transform: translateY(-1px); +} +.nav-link.is-active, +.nav-link[aria-current="page"] { + color: var(--text); +} +.nav-link.is-active::after, +.nav-link[aria-current="page"]::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + bottom: 3px; + height: 1.5px; + background: var(--accent-lacquer); + border-radius: 2px; } -.sidebar-nav-item span { font-family: var(--mono); font-size: 10px; color: var(--text-faint-3); width: 20px; display: inline-block; } -.sidebar-nav-item:hover { background: rgba(36, 34, 27, 0.08); transform: translateX(3px); } -.sidebar-nav-item.active { color: var(--text); background: rgba(36, 34, 27, 0.06); } -.sidebar-elsewhere { margin-top: auto; padding-top: 28px; } -.sidebar-elsewhere-label { - font-family: var(--mono); - font-size: 10px; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--text-faint-3); - margin-bottom: 12px; +.nav-sep { + width: 1px; + height: 18px; + background: var(--border); + margin: 0 8px; + flex: none; +} + +/* ========================================================================== + Shell + ========================================================================== */ + +.site-main { + padding-top: var(--nav-h); + min-height: 100vh; } -.sidebar-elsewhere-links { display: flex; flex-direction: column; gap: 7px; font-size: 15px; } -main.site-main { - margin-left: 272px; - flex: 1; - min-width: 0; - padding: 0 48px 120px 56px; +.wrap { + max-width: var(--shell); + margin: 0 auto; + padding: 0 clamp(18px, 4vw, 46px); } -.page-section { max-width: 720px; margin: 0 auto; padding-top: 108px; } -.page-section.wide { max-width: 760px; } +/* ========================================================================== + Hero + chimes canvas + ========================================================================== */ -h1.page-title { - font-size: 62px; - line-height: 1.02; +.hero { + position: relative; + min-height: min(78vh, 760px); + display: flex; + align-items: flex-end; + overflow: hidden; + touch-action: pan-y; + background: linear-gradient(180deg, var(--bg-sunk) 0%, var(--bg) 78%); + border-bottom: 1px solid var(--border-light); +} + +#chimes-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 0; +} + +.hero-inner { + position: relative; + z-index: 1; + width: 100%; + max-width: var(--shell); + margin: 0 auto; + padding: 0 clamp(18px, 4vw, 46px) clamp(52px, 9vh, 96px); +} + +.hero-eyebrow { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--text-faint); + margin-bottom: 18px; +} + +.hero-title { + font-size: clamp(2.5rem, 9vw, 7rem); + line-height: 0.95; font-weight: 400; - letter-spacing: -0.02em; + letter-spacing: -0.035em; margin: 0; - animation: fadeUp 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; - animation-delay: 0.07s; + color: var(--text); +} +.hero-title em { + font-style: italic; + color: var(--accent-lacquer); } -h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } -.page-lede { - font-size: 21px; +.hero-lede { + font-size: clamp(1.05rem, 1.6vw, 1.28rem); line-height: 1.6; color: var(--text-soft); - max-width: 560px; - margin: 28px 0 0; font-weight: 300; - animation: fadeUp 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; - animation-delay: 0.15s; + max-width: min(52ch, 100%); + margin: 26px 0 0; + /* keep the text legible over the strands */ + text-shadow: 0 1px 0 rgba(250, 247, 240, 0.6); } -.page-lede.tight { margin: 0 0 44px; font-size: 19px; } -.divider { - height: 1px; - background: var(--border); - margin: 56px 0 34px; - transform-origin: left; - animation: barGrow 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; - animation-delay: 0.24s; +.hero-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 34px; + flex-wrap: wrap; +} + +.button { + display: inline-flex; + align-items: center; + gap: 8px; + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 12px 20px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--paper-raise); + color: var(--text); + cursor: pointer; + box-shadow: var(--shadow-1); + transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease), + border-color 0.25s var(--ease), background 0.25s var(--ease); +} +.button:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); + border-color: rgba(36, 34, 27, 0.22); + text-decoration: none; + color: var(--text); +} +.button.is-primary { + background: var(--text); + color: var(--bg); + border-color: var(--text); +} +.button.is-primary:hover { color: var(--bg); } + +.hero-hint { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-faint-3); + margin-top: 26px; +} + +/* Audio toggle */ + +.chimes-audio { + position: absolute; + right: clamp(18px, 4vw, 46px); + bottom: clamp(20px, 5vh, 40px); + z-index: 2; + width: 42px; + height: 42px; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--paper-raise); + color: var(--text-muted); + cursor: pointer; + box-shadow: var(--shadow-1); + transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease), + color 0.25s var(--ease), border-color 0.25s var(--ease); +} +.chimes-audio:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); + color: var(--text); +} +.chimes-audio.is-on { + color: var(--accent-lacquer); + border-color: rgba(156, 59, 40, 0.4); +} +.chimes-audio svg { width: 17px; height: 17px; display: block; } +.chimes-audio .icon-on { display: none; } +.chimes-audio.is-on .icon-on { display: block; } +.chimes-audio.is-on .icon-off { display: none; } + +/* ========================================================================== + Home sections + ========================================================================== */ + +.band { + padding: clamp(64px, 10vh, 118px) 0; + border-bottom: 1px solid var(--border-light); +} +.band:last-of-type { border-bottom: 0; } +.band.is-sunk { background: var(--bg-sunk); } + +.band-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; + margin-bottom: 34px; + flex-wrap: wrap; +} +.band-head h2 { + font-family: var(--mono); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #6b6555; + margin: 0; +} +.band-head .band-more { + font-family: var(--mono); + font-size: 12px; + letter-spacing: 0.08em; + color: var(--link); } .section-head { @@ -167,8 +480,8 @@ h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } align-items: baseline; justify-content: space-between; margin-bottom: 22px; - animation: fadeUp 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; - animation-delay: 0.3s; + gap: 18px; + flex-wrap: wrap; } .section-head h2 { font-size: 15px; @@ -180,28 +493,104 @@ h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } margin: 0; } -.post-list { display: flex; flex-direction: column; } +/* ========================================================================== + Inner pages + ========================================================================== */ + +.page-section { + max-width: var(--measure); + margin: 0 auto; + padding: clamp(56px, 9vh, 96px) clamp(18px, 4vw, 46px) 120px; +} +.page-section.wide { max-width: 820px; } + +h1.page-title { + font-size: clamp(2.6rem, 6vw, 3.9rem); + line-height: 1.02; + font-weight: 400; + letter-spacing: -0.03em; + margin: 0; +} +h1.page-title.smaller { + font-size: clamp(2.3rem, 5vw, 3.25rem); + line-height: 1.05; + margin: 0 0 42px; +} + +.page-lede { + font-size: 21px; + line-height: 1.6; + color: var(--text-soft); + max-width: min(56ch, 100%); + margin: 28px 0 0; + font-weight: 300; +} +.page-lede.tight { margin: 0 0 40px; font-size: 19px; } + +/* ========================================================================== + Post rows — raised paper cards + ========================================================================== */ + +.post-list { + display: flex; + flex-direction: column; + gap: 10px; +} + .post-row { display: grid; grid-template-columns: 118px 1fr; - gap: 22px; + gap: 24px; align-items: baseline; - padding: 17px 14px 17px 0; - margin-left: -14px; - border-radius: 6px; - border-top: 1px solid var(--border-light); + padding: 20px 22px; + border-radius: 10px; + border: 1px solid var(--border-light); + background: var(--paper-raise); + box-shadow: var(--shadow-1); cursor: pointer; - transition: background 0.22s ease, padding 0.22s ease, transform 0.22s ease; - animation: fadeUp 0.55s cubic-bezier(0.2, 0.7, 0.2, 1) both; -} -.post-row:hover { background: rgba(36, 34, 27, 0.045); padding-left: 14px; transform: translateX(2px); } -.post-row.large { padding: 22px 14px 22px 0; border-top-color: var(--border); } -.post-row-date { font-family: var(--mono); font-size: 12px; color: var(--text-faint); padding-top: 4px; } -.post-row-title { font-size: 22px; line-height: 1.28; color: var(--text); } -.post-row.large .post-row-title { font-size: 26px; line-height: 1.24; letter-spacing: -0.01em; } -.post-row-blurb { font-size: 15px; line-height: 1.5; color: var(--text-muted); margin-top: 4px; font-weight: 300; } -.post-row.large .post-row-blurb { font-size: 16px; line-height: 1.55; max-width: 560px; margin-top: 6px; } -.post-row-tags { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; } + transition: transform 0.28s var(--ease), box-shadow 0.28s var(--ease), + border-color 0.28s var(--ease); +} +.post-row:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); + border-color: rgba(36, 34, 27, 0.18); + text-decoration: none; +} +.post-row.large { padding: 26px 26px; } + +.post-row-date { + font-family: var(--mono); + font-size: 12px; + color: var(--text-faint); + padding-top: 5px; +} +.post-row-title { + font-size: 22px; + line-height: 1.28; + color: var(--text); + transition: color 0.25s var(--ease); +} +.post-row:hover .post-row-title { color: var(--accent-lacquer); } +.post-row.large .post-row-title { + font-size: 26px; + line-height: 1.24; + letter-spacing: -0.012em; +} +.post-row-blurb { + font-size: 15px; + line-height: 1.5; + color: var(--text-muted); + margin-top: 5px; + font-weight: 300; +} +.post-row.large .post-row-blurb { + font-size: 16px; + line-height: 1.55; + max-width: min(60ch, 100%); + margin-top: 7px; +} +.post-row-tags { display: flex; gap: 8px; margin-top: 13px; flex-wrap: wrap; } .post-row-tag { font-family: var(--mono); font-size: 10px; @@ -210,40 +599,215 @@ h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } color: var(--text-faint-2); border: 1px solid rgba(36, 34, 27, 0.16); padding: 3px 8px; - border-radius: 2px; + border-radius: 3px; +} + +/* ========================================================================== + Shelves + ========================================================================== */ + +.shelf-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 26px; +} + +.shelf-card { + background: var(--paper-raise); + border: 1px solid var(--border-light); + border-radius: 12px; + padding: 26px 26px 20px; + box-shadow: var(--shadow-1); + transition: transform 0.28s var(--ease), box-shadow 0.28s var(--ease); +} +.shelf-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); +} +.shelf-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 14px; + margin-bottom: 6px; +} +.shelf-card-head h3 { + font-size: 20px; + font-weight: 500; + letter-spacing: -0.01em; + margin: 0; +} +.shelf-card-head a { font-family: var(--mono); font-size: 12px; } +.shelf-card-note { + font-size: 14.5px; + color: var(--text-muted); + font-weight: 300; + margin: 0 0 6px; } .shelf-list { display: flex; flex-direction: column; } .shelf-row { display: grid; grid-template-columns: 1fr auto; - gap: 22px; + gap: 20px; align-items: baseline; - padding: 20px 14px 20px 0; - margin-left: -14px; - border-radius: 6px; - border-top: 1px solid var(--border); - transition: background 0.22s ease, padding 0.22s ease; - animation: fadeUp 0.55s cubic-bezier(0.2, 0.7, 0.2, 1) both; + padding: 16px 0; + border-top: 1px solid var(--border-light); + transition: transform 0.24s var(--ease); +} +.shelf-row:hover { transform: translateX(3px); } +.shelf-row-title { font-size: 18px; line-height: 1.32; color: var(--text); } +.shelf-row-sub { + font-size: 14px; + color: var(--text-muted); + margin-top: 3px; + font-weight: 300; } -.shelf-row:hover { background: rgba(36, 34, 27, 0.045); padding-left: 14px; } -.shelf-row-title { font-size: 22px; line-height: 1.3; color: var(--text); } -.shelf-row-sub { font-size: 15px; color: var(--text-muted); margin-top: 4px; font-weight: 300; } .shelf-row-meta { font-family: var(--mono); font-size: 12px; color: var(--text-faint); } -.shelf-row-status { font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } +.shelf-row-status { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* Archive pages get roomier rows */ +.page-section .shelf-row { padding: 20px 0; border-top-color: var(--border); } +.page-section .shelf-row-title { font-size: 21px; } +.page-section .shelf-row-sub { font-size: 15px; } + +/* ========================================================================== + About overlay + ========================================================================== */ + +.about-overlay { + position: fixed; + inset: 0; + z-index: 300; + display: grid; + place-items: center; + padding: clamp(16px, 4vw, 40px); + background: rgba(36, 34, 27, 0.44); + -webkit-backdrop-filter: blur(7px); + backdrop-filter: blur(7px); + opacity: 0; + transition: opacity 0.32s var(--ease); +} +.about-overlay.is-open { opacity: 1; } + +.about-panel { + position: relative; + width: min(660px, 100%); + max-height: 88vh; + overflow-y: auto; + background: var(--paper-raise); + border: 1px solid var(--border); + border-radius: 16px; + padding: clamp(28px, 5vw, 46px); + box-shadow: var(--shadow-3); + transform: scale(0.97) translateY(12px); + transition: transform 0.36s var(--ease); +} +.about-overlay.is-open .about-panel { transform: none; } + +.about-close { + position: absolute; + top: 16px; + right: 16px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text-muted); + font-family: var(--mono); + font-size: 15px; + line-height: 1; + cursor: pointer; + transition: color 0.2s var(--ease), background 0.2s var(--ease); +} +.about-close:hover { color: var(--text); background: var(--bg-sunk); } + +.about-head { + display: flex; + align-items: center; + gap: 18px; + margin-bottom: 22px; +} +.about-avatar { + width: 62px; + height: 62px; + border-radius: 50%; + object-fit: cover; + background: #d9d2c0; + border: 1px solid var(--border); + flex: none; +} +.about-eyebrow { + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--text-faint-3); + margin-bottom: 5px; +} +.about-name { + font-size: 27px; + font-weight: 400; + letter-spacing: -0.02em; + margin: 0; +} +.about-body p { + font-size: 17px; + line-height: 1.68; + color: var(--text-body); + font-weight: 300; + margin: 0 0 16px; +} +.about-links { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 24px; + padding-top: 22px; + border-top: 1px solid var(--border-light); +} +.about-links a { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 9px 15px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text-soft); + transition: transform 0.22s var(--ease), border-color 0.22s var(--ease), + color 0.22s var(--ease); +} +.about-links a:hover { + transform: translateY(-2px); + color: var(--text); + border-color: rgba(36, 34, 27, 0.24); + text-decoration: none; +} + +/* ========================================================================== + Post layout + ========================================================================== */ -/* Post layout */ .post-grid { display: grid; - grid-template-columns: 1fr 216px; + grid-template-columns: minmax(0, 1fr) 216px; gap: 56px; max-width: 1010px; margin: 0 auto; - padding-top: 96px; + padding: clamp(48px, 8vh, 84px) clamp(18px, 4vw, 46px) 120px; align-items: start; } -.post-article { max-width: 680px; min-width: 0; animation: fadeUp 0.6s cubic-bezier(0.2, 0.7, 0.2, 1) both; } -.post-back { cursor: pointer; font-family: var(--mono); font-size: 12px; color: var(--link); } +.post-article { max-width: 680px; min-width: 0; } +.post-back { font-family: var(--mono); font-size: 12px; color: var(--link); } .post-meta { font-family: var(--mono); font-size: 11px; @@ -252,23 +816,55 @@ h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } color: var(--text-faint); margin: 30px 0 16px; } -.post-title { font-size: 46px; line-height: 1.08; font-weight: 400; letter-spacing: -0.02em; margin: 0; } -.post-subtitle { font-size: 20px; line-height: 1.55; color: var(--text-soft); font-weight: 300; margin: 22px 0 0; font-style: italic; } +.post-title { + font-size: clamp(2.2rem, 5vw, 2.9rem); + line-height: 1.08; + font-weight: 400; + letter-spacing: -0.025em; + margin: 0; +} +.post-subtitle { + font-size: 20px; + line-height: 1.55; + color: var(--text-soft); + font-weight: 300; + margin: 22px 0 0; + font-style: italic; +} .post-body { font-size: 18px; line-height: 1.72; color: var(--text-body); } -.post-body h2 { font-size: 27px; font-weight: 500; letter-spacing: -0.01em; margin: 44px 0 16px; scroll-margin-top: 40px; } +.post-body h2 { + font-size: 27px; + font-weight: 500; + letter-spacing: -0.01em; + margin: 44px 0 16px; + scroll-margin-top: calc(var(--nav-h) + 20px); +} .post-body h2:first-child { margin-top: 0; } .post-body p { margin: 0 0 18px; } -.post-body img { max-width: 100%; border-radius: 4px; } -.post-body code { font-family: var(--mono); font-size: 15px; background: #e8e2d2; padding: 1px 6px; border-radius: 3px; } +.post-body img { max-width: 100%; border-radius: 6px; box-shadow: var(--shadow-1); } +.post-body code { + font-family: var(--mono); + font-size: 15px; + background: #e8e2d2; + padding: 1px 6px; + border-radius: 3px; +} .post-body pre code { background: none; padding: 0; } .post-body ul, .post-body ol { padding-left: 1.3em; margin: 0 0 18px; } +.post-body blockquote { + margin: 0 0 18px; + padding: 2px 0 2px 20px; + border-left: 2px solid var(--accent); + color: var(--text-soft); + font-style: italic; +} .post-byline { display: flex; align-items: center; gap: 14px; - margin-top: 28px; + margin-top: 34px; padding-top: 28px; border-top: 1px solid var(--border); } @@ -277,17 +873,17 @@ h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } height: 46px; border-radius: 50%; background: #d9d2c0; - display: flex; - align-items: center; - justify-content: center; + display: grid; + place-items: center; font-family: var(--mono); font-size: 15px; color: #6b6555; + flex: none; } .post-byline-name { font-size: 16px; color: var(--text); } .post-byline-desc { font-size: 14px; color: var(--text-muted); font-weight: 300; } -.post-toc { position: sticky; top: 96px; align-self: start; } +.post-toc { position: sticky; top: calc(var(--nav-h) + 28px); align-self: start; } .post-toc-label { font-family: var(--mono); font-size: 10px; @@ -303,22 +899,28 @@ h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } line-height: 1.35; padding: 5px 0 5px 12px; border-left: 2px solid var(--border); - transition: color 0.25s ease, border-color 0.25s ease, font-weight 0.1s; + transition: color 0.25s var(--ease), border-color 0.25s var(--ease); color: var(--text-faint-2); - font-weight: 400; } .post-toc-list a:hover { color: var(--text); text-decoration: none; } -.post-toc-list a.active { color: var(--text); border-left-color: var(--accent); font-weight: 500; } +.post-toc-list a.active { + color: var(--text); + border-left-color: var(--accent-lacquer); + font-weight: 500; +} -/* Code blocks stay dark and left-aligned regardless of surrounding warm theme */ +/* Code blocks stay dark and left-aligned regardless of the warm surround */ .post-body figure.highlight { text-align: left; margin-left: 0; margin-right: 0; } -.post-body .highlight, .post-body .highlight pre, .post-body pre { +.post-body .highlight, +.post-body .highlight pre, +.post-body pre { background-color: var(--code-bg); color: var(--code-text); - border-radius: 3px; + border-radius: 6px; text-align: left; } -.post-body .highlight pre, .post-body pre { +.post-body .highlight pre, +.post-body pre { padding: 20px 22px; overflow-x: auto; font-family: var(--mono); @@ -326,13 +928,106 @@ h1.page-title.smaller { font-size: 52px; line-height: 1.05; margin: 0 0 46px; } line-height: 1.75; } +/* ========================================================================== + Footer + ========================================================================== */ + +.site-footer { + border-top: 1px solid var(--border-light); + padding: 34px 0 46px; + background: var(--bg-sunk); +} +.site-footer .wrap { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + flex-wrap: wrap; +} +.site-footer .colophon { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-faint-3); +} +.footer-links { display: flex; gap: 16px; flex-wrap: wrap; } +.footer-links a { font-family: var(--mono); font-size: 12px; color: var(--text-muted); } +.footer-links a:hover { color: var(--text); } + +/* ========================================================================== + Responsive + ========================================================================== */ + @media (max-width: 900px) { - .sidebar { position: static; width: auto; height: auto; } - main.site-main { margin-left: 0; padding: 0 24px 80px; } - .post-grid { grid-template-columns: 1fr; } + .post-grid { grid-template-columns: 1fr; gap: 0; } .post-toc { display: none; } - h1.page-title { font-size: 42px; } - .post-row, .post-row.large { grid-template-columns: 1fr; gap: 4px; } + .shelf-grid { grid-template-columns: 1fr; } + .post-row, + .post-row.large { grid-template-columns: 1fr; gap: 6px; } .post-row-date { padding-top: 0; } - .shelf-row { grid-template-columns: 1fr; gap: 6px; } +} + +@media (max-width: 720px) { + :root { --nav-h: 60px; } + .topbar { gap: 12px; padding: 0 16px; } + .topbar-brand { font-size: 15px; } + .topbar-brand .mark { display: none; } + .nav-link { padding: 7px 9px; font-size: 14px; } + .nav-sep { display: none; } + .hero { min-height: 74vh; } + .hero-lede { font-size: 1.02rem; } + .about-panel { border-radius: 12px; } +} + +@media (max-width: 620px) { + /* The brand already links home, so the Home item is redundant here. */ + .topbar-nav > .nav-link:first-child { display: none; } + .topbar-nav { gap: 0; } + .nav-link { padding: 6px 8px; font-size: 13.5px; } + .hero-inner { padding-bottom: 44px; } + .hero-hint { font-size: 10px; letter-spacing: 0.1em; } + .chimes-audio { width: 38px; height: 38px; } + .post-row, .post-row.large { padding: 18px; } + .shelf-card { padding: 20px 20px 16px; } +} + +@media (max-width: 420px) { + .nav-link { padding: 6px 6px; font-size: 12.5px; } + .hero-hint { display: none; } +} + +/* ========================================================================== + Reduced motion — kill every transition and reveal + ========================================================================== */ + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } + + [data-reveal], + [data-reveal].is-in, + [data-reveal-stagger] > *, + [data-reveal-stagger].is-in > * { + opacity: 1 !important; + transform: none !important; + } + + .post-row:hover, + .shelf-card:hover, + .button:hover, + .chimes-audio:hover, + .about-links a:hover, + .nav-link:hover, + .shelf-row:hover { + transform: none !important; + } } diff --git a/static/js/chimes.js b/static/js/chimes.js new file mode 100644 index 0000000..594f041 --- /dev/null +++ b/static/js/chimes.js @@ -0,0 +1,452 @@ +/* + * chimes.js — hanging beaded strands, verlet-simulated. + * + * Strands are pinned at the top of the hero and fall under gravity. Moving the + * pointer (or dragging on touch) parts them; they swing and settle. Sweeping + * across a strand rings it, but only once the visitor has unmuted. + * + * No dependencies. No audio files — the chime is synthesised on the fly. + */ +(function () { + 'use strict'; + + var canvas = document.getElementById('chimes-canvas'); + if (!canvas || !canvas.getContext) return; + + var host = canvas.parentNode; + var ctx = canvas.getContext('2d'); + var TAU = Math.PI * 2; + + var motionQuery = window.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : null; + var reduceMotion = motionQuery ? motionQuery.matches : false; + + /* ---------------------------------------------------------------- tuning */ + + var POINTS_PER_STRAND = 18; + var GRAVITY = 0.42; + var DAMPING = 0.985; + var RELAX_PASSES = 6; + var POINTER_RADIUS = 98; + var STRAND_LENGTH_RATIO = 0.78; // longest strand, as a fraction of hero height + var STRIKE_SPEED = 2.2; // px per frame before a strand rings + var STRIKE_COOLDOWN = 170; // ms + + /* ------------------------------------------------------------------ state */ + + var W = 0; + var H = 0; + var strands = []; + var maxReach = 0; + var rafId = 0; + var running = false; + var visible = true; + + var pointer = { x: -9999, y: -9999, px: -9999, py: -9999, active: false }; + + var styles = getComputedStyle(document.documentElement); + function token(name, fallback) { + var v = styles.getPropertyValue(name); + return v && v.trim() ? v.trim() : fallback; + } + var INK = token('--strand-ink', 'rgba(36, 34, 27, 0.26)'); + var BEAD = token('--strand-bead', 'rgba(36, 34, 27, 0.4)'); + var ACCENT = token('--accent-lacquer', '#9c3b28'); + + /* ------------------------------------------------------------------ audio */ + + // Two octaves of a major pentatonic, so a left-to-right sweep plays a run + // rather than a chromatic smear. + var SCALE = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24, 26]; + var BASE_FREQ = 392; // G4 + + var audio = { + on: false, + ctx: null, + master: null, + + init: function () { + if (this.ctx) return true; + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return false; + this.ctx = new AC(); + this.master = this.ctx.createGain(); + this.master.gain.value = 0.5; + this.master.connect(this.ctx.destination); + return true; + }, + + ring: function (index, velocity) { + if (!this.on || !this.ctx) return; + if (this.ctx.state === 'suspended') this.ctx.resume(); + + var t = this.ctx.currentTime; + var freq = BASE_FREQ * Math.pow(2, SCALE[index % SCALE.length] / 12); + + var gain = this.ctx.createGain(); + var filter = this.ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.value = 3400; + + var body = this.ctx.createOscillator(); + body.type = 'triangle'; + body.frequency.value = freq; + + var shimmer = this.ctx.createOscillator(); + shimmer.type = 'sine'; + shimmer.frequency.value = freq * 2.01; // slight detune, gives it a beat + var shimmerGain = this.ctx.createGain(); + shimmerGain.gain.value = 0.3; + + body.connect(gain); + shimmer.connect(shimmerGain); + shimmerGain.connect(gain); + gain.connect(filter); + filter.connect(this.master); + + var peak = 0.16 * (0.45 + velocity * 0.55); + gain.gain.setValueAtTime(0.0001, t); + gain.gain.exponentialRampToValueAtTime(peak, t + 0.005); + gain.gain.exponentialRampToValueAtTime(0.0001, t + 1.25); + + body.start(t); + shimmer.start(t); + body.stop(t + 1.3); + shimmer.stop(t + 1.3); + } + }; + + /* ------------------------------------------------------------------ build */ + + function build() { + var rect = host.getBoundingClientRect(); + W = Math.max(1, Math.round(rect.width)); + H = Math.max(1, Math.round(rect.height)); + + var dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + canvas.style.width = W + 'px'; + canvas.style.height = H + 'px'; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + var count = Math.max(10, Math.min(34, Math.round(W / 46))); + var gap = W / (count + 1); + var longest = H * STRAND_LENGTH_RATIO; + + strands = []; + maxReach = 0; + + for (var s = 0; s < count; s++) { + var x = gap * (s + 1); + + // Vary the hem so the curtain reads as hand-strung rather than machined: + // a slow wave across the row, plus a small deterministic jitter. + var wave = Math.sin(s * 0.62) * 0.5 + Math.sin(s * 1.71 + 1.3) * 0.5; + var jitter = ((Math.sin(s * 12.9898) * 43758.5453) % 1 + 1) % 1; + var length = longest * (0.72 + 0.14 * (wave * 0.5 + 0.5) + 0.14 * jitter); + var spacing = length / (POINTS_PER_STRAND - 1); + if (length > maxReach) maxReach = length; + + var pts = []; + for (var i = 0; i < POINTS_PER_STRAND; i++) { + pts.push({ x: x, y: i * spacing, ox: x, oy: i * spacing }); + } + strands.push({ restX: x, pts: pts, spacing: spacing, lastRing: 0 }); + } + } + + /* ---------------------------------------------------------------- physics */ + + function step() { + var dx = pointer.x - pointer.px; + var dy = pointer.y - pointer.py; + var moving = pointer.active && Math.abs(dx) + Math.abs(dy) > 0.01; + + for (var s = 0; s < strands.length; s++) { + var pts = strands[s].pts; + var spacing = strands[s].spacing; + var i, p; + + // integrate + for (i = 1; i < pts.length; i++) { + p = pts[i]; + var vx = (p.x - p.ox) * DAMPING; + var vy = (p.y - p.oy) * DAMPING; + p.ox = p.x; + p.oy = p.y; + p.x += vx; + p.y += vy + GRAVITY; + } + + // pointer push, with linear falloff + if (moving) { + for (i = 1; i < pts.length; i++) { + p = pts[i]; + var ddx = p.x - pointer.x; + var ddy = p.y - pointer.y; + var d = Math.sqrt(ddx * ddx + ddy * ddy); + if (d < POINTER_RADIUS) { + var f = 1 - d / POINTER_RADIUS; + p.x += dx * f * 0.5; + p.y += dy * f * 0.28; + } + } + } + + // hold segment length; point 0 stays pinned to the top edge + for (var pass = 0; pass < RELAX_PASSES; pass++) { + pts[0].x = strands[s].restX; + pts[0].y = 0; + for (i = 0; i < pts.length - 1; i++) { + var a = pts[i]; + var b = pts[i + 1]; + var ax = b.x - a.x; + var ay = b.y - a.y; + var dist = Math.sqrt(ax * ax + ay * ay) || 0.0001; + var diff = ((dist - spacing) / dist) * 0.5; + var ox = ax * diff; + var oy = ay * diff; + if (i === 0) { + b.x -= ox * 2; + b.y -= oy * 2; + } else { + a.x += ox; + a.y += oy; + b.x -= ox; + b.y -= oy; + } + } + } + } + + if (audio.on && pointer.active) { + var speed = Math.abs(dx); + if (speed >= STRIKE_SPEED && pointer.y >= 0 && pointer.y <= maxReach) { + var lo = Math.min(pointer.px, pointer.x); + var hi = Math.max(pointer.px, pointer.x); + var now = Date.now(); + for (var k = 0; k < strands.length; k++) { + var st = strands[k]; + if (st.restX < lo || st.restX > hi) continue; + if (pointer.y > (POINTS_PER_STRAND - 1) * st.spacing) continue; + if (now - st.lastRing < STRIKE_COOLDOWN) continue; + st.lastRing = now; + audio.ring(k, Math.min(1, speed / 26)); + } + } + } + + pointer.px = pointer.x; + pointer.py = pointer.y; + } + + /* ----------------------------------------------------------------- render */ + + function render() { + ctx.clearRect(0, 0, W, H); + + for (var s = 0; s < strands.length; s++) { + var pts = strands[s].pts; + var last = pts.length - 1; + var i; + + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (i = 1; i < last; i++) { + ctx.quadraticCurveTo( + pts[i].x, + pts[i].y, + (pts[i].x + pts[i + 1].x) / 2, + (pts[i].y + pts[i + 1].y) / 2 + ); + } + ctx.lineTo(pts[last].x, pts[last].y); + ctx.strokeStyle = INK; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.fillStyle = BEAD; + for (i = 3; i <= last; i += 3) { + ctx.beginPath(); + ctx.arc(pts[i].x, pts[i].y, 1.5 + (i / last) * 1.4, 0, TAU); + ctx.fill(); + } + + // one warm bead per strand, low on the run + var accentAt = Math.floor(last * 0.62); + ctx.beginPath(); + ctx.arc(pts[accentAt].x, pts[accentAt].y, 3.1, 0, TAU); + ctx.globalAlpha = 0.62; + ctx.fillStyle = ACCENT; + ctx.fill(); + ctx.globalAlpha = 1; + } + } + + /* ------------------------------------------------------------------- loop */ + + function frame() { + step(); + render(); + rafId = window.requestAnimationFrame(frame); + } + + function start() { + if (running || reduceMotion) return; + running = true; + rafId = window.requestAnimationFrame(frame); + } + + function stop() { + if (!running) return; + running = false; + window.cancelAnimationFrame(rafId); + } + + function settle() { + // Drop the strands straight down and draw a single frame. Used for + // reduced-motion, where nothing should move at all. + build(); + render(); + } + + /* ----------------------------------------------------------------- events */ + + function localPoint(e) { + var rect = canvas.getBoundingClientRect(); + pointer.x = e.clientX - rect.left; + pointer.y = e.clientY - rect.top; + } + + function onMove(e) { + if (reduceMotion) return; + if (!pointer.active) { + localPoint(e); + pointer.px = pointer.x; + pointer.py = pointer.y; + pointer.active = true; + return; + } + localPoint(e); + } + + function onLeave() { + pointer.active = false; + pointer.x = pointer.px = -9999; + pointer.y = pointer.py = -9999; + } + + if (window.PointerEvent) { + host.addEventListener('pointermove', onMove); + host.addEventListener('pointerdown', onMove); + host.addEventListener('pointerleave', onLeave); + host.addEventListener('pointercancel', onLeave); + } else { + host.addEventListener('mousemove', onMove); + host.addEventListener('mouseleave', onLeave); + host.addEventListener('touchmove', function (e) { + if (e.touches && e.touches.length) onMove(e.touches[0]); + }); + host.addEventListener('touchend', onLeave); + } + + var resizeTimer = 0; + window.addEventListener('resize', function () { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(function () { + build(); + if (reduceMotion) render(); + }, 160); + }); + + document.addEventListener('visibilitychange', function () { + if (document.hidden) stop(); + else if (visible && !reduceMotion) start(); + }); + + if ('IntersectionObserver' in window) { + new IntersectionObserver( + function (entries) { + visible = entries[0].isIntersecting; + if (visible && !document.hidden) start(); + else stop(); + }, + { threshold: 0 } + ).observe(host); + } + + if (motionQuery && motionQuery.addEventListener) { + motionQuery.addEventListener('change', function (e) { + reduceMotion = e.matches; + if (reduceMotion) { + stop(); + settle(); + } else { + start(); + } + }); + } + + /* --------------------------------------------------------- audio controls */ + + var STORAGE_KEY = 'chimes:audio'; + var toggle = document.getElementById('chimes-audio'); + + function readStored() { + try { + return window.localStorage.getItem(STORAGE_KEY) === 'on'; + } catch (err) { + return false; + } + } + + function writeStored(on) { + try { + window.localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off'); + } catch (err) { + /* private mode — preference simply won't persist */ + } + } + + function applyAudio(on) { + // The AudioContext is only created once the visitor asks for sound, which + // sidesteps browser autoplay policy entirely. + if (on && !audio.init()) return; + audio.on = on; + if (on && audio.ctx && audio.ctx.state === 'suspended') audio.ctx.resume(); + if (toggle) { + toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); + toggle.classList.toggle('is-on', on); + toggle.setAttribute('aria-label', on ? 'Mute chimes' : 'Unmute chimes'); + } + } + + if (toggle) { + toggle.addEventListener('click', function () { + var next = !audio.on; + applyAudio(next); + writeStored(audio.on); + }); + // Restore the stored preference, but only wire it up on first interaction: + // creating an AudioContext before a gesture leaves it suspended anyway. + if (readStored()) { + var restore = function () { + applyAudio(true); + document.removeEventListener('pointerdown', restore); + document.removeEventListener('keydown', restore); + }; + document.addEventListener('pointerdown', restore); + document.addEventListener('keydown', restore); + toggle.setAttribute('aria-pressed', 'true'); + toggle.classList.add('is-on'); + } + } + + /* ------------------------------------------------------------------ boot */ + + build(); + if (reduceMotion) render(); + else start(); +})(); diff --git a/static/js/site.js b/static/js/site.js new file mode 100644 index 0000000..fca9bbf --- /dev/null +++ b/static/js/site.js @@ -0,0 +1,215 @@ +/* + * site.js — scroll reveals, the About overlay, and nav scrollspy. + * + * Everything here degrades to "already visible, no motion" when the visitor has + * asked for reduced motion, and to plain anchor links if JS never runs. + */ +(function () { + 'use strict'; + + var reduceMotion = + window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + /* --------------------------------------------------------- scroll reveals */ + + var revealables = Array.prototype.slice.call(document.querySelectorAll('[data-reveal]')); + + revealables.forEach(function (el) { + if (!el.hasAttribute('data-reveal-stagger')) return; + Array.prototype.forEach.call(el.children, function (child, i) { + child.style.transitionDelay = i * 60 + 'ms'; + }); + }); + + if (reduceMotion || !('IntersectionObserver' in window)) { + revealables.forEach(function (el) { + el.classList.add('is-in'); + }); + } else { + var revealObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + entry.target.classList.add('is-in'); + revealObserver.unobserve(entry.target); + }); + }, + { rootMargin: '0px 0px -12% 0px', threshold: 0.08 } + ); + revealables.forEach(function (el) { + revealObserver.observe(el); + }); + } + + /* ----------------------------------------------------------- About overlay */ + + var modal = document.getElementById('about-modal'); + + if (modal) { + var openers = Array.prototype.slice.call(document.querySelectorAll('[data-about-open]')); + var closers = Array.prototype.slice.call(modal.querySelectorAll('[data-about-close]')); + var panel = modal.querySelector('.about-panel'); + var lastFocused = null; + var isOpen = false; + + var FOCUSABLE = + 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'; + + function focusable() { + return Array.prototype.slice.call(modal.querySelectorAll(FOCUSABLE)).filter(function (el) { + return el.offsetParent !== null; + }); + } + + function openModal() { + if (isOpen) return; + isOpen = true; + lastFocused = document.activeElement; + modal.hidden = false; + // force a reflow so the transition actually plays + void modal.offsetWidth; + modal.classList.add('is-open'); + document.body.classList.add('is-locked'); + openers.forEach(function (el) { + el.setAttribute('aria-expanded', 'true'); + }); + var first = focusable()[0]; + if (first) first.focus(); + } + + function closeModal() { + if (!isOpen) return; + isOpen = false; + modal.classList.remove('is-open'); + document.body.classList.remove('is-locked'); + openers.forEach(function (el) { + el.setAttribute('aria-expanded', 'false'); + }); + + var finish = function () { + modal.hidden = true; + modal.removeEventListener('transitionend', finish); + }; + if (reduceMotion) finish(); + else modal.addEventListener('transitionend', finish); + + if (lastFocused && lastFocused.focus) lastFocused.focus(); + } + + openers.forEach(function (el) { + el.setAttribute('aria-expanded', 'false'); + el.addEventListener('click', function (e) { + e.preventDefault(); + openModal(); + }); + }); + + closers.forEach(function (el) { + el.addEventListener('click', function (e) { + e.preventDefault(); + closeModal(); + }); + }); + + modal.addEventListener('click', function (e) { + if (panel && !panel.contains(e.target)) closeModal(); + }); + + document.addEventListener('keydown', function (e) { + if (!isOpen) return; + + if (e.key === 'Escape' || e.key === 'Esc') { + e.preventDefault(); + closeModal(); + return; + } + + if (e.key !== 'Tab') return; + + var items = focusable(); + if (!items.length) return; + var first = items[0]; + var last = items[items.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }); + } + + /* ------------------------------------------------------------- scrollspy */ + + var spyLinks = Array.prototype.slice.call(document.querySelectorAll('[data-section]')); + + if (spyLinks.length) { + var sections = spyLinks + .map(function (link) { + var el = document.getElementById(link.getAttribute('data-section')); + return el ? { link: link, el: el } : null; + }) + .filter(Boolean); + + if (sections.length) { + var current = null; + + var syncSpy = function () { + var best = sections[0]; + for (var i = 0; i < sections.length; i++) { + if (sections[i].el.getBoundingClientRect().top <= 140) best = sections[i]; + } + if (best === current) return; + current = best; + sections.forEach(function (s) { + s.link.classList.toggle('is-active', s === best); + }); + }; + + var ticking = false; + window.addEventListener( + 'scroll', + function () { + if (ticking) return; + ticking = true; + window.requestAnimationFrame(function () { + syncSpy(); + ticking = false; + }); + }, + { passive: true } + ); + syncSpy(); + } + } + + /* -------------------------------------------- smooth in-page anchor jumps */ + + if (!reduceMotion) { + document.addEventListener('click', function (e) { + var link = e.target.closest ? e.target.closest('a[href^="#"]') : null; + if (!link) return; + var id = link.getAttribute('href').slice(1); + if (!id) return; + var target = document.getElementById(id); + if (!target) return; + e.preventDefault(); + var top = target.getBoundingClientRect().top + window.pageYOffset - 72; + window.scrollTo({ top: top, behavior: 'smooth' }); + if (history.replaceState) history.replaceState(null, '', '#' + id); + }); + } + + /* ------------------------------------------------ nav shadow once scrolled */ + + var nav = document.querySelector('.topbar'); + if (nav) { + var syncNav = function () { + nav.classList.toggle('is-stuck', window.pageYOffset > 12); + }; + window.addEventListener('scroll', syncNav, { passive: true }); + syncNav(); + } +})();