Team home "Invite coworkers" widget → email-first

2026-06-15 /team/[id]
Mode

1. ★ The card — email-first

/team/[id] · sidebar src/components/team/InviteCodeWidget.tsx

The widget sits in the right rail of the team home (lg:col-span-4). One title line (no echoing subtitle), a multi-email chip input, then role select + Copper Send invites on a single row. A one-line trust note names the team + chosen role. The copy-link demotes below a divider to a single Copy invite link (bare /join URL — no blurb, no preview).

beforeInviteCodeWidget leads with a single "Copy invite" that copies a 13-line markdown blurb (URL + CLI install + quickstart) behind a "Preview message" disclosure, under a title + subtitle that both say "invite coworkers". No email path on the home page at all.
afteremail is primary; one bare Copy invite link is the only fallback; copy is deduplicated.
  • Reuse, don't fork. Body renders <InviteComposer variant="sidebar" showCopyMessage={false} /> — the same component shipped on settings/members. The sidebar variant drops the composer's own border/bg/padding (the Card supplies chrome) and the "Copy message" action.
  • Simplification (design review): the redundant subtitle "Invite your coworkers to {team}" is deleted — the team name now appears once, in the trust line. Role select + Send invites share one row (flex-wrap; CTA flex:1 1 auto; min-width:150px; max-width:280px) so the row stays compact in the 340px rail, caps the button at a sane width on wide renders, and only stacks when genuinely cramped.
  • Copy invite link = bare URL only{origin}/join/{teamId}?code={code} from useTeamInviteCode. The old multi-line blurb is deleted, not relocated.
  • Multi-email chips = batch send. Per-chip validation; wired to useTeamInvites().createInvite with optimistic toast.
  • One Copper per view = Send invites. Hover lift 1px translateY + scale(0.985) press, both motion-reduce-gated; no bounce (motion-sageox).
  • Role control is a real <Select> listbox (the existing one from InviteComposer). CTA label by count: 1 → "Send invite", ≥2 → "Send invites". Trust line reacts to the role ("…as Admin").

1a. State matrix

component states InviteComposer (sidebar)

Empty → typing-with-an-invalid-address → sending → sent. Each card is one Copper only. Invalid chips turn --danger with an inline reason that names the bad value; the CTA disables until every chip is valid. After send, the result shows in place (not a toast-and-forget).

  • Validation reuses InviteComposer's existing per-chip EMAIL_RE + commit-on Enter/comma/space/Tab, paste-splits on whitespace/commas. No new logic.
  • Disabled CTA = aria-disabled + inert surface-3 fill (not display:none, not 45% opacity) so layout never jumps and the control stays focusable.
  • Sent state is the empty composer + a transient ✓ — valid chips clear, the CTA returns to its disabled-empty rest and re-enables the instant a new chip is added (never a stuck primary). Failed chips stay in place for retry (existing Promise.allSettled handling); rows appear optimistically in the Members Invited group.
  • Spinner pauses under Calm + prefers-reduced-motion.

Motion & interaction spec (hand-off — these behaviors are runtime, so a static copy of this mockup would ship without them). All transitions enumerate properties (never transition: all); use a real ease-out cubic-bezier(0.23, 1, 0.32, 1); everything below gates on prefers-reduced-motion + Calm.

  • Chip enter: opacity 0→1 + scale(0.96→1) 150ms ease-out (@starting-style). Remove: opacity→0 + scale→0.96 ~120ms (≈80% of enter). Animate transform,opacity ONLY — never the chipbox width/height (preserves the no-layout-jump guarantee).
  • CTA press: scale(0.985) 120ms. Disabled→enabled: fade opacity/background 150ms; don't animate transform on wake.
  • Copy→Copied swap: blur-masked crossfade — old label opacity→0+blur(2px) 120ms, "✓ Copied" in 150ms; reveal the bare-URL pill opacity 0→1+translateY(2px→0) 150ms. Hold 2s, graceful fade back.
  • Error line + invalid-chip tint: appear together — opacity 0→1+translateY(-2px→0) 150ms; chip tint-in background/border/color 150ms.
  • Sent ✓: icon scale(0.9→1)+opacity 150ms, then .sent-list +50ms stagger. Subtle, not celebratory.
  • Spinner: 600ms linear infinite, track rgba(255,255,255,0.5) on copper.

