UI-SPEC — Community highlights (v4.50 Phase 1: the badge contract)
Issue: OBJ-3135 (Phase 1 of milestone v4.50, parent OBJ-3134) ROADMAP: .planning/milestones/v4.50-community-badges-real-data-ROADMAP.mdMockup: docs/ui-specs/mockups/v4.50-community-badges/community-highlights.htmlEvidence: 6 stills + 2 motion recordings under that directory's screenshots/. Axe-clean, both themes. Verified against: origin/master @ 8f822af7a.
Scope
This is the design contract Phases 2–4 implement against. It decides which of the ten declared community badges describe something the product can actually compute, states each survivor's exact server-side threshold as a sentence, writes its label, names the host surface and component, and specifies overflow, states, and accessibility.
Four badges survive. Six are dropped. No code changes here — Phase 1 produces a document.
The rule this spec establishes, and the one Phase 5 should write down:
A community mark ships only when it is a monotonic threshold crossing or an admin-set status with a production write path, and its label states the threshold. Anything else is a placeholder wearing a badge's clothes.
Every keep and every drop below falls out of that one sentence. It is also why the surface needs no tooltip: if the label states the threshold, there is nothing left to explain.
0 · Step 0 first — the word "badge" is already taken
Per v4.47-concept-affordance-grammar.md §2 Step 0 ("if two things share a word, fix the word before you reach for a tier"), this fires before any tiering.
"Badge" is a locked product noun for a different thing. In guide_site/achievements-xp-and-ranks.md:96-98 a badge is something a user unlocks, carries a rarity tier (Common → Legendary), and lands in the Achievement Hall. guide_site/notifications.md:15 ("You earned the Streak Starter badge!"), guide_site/your-dashboard.md:49 ("Showcased achievements — your selected badges on display") and guide_site/communities.md ("Finish the challenge and you earn its badge, which lands in your Achievement Hall") all use it that way.
A community mark is none of those things: it describes a community, not a person; it has no rarity; it does not unlock; it lands nowhere. Calling it a badge in user-facing copy makes a claim the product contradicts one tab over.
Ruling.
- No user-facing string says "badge" on this surface. The row carries no visible collective noun at all — each pill states its own claim, so nothing needs one.
- The word "Highlights" appears in exactly two places, both non-decorative: the row's
aria-label, and the one error string. It is a plain-English descriptor, not a minted product term — it takes no capital treatment elsewhere, gets no guide page of its own, and is not a new tier, currency, or ladder. - Internal names do not change.
Types::CommunityBadgesType,Community#badges,COMMUNITY_BADGES_QUERYanduseCommunityBadgeskeep their names — they are never user-visible, and renaming the GraphQL type is a larger schema change than this milestone needs. The file moves (§3), but keeps its name.
1 · The inventory — ten decisions
Data availability is taken from the ROADMAP's verified table, not re-derived. Two rows below carry a finding the ROADMAP did not have; those are marked ⚠ and explained underneath.
| # | Field | Decision | One-line reason |
|---|---|---|---|
| 1 | club1k | Keep | members_count is a real counter cache. Monotonic in practice, and the label already is the threshold. |
| 2 | wins500 | Keep | goals.where(completed: true).count is genuinely computed and sound. The only badge in the set that was never broken. |
| 3 | featured | Keep — copy redefined ⚠ | is_featured has a real admin write path (Admin::SetCommunityFeatured), but its schema description is false: nothing on discovery reads the flag. |
| 4 | streak100 | Redefine → keep | No community streak exists, but users.longest_streak is real, monotonic per user, and countable in one aggregate query. New claim written below. |
| 5 | verified | Drop ⚠ | communities.is_verified has no production writer. It is a hardcoded false wearing a column. |
| 6 | topActive | Drop | 20% of health_score is the literal response_score = 50, and "top tier" is a ranking claim over an absolute cutoff. Fixing the activity proxy does not fix either. |
| 7 | earlySupporter | Drop | No launch-date anchor exists. FOUNDING_WINDOW_DAYS is a 30-day transient status, already surfaced by v4.30's "Founding spots open". |
| 8 | topContributor | Drop | "High ratio" is not a threshold. A ratio badge toggles on and off week to week, and the name describes a person. |
| 9 | allyMagnet | Drop | The data cannot distinguish "allies who met here" from "allies who both happen to be members". Every honest phrasing is unintelligible; every intelligible one over-claims. |
| 10 | perfectMonth | Drop | N members × 30 days per community, "all active members" has no stable definition, and it is near-unachievable above a handful of members. |
⚠ Two findings the ROADMAP did not carry
verified has no production write path. Grepping the whole tree for is_verified returns exactly five non-spec sites: the badges hash (community.rb:142), the CommunityType field declaration (community_type.rb:29), the schema, the migration that added it, and rails_api/lib/tasks/social_simulator.rake:403 — a dev/demo seeding task. There is no admin mutation, no interaction, no controller, no console-free path. In production communities.is_verified is false for every community, permanently.
That makes verified runtime-indistinguishable from topContributor: false — the exact shape this milestone exists to delete. Keeping it would ship a field that can never be true. Dropped, and the missing verification write path is recorded for Maggie in §12 as a product gap, not a design one. (CommunityType.isVerified is a separate field and is not in scope — it stays.)
topActive is not fixable by fixing the proxy. The ROADMAP frames topActive's problem as the users.updated_at activity proxy alone, and offers Phase 2 the choice "fix the proxy or drop the badge." Reading health_score (community.rb:109-129) shows the proxy is only 40% of it:
response_score = 50 # "Response time (20%) - simplified for now to avoid complex queries"A hardcoded constant is 20% of the number. So even with a real activity signal wired in, one fifth of topActive's input is still a placeholder — a second instance of the same defect, inside the number the badge reads. Drop is the only honest option, and it saves Phase 2 the entire active_members_count rewrite.
The surface loses nothing: CommunityHero.vue:38-44 already renders a live "{n} posts today" pill from real data, which is a better liveness signal than a threshold badge and is already honest.
2 · The four kept marks — threshold, label, and where the number comes from
Every threshold below is written as a sentence, per the phase's gating criterion. Threshold constants are named so Phase 2 has no magic numbers (community.rb already does this for FOUNDING_WINDOW_DAYS and DISCOVERY_THRESHOLD).
| Field | Label (verbatim) | Icon | The threshold, as a sentence |
|---|---|---|---|
club1k | 1,000 members | users | 1,000 or more people are currently members of this community. |
wins500 | 500 goals done | target | Members of this community have completed 500 or more goals, all-time. |
streak100 | 10 members reached 100 days | flame | 10 or more of this community's current members have each reached a streak of 100 days or longer at some point. |
featured | Staff pick | sparkles | An Objectuve admin has marked this community as featured. |
Server-side derivation Phase 2 implements
| Field | Constant | Expression | Query shape |
|---|---|---|---|
club1k | CLUB_MEMBER_THRESHOLD = 1_000 | members_count >= CLUB_MEMBER_THRESHOLD | counter cache, no query |
wins500 | WINS_THRESHOLD = 500 | goals.where(completed: true).count >= WINS_THRESHOLD | one aggregate (unchanged from today) |
streak100 | STREAK_DAYS = 100, STREAK_HOLDERS_THRESHOLD = 10 | members.joins(:user).where('users.longest_streak >= ?', STREAK_DAYS).count >= STREAK_HOLDERS_THRESHOLD | one aggregate across members — no per-member iteration |
featured | — | is_featured | column read |
Why longest_streak, not current_streak — the load-bearing detail
users.current_streak is written only by User#update_streak! (user.rb:185-191), which runs on activity. There is no nightly decay job — grepping every writer confirms it. So a user who stopped showing up six months ago still carries current_streak: 137 in the column.
A badge reading current_streak and claiming members "currently hold" a 100-day streak would therefore be stale by construction — the same defect as earlySupporter, one milestone later.
longest_streak is clamped monotonically in the same write (longest_streak: [max_streak, longest_streak, new_active_streak].max). A personal record is permanently true once set, so a stale longest_streak can only ever undercount — it can delay the badge, never falsely grant it. That is the safe direction for a threshold.
This is why the label's tense is load-bearing: "reached" is true; "hold" would not be. Do not soften this to "have 100-day streaks" in implementation.
Why featured's copy changes
The current schema description reads "Community is currently featured on the discovery page." That is false. is_featured is written by Admin::SetCommunityFeatured via /communities/curation, but no discovery surface reads it — discovery ranks on editorial_slot / editorial_position (v4.30). Being featured puts a community nowhere.
The honest claim is that a human at Objectuve picked it out, which is what "Staff pick" says. The GraphQL description must change with it (§10).
One host-surface consequence Phase 4 owns
CommunityHero.vue:44-51 renders a "Featured" pill — but only as a v-else-if, so it disappears the moment the community has any posts today. That is a mark that blinks in and out on activity.
Ruling: the highlights row becomes the single home for featured. Phase 4 removes the hero's v-else-if Featured pill; the postsToday > 0 pill above it stays. Community.isFeatured is untouched — this is a template change, not a data one.
3 · Host surface and component
Surface: ionic_frontend/src/views/Community.vue — the community detail page. Component: CommunityBadges.vue, moved to ionic_frontend/src/components/community/ (singular).
On the directory trap. components/communities/ (plural) is the discovery/list neighbourhood — CommunityListItem, EditorialCarousel, MyCommunityCard, CommunityCardV2, CommunitiesHubCard. components/community/ (singular) is exactly the set of pieces views/Community.vue composes: every one of its 18 components has a call site in that view and nowhere else. A component that renders on the detail page belongs in the neighbourhood the detail page reads from — otherwise the next person editing that view cannot find it.
The move is free right now: the component has zero production call sites, so nothing imports it. It gets materially more expensive after Phase 4. Phase 3 moves the file and its story; the component name does not change.
Placement in the view. Directly after CommunityHero / TeamRoomHeader, before the creator-progress callout — inside the flex flex-col gap-4 left column at Community.vue:110.
top bar
CommunityHero
▶ CommunityBadges ← here
creator-progress callout (admin, conditional)
CommunityChallengeCard (conditional)
CommunityStatsRow
…Team rooms: suppressed. The row renders v-if="!isTeamRoom". "1,000 members" and "Staff pick" are meaningless in a private, seat-based team room, and v4.35's team-room spec already suppresses the hero, the category chips and the share affordance on the same grounds — a private team room is never meant to be discoverable. This is a ruling, not an omission.
4 · Layout
390 / 375 px desktop (≥1024)
┌──────────────────────────────┐ identical — the row is
│ CommunityHero │ already flex-wrap and is
├──────────────────────────────┤ never wider than 4 pills
│ ⬤ 1,000 members ⬤ 500 goals │ (~540px), so it fits the
│ ⬤ 10 members reached 100 days│ left column on one line.
│ ⬤ Staff pick │
├──────────────────────────────┤
│ CommunityStatsRow │
└──────────────────────────────┘<ul role="list" aria-label="Community highlights" class="flex flex-wrap gap-2">
<li class="inline-flex items-center gap-1.5 rounded-full border border-solid px-2.5 py-1
text-2xs font-semibold <tone>">
<AppIcon :name="…" :size="12" aria-hidden="true" />
<span>1,000 members</span>
</li>
</ul>role="list" is explicit: WebKit/VoiceOver strips list semantics when list-style: none applies — the same reason ConceptExplainerSheet.vue carries it with an eslint-disable comment. Copy that comment across.
Order is fixed, and it is not the schema's order — size, then output, then depth, then editorial: club1k → wins500 → streak100 → featured. The editorial mark goes last because it is the one nobody earned.
Overflow — measured, not asserted
All numbers below are read from the rendered DOM in the mockup at a 343 px content box (375 px viewport − the page's px-4 gutters), the tightest supported case.
| Marks present | Lines | Row height | Widest pill | Horizontal overflow |
|---|---|---|---|---|
| 1 | 1 | 26 px | 123 px | none |
| 3 | 2 | 60 px | 123 px | none |
| all 4 | 2 | 60 px | 201 px | none |
| (today's shipped 10-pill row, same box) | 4 | 120 px | — | none |
Ruling: wrap. Never scroll, never truncate.
- A horizontally scrolled row hides content behind a gesture with no affordance.
- A
+N morechip is a disclosure, which the v4.47 grammar would then have to tier — inventing an affordance to hide four short labels is worse than showing four short labels. - The ceiling is bounded and known: 4 marks, 2 lines, 60 px. That is the cost, in full.
5 · Design tokens
No hex anywhere. Two tones only — the whole point is that a row of marks should read as one system, not five gradients.
| Surface | Token | For reference |
|---|---|---|
| Earned mark — fill | bg-card | --card |
| Earned mark — border | border-border | --border |
| Earned mark — ink + icon | text-foreground | --foreground |
| Editorial mark — fill | bg-accent/14 | the governed --ink-safe-accent-max ceiling (v4.42) |
| Editorial mark — border | border-accent/25 | |
| Editorial mark — ink + icon | text-accent-accessible | 27 89% 35% light / 27 89% 55% dark |
| Loading skeleton | bg-muted + animate-pulse | --muted |
| Error pill ink | text-muted-foreground | --muted-foreground |
Why one accent and not four. The shipped component gives each pill its own gradient — five distinct fills across ten pills. At a glance that reads as a carnival, not a hierarchy, and it makes every mark look equally loud. Differentiate by icon, not by fill. The single accent is spent on the one real distinction in the set: featured is the mark nobody earned. One accent, one meaning.
Dark mode delta: none beyond the token flip. Every value above already carries its own dark value; no dark: variant is hand-written. bg-accent/14 + text-accent-accessible is the pairing ConceptExplainerSheet.vue already ships — reuse, not invention. Verified axe-clean in both themes at all three viewports.
Icons. users / target / flame / sparkles from components/icons/registry.ts, at 12 px, aria-hidden="true" (the visible label carries the meaning). Not streak / StreakIcon — v4.48 binds the streak motif to a person's own streak; this pill counts other people's records, so it takes the neutral flame.
6 · Typography
| Element | Font | Class |
|---|---|---|
| Mark label | Inter | text-2xs font-semibold (11 px — the metadata micro-label rung, tailwind-tokens.cjs:234) |
| Error line | Inter | text-2xs font-semibold |
No headings, no eyebrow. The row is metadata under a hero; giving it a title would add chrome that says nothing.
7 · State catalog
The empty state renders nothing, so loading and error must each render something it does not. That is the direct application of CLAUDE.md's "Absent USER_QUERY user rendered identical to a real zero streak" gotcha, which the ROADMAP flags for Phase 4.
| State | What renders |
|---|---|
| Default | The <ul> with one <li> per true field, in the fixed order. Fields that are false render no element at all — not a dimmed one. |
| Loading | One skeleton pill: role="status" aria-busy="true" aria-label="Loading community highlights", containing a h-[26px] w-[112px] rounded-full bg-muted animate-pulse block. Height reserved at 26 px so the swap does not shift the page. |
Empty (query resolved, every field false) | Nothing. The <ul> is absent from the DOM. The parent column's gap-4 closes; no residual margin, no placeholder, no "none yet" line. |
| Error (query failed) | One inert pill, alert-triangle icon + "Highlights didn't load.", text-muted-foreground. No retry here — Community.vue's page-level Try again already refetches. |
| Success | Not a state. Nothing on this row transitions on user action. |
| Disabled | Not a state. A mark that is not true is not rendered. |
| Focus | Not a state — no focusable element on this row (§9). |
| Hover (desktop) | Not a state. Nothing here is interactive; a hover treatment would promise an action that does not exist. |
| Active / pressed | Not a state, same reason. |
| Dark mode | Token flip only. No dark: variants. |
| Motion-reduced | See §8. |
role="status" on the skeleton is not decorative: axe-core flags aria-label on a bare <div> as aria-prohibited-attr (serious). Caught in the render, fixed here.
8 · Motion
One transition exists: skeleton → loaded.
| From | Trigger | To | Transition | Reduced-motion fallback |
|---|---|---|---|---|
| Loading skeleton | useCommunityBadges resolves | Highlights row | motion-safe:animate-in fade-in duration-200 — opacity only, cubic-bezier(0.4, 0, 0.2, 1) | instant swap, no fade |
200 ms and no movement, deliberately. DESIGN.md §Motion locks the band at 200–400 ms; 200 is its floor, and this row is metadata, not a celebration — it takes the shortest allowed value. No translate: the skeleton reserves the row's height, so nothing needs to move to explain what happened. No easing curve, duration, or tier is invented here.
Recorded in community-highlights-mobile-{light,dark}-motion.webp.
9 · Accessibility
- Focus order: unchanged. The row introduces zero tab stops — every element is an inert
<li>. - The ≥44 px rule has nothing to bind to. This is the one criterion in Orion's package with no applicable target, and the reason is a design decision, not an oversight: see §11.
- ARIA:
role="list"+aria-label="Community highlights"on the<ul>. Icons arearia-hidden="true"— the visible label carries the meaning, so announcing the glyph would only make the row slower to hear. Noaria-labelon any<li>: the visible text is the accessible name, and overriding it with a longer sentence would desync what is seen from what is read. - Keyboard: nothing to operate.
- Contrast: axe-core clean at 390 / 768 / 1280 × light / dark, zero violations. The editorial pill (
text-accent-accessibleonbg-accent/14) passes in both modes; it is the same pairingConceptExplainerSheetships. - Screen-reader flow for the empty case: silence. There is no element, so nothing is announced — correct, because "this community has no highlights" is not information a screen-reader user needs read to them on every page load.
10 · GraphQL field descriptions (Phase 2 must update these with the computation)
A description that no longer matches its computation is the earlySupporter bug recurring. These strings must land in community_badges_type.rb in the same commit as the new thresholds.
| Field | New description |
|---|---|
club1k | '1,000 or more people are currently members of this community.' |
wins500 | 'Members of this community have completed 500 or more goals, all-time.' |
streak100 | '10 or more current members have each reached a streak of 100 days or longer at some point. Derived from users.longest_streak, which is monotonic — never from current_streak, which goes stale.' |
featured | 'An Objectuve admin has marked this community as featured. Does not affect discovery ranking.' |
The six dropped fields are removed from both CommunityBadgesType and Community#badges. That is a schema change — regenerate and commit all three codegen artifacts in the same commit or CI's GraphQL Schema + Codegen Drift Check fails.
11 · Two corrections to the ROADMAP
Both are stated here rather than silently absorbed, because Phase 4's acceptance criteria depend on them.
11.1 The W2-04 tooltip copy must not be restored. It is retired.
The ROADMAP's Phase 4 criterion reads: "The W2-04 tooltip copy is restored… This is the criterion the whole milestone exists to satisfy." That conflicts with a ruling v4.47 already locked.
v4.47-concept-affordance-grammar.md §3 tiers W2-04 as T1-inline, explicitly not T1-tip, and §5.3 gives the reason as a hard rule:
Corollary — a non-interactive element does not get a tooltip. If the thing you want to explain cannot justify being a control, the remedy is T1-inline. This is what decides W2-04.
§9.1 goes further and declares v-tooltip unsupported, instructing that the ten usages be deleted rather than rewired. Routing that copy through the repaired UiTooltip would mean turning ten decorative pills into ten buttons — ten new tab stops for text that could simply render.
And Step 0 dissolves the need entirely. The reason W2-04 needed ten explanations is that its labels were nouns hiding their numbers — "Top Active", "1K Club", "500 Wins". Change the label to state the threshold and the explanation is the label. Under the grammar's Step 1, all four kept marks are T0: self-evident in place, ship nothing.
So no tooltip, no explainer sheet, no ⓘ, no book-open link-out on this row. That is not a gap; it is the strongest available outcome under the grammar, and it is why §9's ≥44 px criterion has nothing to bind to.
Replacement Phase 4 criterion, superseding the ROADMAP's:
Every label on the highlights row states its own threshold verbatim per §2, and the row ships without
v-tooltip,UiTooltip, or any ⓘ affordance. The v4.47 W2-04 finding is closed by deletion, not by restoration.
The fuller explanation still gets written — it belongs in guide_site/communities.md, which is Phase 5 (Dori), not in an in-app bubble. Draft copy is in §12.
11.2 topActive's activity-proxy rewrite is no longer needed
With topActive dropped (§1), Phase 2 does not need to touch active_members_count, and health_score does not change. The ROADMAP's "fixing active_members_count changes health_score, a shared number" risk row is therefore void for this milestone — CommunityHealthScore.vue (itself unwired) sees no change. Phase 2 gets materially smaller.
12 · Microcopy
Every user-facing string on this surface. No placeholders.
| Surface | String | Tone check |
|---|---|---|
Mark — club1k | 1,000 members | states the threshold ✅, brief ✅ |
Mark — wins500 | 500 goals done | states the threshold ✅, "done" over "completed" ✅ |
Mark — streak100 | 10 members reached 100 days | states count and tense ✅, "reached" is the true verb ✅ |
Mark — featured | Staff pick | says what the flag means, not what it doesn't do ✅ |
| Error | Highlights didn't load. | factual, no false retry promise ✅ |
| Loading (SR only) | Loading community highlights | ✅ |
| Row (SR only) | Community highlights | plain descriptor, not a minted noun ✅ |
Draft guide_site/communities.md addition — for Dori in Phase 5, not implemented here. Reuses the page's existing "Inside a community you'll find a hero header, quick stats…" paragraph as its anchor:
Some communities carry a mark or two under their name. 1,000 members and 500 goals done are exactly what they say. 10 members reached 100 days means ten people here have each hit a 100-day streak at some point — not that they're all on one today. Staff pick means someone at Objectuve chose it by hand.
13 · Storybook story brief
Path: ionic_frontend/src/components/community/CommunityBadges.stories.ts (moves with the component).
All eight existing stories use the fictional prop shape and must be replaced, not amended — a story documenting a contract that cannot exist is worse than no story.
Variants:
| Story | State |
|---|---|
AllFour | every field true — the 2-line, 60 px ceiling |
SingleMark | club1k only |
EarnedOnly | the three earned marks, no featured |
EditorialOnly | featured only — the accent tone alone |
Empty | every field false — asserts the component renders nothing |
Loading | loading: true — the skeleton |
Errored | errored: true — the one inert pill |
argTypes: club1k, wins500, streak100, featured (all boolean), plus loading and errored (boolean). Field names match CommunityBadgesType after Phase 2 field-for-field — no prop without a backing field, no field without a prop.
play() interaction test (per the ROADMAP's Phase 3 criterion) covering AllFour and Empty: Empty must assert the <ul> is absent from the DOM, not merely hidden — that is the assertion that keeps the empty contract from silently drifting into a rendered-but-invisible row. Do not add it to v4.46's coverage-gated Vitest project.
14 · Acceptance criteria for Codi
Phase 2 (backend)
- [ ]
Community#badgesreturns exactly four keys:club1k,wins500,streak100,featured. Nofalseliteral remains. - [ ] Six fields removed from both
Types::CommunityBadgesTypeandCommunity#badges. - [ ] All three codegen artifacts regenerated and committed in the same commit.
- [ ]
streak100derives fromusers.longest_streak, notcurrent_streak, in a single aggregate query — no per-member iteration. - [ ]
club1kreadsmembers_count, notmembers.count. - [ ]
CLUB_MEMBER_THRESHOLD,WINS_THRESHOLD,STREAK_DAYS,STREAK_HOLDERS_THRESHOLDare named constants. - [ ] Field descriptions match §10 verbatim.
- [ ]
active_members_countandhealth_scoreare untouched (see §11.2). - [ ] RSpec: each badge at, just below, and just above threshold, plus a
/graphqlrequest spec. ≥80% SimpleCov. Specs usepublic_id.
Phase 3 (component)
- [ ] File moved to
components/community/CommunityBadges.vue; story moves with it. - [ ]
definePropsmatchesCommunityBadgesTypefield-for-field;streak100typedboolean. - [ ] Six fictional props and their template branches deleted.
- [ ] Labels match §2 verbatim; tokens match §5; no gradient fills; no
dark:variants. - [ ] No
v-tooltip, noUiTooltip, no ⓘ anywhere in the component (§11.1). - [ ]
<script setup lang="ts">; stories rewritten per §13;npm run test:storybookpasses.
Phase 4 (wiring)
- [ ] Rendered in
views/Community.vueafterCommunityHero, viauseCommunityBadges— the composable's first call site. - [ ]
v-if="!isTeamRoom". - [ ]
CommunityHero.vue'sv-else-ifFeatured pill removed; thepostsTodaypill stays. - [ ] Loading / empty / error render per §7 — and empty is byte-distinguishable from both loading and error.
- [ ] No horizontal scroll at 375 px with all four marks present; row is 2 lines / 60 px.
- [ ] Vitest component test covers badges present, absent, loading, errored.
- [ ] Playwright smoke on the community detail page still passes.
- [ ] Light + dark captures at 375 px attached per
ui-evidence-and-critique.
15 · Findings recorded for Maggie to route (not fixed here)
Neither is a UI-SPEC gap; both are outside this phase's scope and neither blocks it.
- Community verification has no write path.
communities.is_verifiedis settable only bysocial_simulator.rake(dev seeding). If verification is meant to be a real product concept, it needs an admin action + curation UI — a feature, not a badge fix. Until then the badge stays dropped. → Orion, product scoping. health_scorecarries a hardcodedresponse_score = 50(community.rb:120), so 20% of it is a placeholder. Now unconsumed by any shipping surface aftertopActiveis dropped, butCommunityHealthScore.vuestill reads it and could be wired at any point. → Orion, backlog.
16 · Open questions
None. Every question the ROADMAP carried into Phase 1 is answered above: earlySupporter (§1, drop — the founding window is transient and already surfaced), the community-streak definition (§2, redefined as member-derived off longest_streak), and the host surface (§3, views/Community.vue via components/community/).
Throughline: n/a — single, non-interactive surface. No navigation, no sequence, no handoff to another screen; the one state swap (§8) begins and ends inside this component. The skeleton→loaded transition is specified against the locked DESIGN.md motion band rather than an invented curve.
Last updated: 2026-09-01 — created (OBJ-3135). · Version: v4.7.0