Skip to content

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 / screenStatus
views/AlliesPage.vue — page header sub-copymodified — one new line
views/AlliesPage.vue — ally row (<li>)modified — new line 2, existing line 1 untouched
components/allies/AllyPartnerBadge.vuereused, unchanged — no new badge, no new variant
components/partners/FindPartnerModal.vuereferenced — its expand-to-confirm-inline pattern is the model for the row confirm
composables/usePartnership.tsmodified 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

SurfaceToken / classNote
Line-2 pill (both actions)border border-solid border-border bg-card rounded-xlone recipe, two labels
"Ask to partner" label + icontext-primary-accessible font-semibold--primary-accessible = 203 78% 35% light / 203 78% 61% dark
"Ask to partner" hover / openhover:border-primary-accessible hover:bg-primary/5border, not fill, carries the state
"End partnership" label + icontext-muted-foreground font-semiboldquiet at rest — it is not a CTA
"End partnership" hoverhover:border-destructive/40 hover:text-destructive-accessibledestructive intent appears on approach, not at rest
Cap explanationtext-sm text-muted-foreground, no border, no fillannotation, not control
Cap / confirm panelrounded-xl border border-solid border-border bg-muted p-3FindPartnerModal's .ally-confirm, retokenised
Confirm — cancelborder border-solid border-border text-muted-foregroundequal width to the primary
Confirm — sendbg-primary text-primary-foreground
Confirm — endbg-destructive text-destructive-foreground--destructive 0 72% 44% / 0 72% 46%
Partner badgeAllyPartnerBadge as shippedgold gradient from-gold-light to-gold, ink text-gold-foreground
Row dividerborder-b border-border/40unchanged

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

ElementFontClass
Page heading "My Allies"Outfitfont-display font-semibold text-2xl (unchanged)
Header count lineIntertext-sm text-muted-foreground (unchanged)
Header partner-rule lineIntertext-sm text-muted-foreground
Ally nameIntertext-sm font-semibold (unchanged)
@usernameIntertext-xs text-muted-foreground (unchanged)
Line-2 control labelIntertext-sm font-semibold
Cap explanation labelIntertext-sm (regular weight — this is the tell)
Confirm / cap panel bodyIntertext-sm text-foreground
Confirm buttonsIntertext-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

ts
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-handshake icon, 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-2 icon spinning, label "Sending…".
  • Dark mode: identical classes; --primary-accessible lightens to 203 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. info icon + "One partner at a time", text-sm text-muted-foreground, min-h-[44px] px-3 -ml-3 so the hit area is real while the label sits flush with the name.
  • Hover: hover:bg-muted/60 only — a hover tint, never a border.
  • Focus: same ring as above.
  • Expanded: bg-muted/60 on the trigger, panel below with the reason + "Jump to Maya".
  • Dark mode: identical; --muted-foreground lightens to 215 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. pending means 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-off icon, 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-accessible 0 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.

