From 049c6c53775eb047ac89c5810f6bd5a2c4ad3df7 Mon Sep 17 00:00:00 2001
From: rzen
Date: Wed, 6 May 2026 20:43:22 -0400
Subject: [PATCH] site: rename "finds" superpost collection to "bundles"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Disambiguates the one-letter `find` (item) vs `finds` (roundup) split that
kept causing confusion. Audience-facing now: `/bundles//`, "Bundle"
labels in home/footer/search.
Internals: `getCollection('finds')` → `('bundles')`, `Entry.type 'finds'`
→ `'bundle'`, `OgFallback 'finds'` → `'bundles'`, `findListMosaic` →
`bundleMosaic`, `FindsPost` → `Bundle`, `findsReferencing` →
`bundlesReferencing`. Build script + generated mosaic dir follow
(`scripts/build-bundle-mosaics.mjs`, `src/generated/bundle-mosaics/`).
Skills `build-finds` → `build-bundle`, `cluster-finds` →
`cluster-bundles`. The `find` collection (individual items) is unchanged.
No URL redirects: low external-link surface area.
---
.../{build-finds => build-bundle}/SKILL.md | 44 +++---
.claude/skills/build-review/SKILL.md | 26 +++-
.../SKILL.md | 44 +++---
.claude/skills/daily-finds/SKILL.md | 6 +-
CHANGELOG.md | 1 +
README.md | 16 +-
package.json | 2 +-
...t-mosaics.mjs => build-bundle-mosaics.mjs} | 22 +--
src/components/Backlinks.astro | 6 +-
src/components/EditConfirm.astro | 8 +-
src/components/EntryGrid.astro | 147 ++++++++++++++++++
src/content.config.ts | 8 +-
.../index.mdx | 0
.../index.mdx | 0
.../2026-05-03-manufactured-delight/index.mdx | 0
.../index.mdx | 0
.../2026-05-03-pocket-carry/index.mdx | 0
.../2026-05-03-single-virtue-tools/index.mdx | 0
.../2026-05-03-six-clean-groans/index.mdx | 0
.../2026-05-03-travel-as-attention/index.mdx | 0
.../2026-05-04-a-small-atlas/index.mdx | 0
.../index.mdx | 0
.../2026-05-04-off-until-needed/index.mdx | 0
src/layouts/BaseLayout.astro | 116 +++++++++++++-
src/lib/backlinks.ts | 6 +-
.../{find-list-mosaic.ts => bundle-mosaic.ts} | 6 +-
src/lib/entries.ts | 20 +--
src/lib/seo.ts | 4 +-
src/pages/{finds => bundles}/[...slug].astro | 59 ++++---
src/pages/{finds => bundles}/index.astro | 32 ++--
src/pages/find/[...slug].astro | 87 +++--------
src/pages/find/index.astro | 14 +-
src/pages/index.astro | 40 ++---
src/pages/rss.xml.js | 16 +-
src/pages/search.astro | 8 +-
src/pages/search.json.js | 20 +--
36 files changed, 503 insertions(+), 255 deletions(-)
rename .claude/skills/{build-finds => build-bundle}/SKILL.md (52%)
rename .claude/skills/{cluster-finds => cluster-bundles}/SKILL.md (52%)
rename scripts/{build-list-mosaics.mjs => build-bundle-mosaics.mjs} (87%)
create mode 100644 src/components/EntryGrid.astro
rename src/content/{finds => bundles}/2026-05-03-backyard-small-upgrades/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-03-mac-apps-quietly-worth-it/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-03-manufactured-delight/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-03-morning-kitchen-ritual/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-03-pocket-carry/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-03-single-virtue-tools/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-03-six-clean-groans/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-03-travel-as-attention/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-04-a-small-atlas/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-04-geography-is-the-program/index.mdx (100%)
rename src/content/{finds => bundles}/2026-05-04-off-until-needed/index.mdx (100%)
rename src/lib/{find-list-mosaic.ts => bundle-mosaic.ts} (64%)
rename src/pages/{finds => bundles}/[...slug].astro (66%)
rename src/pages/{finds => bundles}/index.astro (76%)
diff --git a/.claude/skills/build-finds/SKILL.md b/.claude/skills/build-bundle/SKILL.md
similarity index 52%
rename from .claude/skills/build-finds/SKILL.md
rename to .claude/skills/build-bundle/SKILL.md
index 2530c56..712dd15 100644
--- a/.claude/skills/build-finds/SKILL.md
+++ b/.claude/skills/build-bundle/SKILL.md
@@ -1,39 +1,39 @@
---
-name: build-finds
-description: Draft a 'finds' superpost (editorial roundup) for unique.rzen.dev. Selects find items from src/content/find/, groups them under a theme, and writes a new file at src/content/finds/. Use when the user runs /build-finds, asks to "draft today's finds post", "build a roundup", or "make a catch".
+name: build-bundle
+description: Draft a 'bundle' (themed editorial roundup of finds) for unique.rzen.dev. Selects find items from src/content/find/, groups them under a theme, and writes a new file at src/content/bundles/. Use when the user runs /build-bundle, asks to "draft today's bundle", "build a roundup", "make a bundle", or "make a catch".
disable-model-invocation: true
---
-# build-finds
+# build-bundle
-Author one editorial superpost (collection: `finds`) that references existing find items. Each superpost has a theme, a short blurb, and an `items` array listing find slugs. The grid layout is rendered by the page template — this skill only writes the MDX.
+Author one editorial bundle (collection: `bundles`) that references existing find items. Each bundle has a theme, a short blurb, and an `items` array listing find slugs. The grid layout is rendered by the page template — this skill only writes the MDX.
Invoke from inside the `unique.rzen.dev` repo.
## Inputs
- `src/content/find/*.mdx` — the available pool of finds.
-- `src/content/finds/*.mdx` — existing superposts (used to detect already-referenced finds).
+- `src/content/bundles/*.mdx` — existing bundles (used to detect already-bundled finds).
- Optional argument: a theme phrase (e.g. "single-virtue tools", "things-disguised-as-other-things").
-- Optional argument: an explicit list of find slugs the user wants in the post.
+- Optional argument: an explicit list of find slugs the user wants in the bundle.
## Procedure
### 1. Determine candidate pool
-- **If the user provided explicit slugs (seed mode)** — usually the case when the user clicks the "Make a finds collection" button on `/find/`. Treat the slugs as a *seed*, not a final list.
+- **If the user provided explicit slugs (seed mode)** — usually the case when the user clicks the "Bundle this selection" button on `/find/`. Treat the slugs as a *seed*, not a final list.
1. Read each seed find's frontmatter (`tags`, `topics`, `subtitle`, `description`) and body.
2. Infer the through-line — the smallest phrase that captures what these finds have in common (a virtue, a use, a feeling, a structural pattern). Lean specific. Good: *"single-virtue tools"*, *"things that pour clean"*, *"objects that exist for no reason except someone wanted them to"*, *"vintage-leaning everyday carry"*. Bad: *"useful things"*, *"cool stuff"*.
- 3. Compute the **unreferenced pool**: every find that does NOT appear in the `items[]` array of any existing `src/content/finds/*.mdx` superpost. The seed slugs may or may not be in this pool — they're the seed regardless.
- 4. From the unreferenced pool, find every entry that fits the inferred theme. Use frontmatter signals (tags, topics, subtitle) and body content. Don't reach: three weakly-fitting items is worse than zero. If a candidate's fit feels strained, drop it.
- 5. Combine `seed + qualifying unreferenced matches`, deduped. Cap at **8 total** (per sizing notes below). If the combined list exceeds 8, drop the weakest fits — never drop a seed slug; if the seed itself is more than 8, ask the user which to drop.
+ 3. Compute the **unbundled pool**: every find that does NOT appear in the `items[]` array of any existing `src/content/bundles/*.mdx` bundle. The seed slugs may or may not be in this pool — they're the seed regardless.
+ 4. From the unbundled pool, find every entry that fits the inferred theme. Use frontmatter signals (tags, topics, subtitle) and body content. Don't reach: three weakly-fitting items is worse than zero. If a candidate's fit feels strained, drop it.
+ 5. Combine `seed + qualifying unbundled matches`, deduped. Cap at **8 total** (per sizing notes below). If the combined list exceeds 8, drop the weakest fits — never drop a seed slug; if the seed itself is more than 8, ask the user which to drop.
6. Produce one cluster (theme phrase + final items list). Continue to step 2 to confirm before writing.
- **If the user provided a theme phrase only:** scan all finds, surface the 8–12 that best fit the theme.
-- **If neither:** scan recent (last ~14 days) finds that are not yet referenced by any existing superpost. From the unreferenced pool, propose 1–3 thematic clusters of 5–10 finds each, with a candidate theme phrase per cluster.
+- **If neither:** scan recent (last ~14 days) finds that are not yet referenced by any existing bundle. From the unbundled pool, propose 1–3 thematic clusters of 5–10 finds each, with a candidate theme phrase per cluster.
-Re-use is allowed — a find can appear in multiple superposts under different themes. But by default, prefer surfacing **unreferenced** finds first; only reach for re-uses when the theme genuinely calls for them.
+Re-use is allowed — a find can appear in multiple bundles under different themes. But by default, prefer surfacing **unbundled** finds first; only reach for re-uses when the theme genuinely calls for them.
### 2. Confirm the cluster
@@ -48,7 +48,7 @@ Seed (your selection):
- :
- ...
-Expansion (unreferenced finds matching the theme):
+Expansion (unbundled finds matching the theme):
- :
- ...
@@ -69,9 +69,9 @@ Cluster B — ""
Wait for the user to confirm, refine the theme phrase, or drop/swap items. Don't write anything until confirmed.
-### 3. Write the superpost
+### 3. Write the bundle
-File: `src/content/finds/-.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max).
+File: `src/content/bundles/-/index.mdx` (theme-slug = kebab-case of the theme phrase, lowercase, ~30 chars max).
Frontmatter:
@@ -81,7 +81,7 @@ title: ""
date: YYYY-MM-DD
tags: [daily-finds, , ]
description: ""
-blurb: ""
+blurb: ""
items:
-
-
@@ -99,15 +99,15 @@ Example body:
### 4. Verify
-- Confirm every slug in `items[]` exists at `src/content/find/.mdx`. If any is missing, abort and report — never reference a non-existent find.
-- Run `npm run build` to confirm the new superpost compiles.
+- Confirm every slug in `items[]` exists at `src/content/find//index.mdx`. If any is missing, abort and report — never reference a non-existent find.
+- Run `npm run build` to confirm the new bundle compiles.
### 5. Update the log
Append to `daily-finds.log.md`:
```
-## YYYY-MM-DD — superpost:
+## YYYY-MM-DD — bundle:
Slug: -
Items referenced:
@@ -118,7 +118,7 @@ Items referenced:
## Notes
-- Default to 5 finds per superpost. Use 3 for tighter themes, up to 8 if the theme really supports it. Beyond 8 the grid stops being scannable.
+- Default to 5 finds per bundle. Use 3 for tighter themes, up to 8 if the theme really supports it. Beyond 8 the grid stops being scannable.
- The body is for the audience, not the user. Never write "pick one", "candidates", "today's pick" — those are internal terms.
-- Never edit existing find files when building a superpost. The find body and metadata are stable; the superpost only references them.
-- If a find that's referenced has been graduated to a review (its frontmatter has `promotedTo`), it can still appear in the superpost — the grid card simply links to the find page, which itself links forward to the review.
+- Never edit existing find files when building a bundle. The find body and metadata are stable; the bundle only references them.
+- If a find that's referenced has been graduated to a review (its frontmatter has `promotedTo`), it can still appear in the bundle — the grid card simply links to the find page, which itself links forward to the review.
diff --git a/.claude/skills/build-review/SKILL.md b/.claude/skills/build-review/SKILL.md
index dcaf5f8..db9c3bc 100644
--- a/.claude/skills/build-review/SKILL.md
+++ b/.claude/skills/build-review/SKILL.md
@@ -72,14 +72,34 @@ Edit `src/content/find/.mdx` frontmatter: add `promotedTo: `.
(Slug usually matches the find's slug, so `promotedTo` and the find's filename match.)
-### 6. Verify
+### 6. Register the hero in `src/lib/hero.ts` (REQUIRED if a hero asset exists)
+
+This is the step that has bitten us repeatedly. **The home grid and category cards do NOT auto-discover review hero assets** — they only show a hero if the review's slug is a key in the `heroes` record exported by `src/lib/hero.ts`. Skipping this step ships a review whose grid card is empty.
+
+For a review with a static image hero (`hero.png`, `hero.jpg`, `hero.webp`):
+
+1. Add an `import` line near the top, alphabetically grouped with the other image imports. The variable name is the slug in camelCase. Example for slug `tablepro`:
+ ```ts
+ import tablepro from '../content/reviews/tablepro/hero.png';
+ ```
+2. Add an entry to the `heroes` record, alphabetically by key:
+ ```ts
+ tablepro: { kind: 'image', src: tablepro },
+ ```
+
+For a review with a video hero (`.mp4`), put the import in the second (video) import group and the record entry uses `kind: 'video'`. Match the existing pattern.
+
+If the review legitimately has no hero asset (rare — e.g. text-only reviews like `snewpapers-newspaper-archive`, `sindre-sorhus-older-mac-apps`), explicitly note it in the log entry from step 8 ("no hero asset — text-only review") so the omission is intentional and traceable.
+
+### 7. Verify
- Run `npm run build`. Confirm:
- `/reviews//` exists, shows the source badge and "← First surfaced…" backlink to the find page.
- `/find//` shows the "→ Read the full review" callout pointing to the review.
- - Any superpost that references the find still renders fine; the find card on the grid remains.
+ - Any bundle that references the find still renders fine; the find card on the grid remains.
+ - Visit `/grid/` (or `/`) and confirm the new review's tile renders with its hero — not blank. **If the tile is empty, step 6 was skipped.**
-### 7. Update the log
+### 8. Update the log
Append to `daily-finds.log.md`:
diff --git a/.claude/skills/cluster-finds/SKILL.md b/.claude/skills/cluster-bundles/SKILL.md
similarity index 52%
rename from .claude/skills/cluster-finds/SKILL.md
rename to .claude/skills/cluster-bundles/SKILL.md
index 425be46..4440e22 100644
--- a/.claude/skills/cluster-finds/SKILL.md
+++ b/.claude/skills/cluster-bundles/SKILL.md
@@ -1,19 +1,19 @@
---
-name: cluster-finds
-description: Batch-cluster every existing find item in src/content/find/ into 4-7 thematic 'finds' superposts. Use when the user wants to bring the find pool up to date all at once — ("re-cluster the finds", "build superposts for everything", "spread the new finds across themed posts"). For drafting one superpost from a single theme, use build-finds instead.
+name: cluster-bundles
+description: Batch-cluster every existing find item in src/content/find/ into 4-7 thematic bundles. Use when the user wants to bring the find pool up to date all at once — ("re-cluster the finds", "build bundles for everything", "spread the new finds across themed bundles"). For drafting one bundle from a single theme, use build-bundle instead.
disable-model-invocation: true
---
-# cluster-finds
+# cluster-bundles
-Bulk version of `build-finds`. Reads the entire `src/content/find/` pool, partitions it into themed clusters, and writes one `src/content/finds/-.mdx` superpost per cluster. The audience-facing site treats `finds` as the primary discovery vehicle for individual `find` items, so the goal is *coverage*: by the end of the run, almost every find that deserves a home should have one.
+Bulk version of `build-bundle`. Reads the entire `src/content/find/` pool, partitions it into themed clusters, and writes one `src/content/bundles/-/index.mdx` bundle per cluster. The audience-facing site treats `bundles` as the primary discovery vehicle for individual `find` items, so the goal is *coverage*: by the end of the run, almost every find that deserves a home should have one.
Invoke from inside the `unique.rzen.dev` repo.
## Inputs
- `src/content/find/*.mdx` — the full pool to cluster.
-- `src/content/finds/*.mdx` — already-written superposts. Their `items[]` lists are the prior clustering — read these to know which finds already have a home and what themes have been used.
+- `src/content/bundles/*.mdx` — already-written bundles. Their `items[]` lists are the prior clustering — read these to know which finds already have a home and what themes have been used.
- Optional argument: a number of clusters to target (default: model's call, usually 4-7).
- Optional argument: a list of theme phrases to seed clustering around.
@@ -23,7 +23,7 @@ Invoke from inside the `unique.rzen.dev` repo.
Read the frontmatter and body of every find. Note the `name`, `subtitle`, `topics`, `tags`, and the body's stated *virtue* (the specific thing that makes the item delightful). Tags and topics are hints, not the answer — the body usually tells you what cluster a find actually belongs to.
-Also read every existing finds file's `items[]` array — these are pre-clustered and should not be re-clustered into a new redundant theme. They can still be *re-used* in a new cluster if the new theme genuinely calls for them (see overlap rules).
+Also read every existing bundle's `items[]` array — these are pre-clustered and should not be re-clustered into a new redundant theme. They can still be *re-used* in a new cluster if the new theme genuinely calls for them (see overlap rules).
### 2. Partition into clusters
@@ -31,13 +31,13 @@ Aim for 4-7 clusters. Each cluster should:
- Hang off **one specific editorial angle**, phrasable in 4-8 words ("Pocket carry", "The morning kitchen ritual", "Travel as a kind of attention"). Avoid generic buckets ("Tools", "Apps") — those are tag pages.
- Hold **3-8 items**. Below 3 the grid feels thin; above 8 it stops being scannable.
-- Be **distinct from existing finds posts** in angle, even if a couple of items repeat.
+- Be **distinct from existing bundles** in angle, even if a couple of items repeat.
Coverage check: after partitioning, every find should appear in at least one cluster (existing or new). If a find genuinely fits no theme, leave it uncovered and report it — don't pad a cluster with a misfit.
### 3. Overlap rules
-Re-use is allowed but rationed. Set a soft budget: at most ~20% of items across all new posts may be repeats from existing posts or from other new posts in this same run. Concretely, for a 25-item pool, that's ~5 reuses total. Track repeats as you draft.
+Re-use is allowed but rationed. Set a soft budget: at most ~20% of items across all new bundles may be repeats from existing bundles or from other new bundles in this same run. Concretely, for a 25-item pool, that's ~5 reuses total. Track repeats as you draft.
When choosing which items to repeat: only repeat a find when the second theme genuinely illuminates a different facet of it. "It also kind of fits over here" is not enough.
@@ -52,15 +52,15 @@ Cluster A — "" (N items)
Cluster B — "" (N items)
...
-Overlap with existing posts: appears in + ; ...
+Overlap with existing bundles: appears in + ; ...
Uncovered finds:
```
Wait for the user to confirm, refine, swap, or merge clusters. Do not write files until they sign off.
-### 5. Write each superpost
+### 5. Write each bundle
-Same file format as `build-finds`. For each cluster, write `src/content/finds/-.mdx`:
+Same file format as `build-bundle`. For each cluster, write `src/content/bundles/-/index.mdx`:
```yaml
---
@@ -75,19 +75,19 @@ items:
- ...
---
->
+>
```
-Body rules (same as `build-finds`):
+Body rules (same as `build-bundle`):
- 1-2 sentence intro paragraph in a blockquote. No headings, no list items, no numbered candidates — the grid handles the items.
- Audience-facing language. Never write "today's pick", "candidates", "winners".
-- Vary the opening across the batch. With 4-7 posts going out the same day, repeating "Today's catch" five times feels mechanical.
+- Vary the opening across the batch. With 4-7 bundles going out the same day, repeating "Today's catch" five times feels mechanical.
### 6. Verify
-- Every slug in every `items[]` must exist at `src/content/find/.mdx`. Confirm before declaring done.
-- Run `npm run build`. If any superpost fails to compile, fix and re-run.
+- Every slug in every `items[]` must exist at `src/content/find//index.mdx`. Confirm before declaring done.
+- Run `npm run build`. If any bundle fails to compile, fix and re-run.
### 7. Update the log
@@ -96,11 +96,11 @@ Append to `daily-finds.log.md`:
```
## YYYY-MM-DD — cluster run
-Wrote superposts from a pool of finds.
-Reuses across posts: (budget: ~20%).
+Wrote bundles from a pool of finds.
+Reuses across bundles: (budget: ~20%).
Uncovered finds: .
-Posts created:
+Bundles created:
- -: (N items)
- -: (N items)
- ...
@@ -108,7 +108,7 @@ Posts created:
## Notes
-- This skill complements, doesn't replace, `build-finds`. After a cluster run, `build-finds` is still the right tool for ad-hoc, single-theme posts on top of new captures.
+- This skill complements, doesn't replace, `build-bundle`. After a cluster run, `build-bundle` is still the right tool for ad-hoc, single-theme bundles on top of new captures.
- If two themes you're considering have heavy overlap (≥50% same items), they're really one theme — merge them and pick the stronger framing.
-- Date all posts with the run date, not staggered — these are siblings written together, and the home feed will sort them naturally by `title`.
-- Don't edit existing finds posts when running this. If an existing post overlaps with a new cluster you're proposing, either keep the new cluster (overlap-tolerated) or drop it from the plan; do not retroactively rewrite history.
+- Date all bundles with the run date, not staggered — these are siblings written together, and the home feed will sort them naturally by `title`.
+- Don't edit existing bundles when running this. If an existing bundle overlaps with a new cluster you're proposing, either keep the new cluster (overlap-tolerated) or drop it from the plan; do not retroactively rewrite history.
diff --git a/.claude/skills/daily-finds/SKILL.md b/.claude/skills/daily-finds/SKILL.md
index 4de7583..1932719 100644
--- a/.claude/skills/daily-finds/SKILL.md
+++ b/.claude/skills/daily-finds/SKILL.md
@@ -1,12 +1,12 @@
---
name: daily-finds
-description: Capture-only daily sweep for unique.rzen.dev. Scans every source in sources.json, writes one find file per qualifying item to src/content/find/, and nominates any new sources cited in the items it reads (plus optionally via WebSearch). Does NOT pick a winner, write a superpost, or address the user/audience. Picking happens via /build-finds and /build-review. Use when the user asks for "today's finds", "morning sweep", "capture", or runs /daily-finds.
+description: Capture-only daily sweep for unique.rzen.dev. Scans every source in sources.json, writes one find file per qualifying item to src/content/find/, and nominates any new sources cited in the items it reads (plus optionally via WebSearch). Does NOT pick a winner, write a bundle, or address the user/audience. Picking happens via /build-bundle and /build-review. Use when the user asks for "today's finds", "morning sweep", "capture", or runs /daily-finds.
disable-model-invocation: true
---
# daily-finds
-Pure capture: every item that passes the bar becomes a `find` file. No daily cap, no theme detection, no audience-facing post. Whether any of these finds appears in a `finds` superpost or graduates to a review is a separate editorial decision.
+Pure capture: every item that passes the bar becomes a `find` file. No daily cap, no theme detection, no audience-facing post. Whether any of these finds appears in a `bundle` or graduates to a review is a separate editorial decision.
Invoke from inside the `unique.rzen.dev` repo.
@@ -108,7 +108,7 @@ Short summary in chat:
```
Captured finds across productive sources today.
Blocked: sources. New source nominees: .
-Run /build-finds to draft a superpost from the recent pool, or /build-review to graduate one to a review.
+Run /build-bundle to draft a bundle from the recent pool, or /build-review to graduate one to a review.
```
That's it. No theme, no candidates list, no "pick a winner."
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 755b534..f64bb84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## May 2026
+- Rename the "finds" superpost concept to **bundle**. The `finds` collection becomes `bundles` (`src/content/bundles/`), URLs move from `/finds//` to `/bundles//`, and the home/footer/search labels switch from "List" to "Bundle". Internal renames: `findListMosaic` → `bundleMosaic`, `FindsPost` → `Bundle`, `findsReferencing` → `bundlesReferencing`, `Entry.type 'finds'` → `'bundle'`, `OgFallback 'finds'` → `'bundles'`. Build script and generated mosaic dir follow (`scripts/build-bundle-mosaics.mjs`, `src/generated/bundle-mosaics/`). Skills `build-finds` and `cluster-finds` renamed to `build-bundle` and `cluster-bundles`. The `find` collection (individual items) is unchanged
- Add Cloudflare Web Analytics: privacy-preserving, cookieless beacon injected into `BaseLayout.astro` so per-page traffic, referrers, and Core Web Vitals show up in the Cloudflare dashboard scoped to `unique.rzen.dev`. Privacy statement on `/legal/` updated to disclose the beacon
- Strengthen the "Use of the site" section on `/legal/`: explicit "inclusion isn't endorsement" + no warranty on safety/quality/durability/fit/continued-existence + no responsibility for what happens after click-through. Tone kept plain, not legalese
- Colocate every collection's articles with their media: each entry is now a folder `/index.mdx` with sibling media files. Applied to `reviews/`, `find/` (104 articles + 89 heroes auto-resolved by `src/lib/find-hero.ts` via `import.meta.glob('../content/find/*/hero.{...}')`), `finds/`, and `posts/`. Slug derivation handled by a shared `stripIndex` helper on each glob loader; URLs unchanged across the entire site. MDX paths shortened from `../../assets/reviews//` to `./`. `src/assets/` removed entirely
diff --git a/README.md b/README.md
index 3abc955..99898ec 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,14 @@ Built with [Astro](https://astro.build) + MDX.
## Key features
-- **Four-collection pipeline**: `find` (captured items, hidden from indexes), `finds` (editorial superposts that reference find slugs in a grid), `reviews` (full-length, sometimes graduated from a find), `posts` (generic essays)
-- **Daily capture skills** under `.claude/skills/`: `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item — capture-only, no editorial selection); `build-finds` (drafts a thematic superpost from the find pool); `build-review` (graduates a find to a full review, linking the two via `fromFind` / `promotedTo`)
+- **Four-collection pipeline**: `find` (captured items, hidden from indexes), `bundles` (editorial roundups that reference find slugs in a grid), `reviews` (full-length, sometimes graduated from a find), `posts` (generic essays)
+- **Daily capture skills** under `.claude/skills/`: `daily-finds` (sweeps every source in `sources.json`, writes one find file per qualifying item — capture-only, no editorial selection); `build-bundle` (drafts a thematic bundle from the find pool); `cluster-bundles` (batch-clusters the entire find pool into multiple bundles); `build-review` (graduates a find to a full review, linking the two via `fromFind` / `promotedTo`)
- **Sources catalog** at `sources.json` with per-source fetch strategy (`webfetch` / `curl` / `skip`) and the `/sources` page rendering a 3-column card stream with a "Recent finds" sub-list per card
-- **Strict find visibility**: find items are reachable only via the superposts that reference them, the `/sources` cross-reference, or direct URL — they don't crowd the home feed, tag pages, or category pages
-- **Backlinks** computed at build time: each find page lists every superpost that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find
+- **Strict find visibility**: find items are reachable only via the bundles that reference them, the `/sources` cross-reference, or direct URL — they don't crowd the home feed, tag pages, or category pages
+- **Backlinks** computed at build time: each find page lists every bundle that references it; promoted finds show a forward link to the review; reviews show a "← First surfaced…" backlink to their originating find
- **Newspaper-style masthead** on every page: live entry-count + last-updated stats on the left, large serif "Unique" wordmark centered between thin rules, About/Blog nav on the right; per-page italic subheader ("A small log of delightful things.") with last-updated date on listing pages
- **Pinterest-style home page**: hero card for the latest review plus a 3-column masonry grid of remaining reviews with a "Load more" reveal; old plain-list home preserved at `/index3/`
-- **Unified chronological feed** at `/index3/` across reviews + finds (superposts) + posts
+- **Unified chronological feed** at `/index3/` across reviews + bundles + posts
- **SEO-friendly site footer** with Browse / Collections (live counts) / About link columns and a centered legal line; consistent 76rem page width across the entire site (article prose constrained to a comfortable reading measure inside the wider layout)
- Reviews render as two-line index entries: bold name + muted subtitle (`Cotypist` / *Autocomplete that lives everywhere on your Mac*)
- Auto-generated `/categories//` and `/tags//` listing pages — finds excluded by design
@@ -113,13 +113,13 @@ description: "Optional tag override."
Body in markdown / MDX.
```
-The folder name becomes the URL at `/posts//`. Drop any media (images, screenshots) next to `index.mdx` and reference them as `./image.png`. Same convention applies to the `find/` and `finds/` collections — each entry is a folder with `index.mdx` and any sibling media.
+The folder name becomes the URL at `/posts//`. Drop any media (images, screenshots) next to `index.mdx` and reference them as `./image.png`. Same convention applies to the `find/` and `bundles/` collections — each entry is a folder with `index.mdx` and any sibling media.
## Project layout
```
src/
- content.config.ts # collection schemas (reviews, posts, find, finds)
+ content.config.ts # collection schemas (reviews, posts, find, bundles)
content/
reviews//
index.mdx # article + frontmatter
@@ -128,7 +128,7 @@ src/
find//
index.mdx
hero.{jpg,png,gif,webp} # auto-resolved by src/lib/find-hero.ts
- finds//index.mdx # editorial superposts (text-only today)
+ bundles//index.mdx # editorial roundups (text intro + grid)
posts//index.mdx # essays
lib/
entries.ts # getAllEntries() — unified feed across collections
diff --git a/package.json b/package.json
index 15fc400..46d4cb1 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "build:mosaics": "node scripts/build-list-mosaics.mjs",
+ "build:mosaics": "node scripts/build-bundle-mosaics.mjs",
"predev": "npm run build:mosaics",
"prestart": "npm run build:mosaics",
"prebuild": "npm run build:mosaics",
diff --git a/scripts/build-list-mosaics.mjs b/scripts/build-bundle-mosaics.mjs
similarity index 87%
rename from scripts/build-list-mosaics.mjs
rename to scripts/build-bundle-mosaics.mjs
index ac81299..de26690 100644
--- a/scripts/build-list-mosaics.mjs
+++ b/scripts/build-bundle-mosaics.mjs
@@ -1,5 +1,5 @@
-// Build-time generator: for each list in src/content/finds, compose a
-// mosaic JPG from the item heroes available in that list. Skips work
+// Build-time generator: for each bundle in src/content/bundles, compose a
+// mosaic JPG from the item heroes available in that bundle. Skips work
// when output is newer than every input. Layout depends on hero count:
// 0 -> no mosaic (file is removed if it exists)
// 1 -> 1200x1200 single image
@@ -15,9 +15,9 @@ import sharp from 'sharp';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
-const FINDS_DIR = join(ROOT, 'src/content/finds');
+const BUNDLES_DIR = join(ROOT, 'src/content/bundles');
const FIND_DIR = join(ROOT, 'src/content/find');
-const OUT_DIR = join(ROOT, 'src/generated/list-mosaics');
+const OUT_DIR = join(ROOT, 'src/generated/bundle-mosaics');
const TILE = 598;
const GAP = 4;
@@ -122,7 +122,7 @@ async function tryUnlink(path) {
async function main() {
await mkdir(OUT_DIR, { recursive: true });
- const listDirs = (await readdir(FINDS_DIR, { withFileTypes: true }))
+ const bundleDirs = (await readdir(BUNDLES_DIR, { withFileTypes: true }))
.filter((d) => d.isDirectory())
.map((d) => d.name);
@@ -131,13 +131,13 @@ async function main() {
let removed = 0;
let absent = 0;
- for (const listSlug of listDirs) {
- const mdxPath = join(FINDS_DIR, listSlug, 'index.mdx');
- const mdPath = join(FINDS_DIR, listSlug, 'index.md');
+ for (const bundleSlug of bundleDirs) {
+ const mdxPath = join(BUNDLES_DIR, bundleSlug, 'index.mdx');
+ const mdPath = join(BUNDLES_DIR, bundleSlug, 'index.md');
const sourcePath = existsSync(mdxPath) ? mdxPath : existsSync(mdPath) ? mdPath : null;
if (!sourcePath) continue;
- const outPath = join(OUT_DIR, `${listSlug}.jpg`);
+ const outPath = join(OUT_DIR, `${bundleSlug}.jpg`);
const fm = await readFrontmatter(sourcePath);
const items = parseItems(fm);
if (items.length === 0) {
@@ -165,10 +165,10 @@ async function main() {
await buildMosaic(heroPaths, outPath);
built++;
- console.log(` built ${listSlug}.jpg (${heroPaths.length}-up)`);
+ console.log(` built ${bundleSlug}.jpg (${heroPaths.length}-up)`);
}
console.log(
- `list mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)`
+ `bundle mosaics: ${built} built, ${skipped} up-to-date, ${removed} removed, ${absent} skipped (no heroes)`
);
}
diff --git a/src/components/Backlinks.astro b/src/components/Backlinks.astro
index 001cb7a..568a5e1 100644
--- a/src/components/Backlinks.astro
+++ b/src/components/Backlinks.astro
@@ -1,8 +1,8 @@
---
-import type { FindsPost } from '../lib/backlinks';
+import type { Bundle } from '../lib/backlinks';
interface Props {
- posts: FindsPost[];
+ posts: Bundle[];
label?: string;
}
const { posts, label = 'Featured in' } = Astro.props;
@@ -15,7 +15,7 @@ const { posts, label = 'Featured in' } = Astro.props;
{posts.map((p, i) => (
<>
{i > 0 && ·}
- {p.data.title}
+ {p.data.title}
>
))}
diff --git a/src/components/EditConfirm.astro b/src/components/EditConfirm.astro
index fcf558a..f205d69 100644
--- a/src/components/EditConfirm.astro
+++ b/src/components/EditConfirm.astro
@@ -135,7 +135,7 @@
let command = btn.dataset.command || '';
let description = btn.dataset.confirm || '';
- if (btn.dataset.editAction === 'finds-collection') {
+ if (btn.dataset.editAction === 'bundle-from-selection') {
const checked = Array.from(
document.querySelectorAll('[data-find-checkbox]:checked')
);
@@ -144,10 +144,10 @@
return;
}
const slugs = checked.map((cb) => cb.dataset.slug).filter(Boolean).join(',');
- command = `/build-finds ${slugs}`;
- description = `Build a finds collection from ${checked.length} selected find${
+ command = `/build-bundle ${slugs}`;
+ description = `Bundle ${checked.length} selected find${
checked.length === 1 ? '' : 's'
- }.`;
+ } under one theme.`;
}
if (btn.dataset.editAction === 'tag-prompt') {
diff --git a/src/components/EntryGrid.astro b/src/components/EntryGrid.astro
new file mode 100644
index 0000000..8b86ee5
--- /dev/null
+++ b/src/components/EntryGrid.astro
@@ -0,0 +1,147 @@
+---
+import { Image } from 'astro:assets';
+import type { ImageMetadata } from 'astro';
+import Masonry from './Masonry.astro';
+import type { Entry } from '../lib/entries';
+import { heroes } from '../lib/hero';
+import { bundleMosaic } from '../lib/bundle-mosaic';
+
+interface Props {
+ entries: Entry[];
+}
+
+const { entries } = Astro.props;
+
+type Visual =
+ | { kind: 'image'; src: ImageMetadata }
+ | { kind: 'video'; src: string };
+
+function visualFor(entry: Entry): Visual | undefined {
+ if (entry.type === 'review') return heroes[entry.id];
+ if (entry.type === 'bundle') {
+ const mosaic = bundleMosaic(entry.id);
+ return mosaic ? { kind: 'image', src: mosaic } : undefined;
+ }
+ return undefined;
+}
+---
+
+
+ {
+ entries.map((entry) => {
+ const visual = visualFor(entry);
+ const isBundle = entry.type === 'bundle';
+ return (
+
+
+ {visual?.kind === 'image' && (
+
+ )}
+ {visual?.kind === 'video' && (
+
+ )}
+
+ {isBundle && Bundle}
+ {entry.primary}
+ {entry.secondary && {entry.secondary}}
+
+
+
+ );
+ })
+ }
+
+
+
diff --git a/src/content.config.ts b/src/content.config.ts
index 5c32f60..8b9766b 100644
--- a/src/content.config.ts
+++ b/src/content.config.ts
@@ -60,16 +60,16 @@ const find = defineCollection({
}),
});
-const finds = defineCollection({
+const bundles = defineCollection({
loader: glob({
pattern: '**/index.mdx',
- base: './src/content/finds',
+ base: './src/content/bundles',
generateId: stripIndex,
}),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
- category: z.string().default('finds'),
+ category: z.string().default('bundles'),
tags: z.array(z.string()).default([]),
description: z.string().optional(),
blurb: z.string().optional(),
@@ -77,4 +77,4 @@ const finds = defineCollection({
}),
});
-export const collections = { reviews, posts, find, finds };
+export const collections = { reviews, posts, find, bundles };
diff --git a/src/content/finds/2026-05-03-backyard-small-upgrades/index.mdx b/src/content/bundles/2026-05-03-backyard-small-upgrades/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-backyard-small-upgrades/index.mdx
rename to src/content/bundles/2026-05-03-backyard-small-upgrades/index.mdx
diff --git a/src/content/finds/2026-05-03-mac-apps-quietly-worth-it/index.mdx b/src/content/bundles/2026-05-03-mac-apps-quietly-worth-it/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-mac-apps-quietly-worth-it/index.mdx
rename to src/content/bundles/2026-05-03-mac-apps-quietly-worth-it/index.mdx
diff --git a/src/content/finds/2026-05-03-manufactured-delight/index.mdx b/src/content/bundles/2026-05-03-manufactured-delight/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-manufactured-delight/index.mdx
rename to src/content/bundles/2026-05-03-manufactured-delight/index.mdx
diff --git a/src/content/finds/2026-05-03-morning-kitchen-ritual/index.mdx b/src/content/bundles/2026-05-03-morning-kitchen-ritual/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-morning-kitchen-ritual/index.mdx
rename to src/content/bundles/2026-05-03-morning-kitchen-ritual/index.mdx
diff --git a/src/content/finds/2026-05-03-pocket-carry/index.mdx b/src/content/bundles/2026-05-03-pocket-carry/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-pocket-carry/index.mdx
rename to src/content/bundles/2026-05-03-pocket-carry/index.mdx
diff --git a/src/content/finds/2026-05-03-single-virtue-tools/index.mdx b/src/content/bundles/2026-05-03-single-virtue-tools/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-single-virtue-tools/index.mdx
rename to src/content/bundles/2026-05-03-single-virtue-tools/index.mdx
diff --git a/src/content/finds/2026-05-03-six-clean-groans/index.mdx b/src/content/bundles/2026-05-03-six-clean-groans/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-six-clean-groans/index.mdx
rename to src/content/bundles/2026-05-03-six-clean-groans/index.mdx
diff --git a/src/content/finds/2026-05-03-travel-as-attention/index.mdx b/src/content/bundles/2026-05-03-travel-as-attention/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-03-travel-as-attention/index.mdx
rename to src/content/bundles/2026-05-03-travel-as-attention/index.mdx
diff --git a/src/content/finds/2026-05-04-a-small-atlas/index.mdx b/src/content/bundles/2026-05-04-a-small-atlas/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-04-a-small-atlas/index.mdx
rename to src/content/bundles/2026-05-04-a-small-atlas/index.mdx
diff --git a/src/content/finds/2026-05-04-geography-is-the-program/index.mdx b/src/content/bundles/2026-05-04-geography-is-the-program/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-04-geography-is-the-program/index.mdx
rename to src/content/bundles/2026-05-04-geography-is-the-program/index.mdx
diff --git a/src/content/finds/2026-05-04-off-until-needed/index.mdx b/src/content/bundles/2026-05-04-off-until-needed/index.mdx
similarity index 100%
rename from src/content/finds/2026-05-04-off-until-needed/index.mdx
rename to src/content/bundles/2026-05-04-off-until-needed/index.mdx
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 6c8886d..2294571 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -137,7 +137,7 @@ const year = new Date().getFullYear();
@@ -417,6 +417,120 @@ const year = new Date().getFullYear();
font-style: italic;
}
hr { border: 0; border-top: 1px solid var(--rule); margin: 2rem 0; }
+
+ /* External CTA — primary "go to source" link on /reviews/ and /find/.
+ Sits in the body region below the write-up, not in the colophon. */
+ .external {
+ margin: 1.5rem 0 0;
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+ .external a {
+ display: inline-block;
+ padding: 0.5rem 1rem;
+ background: var(--accent);
+ color: var(--bg);
+ border-radius: 6px;
+ font-weight: 600;
+ font-size: 0.95rem;
+ }
+ .external a:hover { text-decoration: none; opacity: 0.9; }
+ .external .aff-badge {
+ display: inline-block;
+ margin-left: 0.6rem;
+ padding: 0.15rem 0.5rem;
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--muted);
+ background: var(--rule);
+ border-radius: 999px;
+ vertical-align: middle;
+ }
+ .aff-disclosure {
+ margin: 0.6rem 0 0;
+ font-size: 0.78rem;
+ color: var(--muted);
+ font-style: italic;
+ }
+
+ /* Colophon — understated meta block at the foot of a post.
+ Used on /reviews/, /find/, /bundles/ pages. */
+ .colophon {
+ margin: 3rem 0 0;
+ padding-top: 1.5rem;
+ border-top: 1px solid var(--rule);
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Helvetica Neue', sans-serif;
+ font-size: 0.8rem;
+ line-height: 1.5;
+ color: var(--muted);
+ }
+ .colophon dl {
+ margin: 0;
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 0.55rem 1.25rem;
+ align-items: baseline;
+ }
+ .colophon dt {
+ margin: 0;
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ line-height: 1.5;
+ color: var(--muted);
+ }
+ .colophon dd {
+ margin: 0;
+ color: var(--muted);
+ line-height: 1.5;
+ }
+ .colophon a {
+ color: var(--muted);
+ border-bottom: 1px solid var(--rule);
+ }
+ .colophon a:hover {
+ color: var(--accent);
+ border-bottom-color: currentColor;
+ text-decoration: none;
+ }
+ .colophon .colophon-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem 0.75rem;
+ }
+ .colophon .colophon-tags a { border-bottom: 0; }
+ .colophon time { color: var(--muted); }
+ .colophon .aff-badge {
+ display: inline-block;
+ margin-left: 0.45rem;
+ padding: 0.1rem 0.4rem;
+ font-size: 0.62rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--muted);
+ background: var(--rule);
+ border-radius: 999px;
+ vertical-align: middle;
+ }
+ .colophon-aff {
+ margin: 1rem 0 0;
+ padding-top: 0.85rem;
+ border-top: 1px dashed var(--rule);
+ font-size: 0.72rem;
+ font-style: italic;
+ color: var(--muted);
+ }
+ @media (max-width: 520px) {
+ .colophon dl {
+ grid-template-columns: 1fr;
+ gap: 0.15rem 0;
+ }
+ .colophon dt { margin-top: 0.6rem; }
+ .colophon dt:first-child { margin-top: 0; }
+ }
{import.meta.env.DEV && }