UI-SPEC — Partner affordance and cap legibility on My Allies
Issue: OBJ-3009 (spec) · parent OBJ-3008 · implements Orion's Task 1 Implemented by: OBJ-3008 Task 3 → ionic_frontend/src/views/AlliesPage.vueMockups: mockups/obj-3008-partner-affordance/ — 6 stills + 2 motion captures, axe-core clean
Scope
One surface: the ally row on ionic_frontend/src/views/AlliesPage.vue, plus that page's header sub-copy. The row gains a second line carrying a per-ally partner control, and the header gains one line stating the one-partner rule before the user taps anything.
The user's report (OBJ-3008) is three complaints in one: I can't tell if I'm allowed a second partner, I can't find how to make someone a partner, and I can't undo the one I have. All three are answered on this one screen. Orion's investigation confirmed the cap is real and intentional (docs/features/accountability-partners.md § Constraints; enforced by require_no_active_partnership in rails_api/app/interactions/social/send_partner_request.rb:9 and accept_partner_request.rb:8), and that endPartnership is a live backend mutation with zero UI call sites — which is why a user with a partner is currently stuck.
Not in scope: FindPartnerModal.vue and the GoalPartnerChip entry point on goal detail (they keep working, unchanged); the AllyPartnerBadge visual (reused verbatim); the Remove-ally ion-alert; PublicProfile.vue (sibling task).
Component / screen inventory
| Component / screen | Status |
|---|---|
views/AlliesPage.vue — page header sub-copy | modified — one new line |
views/AlliesPage.vue — ally row (<li>) | modified — new line 2, existing line 1 untouched |
components/allies/AllyPartnerBadge.vue | reused, unchanged — no new badge, no new variant |
components/partners/FindPartnerModal.vue | referenced — its expand-to-confirm-inline pattern is the model for the row confirm |
composables/usePartnership.ts | modified by Codi — add an endPartnership wrapper (spec'd in § Data & refresh contract) |
Layout
The row keeps its shipped line 1 exactly as it is today (AlliesPage.vue:107-160): avatar → name + AllyPartnerBadge → @username → Remove button. Everything new is line 2, indented pl-14 (56px = the 44px avatar + the gap-3) so it aligns under the name, and pr-14 on the right so it clears the trailing Remove button.
Row vertical padding drops from py-4 to py-3 on rows that carry a line 2, so the added line costs ~48px rather than ~64px.
390px — active partner row
┌──────────────────────────────────────────────────────┐
│ ⬤ Maya Okonkwo [🤝 Partner] [👤-]│ ← line 1, unchanged
│ @mayao │
│ ┌───────────────────────┐ │
│ │ 🔗̸ End partnership │ │ ← line 2, bordered pill
│ └───────────────────────┘ │
└──────────────────────────────────────────────────────┘
390px — cap-blocked row, expanded
┌──────────────────────────────────────────────────────┐
│ ⬤ Jordan Reyes [👤-]│
│ @jreyes │
│ ⓘ One partner at a time │ ← bare text, NOT a pill
│ ┌───────────────────────────────────────────┐ │
│ │ You're partnered with Maya right now. End │ │
│ │ that partnership and this spot opens up. │ │
│ │ ┌──────────────────┐ │ │
│ │ │ Jump to Maya → │ │ │
│ │ └──────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘Desktop / tablet are identical — the page is already max-w-2xl mx-auto. Line 2 gains hover affordance and nothing else.
Ruling 1 — a bordered pill means "you can do this"; bare text means "here's why you can't"
The first render of this mockup gave all three line-2 variants the same ghost-button treatment, and the available action was indistinguishable from the two explanations at a glance. That defeats the whole point of the spec.
The rule Codi must hold: anything the user can act on is a bordered pill (border border-solid border-border bg-card, min-h-[44px], rounded-xl). The cap explanation is bare text with no border and no fill. It is still a <button> — tapping it opens the reason — but it never dresses as a control, because the thing it would do is not the thing the user wanted.
This is also why the cap-blocked variant is not a disabled "Ask to partner" button. A greyed-out control that looks like the real one, with no reason attached, is the pattern this issue exists to remove.
Design tokens
| Surface | Token / class | Note |
|---|---|---|
| Line-2 pill (both actions) | border border-solid border-border bg-card rounded-xl | one recipe, two labels |
| "Ask to partner" label + icon | text-primary-accessible font-semibold | --primary-accessible = 203 78% 35% light / 203 78% 61% dark |
| "Ask to partner" hover / open | hover:border-primary-accessible hover:bg-primary/5 | border, not fill, carries the state |
| "End partnership" label + icon | text-muted-foreground font-semibold | quiet at rest — it is not a CTA |
| "End partnership" hover | hover:border-destructive/40 hover:text-destructive-accessible | destructive intent appears on approach, not at rest |
| Cap explanation | text-sm text-muted-foreground, no border, no fill | annotation, not control |
| Cap / confirm panel | rounded-xl border border-solid border-border bg-muted p-3 | FindPartnerModal's .ally-confirm, retokenised |
| Confirm — cancel | border border-solid border-border text-muted-foreground | equal width to the primary |
| Confirm — send | bg-primary text-primary-foreground | |
| Confirm — end | bg-destructive text-destructive-foreground | --destructive 0 72% 44% / 0 72% 46% |
| Partner badge | AllyPartnerBadge as shipped | gold gradient from-gold-light to-gold, ink text-gold-foreground |
| Row divider | border-b border-border/40 | unchanged |
No hex anywhere. No new token proposed — every value above already exists in design_system/css/tokens.css.
Deltas from FindPartnerModal's confirm panel, stated so they're deliberate: its border is 1.5px solid hsl(var(--muted-foreground) / 0.3) in a <style scoped> block; the row confirm uses border-border as a Tailwind class instead. Same visual weight, one fewer hand-rolled value, and it inherits the dark-mode border token rather than an alpha of a text token.
Typography
| Element | Font | Class |
|---|---|---|
| Page heading "My Allies" | Outfit | font-display font-semibold text-2xl (unchanged) |
| Header count line | Inter | text-sm text-muted-foreground (unchanged) |
| Header partner-rule line | Inter | text-sm text-muted-foreground |
| Ally name | Inter | text-sm font-semibold (unchanged) |
@username | Inter | text-xs text-muted-foreground (unchanged) |
| Line-2 control label | Inter | text-sm font-semibold |
| Cap explanation label | Inter | text-sm (regular weight — this is the tell) |
| Confirm / cap panel body | Inter | text-sm text-foreground |
| Confirm buttons | Inter | text-sm font-semibold |
Three sizes on the screen (text-2xl, text-sm, text-xs). No arbitrary text-[Npx].
State catalog
partnerStatus comes straight off USER_ALLIES_WITH_PARTNER_STATUS_QUERY (constants/graphql/social.js:360), already selected today. The fourth state — cap-blocked — is derived client-side, not fetched.
Derivation
const hasActivePartner = computed(() => allies.value.some(a => a.partnerStatus === 'active'))Derive it from active only — never from pending. SendPartnerRequest's require_no_pending_request guard is scoped to the one UserAlly record, so an outgoing request to Jordan does not block a request to Sam server-side. Blocking the UI on pending would invent a rule the backend doesn't enforce. See § Known race below for what this costs.
none — spot open
- Default: bordered pill,
heart-handshakeicon, label "Ask to partner",text-primary-accessible font-semibold. - Hover (desktop):
hover:border-primary-accessible hover:bg-primary/5, 150ms. - Focus:
outline-none ring-2 ring-primary-accessible ring-offset-2 ring-offset-card. - Active / pressed:
active:scale-[0.98]— matches every other pressable on this page. - Open (confirm showing):
border-primary-accessible bg-primary/5,aria-expanded="true". - Sending:
disabled,aria-busy="true",opacity-60,loader-2icon spinning, label "Sending…". - Dark mode: identical classes;
--primary-accessiblelightens to203 78% 61%on its own. - Motion-reduced: the spinner stops (
motion-reduce:animate-none); the disabled + label change still carries the state.
none — cap-blocked
- Default: no border, no fill.
infoicon + "One partner at a time",text-sm text-muted-foreground,min-h-[44px] px-3 -ml-3so the hit area is real while the label sits flush with the name. - Hover:
hover:bg-muted/60only — a hover tint, never a border. - Focus: same ring as above.
- Expanded:
bg-muted/60on the trigger, panel below with the reason + "Jump to Maya". - Dark mode: identical;
--muted-foregroundlightens to215 20% 65%. - Motion-reduced: disclosure fades (opacity-only, 150ms) instead of animating height.
pending
- Only the shipped
AllyPartnerBadge status="pending"on line 1. No line 2 at all — the row stays one line. - No action, no disabled control, no explanatory text.
pendingmeans the answer is somewhere else, and this page cannot honestly say where (see § Throughline). - Dark mode / motion-reduced: inherited from the badge; nothing new.
active
- Default: bordered pill,
link-2-officon, label "End partnership",text-muted-foreground font-semibold. - Hover:
hover:border-destructive/40 hover:text-destructive-accessible. - Focus / pressed: same ring, same
active:scale-[0.98]. - Open:
aria-expanded="true", confirm panel below. - Ending: both confirm buttons
disabled; the primary shows a spinner and reads "Ending…",aria-busy="true". - Dark mode: identical;
--destructive-accessible0 74% 37%→0 70% 68%. - Motion-reduced: as above.
Shared — list-level states
loading, error, and empty are unchanged (AlliesPage.vue:33-99). The empty state has no allies, so it has no partner affordance to show; the header partner-rule line is suppressed when allies.length === 0, exactly as the existing count line already is.
Microcopy
Every string, final. No placeholders.
| Surface | String | Tone check |
|---|---|---|
| Header — no partner, no open request | One ally can be your accountability partner — ask anyone here. | states the rule + the action ✅, one sentence ✅ |
| Header — active partner | {FirstName} is your accountability partner. One at a time — end it to pick someone else. | names the rule and the swap path ✅, no guilt ✅ |
| Header — request open, no partner | A partner request with {FirstName} is still open. One partner at a time. | direction-neutral ✅ (see § Throughline) |
| Row — request control | Ask to partner | verb-first ✅, 3 words ✅ |
| Row — request in flight | Sending… | matches FindPartnerModal's existing in-flight voice ✅ |
| Row — cap explanation | One partner at a time | the reason is the label ✅ |
| Cap panel — body | You're partnered with {FirstName} right now. End that partnership and this spot opens up. | names the blocker + the exit ✅ |
| Cap panel — action | Jump to {FirstName} | forward action, no dead end ✅ |
| Row — end control | End partnership | plain, unhedged ✅ |
| End confirm — body | End your partnership with {FirstName}? Your mutual streak goes back to zero — your own streak and XP stay put. | states the real consequence and its real limit ✅ |
| End confirm — cancel | Cancel | neutral ✅ — not "Keep partner" |
| End confirm — primary | End partnership | same words as the trigger ✅ |
| End in flight | Ending… | ✅ |
| Ask confirm — body | Ask {FirstName} to be your accountability partner? You'll see each other's check-ins and build a mutual streak. | says what the user is opting into ✅ |
| Ask confirm — cancel | Cancel | ✅ |
| Ask confirm — primary | Send request | ✅ |
| Toast — request sent | Partner request sent. | <noun> <past-tense verb> template ✅ |
| Toast — partnership ended | Partnership ended. | flat, not celebratory, not mournful ✅ |
| Toast — end failed | Couldn't end the partnership. Try again. | <what failed> · <recovery> ✅ |
| Toast — request failed (generic) | Couldn't send that request. Try again. | ✅ |
| Toast — request failed (server) | server string, verbatim | e.g. You already have an active accountability partner |
Ruling 2 — no dark patterns on the exit
Three deliberate choices, all of which cost the product nothing and are non-negotiable:
- Cancel is "Cancel," not "Keep partner." A cancel label that argues for one branch is a nudge, and this app doesn't nudge people into relationships.
- Ending takes exactly as many taps as starting. Both are: tap the row control → tap the confirm. No extra "are you sure," no typed confirmation, no cooldown.
- The consequence is stated once, factually, and bounded. "Your mutual streak goes back to zero" is true (
end_partnership.rb:18,mutual_streak_count: 0). "Your own streak and XP stay put" is equally true (docs/features/accountability-partners.md§ Constraints) and is the half a scare-copy version would omit.
Ruling 3 — the confirm never names a streak number
The obvious copy is "Your 12-day mutual streak goes back to zero." Do not write it.USER_ALLIES_WITH_PARTNER_STATUS_QUERY selects id, publicId, firstName, lastName, username, photo, mutualCount, partnerStatus, accountabilityPartner — no mutualStreakCount. Naming a number would mean either adding a field for one sentence, or rendering a fallback as fact, which is exactly the query-state-honesty failure desi-design-context.md §14 exists to prevent. The unnumbered sentence is true without a new field.
Ruling 4 — one shared toast string changes
usePartnership.sendPartnerRequest currently toasts 'Partnership request sent!' (usePartnership.ts:44). Change it to Partner request sent. — the brand template for a success toast is <noun> <past-tense verb>, and the exclamation mark is empty praise for an action that has not yet been accepted by anyone. This string is shared with FindPartnerModal, which is the point: both entry points should say the same thing. One-line change, same file.
Motion
| What | Duration | Curve | Reduced-motion |
|---|---|---|---|
| Confirm / cap disclosure open + close | 200ms | cubic-bezier(0.4, 0, 0.2, 1) | opacity-only, 150ms, no height animation |
| Pill hover / border tint | 150ms | default transition-colors | unaffected (no transform) |
| Pill press | active:scale-[0.98] | existing page idiom | motion-reduce:active:scale-100 |
| "Jump to {Name}" scroll | scrollIntoView({ block: 'center', behavior: 'smooth' }) | browser | behavior: 'auto' |
| In-flight spinner | 800ms linear loop | — | motion-reduce:animate-none |
All four are inside the ≤300ms band DESIGN.md § Motion sets for small state changes, on the standard curve. No useModalAnimations involvement — nothing here is an ion-modal, so there is no 250ms modal timing to match and no bespoke per-modal curve being invented.
The disclosure is a grid-template-rows: 0fr → 1fr transition on a wrapper with overflow: hidden — height animates without a measured pixel value, so it works for both the two-line cap panel and the taller confirm panel with no JS measurement.
Data & refresh contract
endPartnership wrapper (usePartnership.ts)
Follow the shape of the three existing wrappers exactly — including the manual in-flight ref (ending, alongside sending/accepting/declining), which is what actually stops a same-tick double-submit; Apollo's own loading is never read by those functions (OBJ-2673 comment, usePartnership.ts:24-31).
refetchQueries: [
{ query: USER_ALLIES_WITH_PARTNER_STATUS_QUERY, variables: { userId } },
{ query: PARTNER_STATUS_QUERY },
]Both queries, not one. sendPartnerRequest refetches only the allies query and acceptPartnerRequest only PARTNER_STATUS_QUERY; ending a partnership changes what both of them return, and the dashboard partner widget reads the second. Refetching one leaves the other surface confidently stale.
Live refresh already covers the other side
AlliesPage.vue:207-249 already re-fetches on partner_ended notifications and on a cable reconnect-after-disconnect. Verify these still fire and do not double-refetch alongside the mutation's own refetchQueries — that's an acceptance item, not an assumption.
Known race — two open outgoing requests
Because the cap is derived from active only, a user with no partner can send requests to two allies. If both accept, the second AcceptPartnerRequest fails its own require_no_active_partnership guard and the second person gets a rejection. This is backend-governed and correct; the UI's job is to be honest about it: surface the server's error string verbatim via toast.error() and refetch, so the row settles into its true state rather than holding an optimistic one. Do not pre-empt this by blocking the second request client-side — that would enforce a rule the server doesn't have.
Accessibility
Focus order (per row)
avatar link → (line 1) Remove button → (line 2) partner control → when open: panel container → Cancel → primary.
aria-label — every interactive element
| Element | aria-label |
|---|---|
| Ask control | Ask {Full Name} to be your accountability partner |
| Ask control, in flight | Sending a partner request to {Full Name} |
| Cap explanation trigger | Why can't I add {Full Name} as a partner? |
| "Jump to" action | Go to {Full Name}'s row |
| End control | End your accountability partnership with {Full Name} |
| Confirm — Cancel | none — the visible "Cancel" is the accessible name, and it's unambiguous inside a labelled group |
| Confirm — Send request | Send a partner request to {Full Name} |
| Confirm — End partnership | End partnership with {Full Name} |
| Remove button | unchanged — Remove {Full Name} as an ally |
Every trigger also carries aria-expanded and aria-controls pointing at its panel id (partner-confirm-{publicId} / partner-cap-{publicId}). Panels are role="group" with an aria-label restating the question.
Full names in labels, first names in visible copy — the label is the screen-reader user's only disambiguator in a list where two allies may share a first name.
Focus management
- Open: move focus to the panel container (
tabindex="-1") so the consequence sentence is announced before either button is reached. - Cancel / collapse: focus returns to the trigger.
- After a successful end: the confirm unmounts and the row's control becomes "Ask to partner" — move focus to that new control rather than letting it fall to
<body>. The list's existingaria-live="polite"(AlliesPage.vue:102) announces the change. - After a successful send: the row loses line 2 entirely (it becomes
pending). Move focus to the row's Remove button — the nearest surviving control in the same row. - "Jump to {Name}": scroll the partner row into view and focus its End control.
Keyboard
No new shortcuts. Enter/Space toggle the disclosure; Escape inside an open panel collapses it and returns focus to the trigger (matching what backdrop-tap does for a modal, without a modal).
Contrast
axe-core, 3 viewports × 2 themes, on the final mockup: zero violations, including zero color-contrast. Muted text uses bare text-muted-foreground with no alpha modifier stacked on it (desi-design-context.md §13). The one alpha in the set — hover:border-destructive/40 — is on a border, not text, and only at hover.
Tap targets
Every interactive element is min-h-[44px]. The cap explanation gets its 44px via min-h-[44px] px-3 -ml-3 — the negative margin pulls the visual label flush with the name while the hit area stays full size. Assert the computed height, not the class name — use tests/unit/helpers/tapTarget.ts (desi-design-context.md §15).
Storybook story brief
Path: ionic_frontend/src/views/AlliesPage.stories.ts (extend the existing file).
Variants — one story per row state, all four required by OBJ-3008 Task 3:
| Story | Fixture |
|---|---|
NoPartnerEligible | 3 allies, all partnerStatus: 'none' |
AskConfirmOpen | as above, one row's confirm expanded |
ActivePartner | 1 ally 'active', 2 'none' → exercises the cap-blocked variant on the other two in the same story |
EndConfirmOpen | as above, the active row's confirm expanded |
CapBlockedExplained | as ActivePartner, a blocked row's cap panel expanded |
PendingRow | 1 ally 'pending', no active partner → the other rows stay eligible |
Sending / Ending | in-flight, both controls disabled |
argTypes Codi should expose: allies (array), hasActivePartner (derived, read-only in the control panel), expandedRowId (string | null), inFlightRowId (string | null).
Every story renders in light and dark via the existing theme decorator.
Flow sequence
The swap journey — end the current partnership, then partner with someone else, without leaving the page. This is the user's actual ask and the acceptance gate for the whole package.
| From | Trigger | To | Transition + duration | Feedback (toast / haptic) | Reduced-motion fallback | Back-nav |
|---|---|---|---|---|---|---|
| Row (cap-blocked, Jordan) | tap "One partner at a time" | cap panel open | disclosure, 200ms cubic-bezier(0.4,0,0.2,1) | none | opacity-only 150ms | tap trigger again collapses; focus returns to trigger |
| Cap panel | tap "Jump to Maya" | Maya's row focused | scrollIntoView smooth, ≤400ms | none | behavior: 'auto' | n/a — same page, no history entry |
| Row (active, Maya) | tap "End partnership" | confirm open | disclosure, 200ms | none | opacity-only 150ms | Cancel collapses; focus returns to trigger |
| Confirm | tap "End partnership" | in-flight | buttons disabled, aria-busy, label → "Ending…" | none yet | same | blocked while in flight (ending ref) |
| In-flight | mutation resolves | Maya's row → none; header → variant A | confirm collapses, 200ms | toast success 2.5s, no haptic | same | n/a |
| Row (Jordan, now eligible) | tap "Ask to partner" | confirm open | disclosure, 200ms | none | opacity-only 150ms | Cancel collapses |
| Confirm | tap "Send request" | in-flight | disabled, label → "Sending…" | none yet | same | blocked while in flight (sending ref) |
| In-flight | mutation resolves | Jordan's row → pending badge, line 2 removed; header → variant C | row line 2 fades out, 200ms | toast success 2.5s, no haptic | opacity-only | n/a |
Toast tiers are the locked ones (docs/reference/motion-vocabulary.md): success 2.5s / no haptic, error 3s / haptics.error() fired by useToast itself. No inline duration override.
Ruling 5 — ending a partnership fires no haptic and no celebration
useToast.success() deliberately fires no haptic, and nothing here should add one. Ending is neither an achievement nor a failure; a success haptic would read as congratulation and an error haptic as punishment. The success tier's silence is correct, not an omission. Likewise no confetti, no badge, no "while you're here" prompt after the row returns to eligible — the flow ends by simply being finished (gates g48/g49/g52).
Acceptance criteria for Codi
- [ ] Renders at 375px width with no horizontal scroll, in every row state
- [ ] All four row states implementable with the classes above:
noneeligible,nonecap-blocked,pending,active - [ ]
hasActivePartnerderived frompartnerStatus === 'active'only — never frompending - [ ] Every microcopy string matches the § Microcopy table verbatim
- [ ]
AllyPartnerBadgereused unchanged — no new badge component, no new variant, no copy of its markup - [ ] Available actions are bordered pills; the cap explanation is bare text (Ruling 1)
- [ ] Ending takes the same number of taps as starting; cancel reads "Cancel" (Ruling 2)
- [ ] The end confirm names no streak number (Ruling 3)
- [ ]
usePartnership.sendPartnerRequest's toast string updated toPartner request sent.(Ruling 4) - [ ]
endPartnershipwrapper follows the existing send/accept/decline shape, with its ownendingin-flight ref - [ ]
endPartnershiprefetches bothUSER_ALLIES_WITH_PARTNER_STATUS_QUERYandPARTNER_STATUS_QUERY - [ ] Existing
partner_endednotification refresh and cable-reconnect refetch still fire, and don't double-refetch - [ ] Every interactive element has the specified
aria-label, plusaria-expanded+aria-controlson triggers - [ ] Every interactive element's computed height ≥44px (use
tests/unit/helpers/tapTarget.ts, not a class-name assertion) - [ ] Dark-mode parity verified in Storybook for all four states
- [ ] Disclosure honors
prefers-reduced-motion— opacity-only, no height animation - [ ] Focus management per § Accessibility, including after a successful end and a successful send
- [ ] Every transition in § Flow sequence matches the locked motion / toast vocabulary — no invented easing, duration, or haptic tier
- [ ] The swap works end to end in one session without leaving
My Allies - [ ] Storybook stories cover all listed variants and render with zero console errors
Throughline
Journey: partner swap on My Allies (multi-step — three disclosures, two mutations, one intra-page navigation). Traced against AlliesPage.vue, usePartnership.ts, constants/graphql/social.js, send_partner_request.rb, accept_partner_request.rb, end_partnership.rb, and the locked vocabulary in docs/reference/motion-vocabulary.md + DESIGN.md § Motion.
Fixed in this spec:
- g42 / g46 — state integrity after the terminal mutation. The obvious
endPartnershipwrapper copiessendPartnerRequestand refetches only the allies query, leaving the dashboard partner widget (PARTNER_STATUS_QUERY) confidently stale. Fixed in § Data & refresh contract — both queries, stated as an acceptance item. - g27 — double-submit. Apollo's
loadingcan't stop a same-tick re-entrant call here (the OBJ-2673 shape). Fixed by requiring a dedicatedendingref, not a shared flag. - g14 / g16 — designed wait state. Both mutations get a disabled + relabelled +
aria-busyin-flight state rather than a blank frame or a flashed spinner. - g24 / g28 — offline or refused mutation. The two-open-requests race is real and backend-governed; § Data & refresh contract specifies surfacing the server string verbatim and refetching, so a refusal is visible and self-correcting rather than a silent optimistic lie.
- g38 — no dead ends. The cap-blocked panel would otherwise terminate in an explanation with nowhere to go. Fixed by the "Jump to {Name}" action, which is the swap path made tappable.
- g13 — reduced motion. Every disclosure has an opacity-only fallback; no animation cleanup can strand a panel open.
- g48 / g49 / g52 — release. Ruling 5: no celebration, no haptic, no post-completion prompt.
Recorded for Maggie (not mine to fix here):
- g38,
pendingrows — implementation gap. A row with an incoming partner request is a dead end on this page: the user must accept from the notification tray, and this page can't say so becauseUserAlly#partner_status(user_ally.rb:47) returns a flat'pending'for both directions anduser_queries.rb:78-104doesn't exposeaccountability_partner_requested_by_id. I deliberately wrote no forward action rather than copy that is wrong half the time. This is Orion's own §8 follow-up (expose request direction) — it should be ticketed, not absorbed into this spec.
Vocabulary gaps: none. Every duration, curve, toast tier, and haptic decision in this spec cites an existing locked entry. Nothing new was minted.
Open questions
None blocking. Orion's assumption stands: the one-partner cap stays, and this spec makes it legible and escapable rather than removing it.