# VOTD Design Decisions
> Read this before touching any code. Do not build anything not listed here without discussing first.

---

## Rule #0 — Never copy SVG path data into code

Topic icons live in `assets/icons/vote topic icons/*_1.svg`. They must **always be rendered from those source files** — never by reading path data out of an SVG and pasting it into TypeScript.

Copying path data into code caused a multi-session debugging disaster: the LABOR icon was rendered from incomplete path data copied from the file, producing a wrong shape that took hours to diagnose. The fix was trivially available the whole time — just render the source file directly.

**The correct pipeline:**
1. Source SVGs live in `assets/icons/vote topic icons/`
2. Run `python3 scripts/render_topic_pngs.py` (from `apps/mobile/`) to produce PNG variants
3. Use the PNGs via `TOPIC_GHOST_IMAGES` and `TOPIC_POSTER_IMAGES` lookup maps in `votd.tsx`

**PNG variants produced:**
- `topic_ghost_<topic>.png` — `#95877C`, 28% opacity, bottom-half fade to transparent, 360×360 (2x)
- `topic_poster_<topic>.png` — white, 18% opacity, no fade, 180×180 (1x), for BacklogCard posters

**If a new topic icon is added:** add it to `TOPICS` in `scripts/render_topic_pngs.py`, re-run the script, then add the `require()` entries to both lookup maps in `votd.tsx`.

---

## Rule #1 — American English only

See `CLAUDE.md` — this rule lives there so it is read at the start of every session.

---

## Rule #2 — No new styles for existing elements
If a component or style already exists anywhere in the codebase, reuse it. Do not create a parallel version. If you think you need something new, stop and discuss first.

---

## Rule #3 — All sizes go through r(), including fonts

Every numeric value in StyleSheet — `fontSize`, `lineHeight`, `margin`, `padding`, `borderRadius`, `gap`, `minWidth` — must be wrapped in `r()`.

`r(n)` = `Math.round(n * width / 390)`. This is the established pattern used by `index.tsx` and all other screens.

**Font size conversion — HTML prototype px → app r() value:**

Prototype CSS values do NOT map 1:1 into r(). Apply a **×1.25 scaling factor** when converting:

| HTML px | app r() |
|---|---|
| 8 | r(10) |
| 9 | r(11) |
| 10 | r(13) |
| 11 | r(14) |
| 12 | r(15) |
| 13 | r(16) |
| 18 | r(23) |
| 20 | r(25) |
| 28 | r(35) |

Confirmed anchor: HTML 12px = app r(15) (matches VoteCard.tsx title font size).

**Hero slots** (Closing Soon) get +1pt on the question text: r(16) not r(15). This also happens to equal HTML 13px × 1.25 = r(16.25) → r(16).

**lineHeight**: apply the same ×1.25 factor (e.g. HTML 13px × 1.35 line-height ratio = 17.55 → app r(16) × 1.35 = r(22)).

---

## Rule #5 — Feedback entry point is always the floating tab, never inline

The feedback button must **always** be `GlobalFeedbackFlag` — a white pill tab that drops from the top of the screen frame, rendered inside a transparent always-visible `Modal` placed once in `_layout.tsx`. This makes it float above every screen in the app at the true surface level.

**Never:**
- Place `InlineFeedbackButton` (now deleted) inside a screen header row
- Import or recreate any inline feedback trigger in any screen file
- Use `zIndex` tricks to float a feedback element inside a screen's own layout

**Why:** Inline placement fights with the Stack navigator's stacking context, pushes header layouts around, and produces inconsistent positioning across screens. The floating tab approach is the only pattern that works universally and consistently.

`GlobalFeedbackFlag` is the sole export from `FeedbackWidget.tsx` for triggering feedback. `InlineFeedbackButton` has been permanently removed from that file.

---

## Rule #6 — QA punchlist updated automatically after every build

After every build — any session where a `buildNumber` bump occurs or a meaningful set of changes is committed — prepend a new `## Build NN` section to `docs/qa-punchlist.md` without waiting to be asked.

**The section must include:**
- One checklist item per testable change (UI, navigation, gate logic, icon, animation, etc.)
- A brief session notes block summarising the technical approach
- The date of the build

**The doc is:** newest build at top → older builds below → Known Gaps at bottom. Never create a separate per-build file.

**When to do it:** as soon as the build bump commit is made, before ending the session. If a session produces significant changes but no build bump, add items to the current build's open section rather than creating a new one.

---

## Rule #2 — Only permitted sources during feed rebuild

While building the new feed in `votd.tsx`, the only permitted references are:

- `design-system-proposal.html` — visual spec for every slot (card shell, layout, typography, spacing, color)
- `DESIGN_DECISIONS.md` — component rules and system decisions
- `constants/theme.ts` — color tokens
- `@/lib/mockData` and `@/components/` — data and shared components

**`votd.legacy.tsx` must not be referenced at all during the rebuild — not for styles, not for logic, not for "just checking."** It exists as a historical record only. Any logic needed (timers, vote state, data selection, routing) is simple enough to write fresh from context. The risk of carrying over legacy visual assumptions alongside logic is too high.

---

## Jurisdiction naming convention (`jurisdictionShort`)
- **Cities**: name only — `Sacramento`, not `Sacramento City`
- **Counties**: name + County — `Sacramento County`
- **Districts / agencies**: their established short name — `SMUD`, `SacRT`, `SCUSD`, `SMAQMD`, `Metro Fire`

`jurisdictionShort` is the source of truth. Display layers must not append or strip words — the data should always be correct as stored.

---

## Approved components and where they live

### VoteTally — `/components/VoteTally.tsx`
The only approved tally design for vote results. Extracted from `VoteCard.tsx`.
- Pct row: 👍 yesPct% left · noPct% 👎 right — **% is the top headline**, bold orange = winner
- Single split bar: orange Yes fill · warm gray No fill · height 6 · borderRadius 3
- Counts row: yesCount Yes left · total votes centre · No noCount right — secondary info
- Used in: `VoteCard` (post-vote state), `HeroCountdownCard` (voted footer)
- **Do not duplicate this design anywhere else. Import VoteTally.**

### VoteCard — `/components/VoteCard.tsx`
Feed card for open votes. Animated Vote Now → Yes/No split CTA. Post-vote shows VoteTally.

