Self-review of the light/dark sweep surfaced neutral colors that lived
in raw inline-style / <style jsx> / gradient values (so the className
sweep correctly skipped them) and a few contrast/affordance nits:
- reply-quote: adapt to the outbound (primary) bubble via onPrimary so
the quote/author stay legible on the violet fill in light mode; wire
it from message-bubble.
- dashboard SVG chrome -> tokens: conversations-chart gridline/crosshair,
pipeline-donut background ring, automation-builder dot-grid gradient.
- members-tab viewer chip: bg-card -> bg-transparent (outline) so it
doesn't blend into a card surface in light mode.
- tag-manager / pipeline-settings color-swatch selection rings: raw
slate/white -> var(--popover)/var(--foreground) so they flip.
- pipeline-board scrollbar (<style jsx>): slate rgb -> var tokens.
- activity-feed zebra stripe: bg-card/40 -> bg-muted/40 (survives light).
- forgot-password / import-modal: restore real hover (dead no-op hovers).
- themed-toaster: gate theme via useSyncExternalStore to avoid a
hydration mismatch on sonner's theme attribute (no setState-in-effect).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce a light/dark MODE axis orthogonal to the existing accent
themes. Neutral surface tokens now live in html[data-mode="light|dark"]
blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*)
stay in html[data-theme] blocks, so any accent works in either mode.
- globals.css: split neutrals (mode) from accent (theme); add light
palette; dark values unchanged (dark stays the default).
- lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode.
- use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync.
- layout.tsx: boot script applies persisted mode before first paint
(no flash); colorScheme "dark light".
- mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware).
- appearance-panel.tsx: light/dark control + updated copy.
- Tokenize hardcoded neutral colors (slate/white/gray) -> semantic
tokens across the app so every surface flips with mode. Flow canvas
and message-bubble timestamps made mode-aware too.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR D — the final pass of the post-review fix series. Bundles the
seven visual / UX polish items the design review flagged.
P1 + P5 — sidebar account strip (`src/components/layout/sidebar.tsx`)
- Owner used to be invisible in the role-chip slot (the previous
check was `accountRole && accountRole !== "owner"`), so owners
looked indistinguishable from admins. Now the chip always
renders, with a per-role icon + colour pulled from a new
`ROLE_CHIP` table.
- Added `title={account.name}` on the truncated name so the full
account name is visible on hover when it's clipped (long names
+ narrow sidebars).
P2 + P3 + P10 — members tab (`members-tab.tsx`)
- **P2** — destructive Remove / Revoke buttons used a neutral
default that only turned red on hover. The destructive intent
was invisible until the user moused over. Both buttons now have
a red default state (`border-red-500/40 bg-red-500/10
text-red-300`) with a darker shade on hover.
- **P3** — role chips were all slate. Replaced with a per-role
colour scale: owner amber (scarce, immutable), admin primary
(significant), agent slate (operational default), viewer muted
slate (read-only). Mirrors the sidebar's `ROLE_CHIP` table so
the two surfaces don't drift; once the surface stabilises this
should hoist into a shared module.
- **P10** — pending invitations section now opens with a one-line
hint: "The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and create
a new one." Pre-fix, admins kept looking for a "copy link"
button on the pending rows; the design decision (don't store
plaintext) is now self-explanatory.
- Cleanup: removed the now-unused `ROLE_LABELS` constant
(subsumed by `ROLE_CHIP[role].label`).
P4 — amber "Save this link now" callout contrast
- Pre-fix: `bg-amber-500/10 + text-amber-200` on slate-900 was
borderline for WCAG AAA body-text contrast.
- Post-fix: `bg-amber-500/15`, border bumped to `/50`, strong
intro promoted to `amber-100` so the urgent copy reads with
proper weight against the dark backdrop.
P7 — Try-again button on join page server_error
- Pre-fix: the only recourse for a server_error peek was to
follow "Create a new account" / "Sign in" links — both lose
the invite token.
- Post-fix: extracted the peek+auth fetch as `loadPeekAndAuth`,
call it from a new "Try again" primary button on the
server_error card. Other failure reasons (not_found / used /
expired) are terminal for this token so they keep the
signup/sign-in escape hatches; only server_error gets retry.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Closes out the post-review fix series. Combined with PRs A
(#178, backend perf), B (#179, UI blocking), and C (#180,
front-end correctness), every item from the consolidated review
report is addressed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR C of the post-review fix series. Bundles six related front-end
correctness / portability fixes. No behavior change for happy-path
users — these are belt-and-braces against edge cases.
S1 — useCan exhaustiveness (`src/hooks/use-can.ts`)
- Add a `default` branch with `const _: never = action` so adding
a new `CanAction` without a case fails the typecheck instead of
silently returning `undefined` at runtime.
S2 + P8 — GatedButton helper (`src/components/ui/gated-button.tsx`)
- `<Button disabled title="...">` doesn't render a tooltip in
Safari or older Firefox: those browsers don't fire mouse events
on disabled controls, so the title never surfaces. Viewers on
those browsers saw silently-disabled CTAs with no explanation.
- New `<GatedButton canAct={...} gateReason="...">` wraps the
Button in a non-disabled `<span>` that owns the `title`. The
span receives mouseover everywhere; the tooltip works.
- Also subsumes the duplicated `READ_ONLY_TITLE = "..."` constants
across 5 list pages. `gateReason` is the verb phrase that
completes "Read-only — your role can't <gateReason>".
- Migrated call sites: broadcasts / automations / flows / contacts
/ pipelines list pages + inbox composer (Send + Templates
buttons). The composer's textarea keeps its inline title since
the GatedButton pattern only applies to buttons.
S6 — Revert optimistic role-change on error (`members-tab.tsx`)
- Pre-fix: PATCH failure showed a toast and left the local
member-row state with the new role. Next interaction
(re-toggling to the same role) was a no-op via the
`member.role === nextRole` guard at the top, so the row
appeared stuck. Now we capture `previousRole` before the
optimistic flip and revert on both 4xx and network failure.
S7 — Sidebar flag check matches settings (`sidebar.tsx`)
- Pre-fix: sidebar's `accountSharingEnabled` didn't guard on
`profileLoading`, so the account-name strip briefly was hidden
during the initial profile fetch then popped in. Settings page
did this guard correctly. Now they match.
S8 — Referrer-Policy: no-referrer on /join (`join/layout.tsx`)
- The plaintext invite token lives in the URL path. Belt-and-
braces against any future external resource on the join page
leaking the token via the Referer header. Set via Next 16's
`metadata` export → `<meta name="referrer" content="no-referrer">`.
Also marks the route `noindex, nofollow` so a join URL can't end
up in search results.
P9 — Client-side label length validation (`invite-member-dialog.tsx`)
- Server caps `label` at 80 chars and returns 400 on overflow.
Add a `MAX_LABEL_LEN` constant on the client, point both the
`<Input maxLength>` and the pre-submit check at it. Catches the
paste-then-submit path where a paste lands an over-limit string
in state before the keystroke-level cap engages.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (the new `never` cast catches typos)
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR B of the post-review fix series. Closes the three user-blocking
UI gaps the design / UX review flagged.
M3 — Charlie's 409 redeem dead-end (join page)
- Pre-fix: a user with existing data clicking an invite saw a
3-second toast then the Accept button re-enabled. The same
retry would 409 forever.
- Post-fix: 409 status is detected explicitly and surfaces a
blocking dialog with an explanation + "Sign out & use a
different email" action. The action calls supabase.signOut()
then reloads the page; the page rebuilds on the signed-out
branch with the "Create account & join" CTA. The invite token
stays in the URL through the reload so the recovery is one
click. Non-409 errors still take the regular toast path.
- Files: `src/app/join/[token]/page.tsx` — new state
`conflictMessage`, new handler `handleSignOutAndRetry`, new
Dialog component in the auth-in render branch.
M4 — WhatsApp share message includes the account name
- Pre-fix: "Join our wacrm account using this link…" — generic,
no team context. In multi-team contexts the recipient
couldn't tell which team they were being invited to.
- Post-fix: "Join <Account Name> on wacrm using this link…".
The account name is snapshotted into the `CreatedInvite`
result at creation time so a later rename can't retroactively
rewrite the wa.me message.
- Files: `src/components/settings/invite-member-dialog.tsx` —
read `account` from `useAuth()`, thread `accountName` through
`CreatedInvite`, update `whatsappShareUrl` copy.
S3 — Members tab roster mobile reflow
- Pre-fix: the member row used a single flex-row with a fixed-
width 128px role Select and a 36px Remove button. On a 375px
viewport the name + email got crushed to ~50px.
- Post-fix: at base width the row stacks vertically — identity
(avatar + name + email) on top, actions (role + Remove) below.
At sm+ it returns to the original inline layout. The joined-
date column stays desktop-only.
- Files: `src/components/settings/members-tab.tsx` — `flex-col`
base, `sm:flex-row sm:items-center` for desktop. Actions
wrapped in their own flex container so the role Select and
Remove button cluster together on both breakpoints.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 6 of the multi-user accounts series. First user-facing surface
for the feature — the Members tab in Settings.
What lands
- `src/components/settings/members-tab.tsx` — roster + pending
invitations. Every member can see the roster (read-only).
Admin+ gets an inline role-select dropdown (excluding the
owner row and self), a Remove button (same restrictions), and
the "Pending invitations" section with revoke.
- `src/components/settings/invite-member-dialog.tsx` — two-step
modal. Form (role + expiry + optional label) → result
(read-only URL with copy-to-clipboard + a "Send via WhatsApp"
deep link). The result step is dismissed-once: the plaintext
token is only ever shown here and never resurfaced, so the
copy carries a warning ("Save this link now").
- `src/app/(dashboard)/settings/page.tsx` — adds `members` to
the tab list, gated behind the existing
`profiles.beta_features.includes('account_sharing')` flag
(same column migration 011 added; flip it via Supabase Studio
on a per-profile basis to dogfood). When the flag is off the
tab trigger AND content render nothing — the page degrades
to the original 5-tab layout. A user landing on
`/settings?tab=members` without the flag is silently bounced
to `?tab=profile` rather than seeing an empty pane.
Why a feature flag at all
- Locked decision from the planning phase: ship the schema and
API unconditionally (they're structural, harmless to existing
single-user installs) but keep the *user-visible* invite
surface behind a flag until the full UI series ships (Join
page, sidebar gating, webhook account routing). Mirrors how
migration 011 / Flows beta worked.
Role-gating
- The tab is reachable by any member of an account. Inside the
tab, mutation controls (Invite member button, role dropdown,
Remove button, pending invitations section + revoke buttons)
are wrapped in `<RequireRole min="admin">` or hidden via
`useAuth().canManageMembers` from #172.
- Server-side RPCs (set_member_role, remove_account_member from
#170; invitation create/revoke from #171) double-check role
anyway — the client gate is UX, not security.
Owner row
- Non-editable. Inline role select and Remove button are hidden
on the owner's row across the board. Owner role changes go
through the (deferred) Transfer Ownership flow.
Self row
- "You" badge on the caller's own row. Role select and Remove
are hidden on it — admins can't accidentally demote or remove
themselves, even though the server RPC would also catch it.
Email visibility
- Admin+ sees teammate emails; agent/viewer sees name + avatar
+ role + joined date only. Implemented server-side by the
`/api/account/members` route in #170; the component just
renders `member.email` when present.
UI patterns
- Mirrors existing tag-manager / template-manager card +
divide-y list layout and the slate-900/slate-700/primary
color tokens from the dark-only theme.
- Dialogs follow the established `bg-slate-900 border-slate-700`
pattern. Destructive actions get the `bg-red-600 hover:bg-red-700`
treatment with `Loader2` spinner.
- "Send via WhatsApp" anchor uses `buttonVariants` directly
rather than `<Button asChild>` — the wacrm Button is the
Base UI ButtonPrimitive and has no Radix-style asChild.
- Base UI Select's `onValueChange` is
`(value: string | null) => void`; every consumer guards on
null. We don't expose a clear affordance, so null shouldn't
fire at runtime — but typescript requires the guard.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; /settings stays static (data
fetches happen client-side via useEffect)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>