Files
logos-web/docs/cms-github-content-plan.md
T

82 KiB
Raw Blame History

Payload CMS + GitHub Content Plan

This document defines the schema, services, and implementation plan for using apps/cms as a Payload CMS editing interface for apps/web navigation, footer, and website copy, while treating GitHub as the source of truth for Builders Hub and Circles content.

Reference screens (verification pending — see §1 "Figma Node Verification"):

  • Builders Hub: Figma 40009046:23948, 40009046:24754, 40009046:25012
  • Circles: Figma 40009046:25359, 40009046:26015
  • Existing page requirements: web-pages.md
  • Existing component requirements: components.md — canonical Figma node IDs live here once reconciled
  • Operational deployment notes (Vercel for dev/staging, self-hosted Node for production, env vars, troubleshooting): deployment.md

1. Decisions

Source of Truth

The repository content files are the production data source.

  • Payload DB stores users, sessions, temporary drafts, and PR status cache only.
  • Production data changes must be represented as GitHub commits or PR diffs.
  • Schema changes must go through code review as normal code PRs.
  • Content edits and new data entries should be created from Payload Admin as branch + PR changes.
  • develop is the staging branch and the base branch for every change.
  • master is the production branch.
  • Direct commits to develop and master are blocked through GitHub branch protection. The CMS cannot enforce this on its own; branch protection must be configured at the GitHub repository level.

Branch Strategy Adoption

This plan adopts a develop-first flow. Both develop (default branch, staging) and master (production) exist on the canonical repo. CMS-generated PRs always target develop; master is reached through a separate promotion PR.

  • develop is the default branch and the staging deploy source.
  • master is the production deploy source.
  • Configure branch protection on both develop and master: require PR review, block direct push.
  • All CMS-generated PRs target develop.
  • A separate release PR promotes develop to master on a controlled cadence.

Web Consumption Model

apps/web must not call the GitHub API at request time.

  • The web app reads local content files during build.
  • Preview deployments build from PR branches; the host must produce a per-PR URL reviewers can open. Vercel covers this in dev/staging; self-hosted production replaces it with whatever PR-preview pipeline the production host runs (e.g. ephemeral container per PR).
  • Staging deploys from develop.
  • Production deploys from master. The production target is self-hosted Node (Next.js standalone build); Vercel is used only for dev/staging.

This fits the current static Next.js structure and keeps runtime reliability independent from GitHub availability.

Locale Policy

The repository routing is locale-aware (apps/web/app/[locale]/...) and the i18n stack is next-intl. Content schemas, file layout, and loaders are locale-aware from day one, even though the runtime currently only ships English.

  • The Language type is a union pre-declared as 'en' | 'fr' | 'ko' so adding a locale is a routing change, not a schema change.
  • The active locale list is routing.locales from apps/web/i18n/routing.ts, read at build time. Today that is ['en'].
  • "Required locale" elsewhere in this document means "locale present in routing.locales". Files for non-active locales (e.g. fr.json while only en is active) are neither required nor read.
  • When a locale is added to routing.ts, the corresponding <locale>.json files become required and a missing translation is a hard build error. There is no silent fallback.
  • messages/{locale}.json keeps UI chrome and validation strings.
  • content/**/<locale>/... holds page copy and structured data.

Figma Node Verification

The Figma node IDs supplied for Builders Hub and Circles were reconciled directly against the Figma file (file key qpsaED5iVKrOXoxwCWXuN3):

Plan reference Actual frame name Status
40009046:23948 Builders Hub (1440×4573) confirmed
40009046:24754 Builders Hub / Ideas (1440×2160) confirmed
40009046:25012 Builders Hub / RFPs (1440×2438) confirmed
40009046:25359 Circles (1440×4160) confirmed
40009046:26015 Circles Detail Page (1440×2014) confirmed

The earlier conflict with docs/components.md (which listed 40009046:23948 as Footer desktop) was a stale entry in components.md, not a node-ID mismatch in this plan. Update docs/components.md so Footer desktop points at its actual node and Builders Hub points at 40009046:23948.

Note on Figma layer names: many sections inside these frames are named Tech stack in Figma. That layer name is a generic section-wrapper convention in this design file, not a reference to the /technology-stack page. Treat layer names as descriptive only.

Media Storage

Phase 1 stores all uploaded media inside the repository under apps/web/public/cms/<type>/<slug>/<file>. Editors upload through Payload; the CMS commits the binary alongside the JSON change in the same PR.

  • Acceptable file types: png, jpg, webp, svg, mp4 (small).
  • Hard size cap per file: 2 MB. Larger uploads are rejected with a clear message.
  • MediaRef.src stores the absolute path from apps/web/public, e.g. /cms/circles/los-angeles/cover.webp.
  • An external storage migration (S3 / R2 / similar) is planned. The MediaRef shape and loader API are designed so that the migration only swaps the storage adapter, not the schema.

Package and Naming

  • Shared content package name: @repo/content.
  • Content directory at the repo root: content/.
  • Branch prefix for CMS PRs: content/.
  • File renames performed during this work: apps/web/components/site-headaer.tsx becomes apps/web/components/site-header.tsx as part of Phase 2.

2. Target Architecture

flowchart LR
  Editor["Editor in Payload Admin"] --> CMS["apps/cms"]
  CMS --> Validator["Zod / JSON Schema validation"]
  Validator --> GitHub["GitHub Contents + Pull Requests API"]
  GitHub --> Repo["content/*.json, *.mdx + apps/web/public/cms/*"]
  Repo --> WebBuild["apps/web build"]
  WebBuild --> StaticSite["Static pages"]

  CMS --> PayloadDB["Payload SQLite/Postgres"]
  PayloadDB --> Cache["users, sessions, drafts, PR cache, file locks"]

Repository Layout

The content tree is locale-aware for any data that contains user-visible copy. Data that is intrinsically locale-agnostic (geographic coordinates, IDs, structural relations) lives outside the locale folder, and locale-specific labels live alongside.

content/
  site/
    <locale>/
      settings.json
      navigation.json
      footer.json
  pages/
    <locale>/
      home.json
      technology-stack.json
      technology-stack-storage.json
      technology-stack-messaging.json
      technology-stack-blockchain.json
      technology-stack-networking.json
      builders-hub.json
      circles.json
      about.json
      take-action.json
      node-program.json
      book.json
      links.json
      faq.json
      work-with-us.json
      brand-guidelines.json
      press.json
      blog.json
      terms.json
      privacy.json
      security.json
  builders-hub/
    settings/
      <locale>.json
    rfps/
      secure-decentralized-frontends/
        index.json            # locale-agnostic fields (slug, reward, applyUrl, dates)
        <locale>.json         # title, summary, description per locale
    ideas/
      quadratic-voting-platform/
        index.json
        <locale>.json
    resources/
      <locale>.json
  circles/
    settings/
      <locale>.json
    circles/
      los-angeles/
        index.json            # coordinates, timezone, joinUrl, status
        <locale>.json         # name, description per locale
    events/
      los-angeles-circle-4/
        index.json            # startsAt, endsAt, circleSlug
        <locale>.json
    initiatives/
      logos-powered-nextdoor-app/
        index.json
        <locale>.json
    resources/
      <locale>.json

The split between index.json (locale-agnostic) and <locale>.json (per-locale) prevents duplicating coordinates, dates, or relations across locale files when a translation is added.

Shared Package Layout

Content types, validation, loaders, and GitHub mutations live in @repo/content instead of being duplicated across apps.

packages/content/
  src/
    schemas/
      common.ts          # MediaRef, CTA, Language, PublishState, schemaVersion helpers
      site.ts
      pages.ts           # PageCopy with typed `componentType` sections + PageSeo
      builders-hub.ts
      circles.ts
    loaders/
      site.ts
      pages.ts
      builders-hub.ts
      circles.ts
    locales/
      registry.ts        # reads routing.ts at build time, drives required-locale check
    github/
      client.ts
      mutations.ts
      pull-requests.ts
      locks.ts           # detects open PRs touching a path
    media/
      adapter.ts         # interface
      repo-adapter.ts    # Phase 1: writes to apps/web/public/cms/*
      external-adapter.ts# Phase 6: S3 / R2
    index.ts

apps/cms and apps/web use the same schemas and loaders. Data that passes CMS validation must pass the same validation during web builds.

3. Payload CMS Model

Globals

Site Settings

The current siteSettings global only contains siteName and siteDescription. It is expanded into a structured site settings model.

type SiteSettings = {
  schemaVersion: 1
  language: Language
  siteName: string
  siteTitle: string
  siteDescription: string
  canonicalUrl: string
  keywords: string[]
  defaultOgImage?: MediaRef
  social: {
    twitter?: string
    discord?: string
    youtube?: string
    github?: string
  }
}

Navigation

Move SITEMAP and COMMUNITY out of apps/web/components/site-header.tsx and into CMS-managed content. Press thumbnails on the open overlay come from the Press collection, not from a separate Navigation field.

type Navigation = {
  schemaVersion: 1
  language: Language
  closedBar: {
    brandLabel: string             // wordmark text shown left ("LOGOS")
    menuLabel: string              // closed-state button text ("MENU")
    closeLabel: string             // open-state button text ("CLOSE MENU")
    openAriaLabel: string          // "Open navigation menu"
    closeAriaLabel: string         // "Close navigation menu"
  }
  sitemap: NavLink[]
  communityCards: Array<{
    label: string
    href: string
    description: string
    image: MediaRef
  }>
  press: {
    label: string                  // section title in overlay ("Press")
    seeAllLabel: string             // "SEE ALL"
    seeAllHref: string              // /press
    visibleCount?: number           // Press Engine query limit; UI applies default of 4 when omitted
  }
}

