site: founding post, headline post layout, masonry grid, source links

- New "Why this exists" post; masthead "founded" links to it
- Post template: serif headline + date in masthead, "Filed under {category}" in footer
- Drop counts from footer Collections list
- Masonry component for home + sources grid; source badges link to /sources/#slug
- Theme toggle (settings page + cog) with dark-mode-aware tokens
- Six new themed find lists; cluster-finds skill
This commit is contained in:
2026-05-03 17:52:14 -04:00
parent c8095d2766
commit e873460465
17 changed files with 683 additions and 251 deletions
+181
View File
@@ -0,0 +1,181 @@
---
interface Props {
initial?: number;
batch?: number;
}
const { initial, batch } = Astro.props;
const dataInitial = initial != null ? String(initial) : undefined;
const dataBatch = batch != null ? String(batch) : undefined;
---
<div
class="masonry-source"
data-initial={dataInitial}
data-batch={dataBatch}
>
<slot />
</div>
<script>
const sources = document.querySelectorAll<HTMLDivElement>('.masonry-source');
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function colCount(): number {
const w = window.innerWidth;
if (w <= 520) return 1;
if (w <= 880) return 2;
return 3;
}
for (const source of sources) {
const tiles = Array.from(source.children) as HTMLElement[];
for (const t of tiles) t.classList.add('masonry-tile');
const initialAttr = source.dataset.initial;
const batchAttr = source.dataset.batch;
const progressive = initialAttr !== undefined && batchAttr !== undefined;
const initial = progressive ? Number(initialAttr) : tiles.length;
const batch = progressive ? Number(batchAttr) : tiles.length;
const masonry = document.createElement('div');
masonry.className = 'masonry';
source.parentElement?.insertBefore(masonry, source);
source.remove();
const sentinel = document.createElement('div');
sentinel.className = 'masonry-sentinel';
sentinel.setAttribute('aria-hidden', 'true');
masonry.parentElement?.insertBefore(sentinel, masonry.nextSibling);
let cols: HTMLDivElement[] = [];
let stash: HTMLElement[] = tiles.slice();
let placed: HTMLElement[] = [];
let currentCols = colCount();
function buildColumns() {
masonry.replaceChildren();
cols = [];
for (let i = 0; i < currentCols; i++) {
const c = document.createElement('div');
c.className = 'masonry-col';
masonry.appendChild(c);
cols.push(c);
}
}
function shortestColumn(): HTMLDivElement {
let min = Infinity;
let idx = 0;
for (let i = 0; i < cols.length; i++) {
const h = cols[i].offsetHeight;
if (h < min) { min = h; idx = i; }
}
return cols[idx];
}
function placeOne(tile: HTMLElement, animate: boolean) {
if (animate && !prefersReducedMotion) {
tile.classList.add('revealing');
tile.addEventListener(
'animationend',
() => tile.classList.remove('revealing'),
{ once: true }
);
}
shortestColumn().appendChild(tile);
}
function placeBatch(n: number, animate: boolean) {
const slice = stash.splice(0, n);
for (const t of slice) {
placed.push(t);
placeOne(t, animate);
}
}
function rebuild() {
stash = placed.concat(stash);
placed = [];
buildColumns();
placeBatch(stash.length, false);
}
buildColumns();
placeBatch(Math.min(initial, stash.length), false);
if (progressive && stash.length > 0) {
let busy = false;
const io = new IntersectionObserver((entries) => {
if (busy || stash.length === 0) return;
if (!entries[0].isIntersecting) return;
busy = true;
placeBatch(Math.min(batch, stash.length), true);
setTimeout(() => { busy = false; }, 120);
if (stash.length === 0) {
io.disconnect();
sentinel.remove();
}
}, { rootMargin: '600px 0px' });
io.observe(sentinel);
} else {
sentinel.remove();
}
let resizeTimer: number | undefined;
window.addEventListener('resize', () => {
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const next = colCount();
if (next !== currentCols) {
currentCols = next;
rebuild();
}
}, 150);
});
}
</script>
<style is:global>
/* No-JS fallback: column-count masonry. JS replaces this with .masonry. */
.masonry-source {
column-count: 3;
column-gap: 1rem;
}
@media (max-width: 880px) { .masonry-source { column-count: 2; } }
@media (max-width: 520px) { .masonry-source { column-count: 1; } }
.masonry-source > * {
break-inside: avoid;
display: block;
margin: 0 0 1rem;
}
/* JS-built masonry: real columns, never reflow placed tiles. */
.masonry {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.masonry-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.masonry-tile.revealing {
animation: masonry-tile-in 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: transform, opacity;
}
@keyframes masonry-tile-in {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.masonry-tile.revealing { animation: none; }
}
.masonry-sentinel {
height: 1px;
margin-top: -1px;
pointer-events: none;
}
</style>
+9 -1
View File
@@ -1,12 +1,17 @@
---
import { sourceSlug } from '../lib/sources';
interface Props {
source: string;
prefix?: string;
}
const { source, prefix = 'via' } = Astro.props;
const href = `/sources/#${sourceSlug(source)}`;
---
<span class="source-badge">{prefix} <span class="name">{source}</span></span>
<span class="source-badge">
{prefix} <a href={href} class="name">{source}</a>
</span>
<style>
.source-badge {
@@ -25,4 +30,7 @@ const { source, prefix = 'via' } = Astro.props;
letter-spacing: 0;
font-weight: 600;
}
.source-badge .name:hover {
text-decoration: underline;
}
</style>