feat(bots): unify command-card chrome with left-side semantic icons and fold WA Meta-ToS warning into the AboutCard modal

This commit is contained in:
v.lagerev 2026-05-05 19:59:24 +03:00
parent 4559a89a31
commit 19f484308e
8 changed files with 375 additions and 244 deletions

View file

@ -46,6 +46,55 @@ const RefreshIcon = () => (
</svg>
);
// Inline SVG info icon — leads the AboutCard. Shared shape with the
// Telegram widget. WhatsApp uses a triangle warning glyph instead
// because its About modal also carries a Meta-ToS risk disclosure.
const InfoIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" />
<path d="M10 9.2 L10 14" stroke-linecap="round" />
<circle cx="10" cy="6.4" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Three QR finder squares + a few module dots — leads the QR-login
// card. Same shape across all three bot widgets.
const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Stays muted
// inside `.command-card.danger` so the rose accent remains a single
// accent on the title.
const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Two chain links joined by a horizontal bar — leads the Discord-
// only Reconnect card. Visually distinct from RefreshIcon's circular
// arrows so the user can tell «re-establish bridge link» from
// «re-fetch status» at a glance.
const LinkIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M8 13 H5.5 a3 3 0 0 1 0 -6 H8" stroke-linecap="round" />
<path d="M12 7 H14.5 a3 3 0 0 1 0 6 H12" stroke-linecap="round" />
<line x1="7" y1="10" x2="13" y2="10" stroke-linecap="round" />
</svg>
);
// Linkifier — same heuristic as TG widget.
const URL_RE = /https?:\/\/[^\s)]+/g;
@ -281,6 +330,9 @@ type AboutCardProps = {
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<InfoIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div>
@ -405,6 +457,9 @@ const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
return (
<button class="command-card danger" type="button" onClick={() => setConfirming(true)}>
<span class="command-card-lead-icon" aria-hidden="true">
<LogoutIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.logout.name')}</div>
<div class="command-card-desc">{t('card.logout.desc')}</div>
@ -849,6 +904,9 @@ export function App({ bootstrap, api }: Props) {
) : null}
<div class="command-grid">
<button class="command-card" type="button" onClick={onClickLoginQr}>
<span class="command-card-lead-icon" aria-hidden="true">
<QrIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.login-qr.name')}</div>
<div class="command-card-desc">{t('card.login-qr.desc')}</div>
@ -863,6 +921,9 @@ export function App({ bootstrap, api }: Props) {
onClick={onClickRefresh}
disabled={refreshing}
>
<span class="command-card-lead-icon" aria-hidden="true">
<RefreshIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc">
@ -870,7 +931,7 @@ export function App({ bootstrap, api }: Props) {
</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
<RefreshIcon />
</span>
</button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
@ -946,6 +1007,9 @@ export function App({ bootstrap, api }: Props) {
* the same command-card chrome so it visually matches Login /
* Logout cards. */}
<button class="command-card" type="button" onClick={onClickReconnect}>
<span class="command-card-lead-icon" aria-hidden="true">
<LinkIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.reconnect.name')}</div>
<div class="command-card-desc">{t('card.reconnect.desc')}</div>

View file

@ -263,7 +263,29 @@ body {
display: block;
}
.command-card.refreshing .command-card-chevron svg {
/* Generic leading-icon slot every command-card carries a semantic
* left-side glyph (mirror of the right-side chevron). Picks up
* `currentColor` from the parent and stays muted by default;
* `.danger` deliberately does NOT colour the lead icon so the rose
* accent stays reserved for the title. */
.command-card-lead-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
/* Spin the leading refresh icon while the card is in its `refreshing`
* in-flight state. The selector targets the lead slot since the
* refresh card moved its glyph from the chevron (right) to the lead
* slot (left) for parity with every other card. */
.command-card.refreshing .command-card-lead-icon svg {
animation: command-card-spin 0.8s linear infinite;
}
@keyframes command-card-spin {

View file

@ -67,6 +67,55 @@ const RefreshIcon = () => (
</svg>
);
// Inline SVG info icon — leads the AboutCard. Shared shape with the
// Discord widget so «How the bot works» reads the same across the
// catalog. WhatsApp uses a triangle warning glyph instead because its
// About modal also carries a Meta-ToS risk disclosure.
const InfoIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" />
<path d="M10 9.2 L10 14" stroke-linecap="round" />
<circle cx="10" cy="6.4" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Smartphone outline — leads the «Войти в Telegram» card. Same shape
// as the WhatsApp pairing-code card; both flows ask for a phone-side
// number, so the icon language stays consistent.
const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
// Three QR finder squares + a sprinkle of module dots — leads every
// QR-login card. The finder pattern is the strongest «this is QR»
// visual cue; no need to draw a full code.
const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Stays muted
// inside `.command-card.danger` so the rose accent is reserved for
// the title only (one accent per card).
const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Linkifier: matches plain http/https URLs in transcript bodies. Bot replies
// regularly carry matrix.to URLs (success line includes one), and our M11
// scaffold previously rendered them as inert text.
@ -671,6 +720,9 @@ type AboutCardProps = {
// next to the button itself, not in a separate header chrome.
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<InfoIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div>
@ -809,6 +861,9 @@ const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => {
disabled={!loginId}
title={!loginId ? t('card.logout.gated') : undefined}
>
<span class="command-card-lead-icon" aria-hidden="true">
<LogoutIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.logout.name')}</div>
<div class="command-card-desc">{t('card.logout.desc')}</div>
@ -1351,6 +1406,9 @@ export function App({ bootstrap, api }: Props) {
</span>
<div class="command-grid">
<button class="command-card" type="button" onClick={onClickLogin}>
<span class="command-card-lead-icon" aria-hidden="true">
<PhoneIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.login.name')}</div>
<div class="command-card-desc">{t('card.login.desc')}</div>
@ -1364,6 +1422,9 @@ export function App({ bootstrap, api }: Props) {
* surface it as a primary action rather than burying it in a
* sub-menu. Same card vocabulary as login-by-phone. */}
<button class="command-card" type="button" onClick={onClickLoginQr}>
<span class="command-card-lead-icon" aria-hidden="true">
<QrIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.login-qr.name')}</div>
<div class="command-card-desc">{t('card.login-qr.desc')}</div>
@ -1376,13 +1437,18 @@ export function App({ bootstrap, api }: Props) {
* `refreshing` class + disabled attribute drive the in-flight
* spinner state disabled gates :hover/:focus via :not(:disabled)
* and so neutralises any WebView quirks that previously made the
* click read as «button stuck on grey» after tap. */}
* click read as «button stuck on grey» after tap. The spinner
* targets the leading icon slot (the chevron is back to ``
* for parity with every other card). */}
<button
class={`command-card${refreshing ? ' refreshing' : ''}`}
type="button"
onClick={onClickRefresh}
disabled={refreshing}
>
<span class="command-card-lead-icon" aria-hidden="true">
<RefreshIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc">
@ -1390,7 +1456,7 @@ export function App({ bootstrap, api }: Props) {
</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
<RefreshIcon />
</span>
</button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />

View file

@ -319,11 +319,33 @@ body {
display: block;
}
/* Spin the chevron icon while the card is in its `refreshing` in-flight
* state. Combined with `disabled` (which dims the card to opacity 0.5 and
* gates :hover via :not(:disabled)), the spinner is the unambiguous «I'm
* working» signal no more guessing whether the click registered. */
.command-card.refreshing .command-card-chevron svg {
/* Generic leading-icon slot every command-card carries a semantic
* left-side glyph (mirror of the right-side chevron). Picks up
* `currentColor` from the parent and stays muted by default; the
* `.danger` modifier on logout deliberately does NOT colour the lead
* icon so the rose accent stays reserved for the title (one accent
* per card). */
.command-card-lead-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
/* Spin the leading refresh icon while the card is in its `refreshing`
* in-flight state. Combined with `disabled` (which dims the card to
* opacity 0.5 and gates :hover via :not(:disabled)), the spinner is
* the unambiguous «I'm working» signal no more guessing whether the
* click registered. The selector targets the lead slot since the
* refresh card moved its glyph from the chevron (right) to the lead
* slot (left) for parity with every other card. */
.command-card.refreshing .command-card-lead-icon svg {
animation: command-card-spin 0.8s linear infinite;
}
@keyframes command-card-spin {

View file

@ -55,6 +55,59 @@ const RefreshIcon = () => (
</svg>
);
// Smartphone outline — leads the «Войти по номеру» card. Shared shape
// across TG (text-login) and WhatsApp (pairing-code login) since both
// flows boil down to «вы вводите номер с телефона».
const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
// Three QR finder squares + a few module dots — leads every QR-login
// card. The finder pattern is the strongest «this is QR» visual cue;
// no need to draw a full code.
const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Picks up the
// rose tint via `.command-card.danger` cascade only on the title;
// the lead icon stays muted so the rose stays a single accent.
const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Triangle warning glyph — leads the WhatsApp-only AboutCard (which
// carries `command-card warn` for the amber outline) and re-appears
// inside the AboutModal's risk-disclosure callout. Stroke-only so it
// picks up the amber tint via `currentColor` in either context.
const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Linkifier — same heuristic as TG / Discord widgets.
const URL_RE = /https?:\/\/[^\s)]+/g;
@ -583,8 +636,17 @@ type AboutCardProps = {
onOpen: () => void;
};
// WhatsApp-only: AboutCard carries the `warn` modifier so the amber
// border + amber-tinted name signal that the modal behind it includes
// the Meta-ToS risk disclosure. The leading icon is the triangle
// WarningIcon (not the info-circle used by TG / Discord) for the
// same reason — the hybrid card description («о работе и рисках»)
// stays believable when the icon previews the «risks» half.
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}>
<button class="command-card warn" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<WarningIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div>
@ -632,11 +694,29 @@ const AboutModal = ({ t, onClose }: AboutModalProps) => {
</button>
</header>
<div class="about-body">
{/* WhatsApp-specific callout: surfaces the Meta-ToS ban risk
* for users who open the About modal without clicking the
* dedicated warning card. Shorter than the warning modal
* just a heads-up + pointer to the full text. */}
<p class="about-warn-callout">{t('warning.about-callout')}</p>
{/* WhatsApp-specific Meta-ToS account-ban risk disclosure.
* Lives at the top of the About modal as an amber-tinted
* block same content the dedicated WarningCard used to
* carry on the disconnected screen, folded in here so the
* About card is the single info entry point (matches the
* TG / Discord shape; the amber block carries the «risks»
* half of the hybrid card description). */}
<aside class="about-warn-callout" aria-label={t('warning.title')}>
<header class="about-warn-callout-head">
<span class="about-warn-callout-icon" aria-hidden="true">
<WarningIcon />
</span>
<h3 class="about-warn-callout-title">{t('warning.title')}</h3>
</header>
<p>{t('warning.body-1')}</p>
<p>{t('warning.body-2')}</p>
<p>
{t('warning.tos-label')}{' '}
<a href={t('warning.tos-url')} target="_blank" rel="noreferrer noopener">
{t('warning.tos-url')}
</a>
</p>
</aside>
<p>{t('about.body-1')}</p>
<p>{t('about.body-2')}</p>
<p>{t('about.body-3')}</p>
@ -658,121 +738,6 @@ const AboutModal = ({ t, onClose }: AboutModalProps) => {
);
};
// --------------------------------------------------------------------------
// Warning card + modal (WhatsApp-specific Meta-ToS risk disclosure)
// --------------------------------------------------------------------------
//
// WhatsApp is the only bot in the Vojo catalog that carries a real
// account-loss risk for the user. Meta's WhatsApp ToS forbids
// connecting an account through unofficial clients (mautrix-whatsapp
// uses the same multi-device API as WhatsApp Web — technically
// standard, but Meta may treat it as a violation). Enforcement is
// unpredictable. Users who don't understand this risk before clicking
// «Войти» can lose their primary messenger.
//
// Two surfaces:
// 1. A dedicated card on the disconnected screen above the login
// cards. Amber tone, ⚠ glyph in the title — visible even if the
// user skips the About modal.
// 2. A short callout at the top of the About modal (handled in
// AboutModal above) so users who open About also see it.
// Inline triangle warning glyph. Stroke-only, picks up `currentColor`
// from the parent so it tints with the amber accent on the card.
const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
type WarningCardProps = {
t: T;
onOpen: () => void;
};
const WarningCard = ({ t, onOpen }: WarningCardProps) => (
<button class="command-card warn" type="button" onClick={onOpen}>
<span class="command-card-warn-icon" aria-hidden="true">
<WarningIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.warning.name')}</div>
<div class="command-card-desc">{t('card.warning.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
type WarningModalProps = {
t: T;
onClose: () => void;
};
const WarningModal = ({ t, onClose }: WarningModalProps) => {
// Same Escape + backdrop-click pattern as AboutModal — no focus-trap
// library because the surface is small. The visible header glyph
// makes the modal's purpose unambiguous.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div
class="about-overlay"
role="dialog"
aria-modal="true"
aria-label={t('warning.title')}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div class="about-panel about-panel-warn">
<header class="about-header about-header-warn">
<span class="about-header-icon" aria-hidden="true">
<WarningIcon />
</span>
<h2 class="about-title">{t('warning.title')}</h2>
<button
type="button"
class="about-close-x"
onClick={onClose}
aria-label={t('warning.aria-close')}
>
×
</button>
</header>
<div class="about-body">
<p>{t('warning.body-1')}</p>
<p>{t('warning.body-2')}</p>
<p>
{t('warning.tos-label')}{' '}
<a href={t('warning.tos-url')} target="_blank" rel="noreferrer noopener">
{t('warning.tos-url')}
</a>
</p>
</div>
<div class="about-footer">
<button type="button" class="btn-primary" onClick={onClose}>
{t('warning.close')}
</button>
</div>
</div>
</div>
);
};
// --------------------------------------------------------------------------
// Logout card with confirm-in-place
// --------------------------------------------------------------------------
@ -833,6 +798,9 @@ const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => {
disabled={!loginId}
title={!loginId ? t('card.logout.gated') : undefined}
>
<span class="command-card-lead-icon" aria-hidden="true">
<LogoutIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.logout.name')}</div>
<div class="command-card-desc">{t('card.logout.desc')}</div>
@ -853,7 +821,6 @@ export function App({ bootstrap, api }: Props) {
const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
const [handshakeOk, setHandshakeOk] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [warningOpen, setWarningOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const seenEventIds = useRef(new Set<string>());
const [state, dispatch] = useReducer(loginReducer, initialLoginState);
@ -1313,17 +1280,18 @@ export function App({ bootstrap, api }: Props) {
{t('status.disconnected')}
</span>
<div class="command-grid">
{/* Warning card sits FIRST. Sized like the other cards
* (no full-width spanning) so the disconnected screen
* reads as a single uniform grid; the amber tint +
* icon carry the visual emphasis instead. */}
<WarningCard t={t} onOpen={() => setWarningOpen(true)} />
{/* Login order mirrors the Telegram widget: phone-flow
* («Войти по номеру») first, QR second. Both are valid
* primary paths; phone-flow is the more familiar entry
* point for users coming from Telegram or used to
* SMS-style codes, so it leads. */}
* SMS-style codes, so it leads. The Meta-ToS risk
* disclosure now lives inside the AboutCard modal as
* an amber callout the hybrid card description tells
* the user it's there. */}
<button class="command-card" type="button" onClick={onClickLoginPairing}>
<span class="command-card-lead-icon" aria-hidden="true">
<PhoneIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.login-pairing.name')}</div>
<div class="command-card-desc">{t('card.login-pairing.desc')}</div>
@ -1333,6 +1301,9 @@ export function App({ bootstrap, api }: Props) {
</span>
</button>
<button class="command-card" type="button" onClick={onClickLoginQr}>
<span class="command-card-lead-icon" aria-hidden="true">
<QrIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.login-qr.name')}</div>
<div class="command-card-desc">{t('card.login-qr.desc')}</div>
@ -1347,6 +1318,9 @@ export function App({ bootstrap, api }: Props) {
onClick={onClickRefresh}
disabled={refreshing}
>
<span class="command-card-lead-icon" aria-hidden="true">
<RefreshIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc">
@ -1354,7 +1328,7 @@ export function App({ bootstrap, api }: Props) {
</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
<RefreshIcon />
</span>
</button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
@ -1461,7 +1435,6 @@ export function App({ bootstrap, api }: Props) {
) : null}
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}
{warningOpen ? <WarningModal t={t} onClose={() => setWarningOpen(false)} /> : null}
<section class="section">
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite">

View file

@ -20,21 +20,15 @@ export const EN: Record<StringKey, string> = {
'card.refresh.name': 'Refresh status',
'card.refresh.desc': 'Re-check whether WhatsApp is linked',
'card.refresh.in-flight': 'Checking…',
'card.warning.name': 'Read before linking',
'card.warning.desc': 'Important information about risks — tap to open',
'warning.title': 'Read before linking WhatsApp',
'warning.title': 'Important before linking WhatsApp',
'warning.body-1':
'Mautrix-whatsapp connects to your account through the same linked-device mechanism as WhatsApp Web. Technically a standard API — but unlike other messengers, WhatsApps terms of service explicitly forbid connecting through third-party clients, and Meta may ban your account for it.',
'warning.body-2':
'WhatsApp bans are regular and unpredictable — Meta does not publish criteria. For some users the bridge works for years without issue; for others the account is banned within hours of linking.',
'warning.tos-label': 'WhatsApp terms of service:',
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
'warning.close': 'Got it',
'warning.aria-close': 'Close warning',
'warning.about-callout':
'⚠ Before linking WhatsApp, read the “Read before linking” card on the bots main screen.',
'card.about.name': 'How the WhatsApp bot works',
'card.about.desc': 'Sign-in, safety, and source code',
'card.about.desc': 'How it works and the risks — tap to read',
'about.title': 'About the WhatsApp bot',
'about.body-1':
'This bot connects WhatsApp to Vojo. After sign-in, your private chats and groups from WhatsApp will appear in Vojos chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.',

View file

@ -35,40 +35,27 @@ export const RU = {
'card.refresh.name': 'Обновить статус',
'card.refresh.desc': 'Перепроверить, привязан ли WhatsApp',
'card.refresh.in-flight': 'Проверяю…',
// --- Warning card (WhatsApp-specific) ---------------------------------
// Карточка-предупреждение ставится ТОЛЬКО для WhatsApp в каталоге
// Vojo, потому что у WhatsApp ToS прямо запрещает подключение
// через сторонние клиенты и Meta это активно энфорсит. Сравнения с
// Telegram/Discord В ИНТЕРФЕЙСЕ намеренно НЕТ: Telegram user ToS
// (telegram.org/tos) такого ограничения вообще не упоминает, а
// Discord — отдельный кейс. Делать сравнение в копии = вводить
// юзера в заблуждение, поэтому warning-модалка говорит ТОЛЬКО про
// WhatsApp/Meta и ссылается ТОЛЬКО на Meta ToS.
// --- About panel -------------------------------------------------------
// WhatsApp-only Meta-ToS risk disclosure is folded into the About
// modal as an amber callout at the top of the body. The AboutCard
// itself carries `command-card warn` (amber border + amber name)
// and a triangle warning glyph in the lead slot — instead of the
// info-circle TG / Discord use — so the «риски» half of the hybrid
// description («о работе и рисках») is visible at a glance before
// the user opens the modal. TG / Discord get the plain «вход,
// безопасность, исходный код» variant because they don't carry an
// account-loss risk in the same way (Telegram user ToS doesn't
// forbid third-party clients; Discord's restriction on self-bots
// lives in developer policies, not user ToS proper). The amber
// block keeps the unique WhatsApp framing without claiming anything
// about TG / Discord by comparison.
//
// ToS reference: https://www.whatsapp.com/legal/terms-of-service
// секция «Harm To WhatsApp Or Our Users» запрещает «software or
// APIs that function substantially the same as our Services» и
// ToS reference for the body: https://www.whatsapp.com/legal/terms-of-service
// section «Harm To WhatsApp Or Our Users» forbids «software or
// APIs that function substantially the same as our Services» and
// «accounts for our Services through unauthorized or automated
// means».
// Triangle glyph lives in the icon slot to the LEFT — don't repeat
// it in the title text or it doubles up («⚠ ⚠ Прочтите...»).
'card.warning.name': 'Прочтите перед подключением',
// Two-line desc: hint + explicit «click here to read» so the user
// doesn't stare at the card wondering if it's interactive (the
// amber tone helps signal it's special, but doesn't tell you it's
// a button).
'card.warning.desc': 'Важная информация о рисках — нажмите, чтобы открыть',
'warning.title': 'Важно знать до подключения WhatsApp',
// Два информационных параграфа: что технически делает мост и
// почему это под риском, и насколько риск реален. Сравнение
// с другими мессенджерами оставлено НЕЯВНЫМ («в отличие от других
// мессенджеров») — без явного перечисления TG/Discord, потому что
// у Telegram user ToS (telegram.org/tos) запрета на сторонние
// клиенты нет вообще, у Discord ToS тоже нет (запрет self-bot'ов
// живёт у них в developer policies, не в ToS proper). Прямой
// формальный запрет в ToS есть ТОЛЬКО у WhatsApp; общая фраза
// «в отличие от других мессенджеров» подчёркивает уникальность
// WhatsApp без неточностей в адрес конкретных сервисов.
'warning.body-1':
'Mautrix-whatsapp подключает ваш аккаунт через тот же механизм связанных устройств, что и WhatsApp Web. Технически это стандартный API — но в отличие от других мессенджеров, условия использования WhatsApp прямо запрещают подключение через сторонние клиенты, и Meta может заблокировать аккаунт за это.',
'warning.body-2':
@ -79,17 +66,12 @@ export const RU = {
// src/app/features/bots/BotWidgetEmbed.ts).
'warning.tos-label': 'Условия использования WhatsApp:',
'warning.tos-url': 'https://www.whatsapp.com/legal/terms-of-service',
'warning.close': 'Понятно',
'warning.aria-close': 'Закрыть предупреждение',
// Короткий callout в About-модале — пойнтер на отдельную карточку
// с предупреждением. Объяснение «почему» живёт в самой модалке
// warning'а; здесь — только указание куда смотреть, чтобы About
// не дублировал warning по сути.
'warning.about-callout':
'⚠ Перед подключением WhatsApp прочтите карточку «Прочтите перед подключением» на главном экране бота.',
// --- About panel -------------------------------------------------------
'card.about.name': 'Как работает WhatsApp-бот',
'card.about.desc': 'Вход, безопасность и исходный код',
// Hybrid copy: tells the user the modal carries BOTH the «как
// работает» explainer AND the Meta-ToS risk disclosure. «нажмите,
// чтобы прочесть» reinforces interactivity — the amber border +
// warning triangle help but the explicit verb seals it.
'card.about.desc': 'Информация о работе и рисках — нажмите, чтобы прочесть',
'about.title': 'О боте WhatsApp',
'about.body-1':
'Этот бот подключает WhatsApp к Vojo. После входа личные чаты и группы из WhatsApp появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в WhatsApp.',

View file

@ -319,11 +319,14 @@ body {
display: block;
}
/* Spin the chevron icon while the card is in its `refreshing` in-flight
* state. Combined with `disabled` (which dims the card to opacity 0.5 and
* gates :hover via :not(:disabled)), the spinner is the unambiguous «I'm
* working» signal no more guessing whether the click registered. */
.command-card.refreshing .command-card-chevron svg {
/* Spin the leading refresh icon while the card is in its `refreshing`
* in-flight state. Combined with `disabled` (which dims the card to
* opacity 0.5 and gates :hover via :not(:disabled)), the spinner is
* the unambiguous «I'm working» signal no more guessing whether the
* click registered. The selector targets the leading icon slot since
* the refresh card moved its glyph from the chevron (right) to the
* lead slot (left) for parity with every other card. */
.command-card.refreshing .command-card-lead-icon svg {
animation: command-card-spin 0.8s linear infinite;
}
@keyframes command-card-spin {
@ -424,45 +427,45 @@ body {
border-color: var(--rose);
}
/* Warning card WhatsApp-specific Meta-ToS account-ban risk disclosure.
* Sized like every other card in the grid (auto-fill column on
* desktop, full-width on mobile) visually distinguished from peers
* by amber tint + border, NOT by spanning the full row. The earlier
* full-width version was visually overweight on a wide desktop grid.
* Amber tint on every layer of chrome (border, name, background)
* without going as loud as `.danger` this isn't destructive, it's
* «read this before you act». */
/* Generic leading-icon slot every command-card carries one as a
* left-side semantic glyph (mirror of the right-side chevron). The
* SVG picks up `currentColor` so it tints with the card's modifier
* muted by default, amber inside `.command-card.warn`, rose-ish
* inheritance left intentionally OFF for `.danger` (only the title
* goes rose; the lead icon stays muted to keep one accent per card). */
.command-card-lead-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
/* Warn variant WhatsApp-only, applied to the AboutCard so the amber
* border, amber-tinted name, and amber lead icon together signal that
* the modal behind this card carries a Meta-ToS risk disclosure (in
* addition to the «how it works» copy). The hybrid card description
* («о работе и рисках») depends on this visual cue to feel honest. */
.command-card.warn {
/* Slight amber tint on the surface itself so the eye can't slide
* past the card on a quick scan; the border underlines it. */
background: rgba(212, 184, 138, 0.06);
border-color: var(--amber);
}
.command-card.warn .command-card-name {
color: var(--amber);
}
.command-card.warn .command-card-lead-icon {
color: var(--amber);
}
:root[data-input='mouse'] .command-card.warn:hover:not(:disabled) {
background: rgba(212, 184, 138, 0.12);
border-color: var(--amber);
}
/* Icon slot to the LEFT of the title block distinct from the chevron
* which is on the right. The triangle SVG picks up `currentColor` from
* the parent's amber tint so it stays in palette without a separate
* fill rule. */
.command-card-warn-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--amber);
}
.command-card-warn-icon svg {
width: 22px;
height: 22px;
display: block;
}
/* Inline confirm-in-place body for the destructive logout card. The button
* group lives inside the same card frame no modal, no layout shift. */
.command-card-confirm {
@ -1061,43 +1064,48 @@ body {
border-top: 1px solid var(--divider);
}
/* WhatsApp-only: Meta-ToS callout that prepends the About body. Same
* tone as `.section-warn-banner` amber tint + border, distinct
* shape from the body paragraphs around it so it doesn't blend in.
/* WhatsApp-only: Meta-ToS risk-disclosure callout at the top of the
* About modal body. Amber tint + border distinguish it from the plain
* paragraphs that follow same visual language the dedicated warning
* card used to carry on the disconnected screen, now folded inline.
*
* Selector specificity note: `.about-body p { color: var(--text) }`
* (specificity 0,1,1) wins over a bare `.about-warn-callout` rule
* (0,1,0), so the callout's amber `color` would silently lose. Pin
* the callout selector inside `.about-body p.about-warn-callout`
* (0,2,1) to outscore the descendant rule. Background and border
* weren't affected because no competing rule sets them on `.about-body p`. */
.about-body p.about-warn-callout {
background: rgba(212, 184, 138, 0.10);
* Body text inside the callout still picks up the about-body's
* `.about-body p` rule (default text colour), which is intentional:
* only the title is amber-tinted, the paragraphs stay readable. */
.about-warn-callout {
background: rgba(212, 184, 138, 0.08);
border: 1px solid var(--amber);
border-radius: 8px;
padding: 10px 12px;
color: var(--amber);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
/* Keep the about-body's `gap: 12px` honoured (no extra margin). */
}
/* Warning modal same chrome as About modal but with an amber-tinted
* header to make the dialog's purpose visible at a glance. The icon
* sits to the left of the title; the close-X stays on the right. */
.about-panel-warn {
border-color: var(--amber);
.about-warn-callout-head {
display: flex;
align-items: center;
gap: 10px;
}
.about-header-warn {
background: rgba(212, 184, 138, 0.08);
}
.about-header-icon {
.about-warn-callout-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--amber);
}
.about-header-icon svg {
width: 22px;
height: 22px;
.about-warn-callout-icon svg {
width: 20px;
height: 20px;
display: block;
}
.about-warn-callout-title {
font-size: 15px;
font-weight: 600;
color: var(--amber);
margin: 0;
line-height: 1.3;
}