chore: initial work on course of empires 3a9dcd76
Steve Simkins · 2026-05-07 23:30 5 file(s) · +471 −0
bun.lock +3 −0
9 9
        "@astrojs/rss": "4.0.14",
10 10
        "@astrojs/ts-plugin": "1.10.6",
11 11
        "astro": "5.16.7",
12 +
        "gsap": "^3.15.0",
12 13
        "markdown-it": "^14.1.1",
13 14
        "rehype-external-links": "^3.0.0",
14 15
        "sanitize-html": "^2.17.3",
561 562
    "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="],
562 563
563 564
    "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
565 +
566 +
    "gsap": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="],
564 567
565 568
    "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
566 569
package.json +1 −0
31 31
		"@astrojs/rss": "4.0.14",
32 32
		"@astrojs/ts-plugin": "1.10.6",
33 33
		"astro": "5.16.7",
34 +
		"gsap": "^3.15.0",
34 35
		"markdown-it": "^14.1.1",
35 36
		"rehype-external-links": "^3.0.0",
36 37
		"sanitize-html": "^2.17.3",
src/components/page/EmpireScroll.astro (added) +264 −0
1 +
---
2 +
import { Image } from "astro:assets";
3 +
import { paintings } from "@/data/courseOfEmpire";
4 +
---
5 +
6 +
<article class="empire">
7 +
  {paintings.map((p, idx) => (
8 +
    <section
9 +
      class="empire-stage"
10 +
      data-slug={p.slug}
11 +
      data-details={p.details.length}
12 +
    >
13 +
      <div class="empire-frame" data-frame>
14 +
        <Image
15 +
          src={p.src}
16 +
          widths={[960, 1440, 1920, 2880]}
17 +
          sizes="100vw"
18 +
          formats={["avif", "webp"]}
19 +
          alt={`${p.title} by Thomas Cole`}
20 +
          loading={idx === 0 ? "eager" : "lazy"}
21 +
          fetchpriority={idx === 0 ? "high" : "auto"}
22 +
          decoding="async"
23 +
          class="empire-img"
24 +
        />
25 +
      </div>
26 +
27 +
      <figcaption class="empire-intro" data-intro>
28 +
        <span class="empire-counter">{String(idx + 1).padStart(2, "0")} / 05</span>
29 +
        <a class="empire-title" href={p.link} target="_blank" rel="noreferrer">
30 +
          {p.title} <span class="empire-year">({p.year})</span>
31 +
        </a>
32 +
        <p class="empire-desc">{p.intro}</p>
33 +
      </figcaption>
34 +
35 +
      {p.details.map((d, i) => (
36 +
        <figcaption class="empire-caption" data-caption data-idx={i}>
37 +
          <p>{d.caption}</p>
38 +
        </figcaption>
39 +
      ))}
40 +
    </section>
41 +
  ))}
42 +
</article>
43 +
44 +
<style>
45 +
  .empire {
46 +
    background: #0a0a0c;
47 +
    color: #f3f1ec;
48 +
  }
49 +
50 +
  .empire-stage {
51 +
    position: relative;
52 +
    height: 100vh;
53 +
    width: 100%;
54 +
    overflow: hidden;
55 +
  }
56 +
57 +
  .empire-frame {
58 +
    position: absolute;
59 +
    inset: 0;
60 +
    transform-origin: 0 0;
61 +
    will-change: transform;
62 +
  }
63 +
64 +
  .empire-img {
65 +
    width: 100%;
66 +
    height: 100%;
67 +
    object-fit: cover;
68 +
    display: block;
69 +
  }
70 +
71 +
  .empire-intro,
