feat(calls): polish call surfaces — unify Answer verb, single live-green, over-lock entrance, press feedback, aria-live phases

This commit is contained in:
heaven 2026-06-24 02:08:20 +03:00
parent aa83b05a31
commit 492e950245
7 changed files with 110 additions and 17 deletions

View file

@ -533,6 +533,7 @@
"incoming": "Incoming call…",
"incoming_label": "Incoming call",
"accept": "Accept",
"answer": "Answer",
"decline": "Decline",
"unknown_caller": "Unknown caller",
"ctl_mic": "Mic",

View file

@ -537,6 +537,7 @@
"incoming": "Входящий звонок…",
"incoming_label": "Входящий звонок",
"accept": "Принять",
"answer": "Ответить",
"decline": "Отклонить",
"unknown_caller": "Неизвестный абонент",
"ctl_mic": "Микрофон",

View file

@ -57,7 +57,10 @@ export function CallActionButton({
aria-hidden
>
{busy ? (
<Spinner size="200" variant="Secondary" />
// 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.
<Spinner size="200" variant={tone === 'danger' ? 'Critical' : 'Secondary'} fill="Solid" />
) : (
<Icon size={compact ? '300' : '400'} src={icon} />
)}

View file

@ -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) {
</Text>
<span className={css.StateLine}>
<span className={classNames(css.LiveDot, callJoined && css.LiveDotPulsing)} aria-hidden />
<Text as="span" size="T200" priority="300" truncate className={css.Timer}>
<Text as="span" size="T200" priority="300" truncate className={css.Timer} aria-hidden>
{subText}
</Text>
<span className={css.SrOnly} role="status" aria-live="polite">
{phaseLabel}
</span>
</span>
</Box>
);

View file

@ -109,7 +109,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
const buttons = (
<div className={classNames(css.RingButtons, native && css.RingButtonsFull)}>
<Button
className={native ? css.RingButtonGrow : undefined}
className={classNames(css.RingButtonPress, native && css.RingButtonGrow)}
variant="Critical"
fill="Soft"
size={native ? '500' : '400'}
@ -122,7 +122,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
</Text>
</Button>
<Button
className={native ? css.RingButtonGrow : undefined}
className={classNames(css.RingButtonPress, native && css.RingButtonGrow)}
variant="Success"
fill="Solid"
size={native ? '500' : '400'}
@ -131,7 +131,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
before={<Icon size="200" src={CallPhoneIcon} filled />}
>
<Text size={native ? 'B500' : 'B400'} truncate>
{t('Call.accept')}
{t('Call.answer')}
</Text>
</Button>
</div>

View file

@ -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() {
)}
</div>
{/* Identity (vertically centred). */}
{/* Identity (vertically centred). Animates in (root stays opaque cover). */}
<div
className={css.OverLockReveal}
style={{
flex: 1,
display: 'flex',
@ -196,7 +202,7 @@ export function OverLockCallScreen() {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 52,
fontSize: toRem(52),
fontWeight: 700,
userSelect: 'none',
}}
@ -206,7 +212,7 @@ export function OverLockCallScreen() {
<div
style={{
color: DAWN.text,
fontSize: 27,
fontSize: toRem(28),
fontWeight: 700,
textAlign: 'center',
maxWidth: '80vw',
@ -222,17 +228,23 @@ export function OverLockCallScreen() {
className={classNames(css.LiveDot, callEmbed && joined && css.LiveDotPulsing)}
aria-hidden
/>
{/* Visible (ticking) text hidden from AT; the phase word is announced once
per transition via the polite live region below. */}
<span
className={css.Timer}
style={{ color: DAWN.textMuted, fontSize: 16, fontWeight: 500 }}
style={{ color: DAWN.textMuted, fontSize: toRem(16), fontWeight: 500 }}
aria-hidden
>
{status}
</span>
<span className={css.SrOnly} role="status" aria-live="polite">
{phaseLabel}
</span>
</span>
</div>
{/* Controls. */}
<div style={{ paddingInline: 16 }}>
<div className={css.OverLockRevealDelayed} style={{ paddingInline: 16 }}>
{callEmbed ? (
<CallControl callEmbed={callEmbed} compact={false} callJoined={joined} native />
) : (

View file

@ -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,
});