diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index 82e646114..713944c07 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -743,6 +743,12 @@ .authcard .facts dd { margin: 0; font: 500 12.5px/1.4 var(--f-mono); color: var(--ink); text-align: right; overflow-wrap: anywhere; } .authcard h1 .host { white-space: nowrap; } .authcard .keys2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.authcard .inv { display: flex; align-items: center; gap: 12px; margin-top: 4px; } +.authcard .inv h1 { margin: 6px 0 0; text-wrap: balance; } +.authcard .inv .ey { font: 500 11.5px/1 var(--f-mono); color: var(--mu); } +.authcard .pt { flex: none; width: 44px; height: 44px; border-radius: 11px; display: grid; place-items: center; color: #fff; font: 700 15px/1 var(--f-body); box-shadow: 0 0 0 1px var(--av-ring); } +.authcard .ds { margin: 0; font-size: 14px; line-height: 21px; color: var(--ink2); } +.authcard .mt { margin: 0; font-size: 13px; line-height: 19px; color: var(--se); } .authcard .sub { margin: 0; font-size: 13.5px; line-height: 1.5; color: var(--se); } .authcard .hint { margin: 0; font-size: 13px; line-height: 1.5; color: var(--se); } .authcard .hint a, .authcard .legal a { color: var(--c-text); font-weight: 600; } @@ -1358,6 +1364,12 @@

Same look. Less said.

Enter code · 390
commonly

Authorize Commonly CLI

Enter the code shown in your terminal.

Device code
WDJB-MJHT
Approve · 390a pending request
commonly

Authorize alex-mbp as @alex?

This device is asking to use your Commonly account.

Client
commonly-cli 0.1.84
Instance
api.commonly.me
Requested
just now
Only approve a code you requested from your own terminal.
Authorized · 390
commonly

Device authorized

You can return to your terminal.
+
+

Invite link

/v2/invite/:token
+
  • The sign-in card, holding a pod. Same brand row, 400px card and keys as sign-in, so a person who follows the link and then signs up never changes page family. Left-aligned like every auth card; the centred card and its 18px title are gone.
  • The pod is the title. Its mark (the same seeded tile it has in the sidebar) sits beside a mono “You’re invited to” and the pod name in the 28px title.
  • One key per state. Signed out: Sign up to join, with Log in under it. Signed in: Join the pod, or Go to pod for a member. A dead link: the alert row and one plain key.

Each state, one line

Signed outDrawn. Pod, member line, Sign up to join, Log in.
Signed inDrawn. Description when the pod has one, member count, Join.
Already a memberNot drawn. The key reads Go to pod.
JoiningNot drawn. The key reads Joining… and is disabled.
Join failedNot drawn. The alert row sits above the key; the key stays.
UnavailableDrawn signed out: the plain key reads What is Commonly?. Signed in, it reads Go to your pods.
LoadingNot drawn. The card holds the brand row and one muted line.
Signed out · 1440from a shared link
commonly
DR
You’re invited to

Design review

4 members · humans and agents working together on Commonly

Already have an account? Log in
+
Signed out · 390
commonly
DR
You’re invited to

Design review

4 members · humans and agents working together on Commonly

Already have an account? Log in
Signed in · 390
commonly
DR
You’re invited to

Design review

Crits on the launch screens before they ship. Agents post the first pass.

4 members

Unavailable · 390signed out; expired or revoked
commonly

Invite unavailable

+
+

Dark mode

one dark value per token
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) => ( +
+
+ + {children} +
+
+ ); + 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'), + , ); } 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) })} -
- -
- }} - /> -
-
-
+ return card( + <> + {podHead(podName)} +

+ {t('inviteRedeem.previewMeta', { count, formattedCount: numberFormatter.format(count) })} +

+ +

+ }} + /> +

+ , ); } // ---- authenticated ---- if (error && !invite) { - return ( -
-
-
{t('inviteRedeem.unavailable')}
-
{error}
- -
-
+ return unavailable( + error, + , ); } 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 ? ( - - ) : ( - - )} -
-
+ 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 ? ( + + ) : ( + + )} + , ); }; 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; }