Skip to content

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 unchangedreel 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, unchangedreels/<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.yaml schema (except an additive, optional label), and the studio render/produce/publish pipeline.
  • The CLI and MCP surfaces (0002 R-* contracts) — no new flags, no --post requirement.
  • 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 reads reels/).

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 ContentSource seam (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…)
  • match locates entries — handles Hugo bundles (*/index.md), flat files (_posts/*.md), nested trees (**/*.md).
  • format: auto detects the fence — YAML ---, TOML +++, JSON {.
  • map with @dir/@file/<frontmatter-key> tokens covers slug-from-dir (Hugo), slug-from-filename (Jekyll YYYY-MM-DD-title.md), or slug-from-field.
  • profile presets (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 explicit slug in frontmatter, so its map overrides slug: slug (field, not @dir) and summary: gist. bundle = the dir (matches the reel's existing bundle:). This is exactly why the mapping is config-driven, not baked in:

    content:
      profile: hugo
      sources:
        - name: post
          match: "content/post/*/index.md"
          map: { slug: slug, summary: gist }
    
    - Normalised 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 intersecting GET /reels (grouped by bundle) with the post set — no separate endpoint needed, though GET /content/posts?have_reels=true is 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 by label), with create · duplicate · rename(label) · delete. Reels with no bundle sit in a trailing Unassigned group, each with a "link to a post" action (opens the picker; sets bundle, moving it under that post) — Q1.
  • "+ Add post" → a content picker dialog. Lists all posts from GET /content/posts with:
  • 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 bundle to the chosen post + a label. It reuses the existing reel-create path; the studio just supplies bundle + label instead 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, …); label carries the friendly name. (Open Q3.)

6. Resolved questions (Matt, 2026-07-17)

  • Q1 — Disconnected reels → RESOLVED. Show CLI-made / no-bundle reels in an "Unassigned" group in the rail, and provide an affordance to link an unassigned reel to a post (sets its bundle, 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: true visually, but show by default.
  • Q3 — Reel id scheme → RESOLVED. <post-slug>, then -2, -3, … (legible on disk); label carries 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 ContentSource therefore 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[] with match/format/map + optional profile presets). The earlier A/B (single dir vs section list) both fall out of this — a "section list" is just multiple sources. The blog is zero-config via profile: 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

  1. 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 + additive label on workspace.Meta. Unit + integration (204 real posts). Blog .keryx.yaml content config added (consumer-side).
  2. Studio rail inversion — ✅ DONE (de50c90). Rail groups reels by post (bundle), post cards + reel lists + "+ reel for this post", Unassigned group; ContentPicker over the posts; select → mint a reel (id from slug, label from title, bundle set).
  3. 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.
  4. Polish — ✅ DONE. Playwright specs rewritten against the 4-tab + post-first flow (68c3e25: reel/scenes/preview/publish/settings/smoke specs). Picker preview: gist was already shown; added a cover thumbnail per post row — a post's image frontmatter resolved to the studio asset endpoint (bundle-relative → /content/asset, external URL used directly, site-root skipped), with a graceful onerror hide. Frontend component tests added for the rail/picker: ContentPicker.test.js (list/gist/cover-resolution/text+facet filter/onpick/close/empty) and ReelRail.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.