0033 — Studio post-first reels (content-driven reel management)¶
Status: IMPLEMENTED (design agreed + signed off with Matt 2026-07-17; all four phases landed — content source, rail inversion, picker filters/relink/label, and phase-4 polish). Date: 2026-07-17 Relates: 0001 §3.2 (stateless tool / owning-project state), 0002 (interface contracts — this changes the studio contract only), 0029 §9 (4-tab IA — this reshapes the Reel-tab entry point / rail), 0028 (component decomposition). Supersedes: nothing — layers on the existing reel model.
1. The inversion¶
Today the studio's left rail is a flat list of reels, and + new mints an
arbitrary reel from a slug. But a reel exists because of a blog post — it is a
promo for that post. Creating a reel with no post is meaningless in the blog's
workflow, and the list buries the thing that actually matters (which post) behind a
slug.
The blog post is king (in the studio). The rail should be content-first: pick a post, then create / manage / duplicate reels for that post. A post may have several reels (each with its own publish schedule — future, out of scope here).
Key realisation — the link already exists. Every reel's workspace.yaml already
carries bundle: content/post/<date>-<slug>. So "a reel belongs to a post" is already
true in the data; this spec makes it the studio's organising principle, it does not
invent a new relationship. And the numbers back the premise: the blog has 204 posts
but 4 reels — most posts never get a reel, so the rail must show the handful of
posts that have reels, while a picker browses the full catalogue.
2. Decisions (locked 2026-07-17)¶
| # | Decision | Resolution |
|---|---|---|
| D1 | Scope | Studio-only. The CLI/MCP reel model is unchanged — reel new <slug> still mints arbitrary reels, bundle stays optional. Post-first is a studio UX layer. |
| D2 | Disconnected reels | Stay possible by construction (a reel with no bundle). A first-class "create a reel with no post" feature is explicitly future, not now. |
| D3 | Storage | Flat, unchanged — reels/<id>/. The UI groups by bundle. Multiple reels per post = multiple dirs sharing a bundle. Zero migration (the 4 existing reels already have bundle). |
| D4 | Reel identity | Editable label + stable id. id = the reel dir (stable); label (new workspace.yaml field) is the human name shown/edited in the studio; first reel defaults its label to the post title. |
| D5 | Content source | SSG-agnostic, config-described. No Hugo assumptions baked in — a content-layout config (match glob · format · field map · optional profile preset) so the same tool serves Hugo / Jekyll / Astro / custom. The blog runs zero-config via profile: hugo. (§4) |
3. What does NOT change¶
- The reel model,
reels/layout,workspace.yamlschema (except an additive, optionallabel), and the studio render/produce/publish pipeline. - The CLI and MCP surfaces (0002 R-* contracts) — no new flags, no
--postrequirement. - keryx's statelessness (0001 §3.2): keryx still operates on the owning project's
directory; this only adds a read of
content/frontmatter (it already readsreels/).
4. New backend — a content source¶
The one genuinely new capability: the studio must enumerate the owning project's posts and their frontmatter (for the picker, grouping, filter/sort). It must NOT assume Hugo — keryx is reusable, and a consumer may use Jekyll, Astro, Eleventy, or a custom layout (D5). So the content source is config-described, exactly like the theme / provider / publisher config already is.
- A
ContentSourceseam (mirrors the provider/publisher seam pattern), driven by a content-layout config that describes any SSG's convention:
content:
profile: hugo # optional preset → sensible defaults for match/format/map
sources: # one or many; each tagged with its name (a filter facet)
- name: post
match: "content/post/*/index.md" # glob to the entry files (bundle or flat)
format: auto # yaml | toml | json | auto (detect by fence)
map: # frontmatter → normalised fields (defaults shown)
slug: "@dir" # @dir (bundle dir) | @file (filename, Jekyll date-strip) | <key>
title: title
date: date
draft: draft
image: image
summary: gist # rich picker preview
# every other frontmatter key → the generic taxonomy map (categories, tags, series…)
matchlocates entries — handles Hugo bundles (*/index.md), flat files (_posts/*.md), nested trees (**/*.md).format: autodetects the fence — YAML---, TOML+++, JSON{.mapwith@dir/@file/<frontmatter-key>tokens covers slug-from-dir (Hugo), slug-from-filename (JekyllYYYY-MM-DD-title.md), or slug-from-field.profilepresets (hugo,jekyll, …) expand to the common defaults so the typical case is one line; every field stays overridable.-
Blog config (verified against its 204 posts, 2026-07-17): the blog uses date-prefixed bundle dirs (
content/post/2026-07-02-<slug>/index.md) with an explicitslugin frontmatter, so itsmapoverridesslug: slug(field, not@dir) andsummary: gist.bundle= the dir (matches the reel's existingbundle:). This is exactly why the mapping is config-driven, not baked in:- Normalisedcontent: profile: hugo sources: - name: post match: "content/post/*/index.md" map: { slug: slug, summary: gist }Post:slug,title,date,bundle(dir path — the reel link),section(the source name — a facet),draft,image,gist, plus the full frontmatter taxonomy as a generic map (taxonomy: {categories:[…], tags:[…], series:[…], …}) so the studio facets on whatever keys the posts carry (Q5), not a hard-coded pair. - API (studio only): -GET /content/posts→ all posts (the picker source), with the fields above. - The rail ("posts with reels") is derived server- or client-side by intersectingGET /reels(grouped bybundle) with the post set — no separate endpoint needed, thoughGET /content/posts?have_reels=trueis an option if we want it server-side. - Cost/perf: 204 posts × a small frontmatter read. Parse once, cache in memory with a cheap invalidation (mtime) — the picker should feel instant. (Open Q6.)
5. Studio UX¶
- Rail = posts-with-reels + an "Unassigned" group. Each post is one card: title ·
date · reel-count (+ taxonomy chips). Grouped/derived by
bundle. Expanding a post lists its reels (each bylabel), with create · duplicate · rename(label) · delete. Reels with nobundlesit in a trailing Unassigned group, each with a "link to a post" action (opens the picker; setsbundle, moving it under that post) — Q1. - "+ Add post" → a content picker dialog. Lists all posts from
GET /content/postswith: - Filter: free-text (title + gist); date range (before / after); and a facet per frontmatter taxonomy present (categories, tags, series, …) — Q5. Draft posts included, marked (Q2).
- Sort: date (default, newest first), title.
- A row shows title · date · gist snippet · taxonomy chips. Selecting a post adds it to the rail and drops you into creating its first reel (label defaults to the post title).
- Creating a reel (studio) now always sets
bundleto the chosen post + alabel. It reuses the existing reel-create path; the studio just suppliesbundle+labelinstead of a bare slug. - Reel id generation: derive a stable dir id from the post slug, disambiguating for
multiple reels (
<post-slug>, then<post-slug>-2, …);labelcarries the friendly name. (Open Q3.)
6. Resolved questions (Matt, 2026-07-17)¶
- Q1 — Disconnected reels → RESOLVED. Show CLI-made / no-
bundlereels in an "Unassigned" group in the rail, and provide an affordance to link an unassigned reel to a post (sets itsbundle, moving it under that post). So a disconnected reel is never lost and can be adopted into the post-first model. - Q2 — Draft posts → RESOLVED. Include them — a draft post is as valid a reel
subject as a published one. Mark
draft: truevisually, but show by default. - Q3 — Reel id scheme → RESOLVED.
<post-slug>, then-2,-3, … (legible on disk);labelcarries the friendly name. - Q5 — Filter facets → RESOLVED (broad). "The more the merrier" — surface every
frontmatter taxonomy the posts carry (categories, tags, and any others — series,
author, etc.) as filter facets, not a fixed pair. Plus a date filter with a
before/after (range) mechanism, not just equality. The
ContentSourcetherefore returns the full normalised frontmatter (a generic taxonomy map), and the picker builds facets from whatever keys are present — important given the anticipated post volume. -
Q6 — Freshness → RESOLVED. Cache the content scan (in-memory), invalidate on content-dir mtime.
-
Q4 — Content targeting → RESOLVED (D5). Not "which Hugo dir" but "how do we describe any content layout." keryx must not bake in Hugo — a consumer may use Jekyll (
_posts/YYYY-MM-DD-*.md), Astro (src/content/blog/**), or a custom tree, and Matt himself has moved SSG before and may again. Resolved to the config-described, SSG-agnostic content source in §4 (sources[]withmatch/format/map+ optionalprofilepresets). The earlier A/B (single dir vs section list) both fall out of this — a "section list" is just multiplesources. The blog is zero-config viaprofile: hugo.
7. Non-goals¶
- CLI / MCP changes (D1). Disconnected-reels as a feature (D2). Per-reel publish schedules (future — the reason a post has multiple reels, but scheduling is its own spec). Writing to the blog's content frontmatter (read-only source).
8. Phasing & status¶
- Content source (backend) — ✅ DONE (dffb610/f2fd968/6fbf59b).
internal/content(SSG-agnostic: doublestar glob, yaml/toml/json auto-detect,@dir/@file/key mapping, hugo/jekyll profiles, generic taxonomy) +GET /api/v1/content/posts+ additivelabelonworkspace.Meta. Unit + integration (204 real posts). Blog.keryx.yamlcontent config added (consumer-side). - Studio rail inversion — ✅ DONE (de50c90). Rail groups reels by post (
bundle), post cards + reel lists + "+ reel for this post", Unassigned group;ContentPickerover the posts; select → mint a reel (id from slug, label from title, bundle set). - Picker filters + relink + label — ✅ DONE (20ed437). Date range (before/after),
sort, per-taxonomy facets (Q5); Unassigned "link to a post" (relink via PUT bundle);
label editing via
PUT /workspace/{slug}/label+workspace.Relabel. - Polish — ✅ DONE. Playwright specs rewritten against the 4-tab + post-first
flow (
68c3e25:reel/scenes/preview/publish/settings/smokespecs). Picker preview: gist was already shown; added a cover thumbnail per post row — a post'simagefrontmatter resolved to the studio asset endpoint (bundle-relative →/content/asset, external URL used directly, site-root skipped), with a gracefulonerrorhide. Frontend component tests added for the rail/picker:ContentPicker.test.js(list/gist/cover-resolution/text+facet filter/onpick/close/empty) andReelRail.test.js(post grouping newest-first, Unassigned group, select, picker open).
This is the Reel-tab entry-point rework the 4-tab IA (0029 §9) opens onto: the Reel tab answers "what post is this reel for?", and now the rail does too.