feat(calls): polish call surfaces — unify Answer verb, single live-green, over-lock entrance, press feedback, aria-live phases
This commit is contained in:
parent
aa83b05a31
commit
492e950245
7 changed files with 110 additions and 17 deletions
|
|
@ -533,6 +533,7 @@
|
|||
"incoming": "Incoming call…",
|
||||
"incoming_label": "Incoming call",
|
||||
"accept": "Accept",
|
||||
"answer": "Answer",
|
||||
"decline": "Decline",
|
||||
"unknown_caller": "Unknown caller",
|
||||
"ctl_mic": "Mic",
|
||||
|
|
|
|||
|
|
@ -537,6 +537,7 @@
|
|||
"incoming": "Входящий звонок…",
|
||||
"incoming_label": "Входящий звонок",
|
||||
"accept": "Принять",
|
||||
"answer": "Ответить",
|
||||
"decline": "Отклонить",
|
||||
"unknown_caller": "Неизвестный абонент",
|
||||
"ctl_mic": "Микрофон",
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue