You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This is a PRD-style epic. It is written to be fed to AI coding sessions as a working brief, work package by work package, with as little human attention as possible. Section 9 is the agent operating model and is not optional reading for whoever runs it.
Mapped against main at 5f92cb5 on 2026-09-02. Implementers must verify against the code; paths below were checked on that commit.
1. Why we're doing this
The Trans Dimension (transdimension.uk) is a ~9.7k line elm-pages static site that consumes PlaceCal's GraphQL API, rebuilt hourly by a cron so that data is at most an hour stale. Maintaining a second frontend, a second design system, a second deploy pipeline and a keepalive cron for one partnership is a drag on a small team, and it gives TD none of the things PlaceCal already has (live data, iCal, CSV, maps, tests, monitoring).
The fix is not to move TD's code into PlaceCal. It is to give PlaceCal one generic capability it should have had anyway: extensions. An extension is a Rails engine that can register a theme (stylesheet, homepage view, map style, fonts, copy) and content for a Site, without core knowing the extension exists. TD becomes the first extension. It also sets up the model GFSC needs next: a public core that anyone can self-host, plus GFSC-specific things (the join site, a future billing portal, client themes) living outside core as extensions.
The goal, stated once. The new transdimension.uk matches the current one exactly, page for page, at every viewport, except for the deliberate deviations listed in section 4a. Anything else that differs is a bug. This is the acceptance criterion for the whole epic and the reason cheap agents can do most of the work: "matches the screenshot" is a decidable gate, "looks good" is not.
The one rule that decides where code goes: if a second partnership could want it, it is a core feature and gets built generically. If only this site wants it, it is views, CSS, assets and copy in an extension. Extensions contain no models, no migrations, no business logic.
2. Repos and boundaries (the target shape)
Repo
Visibility
Contains
Exists today?
geeksforsocialchange/PlaceCal
public
the deployable app, the extension mechanism, every generic feature
GFSC's installation: which core version, which extensions, deploy config, and later the join site and billing
no, future work, not on the TD critical path
Interim compromise, stated openly. Until the private distribution repo exists, placecal.org's installation is the public repo itself, so the TD extension is listed in the public Gemfile under a clearly labelled group :extensions with a comment saying it is installation-specific and removable. The alternative (an uncommitted Gemfile.extensions) does not work with a committed Gemfile.lock and BUNDLE_DEPLOYMENT=1 in the Dockerfile (Dockerfile:20). The private installation repo removes the compromise when it exists.
Mossley is out of scope. It is the only existing bespoke site (app/controllers/sites_controller.rb:11-18, app/views/sites/mossley.rb, app/assets/stylesheets/themes/custom/mossley.scss, config/initializers/dartsass.rb:14, public/map-styles/mossley.json) and it may be retired. Do not migrate it, refactor it, or delete it in this epic. The extension registry is built beside the Mossley branch, not through it. When Mossley's fate is decided, it is either deleted (one small PR) or becomes the second extension (a copy of the TD recipe).
3. Current state (verified 2026-09-02)
Site model and theming
app/models/site.rb:65-67: enumerize :theme, in: %i[pink orange green blue custom], default: :pink. Plain nullable string column, no DB constraint (db/schema.rb:279-306).
app/models/site.rb:237-242stylesheet_link: built-in themes map to themes/<theme>; custom maps to themes/custom/<slug> if the built asset exists. This is the only CSS hook.
config/initializers/dartsass.rb:7-16: static build map. Every stylesheet, including the four built-in themes (each 7 to 10 lines of CSS variables) and Mossley, is hand-registered here.
app/helpers/map_helper.rb:63-80style_url_for_site: parallel slug-keyed lookup into public/map-styles/*.json.
app/views/admin/sites/form_tab_images.rb:20-29: theme select built from Site.theme.values.
Two CSS systems coexist: legacy SCSS with --base-* variables (app/assets/stylesheets/application.scss:1-35) and Tailwind v4 with --color-* tokens (app/tailwind/public/_theme.css, TODO at line 55 about runtime overriding). Agreed direction is Tailwind plus CSS custom properties for everything; do not add new SCSS.
Tailwind builds via @tailwindcss/cli from package.json (yarn build), not a gem. Built CSS lands in app/assets/builds/. Propshaft serves it.
Views
Phlex 2 everywhere. config/initializers/phlex.rb pushes app/views (namespace Views) and app/components (namespace Components, a Phlex::Kit) onto the Zeitwerk autoloader. There is no engine-aware view path config; an engine will need its own namespaces.
Views::Base (app/views/base.rb) and Components::Base carry the Rails helper mixins. Views::Homepage::Base (app/views/homepage/base.rb) is the precedent for a base class that opts a page into a stylesheet via before_template and content_for.
Layout is app/views/layouts/application.rb. Stylesheet chain at lines 19-26: application, public_tailwind, home (opt-in), site.stylesheet_link, print. Font preloads at 27-30. A retired Plausible script tag at line 35 (see below).
Site homepage: SitesController#index renders Views::Sites::Default or Views::Sites::Mossley by slug string.
There is no Page model. Static content is app/content/*.md rendered by Views::Directory::MarkdownPage (app/views/directory/markdown_page.rb), directory-only.
Get-in-touch is app/models/join.rb (ActiveModel, not persisted), JoinsController, Views::Directory::Join. Directory-only. Components::ContactForm and ContactRequest exist only on the unmerged feature/join-site branch (PR Add join.placecal.org marketing site behind JOIN_SITE_ENABLED flag #3302). Do not depend on that branch.
Filtering
Local /events filters by neighbourhood, period, repeating, sort only (events_controller.rb:113-146). Local /partners filters by category and neighbourhood (partners_controller.rb:172-188). There is no public tag or partnership filter on sites. PartnersQuery#call already accepts tag_id:; EventsQuery has no tag parameter.
Directory /events and /partners already have a partnership param, but there it means a Site, not a Tag.
Site-level plumbing that is directory-only or hard-coded
Sitemaps: SitemapsController 404s unless directory_request? (line 36-38), base URL is Site::DIRECTORY_URL. Sites get no sitemap.
Web manifest: none exists anywhere.
Analytics: Plausible is retired as a service, but its script tag (app/views/layouts/application.rb:35) and server-side event posting (application_controller.rb:169-198) are still in the code and still run in production. Removing them is a separate tidy-up ticket, not part of this epic; do not build anything on them.
Fonts: self-hosted Rawline and Trocchi via app/tailwind/public/fonts.css. No external font loading.
Robots: per-site via SiteRobots concern, already fine.
4. Design decisions (made; do not reopen without Kim)
D1. Extensions are Rails engines that register into a core registry. Core gains lib/placecal/extensions.rb (PlaceCal::Extensions) and lib/placecal/theme.rb (PlaceCal::Theme). An engine registers one or more themes in its engine.rb initializer:
PlaceCal::Extensions.register_theme(:transdimension)do |theme|
theme.stylesheet'transdimension/theme'# asset logical path, built by the enginetheme.homepage_view'Transdimension::Views::Home'# Phlex class name, resolved lazilytheme.map_style'transdimension'# public/map-styles/<name>.json, optionaltheme.head'Transdimension::Components::Head'# optional Phlex component rendered in <head> (fonts, manifest link)theme.event_filter_style:day_strip# optional, default :date_picker (D22)end
Locale files need no registration; Rails loads an engine's config/locales automatically. Core's four built-in themes (pink, orange, green, blue) register themselves through the same API at boot, so there is exactly one code path. custom stays as a legacy value that resolves to the existing slug-based lookup, so Mossley keeps working untouched.
D2. Site.theme validates against the registry, not a static enum. Replace enumerize on theme with validates :theme, inclusion: { in: ->(_site) { PlaceCal::Extensions.theme_names } } (the lambda takes the record, Rails passes it) and a Site#theme_definition accessor. Admin select reads from the registry. Anything reading Site.theme.values must change.
D3. Theme lookups go through Site#theme_definition.stylesheet_link, MapHelper#style_url_for_site, SitesController#index, and the layout head all ask the theme definition. The Mossley if slug == branch stays, but it is reached only when theme_definition.homepage_view is nil. Net: core gets one new indirection and no new special cases.
D4. Themes are Tailwind plus CSS custom properties. A theme stylesheet overrides the --color-* tokens from app/tailwind/public/_theme.css and may add component-level CSS. The engine builds its own CSS with @tailwindcss/cli (scanning its own views) into its own app/assets/builds/, which Propshaft picks up automatically because engines' app/assets are on the asset path. The built CSS is committed in the engine repo (the engine's CI fails if it is stale), so core's Dockerfile needs no Node build step for extensions. No dartsass, no SCSS in extensions. Core's _theme.css TODO at line 55 gets done as part of this: all themeable values become :root custom properties.
D5. Static pages are a core model, not extension views.Page (site_id, slug, title, body markdown, body_html via the existing HtmlRenderCache concern, position, show_in_nav, is_published). Routed at /:slug after all other routes, with a reserved-slug validation generated from Rails.application.routes. Site admins edit their own pages in admin. TD's About and Privacy content is seeded into Pages, not shipped as views. The theme styles the page template.
D6. Site navigation is derived, not configured.sub_site_navigation becomes: Home, Events, Partners, News (only when site.news_article_count > 0, matching #3309), then published Pages with show_in_nav, then a Join link when the site has a contact_email (D13). No nav editor UI in this epic.
D7. The region selector is a generic partnership-tag filter. When a Site has more than one Partnership tag, the public /events, /partners and / pages show a segmented control of All plus the site's Partnership tags, in tag order, driven by ?region=<tag slug>. All is the default. EventsQuery gains tag_id: mirroring PartnersQuery. The list is derived from site.tags, so adding a region to a site is admin config, not code (Kim expects more regions to sign up soon). Sites with zero or one Partnership tag see no change. Details in D19 to D25.
D8. Join form on sites reuses the existing Join model and mailer.get-in-touch is routed on sites as well as the directory, the email subject carries the site name, and the theme may override the view. TD's Formspree form goes away. Field set is PlaceCal's, not TD's; recipient is per site (confirmed, D13).
D9. Per-site sitemap and manifest derive from site.url. No new columns for these. No analytics work: Plausible is retired.
D10. Article to site association stays tag-derived. No site_id on articles. #3309 owns article scoping.
D11. URL compatibility. TD partner URLs already use PlaceCal slugs, and friendly_id finders accept numeric ids, so /partners/:id_or_slug works. TD event URLs are /events/:id and PlaceCal's are the same. News URLs must be checked (WP 1.9). Anything that does not match gets a 301 in core's routes, keyed on the site, not hard-coded to TD.
4a. Deliberate deviations from the current TD site
These are the only places the new site is allowed to differ. Georgie (GI) reviews this list on staging, not the whole site.
Area
Old TD
New
Why
Data freshness
up to an hour stale, one month back and six months forward
live, all history and future
PlaceCal is the source
Event filters
day paginator plus region selector
PlaceCal's period, neighbourhood, repeating and sort filters plus the region selector; default period is PlaceCal's density auto-pick, not Today
PlaceCal's parameters rendered as a Today / Tomorrow / next five days strip (core filter style, D22)
same look, no bespoke code
Footer and nav
hand-coded
derived (D6) but rendering the same items in the same order
generic feature
Region selector
client-side, URL synced via ports
server-side ?region= param (D7), same URL shape, choice carried across Home, Events and Partners via nav links (D20)
generic feature
If an implementer finds another difference that cannot be styled away, it goes on this table in the PR, not silently into the site.
4b. Decisions taken with Kim on 2026-09-02 (from the section 7 questions)
D12. Repo names as in section 2.
D13. Join form uses PlaceCal's fields, and each site can have its own recipient: new column sites.contact_email (nullable), editable in admin by root and the site admin, used by JoinMailer when present, falling back to the current support address. The Join link appears in a site's nav when contact_email is set, which keeps other sites unchanged and makes the nav derivation rule (D6) hold.
D14. Page URLs match PlaceCal's own convention, which is top-level (/privacy, /terms-of-use today), so TD keeps /about and /privacy. Site-scoped 301s are allowed wherever the audit in WP 1.9 finds a mismatch.
D15. Fidelity is the goal in section 1 and the table in 4a.
D16. Typekit stays, loaded via the theme head hook. Kim confirmed the licence is held.
D17. Gemfile compromise (section 2) accepted.
D18. Reviewer for the staging soak is Georgie at GI. Expectation is a light review; the deviations table is what they are asked to look at.
Filter and paginator decisions, taken with Kim the same day:
D19. Param name is region, value is the Partnership tag slug, matching TD's existing URLs. The visible label comes from locale keys (theme can override). If a future site's partnership tags are thematic, the label changes, not the param.
D20. Region is sticky via links, not state. When region is set, the site nav links to Home, Events and Partners carry it. No cookie, no session.
D21. Default period on TD is PlaceCal's density auto-pick (EventsController#default_period), not TD's Today.
D22. Day strip is a core filter style. The theme definition gets event_filter_style :day_strip (default :date_picker). The day strip renders Today, Tomorrow and the next five days as links to the existing /events/YYYY/M/D?period=day URLs plus an "All upcoming" link (period=future), and reuses the existing hidden fields for sort and repeating. No new query parameters.
D23. "All past events" is dropped. Recorded in 4a. If wanted later it is a past period in EventsQuery, a separate ticket.
D24. Neighbourhood dropdown stays available on TD. It shows whenever a site has more than one neighbourhood, exactly as today. Whether TD's Site record carries neighbourhoods is admin config decided in WP 2.7, not code; Site has no neighbourhood validation (app/models/site.rb:127-129), so a tagged site with none is valid. Kim wants the flexibility because more regions will sign up; making region and neighbourhood interact more cleverly is a future ticket.
D25. Sort and repeating toggles stay visible on TD as improvements.
5. Scope by phase
Each phase is one or more PRs. Each work package (WP) below is written to be handed to an agent on its own. Model tier is the cheapest model that should do it well; the coordinator can always go up a tier. Gate is what must pass before the WP is done; every WP also passes bundle exec rubocop, bundle exec rspec spec/i18n_keys_spec.rb, and the specs it touched.
Phase 0: extension mechanism (one PR, core)
WP
What
Files
Tier
Gate
0.1
PlaceCal::Theme value object and PlaceCal::Extensions registry with register_theme, theme_names, find_theme, reset! (for specs). Built-ins registered in config/initializers/extensions.rb. custom registered as a legacy theme whose stylesheet resolves via the old slug lookup.
new lib/placecal/theme.rb, lib/placecal/extensions.rb, config/initializers/extensions.rb; specs in spec/lib/placecal/
Opus (design), then Sonnet
registry specs pass; PlaceCal::Extensions.theme_names contains exactly pink orange green blue custom at boot
0.2
Site.theme validated against the registry; Site#theme_definition; stylesheet_link and MapHelper#style_url_for_site delegate to it; admin select reads the registry; SitesController#index renders theme_definition.homepage_view when present, else existing behaviour.
spec/models/site_spec.rb, spec/helpers, spec/system/admin/sites_spec.rb, features/admin/sites.feature; visiting mossley.lvh.me and a pink site in dev still render identically (screenshot both before and after, compare)
0.3
Layout head hook: render theme_definition.head component if present, after the stylesheet chain; expose content_for(:theme_head) as the fallback.
app/views/layouts/application.rb:19-30
Sonnet
layout spec; no visual change on existing sites
0.4
Engine loading proof: a throwaway engine under spec/fixtures/extensions/example_theme/ (engine.rb, one stylesheet, one homepage view, one locale key) registered in rails_helper for tests only. Proves that the engine's own namespaces (ExampleTheme::Views::Home, ExampleTheme::Components::Head) autoload, that the layout can render them, that the engine's committed CSS is served by Propshaft, and that its locale file loads. The engine may call Rails.autoloaders.main.push_dir for its own directories inside its engine.rb; core's config/initializers/phlex.rb and the core Views/Components namespaces stay untouched.
Opus for the first pass (this is where the unknowns are), Sonnet to finish
request spec renders the fixture homepage with the fixture stylesheet linked and the locale string present
0.5
doc/extensions.md: how to write an extension, the hard rule on contents, the Tailwind build recipe, and the group :extensions Gemfile convention. Cross-link from doc/ai/context.md.
docs
Haiku
reviewed by coordinator
Escalate to Kim if: the engine's classes cannot be autoloaded without editing a core config file, or if Propshaft will not serve an engine's app/assets/builds without core config changes. Either would mean every future extension forces a core edit, which breaks the boundary in section 2.
Phase 1: generic core features TD needs (several small PRs, core)
All of these follow Phase 0. Within the phase, 1.1, 1.3, 1.3b, 1.4, 1.5, 1.6 and 1.8 are independent and can run in parallel. 1.2 follows 1.1 and 1.4 (it derives nav from Pages and contact_email). 1.9 follows 1.1 and needs a local TD Site record (a dev run of the 2.7 steps).
WP
What
Tier
Gate
1.1 Pages model
Migration, model with HtmlRenderCache, reserved slug validation, PagePolicy (root, editor, and the site's site_admin), admin CRUD following admin/articles patterns, public PagesController#show on sites, Views::Sites::Pages::Show using the existing markdown page styling.
Sonnet
model, policy, request specs; features/admin/ scenario for a site admin creating a page; reserved slug events rejected
1.2 Derived nav
D6 and D13. Also add Pages, News and Join to Components::Footer link list (app/components/footer.rb:44-56). Item order must be able to reproduce TD's current nav (Home, Events, Partners, News, About, Join Us).
Sonnet
spec/components/navigation_component_spec.rb, footer spec; coordinate with #3309 if it has landed (it adds the News link too; take theirs)
1.3 Region filter
D7, D19, D20. EventsQuery#call(tag_id:), region param resolved by tag slug on local events index, partners index and site homepage; Components::EventFilter/PartnerFilter gain an All-plus-tags segmented control shown only when site.tags.count > 1; sub_site_navigation carries region on Home, Events and Partners links when set. Strings via locale keys.
Sonnet
spec/queries/events_query_spec.rb, request specs for all three pages, component specs, nav spec proving links carry the param; features/public/browse_events.feature scenario with a two-tag site; unknown slug ignored, not 500
1.3b Day strip filter style
D22. PlaceCal::Theme#event_filter_style, Components::EventFilter renders the day strip variant when the site's theme asks for it: Today, Tomorrow, next five days, All upcoming, current day highlighted, horizontally scrollable on mobile. Built-in themes keep the date picker.
Sonnet
component spec for both styles; request spec with the fixture theme from 0.4 set to :day_strip; screenshot at 450 and 1250
1.4 Join on sites
D8 and D13. Migration adding sites.contact_email, admin field and policy, route get-in-touch under the site constraint, JoinMailer recipient from the site with fallback, subject carries the site name, Views::Sites::Join (copy of the directory view with site styling).
Sonnet
migration; model validation spec (email format); request spec posts and delivers to contact_email, and to the fallback when blank; invisible captcha still applied
1.5 Per-site sitemap
Drop require_directory; base URL from site.url when on a site; include pages; site robots currently advertise placecal.org's sitemap, so change SiteRobots#published_robots to advertise the site's own.
Sonnet
spec/requests for a site sitemap; directory sitemap unchanged
1.6 Web manifest
/manifest.webmanifest per site: name, short_name, theme_color from the theme's primary token, icons from site.logo when PNG else core icon. Link tag in layout head.
Haiku
request spec; JSON validates
1.8 Theme tokens
Finish the _theme.css:55 TODO: every colour, font and radius used by public components is a :root custom property; built-in theme stylesheets override only tokens. Green theme's wrong --base-* values (orange hex in themes/green.scss) fixed while here.
Haiku, with a Sonnet review
yarn build clean; visual regression run (bin/visual-regression) shows no diffs on existing sites
1.9 URL audit
Script that lists 50 live TD URLs per route (partners, events, news) from transdimension.uk's sitemap or GraphQL and checks each resolves 200 on the same path against a local PlaceCal with the TD Site record and pages configured (needs 1.1). Output a table. Add site-scoped 301s in routes for whatever fails.
Haiku for the script, Sonnet for the redirects
table committed to the PR description; zero unresolved
Phase 2: the Trans Dimension extension (new public repo, plus one Gemfile line in core)
Repo placecal-theme-transdimension, generated with rails plugin new --full (not --mountable: the engine must not isolate its namespace or routes, it plugs into the host app) and trimmed: lib/transdimension/engine.rb, app/views/transdimension/, app/components/transdimension/, app/assets/, config/locales/, content/, package.json for the Tailwind build, the built CSS committed (D4), its own rspec setup that boots against a checkout of core (document the recipe in 0.5).
WP
What
Tier
Gate
2.1 Skeleton
Engine registering :transdimension per D1 with event_filter_style :day_strip, Tailwind build wired, CI running the engine's specs against core main, LICENSE (Hippocratic, copied from TD), README with asset copyright note.
Sonnet
engine boots in core's dev; theme_names includes transdimension
2.1b Golden screenshots
Script in the extension repo that captures every TD route (section 3) on transdimension.uk at the four viewports from core's spec/system/visual_regression_spec.rb into a committed golden set, with a manifest of URL, viewport and capture date. Done first because the Elm site will be archived and the goldens are the acceptance artifact for every later Phase 2 WP. Re-run before Phase 3 for a fresh set.
Haiku
every image opens and is non-blank (check bytes, not size); manifest complete
2.2 Tokens and global styles
Port src/Theme/Global.elm to token overrides plus global CSS: colours, Covik Sans via the head hook (Typekit kit id from TD's index.html), type scale, spacing.
Haiku
dev screenshots for /events and /partners at the four viewports against the 2.1b goldens, reviewed by coordinator
2.3 Homepage
Transdimension::Views::Home: port src/Theme/Page/Index.elm using core components where they exist (event list, partner list, region filter from 1.3). Illustrations copied into the engine's assets.
Sonnet
matches the / goldens; all copy from locale keys (transdimension.* namespace)
2.4 Page templates
Styling for core's events index/show, partners index/show, news index/show, pages show, join, using only CSS and (where core exposes them) component slots. If a page cannot be styled without a core change, stop and file a core WP; do not fork the view into the engine.
Haiku per page, Sonnet if a slot is needed
matches the goldens for that route, differences only from the 4a table
2.5 Copy
src/Copy/Text.elm (~200 strings) to config/locales/en.yml under transdimension.*, keyed by page. Site-specific overrides of core strings (for example the events page heading) via the same file, since engine locales load after core.
Haiku
i18n_keys_spec equivalent in the engine; reviewer confirms no hardcoded UI strings in engine views (every visible string goes through t())
2.6 Content seed
content/about/*.md and privacy.md to a rake transdimension:seed_pages[site_slug] task that upserts Pages (1.1) idempotently. Makers section (GI, GFSC) becomes part of the About page body.
Haiku
task runs twice without duplicates; pages render
2.7 Site record
Documented admin steps (not a migration): create Site transdimension, url https://transdimension.uk, theme transdimension, Partnership tags 3 and 30 (London, Manchester), site admin, logo, hero, contact_email, and a documented choice on neighbourhoods (D24: none means no dropdown; adding them enables it). A bin/rails transdimension:check task that asserts the record matches.
Haiku
check task green on staging
2.8 Core Gemfile line
group :extensions do gem 'placecal-theme-transdimension', github: ..., tag: ... end with the comment from section 2. Dockerfile unchanged: the repo is public so no credentials, and the engine ships its CSS prebuilt (D4).
Haiku
CI green; image builds; the deployed image serves the engine's stylesheet
Phase 3: staging soak (no code)
Deploy core plus extension to staging, run 2.6 and 2.7 there, point a test hostname at it via Kamal's proxy hosts (config/deploy.staging.yml).
Lighthouse and axe on every route; compare with transdimension.uk. Fix regressions in whichever repo owns them.
Ask Gendered Intelligence to review on staging. Human step. This is the one hard external dependency.
Phase 4: cutover (one PR in core, runbook for the rest)
Step
Owner
Add transdimension.uk and www.transdimension.uk to proxy.hosts in config/deploy.production.yml (pattern: queerleeds.lgbt)
agent, PR
Cloudflare DNS for transdimension.uk to PlaceCal production; TLS via Kamal proxy
Kim
Disable build.yml and keepalive.yml in the TD repo, archive the repo with a README pointer to the extension repo
agent, PR in TD repo (draft), Kim archives
Watch AppSignal for 48h for 404s on transdimension.uk and add 301s as needed
agent, scheduled
Request Bing and Google recrawl
Kim
Future context, not in scope: the private GFSC installation
GFSC expects to run a private overlay on the public core at some point: a small private repo that pins a core version, adds extensions (client themes, the join site, a billing portal), and owns deploy. That work needs its own PRD and is not started from this one. It matters here only because three decisions now have to leave the door open:
D1 is general, not TD-shaped. The registry accepts any number of engines and anything a theme registers is optional, so a private engine plugs in the same way a public one does. Nothing in core may check for a specific extension by name.
The Gemfile compromise in section 2 is temporary. The group :extensions block is the seam the private repo later takes over; keep it a single block with a comment, so removing it is one edit.
WP 0.4 must prove engine loading with no core edits. If adding an extension ever requires touching core config, a private extension would force private edits into the public repo, which is exactly what the overlay is meant to avoid.
Two related notes for that future PRD, recorded so they are not lost: PR #3302 (join site) is unmerged, so it could be reworked as an extension rather than merged into core, with only the persisted ContactRequest model landing in core; and the Docker build has no credentials for private gems today, so a private engine needs a build secret or, better, deploys from the private repo. Mossley is deleted or converted once its fate is decided.
6. Non-goals
No nav editor, no theme editor, no uploaded-CSS theming in admin.
No Mossley work of any kind.
No changes to GraphQL. TD's queries keep working until the Elm site is archived, and other consumers may exist.
No new SCSS anywhere.
No moderation, no per-site article ownership.
No billing, no join-site extraction, no private distribution repo.
7. Open decisions
All answered on 2026-09-02; see 4b. Nothing outstanding. New questions raised during implementation go in the PR description and are batched for Kim at the phase boundary, not asked one at a time.
8. Risks and trade-offs
TD becomes a live dependency on PlaceCal production. Accepted in the issue.
Engine autoloading with Phlex namespaces is the main technical unknown; WP 0.4 exists to burn it down first.
Two Tailwind builds (core and engine) that cannot see each other's templates. Handled by the engine committing its built CSS (D4); the risk that remains is a stale committed build, which the engine's CI check covers.
9. Agent operating model (how to run this unattended)
Roles.
Coordinator (Fable or Opus): owns this document, writes each WP brief from the tables above, spawns implementers, reviews every diff, runs the full suite before opening a PR, and is the only role that talks to Kim. Talks to Kim only at phase boundaries and for section 7.
Implementer (tier per WP): works one WP on one branch, returns a diff and the gate output.
Reviewer (Sonnet): independent read of each implementer diff against the WP gate and the hard rule, before the coordinator looks. Cheap, catches the obvious.
Brief template (what every implementer gets, nothing else):
The WP row, the relevant decisions from section 4 verbatim, and the file list.
The gate as literal commands.
The rules block: never hardcode UI strings (doc/ai/prompts/views.md); no SCSS; no models or logic in the engine; no git stash; push with an explicit refspec; do not touch db/schema.rb unless the WP has a migration; do not edit files outside the list without saying so in the report.
Stop conditions: any gate failure it cannot fix in two attempts, any need to change a file outside its list, any question about product behaviour. On stop, report what was tried and end.
Parallelism. Phase 1 WPs run concurrently in separate checkouts (deliberate sibling worktrees, not auto-spawned ones), each with a private test database:
Fresh checkouts need bin/rails dartsass:build && yarn install && yarn build before request specs pass. Only the WP with the migration touches db/schema.rb; the coordinator scrubs stray columns before merging.
Verification is the artifact, not a proxy. A WP is done when the gate commands have been run and their output is in the report. Visual WPs (1.3b, 1.8, 2.2 to 2.4) include screenshots in the report at the four viewports from spec/system/visual_regression_spec.rb, beside the 2.1b golden for the same route. The coordinator looks at the images, not the file sizes, and rejects any difference not in the 4a table.
PR discipline. One PR per phase for Phase 0 and 2, one per WP for Phase 1. Every PR is opened as a draft after merging origin/main, with the WP table row and gate output in the description. Nothing merges without Kim. Nothing deploys without Kim.
Unattended run mode. When the whole epic is run in one unattended session, the merge dependency between phases is handled with an integration branch, feature/td-extension, cut from origin/main. Each WP is a sub-branch off the integration branch; its draft PR targets the integration branch, not main; the coordinator reviews and merges WP PRs into the integration branch itself. One draft PR from the integration branch to main is opened at the start and its description is the running progress log (WP status, gate output links, questions for Kim), updated after every WP so the state survives context compaction and is readable from a phone. main is still never merged to without Kim. The extension repo is created by the coordinator with gh repo create under the org as public; its own main is owned by the coordinator during the run. The session ends at the Phase 3 boundary: integration PR ready for review, extension repo ready, and a cutover runbook comment listing the human steps.
Cost shape. Roughly: Opus for 0.1 and 0.4 and for every review; Sonnet for 0.2, 0.3, 1.1 to 1.5 including 1.3b, 2.1, 2.3; Haiku for 0.5, 1.6, 1.8, 1.9, 2.1b, 2.2, 2.4 to 2.8. Most of the tokens in this epic are CSS porting and copy migration, and those are the cheap-tier jobs.
Suggested order. 0 (serial, one session), then Phase 1 with the parallelism described there, then 2 (serial: 2.1, 2.1b, then each page builds on the tokens), then 3 and 4. The private installation work needs its own PRD and should not be started from this one.
Original speculative write-up (2026-07-25), superseded by the PRD above
Summary
Speculative / discussion issue: The Trans Dimension (transdimension.uk) is currently a standalone elm-pages static site consuming PlaceCal's GraphQL API. Maintaining it alongside PlaceCal is becoming a drag. This issue sketches what it would take to retire the standalone site and serve TD as a themed Site inside PlaceCal — without polluting PlaceCal's codebase with TD-specific code.
No commitment implied; this is a written-up feasibility investigation to react to.
Current state
The Trans Dimension repo (~9.7k lines of Elm):
elm-pages 3 static site on Cloudflare Pages, rebuilt hourly by a cron workflow because data is baked in at build time (plus a monthly keepalive commit so GitHub doesn't disable the cron).
Data layer is thin: 3 hand-written GraphQL queries (eventsByFilter, partnersByTag, articlesByTag) scoped by partnership tags (3|London, 30|Manchester), ~600 lines including decoders.
The bulk is the design system: ~4,300 lines of elm-css (Covik Sans via Typekit, Harry Woodgate illustrations, bespoke layouts per page), plus a 438-line hand-rolled day paginator, a region selector synced to the URL via ports, and a 573-line Join Us form posting to Formspree.
Non-PlaceCal content: 6 markdown files (About sections, Privacy) and ~200 copy strings hardcoded in Elm.
PlaceCal side — most of what TD does already exists:
Domain-based Site resolution, tag + neighbourhood scoping, events with paginator/timeline/filters, partner pages with interactive maps, tag-derived site news, iCal/CSV exports, OG images, per-site robots.
Theming today is minimal: built-in themes are one CSS variable; the only heavily-customised precedent is Mossley, which is a hard-coded if current_site.slug == 'mossley' branch in SitesController plus a custom SCSS file hand-registered in the dartsass initializer.
What maps over for free
The entire TD data layer disappears (queries → ActiveRecord), along with the hourly rebuild cron, the keepalive cron, the build cache, and the 1-month-back/6-months-forward data window. Data becomes live.
TD's custom paginator and URL-sync port workaround are replaced by existing PlaceCal UI.
TD gains things it lacks: iCal feeds, CSV export, interactive maps, embeddable widget.
Custom domain is an established pattern (Site url + proxy hosts in deploy.production.yml + Cloudflare), same as queerleeds.lgbt.
What's net-new in PlaceCal
A real theme mechanism — replace the Mossley if slug == branch and hand-edited dartsass registration with a theme registry (views, stylesheets, nav, keyed by site.theme). This is paying down existing debt; Mossley becomes the second customer.
Per-site static pages + configurable nav — no per-site About/Join pages exist today, and sub_site_navigation is hardcoded to Home/Events/Partners (doesn't even include News). Generic feature, benefits every site.
Region/tag filter UI — TD filters events/partners between two partnership tags; public PlaceCal filters by neighbourhood only.
Small stuff — web manifest, per-site sitemap (currently directory-only with hardcoded placecal.org URLs), slug→ID redirects for SEO, a Join form (could build on get-in-touch), per-site font loading + Plausible domain.
Avoiding codebase pollution / monolith creep
The proposal is not to move TD's code into this repo. Instead:
TD theme as a Rails engine in its own repo (e.g. placecal-theme-transdimension), added via the Gemfile. Engines natively support prepended view paths, asset registration, and locale files. TD's design assets, copy, and licence stay in TD's own repo with its own maintainers.
Hard rule for what a theme may contain: views, CSS, assets, copy. No models, no migrations, no business logic. Anything a theme needs that smells like a feature (e.g. the tag filter) gets built as a generic core capability, and the theme merely styles it. Site-specific → engine; useful-to-any-site → core.
Foundation first: as much as possible via data-driven theming (CSS custom properties, uploaded assets, pages model, nav config), with the engine as the escape hatch for genuinely bespoke layout.
Net effect on this repo should be cleaner than today — the Mossley special-casing goes away — and the next partnership wanting a bespoke site costs a theme repo, not a fork of an Elm app.
Rough sizing
Pragmatic reskin (TD adopts PlaceCal page structures + heavy custom SCSS + bespoke homepage + 2–3 static pages, Mossley-style): ~1–2 weeks, good spike to make the remaining gap concrete.
Faithful port behind a proper engine/theme mechanism: ~4–8 weeks including the core generalisation work.
Trade-offs to accept
TD loses static-site resilience (currently stays up if PlaceCal is down) and becomes a live dependency — in exchange for PlaceCal's CI, test suites, and monitoring (TD currently has ~163 lines of tests and no test CI).
Theme and domain changes require a PlaceCal deploy, not just admin config.
Article→site association stays tag-derived (no site_id) unless we decide otherwise.
This is a PRD-style epic. It is written to be fed to AI coding sessions as a working brief, work package by work package, with as little human attention as possible. Section 9 is the agent operating model and is not optional reading for whoever runs it.
Mapped against
mainat 5f92cb5 on 2026-09-02. Implementers must verify against the code; paths below were checked on that commit.1. Why we're doing this
The Trans Dimension (transdimension.uk) is a ~9.7k line elm-pages static site that consumes PlaceCal's GraphQL API, rebuilt hourly by a cron so that data is at most an hour stale. Maintaining a second frontend, a second design system, a second deploy pipeline and a keepalive cron for one partnership is a drag on a small team, and it gives TD none of the things PlaceCal already has (live data, iCal, CSV, maps, tests, monitoring).
The fix is not to move TD's code into PlaceCal. It is to give PlaceCal one generic capability it should have had anyway: extensions. An extension is a Rails engine that can register a theme (stylesheet, homepage view, map style, fonts, copy) and content for a Site, without core knowing the extension exists. TD becomes the first extension. It also sets up the model GFSC needs next: a public core that anyone can self-host, plus GFSC-specific things (the join site, a future billing portal, client themes) living outside core as extensions.
The goal, stated once. The new transdimension.uk matches the current one exactly, page for page, at every viewport, except for the deliberate deviations listed in section 4a. Anything else that differs is a bug. This is the acceptance criterion for the whole epic and the reason cheap agents can do most of the work: "matches the screenshot" is a decidable gate, "looks good" is not.
The one rule that decides where code goes: if a second partnership could want it, it is a core feature and gets built generically. If only this site wants it, it is views, CSS, assets and copy in an extension. Extensions contain no models, no migrations, no business logic.
2. Repos and boundaries (the target shape)
geeksforsocialchange/PlaceCalgeeksforsocialchange/placecal-theme-transdimensiongeeksforsocialchange/placecal-gfscInterim compromise, stated openly. Until the private distribution repo exists,
placecal.org's installation is the public repo itself, so the TD extension is listed in the publicGemfileunder a clearly labelledgroup :extensionswith a comment saying it is installation-specific and removable. The alternative (an uncommittedGemfile.extensions) does not work with a committedGemfile.lockandBUNDLE_DEPLOYMENT=1in the Dockerfile (Dockerfile:20). The private installation repo removes the compromise when it exists.Mossley is out of scope. It is the only existing bespoke site (
app/controllers/sites_controller.rb:11-18,app/views/sites/mossley.rb,app/assets/stylesheets/themes/custom/mossley.scss,config/initializers/dartsass.rb:14,public/map-styles/mossley.json) and it may be retired. Do not migrate it, refactor it, or delete it in this epic. The extension registry is built beside the Mossley branch, not through it. When Mossley's fate is decided, it is either deleted (one small PR) or becomes the second extension (a copy of the TD recipe).3. Current state (verified 2026-09-02)
Site model and theming
app/models/site.rb:65-67:enumerize :theme, in: %i[pink orange green blue custom], default: :pink. Plain nullable string column, no DB constraint (db/schema.rb:279-306).app/models/site.rb:237-242stylesheet_link: built-in themes map tothemes/<theme>;custommaps tothemes/custom/<slug>if the built asset exists. This is the only CSS hook.config/initializers/dartsass.rb:7-16: static build map. Every stylesheet, including the four built-in themes (each 7 to 10 lines of CSS variables) and Mossley, is hand-registered here.app/helpers/map_helper.rb:63-80style_url_for_site: parallel slug-keyed lookup intopublic/map-styles/*.json.app/views/admin/sites/form_tab_images.rb:20-29: theme select built fromSite.theme.values.--base-*variables (app/assets/stylesheets/application.scss:1-35) and Tailwind v4 with--color-*tokens (app/tailwind/public/_theme.css, TODO at line 55 about runtime overriding). Agreed direction is Tailwind plus CSS custom properties for everything; do not add new SCSS.@tailwindcss/clifrompackage.json(yarn build), not a gem. Built CSS lands inapp/assets/builds/. Propshaft serves it.Views
config/initializers/phlex.rbpushesapp/views(namespaceViews) andapp/components(namespaceComponents, aPhlex::Kit) onto the Zeitwerk autoloader. There is no engine-aware view path config; an engine will need its own namespaces.Views::Base(app/views/base.rb) andComponents::Basecarry the Rails helper mixins.Views::Homepage::Base(app/views/homepage/base.rb) is the precedent for a base class that opts a page into a stylesheet viabefore_templateandcontent_for.app/views/layouts/application.rb. Stylesheet chain at lines 19-26:application,public_tailwind,home(opt-in),site.stylesheet_link,print. Font preloads at 27-30. A retired Plausible script tag at line 35 (see below).SitesController#indexrendersViews::Sites::DefaultorViews::Sites::Mossleyby slug string.Navigation, pages, forms
application_controller.rb:277-283sub_site_navigationis hard-coded Home / Events / Partners. News is not linked from anywhere (also being fixed in News v2: visibility follows the partner, news everywhere, RSS, publishing UX (#3308) #3309, coordinate).app/content/*.mdrendered byViews::Directory::MarkdownPage(app/views/directory/markdown_page.rb), directory-only.app/models/join.rb(ActiveModel, not persisted),JoinsController,Views::Directory::Join. Directory-only.Components::ContactFormandContactRequestexist only on the unmergedfeature/join-sitebranch (PR Add join.placecal.org marketing site behind JOIN_SITE_ENABLED flag #3302). Do not depend on that branch.Filtering
/eventsfilters byneighbourhood,period,repeating,sortonly (events_controller.rb:113-146). Local/partnersfilters bycategoryandneighbourhood(partners_controller.rb:172-188). There is no public tag or partnership filter on sites.PartnersQuery#callalready acceptstag_id:;EventsQueryhas no tag parameter./eventsand/partnersalready have apartnershipparam, but there it means a Site, not a Tag.Site-level plumbing that is directory-only or hard-coded
SitemapsController404s unlessdirectory_request?(line 36-38), base URL isSite::DIRECTORY_URL. Sites get no sitemap.app/views/layouts/application.rb:35) and server-side event posting (application_controller.rb:169-198) are still in the code and still run in production. Removing them is a separate tidy-up ticket, not part of this epic; do not build anything on them.app/tailwind/public/fonts.css. No external font loading.SiteRobotsconcern, already fine.The Trans Dimension (for the port)
/,/about,/events,/events/:id,/join-us,/news,/news/:item,/partners,/partners/:slug,/privacy.content/about/{main,accessibility,placecal}.md,content/about/makers/{gfsc,gi}.md,content/privacy.md. Copy: ~200 strings insrc/Copy/Text.elm.src/Theme/*.elm(~4,300 lines elm-css), Covik Sans via Typekit, Harry Woodgate illustrations underpublic/images.3|London,30|Manchester) from an env constant, filtering events and partners client-side..github/workflows/build.yml, monthlykeepalive.yml.4. Design decisions (made; do not reopen without Kim)
D1. Extensions are Rails engines that register into a core registry. Core gains
lib/placecal/extensions.rb(PlaceCal::Extensions) andlib/placecal/theme.rb(PlaceCal::Theme). An engine registers one or more themes in itsengine.rbinitializer:Locale files need no registration; Rails loads an engine's
config/localesautomatically. Core's four built-in themes (pink,orange,green,blue) register themselves through the same API at boot, so there is exactly one code path.customstays as a legacy value that resolves to the existing slug-based lookup, so Mossley keeps working untouched.D2.
Site.themevalidates against the registry, not a static enum. Replaceenumerizeonthemewithvalidates :theme, inclusion: { in: ->(_site) { PlaceCal::Extensions.theme_names } }(the lambda takes the record, Rails passes it) and aSite#theme_definitionaccessor. Admin select reads from the registry. Anything readingSite.theme.valuesmust change.D3. Theme lookups go through
Site#theme_definition.stylesheet_link,MapHelper#style_url_for_site,SitesController#index, and the layout head all ask the theme definition. The Mossleyif slug ==branch stays, but it is reached only whentheme_definition.homepage_viewis nil. Net: core gets one new indirection and no new special cases.D4. Themes are Tailwind plus CSS custom properties. A theme stylesheet overrides the
--color-*tokens fromapp/tailwind/public/_theme.cssand may add component-level CSS. The engine builds its own CSS with@tailwindcss/cli(scanning its own views) into its ownapp/assets/builds/, which Propshaft picks up automatically because engines'app/assetsare on the asset path. The built CSS is committed in the engine repo (the engine's CI fails if it is stale), so core's Dockerfile needs no Node build step for extensions. No dartsass, no SCSS in extensions. Core's_theme.cssTODO at line 55 gets done as part of this: all themeable values become:rootcustom properties.D5. Static pages are a core model, not extension views.
Page(site_id,slug,title,bodymarkdown,body_htmlvia the existingHtmlRenderCacheconcern,position,show_in_nav,is_published). Routed at/:slugafter all other routes, with a reserved-slug validation generated fromRails.application.routes. Site admins edit their own pages in admin. TD's About and Privacy content is seeded into Pages, not shipped as views. The theme styles the page template.D6. Site navigation is derived, not configured.
sub_site_navigationbecomes: Home, Events, Partners, News (only whensite.news_article_count > 0, matching #3309), then published Pages withshow_in_nav, then a Join link when the site has acontact_email(D13). No nav editor UI in this epic.D7. The region selector is a generic partnership-tag filter. When a Site has more than one Partnership tag, the public
/events,/partnersand/pages show a segmented control of All plus the site's Partnership tags, in tag order, driven by?region=<tag slug>. All is the default.EventsQuerygainstag_id:mirroringPartnersQuery. The list is derived fromsite.tags, so adding a region to a site is admin config, not code (Kim expects more regions to sign up soon). Sites with zero or one Partnership tag see no change. Details in D19 to D25.D8. Join form on sites reuses the existing
Joinmodel and mailer.get-in-touchis routed on sites as well as the directory, the email subject carries the site name, and the theme may override the view. TD's Formspree form goes away. Field set is PlaceCal's, not TD's; recipient is per site (confirmed, D13).D9. Per-site sitemap and manifest derive from
site.url. No new columns for these. No analytics work: Plausible is retired.D10. Article to site association stays tag-derived. No
site_idon articles. #3309 owns article scoping.D11. URL compatibility. TD partner URLs already use PlaceCal slugs, and
friendly_idfinders accept numeric ids, so/partners/:id_or_slugworks. TD event URLs are/events/:idand PlaceCal's are the same. News URLs must be checked (WP 1.9). Anything that does not match gets a 301 in core's routes, keyed on the site, not hard-coded to TD.4a. Deliberate deviations from the current TD site
These are the only places the new site is allowed to differ. Georgie (GI) reviews this list on staging, not the whole site.
Joinfields, sent to the site's own contact address?region=param (D7), same URL shape, choice carried across Home, Events and Partners via nav links (D20)If an implementer finds another difference that cannot be styled away, it goes on this table in the PR, not silently into the site.
4b. Decisions taken with Kim on 2026-09-02 (from the section 7 questions)
sites.contact_email(nullable), editable in admin by root and the site admin, used byJoinMailerwhen present, falling back to the current support address. The Join link appears in a site's nav whencontact_emailis set, which keeps other sites unchanged and makes the nav derivation rule (D6) hold./privacy,/terms-of-usetoday), so TD keeps/aboutand/privacy. Site-scoped 301s are allowed wherever the audit in WP 1.9 finds a mismatch.Filter and paginator decisions, taken with Kim the same day:
region, value is the Partnership tag slug, matching TD's existing URLs. The visible label comes from locale keys (theme can override). If a future site's partnership tags are thematic, the label changes, not the param.regionis set, the site nav links to Home, Events and Partners carry it. No cookie, no session.EventsController#default_period), not TD's Today.event_filter_style :day_strip(default:date_picker). The day strip renders Today, Tomorrow and the next five days as links to the existing/events/YYYY/M/D?period=dayURLs plus an "All upcoming" link (period=future), and reuses the existing hidden fields for sort and repeating. No new query parameters.pastperiod inEventsQuery, a separate ticket.Sitehas no neighbourhood validation (app/models/site.rb:127-129), so a tagged site with none is valid. Kim wants the flexibility because more regions will sign up; making region and neighbourhood interact more cleverly is a future ticket.5. Scope by phase
Each phase is one or more PRs. Each work package (WP) below is written to be handed to an agent on its own. Model tier is the cheapest model that should do it well; the coordinator can always go up a tier. Gate is what must pass before the WP is done; every WP also passes
bundle exec rubocop,bundle exec rspec spec/i18n_keys_spec.rb, and the specs it touched.Phase 0: extension mechanism (one PR, core)
PlaceCal::Themevalue object andPlaceCal::Extensionsregistry withregister_theme,theme_names,find_theme,reset!(for specs). Built-ins registered inconfig/initializers/extensions.rb.customregistered as a legacy theme whose stylesheet resolves via the old slug lookup.lib/placecal/theme.rb,lib/placecal/extensions.rb,config/initializers/extensions.rb; specs inspec/lib/placecal/PlaceCal::Extensions.theme_namescontains exactlypink orange green blue customat bootSite.themevalidated against the registry;Site#theme_definition;stylesheet_linkandMapHelper#style_url_for_sitedelegate to it; admin select reads the registry;SitesController#indexrenderstheme_definition.homepage_viewwhen present, else existing behaviour.app/models/site.rb:65-67,237-242,app/helpers/map_helper.rb:63-80,app/views/admin/sites/form_tab_images.rb:20-29,app/controllers/sites_controller.rb,app/policies/site_policy.rbspec/models/site_spec.rb,spec/helpers,spec/system/admin/sites_spec.rb,features/admin/sites.feature; visitingmossley.lvh.meand a pink site in dev still render identically (screenshot both before and after, compare)theme_definition.headcomponent if present, after the stylesheet chain; exposecontent_for(:theme_head)as the fallback.app/views/layouts/application.rb:19-30spec/fixtures/extensions/example_theme/(engine.rb, one stylesheet, one homepage view, one locale key) registered inrails_helperfor tests only. Proves that the engine's own namespaces (ExampleTheme::Views::Home,ExampleTheme::Components::Head) autoload, that the layout can render them, that the engine's committed CSS is served by Propshaft, and that its locale file loads. The engine may callRails.autoloaders.main.push_dirfor its own directories inside itsengine.rb; core'sconfig/initializers/phlex.rband the coreViews/Componentsnamespaces stay untouched.spec/fixtures/extensions/example_theme/**,spec/rails_helper.rb, request specdoc/extensions.md: how to write an extension, the hard rule on contents, the Tailwind build recipe, and thegroup :extensionsGemfile convention. Cross-link fromdoc/ai/context.md.Escalate to Kim if: the engine's classes cannot be autoloaded without editing a core config file, or if Propshaft will not serve an engine's
app/assets/buildswithout core config changes. Either would mean every future extension forces a core edit, which breaks the boundary in section 2.Phase 1: generic core features TD needs (several small PRs, core)
All of these follow Phase 0. Within the phase, 1.1, 1.3, 1.3b, 1.4, 1.5, 1.6 and 1.8 are independent and can run in parallel. 1.2 follows 1.1 and 1.4 (it derives nav from Pages and
contact_email). 1.9 follows 1.1 and needs a local TD Site record (a dev run of the 2.7 steps).HtmlRenderCache, reserved slug validation,PagePolicy(root, editor, and the site'ssite_admin), admin CRUD followingadmin/articlespatterns, publicPagesController#showon sites,Views::Sites::Pages::Showusing the existing markdown page styling.features/admin/scenario for a site admin creating a page; reserved slugeventsrejectedComponents::Footerlink list (app/components/footer.rb:44-56). Item order must be able to reproduce TD's current nav (Home, Events, Partners, News, About, Join Us).spec/components/navigation_component_spec.rb, footer spec; coordinate with #3309 if it has landed (it adds the News link too; take theirs)EventsQuery#call(tag_id:),regionparam resolved by tag slug on local events index, partners index and site homepage;Components::EventFilter/PartnerFiltergain an All-plus-tags segmented control shown only whensite.tags.count > 1;sub_site_navigationcarriesregionon Home, Events and Partners links when set. Strings via locale keys.spec/queries/events_query_spec.rb, request specs for all three pages, component specs, nav spec proving links carry the param;features/public/browse_events.featurescenario with a two-tag site; unknown slug ignored, not 500PlaceCal::Theme#event_filter_style,Components::EventFilterrenders the day strip variant when the site's theme asks for it: Today, Tomorrow, next five days, All upcoming, current day highlighted, horizontally scrollable on mobile. Built-in themes keep the date picker.:day_strip; screenshot at 450 and 1250sites.contact_email, admin field and policy, routeget-in-touchunder the site constraint,JoinMailerrecipient from the site with fallback, subject carries the site name,Views::Sites::Join(copy of the directory view with site styling).contact_email, and to the fallback when blank; invisible captcha still appliedrequire_directory; base URL fromsite.urlwhen on a site; include pages; site robots currently advertise placecal.org's sitemap, so changeSiteRobots#published_robotsto advertise the site's own.spec/requestsfor a site sitemap; directory sitemap unchanged/manifest.webmanifestper site: name, short_name,theme_colorfrom the theme's primary token, icons fromsite.logowhen PNG else core icon. Link tag in layout head._theme.css:55TODO: every colour, font and radius used by public components is a:rootcustom property; built-in theme stylesheets override only tokens. Green theme's wrong--base-*values (orange hex inthemes/green.scss) fixed while here.yarn buildclean; visual regression run (bin/visual-regression) shows no diffs on existing sitesPhase 2: the Trans Dimension extension (new public repo, plus one Gemfile line in core)
Repo
placecal-theme-transdimension, generated withrails plugin new --full(not--mountable: the engine must not isolate its namespace or routes, it plugs into the host app) and trimmed:lib/transdimension/engine.rb,app/views/transdimension/,app/components/transdimension/,app/assets/,config/locales/,content/,package.jsonfor the Tailwind build, the built CSS committed (D4), its ownrspecsetup that boots against a checkout of core (document the recipe in 0.5).:transdimensionper D1 withevent_filter_style :day_strip, Tailwind build wired, CI running the engine's specs against coremain, LICENSE (Hippocratic, copied from TD), README with asset copyright note.theme_namesincludestransdimensionspec/system/visual_regression_spec.rbinto a committed golden set, with a manifest of URL, viewport and capture date. Done first because the Elm site will be archived and the goldens are the acceptance artifact for every later Phase 2 WP. Re-run before Phase 3 for a fresh set.src/Theme/Global.elmto token overrides plus global CSS: colours, Covik Sans via the head hook (Typekit kit id from TD'sindex.html), type scale, spacing./eventsand/partnersat the four viewports against the 2.1b goldens, reviewed by coordinatorTransdimension::Views::Home: portsrc/Theme/Page/Index.elmusing core components where they exist (event list, partner list, region filter from 1.3). Illustrations copied into the engine's assets./goldens; all copy from locale keys (transdimension.*namespace)src/Copy/Text.elm(~200 strings) toconfig/locales/en.ymlundertransdimension.*, keyed by page. Site-specific overrides of core strings (for example the events page heading) via the same file, since engine locales load after core.i18n_keys_specequivalent in the engine; reviewer confirms no hardcoded UI strings in engine views (every visible string goes throught())content/about/*.mdandprivacy.mdto arake transdimension:seed_pages[site_slug]task that upserts Pages (1.1) idempotently. Makers section (GI, GFSC) becomes part of the About page body.transdimension, urlhttps://transdimension.uk, themetransdimension, Partnership tags 3 and 30 (London, Manchester), site admin, logo, hero,contact_email, and a documented choice on neighbourhoods (D24: none means no dropdown; adding them enables it). Abin/rails transdimension:checktask that asserts the record matches.group :extensions do gem 'placecal-theme-transdimension', github: ..., tag: ... endwith the comment from section 2. Dockerfile unchanged: the repo is public so no credentials, and the engine ships its CSS prebuilt (D4).Phase 3: staging soak (no code)
config/deploy.staging.yml).Phase 4: cutover (one PR in core, runbook for the rest)
transdimension.ukandwww.transdimension.uktoproxy.hostsinconfig/deploy.production.yml(pattern: queerleeds.lgbt)build.ymlandkeepalive.ymlin the TD repo, archive the repo with a README pointer to the extension repoFuture context, not in scope: the private GFSC installation
GFSC expects to run a private overlay on the public core at some point: a small private repo that pins a core version, adds extensions (client themes, the join site, a billing portal), and owns deploy. That work needs its own PRD and is not started from this one. It matters here only because three decisions now have to leave the door open:
group :extensionsblock is the seam the private repo later takes over; keep it a single block with a comment, so removing it is one edit.Two related notes for that future PRD, recorded so they are not lost: PR #3302 (join site) is unmerged, so it could be reworked as an extension rather than merged into core, with only the persisted
ContactRequestmodel landing in core; and the Docker build has no credentials for private gems today, so a private engine needs a build secret or, better, deploys from the private repo. Mossley is deleted or converted once its fate is decided.6. Non-goals
7. Open decisions
All answered on 2026-09-02; see 4b. Nothing outstanding. New questions raised during implementation go in the PR description and are batched for Kim at the phase boundary, not asked one at a time.
8. Risks and trade-offs
9. Agent operating model (how to run this unattended)
Roles.
Brief template (what every implementer gets, nothing else):
doc/ai/prompts/views.md); no SCSS; no models or logic in the engine; nogit stash; push with an explicit refspec; do not touchdb/schema.rbunless the WP has a migration; do not edit files outside the list without saying so in the report.Parallelism. Phase 1 WPs run concurrently in separate checkouts (deliberate sibling worktrees, not auto-spawned ones), each with a private test database:
Fresh checkouts need
bin/rails dartsass:build && yarn install && yarn buildbefore request specs pass. Only the WP with the migration touchesdb/schema.rb; the coordinator scrubs stray columns before merging.Verification is the artifact, not a proxy. A WP is done when the gate commands have been run and their output is in the report. Visual WPs (1.3b, 1.8, 2.2 to 2.4) include screenshots in the report at the four viewports from
spec/system/visual_regression_spec.rb, beside the 2.1b golden for the same route. The coordinator looks at the images, not the file sizes, and rejects any difference not in the 4a table.PR discipline. One PR per phase for Phase 0 and 2, one per WP for Phase 1. Every PR is opened as a draft after merging
origin/main, with the WP table row and gate output in the description. Nothing merges without Kim. Nothing deploys without Kim.Unattended run mode. When the whole epic is run in one unattended session, the merge dependency between phases is handled with an integration branch,
feature/td-extension, cut fromorigin/main. Each WP is a sub-branch off the integration branch; its draft PR targets the integration branch, notmain; the coordinator reviews and merges WP PRs into the integration branch itself. One draft PR from the integration branch tomainis opened at the start and its description is the running progress log (WP status, gate output links, questions for Kim), updated after every WP so the state survives context compaction and is readable from a phone.mainis still never merged to without Kim. The extension repo is created by the coordinator withgh repo createunder the org as public; its ownmainis owned by the coordinator during the run. The session ends at the Phase 3 boundary: integration PR ready for review, extension repo ready, and a cutover runbook comment listing the human steps.Cost shape. Roughly: Opus for 0.1 and 0.4 and for every review; Sonnet for 0.2, 0.3, 1.1 to 1.5 including 1.3b, 2.1, 2.3; Haiku for 0.5, 1.6, 1.8, 1.9, 2.1b, 2.2, 2.4 to 2.8. Most of the tokens in this epic are CSS porting and copy migration, and those are the cheap-tier jobs.
Suggested order. 0 (serial, one session), then Phase 1 with the parallelism described there, then 2 (serial: 2.1, 2.1b, then each page builds on the tokens), then 3 and 4. The private installation work needs its own PRD and should not be started from this one.
Original speculative write-up (2026-07-25), superseded by the PRD above
Summary
Speculative / discussion issue: The Trans Dimension (transdimension.uk) is currently a standalone elm-pages static site consuming PlaceCal's GraphQL API. Maintaining it alongside PlaceCal is becoming a drag. This issue sketches what it would take to retire the standalone site and serve TD as a themed Site inside PlaceCal — without polluting PlaceCal's codebase with TD-specific code.
No commitment implied; this is a written-up feasibility investigation to react to.
Current state
The Trans Dimension repo (~9.7k lines of Elm):
eventsByFilter,partnersByTag,articlesByTag) scoped by partnership tags (3|London,30|Manchester), ~600 lines including decoders.PlaceCal side — most of what TD does already exists:
if current_site.slug == 'mossley'branch inSitesControllerplus a custom SCSS file hand-registered in the dartsass initializer.What maps over for free
url+ proxy hosts indeploy.production.yml+ Cloudflare), same as queerleeds.lgbt.What's net-new in PlaceCal
if slug ==branch and hand-edited dartsass registration with a theme registry (views, stylesheets, nav, keyed bysite.theme). This is paying down existing debt; Mossley becomes the second customer.sub_site_navigationis hardcoded to Home/Events/Partners (doesn't even include News). Generic feature, benefits every site.Avoiding codebase pollution / monolith creep
The proposal is not to move TD's code into this repo. Instead:
placecal-theme-transdimension), added via the Gemfile. Engines natively support prepended view paths, asset registration, and locale files. TD's design assets, copy, and licence stay in TD's own repo with its own maintainers.Net effect on this repo should be cleaner than today — the Mossley special-casing goes away — and the next partnership wanting a bespoke site costs a theme repo, not a fork of an Elm app.
Rough sizing
Trade-offs to accept
site_id) unless we decide otherwise.🤖 Written up by Claude Code from a joint exploration of both codebases.