diff --git a/frontend/src/i18n/__tests__/i18n.test.ts b/frontend/src/i18n/__tests__/i18n.test.ts
index f5af9f57a..c4e1dfe89 100644
--- a/frontend/src/i18n/__tests__/i18n.test.ts
+++ b/frontend/src/i18n/__tests__/i18n.test.ts
@@ -114,10 +114,10 @@ describe('i18n configuration', () => {
lng: 'zh-CN',
provider: 'GitHub',
})).toBe('使用 GitHub 继续');
- expect(i18n.t('inviteRedeem.invitedTo', {
+ expect(i18n.t('inviteRedeem.joinPod', {
lng: 'zh-CN',
podName: 'Commonly HQ',
- })).toBe('你受邀加入 Commonly HQ');
+ })).toBe('加入 Commonly HQ');
});
it('keeps the verification copy glossary-clean in Simplified Chinese', () => {
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 0f0c3fd4b..c4a09fb78 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -482,7 +482,7 @@
"whatIsCommonly": "What is Commonly?",
"fallbackPod": "a pod",
"untitledPod": "Untitled pod",
- "invitedTo": "You've been invited to {{podName}}",
+ "invitedEyebrow": "You’re invited to",
"sharedWorkspaceDescription": "A private workspace shared with you",
"previewMeta_one": "{{formattedCount}} member · humans and agents working together on Commonly",
"previewMeta_other": "{{formattedCount}} members · humans and agents working together on Commonly",
@@ -491,7 +491,7 @@
"signUpToJoin": "Sign up to join",
"alreadyHaveAccount": "Already have an account?
Log in ",
"goToPods": "Go to your pods",
- "goToPod": "Go to pod →",
+ "goToPod": "Go to pod",
"joining": "Joining…",
"joinPod": "Join {{podName}}",
"errors": {
diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json
index e809799bd..831d476da 100644
--- a/frontend/src/i18n/locales/zh-CN.json
+++ b/frontend/src/i18n/locales/zh-CN.json
@@ -482,12 +482,12 @@
"whatIsCommonly": "Commonly 是什么?",
"fallbackPod": "一个 Pod",
"untitledPod": "未命名 Pod",
- "invitedTo": "你受邀加入 {{podName}}",
+ "invitedEyebrow": "邀请你加入",
"sharedWorkspaceDescription": "与你共享的私人工作空间",
"signUpToJoin": "注册并加入",
"alreadyHaveAccount": "已有账号?
登录 ",
"goToPods": "前往你的 Pod",
- "goToPod": "前往 Pod →",
+ "goToPod": "前往 Pod",
"joining": "正在加入…",
"joinPod": "加入 {{podName}}",
"errors": {
diff --git a/frontend/src/v2/__tests__/V2InviteRedeemLayout.test.tsx b/frontend/src/v2/__tests__/V2InviteRedeemLayout.test.tsx
new file mode 100644
index 000000000..20bc4fb58
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2InviteRedeemLayout.test.tsx
@@ -0,0 +1,86 @@
+// @ts-nocheck
+// The d215 invite frames: the auth card holding a pod, one key per state.
+import React from 'react';
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import axios from 'axios';
+import V2InviteRedeem from '../components/V2InviteRedeem';
+
+const mockApi = { get: jest.fn(), post: jest.fn(), patch: jest.fn(), del: jest.fn() };
+let mockAuthenticated = false;
+
+jest.mock('axios', () => ({ get: jest.fn() }));
+jest.mock('../../context/AuthContext', () => ({
+ useAuth: () => ({ isAuthenticated: mockAuthenticated, loading: false }),
+}));
+jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi }));
+
+const TOKEN = 'b'.repeat(32);
+const renderPage = () => render(
+
+ } />
+ ,
+);
+
+describe('V2InviteRedeem layout', () => {
+ beforeEach(() => { jest.clearAllMocks(); mockAuthenticated = false; });
+
+ test('signed out: the pod is the title inside the auth card, with sign up and log in', async () => {
+ axios.get.mockResolvedValue({ data: { pod: { name: 'Design review', memberCount: 4 } } });
+ const { container } = renderPage();
+ const title = await screen.findByRole('heading', { level: 1, name: 'Design review' });
+ const card = container.querySelector('.v2-login__card.v2-login__card--stack');
+ expect(card).toContainElement(title);
+ expect(card.querySelector('.v2-login__brand')).not.toBeNull();
+ expect(within(card).getByText('You’re invited to')).toHaveClass('v2-invite__eyebrow');
+ expect(within(card).getByText(/4 members/)).toHaveClass('v2-invite__meta');
+ expect(within(card).getByRole('button', { name: 'Sign up to join' })).toHaveClass('v2-login__submit');
+ expect(within(card).getByRole('link', { name: 'Log in' })).toHaveAttribute('href', `/v2/login?next=${encodeURIComponent(`/v2/invite/${TOKEN}`)}`);
+ expect(container.querySelector('.v2-invite-card, .v2-invite-page')).toBeNull();
+ });
+
+ test('a dead link: the alert row and one plain key', async () => {
+ axios.get.mockRejectedValue(new Error('gone'));
+ renderPage();
+ expect(await screen.findByRole('heading', { level: 1, name: 'Invite unavailable' })).toBeInTheDocument();
+ expect(screen.getByRole('alert')).toHaveTextContent('This invite is no longer valid.');
+ const key = screen.getByRole('button', { name: 'What is Commonly?' });
+ expect(key).toHaveClass('v2-login__submit', 'v2-login__submit--ghost');
+ });
+
+ test('signed in: description, member count and Join; a member gets Go to pod', async () => {
+ mockAuthenticated = true;
+ mockApi.get.mockResolvedValue({
+ pod: { _id: 'p1', name: 'Design review', description: 'Crits on the launch screens.', memberCount: 4 },
+ alreadyMember: false,
+ });
+ const { unmount } = renderPage();
+ expect(await screen.findByRole('heading', { level: 1, name: 'Design review' })).toBeInTheDocument();
+ expect(screen.getByText('Crits on the launch screens.')).toHaveClass('v2-invite__description');
+ expect(screen.getByRole('button', { name: 'Join Design review' })).toHaveClass('v2-login__submit');
+ unmount();
+
+ mockApi.get.mockResolvedValue({ pod: { _id: 'p1', name: 'Design review', memberCount: 4 }, alreadyMember: true });
+ renderPage();
+ expect(await screen.findByRole('button', { name: 'Go to pod' })).toBeInTheDocument();
+ });
+ test('signed in: a dead link and a failed join both show the alert row inside the card', async () => {
+ mockAuthenticated = true;
+ mockApi.get.mockRejectedValue({ response: { data: { msg: 'Invite expired' } } });
+ const { container, unmount } = renderPage();
+ expect(await screen.findByRole('heading', { level: 1, name: 'Invite unavailable' })).toBeInTheDocument();
+ const card = container.querySelector('.v2-login__card');
+ expect(within(card).getByRole('alert')).toHaveTextContent('Invite expired');
+ expect(within(card).getByRole('button', { name: 'Go to your pods' })).toHaveClass('v2-login__submit--ghost');
+ unmount();
+
+ mockApi.get.mockResolvedValue({ pod: { _id: 'p1', name: 'Design review', memberCount: 4 }, alreadyMember: false });
+ mockApi.post.mockRejectedValue({ response: { data: { msg: 'Pod is full' } } });
+ const second = renderPage();
+ fireEvent.click(await screen.findByRole('button', { name: 'Join Design review' }));
+ const alert = await screen.findByRole('alert');
+ expect(second.container.querySelector('.v2-login__card')).toContainElement(alert);
+ expect(alert).toHaveTextContent('Pod is full');
+ expect(screen.getByRole('button', { name: 'Join Design review' })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/v2/__tests__/V2Login.test.tsx b/frontend/src/v2/__tests__/V2Login.test.tsx
index fb8fad806..364cc6208 100644
--- a/frontend/src/v2/__tests__/V2Login.test.tsx
+++ b/frontend/src/v2/__tests__/V2Login.test.tsx
@@ -765,7 +765,8 @@ describe('V2 routing', () => {
try {
renderAt('/v2/community');
- expect(await screen.findByText(/invited to Commonly HQ/)).toBeInTheDocument();
+ expect(await screen.findByRole('heading', { level: 1, name: 'Commonly HQ' })).toBeInTheDocument();
+ expect(screen.getByText('You’re invited to')).toBeInTheDocument();
expect(axios.get).toHaveBeenCalledWith('/api/pods/6a5fe677306155f677c26abf');
expect(axios.get).toHaveBeenCalledWith('/api/invites/7b91255f18ae3c0ae3721707a6613731/preview');
} finally {
diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
index a4d4b208a..3a2845a27 100644
--- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
+++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts
@@ -161,6 +161,20 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(phone).toMatch(/\.v2-profile \.v2-profile__follow, \.v2-profile \.v2-profile__edit \{[^}]*flex: 1 0 100%/);
});
+ test('/invite: a long pod name or description wraps inside the auth card (d215 s-invite)', () => {
+ const invite = read('../components/V2InviteRedeem.css');
+ expect(ruleBody(readV2Css(), '.v2-login__card--stack')).toContain('grid-template-columns: minmax(0, 1fr)');
+ expect(invite).toMatch(/\.v2-invite__heading \{[^}]*min-width: 0/);
+ expect(invite).toMatch(/\.v2-invite__heading \.v2-login__title \{[^}]*overflow-wrap: anywhere/);
+ expect(invite).toMatch(/\.v2-invite__description \{[^}]*overflow-wrap: anywhere/);
+ // The join key reads "Join
": it grows instead of clipping a long name.
+ const key = ruleBody(readV2Css(), '.v2-root .v2-login__card--stack button.v2-login__submit');
+ expect(key).toContain('overflow-wrap: anywhere');
+ expect(key).toContain('height: auto');
+ // A min-height here outranks the phone rule's 44px on every auth page.
+ expect(key).not.toMatch(/^\s*min-height:/m);
+ });
+
test('/cli/authorize scrolls inside the .v2-root shell on a short viewport (TASK-682)', () => {
// .v2-root is height 100vh + overflow hidden; the page must sit in a
// scroller or the lower controls (Deny / Authorize) are unreachable on a
diff --git a/frontend/src/v2/__tests__/v2-type-floors.test.ts b/frontend/src/v2/__tests__/v2-type-floors.test.ts
index 8811e1c7d..ce72ff34c 100644
--- a/frontend/src/v2/__tests__/v2-type-floors.test.ts
+++ b/frontend/src/v2/__tests__/v2-type-floors.test.ts
@@ -27,8 +27,8 @@ import { readV2Css } from '../__testkit__/v2Css';
*/
// v2.css plus the surface files lifted out of it (D223): the chat sheets V2App
// loads, and the per-surface sheets their components import (R04 BYO, S4 board,
-// R05 Connect, R06 Your team).
-const CSS = [readV2Css(), ...['../components/V2AgentBYO.css', '../components/V2PodBoard.css', '../components/V2ConnectPage.css', '../components/V2YourTeamPage.css']
+// R05 Connect, R06 Your team, the invite page).
+const CSS = [readV2Css(), ...['../components/V2AgentBYO.css', '../components/V2PodBoard.css', '../components/V2ConnectPage.css', '../components/V2YourTeamPage.css', '../components/V2InviteRedeem.css']
.map((rel) => fs.readFileSync(path.join(__dirname, rel), 'utf8'))]
.join('\n');
@@ -141,7 +141,8 @@ const ALLOWLIST: string[] = [
// with the Connect lift, so its entry left this list.
'.v2-approval__badge', // 10.5px — mono-below-floor
'.v2-approval__time', // 11.5px — no-mono-family
- '.v2-invite-card__meta', // 11.0px — no-mono-family
+ // The invite link lift dropped `.v2-invite-card__meta` (11.0px, no family)
+ // with the old centred card, so its entry left this list.
'.v2-invite-link', // 11.0px — no-mono-family
'.v2-invite-manage__empty', // 11.0px — no-mono-family
'.v2-invite-manage__meta', // 10.0px — no-mono-family
diff --git a/frontend/src/v2/components/V2InviteRedeem.css b/frontend/src/v2/components/V2InviteRedeem.css
new file mode 100644
index 000000000..519a741ad
--- /dev/null
+++ b/frontend/src/v2/components/V2InviteRedeem.css
@@ -0,0 +1,42 @@
+/* Invite link, the d215 invite frames: the auth card holding a pod. The pod's
+ seeded mark sits beside a mono eyebrow and the pod name as the 28px title. */
+.v2-invite__pod {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+.v2-root .v2-avatar.v2-invite__mark {
+ flex: none;
+ width: 44px;
+ height: 44px;
+ border-radius: 11px;
+ font-size: 15px;
+}
+.v2-invite__heading {
+ min-width: 0;
+}
+.v2-invite__eyebrow {
+ display: block;
+ font: 500 11.5px/1 var(--v2-font-mono);
+ color: var(--v2-text-muted);
+}
+/* Outranks .v2-login__card--stack .v2-login__title (4px) on specificity, not
+ on the order the lazy chunk's stylesheet lands. */
+.v2-login__card--stack .v2-invite__heading .v2-login__title {
+ margin: 6px 0 0;
+ overflow-wrap: anywhere;
+}
+.v2-invite__description {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-size: 14px;
+ line-height: 21px;
+ color: var(--v2-text-secondary);
+}
+.v2-invite__meta {
+ margin: 0;
+ font-size: 13px;
+ line-height: 19px;
+ color: var(--v2-text-secondary);
+}
diff --git a/frontend/src/v2/components/V2InviteRedeem.tsx b/frontend/src/v2/components/V2InviteRedeem.tsx
index 369fdb2b6..497a22109 100644
--- a/frontend/src/v2/components/V2InviteRedeem.tsx
+++ b/frontend/src/v2/components/V2InviteRedeem.tsx
@@ -9,6 +9,9 @@ import axios from 'axios';
import { useAuth } from '../../context/AuthContext';
import { useV2Api } from '../hooks/useV2Api';
import V2Avatar from './V2Avatar';
+import V2AuthBrand from './V2AuthBrand';
+import V2AuthNotice from './V2AuthNotice';
+import './V2InviteRedeem.css';
import { Trans, useTranslation } from 'react-i18next';
interface InvitePodInfo {
@@ -56,6 +59,7 @@ const V2InviteRedeem: React.FC = () => {
(async () => {
try {
setLoading(true);
+ setError(null);
const res = await axios.get(
`/api/invites/${encodeURIComponent(token)}/preview`,
);
@@ -78,6 +82,7 @@ const V2InviteRedeem: React.FC = () => {
(async () => {
try {
setLoading(true);
+ setError(null);
const data = await api.get(`/api/invites/${encodeURIComponent(token)}`);
if (!cancelled) setInvite(data);
} catch (err) {
@@ -113,63 +118,79 @@ const V2InviteRedeem: React.FC = () => {
}
};
+ // Layout: the d215 invite frames. The sign-in card family holding a pod:
+ // brand row, the pod's mark beside its name as the title, one key per state.
+ const card = (children: React.ReactNode) => (
+
+ );
+ const podHead = (name: string) => (
+
+
+
+ {t('inviteRedeem.invitedEyebrow')}
+
{name}
+
+
+ );
+ const unavailable = (message: string, action: React.ReactNode) => card(
+ <>
+ {t('inviteRedeem.unavailable')}
+ {message}
+ {action}
+ >,
+ );
+
if (authLoading || loading) {
- return {t('inviteRedeem.loading')}
;
+ return card({t('inviteRedeem.loading')}
);
}
// ---- anonymous: preview + signup funnel ----
if (!isAuthenticated) {
if (error || !preview) {
- return (
-
-
-
{t('inviteRedeem.unavailable')}
-
{error || t('inviteRedeem.errors.invalid')}
-
{t('inviteRedeem.whatIsCommonly')}
-
-
+ return unavailable(
+ error || t('inviteRedeem.errors.invalid'),
+ navigate('/v2')}>
+ {t('inviteRedeem.whatIsCommonly')}
+ ,
);
}
const podName = preview.pod?.name || t('inviteRedeem.fallbackPod');
const count = preview.pod?.memberCount ?? 0;
- return (
-
-
-
-
{t('inviteRedeem.invitedTo', { podName })}
-
- {t('inviteRedeem.previewMeta', { count, formattedCount: numberFormatter.format(count) })}
-
-
navigate(`/v2/register?next=${nextParam}`)}
- >
- {t('inviteRedeem.signUpToJoin')}
-
-
- }}
- />
-
-
-
+ return card(
+ <>
+ {podHead(podName)}
+
+ {t('inviteRedeem.previewMeta', { count, formattedCount: numberFormatter.format(count) })}
+
+ navigate(`/v2/register?next=${nextParam}`)}
+ >
+ {t('inviteRedeem.signUpToJoin')}
+
+
+ }}
+ />
+
+ >,
);
}
// ---- authenticated ----
if (error && !invite) {
- return (
-
-
-
{t('inviteRedeem.unavailable')}
-
{error}
-
navigate('/v2', { replace: true })}>
- {t('inviteRedeem.goToPods')}
-
-
-
+ return unavailable(
+ error,
+ navigate('/v2', { replace: true })}>
+ {t('inviteRedeem.goToPods')}
+ ,
);
}
if (!invite) return null;
@@ -177,46 +198,43 @@ const V2InviteRedeem: React.FC = () => {
const pod = invite.pod;
const podName = pod.name || t('inviteRedeem.untitledPod');
- return (
-
-
-
-
{podName}
- {pod.description && (
-
- {/* Invite resolution sends the ownership fact, not the creator ID.
- Keep the owner's original description while correcting it for guests. */}
- {invite.isOwner === false && pod.name === 'My Workspace' && pod.description === 'Your private workspace'
- ? t('inviteRedeem.sharedWorkspaceDescription') : pod.description}
-
- )}
-
- {t('inviteRedeem.memberCount', {
- count: pod.memberCount ?? 0,
- formattedCount: numberFormatter.format(pod.memberCount ?? 0),
- })}
-
- {error &&
{error}
}
- {invite.alreadyMember ? (
-
navigate(`/v2/pods/${pod._id}`, { replace: true })}
- >
- {t('inviteRedeem.goToPod')}
-
- ) : (
-
- {redeeming ? t('inviteRedeem.joining') : t('inviteRedeem.joinPod', { podName })}
-
- )}
-
-
+ return card(
+ <>
+ {podHead(podName)}
+ {pod.description && (
+
+ {/* Invite resolution sends the ownership fact, not the creator ID.
+ Keep the owner's original description while correcting it for guests. */}
+ {invite.isOwner === false && pod.name === 'My Workspace' && pod.description === 'Your private workspace'
+ ? t('inviteRedeem.sharedWorkspaceDescription') : pod.description}
+
+ )}
+
+ {t('inviteRedeem.memberCount', {
+ count: pod.memberCount ?? 0,
+ formattedCount: numberFormatter.format(pod.memberCount ?? 0),
+ })}
+
+ {error && {error} }
+ {invite.alreadyMember ? (
+ navigate(`/v2/pods/${pod._id}`, { replace: true })}
+ >
+ {t('inviteRedeem.goToPod')}
+
+ ) : (
+
+ {redeeming ? t('inviteRedeem.joining') : t('inviteRedeem.joinPod', { podName })}
+
+ )}
+ >,
);
};
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 46cb44b0b..cf6086701 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -1795,60 +1795,6 @@ body.modern-ui.v2-canvas {
}
}
-/* Standalone redeem page — `/v2/invite/:token`. Centered card so the
- logged-in user lands on a focused, readable join surface. */
-.v2-invite-page {
- min-height: 100vh;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 32px;
- background: var(--v2-bg-subtle);
-}
-
-.v2-invite-card {
- width: min(420px, 100%);
- background: var(--v2-surface);
- border: 1px solid var(--v2-border-soft);
- border-radius: var(--v2-radius-md, 12px);
- padding: 28px 24px 24px;
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- gap: 10px;
-}
-
-.v2-invite-card--loading {
- color: var(--v2-text-secondary);
- font-size: 13px;
-}
-
-.v2-invite-card__title {
- margin-top: 4px;
- font-size: 18px;
- font-weight: 700;
- color: var(--v2-text-primary);
-}
-
-.v2-invite-card__description {
- font-size: 13px;
- color: var(--v2-text-secondary);
- line-height: 1.5;
-}
-
-.v2-invite-card__meta {
- font-size: 11px;
- color: var(--v2-text-tertiary);
- letter-spacing: 0.04em;
- text-transform: uppercase;
-}
-
-.v2-invite-card__error {
- font-size: 12px;
- color: var(--v2-danger);
-}
-
.v2-root button.v2-invite-card__cta,
.v2-invite-card__cta {
width: 100%;
@@ -1991,6 +1937,9 @@ body.modern-ui.v2-canvas {
the card (invite-required has two) lends its rows to that grid. */
.v2-login__card--stack {
display: grid;
+ /* minmax(0, 1fr): one long unbroken word (a pod description, a URL) wraps
+ inside the card instead of widening the grid column past it. */
+ grid-template-columns: minmax(0, 1fr);
gap: 16px;
}
.v2-login__card--stack > form {
@@ -2029,6 +1978,14 @@ body.modern-ui.v2-canvas {
}
.v2-root .v2-login__card--stack button.v2-login__submit {
margin-top: 0;
+ /* A key can carry a pod name ("Join "); an unbroken one wraps
+ inside the key instead of painting past the card. One line stays 40px
+ (border-box 9 + 20 + 9 + 2). No min-height here: it would outrank the
+ phone rule's 44px. */
+ height: auto;
+ padding-block: 9px;
+ line-height: 20px;
+ overflow-wrap: anywhere;
}
.v2-login__brand {
@@ -4270,7 +4227,7 @@ body.modern-ui.v2-canvas {
.v2-root:lang(zh) .v2-activity__queue-detail,
.v2-root:lang(zh) .v2-activity__empty,
.v2-root:lang(zh) .v2-modal__hint,
-.v2-root:lang(zh) .v2-invite-card__description,
+.v2-root:lang(zh) .v2-invite__description,
.v2-root:lang(zh) .v2-login__field-hint {
line-height: 1.6;
}