The Navigation loader reads only chrome labels and links from repo content. Article cards are fetched dynamically from Logos Press Engine at build time by the web app.

Move footer link groups and tagline out of apps/web/components/site-footer.tsx and into CMS-managed content.

type Footer = {
  schemaVersion: 1
  language: Language
  tagline: string
  image: MediaRef
  mainLinks: FooterLink[]
  socialLinks: FooterLink[]
  researchLinks: FooterLink[]
  infrastructureLinks: FooterLink[]
  legalLinks: FooterLink[]
  builtBy: {
    label: string
    attribution: string
    href?: string
  }
}

Collections

Pages

The current pages collection only has title, slug, and content. Replace it with route-level structured copy where every section declares the component it renders. Freeform rich text is too loose for Figma-mapped sections, and a Record<string, ...> shape is too loose for editors — they cannot tell which fields a given section requires.

type PageCopy = {
  schemaVersion: 1
  language: Language
  route: string
  title: string
  description: string
  heading?: string
  seo?: PageSeo
  sections: PageSection[]
}

type PageSeo = {
  metaTitle?: string             // overrides `title` for <title>
  metaDescription?: string       // overrides `description` for meta description
  keywords?: string[]
  ogImage?: MediaRef
  noindex?: boolean
  canonicalUrl?: string
}

type PageSection =
  | HeroSection
  | RichTextSection
  | CardGridSection
  | TableSection
  | GiantSwitchSection
  | RelatedArticlesSection
  | CtaPanelSection
  | GallerySection
  | TechStackOverviewSection
  | CustomSection                // escape hatch with explicit JSON schema

type HeroSection = {
  componentType: 'hero'
  key: string                    // e.g. 'home.atf'
  eyebrow?: string
  headline: string
  body?: string
  background?: MediaRef
  ctas?: CTA[]
}

type CardGridSection = {
  componentType: 'cardGrid'
  key: string
  heading?: string
  subheading?: string
  cards: Array<{
    title: string
    description?: string
    image?: MediaRef
    cta?: CTA
    showIcon?: boolean
  }>
}

type GiantSwitchSection = {
  componentType: 'giantSwitch'
  key: string
  accent: 'grey' | 'yellow'
  imagePosition: 'left' | 'right'
  title: string
  description: string
  image: MediaRef
  tags?: Array<{
    label: string
    icon?: 'wallet' | 'chat' | 'files' | 'explorer' | 'lambda' // names map to React SVG icons in @acid-info/logos-ui/icons
  }>
  primaryCta?: CTA
  secondaryCta?: CTA
}

type RelatedArticlesSection = {
  componentType: 'relatedArticles'
  key: string
  label?: string
  eyebrow?: string
  title: string
  cta?: CTA
  visibleCount?: number          // Press Engine query limit; UI applies default of 4 when omitted
}

// Minimal placeholders for sections used today — full shapes filled in alongside Figma walkthrough.
type RichTextSection = { componentType: 'richText'; key: string; body: string }
type TableSection = {
  componentType: 'table'
  key: string
  title: string
  subtitle?: string
  action?: CTA
  rows: Array<{
    number?: string
    title: string
    description?: string
    reward?: { amount: number; currency: 'USDC'; xp?: number }
    cta?: CTA
  }>
}
type CtaPanelSection = { componentType: 'ctaPanel'; key: string; title: string; description?: string; image?: MediaRef; cta: CTA }
type GallerySection = { componentType: 'gallery'; key: string; items: Array<{ image: MediaRef; caption?: string; date?: string }> }
type TechStackOverviewSection = {
  componentType: 'techStackOverview'
  key: string
  pillars: Array<{ id: 'storage'|'messaging'|'blockchain'|'userModules'; title: string; body: string; href: string }>
  networkingTitle: string
  foundationTitle: string
}
type CustomSection = {
  componentType: 'custom'
  key: string
  customSchemaId: string         // names a Zod schema registered in @repo/content/schemas/custom-sections
  payload: unknown               // shape validated against the named schema at load time
}

Section key mirrors Figma section keys, for example home.atf, home.techStack, buildersHub.heroSection, circles.heroSection. The CMS form per componentType renders the matching field set, so editors never see "what does this Record key mean?".

Tech-stack sub-pages (storage, messaging, blockchain, networking) reuse RelatedArticlesSection so the four article cards on every sub-page resolve from the central Press collection.

4. GitHub Data Schema

Shared Types

type Language = 'en' | 'fr' | 'ko'

type MediaRef = {
  src: string         // absolute path from apps/web/public, e.g. /cms/...
  alt: string
  width?: number
  height?: number
  credit?: string
}

type CTA = {
  label: string                                            // stored sentence case (e.g. "Join this circle"); component renders uppercase via CSS
  href: string
  external?: boolean
  variant?: 'primary' | 'secondary' | 'tertiary' | 'link' // default 'primary'
  iconOverride?: 'arrow-right' | 'arrow-left' | 'none' | string // default 'arrow-right'; back-link CTAs use 'arrow-left'
}

type PublishState = 'draft' | 'review' | 'published' | 'archived'

// Every persisted content type carries a schemaVersion from day one
// so that future migrations can detect and upgrade older payloads.
type WithSchemaVersion = { schemaVersion: number }

Ordering Convention

There are two cases:

  • Embedded arrays inside one JSON file (e.g. overviewLinks[], actionPanels[], communityCards[], organizers[], cards[] inside a CardGridSection): array order is authoritative. No order: number field. Editors reorder by drag-and-drop, Payload writes the new array order back.
  • Collections of separate files (e.g. RFP, Idea, Circle, CircleEvent, CircleInitiative, BuilderResource, CircleResource): each record may carry an optional order?: number. The loader sorts by order ascending, then publishedAt descending, then slug for tie-break. This is necessary because separate files have no inherent array position.

Identity and Slugs

slug is the canonical key for every record (locale-agnostic, lowercase kebab-case). The earlier id: string field is dropped from all schemas. Cross-references (relatedIdeas, circleSlug, pinnedSlugs) are slug-based.

Slug Renames

Slug renames are not supported through the standard editor flow. To rename a slug, an admin runs pnpm --filter @repo/content rename-slug <type> <old> <new>, which:

  1. Renames the directory.
  2. Sweeps every JSON file for references and rewrites them.
  3. Adds an entry to a permanent content/redirects.json so the old URL 301s to the new one.

The CMS UI hides the slug field after creation and shows a "Rename via CLI" hint instead, to prevent accidental dead references.

Locale-Agnostic vs Localized Fields

Place names like Circle.city, Circle.country, CircleEvent.venueName, and CircleEvent.address are stored on index.json (locale-agnostic) and treated as English by default. When a locale that requires translated place names is activated, these fields move into the <locale>.json shape; the migration is documented as a Phase 5 schema bump because it changes the storage layout. Person names (organizers[].name, submitter.name, author.name) stay locale-agnostic indefinitely.

Date and Time Display

startsAt, endsAt, publishedAt, submittedAt, closesAt are always ISO 8601 in UTC. Surface-specific display strings are produced by the loader, not stored:

Surface Example Generated by
Circles index event group header JANUARY 21 / WEDNESDAY formatEventDateForSurface(event, 'index-group', locale)
Circles index event card (no date — date is in the group header above) time row only: 12:00 PM · 6:00 PM GMT+1 (event-local · viewer-local) formatEventDateForSurface(event, 'index-card', locale)
Circle detail event card calendar row February 22, 2026, clock row 12:00 PM formatEventDateForSurface(event, 'detail-card', locale)
Press article card 02.14.26 Logos Press Engine response

The index-card time row pairs the event's local time with the viewer's local time using the browser's Intl.DateTimeFormat. The detail-card surface omits viewer-local conversion to keep copy unambiguous when the page is shared.

CircleEvent carries displayDateOverride, displayWeekdayOverride, and displayTimeOverride strictly as escape hatches for events whose auto-formatted output is wrong (e.g. multi-day events, unusual venues). The CMS form leaves these blank by default; if filled, they take precedence on every surface uniformly.

Event Card Surface Mapping

Surface Date row Title Location field used Avatar stack
Circles index card (463×135) none — date is in the date+weekday group header above CircleEventLocale.title locationLabel (city + region) up to 3 avatars; missing slots fall back to grey placeholder circles
Circle detail card (702×234) yes — calendar row + separate clock row CircleEventLocale.title venueName (single venue) up to 3 avatars; missing slots fall back to grey placeholder circles

Builders Hub

The relevant screens are /builders-hub, /builders-hub/ideas, and /builders-hub/rfps. The content model needs RFPs, ideas, resources, and page-level CTA/banner settings.

BuilderHubSettings

Stored at content/builders-hub/settings/<locale>.json.