SurfaceStringTone check
Header — no partner, no open requestOne 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 partnerA partner request with {FirstName} is still open. One partner at a time.direction-neutral ✅ (see § Throughline)
Row — request controlAsk to partnerverb-first ✅, 3 words ✅
Row — request in flightSending…matches FindPartnerModal's existing in-flight voice ✅
Row — cap explanationOne partner at a timethe reason is the label ✅
Cap panel — bodyYou're partnered with {FirstName} right now. End that partnership and this spot opens up.names the blocker + the exit ✅
Cap panel — actionJump to {FirstName}forward action, no dead end ✅
Row — end controlEnd partnershipplain, unhedged ✅
End confirm — bodyEnd 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 — cancelCancelneutral ✅ — not "Keep partner"
End confirm — primaryEnd partnershipsame words as the trigger ✅
End in flightEnding…
Ask confirm — bodyAsk {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 — cancelCancel
Ask confirm — primarySend request
Toast — request sentPartner request sent.<noun> <past-tense verb> template ✅
Toast — partnership endedPartnership ended.flat, not celebratory, not mournful ✅
Toast — end failedCouldn'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, verbatime.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

WhatDurationCurveReduced-motion
Confirm / cap disclosure open + close200mscubic-bezier(0.4, 0, 0.2, 1)opacity-only, 150ms, no height animation
Pill hover / border tint150msdefault transition-colorsunaffected (no transform)
Pill pressactive:scale-[0.98]existing page idiommotion-reduce:active:scale-100
"Jump to {Name}" scrollscrollIntoView({ block: 'center', behavior: 'smooth' })browserbehavior: 'auto'
In-flight spinner800ms linear loopmotion-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

Elementaria-label
Ask controlAsk {Full Name} to be your accountability partner
Ask control, in flightSending a partner request to {Full Name}
Cap explanation triggerWhy can't I add {Full Name} as a partner?
"Jump to" actionGo to {Full Name}'s row
End controlEnd your accountability partnership with {Full Name}
Confirm — Cancelnone — the visible "Cancel" is the accessible name, and it's unambiguous inside a labelled group
Confirm — Send requestSend a partner request to {Full Name}
Confirm — End partnershipEnd partnership with {Full Name}
Remove buttonunchangedRemove {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 existing aria-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:

StoryFixture
NoPartnerEligible3 allies, all partnerStatus: 'none'
AskConfirmOpenas above, one row's confirm expanded
ActivePartner1 ally 'active', 2 'none' → exercises the cap-blocked variant on the other two in the same story
EndConfirmOpenas above, the active row's confirm expanded
CapBlockedExplainedas ActivePartner, a blocked row's cap panel expanded
PendingRow1 ally 'pending', no active partner → the other rows stay eligible
Sending / Endingin-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.

FromTriggerToTransition + durationFeedback (toast / haptic)Reduced-motion fallbackBack-nav
Row (cap-blocked, Jordan)tap "One partner at a time"cap panel opendisclosure, 200ms cubic-bezier(0.4,0,0.2,1)noneopacity-only 150mstap trigger again collapses; focus returns to trigger
Cap paneltap "Jump to Maya"Maya's row focusedscrollIntoView smooth, ≤400msnonebehavior: 'auto'n/a — same page, no history entry
Row (active, Maya)tap "End partnership"confirm opendisclosure, 200msnoneopacity-only 150msCancel collapses; focus returns to trigger
Confirmtap "End partnership"in-flightbuttons disabled, aria-busy, label → "Ending…"none yetsameblocked while in flight (ending ref)
In-flightmutation resolvesMaya's row → none; header → variant Aconfirm collapses, 200mstoast success 2.5s, no hapticsamen/a
Row (Jordan, now eligible)tap "Ask to partner"confirm opendisclosure, 200msnoneopacity-only 150msCancel collapses
Confirmtap "Send request"in-flightdisabled, label → "Sending…"none yetsameblocked while in flight (sending ref)
In-flightmutation resolvesJordan's row → pending badge, line 2 removed; header → variant Crow line 2 fades out, 200mstoast success 2.5s, no hapticopacity-onlyn/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: none eligible, none cap-blocked, pending, active
  • [ ] hasActivePartner derived from partnerStatus === 'active' only — never from pending
  • [ ] Every microcopy string matches the § Microcopy table verbatim
  • [ ] AllyPartnerBadge reused 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 to Partner request sent. (Ruling 4)
  • [ ] endPartnership wrapper follows the existing send/accept/decline shape, with its own ending in-flight ref
  • [ ] endPartnership refetches both USER_ALLIES_WITH_PARTNER_STATUS_QUERY and PARTNER_STATUS_QUERY
  • [ ] Existing partner_ended notification refresh and cable-reconnect refetch still fire, and don't double-refetch
  • [ ] Every interactive element has the specified aria-label, plus aria-expanded + aria-controls on 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 endPartnership wrapper copies sendPartnerRequest and 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 loading can't stop a same-tick re-entrant call here (the OBJ-2673 shape). Fixed by requiring a dedicated ending ref, not a shared flag.
  • g14 / g16 — designed wait state. Both mutations get a disabled + relabelled + aria-busy in-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, pending rows — 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 because UserAlly#partner_status (user_ally.rb:47) returns a flat 'pending' for both directions and user_queries.rb:78-104 doesn't expose accountability_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.

Loading…