### ResultBar — `votd.tsx` (local component)
Two stacked rows (👍 bar · 👎 bar). Used in `ClosedCard` and `VoterShareCard` in `votd.tsx`.
**Not the approved tally for voted-state on open votes. Do not use on BEIGE backgrounds.**

### compactVotedPill — `votd.tsx` (style `g.compactVotedPill`)
"👍 You voted Yes" confirmation pill. Orange tint background. Used in `CompactCard`, `CountdownCard`, `FeatureCard` (open+voted).

---

## Hero card — `HeroCountdownCard` in `votd.tsx`
- White card (`heroCard`), BEIGE clock band (`heroClockBand`), white base below
- Clock: HRS:MIN:SEC — days are expressed as hours (e.g. 3 days = 72 hrs). Urgent (hh < 24) → orange numerals
- Live vote count simulated via self-rescheduling setTimeout
- **Unvoted footer**: `heroFooterBtn` (#D4C4AD) with "Vote Now" in `heroFooterBtnText` (orange)
- **Voted footer**: `heroTally` wrapper (margin/padding only) containing `VoteTally`
- No Yes/No buttons at hero level — Vote Now only
- Title and CTA both navigate to vote-item screen (onPress)

---

## Color tokens (votd.tsx)
| Token | Value | Use |
|---|---|---|
| O | #C85014 | Brand orange |
| BEIGE | #EBE0D3 | Card bands, borders, bar tracks |
| OFF_WHITE | #FAF9F8 | Screen background |
| WARM_GREY | #F2EDE8 | Subtle inset panels |
| WHITE | #ffffff | Card bases |
| NEAR_BLACK | #201F1E | Body text |
| GRAY | #605E5C | Secondary text |
| LIGHT | #95877C | Tertiary text, meta |

---

## Screens
- `votd.tsx` — Daily Feed (FM.T1.1–T1.18)
- `my-votes.tsx` — Votes screen (Open / Closed tabs)
- `votes-detail.tsx` — Closed votes list (Area / Topic drill-down)
- `vote-item.tsx` — Full vote detail / cast vote
- `components/VOTDOverlayCard.tsx` — Vote confirmation overlay (animated split bar)
- `components/VoteCard.tsx` — Feed vote card

---

---

## FM.T1.2 — Top Votes

Always rendered as a **couplet** — two `TopVoteCard` components back to back, one header covers both.

### Section header
- First card: `title="Top Votes"` + `sub` generated by `topVoteSubhead()` for that card's item
- Second card: `SectionHeader` with `sub` only (no title) — `title` prop is optional, omit it

### Compilation rules

**Tier → scope mapping** (drives banner icon + headline jurisdiction):

| Tier | Scope | Icon |
|---|---|---|
| `FEDERAL` | `'Country'` | ★ star |
| `STATE` | `'State'` | 🐻 bear silhouette |
| `COUNTY` / `MUNICIPAL` / `SPECIAL_DISTRICT` / `SCHOOL` | `'Local'` | 🏛 dome |

**Selection** — `selectTopForTier(tiers, openPool, closedPool)`:
- Filter both pools by tier group
- Sort each by `releaseDate` descending (most recent first — positions the current leading issue, not all-time volume)
- Prefer open items (vote window = 30 days from `releaseDate`); fall back to closed if none
- Returns `{ item: MockVoteItem, isOpen: boolean }`

**Tier pools used for selection:**
- Country: `['FEDERAL']`
- State: `['STATE']`
- Local: `['COUNTY', 'MUNICIPAL', 'SPECIAL_DISTRICT', 'SCHOOL']` — all local tiers compete together
- When the selected item is `SPECIAL_DISTRICT` or `SCHOOL`, the banner reads `IN YOUR COMMUNITY` — these tiers are never selected in isolation, they always compete in the full local pool (`COMMUNITY_TIERS` = `['SPECIAL_DISTRICT', 'SCHOOL']` is defined separately for any future community-only modules)

**Subhead copy** — `topVoteSubhead(scope, isOpen)`:
- Open → `"Voting open · top issue in {place}"`
- Closed → `"Final results · top issue in {place}"`
- Place by tier:

| Tier | Place phrase | Rationale |
|---|---|---|
| `FEDERAL` | `"the country"` | |
| `STATE` | `"California"` | |
| `MUNICIPAL` | city name — e.g. `"Sacramento"` | incorporated places go by name regardless of size |
| `COUNTY` | `"your county"` | |
| `SPECIAL_DISTRICT` / `SCHOOL` | `"your community"` | district-served areas — utilities, transit, schools, agencies |

- Always fits on a single line

**Open vs closed card state**:
- `isOpen = true` → shows "Vote Now" button + days remaining badge
- `isOpen = false` → shows YES/NO tally pills with result percentages

### Banner design
- Background: `BANNER_BG` = `ORANGE` (`#C85014`)
- Text + icons: `BANNER_TEXT` = `rgba(255,255,255,0.80)`
- Single band: `[icon r(48)] [#1 IN {PLACE}] [icon r(48)]` — all vertically centred
- Vote count centred below the headline row, pinned to bottom of banner via `justifyContent:'space-between'`
- Font size computed from available text zone width — `bannerFS` formula in `TopVoteCard`
- Icons: star (★ text glyph), bear (`BearSvg` r(44)×r(22)), dome (`DomeSvg` r(40)×r(40))
- All icon zones `r(48)×r(48)` with `overflow:'hidden'` — nothing escapes

**Headline text layout by tier:**

| Tier | Banner reads | IN-box |
|---|---|---|
| `FEDERAL` | `#1 IN THE USA` | `IN` / `THE` stacked |
| `STATE` | `#1 IN CALIFORNIA` | `IN` alone |
| `MUNICIPAL` | `#1 IN SACRAMENTO` | `IN` alone — named place, same as State |
| `COUNTY` | `#1 IN YOUR COUNTY` | `IN` / `YOUR` stacked |
| `SPECIAL_DISTRICT` / `SCHOOL` | `#1 IN YOUR COMMUNITY` | `IN` / `YOUR` stacked |

The r(22)×r(48) IN-box uses `flexDirection:'column'` + `justifyContent:'center'` — one or two lines all centre-align the same way.

### Bottom section design
- Background: `VOTE_NOW_TINT` = `rgba(200,80,20,0.09)` — barely-there orange wash
- Contains: days-left badge (open only), poll question (max 3 lines), Vote Now button or tally pills

### Vote Now button — "ghost with tint" style
- Resting: `transparent` background (inherits section tint), `1pt` solid `ORANGE` border full perimeter
- Bottom corners: `borderBottomLeftRadius / borderBottomRightRadius = r(14)` — matches card `borderRadius` so border hugs card shape exactly
- Pressed: fills with one additional layer of `VOTE_NOW_TINT` (doubles the tint) via `Pressable` style callback
- Full bleed: `marginHorizontal: r(-20)` flush to card edges, height `r(48)`
- Text: `ORANGE`, `Roboto_700Bold`, `r(16)`
- Token: `VOTE_NOW_TINT = 'rgba(200,80,20,0.09)'` in `constants/theme.ts`

### Couplet pairings (feed positions)
| Position | First card | Second card |
|---|---|---|
| M2/M3 | Country | State |
| M6/M7 | Local | Country |
| M12/M13 | State | Local |

---

---

## Insights Breaker — FM.T1.6, FM.T1.12, FM.T2.5 (agreed March 2026)

Full-width feed breaker modules that surface platform and personal data insights between vote cards. Two chart styles, two colour themes, nine named series.

---

### Colour themes

| Theme | Use | Background | Primary accent | Secondary accent |
|---|---|---|---|---|
| Hero | Export, social, yearbook, brand-facing | `#5C415D` Purple Dark | `#FF9B21` Amber | — |
| Companion | In-app default (light) | `rgba(153,111,154,0.14)` Mauve tint | `#5C415D` Purple Dark | `#E87613` Tangerine |

**Hero** is the export and press-facing theme — used when the breaker leaves the app (social share, local news embed, yearbook). **Companion** is the in-app default — airy, light, lives in the feed without dominating it. Variants (Brown, Grey) exist as supporting cast for in-app variety but have no export role and are not in v1.

---

### Chart styles

| Code | Type | Primary use |
|---|---|---|
| W6 | Area line — 6–8 data points, filled gradient below | Timing, momentum, streaks, volume over time |
| T3 | Horizontal rank bars — top 3–5 items with % labels | Topics, rankings, comparisons, margins |

No other chart styles in v1. Do not introduce new chart types without design review.

---

### Content spec — element stack

Every breaker has exactly five elements. Each element does its own lifting — no repetition across elements.

| Element | Job | Rule |
|---|---|---|
| **Headline** | The takeaway / lead. One step beyond what you can read from the graphic. | Not the data — the conclusion. Specific, glanceable. Must survive without the graphic. |
| **Badge** | Series identity. What recurring lens this insight belongs to. | Not a description — a category. Sets the frame for the headline. |
| **Caption** | The so-what. Context, implication, reason it's interesting. | Never restate the headline. Never describe the graphic. Make the number matter. |
| **Graphic** | The evidence. Makes the data visible. | Supports the headline — deepens it, doesn't carry it alone. Could be W6, T3, or future types. |
| **Foot** | Provenance. Sample size, geography, time period. | Not editorial. Load-bearing. Format: `n votes · geography · time period` |

**Headline quality test:** A bare percentage change ("Up 18%") fails unless the reader already knows the baseline. A ratio or comparative ("3× more active on Tuesdays") is self-explanatory. A superlative with context ("Highest week this quarter") passes. An observation ("Tuesday evenings") fails — that's a label, not a takeaway.

---

### Platform vs personal distinction

| Mode | Copy approach | Badge approach | v1? |
|---|---|---|---|
| **Platform** | Stat can be a sharp editorial claim — same data for every user, written once | Named series or functional label both work | Yes |
| **Personal** | Stat is runtime-generated — unknown at design time, different per user | Badge must be a stable container: "Your civic snapshot · Q4 Sacramento" | Yes — constrained |

Personal breakers use the same visual component as platform breakers. The distinction is entirely in copy and data source, not design.

---

### Series — nine named badges

| Badge | Mode | Chart | Notes |
|---|---|---|---|
| What Moves Us | P · B | T3, W6 | Topic trends, geo trends, volume trends |
| Fresh Tracks | B | T3, W6 | Disparities, contrarian patterns. Frame always = curiosity/discovery, never outlier-shaming |
| Timing is Everything | P · B | W6 primary | Everything about when — day of week, time of day, deadline spikes |
| Hot Streaks | B | T3, W6 | Momentum, consecutive activity — topic, geo, or personal |
| Close Calls | P | T3 primary | Tight races, near-misses, margin as the story |
| (Nearly) Unanimous | P | T3 primary | Landslides, strong consensus — flip side of Close Calls |
| Not Obvious | P · B | T3, W6 | Anomalies, surprises. Only runs when signal is clear |
| On the Record | U only | T3, W6 | Personal history, quarterly rollups, milestones. Never platform |
| Personal Insights | U only · v2+ | T3, W6 | Demographic context, cohort belonging. Requires demographic pipeline + earned user trust |

P = platform · U = personal · B = both

---

### V1 constraints

Ship with simplified data structures. No comparative reference lines, no small multiples, no two-series charts. Data achievable with basic analytics from day one.

- **Platform v1 series:** What Moves Us (T3), Timing is Everything (W6), Close Calls (T3)
- **Personal v1 series:** On the Record (T3 rank, W6 activity)
- Copy is close to template-fillable: `[city] voters are most active on [day]. Activity drops on [day].` — generated from data, not written editorially per card.
- Every element still earns its place. V1 is constrained, not lazy.

---

## Topic Card Icons — two separate tracks

Topic cards (`card-feat-topic`) use icons on two independent tracks:

### Ghost background icon
- Size: `r(180) × r(180)`, `top: r(6)`, centered via `left: 0`, `right: 0`, `alignItems: 'center'`
- Positioned absolute behind card content: `zIndex: 0` on ghost, `zIndex: 1` on all content layers
- Card must have `position: 'relative'`
- Fill: SVG-native `linearGradient` with `gradientUnits="objectBoundingBox"` — **do not use a View/LinearGradient overlay**
- Gradient spec: `stopColor="#95877C"` · `stopOpacity="0.15"` at offsets `0` and `0.3` → `stopOpacity="0"` at offset `1`
- Watermark feel: 0.15 opacity gives subtle presence without competing with text
- Source: inline path data from `assets/icons/vote topic icons/*_1.svg`, stored in `TOPIC_GHOST_PATHS` lookup map in `votd.tsx`
- Topic is read from `MockVoteItem.topic` and passed as `topic` prop to `TopicVoteCard` → `TopicGhost`
- `paddingTop: r(72)` on the body pushes the question down into the ghost zone
- Use the `TopicVoteCard` component in `votd.tsx` — do not reimplement this pattern

### Banner label icon
- Small inline SVG (14×14) in `.tv-banner-label`, left of the rank + status text
- Fill: `#C85014` (orange)
- **Universal icon**: always `noun-topic-2334513.svg` (person at podium with speech bubble + star) for ALL Topic cards — does not change by category
- The ghost background communicates category; the banner icon communicates "this is a Topic card"

### Banner label copy
- Format: `#N Topic · {status}`
- Status values:
  - Open vote → `Open for Vote`
  - Closed vote → `Voting Closed`
- Examples: `#1 Topic · Open for Vote` · `#2 Topic · Voting Closed`
- N = rank of this topic card in the current feed (not rank within its category)

### Ghost track — all categories covered ✓
All topic categories have at least one illustrated SVG in `assets/icons/vote topic icons/`:
Agriculture, Business Regulation, Criminal Justice/Safety, Defense, Economy/Jobs, Education, Energy, Environment, Foreign Policy, Government Reform, Housing, Infrastructure, Labor, Parks/Community, Public Health, Public Services, Taxes/Spending, Transportation. No missing categories.

---

## Feed deduplication rules

Each slot has one of four dedup postures. The engine walks the slot list in order.

### Postures

**ANCHOR** — first claim on the ranked pool. Item is added to the hard exclusion set.
No cascade needed — this slot always wins.

**CASCADE** — tries its top-ranked qualifying item. If that item is already in the hard
exclusion set, skips it and tries the next-ranked item in the same category (tier or topic).
The `#` shown on the card reflects which item was actually selected (e.g. if #1 is taken,
the card shows #2). Item claimed is added to the hard exclusion set.

**SOFT-AVOID** — tries to find an item not in the hard exclusion set, but does not cascade
indefinitely. If no clean item is available within the filter window, uses the best
qualifying item regardless. Does NOT add to the hard exclusion set.

**RECENCY** — bypasses the exclusion set entirely. Picks the most recently active item
matching its filter. Never adds to the hard exclusion set. Overlap is intentional.

**NONE** — no items (aggregate data, personal stats, or structural cards). No dedup concern.

---

### Slot-by-slot rules

| Slot | Module | Posture | Notes |
|---|---|---|---|
| 1 | EndingSoon | **ANCHOR** | Soonest-closing open vote. First claim. |
| 2 | TopVotes Area (USA Final / State Open / etc.) | **CASCADE** | Cascades within tier. Area card always first in couplet. |
| 3 | TopVotes Topic (paired with slot 2) | **CASCADE** | Cascades within topic. Topic card always second in couplet. |
| 4 | YourSideIsLosing | **CASCADE** | Votes user has already cast where they're currently losing. Avoids all prior claimed items. |
| 5 | NeighborProposal | **RECENCY** | Most recently submitted neighbor proposal. |
| 6 | Breaker | **NONE** | Aggregate platform data. |
| 7 | TopVotes Area (Local Open / etc.) | **CASCADE** | Cascades within tier. Area first. |
| 8 | TopVotes Topic (paired with slot 7) | **CASCADE** | Cascades within topic. Topic second. |
| 9 | CommunityVoted | **RECENCY** | Most recently closed vote in user's community. |
| 10 | AddResearch | **SOFT-AVOID** | Votes 5–12 days old with fewest sources. Prefers items not in exclusion set. |
| 11 | Breaker | **NONE** | |
| 12 | TopVotes Area (State Final / etc.) | **CASCADE** | Area first. |
| 13 | TopVotes Topic (paired with slot 12) | **CASCADE** | Topic second. |
| 14 | JustClosed | **RECENCY** | Most recently closed votes. Overlap fine. |
| 15 | TopVotes Area (USA Open / etc.) | **CASCADE** | Area first. |
| 16 | TopVotes Topic (paired with slot 15) | **CASCADE** | Topic second. |
| 17 | YourMilestones | **NONE** | Personal stats. |
| 18 | StandAndBeCounted | **NONE** | Aggregate counts. |
| 19 | YourBacklog | **RECENCY** | Open votes user hasn't voted on yet. |
| 20 | TopVotes Area (#2 USA Final / State Final) | **CASCADE** | Area first. |
| 21 | TopVotes Topic (paired with slot 20) | **CASCADE** | Topic second. |
| 22 | PushToTheFinish | **SOFT-AVOID** | Open votes close to deadline user hasn't cast. Prefers not in exclusion set. |
| 23 | NeighborProposal #2 | **RECENCY** | Second most recent neighbor proposal. |
| 24 | TopVotes Area (#2 Local Open / etc.) | **CASCADE** | Area first. |
| 25 | TopVotes Topic (paired with slot 24) | **CASCADE** | Topic second. |
| 26 | TopIssuesByTopic | **NONE** | Category list — no individual vote items. |
| 27 | Breaker | **NONE** | |
| 28 | TopVotes Area (#2 State Open / Local Closed) | **CASCADE** | Area first. |
| 29 | TopVotes Topic (paired with slot 28) | **CASCADE** | Topic second. |
| 30 | EndMarker | **NONE** | |
| 31 | ProposeVote | **NONE** | |

---

### Couplet rule
Within every TopVotes couplet, the Area card (USA / State / Local) always renders first.
The Topic card always renders second. This is a display ordering rule, not a priority rule —
both slots run through CASCADE independently against their own ranked queues.

### YourSideIsLosing rule
Pulls only from votes the user has **already cast** where their side (Yes or No) is currently
losing. No prediction or affinity inference. If no such vote exists, slot is skipped.

### AddResearch filter
Targets open votes released 5–12 days ago (past the initial buzz window) with the fewest
attached sources. Source count drives selection, not score. Soft-avoids exclusion set.

---

## Feed order — final (design-system-proposal.html)

| # | Slot | Card type |
|---|---|---|
| 1 | Closing Soon | cardFeat |
| 2 | Top Votes · USA / State (couplet) | cardFeat |
| 3 | Your Side Is Losing | cardFeat |
| 4 | Neighbor Proposals | cardStd |
| 5 | VOTD Insights · Tuesdays | brkCompNoPad |
| 6 | Top Votes · Local / Topic (couplet) | cardFeat |
| 7 | Your Community Voted | cardStd |
| 8 | Add Research | cardFeat |
| 9 | VOTD Insights · Housing leads | brkComp |
| 10+11 | Top Votes · State / Local (couplet) | cardFeat |
| 12 | Just Closed | cardStd |
| 13 | Top Votes · USA / Topic (couplet) | cardFeat |
| 14 | Your Milestones | cardStd |
| 15 | Stand and Be Counted | cardFeat |
| 16 | Your Backlog | compactCard |
| 17+18 | Top Votes · #2 USA / State (couplet) | cardFeat |
| 19 | Your Side Is Winning | cardFeat |
| 19 | Neighbors Want to Know #2 | cardStd |
| 20 | VOTD Insights · 3 pts apart | brkComp |
| 21 | Top Votes · #2 Local / Topic (couplet) | cardFeat |
| 22 | Add Research #2 | cardFeat |
| 23 | Top Issues by Topic | cardStd |
| 24 | VOTD Insights · top 14% | brkComp |
| 25 | Top Votes · #2 State / Local (couplet) | cardFeat |
| 26 | You're up to date | — |
| 27 | Propose a Vote | proposeCard |

---

## Propose a Vote card

End-of-feed CTA. Taken directly from `index.tsx` (`proposeCard` styles). Do not redesign.

- White background, 12px radius, **1.5px dashed orange border** (`#C85014`)
- Centered column: `＋` in 36×36 rounded box (dashed orange border) → title → subtitle
- Title: "Propose a Vote" — Roboto 700, orange, r(16)
- Subtitle: "What would make things better?" — Roboto 400, `#95877C`, r(14)

---

## You're up to date — end marker

Feed terminator. Sits at slot 26, immediately before Propose a Vote.

- No subtitle — "Pull to refresh" was removed
- More vertical padding (36px top, 28px bottom) to give the moment space
- 🎉 emoji above the text (replaces SVG confetti)
- "You're up to date" text at r(16), bold
- Not a card — no border, no background, no radius

---

## Top Issues by Topic section (slot 23)

Section title: **"Top Issues by Topic"**
Section sub: none — subheadline removed

Uses `CategoryRow` design from `votd.tsx`:
- 52×52 poster thumbnail (base64 PNG from `policy_posters_all/`)
- 8px/700/orange/uppercase topic label (eyebrow)
- 10px/700 vote title, 2-line clamp
- 9px grey vote count
- Grey chevron `›`

Category labels used (from `TOPIC_DISPLAY` in `votd.tsx`): Housing, Infrastructure, Transportation, Public Health, Education, Environment.

---

## Share Vote — permission rules by verification level

The share layer (`StickyVoteBar` voted state) has two variants: **"Share how I voted"** (position) and **"Share this vote"** (anonymous). Access is gated by `verificationLevel`.

| Level | Share this vote | Share how I voted | Notes |
|---|---|---|---|
| `unverified` / `guest` | ✅ | ❌ disabled | Locked to anonymous. Toggle pill greyed out and untappable. |
| `phone_verified` | ✅ | ❌ disabled | Same as guest — phone auth alone doesn't unlock Share Position. |
| `verified` | ✅ | ✅ | Both variants available. Defaults to last selected. |

**Implementation:**
- `canSharePosition = verificationLevel === 'verified'`
- Initial `shareVariant` state: `canSharePosition ? _shareVariant : 'anonymous'`
- "Share how I voted" pill: `disabled` + `variantPillDisabled` style when `!canSharePosition`
- `_shareVariant` module-level var persists the last choice across re-renders for verified users only

**Share from feed:**
- "Share This Vote" button on urgency cards (`YourSideIsLosing`, `YourSideIsWinning`) navigates to `/vote-item?autoShare=1`
- `StickyVoteBar` receives `autoShare` prop and immediately opens the share layer via `useEffect`

---

## Verification tiers and gating rules

There are four verification levels, each with distinct permissions:

| Level | Vote | See Tallies | Share Position | Add Research | Propose Vote | Gate Route |
|---|---|---|---|---|---|---|
| `guest` / `unverified` | Yes | No | No | No | No | A sequence → `/signup` |
| `phone_verified` | Yes | No | No | No | No | D sequence → `/profile-setup` (showSkipOnEntry) |
| `verified` (pending verification) | Yes | No | Yes | Yes | Yes | None — all gates lifted |
| `verified` (fully verified) | Yes | Yes | Yes | Yes | Yes | None |

**Key distinctions:**

- **Tally visibility** is withheld until the voter is fully verified (residency confirmed, 24–48 hr). A `verified` user whose residency check is still pending cannot see vote tallies.
- **Research and proposals always require admin screening** regardless of verification level. Submissions go through an approval queue before appearing publicly.
- **Release Approved Hold** — even if an admin approves a research source or proposal, it is not released publicly until the submitting voter's verification is fully confirmed. This prevents unverified users from getting content into the system via the approval queue before their identity is established.
- In the mock flow, `registration-complete` promotes the user to `verified` immediately so the full flow can be tested end-to-end. In production, the server holds at `verifying` until residency is confirmed, then promotes to `verified`.

**Pending state (AR8):** submitted research sources show at 45% opacity with a "Pending approval" badge. This is a client-side visual treatment only — the actual approval status will come from the backend.

---

## Voted-state card behavior by verification level

After a user votes on an open item, the VoteCard right-hand area changes depending on their verification tier. This applies on the Votes — Open list and My Profile — Votes tab.

| Level | Open item (voted) | Closed item (voted) |
|---|---|---|
| `verified` (fully) | Tally visible, user's vote highlighted (`showUserVote`) | Tally visible, user's vote highlighted |
| `verified` (pending) | Faded "Sign up for tally" CTA | Tally visible (verification resolved by close) |
| `phone_verified` | Faded "Sign up for tally" CTA | Tally visible (verification resolved by close) |
| `guest` / `unverified` | Faded "Sign up for tally" CTA | Tally visible (verification resolved by close) |

**"Sign up for tally" CTA** — orange text at 55% opacity on the Vote Now tint background. Tapping routes to `/profile-setup` with the D sequence ("Finish signing up", no skip overlay). Implemented via `tallyGatedText` and `onTallyGated` props on VoteCard.

**Excluded surfaces:**

- **Follows tab** — only contains unvoted items, so the tally gate never applies.
- **Feed** — updates based on macro events (new day, vote closing, results in), not per-vote button changes. Individual vote actions do not alter feed card state.
- **VOTD overlay** — has its own voted-state rendering (tally roll-up + share sheet), not VoteCard.

---

## Things NOT yet built (stubs)
- FM.T1.4 It's Not Over Yet → now rendered as "Your Side Is Losing / Winning" variant
- FM.T1.11 Add Research — built in slots 8 and 22 using `urg-body` pattern
- Stand and Be Counted CTA → navigate to Open Votes screen (slot 15 card exists, tap target not wired)

---

## Known issues / punch list
- **Banner "IN THE" vertical alignment** — "IN THE" / "IN YOUR" in the r(22) IN-box does not vertically centre against `#1` and the place word when font size is capped to CALIFORNIA reference size. Root cause: React Native baseline-aligns mixed `Text`/`View` siblings in a flex row. Needs a fresh approach — likely requires testing on device or in Expo Go to find the correct combination of `lineHeight`, `height`, and alignment properties.
- **Add Research card shell** — uses its own `ar.card` style (no border, `overflow: 'visible'`) rather than `urg.card`. This is intentional: `overflow: 'hidden'` on `urg.card` clips the iOS shadow when there is no border to establish the card boundary. `ar.card` is the correct shell for any borderless `cardFeat`. Slot 22 (Add Research #2) should reuse `ar.card` + `urg.body`/`urg.btn`.

---

---

## Appendix — Feed Evolution

*Vestige of the card-system design process. Kept for posterity — shows the problem diagnosis and the reasoning behind the two-tier system.*

---

### The problem: 12 card shells, no system

The feed accumulated card styles organically, with no consistent logic. Most differed by only 2px of radius or a shadow tweak.

| Style | BG | Radius | Shadow | Decision |
|---|---|---|---|---|
| `heroCard` | WHITE | r(16) | 0.07/12 | → cardFeat |
| `topVoteCard` | WHITE | r(14) | none | → cardFeat |
| `urgentCard` | WHITE | r(14) | 0.07/10 | → cardFeat |
| `insightFeatureCard` | WHITE | r(14) | 0.08/10 | → cardFeat |
| `featureCard` | WHITE | r(12) | 0.06/8 | → cardFeat |
| `standBanner` | WHITE | r(12) | 0.06/8 | → cardFeat |
| `card` | OFF_WHITE | r(12) | 0.06/8 | → cardStd (rename) |
| `closedCard` | WHITE | r(12) | 0.06/8 | → cardStd |
| `insightCard` | WHITE | r(12) | 0.06/6 | removed — unused |
| `stubCard` | WHITE | r(12) | none | kept — dashed border is intentionally distinct |
| `compactCard` | WHITE | r(12) | 0.06/6 | kept — carousel context |

Three additional problems identified:

**No contrast logic.** WHITE feed + WHITE cards = near-zero contrast. The one exception (`card` using OFF_WHITE) was inconsistently applied. No rule for when a card earns a white background.

**Shadow inconsistency.** Five shadow configs, no hierarchy logic. `topVoteCard` had no shadow at all; `heroCard` had the strongest. None mapped to a deliberate hierarchy — it just accumulated.

---

### The solution: two card tiers

Collapse 12 shells to 7 styles. Every slot in the feed uses one of: `cardStd` · `cardFeat` · `stubCard` · `compactCard` · `brkComp` · `brkCompNoPad` · `proposeCard`.

| Property | cardStd | cardFeat |
|---|---|---|
| Background | OFF_WHITE `#FAF9F8` | WHITE `#FFFFFF` |
| Border | 1px `rgba(200,80,20,0.12)` | 1px `rgba(200,80,20,0.18)` |
| Radius | r(12) | r(14) |
| Shadow opacity | 0.05 | 0.08 |
| Shadow radius | 6px | 12px |
| Shadow offset | 0, 2 | 0, 3 |
| Margin bottom | r(4) | r(8) |

**Background logic:**
1. Feed → WHITE `#FFFFFF` — clean field; feature cards sit in it rather than floating above a grey base
2. Standard cards → OFF_WHITE `#FAF9F8` — milestones, community, category lists, proposals; surfaces, not moments
3. Feature cards → WHITE `#FFFFFF` — countdown, top vote, urgency; white-on-white with shadow + border = lift without fight
4. Breakers → mauve tint `rgba(153,111,154,0.14)` — full-width, no radius; unchanged; colour contrast vs white feed is the main differentiator

**Shadow logic:** Two levels only. Standard shadow (opacity 0.05, radius 6, offset 0,2) for `cardStd`. Feature shadow (opacity 0.08, radius 12, offset 0,3) for `cardFeat`. No bespoke configs.

---

---

## Component naming convention — card types

All feed card components in `votd.tsx` follow PascalCase, named after their content role, not their visual tier.

| # | Component name | Card tier | Border |
|---|---|---|---|
| 1 | `ClosingSoon` | cardFeat | ✓ |
| 2 | `TopVoteArea` | cardFeat | ✗ |
| 3 | `TopVoteTopic` | cardFeat | ✗ |
| 4 | `YourSideIsLosing` | cardFeat | ✗ |
| 5 | `NeighborProposal` | cardStd | ✓ |
| 6 | `Breaker` | brkComp / brkCompNoPad | ✗ (full-width, no radius) |
| 7 | `CommunityVoted` | cardStd | ✓ |
| 8 | `AddResearch` | cardFeat | ✗ |
| 9 | `JustClosed` | cardStd | ✗ |
| 10 | `YourMilestones` | cardStd | ✗ |
| 11 | `StandAndBeCounted` | cardFeat | ✓ |
| 12 | `YourBacklog` | compactCard | ✗ |
| 13 | `TopIssuesByTopic` | cardStd | ✗ (not yet built) |

### Border rule
Only four components get a border: **ClosingSoon, NeighborProposal, CommunityVoted, StandAndBeCounted**. Everything else is borderless.

**Implementation pattern for borderless cardFeat** (components that need `overflow: 'hidden'` to clip a footer button): use the two-layer `ar.shadow` / `ar.inner` shell from `votd.tsx`. The outer View carries the shadow; the inner View clips. Do not put `borderWidth` on either layer.

**Implementation pattern for borderless cardStd** (components with no full-bleed child): simply omit `borderWidth` and `borderColor` from the card style. No two-layer pattern needed since cardStd has no clipping requirement.

### Naming rules
- Names describe **what the card does for the user**, not how it looks (`YourMilestones`, not `MilestoneCard`).
- No generic suffixes like `Card` or `Widget` — the component name is the full identity.
- The one exception is `Breaker`, which IS the visual type and has no more specific content role (it is a data-insight module with interchangeable content).
- `TopVoteArea` = a Top Votes card scoped to a geographic area (Country, State, Local).
- `TopVoteTopic` = a Top Votes card scoped to a topic category.
- `TopIssuesByTopic` = the ranked topic-category list section (slot 23) — distinct from `TopVoteTopic`.

---

## Appendix — QA Audit · March 21, 2026

Full-codebase design + code audit run against `apps/mobile` (all screens, components, lib, and constants). Findings are grouped by severity. Items marked **OPEN** have not yet been resolved; items marked **FIXED** were addressed in the same session this audit was produced.

---

### Critical — Address Before Any Production Build

**C-1 · Color tokens redefined in every file**
`O = '#C85014'` (orange) and `DIVIDER` variants are copy-pasted into 9+ files instead of being imported from `constants/theme.ts`. Any future brand tweak requires touching every file individually.
_Recommended fix:_ import `{ O, DIVIDER, GRAY, ... }` from `@/constants/theme` in every screen and component. Status: **FIXED** — all 22 files migrated to `@/constants/theme` imports.

**C-2 · DIVIDER value mismatch**
`index.tsx` defines `DIVIDER = '#F0EAE3'`; `profile.tsx` uses `#EDEBE9`; `theme.ts` exports `#EBE0D3`. Three different values for the same semantic token.
_Recommended fix:_ canonicalise to `#EBE0D3` in `theme.ts` and remove all inline redefinitions. Status: **FIXED** — resolved as part of C-1; each file's `DIVIDER` now maps to `BEIGE` or `STONE` from `theme.ts`.

**C-3 · Utility functions duplicated across files**
`formatCount()` (number → `"1.2k"` display) and `yesFillColor()` (pct → orange/gray) are independently reimplemented in at least three files (`VoteCard.tsx`, `votd.tsx`, `TallyView`-equivalent).
_Recommended fix:_ extract to `lib/utils.ts`, export once, import everywhere. Status: **FIXED** — `yesFillColor` / `yesTextColor` moved to `lib/utils.ts`; `formatCount` canonical in `lib/mockData.ts`; inline copies removed.

---

### High — Address Before Feature-Complete Milestone

**H-1 · Two competing scale patterns**
Most screens use `Math.round(n * scale)` inline (scale computed as `width / 390`). Some screens use a local `r(n)` wrapper. Neither is the project standard — pick one and apply it universally.
_Recommended fix:_ export `r(n)` from `constants/theme.ts` (or `lib/utils.ts`) and replace all `Math.round(n * scale)` callsites. Status: **FIXED** — `const r = (n: number) => Math.round(n * scale)` added to all 19 affected files; all `Math.round(n * scale)` callsites replaced with `r(n)`.

**H-2 · Missing shared components**
Three UI patterns recur across screens with no shared abstraction:
- **EmptyState** — same layout/copy pattern in `feed.tsx`, `explore.tsx`, `(tabs)/index.tsx`
- **TabBar / SubTabRow** — identical underline-tab row in `feed.tsx` and `profile.tsx`
- **DotsIcon** — inline `···` character used in multiple cards instead of a shared icon component

_Recommended fix:_ create `components/EmptyState.tsx`, `components/SubTabRow.tsx`, `components/DotsIcon.tsx`. Status: **FIXED** — `EmptyState.tsx` and `DotsIcon.tsx` created and wired into `profile.tsx` and `index.tsx`; SubTabRow deferred (unique per-screen).

**H-3 · Shadow definitions repeated inline**
`shadowColor / shadowOffset / shadowOpacity / shadowRadius / elevation` blocks are copy-pasted across `VoteCard.tsx`, `votd.tsx`, and several home feed cards. This is 6–8 lines repeated each time.
_Recommended fix:_ export `cardShadow` and `cardShadowStrong` style objects from `constants/theme.ts`. Status: **N/A** — on close inspection each shadow block is unique to its context (upward shadow, white glow, card lift at 0.05 opacity); no meaningful duplication found.

---

### Bugs — Functional Issues in Current Prototype

**B-1 · `index.tsx` hasVoted/userVote contradiction**
The Discover tab passes `hasVoted={true}` with `userVote={undefined}` to VoteCard for every item. VoteCard uses `hasVoted` to show the voted state but `userVote` to decide which side to highlight — the result is a "voted" visual with no side highlighted.
_Recommended fix:_ wire `index.tsx` to a votes state (same pattern as `feed.tsx`) or pass `hasVoted={false}` until voting is wired. Status: **FIXED** — Closed tab VoteCards corrected to `hasVoted={false}`.

**B-2 · `index.tsx` onStar is a no-op**
Star button passes `onStar={() => {}}` — tapping the star on the Discover feed does nothing and does not persist to FollowsContext.
_Recommended fix:_ import `useFollows()` and pass `onStar={() => toggleFollow(item.id)}` (same pattern as `feed.tsx`). Status: **FIXED** — `useFollows` wired into `index.tsx`; all VoteCards now pass live `onStar` and `isStarred` props.

**B-3 · `feed.tsx` brittle rank parsing**
`rank={parseInt(item.id.split('-')[1], 10)}` derives the display rank from the mock ID string (`mock-001` → `1`). This will break if IDs change format.
_Recommended fix:_ add a `rank` field to `MockVoteItem` in `mockData.ts`, or derive rank from array index. Status: **FIXED** — `rank` now derived from `idx + 1` in the `.map((item, idx) =>` callback.

---

### Medium — Polish / Consistency

**M-1 · Typography: sectionHeader font varies**
Section headers on the Discover tab use `Roboto_700Bold`; equivalent headers on the Profile/About tab use `Roboto_600SemiBold`. One should be the standard.
_Recommendation:_ `Roboto_700Bold` at `r(13)` uppercase is the existing pattern — apply consistently. Status: **N/A** — the two uses serve different semantic roles (`600SemiBold` for alignment labels, `700Bold` for jurisdiction group headers); distinction is intentional.

**M-2 · borderRadius not scaled in some files**
`borderRadius: 28` appears hardcoded (not wrapped in `r()`) in several button styles. On very small or very large screens this produces a slightly off result.
_Recommendation:_ wrap pill-radius values (`r(28)`) for full consistency. Status: **N/A** — the difference is sub-pixel on real devices; won't-fix to avoid churn.

**M-3 · VoteCard `hideDots` default**
`hideDots` prop defaults to `false` (dots visible). The majority of call sites (feed, profile) pass `hideDots={true}`. Consider inverting the default to `showDots` or making `hideDots` default `true`.
_Recommendation:_ audit all VoteCard usages and decide canonical default. Status: **N/A** — `hideDots={false}` (show by default) is intentionally safe; each callsite that needs dots hidden passes the prop explicitly.

---

### Low — Housekeeping

**L-1 · Dev-only comments in `onboarding.tsx`**
Several inline `// TODO: replace with real nav` comments remain. Fine for prototype; remove before any external demo. Status: **N/A** — deferred; acceptable in a prototype context.

**L-2 · `address-setup.tsx` TODO comment**
A `// TODO: replace with real USPS...` comment was left inside `mockUspsMatch`. Status: **FIXED** — converted to a descriptive inline note: _"Prototype stub — production will call the USPS Address Validation API here."_

**L-3 · `MOCK_CODE = '123456'` visible in `verify-otp.tsx`**
The hardcoded OTP was displayed in a dev Alert for tester convenience. Status: **FIXED** — hint is now gated behind `__DEV__`: `${__DEV__ ? \`\n\nDev: use ${MOCK_CODE}\` : ''}`.

---

### Summary Table

| ID | Category | Effort | Priority | Result |
|----|----------|--------|----------|--------|
| C-1 | Color token imports | Medium | Critical | ✅ FIXED |
| C-2 | DIVIDER value canon | Low | Critical | ✅ FIXED |
| C-3 | Extract utils | Low | Critical | ✅ FIXED |
| H-1 | Unified scale fn | Medium | High | ✅ FIXED |
| H-2 | Shared components | Medium | High | ✅ FIXED |
| H-3 | Shadow constants | Low | High | — N/A |
| B-1 | hasVoted fix | Low | Bug | ✅ FIXED |
| B-2 | onStar fix | Low | Bug | ✅ FIXED |
| B-3 | rank field | Low | Bug | ✅ FIXED |
| M-1 | sectionHeader font | Low | Medium | — N/A |
| M-2 | borderRadius scale | Low | Medium | — N/A |
| M-3 | hideDots default | Low | Medium | — N/A |
| L-1 | Dev comments | Trivial | Low | — N/A |
| L-2 | TODO comment | Trivial | Low | ✅ FIXED |
| L-3 | Mock OTP hint | Trivial | Low | ✅ FIXED |

_Audit conducted by Claude (claude-sonnet-4-6) across the full `apps/mobile` tree. No third-party integrations, backend calls, or native modules were in scope._

---

## Rule #8 — One person, one vote: multi-layer duplicate vote prevention

**Added:** 2026-04-08

Allowing a user to vote twice on the same item is an existential threat to the app's credibility. Duplicate vote prevention must be enforced at every layer — never rely on a single check.

### Defense-in-depth layers

| Layer | Where | How | File(s) |
|-------|-------|-----|---------|
| **1 — UI** | VOTD selection | `votdItem` filters out items already in the `voted` map. The VOTD overlay never shows an already-voted item. | `VOTDContext.tsx` |
| **1 — UI** | Vote-item screen | Initializes in `'voted'` state if `votedMap[item.id]` exists — hides vote buttons. | `vote-item.tsx` |
| **2 — State** | `recordVote()` | Returns `false` and logs a warning if the item ID is already in the `voted` map. Double-checks inside the `setVoted` updater to guard against race conditions. | `VOTDContext.tsx` |
| **3 — Hydration** | `hydrated` flag | Starts `false`, flips to `true` only after AsyncStorage loads the voted map. The VOTD overlay (`showOverlay`) is gated on `hydrated` so there is no window where vote UI renders with empty state. | `VOTDContext.tsx`, `_layout.tsx` |
| **4 — Watch** | Phone ↔ watch | `buildPayload()` filters out voted items before sending to the watch. `onVoteReceived` routes through `recordVote()` which rejects duplicates. | `watchBridge.ts`, `_layout.tsx` |
| **5 — Server** *(future)* | Supabase | Unique constraint on `(user_id, item_id)`. Server rejects duplicates regardless of client behavior. | — |

### Rules for contributors

- **Never bypass `recordVote()`.** All vote actions — phone UI, watch bridge, future API — must go through this single function.
- **Never show vote buttons without checking `hasVoted(itemId)` or `voted[itemId]`.** If the item is already voted, show the tally/results view instead.
- **Never render vote-related UI before `hydrated === true`.** The voted map must be loaded from AsyncStorage before any vote selection or vote button rendering.
- **If adding a new vote entry point** (e.g. notification deep link, widget), wire it through `recordVote()` and check `hasVoted()` first.

---

## Rule #9 — Preview profile is a sealed-off alternate reality

**Added:** 2026-04-08

The "Preview Active Profile" experience and the real user profile must **NEVER** share data, code paths, or variables. This rule exists because preview data repeatedly leaked into real accounts through shared variables, merged arrays, and conditional logic that mixed the two.

### Architecture

- **`PreviewProfile.tsx`** — A fully self-contained component with its own hard-coded profile (Jamie Rivera), stats, voted items, and followed items. **Zero imports from `ProfileContext`, `VOTDContext`, or any shared state.**
- **`profile.tsx`** — When `previewVerified` is true, renders `<PreviewProfile onClose={...} />`. The real profile code path has zero preview data — no `VERIFIED_STATS`, no `PREVIEW_PROFILE`, no `display*` wrappers. The two render trees never intersect.

### Rules for contributors

- **Never import context providers in `PreviewProfile.tsx`.** It must have no `useProfile()`, `useVOTD()`, or `useFollows()` calls.
- **Never add `display*` wrapper variables** that switch between real and preview data based on a boolean. That pattern is what caused the original leaks.
- **If preview needs new data**, add it as a hard-coded constant inside `PreviewProfile.tsx` — never derive it from real user state.
- **If the real profile UI changes**, update `PreviewProfile.tsx` separately. Shared styles are duplicated intentionally to prevent coupling.
