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:
parent
4559a89a31
commit
19f484308e
8 changed files with 375 additions and 244 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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, WhatsApp’s 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 bot’s 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 Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal WhatsApp messages.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue