Communities Deep Dive — Architecture & Implementation
For the user-facing feature overview, see Communities — Feature Guide.
This document details the component architecture, GraphQL contracts, and design patterns for the Communities feature (OBJ-70 overhaul).
Related docs:
- Community API Pagination & Query Optimization PRD — original product rationale and design exploration
Component Architecture
Discovery Page (Communities.vue)
File: ionic_frontend/src/views/Communities.vue
The main discovery surface. Orchestrates:
- Header (search, create button)
CommunitiesHubCard— unified stat strip + Today feed + Coach nudge column (replaces the formerCommunityStatsTile×4 grid and standaloneAiInsightCard)CommunityAlliesSection— dedicated Allies module, gated behindallyFlowEnabled, mounted between the hub card and the Your communities rail (OBJ-1569)AllyLookupModal— 460×720 ally discovery modal- Your communities rail (vertical stack on mobile, 2-up grid on desktop)
- Explore section (category chips, sort, view toggle, card grid)
- Empty / loading / error states
Key composables:
useCommunity()— fetches list, handles filtering, sorting, join/leave mutationsuseAiInsights('communities')— powers the Coach nudge insideCommunitiesHubCard(viahubCoachNudgecomputed)useUnifiedFeed(5)— fetches today's activity feed items for the hub card (limit 5)useEditorialDiscovery()— behind theeditorial-discoveryflag; data source for the four editorial discovery rails (see Editorial Discovery Rails below)
State management:
communities(ref) — all communities, filtered/sorted client-sidemyCommunities(computed) — user's joined communitiesselectedCategory(ref) — active category chipsortBy(ref) — current sort orderviewMode(ref) — Grid or ListsearchQuery(ref) — live search inputcommunityInsights(computed frominsightsResult) — stats (joined, postsThisWeek, partnersCount, suggestedCount)hubStats(computed) — mapscommunityInsightstoCommunitiesHubCardstat shapehubCoachNudge(computed) — mapsuseAiInsightsinsight to hub card coach nudge shapeallyFlowEnabled(const) —isFeatureFlagEnabled('ally-request-flow-enabled'); gatesCommunityAlliesSectionand every ally affordance on the pageuserAllies(computed fromqueries.USER_ALLIES_QUERY) — feedsCommunityAlliesSection'salliesproppendingAllyRequests(computed fromPENDING_ALLY_REQUESTS_QUERY) — feedsCommunityAlliesSection'spending-requestspropshowAllyLookup(ref) — controlsAllyLookupModalopen stateallyLookupInitialTab(ref) — controls which tab the modal opens on ('search'or'pending')
AllyLookupModal open paths (both driven by CommunityAlliesSection's emits):
openAllyLookup()— section'sadd-allyemit ("Add an ally" / "Find your first ally") → opens on'search'openPendingAllyRequests()— section'sreview-requestsemit ("Review" on the pending strip) → opens on'pending'
Feed navigation: the hub card's open-feed emit routes to /feed; the Allies section's view-ally-feed emit routes to /feed?filter=ally_activity (see CommunityAlliesSection below).
Layout:
- Desktop:
max-w-5xlcentered container; Explore gridgrid-cols-3 - Mobile: full-width; Explore grid full-width stacked; Your communities uses
flex flex-col - Tablet: intermediate breakpoints via Tailwind's
sm:andlg:utilities
Editorial Discovery Rails (EditorialCarousel.vue, FoundingMemberOpportunitiesSection.vue)
Files:
ionic_frontend/src/components/communities/EditorialCarousel.vue(+.stories.ts)ionic_frontend/src/components/communities/FoundingMemberOpportunitiesSection.vue(+.stories.ts)
Both live under components/communities/ (plural — the discovery-surface directory, alongside CommunityCardV2.vue), not components/community/ (singular — the single-community detail directory). Behind the editorial-discovery flag; see Feature Flags for lifecycle state.
EditorialCarousel.vue props:
editorialSlot('active_now' | 'just_started' | 'most_members') — selects the heading/framing/empty-state copy from an internal lookup tablecommunities(Community[]) — the slot's fetched communities; the component slices to 8 for displayloading(boolean) — renders 4 skeletonCommunityCardV2placeholdersjoining(string | null) — thepublicIdof a community currently mid-join, forwarded to the matching card
EditorialCarousel.vue emits: community-click, join, empty-cta (no see-all — omitted this release).
FoundingMemberOpportunitiesSection.vue props: communities (Community[], sliced to 12), loading, joining. Emits: community-click, join, empty-create.
Neither component fetches its own data or owns join/navigate logic — Communities.vue wires both to useEditorialDiscovery()'s output and to the page's existing navigateToCommunity / handleJoin / isJoining.
All-empty collapse: when activeNow, justStarted, and mostMembers are all empty, Communities.vue renders one shared "We haven't picked yet." panel instead of the three EditorialCarousel instances (each of which would otherwise render its own near-identical empty state back to back). FoundingMemberOpportunitiesSection is not part of this collapse — it always renders, including its own empty state, since it draws from a structurally different, always-populated-eventually query (formingCommunities, not admin-gated).
Community Detail Page (Community.vue)
File: ionic_frontend/src/views/Community.vue
The main detail surface. Orchestrates:
- Top bar (back link, status pills, share/open buttons)
- Hero card (
CommunityHero) - Stats row (
CommunityStatsRow) - Tabbed interface (Feed, Members, Goals, About)
- Sidebar (desktop) or inline sections (mobile): member rollup, Sage, guidelines, meta
Key composables:
useCommunity(communityId)— fetches community details, handles join/leave/follow mutationsuseAiInsights()— Sage carduseMediaQuery()— sticky sidebar behavior
Layout:
- Desktop:
grid grid-cols-[minmax(0,1fr)_320px] gap-4— left column for hero/stats/tabs, right sidebar (sticky) - Mobile: stacked vertically with full-width sections; tabs sticky-top
- Sidebar height clamped to viewport with
overflow-y-auto max-h-[calc(100vh-3rem)]
Hero Card (CommunityHero.vue)
File: ionic_frontend/src/components/community/CommunityHero.vue
The colored banner with join/leave state machine.
Props:
community(CommunityType) — full community objectisLoading(boolean) — skeleton stateisJoining(boolean) — button in-flightisNotifying(boolean) — secondary button in-flightjoined(boolean) — membership statenotifying(boolean) — follow state (without join)
Surface:
bg-category-{slug} community-stripe— category-colored with stripe overlay- White text throughout
States:
- Non-member:
+ Join community(primary) +⌐ Notify me(secondary glass) - Joining:
Joining…(spinner, disabled) - Member:
Joined ✓(muted glass; click opens leave-confirm modal) - Notifying:
Notified ✓(secondary glass; click unfollows) - Posted today:
● {N} posts todaypill top-right (or★ FEATUREDif applicable)
Leave confirm modal:
- Headline: "Step away from {Community}?"
- Body: "You'll lose your partner overlap and your posts will stay. You can come back any time."
- Buttons:
[Cancel][Step away] - Traps focus; Escape cancels; click-outside cancels
Stats Row (CommunityStatsRow.vue)
File: ionic_frontend/src/components/community/CommunityStatsRow.vue
A 4-up (desktop) or 2×2 (mobile) grid of metrics.
Props:
totalProgress(number, nullable) — user's aggregate goal progress (%)activity(number) — total check-insachievements(number) — completed goalsdiscussions(number) — feed posts
States:
totalProgress === null→ tile hides on desktop (grid collapses to 3-up); on mobile stays 2×2 with0- Loading skeleton for each tile
- Error state shows
—with tooltip on hover
Responsive:
- Desktop:
grid grid-cols-4(orgrid-cols-3if totalProgress is null) - Mobile:
grid grid-cols-2 gap-3(always 2×2, even with null progress)
Communities Hub Card (CommunitiesHubCard.vue)
File: ionic_frontend/src/components/communities/CommunitiesHubCard.vue
Unified card that replaced the former CommunityStatsTile ×4 grid + standalone AiInsightCard. Has three regions: a 4-up stat strip, a Today feed column, and a Coach nudge column.
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
stats | HubStats | required | { joined, postsThisWeek, partners, suggested } — all nullable |
feedItems | HubFeedItem[] | required | Activity items for the Today feed |
coachNudge | CoachNudgePayload | null | null | { message, ctaLabel? } from useAiInsights |
loading | boolean | false | Skeleton / loading state |
maxFeedRows | number | 4 | Clamps displayed feed items |
quietTitle | string | 'Quiet morning' | Empty-feed headline |
quietMessage | string | 'No posts in your communities yet…' | Empty-feed body |
alliesTappable | boolean | false | Makes the Allies tile a focusable <button> with a chevron affordance; renders as an inert <div> when false. Callers gate this on isFeatureFlagEnabled('ally-request-flow-enabled') so a tap never bounces to /dashboard when the flag is off. |
Emits:
| Event | Payload | Fired when |
|---|---|---|
feed-item-click | HubFeedItem | User taps a feed row |
post-update | — | "Post an update" CTA in quiet state |
browse-communities | — | "Browse communities" CTA in quiet state |
coach-cta | — | Coach nudge CTA button |
open-allies | — | User taps the Allies tile (only fires when alliesTappable is true); Communities.vue handles it with router.push({ name: 'Allies' }) |
open-feed | — | "Open feed →" link in the Today feed header → Communities.vue routes to /feed |
Stat strip colors:
- Joined / Posts this wk → brand blue
#177bbb - Allies → green
#10b77f - Suggested → purple
#a855f7
Today feed: Live indicator uses a CSS pulse animation (hub-pulse, 2.2 s ease-out); animation disabled with @media (prefers-reduced-motion: reduce). Avatars fall back to initials via initialsFor() with a stable hue derived from the actor name.
"For you" column (Coach-only, OBJ-1569): Renders the Coach nudge only. The allyNudge prop, ally-accept/find-allies emits, and the empty-nudge "Add an ally" fallback were removed — ally actions moved to CommunityAlliesSection. When there's no coachNudge, the column renders nothing.
Accessibility: aria-labelledby="hub-stats-heading" (sr-only heading); icon containers are aria-hidden.
CommunityAlliesSection (CommunityAlliesSection.vue)
File: ionic_frontend/src/components/communities/CommunityAlliesSection.vue
New in OBJ-1569. Consolidates every ally entry point on the Communities page — previously split across the header's My allies/Find allies buttons and CommunitiesHubCard's ally-nudge column — into one labeled <section>, mounted between CommunitiesHubCard and the Your communities rail. Gated by Communities.vue's v-if="allyFlowEnabled"; when the flag is off, no ally affordance renders anywhere on the page.
Props:
| Prop | Type | Description |
|---|---|---|
allies | Ally[] | Drives the has-allies/zero-state branch, the plural/singular subtitle, and the avatar stack ([] → zero state) |
pendingRequests | PendingRequest[] | Drives the pending strip ([] → no strip) |
loading | boolean | Skeleton state |
error | boolean | Shows the inline "Couldn't load your allies." message without blanking the section |
Emits:
| Event | Fired when | Handler in Communities.vue |
|---|---|---|
add-ally | "Add an ally" / "Find your first ally" | openAllyLookup() → opens AllyLookupModal on 'search' |
review-requests | "Review" on the pending strip | openPendingAllyRequests() → opens AllyLookupModal on 'pending' |
view-ally-feed | "See ally activity →" (has-allies state only) | routes to /feed?filter=ally_activity |
see-all | Avatar-stack "See all" link | routes to { name: 'Allies' } |
States: default (has allies), single-pending, multi-pending, zero, loading, error — see Communities § Allies section for the user-facing description of each.
Accessibility: <section aria-labelledby="allies-section-heading"> with a visible <h2>; avatar-stack link carries aria-label="See all your allies"; the pending strip is an aria-live="polite" region; tap targets ≥44px; avatar-initial gradients use a darkened ramp (hsl(H 60% 46%)→34%, vs. the hub card's lighter ramp) for AA contrast.
AllyLookupModal (AllyLookupModal.vue)
File: ionic_frontend/src/components/allies/AllyLookupModal.vue
A 460 × 720 (max 90 vh) modal for finding and inviting allies. Wraps ion-modal with backdrop-dismiss and keyboard-close.
Props:
| Prop | Type | Description |
|---|---|---|
isOpen | boolean | Controls visibility |
inviteUrl | string? | Ally invite link (defaults to 'objectuve.com') |
initialTab | 'search' | 'suggested' | 'pending'? | Which tab to open on; defaults to 'search' |
Emits: close, request-sent (with publicId)
Tabs:
- Search — powered by
useAllySearchcomposable. Live debounced search (≥ 2 chars). Below threshold: recent searches (session ref, ≤ 5) + invite link card. Tab label updates toResults · Nwhen results exist. - Suggested —
SUGGESTED_ALLIES_QUERY(cache-and-network).SuggestedRowextendsAllySearchResultwithreason: string | nullandmutualCount: number. Reason shown below each row in violet. - Pending —
PENDING_ALLY_REQUESTS_QUERY(cache-and-network). Incoming requests →PendingAllyRequestRow; outgoing placeholder ref (not yet server-exposed). Badge dot shown on tab whenincomingRequests.length > 0.
On open: watch(isOpen) resets activeTab to props.initialTab ?? 'search', clears search, focuses the search input, and refetches pending requests.
Send request: SEND_ALLY_REQUEST_MUTATION → flipToPending() (optimistic status mutation on Apollo result objects) → ally_request_sent PostHog event.
ARIA: role="dialog", aria-modal="true", aria-labelledby="ally-lookup-heading", tabindex="-1" on container; full tablist/tab/tabpanel pattern.
AllyPartnerBadge (AllyPartnerBadge.vue)
File: ionic_frontend/src/components/allies/AllyPartnerBadge.vue
A small, non-interactive indicator rendered inline after an ally's name on the allies list (AlliesPage.vue), showing whether that ally is also an accountability partner.
Props:
| Prop | Type | Description |
|---|---|---|
status | 'active' | 'pending' | 'none' | Sourced from ally.partnerStatus ?? 'none' |
States:
active— gold gradient chip,HeartHandshakeicon, visible text "Partner" (screen reader hears "accountability Partner")pending— neutral chip (slate-600light /slate-300dark for AA contrast),Clockicon, visible text "Pending" (screen reader hears "Partner request Pending")none— renders nothing
State is conveyed via icon + text together, never color alone. AlliesPage.vue fetches the underlying partnerStatus field via USER_ALLIES_WITH_PARTNER_STATUS_QUERY — see Accountability Partners § GraphQL API for the field definition.
Community Card V2 (CommunityCardV2.vue)
File: ionic_frontend/src/components/communities/CommunityCardV2.vue
The new discovery card. Supersedes the now-deleted EnhancedCommunityCard.
Props:
community(CommunityType) — full community objectjoined(boolean) — membership statejoining(boolean) — join in-flightloading(boolean) — skeleton state
Surface:
bg-category-{slug} community-stripe— category-colored with striperelative overflow-hidden rounded-2xl p-5 min-h-[260px]- White text (
text-category-{slug}-foreground) - Decorative faint label top-right (rotated, semi-transparent)
Sections:
- Top eyebrow: Category name uppercase + optional
★ FEATUREDpill - Avatar + name:
w-10 h-10rounded initials disc + bold community name - Description: 2-line clamp,
text-white/85 - Stats row: 3-up
[{N} MEMBERS] [{N} ACTIVE ↑X%] [{N} GOALS] - Join button: Primary (white) or
Joined ✓(muted glass)
States:
- Hover (desktop): Lift (
-translate-y-1), shadow increase, decorative label rotate - Joined: Button shows
Joined ✓; click toggles to non-member state - Loading:
LoadingSkeletonCardwrapper withmin-h-[260px] - Motion-reduced: Drop hover lift and label rotate transitions
Category → Token Mapping:
// src/helpers/communityCategory.ts
export const categoryToToken = (name?: string) => {
switch (name?.toLowerCase()) {
case 'personal development': return 'personal'
case 'health': return 'health'
case 'career development': return 'career'
case 'wealth creation': return 'wealth'
case 'wellbeing': return 'wellbeing'
default: return 'other'
}
}Computed surface class (dynamic class binding):
const surfaceClass = computed(() => {
const slug = categoryToToken(community.category)
return `bg-category-${slug}`
})Category Chip (CommunityCategoryChip.vue)
File: ionic_frontend/src/components/communities/CommunityCategoryChip.vue
A filter chip with optional count.
Props:
label(string) — category name or "All"count(number, optional) — count badgeselected(boolean) — active statedisabled(boolean) — grayed out
Styling:
- Default:
bg-card border border-border rounded-full px-3 py-1.5 text-xs font-medium - Selected:
bg-primary text-primary-foreground border-transparent shadow-sm - Count:
opacity-70 ml-1(softer than label)
My Community Card (MyCommunityCard.vue)
File: ionic_frontend/src/components/communities/MyCommunityCard.vue
Horizontal card for the "Your Communities" rail.
Props:
community(CommunityType)activeCount(number)postCount(number)
Structure (horizontal row):
┌─────────────────────────────────────┐
│ ┌──┐ Community Name 318 · 214 › │
│ │HB│ │
│ └──┘ │
└─────────────────────────────────────┘- Avatar disc (category-tinted bg)
- Name + metadata line (
{activeCount} active · {postCount} posts) - Chevron-right at end
Member Rollup (CommunityMemberRollup.vue)
File: ionic_frontend/src/components/community/CommunityMemberRollup.vue
Avatar stack with count.
Props:
members(UserType[]) — top 5 members in recency ordertotalCount(number) — total member countactiveCount(number) — active today
Structure:
◯ ◯ ◯ ◯ ◯ +1,243 more
1,248 members · 142 active today
[ See all members → ]- Avatar stack: 5 max,
-ml-2overlap,w-8 h-8 rounded-full, ring-2 ring-card - "+X more" count circle (6th position, muted bg)
- Count line below
- "See all members →" link (navigates to Members tab)
Empty state:
- Show only founder avatar
- Copy: "Just you and the silence. Invite a friend."
- "Invite a partner" CTA (existing share flow)
Feed Composer (CommunityFeedComposer.vue)
File: ionic_frontend/src/components/community/CommunityFeedComposer.vue
Textarea + optional embedded item picker (members only).
Props:
community(CommunityType)disabled(boolean)
Structure:
- Avatar disc (user's own avatar)
- Click to open modal with text body + item picker + Post/Cancel buttons
- Placeholder: "Share a goal, a question, or a moment."
Modal:
- Textarea for text body
- Optional embedded item picker (goal / achievement / milestone)
[Post]button (sendscreateCommunityPostmutation)[Cancel]button- Focus trap; Escape cancels
Feed & Feed Card (CommunityFeed.vue, CommunityFeedCard.vue)
File: ionic_frontend/src/components/community/CommunityFeed.vue + CommunityFeedCard.vue
Displays posts with composer.
Post structure:
- Header: avatar + name (clickable to profile) + timestamp + type chip (ACHIEVEMENT / GOAL / MILESTONE / QUESTION / MOMENT) + overflow menu (Report, Mute)
- Body: text + optional embedded card
- Footer: like / comment / share counts (interactive buttons)
Embedded cards (by post type):
- Achievement:
Awardicon + gold bar (bg-gold/10 border-l-2 border-gold) - Goal:
Targeticon + primary bar (bg-primary/10 border-l-2 border-primary) - Milestone:
Sparklesicon + coach bar (bg-coach/10 border-l-2 border-coach)
Sorting:
- Most recent (default)
- Most liked
- Most commented
Empty state:
- "No posts yet — go first."
- "Drop a goal, ask a question, share something small. People follow lights."
+ Make the first postCTA (opens composer)
Guidelines Panel (CommunityGuidelinesPanel.vue)
File: ionic_frontend/src/components/community/CommunityGuidelinesPanel.vue
Community rules display.
Props:
guidelines(string) — newline-separated rules or empty string
Structure:
- Eyebrow: "COMMUNITY GUIDELINES"
- Intro line (italic, optional)
- Ordered list (
<ol class="list-decimal list-inside">)
States:
- Empty: "This community runs on common sense. Be a good ally." (centered italic)
- Long (>5): Show first 3 + "Show all →" toggle
Parsing:
const rules = guidelines
.split('\n')
.filter(line => line.trim())
.slice(0, collapsed ? 3 : undefined)Meta Panel (CommunityMetaPanel.vue)
File: ionic_frontend/src/components/community/CommunityMetaPanel.vue
Key-value metadata grid.
Props:
community(CommunityType)
Grid layout:
grid grid-cols-[max-content_1fr] gap-x-4 gap-y-2Rows:
- CATEGORY: Community category name
- MEMBERS: Total member count
- FOUNDED: Formatted date (e.g., "March 2024") from
created_at - FOUNDER: Founder name (linked to
/users/{founderId})
Empty values: Show em-dash (—)
GraphQL Schema & Queries
Query: Communities List
query CommunitiesList($first: Int, $after: String, $search: String, $category: String, $sort: CommunitySortEnum) {
communities(first: $first, after: $after, search: $search, category: $category, sort: $sort) {
edges { node { id name category isFeatured ... } }
pageInfo { hasNextPage endCursor }
}
communityInsights(userId: $currentUserId) {
joinedCount
postsThisWeek
partnersCount # NEW
suggestedCount # NEW
}
}Query: Community Detail
query CommunityDetail($id: ID!) {
community(id: $id) {
id
name
description
category
avatar
createdAt
isFeatured
memberCount
activeCount # today
goalCount
postCount # total
postsToday # NEW (optional; for hero indicator)
guidelines
founder { id name avatar }
user_progress_percent # NEW; user's aggregate goal progress in this community
members(first: 5) {
edges { node { id name avatar } }
totalCount
}
}
}New Type: CommunityInsightsType
type CommunityInsightsType {
communities_joined: Int # existing
posts_this_week: Int # existing
partners_count: Int # NEW; count of accepted accountability partners
suggested_count: Int # NEW; from community_suggestions query
}New Field: CommunityType.user_progress_percent
type CommunityType {
# ... existing fields
user_progress_percent: Float # NEW; null if user has no shared goals
}Computation:
- Fetches user's goals where
community_goals.community_id = self.id - Aggregates progress across those shared goals
- Returns
average(progress_percent)ornullif count = 0
N+1 safety:
- Scoped via Goldiloader's
has_many :goalsassociation with:preload_scope
New Query: suggestedAllies (PR #918)
query SuggestedAllies($limit: Int) {
suggestedAllies(limit: $limit) {
publicId
firstName
lastName
username
photo { imageUrl }
allyStatus
reason
mutualCount
}
}Backed by Social::FindSuggestedAllies interaction. Default limit: 10, max: 25.
New Type: AllySuggestionType (PR #918)
type AllySuggestionType {
publicId: ID!
firstName: String!
lastName: String
username: String
photo: PhotoType
allyStatus: String! # "NONE" at suggestion time
reason: String # e.g. "Both in Marathon in 16 weeks · 2 shared communities"
mutualCount: Int! # number of shared communities
}Query: editorialCommunities / formingCommunities (OBJ-2191)
query EditorialCommunitiesQuery($slot: String!) {
editorialCommunities(slot: $slot) { id publicId name description imageUrl coverImage category isVerified isFeatured isFounding memberCount activeMembers totalGoals }
}
query FormingCommunitiesQuery {
formingCommunities { id publicId name description imageUrl coverImage category isVerified isFeatured isFounding memberCount activeMembers totalGoals }
}editorialCommunities(slot:) (rails_api/app/graphql/types/query_type.rb:446) — unauthenticated, no require_auth!. Resolves to Community.discoverable.visible_to(current_user).where(editorial_slot: slot).where.not(editorial_position: nil).order(editorial_position: :asc, editorial_updated_at: :desc).limit(8) (rails_api/app/graphql/resolvers/community_queries.rb). slot must be one of Community::EDITORIAL_SLOTS (active_now, just_started, most_members — rails_api/app/models/community.rb:25); an unrecognized slot returns [] without raising. A community only appears once it (a) has an editorial_slot/editorial_position assigned via the admin-only setCommunityEditorialSlot mutation, and (b) is past the .discoverable floor (members_count >= 5, Community::DISCOVERY_THRESHOLD) — a slotted community below that floor is filtered out silently, not an error state.
formingCommunities (rails_api/app/graphql/types/query_type.rb:453) — requires authentication (require_auth!). Resolves to communities the caller does not admin, with members_count between 0 and 4, newest first, capped at 12.
Mutation: setCommunityEditorialSlot (admin-only, OBJ-2189)
mutation SetCommunityEditorialSlot($communityId: ID!, $slot: String, $position: Int) {
setCommunityEditorialSlot(communityId: $communityId, slot: $slot, position: $position) {
community { id editorialSlot editorialPosition }
errors
}
}Backed by Admin::SetCommunityEditorialSlot (rails_api/app/interactions/admin/set_community_editorial_slot.rb), wrapped in Admin::Logged for audit trail. require_admin!-gated; passing slot: null clears the community's slot/position/editorial_updated_at. Assigning a position already held by another community in the same slot bumps that community out (editorial_slot/editorial_position/editorial_updated_at all cleared) inside the same transaction — intentional, there is no swap/shift affordance. CommunityType.editorialSlot (rails_api/app/graphql/types/community_type.rb:107) / editorialPosition (rails_api/app/graphql/types/community_type.rb:109) expose the current assignment.
Called by the admin Communities curation screen (admin_dashboard/src/views/CommunityCurationView.vue, /communities/curation, OBJ-2238) — see Admin Dashboard Guide § Communities curation for the operator workflow.
Query: curatableCommunities (admin-only, OBJ-2236)
query CuratableCommunitiesQuery {
curatableCommunities {
publicId
name
category
memberCount
editorialSlot
editorialPosition
isFeatured
}
}Backs the admin Communities curation screen's table. Resolvers::AdminQueries#curatable_communities (rails_api/app/graphql/resolvers/admin_queries.rb:229-238), require_admin!-gated. Unlike editorialCommunities, which stays behind the consumer-facing .discoverable floor by design (see above), curatableCommunities widens to members_count >= 5 OR editorial_slot IS NOT NULL, .visible_to(current_user), ordered by editorial_slot, editorial_position, then name:
Community
.where(arel_table[:members_count].gteq(Community::DISCOVERY_THRESHOLD)
.or(arel_table[:editorial_slot].not_eq(nil)))
.visible_to(current_user)
.includes(:goal_category)
.order(:editorial_slot, :editorial_position, :name)Why the widening exists — the orphan case. Before this widening (OBJ-2236, Phase 2 Task 2-1), the admin resolver reused the same .discoverable scope as the consumer query. A community slotted at 6 members that later dropped below 5 kept its editorial_slot/editorial_position in the database but vanished from both editorialCommunities (consumer, correctly — the floor is a real product rule) and curatable_communities (admin, incorrectly — no admin could see it, clear it, or even know a rail position was silently held hostage). The OR editorial_slot IS NOT NULL clause makes any sub-threshold slot-holder visible and clearable in the admin list while editorialCommunities continues to hide it from consumers unchanged — editorialCommunities's own spec suite was asserted to pass unmodified by this change, confirming the consumer-side floor was never touched.
Mutation: setCommunityFeatured (admin-only, OBJ-2236)
mutation SetCommunityFeaturedMutation($communityId: ID!, $featured: Boolean!) {
setCommunityFeatured(communityId: $communityId, featured: $featured) {
community { publicId isFeatured }
errors
}
}Backed by Admin::SetCommunityFeatured (rails_api/app/interactions/admin/set_community_featured.rb), mirroring Admin::SetCommunityEditorialSlot's admin gate and Admin::Logged audit contract (action_type: 'set_community_featured') but with no collision/transaction logic — is_featured is a single boolean column with no slot/position semantics to protect.
is_featured is orthogonal to editorial_slot/editorial_position — a deliberate product decision (Option A: separate write path, no consolidation), not an oversight. The is_featured column is exposed as CommunityType.isFeatured (rails_api/app/graphql/types/community_type.rb:31) and read directly by CommunityCardV2.vue:47 (v-if="community.isFeatured") to render the "FEATURED" badge wherever community cards appear across the app; editorial_slot/editorial_position drive placement in the three /communities editorial rails instead. A community can carry either, both, or neither independently — there is no rule linking them. Before this mutation shipped, is_featured had no write path at all in this application (Rails db:seed / direct DB writes were the only way to set it); this is its first-ever admin-facing write path.
Existing Mutations (Unchanged)
joinCommunity(communityId: ID!) -> CommunityleaveCommunity(communityId: ID!) -> CommunityfollowCommunity(communityId: ID!) -> Community— creates follow without joinunfollowCommunity(communityId: ID!) -> CommunitycreateCommunityPost(communityId: ID!, body: String, itemId: ID, itemType: String) -> Post
Design Tokens
All tokens live in design_system/css/tokens.css and are exposed via design_system/tailwind-tokens.cjs.
CSS Variables (:root)
/* Community category surface palette — one value, both modes (OBJ-2231) */
--category-personal: 276 62% 50%;
--category-personal-fg: 0 0% 100%;
--category-health: 147 70% 27%;
--category-health-fg: 0 0% 100%;
--category-career: 215 45% 37%;
--category-career-fg: 0 0% 100%;
--category-wealth: 174 80% 25%;
--category-wealth-fg: 0 0% 100%;
--category-wellbeing: 250 58% 52%;
--category-wellbeing-fg: 0 0% 100%;
--category-other: 222 20% 45%;
--category-other-fg: 0 0% 100%;There's no .dark override for these six tokens — the surface is opaque, so a mode-specific lift bought no visible benefit while pushing white-text contrast below the WCAG AA floor in dark mode. OBJ-2231 measured worst-case white-on-stripe contrast at 4.77:1 (category-other), clearing 4.5:1 in both modes.
Tailwind Extension
// tailwind.config.js
extend: {
colors: {
category: {
personal: {
DEFAULT: 'hsl(var(--category-personal))',
foreground: 'hsl(var(--category-personal-fg))',
},
health: {
DEFAULT: 'hsl(var(--category-health))',
foreground: 'hsl(var(--category-health-fg))',
},
career: {
DEFAULT: 'hsl(var(--category-career))',
foreground: 'hsl(var(--category-career-fg))',
},
wealth: {
DEFAULT: 'hsl(var(--category-wealth))',
foreground: 'hsl(var(--category-wealth-fg))',
},
wellbeing: {
DEFAULT: 'hsl(var(--category-wellbeing))',
foreground: 'hsl(var(--category-wellbeing-fg))',
},
other: {
DEFAULT: 'hsl(var(--category-other))',
foreground: 'hsl(var(--category-other-fg))',
},
},
},
}Utility Classes
@layer utilities {
.community-stripe {
background-image:
repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.06) 0,
rgba(255, 255, 255, 0.06) 1px,
transparent 1px,
transparent 14px
);
}
.community-stripe-soft {
background-image:
repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.04) 0,
rgba(255, 255, 255, 0.04) 1px,
transparent 1px,
transparent 18px
);
}
}Composables
useAiInsights
// Communities.vue calls it as:
const { insight, fetchInsight } = useAiInsights('communities')
// On mount:
fetchInsight({
page: 'communities',
joinedCommunityCount: myCommunities.value.length,
postsThisWeek: communityInsights.value?.postsThisWeek ?? 0,
})Returns:
insight— card data (message,ctaLabel)fetchInsight(params)— trigger fetch with contextloading— boolean
The insight is surfaced via hubCoachNudge computed → :coach-nudge prop on CommunitiesHubCard. The former standalone AiInsightCard component is no longer used on this page.
useUnifiedFeed
const { items: hubFeedItems, loading: hubFeedLoading } = useUnifiedFeed(5)Fetches today's activity items from the user's joined communities, capped at the provided limit. Powers the Today feed column of CommunitiesHubCard.
useCommunity
const {
community,
joined,
joining,
notifying,
handleJoin,
handleLeave,
handleNotify,
loading,
} = useCommunity(communityId)Returns all community data and mutations for join/leave/follow.
useEditorialDiscovery
File: ionic_frontend/src/composables/useEditorialDiscovery.ts
const {
flagEnabled,
activeNow,
justStarted,
mostMembers,
formingCommunities,
editorialLoading,
} = useEditorialDiscovery()Ships exactly these six values — no ally-gated suggestedCommunities/recommendedCommunities/gateReason/personalCarouselsGated from the pre-launch reference version; that half never shipped on this branch. flagEnabled reads editorial-discovery through the typed useFeatureFlag() composable (see Feature Flags § Reading flags safely), not the raw PostHog client. The three editorial queries and formingCommunities are all gated enabled: flagEnabled, so nothing fetches while the flag is off.
useAllySearch
const { search, results, loading, error } = useAllySearch()
// search(query: string) — debounced, triggers when query.length >= 2Used by AllyLookupModal for the Search tab. Results are AllySearchResult[] with publicId, firstName, lastName, username, photo, allyStatus.
Backend: Social::FindSuggestedAllies
File: rails_api/app/interactions/social/find_suggested_allies.rb
Interaction that powers the suggestedAllies GraphQL query. Suggests co-members of the caller's communities as potential allies.
Inputs:
user(User, required) — the requesting userlimit(Integer, default 10, max 25)
Algorithm:
- Collect
community_idsfromCommunityMember.where(user: user) - Early return
[]if the user has no communities - Build
excluded_user_ids— self + blocked-by-them + blocked-by-me + any existingUserAllyrecords (all statuses) - Query
CommunityMemberfor co-members in the same communities, grouped byuser_id, ordered byCOUNT(*) DESC, limited to cap - For each candidate user, look up the most recently joined shared community to produce a
reasonstring - Return
{ user, reason, mutual_count, ally_status: 'NONE' }
Reason string format:
- Single shared community:
"Both in {community_name}" - Multiple:
"Both in {community_name} · {N} shared communities"
N+1 note: Uses CommunityMember filtered .count calls without preloads — intentional, as counter_cache is disabled on these associations (counter_cache_enable = false).
Missing spec (deferred): A spec for blocked_by_me exclusion (users the caller has blocked) is not yet written. Filed as a follow-up.
Known Limitations & Follow-ups
CommunityFeed.vuecomposerRef naming: The ref pointing toCommunityFeedComposerhas the same name (composerRef) as the parentCommunity.vue's ref toCommunityFeed. Not a bug (CI passes), but confusing for future readers. Post-ship: rename internal ref tofeedComposerRef(separate issue).Sage CTA on feed cards: The composer
defineExposegap means clicking the Sage insight's "Introduce yourself" CTA on the detail page doesn't open the composer. Codi flagged as post-ship follow-up (separate issue).Screenshots: Desktop and mobile screenshots in
docs/assets/communities/were regenerated from the live app on 2026-05-31 (light mode:communities-list-{mobile,desktop}.png,community-detail-{mobile,desktop}.png). Dark-mode variants remain an optional follow-up.blocked_by_mespec missing (find_suggested_allies_spec.rb): The spec covers'excludes users who blocked the caller'but not the reverse. Deferred — file a separate issue.App.vuedev helper (window.__openOnboardingWizard, lines 130–138): A drive-by debug shortcut unrelated to communities. DEV-gated; no production risk. Should move to a focused onboarding PR.Apollo cache mutation in
AllyLookupModal(flipToPending): The function mutates Apollo result objects directly rather than usingcache.modify(). Works in practice (queries usecache-and-networkand refetch on next open) but is technically incorrect. Deferred.Redundant plucks in
find_suggested_allies.rb(blocked_by_me/blocked_by_them):UserAlly.where(user: user).pluck(:ally_id)inexistingalready coversblocked_by_me. Two extra queries fire on every call. Deferred cleanup.
Testing
Unit Tests
- Component props/slots for every new component (CommunityCardV2, CommunityHero, CommunityStatsRow, etc.)
- Category token helper (
categoryToToken→ correct enum case folding)
Visual Regression (Storybook)
- All variants per component story brief
- Light + dark mode on each variant
- Mobile viewport for responsive components
E2E (Cypress)
- Load communities list
- Search and category filtering
- Join/leave with confirm dialog
- Post to feed and sort
- Dismiss Sage insight
- Share community link
- Switch tabs and see members
Last updated: 2026-08-11 — corrected the bg-category-* token ramp to clear WCAG AA white-text contrast in both modes (OBJ-2231): one mode-independent HSL value per token, .dark overrides removed.
Prior update: 2026-08-07 — documented EditorialCarousel.vue/FoundingMemberOpportunitiesSection.vue, useEditorialDiscovery(), the editorialCommunities/formingCommunities queries, the admin-only setCommunityEditorialSlot mutation (OBJ-2191), the widened curatableCommunities query and orphan-slot fix, and the setCommunityFeatured mutation (OBJ-2236/OBJ-2238).