72 +
  .empire-caption {
73 +
    position: absolute;
74 +
    left: 1.5rem;
75 +
    bottom: 1.5rem;
76 +
    max-width: min(60ch, calc(100% - 3rem));
77 +
    padding: 1rem 1.25rem;
78 +
    background: rgba(10, 10, 12, 0.72);
79 +
    backdrop-filter: blur(8px);
80 +
    -webkit-backdrop-filter: blur(8px);
81 +
    border-left: 2px solid rgba(243, 241, 236, 0.6);
82 +
    opacity: 0;
83 +
    pointer-events: none;
84 +
    z-index: 2;
85 +
  }
86 +
87 +
  .empire-intro {
88 +
    pointer-events: auto;
89 +
  }
90 +
91 +
  .empire-counter {
92 +
    display: block;
93 +
    font-size: 0.75rem;
94 +
    letter-spacing: 0.12em;
95 +
    text-transform: uppercase;
96 +
    color: rgba(243, 241, 236, 0.55);
97 +
    margin-bottom: 0.4rem;
98 +
  }
99 +
100 +
  .empire-title {
101 +
    font-weight: 600;
102 +
    font-size: 1.05rem;
103 +
    color: #fff;
104 +
    text-decoration: none;
105 +
    display: inline-block;
106 +
    margin-bottom: 0.5rem;
107 +
  }
108 +
  .empire-title:hover { text-decoration: underline; }
109 +
110 +
  .empire-year {
111 +
    font-weight: 400;
112 +
    color: rgba(243, 241, 236, 0.55);
113 +
  }
114 +
115 +
  .empire-desc,
116 +
  .empire-caption p {
117 +
    font-size: 0.875rem;
118 +
    line-height: 1.6;
119 +
    color: rgba(243, 241, 236, 0.88);
120 +
    margin: 0;
121 +
  }
122 +
123 +
  @media (prefers-reduced-motion: reduce) {
124 +
    .empire-stage {
125 +
      height: auto;
126 +
    }
127 +
    .empire-frame {
128 +
      position: relative;
129 +
      transform: none !important;
130 +
    }
131 +
    .empire-img {
132 +
      height: auto;
133 +
      aspect-ratio: 3000 / 1865;
134 +
    }
135 +
    .empire-intro,
136 +
    .empire-caption {
137 +
      position: static;
138 +
      opacity: 1;
139 +
      max-width: none;
140 +
      margin: 0.75rem 1.5rem 0;
141 +
    }
142 +
  }
143 +
</style>
144 +
145 +
<script>
146 +
  import gsap from "gsap";
147 +
  import { ScrollTrigger } from "gsap/ScrollTrigger";
148 +
  import { paintings } from "@/data/courseOfEmpire";
149 +
150 +
  gsap.registerPlugin(ScrollTrigger);
151 +
152 +
  type Detail = { x: number; y: number; scale: number };
153 +
154 +
  const detailsBySlug: Record<string, Detail[]> = Object.fromEntries(
155 +
    paintings.map((p) => [
156 +
      p.slug,
157 +
      p.details.map((d) => ({ x: d.x, y: d.y, scale: d.scale })),
158 +
    ]),
159 +
  );
160 +
161 +
  function focusFor(d: Detail, w: number, h: number, scaleMult: number) {
162 +
    const s = 1 + (d.scale - 1) * scaleMult;
163 +
    return {
164 +
      scale: s,
165 +
      x: w / 2 - d.x * w * s,
166 +
      y: h / 2 - d.y * h * s,
167 +
    };
168 +
  }
169 +
170 +
  const triggers: ScrollTrigger[] = [];
