diff --git a/public/locales/en.json b/public/locales/en.json index b5919e14..8c681b38 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -533,6 +533,7 @@ "incoming": "Incoming call…", "incoming_label": "Incoming call", "accept": "Accept", + "answer": "Answer", "decline": "Decline", "unknown_caller": "Unknown caller", "ctl_mic": "Mic", diff --git a/public/locales/ru.json b/public/locales/ru.json index e13a925d..399b7960 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -537,6 +537,7 @@ "incoming": "Входящий звонок…", "incoming_label": "Входящий звонок", "accept": "Принять", + "answer": "Ответить", "decline": "Отклонить", "unknown_caller": "Неизвестный абонент", "ctl_mic": "Микрофон", diff --git a/src/app/features/call-status/CallActionButton.tsx b/src/app/features/call-status/CallActionButton.tsx index 00aa659c..73561a31 100644 --- a/src/app/features/call-status/CallActionButton.tsx +++ b/src/app/features/call-status/CallActionButton.tsx @@ -57,7 +57,10 @@ export function CallActionButton({ aria-hidden > {busy ? ( - + // Match the disc tone: a destructive (red) disc shows a Critical spinner, + // everything else a neutral Secondary — a grey-purple spinner on the red + // hangup disc read as a foreign element, esp. in light theme. + ) : ( )} diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index 50051d80..b0a7101e 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -96,6 +96,15 @@ export function CallStatus({ callEmbed }: CallStatusProps) { : t('Call.in_call_count', { count: memberCount }); } + // Screen-reader phase word — the same lifecycle WITHOUT the per-second timer, so + // AT announces «Connecting → Calling → In call» transitions instead of reading + // out every tick of the visible counter. + let phaseLabel: string; + if (!callJoined) phaseLabel = t('Call.connecting'); + else if (!everConnected) phaseLabel = t('Call.calling'); + else if (isOneOnOne) phaseLabel = t('Call.in_call'); + else phaseLabel = t('Call.in_call_count', { count: memberCount }); + const native = isNativePlatform(); const avatar = ( @@ -131,9 +140,12 @@ export function CallStatus({ callEmbed }: CallStatusProps) { - + {subText} + + {phaseLabel} + ); diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx index 17a59bde..ddd2f3f8 100644 --- a/src/app/features/call-status/IncomingCallStrip.tsx +++ b/src/app/features/call-status/IncomingCallStrip.tsx @@ -109,7 +109,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) { const buttons = (
diff --git a/src/app/features/call-status/OverLockCallScreen.tsx b/src/app/features/call-status/OverLockCallScreen.tsx index 0ca6cd4e..09a127c7 100644 --- a/src/app/features/call-status/OverLockCallScreen.tsx +++ b/src/app/features/call-status/OverLockCallScreen.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useAtomValue, useSetAtom } from 'jotai'; import { useTranslation } from 'react-i18next'; +import { toRem } from 'folds'; import classNames from 'classnames'; import { callEmbedAtom, overLockCallAtom } from '../../state/callEmbed'; import { incomingCallsAtom } from '../../state/incomingCalls'; @@ -107,8 +108,12 @@ export function OverLockCallScreen() { if (!overLock) return null; - const name = callEmbed?.room?.name?.trim() || 'Vojo'; + const name = callEmbed?.room?.name?.trim() || t('Call.unknown_caller'); const initial = String.fromCodePoint(name.codePointAt(0) ?? 63).toUpperCase(); + // Visible state text walks Connecting → live timer. The PHASE word (without the + // per-second timer) is announced separately to screen readers so AT doesn't read + // out every tick. + const phaseLabel = callEmbed && joined ? t('Call.in_call') : t('Call.connecting'); const status = callEmbed && joined ? timer || t('Call.in_call') : t('Call.connecting'); // Leave over-lock (device returns to the still-locked lockscreen) WITHOUT @@ -155,6 +160,7 @@ export function OverLockCallScreen() { onClick={leaveOverLock} aria-label={t('Call.minimize')} title={t('Call.minimize')} + className={css.OverLockIconButton} style={{ display: 'flex', alignItems: 'center', @@ -163,7 +169,6 @@ export function OverLockCallScreen() { height: 44, borderRadius: '50%', border: 'none', - background: 'transparent', color: DAWN.text, cursor: 'pointer', }} @@ -173,8 +178,9 @@ export function OverLockCallScreen() { )} - {/* Identity (vertically centred). */} + {/* Identity (vertically centred). Animates in (root stays opaque cover). */}
+ {/* Visible (ticking) text hidden from AT; the phase word is announced once + per transition via the polite live region below. */} {status} + + {phaseLabel} +
{/* Controls. */} -
+
{callEmbed ? ( ) : ( diff --git a/src/app/features/call-status/styles.css.ts b/src/app/features/call-status/styles.css.ts index 97eda777..1c495efe 100644 --- a/src/app/features/call-status/styles.css.ts +++ b/src/app/features/call-status/styles.css.ts @@ -102,12 +102,16 @@ export const CallAvatarCompact = style({ flexShrink: 0, }); -// Pulsing ring on the incoming-call avatar — a soft teal halo that expands and -// fades, the «ringing right now» cue. Matches the rail's orbit accent. +// Pulsing ring on the incoming-call avatar — a soft halo that expands and fades, +// the «ringing right now» cue. Tuned to Success.Main #7dd3a8 (rgb 125,211,168) so +// the «live / answer» green is the SAME hue across every call surface (LiveDot, +// native ring glow + chevrons, answer disc). NB: this is deliberately NOT the +// brand orbit-accent teal #5be3c5 (SyncIndicator / HorseshoeContainer) — that one +// signals sync, this one signals an answerable call. const callerPulse = keyframes({ - '0%': { boxShadow: '0 0 0 0 rgba(91, 227, 197, 0.45)' }, - '70%': { boxShadow: `0 0 0 ${toRem(14)} rgba(91, 227, 197, 0)` }, - '100%': { boxShadow: '0 0 0 0 rgba(91, 227, 197, 0)' }, + '0%': { boxShadow: '0 0 0 0 rgba(125, 211, 168, 0.45)' }, + '70%': { boxShadow: `0 0 0 ${toRem(14)} rgba(125, 211, 168, 0)` }, + '100%': { boxShadow: '0 0 0 0 rgba(125, 211, 168, 0)' }, }); export const CallerPulse = style({ @@ -281,3 +285,63 @@ export const RingButtonGrow = style({ flexGrow: 1, flexBasis: 0, }); + +// Press feedback for the big Answer/Decline buttons (folds Button has none of its +// own) so they feel as tactile as the control discs (which scale on :active). +export const RingButtonPress = style({ + transition: 'transform 90ms ease', + selectors: { + '&:active:not(:disabled)': { transform: 'scale(0.97)' }, + }, + '@media': { + '(prefers-reduced-motion: reduce)': { transition: 'none' }, + }, +}); + +// === Over-lock screen entrance === +// The over-lock root is the privacy cover and must stay opaque/static from frame +// one; only its CONTENTS animate in so the screen doesn't hard-cut over the +// lockscreen. CSS-only (plays once on portal mount, never replays on the 1s timer +// re-render), reduced-motion aware. +const overlockEnter = keyframes({ + from: { opacity: 0, transform: 'translateY(10px) scale(0.985)' }, + to: { opacity: 1, transform: 'none' }, +}); + +export const OverLockReveal = style({ + animation: `${overlockEnter} 260ms cubic-bezier(0.22, 1, 0.36, 1) both`, + '@media': { + '(prefers-reduced-motion: reduce)': { animation: 'none' }, + }, +}); + +// Controls trail the identity in by a beat (staggered arrival reads as "settling"). +export const OverLockRevealDelayed = style([OverLockReveal, { animationDelay: '70ms' }]); + +// Minimize chevron: a faint resting disc so the affordance is discoverable (was a +// fully-transparent icon), plus press feedback. +export const OverLockIconButton = style({ + backgroundColor: 'rgba(255, 255, 255, 0.08)', + transition: 'background-color 140ms ease, transform 90ms ease', + selectors: { + '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.14)' }, + '&:active': { transform: 'scale(0.94)' }, + }, + '@media': { + '(prefers-reduced-motion: reduce)': { transition: 'background-color 140ms ease' }, + }, +}); + +// Screen-reader-only: announce call-phase transitions (Connecting → Calling → In +// call) without speaking the per-second ticking timer. Standard visually-hidden. +export const SrOnly = style({ + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +});