Five templates had nearly identical prose CSS, roughly 200 lines each. Rebuilt the project detail page and found the real work was in the shared styles I should've extracted months ago.
The gap
I opened a project page next to a blog post and the difference was embarrassing. The blog had a sticky sidebar, table of contents with scroll spy, mobile sticky bar with progress indicator, share buttons. The project page was a single column with a title, some tags, and a body. Meanwhile, the project schema had fields for client, role, duration, engagement type, tech stack, services, and related testimonials that weren’t surfaced anywhere.
So I rebuilt the project template to match. Two-column grid with a sticky sidebar on desktop, same mobile treatment as the blog. The sidebar stacks project metadata, action links (with live GitHub star counts), tech stack, service chips, share buttons, and a table of contents when there are enough headings.
That part was straightforward. What I didn’t expect was the cleanup it forced.
A thousand lines of duplicated CSS
Five templates on this site render long-form content: blog posts, field notes, colophon entries, artifacts, and now projects. Each one had its own prose styles. Roughly 200 lines of typography, spacing, and element formatting, copy-pasted with minor drift between them.
I only noticed because I was about to paste those same 200 lines into a sixth location and thought, this is absurd.
Extracted everything to src/styles/prose.css and replaced the inline blocks with @import. Templates that need tweaks layer overrides after the import. Field notes adds top margin, artifacts adds diagram styles. Everyone else just imports and moves on. It’s the kind of cleanup that only happens when you’re building something new and the duplication finally becomes too obvious to ignore.
Cross-referencing content
Added a relatedContent field to the project schema. It’s a Zod discriminated union that supports writing posts, field notes, colophon entries, tweets, and external links. The discriminator means each type carries only the fields it needs, and Zod validates that you’re not mixing them up. Now project pages can link back to relevant content across the site without it being a freeform URL field.
The GitHub sync script also learned a new trick. It queries stargazerCount and writes a github-repos-meta.json file at build time, so project pages can show star counts without making API calls in the browser.
Why Web Components (again)
All the interactive behavior uses Web Components for the same reason the blog does: Astro’s View Transitions swap the DOM on navigation, which destroys event listeners. Web Components re-attach via connectedCallback every time they enter the DOM. I’ve written about this pattern before, but it keeps earning its keep. Three new components handle the mobile sticky bar, floating share button, and clipboard sharing.
The scroll spy click-lock
This one’s my favorite detail. The table of contents has a scroll spy that highlights the current heading as you read. Standard stuff. But when you click a TOC link, it triggers smooth scrolling. During that animation, scroll events keep firing, and the spy tries to update the active heading based on whatever it sees mid-scroll. So you click “Section 4” and watch the highlight briefly jump to sections 2 and 3 before landing on 4.
The fix is a click lock. When you click a TOC link, the spy stops updating for the duration of the smooth scroll. It sets the clicked heading as active immediately, then re-enables once the scroll settles. Small thing, but the TOC felt broken without it.