type BuilderHubSettings = {
  schemaVersion: 1
  language: Language
  hero: {
    title: string                           // "Logos Builders Hub"
    description?: string                    // right-side intro paragraph
    eyebrow?: string                        // small top-left label, optional
    backLink?: { label: string; href: string } // small "← <parent>" link above the hero
    topRightCta?: CTA                       // top-right utility CTA in the hero (e.g. external link)
  }
  overviewLinks: Array<{
    key: 'ideas' | 'rfps' | 'resources' | 'github-issues' | 'contribute' | string
    title: string                           // "Ideas", "RFPs", etc.
    description: string                     // single-line summary shown to the right of the title
    primaryCta: CTA                         // first of the two CTAs on each row (60×17 in Figma)
    secondaryCta?: CTA                      // second CTA on each row (78×17 in Figma)
  }>
  // The home-page submit CTAs and the listing-page submit CTAs are intentionally
  // separate: the home page often shows shorter copy ("Submit") next to a section
  // header, while the listing page uses the full call-to-action ("Submit an RFP →")
  // in the hero. Keep both fields so editors can phrase each one for its surface.
  //
  // Figma shows a 2×4 RFP grid (7 RFP cards + 1 terminator tile cross-promoting Ideas)
  // and a 7-row Ideas table on the home page. The fields below let editors control
  // exactly which slugs appear on the home and how many slots are filled.
  rfpsSection: {
    title: string
    description?: string
    filterCta?: CTA
    seeAllCta: CTA
    submitCta?: CTA           // home-page section header; listing page uses BuilderHubListingPageSettings.submitCta
    pinnedSlugs?: string[]    // RFPs displayed on the home grid; loader fills remaining slots from latest published
    displayCount?: number     // total tiles on the home grid (Figma default 7 RFP cards + 1 terminator = 8)
    terminatorCard?: {        // last grid cell: cross-promotion tile with thumbnails of related items
      kind: 'see-all-rfps' | 'see-all-ideas'
      title: string                  // "See all ideas"
      thumbnailSlugs: string[]       // 4 slugs from the linked collection (ideas / rfps)
      href: string                   // entire card is the click surface; Figma renders no inner CTA button
    }
  }
  ideasSection: {
    title: string
    description?: string
    seeAllCta: CTA
    submitCta?: CTA
    pinnedSlugs?: string[]    // Ideas displayed on the home table; loader fills remaining slots from latest published
    displayCount?: number     // number of rows on the home (Figma default 7)
  }
  appInstall: {
    accent: 'grey' | 'yellow'                              // GiantSwitch accent
    imagePosition: 'left' | 'right'                        // GiantSwitch image position
    title: string
    description: string
    tags: Array<{
      label: string
      icon?: 'wallet' | 'chat' | 'files' | 'explorer' | 'lambda'
    }>
    installCta: CTA
    learnMoreCta: CTA
    image: MediaRef
  }
  actionPanels: Array<{
    variant: 'image-overlay' | 'flat'    // Figma shows two distinct treatments: rounded image-overlay (boilerplate apps) and flat dark-green pill (office hours)
    title: string
    description?: string
    image?: MediaRef                     // required when variant = 'image-overlay'
    cta: CTA
  }>
  officeHours?: {
    title: string
    description: string
    cta: CTA
  }
  resourcesSection: {
    title: string
    description?: string
    helpCenterCta?: CTA
  }
}

RFP

Locale-agnostic root at content/builders-hub/rfps/<slug>/index.json:

type RFPIndex = {
  schemaVersion: 1
  slug: string
  status: PublishState
  reward: {
    amount: number
    currency: 'USDC'
    xp?: number
  }
  applyUrl: string
  tags: string[]
  image?: MediaRef
  featured: boolean
  order?: number
  publishedAt?: string
  closesAt?: string
  owner?: {
    name: string
    handle?: string
  }
  relatedIdeas?: string[]   // slug references; validated for existence at load time
}

Per-locale copy at content/builders-hub/rfps/<slug>/<locale>.json:

type RFPLocale = {
  language: Language
  title: string
  summary: string
  description: string
  ctaLabel?: string
}

relatedIdeas is a one-way reference. The reverse direction is computed by the loader at build time, not stored on Idea, to avoid two-sided sync drift.

Idea

Same split: content/builders-hub/ideas/<slug>/index.json and <slug>/<locale>.json.

type IdeaIndex = {
  schemaVersion: 1
  slug: string
  status: PublishState
  submitter: {
    name?: string
    handle: string         // stored without leading '@'
  }
  reward?: {               // Figma renders rewards on Idea rows in both the home table and the listing
    amount: number
    currency: 'USDC'
    xp?: number
  }
  image?: MediaRef
  tags: string[]
  featured: boolean
  order?: number
  submittedAt?: string
  discussionUrl?: string
}

type IdeaLocale = {
  language: Language
  title: string
  summary: string
  description: string
  ctaLabel?: string
}

Idea.relatedRfps is dropped from the schema. Reverse links are computed by the loader from RFP.relatedIdeas.

BuilderResource

The Builders Hub resources list lives in a single per-locale file content/builders-hub/resources/<locale>.json shaped as:

type BuilderResourcesFile = {
  schemaVersion: 1
  language: Language
  items: BuilderResourceLocale[]
}

type BuilderResourceLocale = {
  slug: string                 // unique within this collection
  title: string
  description: string
  ctaLabel: string
  href: string                 // duplicated across locale files; loader warns on divergence
  status: PublishState         // duplicated across locale files; loader warns on divergence
}

Resources are kept simple (per-locale flat) rather than split into index.json + <locale>.json because there is no image, coordinates, or other heavy locale-agnostic state worth deduplicating. The loader emits a warning in CI if href or status for the same slug diverges between locale files. Same shape applies to CircleResource.

BuilderHubListingPageSettings

type BuilderHubListingPageSettings = {
  schemaVersion: 1
  language: Language
  page: 'ideas' | 'rfps'
  title: string
  description: string
  breadcrumbLabel: string
  submitCta: CTA
  defaultView: 'grid' | 'list'
  pageSize: number
  pagination: {
    previousLabel: string
    nextLabel: string
  }
  bottomCta: {
    title: string
    cta: CTA
  }
}

Detail Page Decision

apps/web currently routes /builders-hub/rfps and /builders-hub/ideas as listing pages only. Detail pages (/builders-hub/rfps/[slug], /builders-hub/ideas/[slug]) are out of scope for Phase 1. The schema keeps slug and a full description so detail pages can be added later without a data migration. applyUrl and discussionUrl continue to be the primary user destinations until then.

Circles

The relevant screens are /circles and /circles/[slug]. The content model needs circles, events, initiatives, resources, and map settings.

CirclesSettings

Stored at content/circles/settings/<locale>.json.

type CirclesSettings = {
  schemaVersion: 1
  language: Language
  hero: {
    title: string                       // "Logos Circles"
    description: string                 // long body copy on the right
    eyebrow?: string                    // small top-left label
    backLink?: { label: string; href: string }  // small "← <parent>" link above hero
    topRightCta?: CTA                   // small top-right utility link
    findCta: CTA                        // primary bottom-left CTA ("Find a Circle Near You")
    joinCta: CTA                        // primary bottom-right CTA ("Start a Circle")
  }
  map: {
    defaultZoom: number
    defaultCenter: { lat: number; lng: number }
    image?: MediaRef                    // static-image fallback for Phase 1
    zoomInAriaLabel: string             // aria-label for the icon-only zoom-in button
    zoomOutAriaLabel: string            // aria-label for the icon-only zoom-out button
  }
  // Section between the map and the events list. The Figma copy ("These meetups are
  // essential for anyone taking actions with us...") matches messages.en.json
  // home.circlesCta.body, so this block is the find-a-circle-nearby CTA reused on
  // /circles. The Figma layer name "Header → Video: Powering Effective Crisis Response"
  // is a designer note, not the rendered headline.
  nearbyCta: {
    title: string                       // e.g. "Circles and Counting." (was home.circlesCta.headline)
    description?: string
    cta: CTA
  }
  eventsSection: {
    title: string
    description?: string
    calendarCta: CTA
  }
  initiativesSection: {
    title: string
    cta?: CTA
  }
  detailJoinCtaLabel: string             // shared label for the hero Join button on every Circle detail page (Figma value: "Join this circle"). Stored sentence case; component uppercases.
  resourcesSection: {
    title: string
    description?: string
    helpCenterCta?: CTA
  }
}

Circle

content/circles/circles/<slug>/index.json:

type CircleIndex = {
  schemaVersion: 1
  slug: string
  status: PublishState
  city: string
  country: string
  region?: string
  coordinates: {
    lat: number
    lng: number
  }
  // GeoJSON-friendly accessor (loader computed): [lng, lat]
  timezone: string
  memberCount?: number
  discordChannel?: string         // stored without leading '#'; UI prepends it (Figma displays "#circle-los-angeles")
  discordUrl?: string
  forumUrl?: string
  joinUrl: string
  image?: MediaRef
  organizers?: Array<{
    name: string
    handle?: string
    avatar?: MediaRef
  }>
  order?: number
  detailBackLink?: { label: string; href: string } // "← All circles" link above the detail-page hero (defaults to { label: 'All circles', href: '/circles' }; sentence case stored, CSS uppercases)
}

type CircleLocale = {
  language: Language
  name: string
  description: string
}

The loader exposes both coordinates: { lat, lng } and coordinatesGeoJson: [lng, lat] so map libraries get the order they expect without re-shaping at the call site.

CircleEvent

content/circles/events/<slug>/index.json:

