1. ★ The card — email-first
/team/[id] · sidebar src/components/team/InviteCodeWidget.tsxThe 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).
InviteCodeWidget 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.- Reuse, don't fork. Body renders
<InviteComposer variant="sidebar" showCopyMessage={false} />— the same component shipped onsettings/members. Thesidebarvariant 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; CTAflex: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}fromuseTeamInviteCode. The old multi-line blurb is deleted, not relocated. - Multi-email chips = batch send. Per-chip validation; wired to
useTeamInvites().createInvitewith optimistic toast. - One Copper per view = Send invites. Hover lift 1px
translateY+scale(0.985)press, bothmotion-reduce-gated; no bounce (motion-sageox). - Role control is a real
<Select>listbox (the existing one fromInviteComposer). 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-chipEMAIL_RE+ commit-onEnter/comma/space/Tab, paste-splits on whitespace/commas. No new logic. - Disabled CTA =
aria-disabled+ inertsurface-3fill (notdisplay: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.allSettledhandling); 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). Animatetransform,opacityONLY — never the chipbox width/height (preserves the no-layout-jump guarantee). - CTA press:
scale(0.985)120ms. Disabled→enabled: fadeopacity/background150ms; don't animatetransformon wake. - Copy→Copied swap: blur-masked crossfade — old label
opacity→0+blur(2px)120ms, "✓ Copied" in 150ms; reveal the bare-URL pillopacity 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-inbackground/border/color150ms. - 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 · canManageInvitesToday 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
memberonly (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/ownerhint (DESIGN.md §11 power-on-demand). The disabled options arearia-disabledin 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 perfeature-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 againstuseAuth) 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.
isError on useTeamInviteCode replaces the ENTIRE widget with "Contact <owner> to request an invite link." — a dead end that hides the (working) email path.- 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 baselineOn 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
Cardchrome;iw-bodyis fluid — no fixed widths. Chip boxflex-wraphandles 3+ emails; the action rowflex-wrapdrops 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.tsxThe 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.
Invite coworkers
Invite your coworkers to AI Council
- Scope. One file rewrite (
InviteCodeWidget.tsx) + one prop onInviteComposer.tsx(variant="sidebar"/showCopyMessage) + passinitialMembersthroughTeamSidebarWidgets+ the backendcanManageInvitesrelaxation. The settings/memberscomposer is untouched (defaultvariant="card"). - Deleted: the echoing subtitle, the 13-line
buildInviteBlurbusage 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'sAccessControlList); public-view changes (widget is member-view only).