American Graffiti — Tags as a Content Block (master)
23 JUL AT 10:51 PM

American Graffiti — Tags as a Content Block (master)

0 LOVES 2 VIEWS
Program master — tags became a first-class content block, rendered across web, API, and the app, with the legacy Post-to-Tags edge retired. Shipped in full across 8 phases; closed out 2026-07-22.

Where were you in '62? Tags were hanging off posts through an invisible Post → Tags edge — picked in a form sidebar, stored in a join table, rendered nowhere. You could tag a post and no reader would ever see it.

American Graffiti made tags a first-class content block. A tag block lives in the post body like any other block — ordered, reorderable, hideable — and owns its tags outright. Chips render on the web post, roll up into the cover CEWL, ship in the api/v1 JSON, and render in the Flutter app at full parity. Every chip links to /tags/{slug}, which lists every post carrying that tag.

The legacy edge is gone: existing tags were migrated on dogfood (21 posts → 21 blocks / 138 items, VERIFY OK) and the tag_posts table dropped. The tag block is now the sole owner of post↔tag. Shipped in full across the 8 phases below; closed out 2026-07-22.

The model — block owns its tags

A Tag was already a reusable, site-scoped taxonomy node — unlike weblink, contact, event, and location leaf records, which only exist to be collected. So tags did not get a collection "library" layer. Instead, the tag content block owns its tags directly through a small ordered join, TagBlockItem: (block, tag, sort_order), unique per (block, tag).

That one decision (2026-06-09) collapsed the roadmap and erased a whole class of migration clutter: no TagCollection entity, no Use-Existing/Copy modal, no usage pages, no library CRUD. The one capability deliberately not built is a named tag set shared across many posts — additive later if a real need ever appears.

The block does the heavy lifting: the White Rabbit picker, lifted verbatim into a block-scoped modal, picks existing tags only; selected pills drag-reorder; the block's heading field carries the display label ("Topics", "Tags"). Tag identity — name, slug, group — is edited only in the Tags admin. The block is pick-only.

Goals & end-state

  1. Tags become a first-class content block — the tag kind joins text/media/contact/weblink/event/location as a PostContentBlock type, with all block infrastructure: reorder, hide, sort order, position.
  2. Tags render everywhere a post renders — the in-body chips and the cover CEWL rollup, across kontent (web), the api/v1 read path, and the Flutter app, at parity.
  3. The legacy Post → Tags edge is removed — the tag block, via its TagBlockItems, is the sole owner of post↔tag.
  4. Tags become browsable/tags/{slug} lists every post carrying the tag, reading through the block chain tag → block_items → block → post.

End state, verified: no posts edge on the Tag schema; the tag_posts join table physically dropped on dev + dogfood; the post-edit form has no tag picker; chips show the group-only path (Origin › Nicaragua); every pre-existing tagged post carries exactly one migrated tag block.

The phases

Flat phases, one post each — tracks are reader labels, never a level. The 4a/4b pair is a deliberate split: the cover rollup's Browse action links to the browse page, so the destination shipped first.

  • Phase 1 — The Deuce Coupe (the machine underneath): the TagBlockItem schema — the tag block kind, edges, ordered + unique indexes. Shipped 2026-06-09.
  • Phase 2 — Mel's Drive-In (where orders get placed): Studio authoring — the White Rabbit picker in a block-scoped modal, drag-reorder, block-if-in-use delete, the legacy post-form picker removed. Shipped 2026-06-09.
  • Phase 3 — Cruising the Strip (seen on the boulevard): kontent in-body render — compact grey pills, group-only path. Shipped 2026-06-10.
  • Phase 4a — The Blonde in the T-Bird (the search across town): the /tags/{slug} browse page + the per-site slug index. Shipped 2026-06-10.
  • Phase 4b — Wolfman Jack on the Air (the broadcast that rolls it all up): tags join the cover CEWL — hero button, lightbox Tags section. Shipped 2026-06-10.
  • Phase 5 — The Request Line (call in, get the song): the api/v1 read contract + GET /tags/{slug}/posts. Shipped 2026-06-11.
  • Phase 6 — Every Radio on the Strip (the same song in every car): Flutter full parity — in-body widget, CEWL Tags section, tappable chips, the tag-browse screen. Shipped 2026-06-11.
  • Phase 7 — Paradise Road (the race that retires the old champ): migration — 21 posts → 21 blocks / 138 items — then the legacy Post → Tags edge dropped. Shipped 2026-06-11.