type CircleEventIndex = {
  schemaVersion: 1
  slug: string
  status: PublishState
  circleSlug: string         // must reference an existing circle
  startsAt: string           // ISO 8601 in UTC — primary source of truth for all date/time rendering
  endsAt?: string
  timezone: string           // IANA timezone for the event venue
  displayDateOverride?: string    // escape hatch — only used when the auto-formatted date must be replaced (rare)
  displayWeekdayOverride?: string
  displayTimeOverride?: string
  venueName?: string         // e.g. "Chateau Marmont"
  address?: string
  eventUrl?: string
  image?: MediaRef           // shown on the detail-page card (702×234) and the index card (463×135)
  hostedBy: Array<{
    name: string             // e.g. "InfinityBase Events"
    avatar?: MediaRef        // small avatar shown in the host stack
  }>
  featured: boolean
  sequenceNumber?: number    // optional series number, e.g. 5 → renders as "Los Angeles Circle #5". Loader auto-prefills max+1 within circleSlug.
}

type CircleEventLocale = {
  language: Language
  title: string              // override; loader prefills from circle.name + sequenceNumber, e.g. "Los Angeles Circle #5"
  locationLabel: string      // city + region shown on the index card with map-pin icon (e.g. "Florianópolis, Santa Catarina"). Detail card uses `venueName` instead.
}

CircleInitiative

content/circles/initiatives/<slug>/index.json:

type CircleInitiativeIndex = {
  schemaVersion: 1
  slug: string
  status: PublishState
  circleSlug: string
  href: string
  image: MediaRef
  featured: boolean
  order?: number
}

type CircleInitiativeLocale = {
  language: Language
  locationLabel: string
  title: string
  description: string
  ctaLabel: string
}

CircleResource

Stored at content/circles/resources/<locale>.json, same shape as BuilderResourcesFile:

type CircleResourcesFile = {
  schemaVersion: 1
  language: Language
  items: CircleResourceLocale[]
}

type CircleResourceLocale = {
  slug: string
  title: string
  description: string
  ctaLabel: string
  href: string
  status: PublishState
}

Press

Press articles are not stored in repo content or edited through this CMS. The open-overlay Press section in the navbar, the homepage Press strip, the /press index, and every tech-stack sub-page's Related Articles block fetch article data from Logos Press Engine.

Repo content owns only surrounding chrome copy such as section labels, CTA labels, and visibleCount. Article title, author, image, date, and canonical URL come from https://press.logos.co/api/search.

5. Figma Coverage Audit

The schemas in this plan were validated directly against the Figma file (file key qpsaED5iVKrOXoxwCWXuN3) for all five frames listed at the top of this document. Coverage tables below reflect that walkthrough. Before implementing each section, re-pull the matching Figma frame spec (fontSize, fills, gaps, padding) and verify that the content schema captures every text and image slot the design uses.

Builders Hub Coverage

Figma frame Required content Coverage
40009046:23948 Builders Hub Hero title, intro copy, optional eyebrow, optional back link, optional top-right CTA BuilderHubSettings.hero
40009046:23948 Builders Hub Top overview rows: Ideas, RFPs, Resources, GitHub Issues, Contribute, each with two CTAs BuilderHubSettings.overviewLinks (primaryCta + secondaryCta)
40009046:23948 Builders Hub Logos App Giant Switch content, tags, image, install CTA, learn-more CTA BuilderHubSettings.appInstall
40009046:23948 Builders Hub RFP card grid (7 cards) + cross-promotion See all ideas terminator tile + filter CTA + see-all CTA BuilderHubSettings.rfpsSection with pinnedSlugs, displayCount, terminatorCard + RFP
40009046:23948 Builders Hub Ideas table (7 rows on home) BuilderHubSettings.ideasSection with pinnedSlugs, displayCount + Idea
40009046:23948 Builders Hub Two large CTA panels: boilerplate apps and office hours BuilderHubSettings.actionPanels and BuilderHubSettings.officeHours
40009046:23948 Builders Hub Additional Resources table: Quick Start, Documentation, Boilerplates, Video Tutorials BuilderResourcesFile and BuilderHubSettings.resourcesSection
40009046:24754 Ideas Breadcrumb, page header (logomark + title), submit CTA, grid/list toggle, default list view BuilderHubListingPageSettings
40009046:24754 Ideas Ten list rows (no pagination — pageSize reaches list count) Idea plus listing settings
40009046:24754 Ideas Bottom CTA: We want your ideas. BuilderHubListingPageSettings.bottomCta
40009046:25012 RFPs Breadcrumb, page header, submit CTA, grid/list toggle, default grid view BuilderHubListingPageSettings
40009046:25012 RFPs RFP cards and list rows with reward and apply CTA RFP

Circles Coverage

Figma frame Required content Coverage
40009046:25359 Circles Hero title, intro copy, optional eyebrow + top-right CTA + back link, find/join CTAs CirclesSettings.hero
40009046:25359 Circles World map image (1432×926), city markers, two icon-only zoom buttons CirclesSettings.map (with aria-only zoom labels) + Circle.coordinates
40009046:25359 Circles Find-a-circle-nearby CTA block below the map (matches home.circlesCta body copy) CirclesSettings.nearbyCta
40009046:25359 Circles Upcoming events grouped by date + weekday header with divider CircleEvent.displayDate, displayWeekday, startsAt + getCircleEventsGroupedByDate loader
40009046:25359 Circles Index event card (463×135): location, date/time, venue, host stack, image CircleEvent
40009046:25359 Circles Winnable Issues cards (3 across) with location label, title, body, CTA, background image CircleInitiative
40009046:25359 Circles Circles Resources table: Start your own Circle, Forum, Discord CircleResourcesFile and CirclesSettings.resourcesSection
40009046:26015 Circle detail Back link "← Circles" above hero, city title (logomark + name), description, members/Discord/forum metadata, Join CTA Circle + Circle.detailBackLink
40009046:26015 Circle detail Metadata rows: members count, Discord channel #circle-..., Forum URL Circle.memberCount, discordChannel (no leading #), discordUrl, forumUrl
40009046:26015 Circle detail Detail event cards (702×234) with image, "Circle #N" title, date / time / location rows, host stack with avatars CircleEvent (incl. sequenceNumber, hostedBy[].avatar) filtered by circleSlug
40009046:26015 Circle detail Two initiative cards (702×282) with location, title, body, CTA, image CircleInitiative filtered by circleSlug

Tech Stack and Home Coverage

Page Required content Coverage
/ Home ATF hero, Build/Node/Circles triptych, About teaser, Tech stack, Use cases, App Install banner, Builder Portal, Editorial image, Press strip, Circles teaser PageCopy with hero, cardGrid, richText, techStackOverview, giantSwitch, ctaPanel, gallery, relatedArticles sections
/technology-stack Hero, four pillar cards, networking + foundation rows, App Install banner, large circle image, Use cases PageCopy with hero, techStackOverview, giantSwitch, cardGrid sections
/technology-stack/storage Hero, intro, builder CTA panel, related articles, sibling tech-stack overview PageCopy with hero, richText, ctaPanel, relatedArticles, techStackOverview
/technology-stack/messaging Hero, intro, case studies, builder CTA panel, related articles Same as above plus a cardGrid for case studies
/technology-stack/blockchain Hero, privacy/cryptarchia explainers, builder CTA panel, related articles Same as above plus a cardGrid for blendNetwork and lez
/technology-stack/networking Hero, intro, three feature cards, builder CTA panel, related articles Same as above
/press Engine label, list of latest articles UI copy from translations plus Logos Press Engine data

Home → Content Migration Map

