Skip to content

Retire the standalone Trans Dimension site and serve it as an extension-themed PlaceCal Site (PRD) #3368

Description

@kimadactyl

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 yes
geeksforsocialchange/placecal-theme-transdimension public, Hippocratic licence, assets © Gendered Intelligence the TD extension: theme, homepage, static page content, copy, fonts, illustrations no, created in Phase 2
geeksforsocialchange/placecal-gfsc private 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-242 stylesheet_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-80 style_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.

Navigation, pages, forms

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.

The Trans Dimension (for the port)

  • Routes: /, /about, /events, /events/:id, /join-us, /news, /news/:item, /partners, /partners/:slug, /privacy.
  • Content: content/about/{main,accessibility,placecal}.md, content/about/makers/{gfsc,gi}.md, content/privacy.md. Copy: ~200 strings in src/Copy/Text.elm.
  • Design: src/Theme/*.elm (~4,300 lines elm-css), Covik Sans via Typekit, Harry Woodgate illustrations under public/images.
  • Region selector: two partnership tags (3|London, 30|Manchester) from an env constant, filtering events and partners client-side.
  • Join Us form posts to Formspree. Data window is one month back, six months forward.
  • Deploy: Cloudflare Pages, hourly rebuild via .github/workflows/build.yml, monthly keepalive.yml.
  • Licence: Hippocratic 3.0. Assets © Gendered Intelligence.

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 engine
  theme.homepage_view 'Transdimension::Views::Home'   # Phlex class name, resolved lazily
  theme.map_style     'transdimension'                # public/map-styles/<name>.json, optional
  theme.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 existing improvement
All past events button on the events page dropped; PlaceCal has no past period Kim: not important enough to build
Partner and event pages TD layouts PlaceCal layouts (map, opening times, iCal, CSV, accessibility info) styled to TD features TD lacks
Join Us form Formspree, TD's own questions PlaceCal's Join fields, sent to the site's own contact address reuse; see D8
News tag-scoped, TD layouts same scoping (D10), PlaceCal layouts styled to TD #3309 owns news
Paginator 438-line bespoke day paginator 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. 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.rb Sonnet 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. spec/fixtures/extensions/example_theme/**, spec/rails_helper.rb, request spec 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.
  • News v2: visibility follows the partner, news everywhere, RSS, publishing UX (#3308) #3309 (news v2) touches navigation and the news pages; land it first or rebase 1.2 and 2.4 on it.

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):

  1. The WP row, the relevant decisions from section 4 verbatim, and the file list.
  2. The gate as literal commands.
  3. 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.
  4. 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.
  5. Report format: files changed, gate output pasted, anything skipped.

Parallelism. Phase 1 WPs run concurrently in separate checkouts (deliberate sibling worktrees, not auto-spawned ones), each with a private test database:

export DATABASE_URL="postgres:///placecal_test_wp11"
RAILS_ENV=test bin/rails db:create db:schema:load
RAILS_ENV=test bundle exec rspec spec/models/page_spec.rb --format progress

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

  1. 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.
  2. 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.
  3. Region/tag filter UI — TD filters events/partners between two partnership tags; public PlaceCal filters by neighbourhood only.
  4. 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.
  • Design assets are © Gendered Intelligence and the TD repo is Hippocratic-licensed — keeping them in a separate theme repo sidesteps licence mixing, but worth a conscious sign-off.

🤖 Written up by Claude Code from a joint exploration of both codebases.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions