Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4010457
Add guide: one chat can run all your other chats (ingest Berman)
ryanportfolio Aug 5, 2026
a08f5a9
Publish guide No. 40 (editor-approved via /merge)
ryanportfolio Aug 5, 2026
17c674f
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
ad65c43
Remove the primary sources list from the home page
ryanportfolio Aug 5, 2026
0282758
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
728407f
Remove the ledger note under the home page catalogue table
ryanportfolio Aug 5, 2026
4a2fbfb
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
f76b55c
Replace the home table's Reading column with clickable topic tags
ryanportfolio Aug 5, 2026
5d9867d
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
abde7c7
Wrap topic chips so they never overflow the page
ryanportfolio Aug 5, 2026
eaa1c1a
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
6eed8ac
Add an all-guides catalogue page; home table total row becomes its CTA
ryanportfolio Aug 5, 2026
bf12b8c
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
f6db0f9
Reveal guide cards individually so the first row shows on load
ryanportfolio Aug 5, 2026
ddc6f0c
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
fd9148a
Show the publication date on catalogue cards, drop the intro line
ryanportfolio Aug 5, 2026
76b4864
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
4ce69c2
Keep the catalogue grid inside the viewport at every width
ryanportfolio Aug 5, 2026
9a9f3ec
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
c4bc501
Print catalogue card dates as MM/DD/YY
ryanportfolio Aug 5, 2026
a8dc785
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
56d1419
Add the index plate: a catalogue call to action below the latest guides
ryanportfolio Aug 5, 2026
eb91994
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 5, 2026
43cbc86
Draw the catalogue as one constellation below the latest guides
ryanportfolio Aug 6, 2026
202817d
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 6, 2026
0bcc7e5
Cut 'on one page' from the constellation plate heading
ryanportfolio Aug 6, 2026
949ecef
Merge remote-tracking branch 'origin/main' into claude/youtube-video-…
ryanportfolio Aug 6, 2026
5fb0fd2
Give the catalogue buttons real depth, and cut the duplicate heading
ryanportfolio Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions site/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,11 @@ const minis: Record<string, string> = {
>get the most out of AI
</p>
<div class="hero-ctas">
<a class="btn solid" href="#tracks">Browse the layers</a>
{latest[0] && <a class="btn" href={`/guides/${latest[0].id}/`}>Latest guide</a>}
<a class="btn hero-cta depth" href="/guides/">
<canvas data-depth aria-hidden="true"></canvas>
<span class="cta-label">All Guides</span>
</a>
{latest[0] && <a class="btn hero-cta" href={`/guides/${latest[0].id}/`}>Latest</a>}
</div>
</div>
<p class="hero-scroll">↓ scroll</p>
Expand Down Expand Up @@ -308,10 +311,11 @@ const minis: Record<string, string> = {

<div class="plate-body">
<div class="plate-type reveal">
<h2>Every guide</h2>
<p class="plate-go">
<a class="btn" href="/guides/">
Every Guide <span class="arw" aria-hidden="true">&#8594;</span>
<a class="btn warp" href="/guides/">
<canvas data-warp aria-hidden="true"></canvas>
<span class="warp-label">Every Guide</span>
<span class="arw" aria-hidden="true">&#8594;</span>
</a>
</p>
</div>
Expand Down Expand Up @@ -472,6 +476,8 @@ const minis: Record<string, string> = {

<script>
import '../scripts/hero.js';
import '../scripts/warp-button.js';
import '../scripts/depth-button.js';
</script>

<style>
Expand Down Expand Up @@ -596,6 +602,32 @@ const minis: Record<string, string> = {
.dek-star { animation: none; opacity: 0.9; }
}
.hero-ctas { display: flex; gap: var(--s-3); margin-top: var(--s-4); }
/* both hero links step up from the sitewide .btn, staying a size under the
plate's catalogue button so the page keeps one loudest element */
.hero-cta {
position: relative;
display: inline-flex;
align-items: center;
isolation: isolate;
overflow: hidden;
padding: 22px 38px;
font-size: 1.05rem;
letter-spacing: 0.15em;
}
.hero-cta canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
display: block;
pointer-events: none;
}
.hero-cta .cta-label { position: relative; z-index: 1; }
/* the depth field has to stay visible, so this one keeps its paper ground
and moves only its rule and ink on hover */
.hero-cta.depth:hover { background: transparent; color: var(--accent); border-color: var(--accent); }
.hero-cta:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.corner {
position: absolute;
z-index: 2;
Expand Down Expand Up @@ -729,10 +761,41 @@ const minis: Record<string, string> = {
}
.plate-type > p { margin-top: var(--s-3); color: var(--ink-soft); font-size: var(--text-sm); }
.plate-go { margin-top: var(--s-4); }
/* The catalogue link, tuned in the button lab. 1.76rem is the editor's 38px at
this page's root size, kept in rem so the label scales with the rest of the
type instead of freezing at one width. */
.plate-go .btn.warp {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.7em;
isolation: isolate;
overflow: hidden;
padding: 34px 49px;
font-size: 1.76rem;
letter-spacing: 0.16em;
border-width: 0.5px;
transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
/* the depth sits behind the label and never intercepts the click */
.plate-go .btn.warp canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
display: block;
pointer-events: none;
}
.plate-go .btn.warp .warp-label,
.plate-go .arw { position: relative; z-index: 1; }
.plate-go .arw { display: inline-block; transition: transform var(--dur) var(--ease); }
.plate-go .btn:hover .arw,
.plate-go .btn:focus-visible .arw { transform: translateX(4px); }
.plate-go .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* the label must stay legible when the stars are behind it, so the fill
stays off and only the rule and the ink change on hover */
.plate-go .btn.warp:hover { background: transparent; color: var(--accent); border-color: var(--accent); }

/* the drawing. Capped at roughly its own scale so the engraved labels never
balloon on an ultrawide or shrink to nothing at the stacking width */
Expand Down
146 changes: 146 additions & 0 deletions site/src/scripts/depth-button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// The hero's catalogue button: a real depth field behind the label, points
// drifting toward the reader in a perspective camera. three.js is already on
// this page for the hero sky, so this adds no new dependency.
//
// Degrades honestly: no WebGL leaves the canvas empty and the button is still a
// plain link, prefers-reduced-motion renders one settled frame and never
// animates, and the loop stops whenever the tab is hidden.

import * as THREE from 'three';

const FX = {
SPEED: 1,
DEPTH: 1,
DENSITY: 1,
GLOW: 1,
PARALLAX: 1,
HOVER: 1,
OPACITY: 1,
};
const COUNT = 420;
const FAR = -22;
const NEAR = 2.5;

const hexOf = (name) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#2743d0';

export function boot() {
const canvas = document.querySelector('canvas[data-depth]');
if (!canvas) return;
const button = canvas.closest('a');

let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
} catch (e) {
return; // no WebGL: the link still works, the canvas simply stays empty
}
renderer.setClearColor(0x000000, 0);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(48, 1, 0.1, 100);
camera.position.z = 4.2 / FX.DEPTH;

const group = new THREE.Group();
group.scale.setScalar(FX.DENSITY * 0.9);
scene.add(group);

// Deterministic layout: the same field on every load, so the button never
// reshuffles itself between visits.
let seed = 20260806;
const rnd = () => {
seed = (seed * 1664525 + 1013904223) % 4294967296;
return seed / 4294967296;
};
const pos = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
pos[i * 3] = (rnd() - 0.5) * 9;
pos[i * 3 + 1] = (rnd() - 0.5) * 5;
pos[i * 3 + 2] = FAR * rnd();
}
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({
size: 0.055,
sizeAttenuation: true,
transparent: true,
blending: THREE.NormalBlending,
depthWrite: false,
});
const points = new THREE.Points(geom, mat);
group.add(points);

const reduced = matchMedia('(prefers-reduced-motion: reduce)');
let hover = 0;
let target = 0;
let mx = 0;
let my = 0;

button.addEventListener('pointerenter', () => { target = 1; });
button.addEventListener('pointerleave', () => { target = 0; mx = 0; my = 0; });
button.addEventListener('pointermove', (e) => {
const r = button.getBoundingClientRect();
mx = ((e.clientX - r.left) / r.width - 0.5) * 2;
my = ((e.clientY - r.top) / r.height - 0.5) * 2;
});
button.addEventListener('focus', () => { target = 1; });
button.addEventListener('blur', () => { target = 0; });

const draw = (t) => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
group.rotation.y = t * 0.05 + mx * 0.5 * FX.PARALLAX;
group.rotation.x = my * 0.3 * FX.PARALLAX;
mat.color.set(hexOf('--star'));
mat.opacity = Math.min(1, FX.OPACITY * FX.GLOW * (0.55 + hover * 0.5));
renderer.render(scene, camera);
};

const advance = () => {
const arr = geom.attributes.position.array;
const step = 0.05 * FX.SPEED * (1 + hover * FX.HOVER * 1.2);
for (let i = 2; i < arr.length; i += 3) {
arr[i] += step;
if (arr[i] > NEAR) arr[i] = FAR;
}
geom.attributes.position.needsUpdate = true;
};

let raf = 0;
const frame = (ms) => {
raf = requestAnimationFrame(frame);
hover += (target - hover) * 0.12;
advance();
draw(ms / 1000);
};

const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const start = () => {
if (reduced.matches) {
stop();
hover = 0;
draw(0); // one settled frame, no motion
return;
}
if (!raf) raf = requestAnimationFrame(frame);
};

document.addEventListener('visibilitychange', () => (document.hidden ? stop() : start()));
reduced.addEventListener('change', start);
new MutationObserver(() => { if (reduced.matches) draw(0); })
.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
window.addEventListener('resize', () => { if (reduced.matches) draw(0); });

start();
}

boot();
Loading
Loading