The migration moves keys out of apps/web/messages/en.json into content/pages/en/*.json. UI chrome, validation strings, and label-only keys stay behind.

messages.en.json path Content destination
home.atf.* content/pages/en/home.json → section home.atf (hero)
home.build.* / home.node.* / home.circles.* content/pages/en/home.json → section home.triptych (cardGrid)
home.about.* content/pages/en/home.json → section home.about (richText)
home.techStack.* content/pages/en/home.json → section home.techStack (techStackOverview)
home.useCases.* content/pages/en/home.json → section home.useCases (cardGrid)
home.parallelSociety.* / home.mountain.* content/pages/en/home.json → section home.parallelSociety (gallery)
home.builderPortal.* content/pages/en/home.json → section home.builderPortal (ctaPanel)
home.press.* content/pages/en/home.json owns the section label, CTA, and visible count; article rows come from Logos Press Engine.
home.circlesCta.* content/pages/en/home.json → section home.circlesCta (ctaPanel)
pages.<route>.title/description/heading content/pages/en/<route>.json → top-level title, description, heading
pages.<route>.hero.* / pages.<route>.intro.* etc. content/pages/en/<route>.json → matching section
footer.* content/site/en/footer.json
common.*, locale.* Stays in messages/<locale>.json

The migration ships incrementally: each route can move independently. After a route migrates, its pages.<route>.* keys are deleted from messages/<locale>.json in the same PR.

6. Service Design

Content Loader Service

Location: packages/content/src/loaders/*

Responsibilities:

  • Read local content/** files.
  • Validate data with Zod schemas.
  • For each entity, merge index.json with the requested <locale>.json.
  • Filter unpublished data for production builds.
  • Normalize sort/order values.
  • Resolve one-way references and compute reverse references (for example, idea.relatedRfps is derived from rfp.relatedIdeas).
  • Return view models for apps/web.

Example API:

getNavigation(locale: Language)
getFooter(locale: Language)
getPageCopy(route: string, locale: Language)
getBuilderHubHome(locale: Language)
getRfps({ locale, status, page, pageSize })            // view is a render concern, not a loader concern
getIdeas({ locale, status, page, pageSize })
getCircles(locale: Language)
getCircleBySlug(slug: string, locale: Language)
getCircleEvents({ circleSlug, locale })
getCircleEventsGroupedByDate({ locale })          // returns Array<{ date, weekday, events: CircleEvent[] }> for the index page
getCircleInitiatives({ circleSlug, locale })
resolveBuilderHubHomeRfps(locale: Language)        // applies pinnedSlugs + displayCount + terminator card from BuilderHubSettings.rfpsSection
resolveBuilderHubHomeIdeas(locale: Language)       // applies pinnedSlugs + displayCount from BuilderHubSettings.ideasSection
formatEventDateForSurface(event, surface: 'index-group' | 'index-card' | 'detail-card' | 'press', locale)
                                                    // returns the surface-specific string ("JANUARY 21 / WEDNESDAY", "February 22, 2026", "02.14.26", etc.)
                                                    // formed from event.startsAt + timezone + locale unless an override field is set

A missing locale file is a build error, not a fallback. The CI step content:validate runs the loader against every supported locale and fails loudly if any required file is missing.

Media Adapter

Location: packages/content/src/media/*

Responsibilities:

  • Accept editor uploads from Payload Admin.
  • Validate file type and size (2 MB hard cap in Phase 1).
  • Persist the binary and produce a MediaRef.src value.

Phase 1 implementation: repo-adapter.ts writes the binary into apps/web/public/cms/<type>/<slug>/<file> and includes it in the same PR as the JSON change.

Phase 6 plan: a second adapter writes to external storage and returns a CDN URL. Switching adapters does not require any schema change because MediaRef is already an opaque src + alt + dimensions shape.

GitHub Mutation Service

Location: packages/content/src/github/*

Responsibilities:

  • Authenticate as a GitHub App. Personal access tokens are not supported.
  • Create branches from develop.
  • Read, update, create, and delete files (JSON and media binaries).
  • Create commits.
  • Create or update pull requests targeting develop.
  • Query PR status for the PR status panel.
  • Detect open PRs that touch a path (file lock check, see below).

Required environment variables:

GITHUB_OWNER=
GITHUB_REPO=
GITHUB_STAGING_BRANCH=develop
GITHUB_PRODUCTION_BRANCH=master
GITHUB_PR_BASE_BRANCH=develop
GITHUB_CONTENT_BRANCH_PREFIX=content/
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
GITHUB_INSTALLATION_ID=
CONTENT_DIRECT_COMMIT_ENABLED=false

GitHub App authentication is the only supported mode for all CMS environments, including local development.

File Lock / Concurrent Edit Guard

Location: packages/content/src/github/locks.ts

Two editors editing the same file produce two PRs and a merge conflict. The CMS guards against this at form-load time and at save time.

  • On form load: the CMS resolves the document's target path and shows recent cached PRs plus any live open PR that touches that path.
  • On save: the same target-path check runs again. If an open PR already touches the path, the CMS commits the updated JSON and media to that PR's branch instead of creating a duplicate branch.
  • PR state is cached in Payload DB (ContentChangeRequest table) and refreshed by live GitHub lookups where the Admin UI needs current status.

CMS Workflow Service

Location: apps/cms/src/services/content-workflow/*

Responsibilities:

  • Convert Payload form data into typed content schema data.
  • Run schema validation and surface errors in the Admin UI.
  • Extend Payload's default save so repo-backed collections persist the Admin draft and then create or update a content PR.
  • Provide one save-driven PR flow that creates a branch on first save and commits follow-up saves into the existing open PR for the same target path.
  • Cache PR metadata in Payload DB.
  • Use Payload drafts for Admin form state and GitHub lookups for current PR status.

PR metadata model:

type ContentChangeRequest = {
  id: string
  contentType: string
  targetPath: string                  // indexed for lock queries
  branchName: string
  pullRequestUrl?: string
  pullRequestNumber?: number
  status: 'draft' | 'open' | 'merged' | 'closed'
  createdBy: string
  createdAt: string
  updatedAt: string
}

Permission Model

Two roles in Payload, with GitHub permissions as the second line of defense.

Role Can edit drafts Can create PR to develop Can promote developmaster Can ship schema PR
Editor yes yes no no
Admin yes yes yes yes

GitHub branch protection enforces the developmaster promotion rule independently of the CMS, so a misbehaving CMS cannot push to master directly.

7. CMS UX Flow

  1. The editor selects a route in Payload Admin.
  2. CMS loads the current JSON file from GitHub develop (with ETag cache).
  3. The recent PR check runs and shows any open PR touching the same file.
  4. The editor updates values in a structured form.
  5. CMS runs schema validation before saving.
  6. Saving creates a content/{type}-{slug}-{timestamp} branch from develop, or reuses the existing open PR that already touches the same target path.
  7. CMS commits the changed JSON file (and any uploaded media) to that branch.
  8. CMS opens or refreshes a PR with develop as the base branch and displays the PR link plus the per-PR preview URL surfaced by the host (Vercel preview in dev/staging; equivalent ephemeral preview in self-hosted production).
  9. The PR is reviewed and merged into develop.
  10. Production receives the change later through a controlled developmaster promotion PR.

Builders Hub and Circles Data Additions

  1. The editor creates an RFP, idea, circle, event, or initiative.
  2. CMS checks slug uniqueness against existing files and against any open PRs introducing the same slug.
  3. The item is saved as draft or review.
  4. CMS validates references, for example CircleEvent.circleSlug must point to an existing circle.
  5. The PR diff adds or updates index.json and <locale>.json files plus any uploaded media.
  6. After merge, the web build updates static pages and lists.

8. Web Integration Plan

Navbar

Current state:

  • apps/web/components/site-headaer.tsx owns SITEMAP, COMMUNITY, and PRESS_ITEMS constants.

Target state:

  • File renamed to site-header.tsx.
  • Load data on the server with getNavigation(locale).
  • Convert MediaRef values into next/image props.
  • Keep header open/close state in a client component, but inject data from a server wrapper.

Recommended structure (App Router native — no .server.tsx suffix):

apps/web/components/site-header/
  site-header.tsx          # server component: calls getNavigation(locale), renders shell
  site-header-client.tsx   # 'use client' — open/close state and overlay interactions
  index.ts

The server component is the default export and the file apps/web/app/[locale]/layout.tsx imports from site-header.tsx. The client child receives data as props.

Current state:

  • apps/web/components/site-footer.tsx owns link arrays as module constants.
  • Tagline and part of the built-by copy currently come from messages/en.json.

Target state:

  • Load the full footer model with getFooter(locale).
  • Move the footer namespace from messages/en.json into content files.

Page Copy

Current state:

  • Most copy lives in apps/web/messages/en.json.
  • Some page and section copy is hardcoded in TSX constants.

Target state:

  • Move route metadata and section copy to content/pages/<locale>/{route}.json.
  • Keep messages/<locale>.json for UI chrome, validation messages, and other product UI strings only.
  • Align content section keys with Figma section keys.

9. PR and Commit Policy

Changes That Require a PR

  • Add a Builders Hub RFP or idea.
  • Add a Circles circle, event, or initiative.
  • Edit published content.
  • Change a schema.
  • Change navigation/footer link structure.
  • Change image asset paths or upload new media.

Direct Commit Allowlist

Direct commits to develop and master are blocked through GitHub branch protection. Follow-up CMS saves target the existing content branch for the open PR, never develop or master directly.

PR Title Rules

content(nav): update main navigation labels
content(footer): update infrastructure links
content(builders-hub): add RFP secure-decentralized-frontends
content(circles): add Los Angeles Circle event
schema(builders-hub): add reward closesAt field

Two separate enforcement points:

  • commitlint (CI step) validates commit messages start with content(scope): or schema(scope): for commits on content/* and schema/* branches.
  • A lightweight GitHub Action validates branch names match the patterns under "Branch Naming Rules" below. CMS-generated branches always conform; the lint catches manual mistakes.

PR Templates

  • .github/PULL_REQUEST_TEMPLATE/content.md — used for content branches; includes Submitted via Logos CMS by … line slot, content-type checklist, and preview URL placeholder.
  • .github/PULL_REQUEST_TEMPLATE/schema.md — used for schema branches; includes migration script reference, backward-compatibility checklist, and schemaVersion bump confirmation.
  • The CMS workflow service selects the content template automatically when opening PRs from content/* branches.

Branch Naming Rules

content/nav-main-20260427-1530
content/footer-links-20260427-1530
content/builders-hub-rfp-secure-decentralized-frontends
content/circles-event-los-angeles-circle-4
schema/builders-hub-reward-closes-at

10. Validation Rules

Shared Rules

  • slug must be lowercase kebab-case.
  • slug must be unique across the relevant collection (checked against the file system and against open PRs).
  • status: published requires all production fields.
  • External links must start with https://.
  • Image alt text is required unless the image is decorative.
  • MediaRef.src must resolve to a file in apps/web/public/cms/... (Phase 1) or to the configured external CDN host (later).
  • MediaRef.alt is required as a string. Decorative images use an explicit empty string (""); the schema validator accepts it but the form labels it as "decorative" so the editor confirms intent.
  • Every persisted record must declare schemaVersion. Loader compares it against the latest known version and either passes through, runs a migration, or fails the build.
  • A missing <locale>.json for a locale in routing.locales fails the build. There is no fallback. Files for inactive locales are ignored.

Builders Hub Rules

  • RFP reward amount must be greater than 0.
  • RFP currency is limited to USDC for now.
  • relatedIdeas references must exist.
  • Published RFPs require applyUrl.
  • Idea submitter handles are stored without @; UI adds it when rendering.
  • BuilderHubListingPageSettings.pageSize must be between 1 and 50.

Listing Pagination Strategy

/builders-hub/rfps and /builders-hub/ideas paginate via dynamic route segments, not search params: /builders-hub/rfps/page/2, /builders-hub/ideas/page/3. Reasons:

  • Compatible with output: 'export' style builds (search params do not pre-render).
  • Each page is its own static URL, which is friendlier for sharing and SEO.
  • The view toggle (grid/list) stays as a search param because it does not affect the visible list of items, only the layout.

generateStaticParams is updated in Phase 1 to enumerate Math.ceil(publishedItems / pageSize) pages.

Publish State Transitions

Toggling status from published to draft or archived removes the item from generateStaticParams on the next build. The previously generated static page either 404s on next deploy or is replaced by a redirect, depending on host configuration. The CMS surfaces a warning when an editor demotes a published item, asking whether a redirect entry should be added at the same time.

Circles Rules

  • Coordinates must be within valid latitude and longitude ranges.
  • Timezone must be a valid IANA timezone.
  • Event startsAt must be ISO 8601.
  • Event circleSlug must point to an existing circle.
  • discordChannel is stored without the leading #. The validator rejects values starting with #.
  • Circle.detailBackLink defaults to { label: 'All circles', href: '/circles' } when omitted; explicit override allowed. Labels are stored sentence case; the CTA component uppercases them at render.
  • Circle detail generateStaticParams includes only status: published circles. The current apps/web/app/[locale]/circles/[slug]/page.tsx returns a hardcoded 'global' slug as a placeholder; that is removed in Phase 1 once real circle data exists, and generateStaticParams is rewritten to read from the loader.
  • BuilderHubSettings.rfpsSection.terminatorCard.thumbnailSlugs must reference at least one and at most four published items in the linked collection.

Map Rendering

Phase 1 renders the Circles world map as a static image with absolutely-positioned markers driven by Circle.coordinates. Reasons:

  • No third-party library token, no runtime dependency, no rate limit.
  • Editors can replace CirclesSettings.map.image to refresh the visual.
  • Markers and labels are rendered by apps/web from the loader.

An interactive map (Mapbox GL or MapLibre) is deferred. When activated, the schema does not change: the loader continues to expose coordinates and coordinatesGeoJson, and the map config gains an optional interactive: { provider, styleUrl } field at that point.

Editor Attribution

Per the repository's commit policy, CMS-generated commits do not append Co-Authored-By: lines. The CMS records editor identity in two places instead:

  • The PR description body includes a line: Submitted via Logos CMS by <editor email> plus a link to the Payload audit log entry.
  • ContentChangeRequest.createdBy records the Payload user ID for internal traceability.

The Git commit author is the GitHub App identity. This keeps individual editor email addresses out of the public Git log while preserving an internal audit trail.

11. Implementation Plan

Phase 0: Branch Strategy and Branch Protection

  • Create develop from current master.
  • Configure GitHub branch protection on both develop and master.
  • Update CI to run on PRs targeting develop and on PRs targeting master.
  • Document the developmaster promotion process in the repo README or a new docs/branching.md.
  • Add a draft promotion PR template at .github/PULL_REQUEST_TEMPLATE/release.md.

Done when:

  • Direct push to develop and master is rejected for non-admin accounts.
  • The promotion process is documented and a dry-run promotion PR opens cleanly (does not need to be merged in this phase).

Phase 1: Content Package and File Database

This phase can run in parallel with Phase 0; only the merge target (and not the loader work itself) depends on develop existing.

  • Create packages/content published as @repo/content. Add it as a workspace dependency in apps/web/package.json and apps/cms/package.json.
  • Add package.json scripts: check-types, validate (runs Zod validation across content/**), validate-locales (asserts every locale in routing.locales has the required files), rename-slug (the slug-rename CLI).
  • Implement Zod schemas with schemaVersion, including the typed PageSection discriminator and the custom-section schema registry.
  • Add content/** fixture files for en only, covering Builders Hub, Circles, Site (Navigation/Footer/Settings), and one tech-stack sub-page as a smoke test.
  • Add loader unit tests, including invalid-fixture failure cases and the typed-section discriminator.
  • Replace the 'global' placeholder in apps/web/app/[locale]/circles/[slug]/page.tsx with a loader-driven generateStaticParams once real circle fixtures exist.
  • Wire a proof of concept in apps/web for Builders Hub and Circles list data.

Done when:

  • pnpm --filter @repo/content check-types passes.
  • pnpm --filter @repo/content validate passes.
  • pnpm --filter web build passes.
  • Invalid content fixtures fail validation with a readable error.
  • Add Navigation, Footer, and PageCopy (typed sections) schemas.
  • Rename site-headaer.tsx to site-header.tsx.
  • Remove constants from site-header.tsx and site-footer.tsx.
  • Migrate route by route per the Home → Content Migration Map. Land each route in its own PR so failures stay isolated.
  • Tech-stack sub-pages (storage, messaging, blockchain, networking) all migrate in this phase because they share the relatedArticles pattern and depend on the Logos Press Engine data layer.
  • Keep messages/<locale>.json strictly for UI chrome.

Done when:

  • Every migrated route renders pixel-identical to the previous build, verified by screenshot diff against the Figma frame.
  • messages/*.json contains only UI strings, validation messages, and locale labels.
  • Logos Press Engine backs the open-overlay press section, the homepage press strip, and every relatedArticles block.

Phase 3: Payload Admin Editing (Local Drafts, Developer-Only)

Phase 3 is a developer-facing milestone, not an editor milestone. It reads content from the local working tree and saves drafts only to Payload DB — there is no way to publish a change yet. Editors can use the system end-to-end starting in Phase 4b.

  • Redefine Payload globals and collections in apps/cms/payload.config.ts around the shared schemas exported from @repo/content.
  • Read content for the Admin form from the local content/** tree using the same @repo/content loaders the web build uses.
  • Display Zod validation errors in Admin.
  • Support local draft save in Payload DB before any GitHub PR is created.

Done when:

  • Admin can view Navigation, Footer, Page Copy, RFP, Idea, Circle, Event, and Initiative data from local files.
  • Validation failures surface in the Admin UI before save.
  • Saving a draft does not write to GitHub.

Phase 4a: GitHub Authentication, Mutation Primitives, and Read Path Switch

  • Implement GitHub App authentication.
  • Implement branch creation, file commit (text and binary), and PR creation.
  • Implement target-path PR lookup in packages/content/src/github/locks.ts.
  • Add the media adapter for the repo path.
  • Switch the Admin read path from local-tree to GitHub develop (with ETag cache) so editors see a deployed-state view, not their working copy.

Done when:

  • Calling the mutation service from a script creates a branch, commits a JSON change and an image binary, and opens a PR against develop.
  • The recent PR query returns open PRs touching a path.
  • Admin form pre-fill loads from develop rather than the local file.

Phase 4b: Payload Save → PR Wiring

  • Extend the Payload save handler for content collections with a workflow service call that creates or updates a PR after the Admin save succeeds.
  • Show the current PR status and merge/sync actions in the Admin UI.
  • Persist ContentChangeRequest rows.

Done when:

  • Creating one RFP in Admin creates a GitHub PR targeting develop.
  • The PR diff shows the JSON file and any uploaded media.
  • Re-saving the same RFP updates the existing PR rather than creating a new one.
  • Add a PR status panel in Admin showing PR state, reviewers, and the host's per-PR preview URL (Vercel deployment URL during dev/staging; the self-hosted equivalent in production).
  • Subscribe to GitHub webhooks for PR state changes (or fall back to short-TTL polling).
  • Surface the lock banner and slug already in flight warnings.

Done when:

  • Editors can see PR state without leaving Admin.
  • A second editor opening the same content sees the in-flight PR and is blocked from creating a duplicate.

Phase 5: Schema Migration Policy

  • Bump-and-migrate process for schemaVersion.
  • Add a migration scripts directory under packages/content/src/migrations/.
  • Land .github/PULL_REQUEST_TEMPLATE/schema.md.
  • Add a backward compatibility checklist.

Done when:

  • A test schema bump from v1 to v2 upgrades existing fixtures end to end.
  • Schema PRs run content validation against the post-migration shape.

Phase 6: External Media Storage (deferred)

  • Implement the external media adapter (S3 / R2 / similar).
  • Add a one-time migration script that moves repo-stored media to external storage and rewrites MediaRef.src values.
  • Switch the active adapter via env var without code changes in apps/web.

Done when:

  • New uploads land in external storage.
  • Existing references continue to resolve.
  • Repo size growth from media stops.

Phase 7: Interactive Map (deferred)

  • Add an optional interactive block to CirclesSettings.map.
  • Pick a provider (Mapbox GL or MapLibre) and document the token / style URL.
  • Render markers from Circle.coordinatesGeoJson.
  • Static-image fallback remains for environments without a token.

Done when:

  • /circles renders an interactive map with markers when a token is configured.
  • The static-image fallback ships pixel-identical to Phase 1 when the token is absent.

Phase Sizing

Rough effort estimate for one engineer working full-time (excluding code review, design review, and unblocking on Figma assets). Treat as planning hints, not commitments.

Phase Effort Critical path?
0 — Branch strategy + protection 0.5 day yes
1 — @repo/content package + schemas + fixtures 45 days yes
2 — Navbar, Footer, page-copy migration (route by route) 68 days yes
3 — Payload Admin local-read 34 days parallelizable with later parts of Phase 2
4a — GitHub auth + mutation primitives 3 days yes
4b — Payload Save → PR wiring 34 days yes
4c — PR status panel + preview links 23 days yes
5 — Schema migration policy + first migration test 2 days no
6 — External media storage 34 days deferred
7 — Interactive map 23 days deferred

Initial Seed Dataset

Phase 1 ships fixtures sufficient to render every Figma frame. The minimum slug set:

# Site
content/site/en/settings.json
content/site/en/navigation.json
content/site/en/footer.json

# Pages (all routes, even if just title/description/heading)
content/pages/en/home.json
content/pages/en/builders-hub.json
content/pages/en/circles.json
content/pages/en/technology-stack.json
content/pages/en/technology-stack-blockchain.json   # smoke-test for typed-section discriminator
# (other pages stubbed with title/description only — full migration in Phase 2)

# Builders Hub
content/builders-hub/settings/en.json
content/builders-hub/rfps/secure-decentralized-frontends/{index.json, en.json}
content/builders-hub/rfps/build-a-dex/{index.json, en.json}
content/builders-hub/rfps/integrate-fileverse/{index.json, en.json}
content/builders-hub/ideas/quadratic-voting/{index.json, en.json}
content/builders-hub/ideas/community-bank/{index.json, en.json}
content/builders-hub/ideas/permissionless-dns/{index.json, en.json}
content/builders-hub/resources/en.json    # 4 items: Quick Start, Documentation, Boilerplates, Video Tutorials

# Circles
content/circles/settings/en.json
content/circles/circles/los-angeles/{index.json, en.json}
content/circles/circles/london/{index.json, en.json}
content/circles/circles/porto/{index.json, en.json}
content/circles/circles/florianopolis/{index.json, en.json}
content/circles/events/los-angeles-circle-4/{index.json, en.json}
content/circles/events/los-angeles-circle-5/{index.json, en.json}
content/circles/events/florianopolis-circle-2/{index.json, en.json}
content/circles/initiatives/logos-powered-nextdoor-app/{index.json, en.json}
content/circles/initiatives/digital-id-replacement/{index.json, en.json}
content/circles/initiatives/digital-escape-egress/{index.json, en.json}
content/circles/resources/en.json         # 3 items: Start your own Circle, Forum, Discord

# Press articles are fetched from Logos Press Engine, not stored in this repo.

Total: 3 site files + 5 page files + 1 builder-hub settings + 6 RFP/Idea bundles + 1 builder-hub resources + 1 circles settings + 4 Circle bundles + 3 Event bundles + 3 Initiative bundles + 1 circles resources.

Real copy comes from apps/web/messages/en.json plus the Figma walkthrough; no Lorem Ipsum should ship in committed seed data.

Phase 0+1 Day-by-Day Breakdown

A concrete first-week plan to remove the cold-start ambiguity.

Day Output
Day 1 AM Create develop from master, configure branch protection on both branches, document promotion process in docs/branching.md. Land Phase 0.
Day 1 PM Scaffold packages/content workspace. package.json with check-types, validate, validate-locales, rename-slug scripts. tsconfig.json extending repo base.
Day 2 Write schemas/common.ts and schemas/site.ts Zod schemas with schemaVersion. Add minimal loaders/site.ts.
Day 3 Write schemas/builders-hub.ts and schemas/circles.ts. Add loaders/builders-hub.ts (incl. resolveBuilderHubHomeRfps/Ideas) and loaders/circles.ts (incl. getCircleEventsGroupedByDate).
Day 4 Write schemas/pages.ts with the typed PageSection discriminator. Add loaders/pages.ts and the custom-section schema registry stub. Build locales/registry.ts reading routing.locales.
Day 5 Author seed fixtures for all listed slugs. Wire apps/web Builders Hub and Circles list pages to call the loaders as a smoke test. Run pnpm --filter @repo/content validate and pnpm --filter web build until green.

End of Day 5: Phase 0 + Phase 1 done, the web build is loader-driven for Builders Hub and Circles list pages. Phase 2 starts the route-by-route page-copy migration.

12. CI Requirements

content:validate
  - pnpm --filter @repo/content validate

content:types
  - pnpm --filter @repo/content check-types

content:locales
  - pnpm --filter @repo/content validate-locales   # fails on missing <locale>.json

web:build
  - pnpm --filter web build

cms:build
  - pnpm --filter cms build

commit:lint
  - commitlint --from=$BASE --to=HEAD              # enforces content() / schema() scopes

Every PR — content-only, schema, or app code — runs content:types, content:validate, content:locales, and web:build. Schema PRs are not exempt; a schema change that breaks existing fixtures must fail CI before merging into develop.

13. Deferred Alternatives

Using Payload DB as the Production Database

Does not match the requirement. If Payload DB becomes the production database, GitHub PR diffs stop being the canonical change record. Payload DB stays as a supporting store.

Calling GitHub API from the Web Runtime

Avoid this. It puts GitHub rate limits, tokens, and availability on the user request path. The web app consumes local files during build.

Freeform Rich Text as the Main Schema

The design is strongly section and component driven. Freeform rich text is limited to long-form legal or article content. Navbar, Footer, Builders Hub, and Circles use typed JSON schemas.

Two-sided Reference Storage

Idea.relatedRfps was considered as a stored field but dropped. Reverse references are computed by the loader from RFP.relatedIdeas to avoid two-sided sync drift.

14. Next Steps

  1. Land Phase 0: create develop, configure branch protection, document the promotion flow in docs/branching.md.
  2. Scaffold @repo/content and implement builders-hub, circles, press, site, and typed pages schemas with schemaVersion.
  3. Author seed data per §11 "Initial Seed Dataset" for en only.
  4. Convert Navbar and Footer in apps/web to loader-based data and rename site-headaer.tsx to site-header.tsx.
  5. Migrate routes incrementally per the Home → Content Migration Map.
  6. Add local JSON editing and validation in Payload Admin before any GitHub PR creation (Phase 3).
  7. Add GitHub App authentication and PR creation in Phase 4a/b/c.

15. Reference Snippets

Paste-ready material for the work the plan calls for. None of this is final — adapt to repo conventions during implementation. Filenames are absolute paths in the repo.

15.1 packages/content/package.json

{
  "name": "@repo/content",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./schemas": "./src/schemas/index.ts",
    "./loaders": "./src/loaders/index.ts",
    "./github": "./src/github/index.ts"
  },
  "scripts": {
    "check-types": "tsc --noEmit",
    "validate": "tsx scripts/validate.ts",
    "validate-locales": "tsx scripts/validate-locales.ts",
    "rename-slug": "tsx scripts/rename-slug.ts",
    "test": "vitest run"
  },
  "dependencies": {
    "@octokit/rest": "^21.0.0",
    "@octokit/auth-app": "^7.0.0",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "tsx": "^4.0.0",
    "typescript": "^5.4.0",
    "vitest": "^1.0.0"
  }
}

15.2 Sample Seed Fixture — RFP

content/builders-hub/rfps/secure-decentralized-frontends/index.json:

{
  "schemaVersion": 1,
  "slug": "secure-decentralized-frontends",
  "status": "published",
  "reward": { "amount": 2500, "currency": "USDC", "xp": 1000 },
  "applyUrl": "https://logos.co/apply/secure-decentralized-frontends",
  "tags": ["frontend", "privacy", "infra"],
  "image": {
    "src": "/cms/builders-hub/rfps/secure-decentralized-frontends/cover.webp",
    "alt": "",
    "width": 96,
    "height": 120
  },
  "featured": true,
  "publishedAt": "2026-01-12T00:00:00Z",
  "owner": { "name": "Logos Builders Team" },
  "relatedIdeas": ["quadratic-voting"]
}

content/builders-hub/rfps/secure-decentralized-frontends/en.json:

{
  "language": "en",
  "title": "Attack Resistant Public Registries",
  "summary": "Quadratic voting platform for DAO members.",
  "description": "Privacy-preserving blockchain for sovereign order and decentralised governance.",
  "ctaLabel": "Apply"
}

15.3 Sample Seed Fixture — Circle Event

content/circles/events/los-angeles-circle-5/index.json:

{
  "schemaVersion": 1,
  "slug": "los-angeles-circle-5",
  "status": "published",
  "circleSlug": "los-angeles",
  "startsAt": "2026-02-22T20:00:00Z",
  "endsAt": "2026-02-22T22:00:00Z",
  "timezone": "America/Los_Angeles",
  "venueName": "Chateau Marmont",
  "address": "8221 Sunset Blvd, Los Angeles, CA",
  "image": {
    "src": "/cms/circles/events/los-angeles-circle-5/cover.webp",
    "alt": "Outdoor gathering at Chateau Marmont",
    "width": 339,
    "height": 234
  },
  "hostedBy": [
    { "name": "InfinityBase Events" },
    { "name": "Logos" },
    { "name": "JB" },
    { "name": "Fabio Seva" }
  ],
  "featured": false,
  "sequenceNumber": 5
}

content/circles/events/los-angeles-circle-5/en.json:

{
  "language": "en",
  "title": "Los Angeles Circle #5",
  "locationLabel": "Los Angeles, California"
}

15.4 Sample Seed Fixture — BuilderHubSettings

content/builders-hub/settings/en.json:

{
  "schemaVersion": 1,
  "language": "en",
  "hero": {
    "title": "Logos Builders Hub",
    "description": "Ideas, resources, and everything you need to start building with Logos tech today."
  },
  "overviewLinks": [
    {
      "key": "ideas",
      "title": "Ideas",
      "description": "Advanced privacy for a new era of decentralised applications and social institutions.",
      "primaryCta": { "label": "Learn more", "href": "/builders-hub/ideas#about" },
      "secondaryCta": { "label": "View now", "href": "/builders-hub/ideas" }
    },
    {
      "key": "rfps",
      "title": "RFPs",
      "description": "Advanced privacy for a new era of decentralised applications and social institutions.",
      "primaryCta": { "label": "Learn more", "href": "/builders-hub/rfps#about" },
      "secondaryCta": { "label": "View now", "href": "/builders-hub/rfps" }
    }
  ],
  "rfpsSection": {
    "title": "RFPs",
    "description": "Lorem ipsum dolor si amet.",
    "seeAllCta": { "label": "See all RFPs", "href": "/builders-hub/rfps" },
    "submitCta": { "label": "Submit an RFP", "href": "https://forum.logos.co/" },
    "displayCount": 8,
    "pinnedSlugs": [
      "secure-decentralized-frontends",
      "build-a-dex",
      "integrate-fileverse"
    ],
    "terminatorCard": {
      "kind": "see-all-ideas",
      "title": "See all ideas",
      "thumbnailSlugs": ["quadratic-voting", "community-bank", "permissionless-dns", "secure-decentralized-frontends"],
      "href": "/builders-hub/ideas"
    }
  },
  "ideasSection": {
    "title": "Ideas",
    "description": "Ideas from our community driving sovereignty forward.",
    "seeAllCta": { "label": "See all ideas", "href": "/builders-hub/ideas" },
    "submitCta": { "label": "Submit an idea", "href": "https://forum.logos.co/" },
    "displayCount": 7
  },
  "appInstall": {
    "accent": "grey",
    "imagePosition": "left",
    "title": "Install the Logos app.",
    "description": "The Logos App is a complete distribution that bundles the kernel, the default modules, and UI packages into a usable product. It provides the primary user interface, hosting \"Simple Apps\" that let users interact with the various modules:",
    "tags": [
      { "label": "Wallet", "icon": "wallet" },
      { "label": "Chat Interface", "icon": "chat" },
      { "label": "Filesharing Tool", "icon": "files" },
      { "label": "Explorer", "icon": "explorer" }
    ],
    "installCta": { "label": "Install", "href": "https://logos.co/app", "variant": "primary" },
    "learnMoreCta": { "label": "Learn more", "href": "/technology-stack#logos-app", "variant": "secondary" },
    "image": {
      "src": "/cms/builders-hub/settings/app-install.webp",
      "alt": "",
      "width": 566,
      "height": 566
    }
  },
  "actionPanels": [
    {
      "variant": "image-overlay",
      "title": "Get started faster with boiler plate apps",
      "description": "Community boiler plates reference implementations that can save time and effort.",
      "image": {
        "src": "/cms/builders-hub/settings/boilerplate.webp",
        "alt": "",
        "width": 892,
        "height": 1114
      },
      "cta": { "label": "Go", "href": "https://github.com/logos/boilerplates" }
    },
    {
      "variant": "flat",
      "title": "Office hours",
      "description": "Talk to Logos core contributors",
      "cta": { "label": "Join", "href": "https://logos.co/office-hours" }
    }
  ],
  "resourcesSection": {
    "title": "Additional Resources",
    "description": "Orem ipsum dolor si amet.",
    "helpCenterCta": { "label": "Get help", "href": "/help" }
  }
}

15.5 routing.ts Posture During Migration

apps/web/i18n/routing.ts stays at locales: ['en'] through Phase 2. Adding a locale is a follow-up:

import { defineRouting } from 'next-intl/routing'

export const routing = defineRouting({
  locales: ['en'],          // Phase 2 — single-locale shipping
  defaultLocale: 'en',
  localePrefix: 'as-needed',
  localeDetection: false,
})

Activating fr is a separate PR that:

  1. Adds 'fr' to the array.
  2. Adds apps/web/messages/fr.json (full translation of UI chrome).
  3. Adds every <locale>.json in content/** for the touched routes.

CI fails until those files exist.

15.6 .github/PULL_REQUEST_TEMPLATE/content.md

## Summary

<!-- One-line summary of what changed -->

## Submitter

Submitted via Logos CMS by <editor email>.
Audit log: <link to Payload entry>.

## Content type

- [ ] Navigation / Footer / Site settings
- [ ] Page copy
- [ ] Builders Hub (RFP / Idea / Resource)
- [ ] Circles (Circle / Event / Initiative / Resource)

## Verification

- [ ] `pnpm --filter @repo/content validate` passes
- [ ] `pnpm --filter @repo/content validate-locales` passes
- [ ] Per-PR preview reviewed (Vercel preview URL during dev/staging; self-hosted equivalent in production)

15.7 .github/PULL_REQUEST_TEMPLATE/schema.md

## Summary

<!-- What schema changed and why -->

## Migration

- [ ] `schemaVersion` bumped on affected types
- [ ] Migration script added under `packages/content/src/migrations/<from>-to-<to>.ts`
- [ ] Migration runs successfully on every existing fixture

## Backward compatibility

- [ ] Existing published content continues to render
- [ ] Loader handles both old and new shapes (or migrate-then-cut-over plan documented below)

## Plan

<!-- Cut-over steps if not strictly backward-compatible -->

15.8 commitlint.config.cjs

module.exports = {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'type-enum': [
      2,
      'always',
      ['content', 'schema', 'feat', 'fix', 'chore', 'docs', 'refactor', 'test', 'build', 'ci'],
    ],
    'scope-enum': [
      0,                                                  // free-form scope; PR title rules in §9 govern by convention
    ],
    'subject-case': [0],
  },
}

15.9 Branch-Name GitHub Action

.github/workflows/branch-lint.yml:

name: Branch lint
on:
  pull_request:
    types: [opened, edited, synchronize]
jobs:
  branch-name:
    runs-on: ubuntu-latest
    steps:
      - name: Validate branch name
        run: |
          BRANCH="${{ github.head_ref }}"
          if [[ "$BRANCH" =~ ^content/ || "$BRANCH" =~ ^schema/ || "$BRANCH" =~ ^(feat|fix|chore|docs|refactor|test|build|ci)/ ]]; then
            echo "Branch name OK: $BRANCH"
          else
            echo "::error::Branch '$BRANCH' must start with content/, schema/, feat/, fix/, chore/, docs/, refactor/, test/, build/, or ci/."
            exit 1
          fi

15.10 Payload Collection Wiring (Skeleton)

apps/cms/src/collections/Rfps.ts — illustrates how a @repo/content Zod schema becomes a Payload collection that round-trips through GitHub. Phase 3 lands the read-only version; Phase 4b adds the PR-creation hook.

import type { CollectionConfig } from 'payload'
import { rfpIndexSchema, rfpLocaleSchema } from '@repo/content/schemas'
import { loadRfp, saveRfpDraft } from '@repo/content/loaders'
import { createOrUpdateContentPullRequest } from '@repo/content/github'

export const Rfps: CollectionConfig = {
  slug: 'rfps',
  admin: { useAsTitle: 'title' },
  fields: [
    { name: 'slug', type: 'text', required: true, admin: { readOnly: true } },
    { name: 'status', type: 'select', options: ['draft', 'review', 'published', 'archived'], required: true },
    { name: 'rewardAmount', type: 'number', required: true },
    { name: 'rewardCurrency', type: 'select', options: ['USDC'], defaultValue: 'USDC' },
    { name: 'rewardXp', type: 'number' },
    { name: 'applyUrl', type: 'text', required: true },
    { name: 'tags', type: 'text', hasMany: true },
    { name: 'image', type: 'upload', relationTo: 'media' },
    { name: 'featured', type: 'checkbox' },
    { name: 'publishedAt', type: 'date' },
    { name: 'closesAt', type: 'date' },
    { name: 'title', type: 'text', required: true, localized: true },
    { name: 'summary', type: 'textarea', required: true, localized: true },
    { name: 'description', type: 'textarea', required: true, localized: true },
  ],
  hooks: {
    // Phase 3: hydrate from local file
    beforeRead: async ({ doc }) => {
      const fromGit = await loadRfp(doc.slug, 'en')   // Phase 4a switches this to GitHub-backed read
      return { ...doc, ...fromGit }
    },
    // Phase 4b: replace DB-write with PR creation
    beforeChange: async ({ data, operation, req }) => {
      if (operation === 'update' || operation === 'create') {
        const action = req.body?.contentWorkflowAction ?? 'draft'
        if (action === 'createPR' || action === 'updatePR') {
          await createOrUpdateContentPullRequest({
            contentType: 'rfp',
            slug: data.slug,
            indexJson: pickIndexFields(data),
            localeJson: { en: pickLocaleFields(data) },
            editor: req.user,
          })
          return false   // skip default DB write; loader will reflect the PR diff once merged
        }
      }
      return data        // 'draft' falls through to a Payload-DB save
    },
  },
}

15.11 Glossary

Term Meaning
index.json Locale-agnostic record file — coordinates, dates, slugs, references
<locale>.json Per-locale record file living next to index.json — translatable copy only
Active locale A locale present in apps/web/i18n/routing.ts routing.locales
Content branch A branch whose name starts with content/ — managed by the CMS, base = develop
Schema branch A branch whose name starts with schema/ — touches @repo/content/schemas, base = develop
Promotion PR A developmaster PR that releases content + code to production
Content lock An open PR that touches a path; the CMS warns when a second editor opens the same path
Terminator card The 8th tile on the Builders Hub home RFP grid that cross-promotes the Ideas collection