171 +
172 +
  function buildTimelines(scaleMult: number, pinFactor: number) {
173 +
    triggers.forEach((t) => t.kill());
174 +
    triggers.length = 0;
175 +
176 +
    const stages = document.querySelectorAll<HTMLElement>(".empire-stage");
177 +
178 +
    stages.forEach((stage) => {
179 +
      const slug = stage.dataset.slug ?? "";
180 +
      const details = detailsBySlug[slug] ?? [];
181 +
      const frame = stage.querySelector<HTMLElement>("[data-frame]");
182 +
      const intro = stage.querySelector<HTMLElement>("[data-intro]");
183 +
      const captions = stage.querySelectorAll<HTMLElement>("[data-caption]");
184 +
      if (!frame) return;
185 +
186 +
      const w = stage.clientWidth;
187 +
      const h = stage.clientHeight;
188 +
189 +
      gsap.set(frame, { scale: 1, x: 0, y: 0 });
190 +
      gsap.set(intro, { opacity: 0 });
191 +
      gsap.set(captions, { opacity: 0 });
192 +
193 +
      const tl = gsap.timeline({
194 +
        scrollTrigger: {
195 +
          trigger: stage,
196 +
          start: "top top",
197 +
          end: `+=${window.innerHeight * (1 + details.length * pinFactor)}`,
198 +
          pin: true,
199 +
          scrub: 0.6,
200 +
          anticipatePin: 1,
201 +
        },
202 +
      });
203 +
204 +
      tl.to(intro, { opacity: 1, duration: 0.08 }, 0);
205 +
      tl.to(intro, { opacity: 0, duration: 0.06 }, 0.16);
206 +
207 +
      const slotStart = 0.22;
208 +
      const slotEnd = 0.94;
209 +
      const span = slotEnd - slotStart;
210 +
      const slot = span / Math.max(details.length, 1);
211 +
212 +
      details.forEach((d, i) => {
213 +
        const f = focusFor(d, w, h, scaleMult);
214 +
        const t0 = slotStart + i * slot;
215 +
        const tPan = slot * 0.4;
216 +
        const tHold = slot * 0.5;
217 +
        const cap = captions[i];
218 +
219 +
        tl.to(frame, { ...f, ease: "power2.inOut", duration: tPan }, t0);
220 +
        if (cap) {
221 +
          tl.to(cap, { opacity: 1, duration: tPan * 0.6 }, t0 + tPan * 0.4);
222 +
          tl.to(cap, { opacity: 0, duration: tPan * 0.4 }, t0 + tPan + tHold * 0.7);
223 +
        }
224 +
      });
225 +
226 +
      tl.to(
227 +
        frame,
228 +
        { scale: 1, x: 0, y: 0, ease: "power2.inOut", duration: 0.06 },
229 +
        0.94,
230 +
      );
231 +
232 +
      if (tl.scrollTrigger) triggers.push(tl.scrollTrigger);
233 +
    });
234 +
  }
235 +
236 +
  const mm = gsap.matchMedia();
237 +
238 +
  mm.add(
239 +
    {
240 +
      reduce: "(prefers-reduced-motion: reduce)",
241 +
      mobile: "(prefers-reduced-motion: no-preference) and (max-width: 768px)",
242 +
      desktop: "(prefers-reduced-motion: no-preference) and (min-width: 769px)",
243 +
    },
244 +
    (ctx) => {
245 +
      const c = ctx.conditions as {
246 +
        reduce: boolean;
247 +
        mobile: boolean;
248 +
        desktop: boolean;
249 +
      };
250 +
      if (c.reduce) return;
251 +
      const scaleMult = c.mobile ? 0.65 : 1;
252 +
      const pinFactor = c.mobile ? 0.7 : 1.0;
253 +
      buildTimelines(scaleMult, pinFactor);
254 +
      return () => {
255 +
        triggers.forEach((t) => t.kill());
256 +
        triggers.length = 0;
257 +
      };
258 +
    },
259 +
  );
260 +
261 +
  if (document.fonts?.ready) {
262 +
    document.fonts.ready.then(() => ScrollTrigger.refresh());
263 +
  }