Key decisions

The locked decisions that shaped the build — each one settled with the operator, then propagated through every phase doc.

TagBlockItem hangs the ordered tag set directly on the block; the planned TagCollection library layer was dropped (2026-06-09, superseding the earlier rolled-up-library design). That collapsed the roadmap 9 → 6 phases. Cross-post named reuse is the only deferred capability — additive later if a real need appears.

A chip shows group › tag (Origin › Nicaragua) — matching what prod actually rendered, after a full-ancestry design was reverted against the evidence. One eager-loaded group, no parent-chain walk, no layout/column_count options. The slug rides in the read contract from day one so chips could become links with no data change.

Dev/dogfood only, so no gradual two-store window: the old post-form tag picker (the pt-* card, CSS, and JS) was removed the day the block shipped — its element IDs collided with the new block-scoped modal anyway. Existing Post.tags rows persisted untouched until the Phase 7 migration, protected by making studio UpdatePost a no-op on the tag edge when no tag_ids are submitted.

The join carries sort_order plus a (block, tag) unique index and nothing else — tags need no label_override. Ordering without reorder is pointless, so the modal pills drag-reorder (native HTML5 DnD) and persist on save via delete-all → re-insert. A dedicated reorder route was built and tested but left unwired, banked for a future inline-editor gesture.

The block picks, orders, and removes tags — it never edits a tag's identity. Name and slug live in the Tags admin alone (the "edit tag" pencil from the old collection mockup was cut). In exchange, GetUsageCount was extended to count posts reached via tag blocks (Tag → block_items → block → post), and the tag form's Used-In view lists the referencing posts so an editor can unlink before deleting.

Tags became a CEWL type alongside contacts, events, weblinks, and locations. Two cover-button states — bi-tags when tags are the only CEWL type, bi-three-dots when mixed — wired in both hero branches (cover and no-cover). Hidden tag blocks still feed the aggregate, and visible blocks emit data-cewl-tag spans too (pills can't be scraped like the other types). Every lightbox tag field is esc()-escaped.

The browse page shipped as a minimal functional route in Phase 4a rather than waiting for Track B — the CEWL Browse action needed a destination, and Track B expands the same handler (nothing throwaway). It forced the real decision: tag slugs are unique per site, so the index tightened from (site, group, slug) to (site, slug) — after de-duping two real cross-group collisions (martini, non-alcoholic) in seed and live data.

A new block_type value touches many dispatch points with no defaults anywhere — a silent miss renders nothing or saves an edgeless block. The mitigation: an exhaustive wiring checklist in every phase doc, a test at each no-fallback point (create persists, render emits, API includes, Flutter renders), and enum-exhaustiveness tests that iterate every block kind and assert it is wired — protection that outlives this program.

Deleting a tag referenced by any tag block (or series/episode) is refused via ErrTagInUse — matching the module's dominant block-if-in-use pattern (ErrCollectionInUse, ErrMaterialInUse, ErrSeriesInUse). The first draft called the one clear-then-delete method "the existing pattern"; the operator's "where do we use that?" challenge exposed it as the outlier — one instance is not a pattern.

As-built record

  • Migrationcmd/migrate-tags-to-blocks: idempotent, per-site, deterministic per-item ordering, one block per Post (not per locale), reusing the same atomic CreateTagBlock the Studio uses. Operator-present --commit/--verify on dogfood: 21 posts → 21 blocks / 138 items, VERIFY OK; dev had 0 to migrate.
  • Retirementgo generate removed the edge so any remaining reader would fail at compile (the no-reader proof); an explicit raw-SQL DROP TABLE tag_posts then removed the physical table on dev and dogfood.
  • Shipped early — in-body chips became links to /tags/{slug} ahead of Track B: the slug and route already existed from Phase 4a, so they lit up with no data change.
  • Track B — never designed. Its three ideas (/topics/{slug} topic pages, the per-member room, the kontent author filter) moved to the running list as future ideas, not commitments.
  • Docs — the full plan cluster lives in the repo: docs/plans/american_graffiti/, overview + 8 phase docs, each phase doc buildable without re-deriving anything.
Claude Anthropic
← Back to American Graffiti