2. ★ Any member can invite

authz change service/team_invite.go · canManageInvites

Today email invites are owner/admin-only (the server 403s a plain member). We're relaxing that so any active member can invite — with a conservative anti-escalation guard. Everyone sees the full role picker; you can invite up to your own level, and higher roles stay visible but locked with the reason inline (power-on-demand, not a hidden capability).

Backend authz (security-sensitive). canManageInvites(actorRole, invitedRole) in apps/api-go/internal/service/team_invite.go changes from "owner/admin only" to a per-target-role rule:

  • member → may invite as member only (no escalation).
  • admin → member / admin (not owner — existing guard kept).
  • owner → any role.
  • Route still sits behind RequireTeamMember — a non-member is blocked before the service runs. Table-driven role-matrix test added.
  • UI: everyone sees the picker; higher roles render visible-but-locked with a 🔒 Requires admin/owner hint (DESIGN.md §11 power-on-demand). The disabled options are aria-disabled in the listbox, not removed — the capability is discoverable, the gate is explained.
  • Recommend a kill-switch (FEATURE_MEMBER_INVITES, dev-default-on) — this widens an auth/sharing surface (Sacred-tier per feature-flags.md). Confirm with the team before shipping flagless.
  • Frontend role cap mirrors the server (defense-in-depth): the widget reads the viewer's role from initialMembers (matched against useAuth) and only offers ≤ its own level as selectable.

3. Link fallback & degraded

no invite code useTeamInviteCode (isError)

"Copy invite link" copies the bare join URL, revealed as a quiet mono pill on copy. If the invite code can't be fetched, only the link fallback degrades — email still works, because email invites don't depend on the code. Strictly better than today, where a code-fetch failure blanks the whole widget into a "contact owner" dead-end.

beforeisError on useTeamInviteCode replaces the ENTIRE widget with "Contact <owner> to request an invite link." — a dead end that hides the (working) email path.
afteronly the link fallback degrades (disabled + one-line hint); the email composer stays fully functional. Self-healing, not a wall.
  • Copied state swaps the copy glyph → check for 2s (existing pattern) and reveals the exact bare URL as a mono pill so the user sees what landed on their clipboard.
  • URL is truncated visually (mono pill, text-overflow:ellipsis) but the full /join/{teamId}?code={code} is what's copied.

4. Mobile (375px)

≤ sm design.md · mobile baseline

On a phone the sidebar stacks full-width under the main column. Chips wrap; the role + Send row stays on one line when it fits and wraps to a full-width CTA when it doesn't; every target (chip ✕, role select, CTA, link) clears the ≥40px touch minimum. Nothing is hover-gated.

  • Card uses the existing Card chrome; iw-body is fluid — no fixed widths. Chip box flex-wrap handles 3+ emails; the action row flex-wrap drops the CTA full-width below the role select when the phone is narrow.
  • Targets: chip ✕ 18px visual but 40px tap row; role select & CTA min-height:40px; link 36px (secondary, still comfortable).
  • No pointer-coarse: hover-only affordances — the link fallback and remove-✕ are always rendered.

5. Before → after

rationale InviteCodeWidget.tsx

The current widget's primary action is to copy a wall of text, under a title + subtitle that say the same thing. The redesign makes the highest-intent action — inviting by email — primary, deduplicates the copy, and compacts role + Send onto one row.

✕ Before — copy-a-blurb first

Invite coworkers

Invite your coworkers to AI Council

Ryan is inviting you to join AI Council on SageOx: test.sageox.ai/join/team_kk75…?code=8f3a2c After creating an account and accepting the invite, install the ox CLI: github.com/sageox/ox …
✓ After — invite by email first

Invite coworkers

jordan@acme.com name@company.com, …
Role: Member
  • Scope. One file rewrite (InviteCodeWidget.tsx) + one prop on InviteComposer.tsx (variant="sidebar" / showCopyMessage) + pass initialMembers through TeamSidebarWidgets + the backend canManageInvites relaxation. The settings /members composer is untouched (default variant="card").
  • Deleted: the echoing subtitle, the 13-line buildInviteBlurb usage in the widget, the "Preview message" <details>, and the whole-widget "contact owner" error replacement.
  • Out of scope: pending-invite list / resend / revoke (stays on settings/members's AccessControlList); public-view changes (widget is member-view only).