264 +
</script>
src/data/courseOfEmpire.ts (added) +173 −0
1 +
import type { ImageMetadata } from "astro";
2 +
import savage from "@/assets/the-savage-state.jpg";
3 +
import pastoral from "@/assets/the-pastoral-state.jpg";
4 +
import consumation from "@/assets/the-consumation-of-empire.jpg";
5 +
import destruction from "@/assets/destruction.jpg";
6 +
import desolation from "@/assets/desolation.jpg";
7 +
8 +
export interface Detail {
9 +
	x: number;
10 +
	y: number;
11 +
	scale: number;
12 +
	caption: string;
13 +
}
14 +
15 +
export interface Painting {
16 +
	slug: string;
17 +
	src: ImageMetadata;
18 +
	title: string;
19 +
	year: string;
20 +
	intro: string;
21 +
	link: string;
22 +
	details: Detail[];
23 +
}
24 +
25 +
export const paintings: Painting[] = [
26 +
	{
27 +
		slug: "savage",
28 +
		src: savage,
29 +
		title: "The Savage State",
30 +
		year: "1834",
31 +
		intro:
32 +
			"Dawn breaks over a wilderness as nomadic hunters chase a wounded deer and storm clouds wreathe the mountain. Nature is supreme; man clings to its edges.",
33 +
		link: "https://explorethomascole.org/project/the-savage-state/",
34 +
		details: [
35 +
			{
36 +
				x: 0.32,
37 +
				y: 0.62,
38 +
				scale: 2.4,
39 +
				caption: "A hunter draws his bow. Skins, not stone — the only architecture is the chase.",
40 +
			},
41 +
			{
42 +
				x: 0.78,
43 +
				y: 0.32,
44 +
				scale: 2.2,
45 +
				caption: "Storm wreathes the peak. The mountain will outlast every empire to come.",
46 +
			},
47 +
			{
48 +
				x: 0.5,
49 +
				y: 0.85,
50 +
				scale: 2.6,
51 +
				caption: "Smoke from a clustered camp at the shore. The first faint claim on the land.",
52 +
			},
53 +
		],
54 +
	},
55 +
	{
56 +
		slug: "pastoral",
57 +
		src: pastoral,
58 +
		title: "The Pastoral or Arcadian State",
59 +
		year: "1834",
60 +
		intro:
61 +
			"Morning light, cleared fields, a Stonehenge-like temple, an old man tracing geometry in the dirt while a boy sketches a figure on stone. Civilization stirs; agriculture, science, art, all still close to the earth.",
62 +
		link: "https://explorethomascole.org/project/the-arcadian-or-pastoral-state/"
63 +
,
64 +
		details: [
65 +
			{
66 +
				x: 0.22,
67 +
				y: 0.58,
68 +
				scale: 2.4,
69 +
				caption: "An old man traces geometry in the dirt. Knowledge begins as scratched lines.",
70 +
			},
71 +
			{
72 +
				x: 0.6,
73 +
				y: 0.45,
74 +
				scale: 2.2,
75 +
				caption: "A trilithon temple on the rise. Ritual housed in stone for the first time.",
76 +
			},
77 +
			{
78 +
				x: 0.85,
79 +
				y: 0.7,
80 +
				scale: 2.5,
81 +
				caption: "Shepherds and a circle of dancers. Labor and leisure still share a field.",
82 +
			},
83 +
		],
84 +
	},
85 +
	{
86 +
		slug: "consummation",
87 +
		src: consumation,
88 +
		title: "The Consummation of Empire",
89 +
		year: "1836",
90 +
		intro:
91 +
			"High noon over a marble city. A triumphant ruler crosses the bridge in an elephant-drawn car, temples crowd the bay, the harbor brims with ships. Splendor so total it can only precede collapse.",
92 +
		link: "https://explorethomascole.org/project/the-consummation-of-empire/"
93 +
,
94 +
		details: [
95 +
			{
96 +
				x: 0.5,
97 +
				y: 0.62,
98 +
				scale: 2.6,
99 +
				caption: "The conqueror crosses the bridge in an elephant-drawn car. Power on parade.",
100 +
			},
101 +
			{
102 +
				x: 0.18,
103 +
				y: 0.4,
104 +
				scale: 2.3,
105 +
				caption: "Colonnades march to the water. Marble built to outlast the men who quarried it.",
106 +
			},
107 +
			{
108 +
				x: 0.82,
109 +
				y: 0.78,
110 +
				scale: 2.4,
111 +
				caption: "The harbor packed with galleys. Trade has become indistinguishable from triumph.",
112 +
			},
113 +
		],
114 +
	},
115 +
	{
116 +
		slug: "destruction",
117 +
		src: destruction,
118 +
		title: "Destruction",
119 +
		year: "1836",
120 +
		intro:
121 +
			"Storm and sack. Invaders pour through the gates, the temple porch becomes a catapult, ships burn in the harbor, a headless warrior presides over the slaughter as the sky turns to fire.",
122 +
		link: "https://explorethomascole.org/project/destruction/",
123 +
		details: [
124 +
			{
125 +
				x: 0.42,
126 +
				y: 0.58,
127 +
				scale: 2.6,
128 +
				caption: "The headless warrior. Even the colossus loses its face when the city falls.",
129 +
			},
130 +
			{
131 +
				x: 0.7,
132 +
				y: 0.75,
133 +
				scale: 2.5,
134 +
				caption: "Bridge collapses under the press of bodies. Architecture eats its makers.",
135 +
			},
136 +
			{
137 +
				x: 0.2,
138 +
				y: 0.35,
139 +
				scale: 2.2,
140 +
				caption: "Sky turns to fire. The same harbor — only the light has changed.",
141 +
			},
142 +
		],
143 +
	},
144 +
	{
145 +
		slug: "desolation",
146 +
		src: desolation,
147 +
		title: "Desolation",
148 +
		year: "1836",
149 +
		intro:
150 +
			"Sunset and moonrise over silent ruins. Vines climb a lone column, a bird nests where a temple stood, deer roam the broken friezes. No human figures remain. Nature outlasts everything man builds.",
151 +
		link: "https://explorethomascole.org/project/desolation/",
152 +
		details: [
153 +
			{
154 +
				x: 0.28,
155 +
				y: 0.5,
156 +
				scale: 2.5,
157 +
				caption: "A single column, ivy-wrapped. The last vertical line man left standing.",
158 +
			},
159 +
			{
160 +
				x: 0.68,
161 +
				y: 0.78,
162 +
				scale: 2.4,
163 +
				caption: "Deer cross the broken frieze. The wilderness has come back through the gate.",
164 +
			},
165 +
			{
166 +
				x: 0.55,
167 +
				y: 0.32,
168 +
				scale: 2.2,
169 +
				caption: "Moonrise where the temple stood. The mountain unchanged across all five.",
170 +
			},
171 +
		],
172 +
	},
173 +
];
src/pages/course-of-empire.astro (added) +30 −0
1 +
---
2 +
import PageLayout from "@/layouts/Base.astro";
3 +
import EmpireScroll from "@/components/page/EmpireScroll.astro";
4 +
5 +
const meta = {
6 +
	title: "The Course of Empire",
7 +
	description:
8 +
		"A scroll-driven tour through Thomas Cole's five-painting cycle.",
9 +
};
10 +
---
11 +
12 +
<PageLayout meta={meta}>
13 +
	<EmpireScroll />
14 +
	<style is:global>
15 +
		body.empire-page {
16 +
			padding: 0 !important;
17 +
			max-width: none !important;
18 +
		}
19 +
		body.empire-page #main {
20 +
			padding: 0;
21 +
		}
22 +
		html.empire-html {
23 +
			scroll-behavior: auto;
24 +
		}
25 +
	</style>
26 +
	<script>
27 +
		document.body.classList.add("empire-page");
28 +
		document.documentElement.classList.add("empire-html");
29 +
	</script>
30 +
</PageLayout>