vojo/apps/widget-telegram/src/styles.css

933 lines
24 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Dawn palette — must stay in sync with
* docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
* (DAWN const, lines 4-23). The widget renders inside the Vojo chat slot
* which is itself a Dawn surface; the iframe inherits the same visual
* canon to feel like a continuation of the host. */
:root {
--bg: #181a20;
--bg2: #0d0e11;
--surface: #21232b;
--surface2: #2a2d36;
--divider: rgba(255, 255, 255, 0.06);
--hairline: rgba(255, 255, 255, 0.08);
--text: #e6e6e9;
--muted: rgba(230, 230, 233, 0.55);
--faint: rgba(230, 230, 233, 0.32);
--fleet: #9580ff;
--fleet-soft: #a59cff;
--green: #7dd3a8;
--amber: #d4b88a;
--rose: #c08e7b;
--section-pad-x: 40px;
}
[data-theme='light'] {
/* Light theme is intentionally a thin remap. Vojo is dark-default; the
* theme param exists so we don't fight an explicit user/host setting,
* not because we expect daily light-mode use. */
--bg: #f5f5f7;
--bg2: #ffffff;
--surface: #f0f0f2;
--surface2: #e8e8ec;
--divider: rgba(0, 0, 0, 0.08);
--hairline: rgba(0, 0, 0, 0.1);
--text: #1a1a1d;
--muted: rgba(26, 26, 29, 0.62);
--faint: rgba(26, 26, 29, 0.4);
}
@media (max-width: 600px) {
:root {
--section-pad-x: 20px;
}
}
* {
box-sizing: border-box;
/* Kills the translucent grey overlay iOS/Android WebViews paint on top
* of any tapped element. On the wide refresh card this overlay was
* read as «button stuck on grey» — the underlying state was correct,
* the WebView's tap-highlight was not. Web browsers ignore this. */
-webkit-tap-highlight-color: transparent;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.app {
display: flex;
flex-direction: column;
min-height: 100%;
max-width: 960px;
margin: 0 auto;
}
/* The hero (avatar + name + handle + description + three-dots menu) is
* OWNED BY THE HOST, not the widget — see src/app/features/bots/BotShell.tsx.
* Removing the widget-side hero collapses the duplicate header that used to
* sit between the host's BotShellHero (which the user actually sees) and
* the iframe content. The widget body now starts with the active-state
* section directly. */
/* ── Section ──────────────────────────────────────────────────────── */
.section {
padding: 24px var(--section-pad-x) 20px;
}
.section + .section {
padding-top: 4px;
}
/* Section label — same dark-bg pill vocabulary as `.section-status` so the
* two pieces in the section-header row read as a matched pair (label
* pill + status pill). The pill chrome wraps the existing uppercase
* letter-spaced typography; chip is non-interactive, no cursor. */
.section-label {
display: inline-flex;
align-items: center;
font-size: 13px;
line-height: 20px;
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
color: var(--muted);
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 8px;
padding: 8px 14px;
margin: 0 0 14px;
white-space: nowrap;
user-select: none;
}
/* Status pill — button-styled but intentionally non-interactive (no
* cursor:pointer, no hover). Replaces the section header for stateful
* sections (disconnected / connected / unknown / logging_out) — the
* pill itself carries the section's identity, so a separate
* `.section-label` would just duplicate the meaning. Same dark-bg
* vocabulary (--bg2 / divider border) as `.recovery-action` and the
* host hero's «О боте» chip. */
.section-status {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
line-height: 20px;
color: var(--muted);
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 8px;
padding: 8px 14px;
margin: 0 0 14px;
user-select: none;
white-space: nowrap;
}
.section-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--faint);
flex-shrink: 0;
}
.section-status.connected {
color: var(--green);
}
.section-status.connected .dot {
background: var(--green);
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
}
.section-status.disconnected {
color: var(--rose);
}
.section-status.disconnected .dot {
background: var(--rose);
}
.section-status.checking {
color: var(--amber);
}
.section-status.checking .dot {
background: var(--amber);
}
/* Wraps the section-status pill + a labeled refresh action when the
* state has no other affordance (unknown / logging_out / connected
* without loginId). Without this row, the user can stare at a
* «Проверка статуса…» pill forever if the first list-logins reply
* dropped on the wire. */
.section-recovery-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.section-recovery-row > .section-status {
margin-bottom: 0;
}
.recovery-action {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 8px;
padding: 8px 14px;
font: inherit;
font-size: 13px;
line-height: 20px;
color: var(--muted);
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
:root[data-input='mouse'] .recovery-action:hover:not(:disabled) {
background: var(--surface);
color: var(--text);
border-color: var(--hairline);
}
.recovery-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.recovery-action svg {
width: 16px;
height: 16px;
}
/* ── Command card (action card with name + desc + chevron) ──────── */
.command-card {
/* The widget runs in an iframe, so it does NOT inherit the host's
* `button { -webkit-appearance: button }` rule (src/index.css:112). The
* browser default for <button> on WebKit is `auto`, which on iOS/Android
* Capacitor WebView resolves to native button rendering — the WebView
* draws its own focus/active overlay ON TOP of our explicit background.
* That overlay was the «button greys out and doesn't snap back» bug:
* after tap, the WebView holds the native focus paint until focus moves
* elsewhere. Setting appearance:none strips the native paint and makes
* our CSS the sole source of truth, matching what the host does for
* inputs (src/index.css:122-124). On the OLD 70px icon-only refresh
* chip the native overlay had nowhere to render visibly; on a wide
* command-card it was very visible. Web browsers ignore appearance for
* <button> already, so this only matters on native WebViews. */
-webkit-appearance: none;
appearance: none;
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
transition: border-color 0.12s, background 0.12s;
}
/* Hover scoped to mouse-mode sessions only. Capacitor Android WebView
* reports `(hover: hover)` as TRUE on a pure-touch device (verified
* via on-device console.log), so a media-query gate doesn't work — the
* rule would apply, then the WebView would synthesise `:hover` on the
* focused element after tap and leave it stuck until the user tapped
* elsewhere (visible symptom: card greys after tap, only un-greys on
* tapping a different button). `[data-input]` is set in main.tsx from
* the actual `pointerdown.pointerType`, which the WebView reports
* truthfully even when its media queries lie. */
:root[data-input='mouse'] .command-card:hover:not(:disabled) {
background: var(--surface);
border-color: var(--hairline);
}
.command-card:focus {
outline: none;
}
/* Keyboard focus ring — same data-input gate. On touch sessions there's
* no keyboard navigation to support and the ring would also stick (focus
* stays on the tapped button until something else takes it). */
:root[data-input='mouse'] .command-card:focus-visible {
outline: 2px solid var(--fleet);
outline-offset: 2px;
}
.command-card:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.command-card-body {
flex: 1;
min-width: 0;
}
.command-card-name {
/* Sans-serif + on-surface text color — the previous monospace + fleet-soft
* styling read like a `/login` CLI label. With «Войти в Telegram» as the
* actual name (no slash, no command-line mimicry), the row should look
* like a primary action title, not a code token. */
font-size: 15px;
color: var(--text);
font-weight: 600;
margin-bottom: 3px;
}
.command-card-desc {
font-size: 14px;
color: var(--muted);
line-height: 19px;
}
.command-card-chevron {
color: var(--muted);
font-size: 18px;
flex-shrink: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* When the chevron slot carries an icon (e.g. refresh card uses
* `<RefreshIcon />` instead of ``), size the SVG explicitly — the icon
* has no intrinsic width and would expand to 300×150 (SVG default) inside
* a flex-shrink:0 container. 18px keeps it visually equivalent to the
* `` glyph used by the other cards. */
.command-card-chevron svg {
width: 18px;
height: 18px;
display: block;
}
/* 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 {
to {
transform: rotate(360deg);
}
}
/* ── Transcript ──────────────────────────────────────────────────── */
.transcript {
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 12px 14px;
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
font-size: 12.5px;
line-height: 1.55;
max-height: 360px;
overflow-y: auto;
/* Custom scrollbar styled into the dark palette. Native browser
* scrollbars (gray, system-themed) clash with the Dawn surface. */
scrollbar-width: thin;
scrollbar-color: var(--surface2) transparent;
}
.transcript::-webkit-scrollbar {
width: 8px;
}
.transcript::-webkit-scrollbar-track {
background: transparent;
}
.transcript::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 4px;
border: 2px solid var(--bg2);
background-clip: padding-box;
}
.transcript::-webkit-scrollbar-thumb:hover {
background: var(--surface);
border: 2px solid var(--bg2);
background-clip: padding-box;
}
.transcript-line {
padding: 4px 0;
display: flex;
gap: 10px;
align-items: flex-start;
white-space: pre-wrap;
word-break: break-word;
}
.transcript-line + .transcript-line {
border-top: 1px dashed var(--divider);
}
.transcript-line .ts {
color: var(--faint);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.transcript-line .body {
flex: 1;
min-width: 0;
}
.transcript-line.from-bot .body {
color: var(--text);
}
.transcript-line.from-user .body {
color: var(--fleet-soft);
}
.transcript-line.diag .body {
color: var(--muted);
}
.transcript-line.error .body {
color: var(--rose);
}
.transcript-empty {
color: var(--faint);
text-align: center;
padding: 16px 0;
font-style: italic;
}
/* Destructive card — keeps the red name to mark «Выйти из Telegram» as a
* destructive action, distinguishing it from the primary login card.
* Hover border stays on the generic input-gated rule (hairline) so the
* accent is consistent across touch and mouse modes. A previous
* `.command-card.danger:hover { border-color: var(--rose) }` override
* was dead in mouse mode (lower specificity than the input-gated rule)
* and only fired in the pre-first-pointerdown touch stub. */
.command-card.danger .command-card-name {
color: var(--rose);
}
/* 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 {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
}
.command-card-confirm-prompt {
font-size: 14px;
color: var(--text);
flex: 1;
min-width: 0;
}
.command-card-confirm-yes,
.command-card-confirm-no,
.btn-primary,
.btn-text,
.btn-icon {
font: inherit;
cursor: pointer;
}
.command-card-confirm-yes {
background: var(--rose);
color: #0c0c0e;
border: none;
border-radius: 7px;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
}
.command-card-confirm-no {
background: transparent;
color: var(--muted);
border: 1px solid var(--divider);
border-radius: 7px;
padding: 7px 14px;
font-size: 13px;
}
.command-card-confirm-yes:disabled,
.command-card-confirm-no:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.command-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: 10px;
}
/* ── Auth card (login forms inside transcript section) ───────────── */
.auth-card {
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
}
.auth-card.error {
border-color: var(--rose);
}
.auth-card-title {
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
}
.auth-card-hint {
font-size: 14px;
color: var(--muted);
line-height: 19px;
}
.auth-card-row {
display: flex;
align-items: stretch;
gap: 10px;
flex-wrap: wrap;
}
.auth-input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--hairline);
border-radius: 8px;
padding: 11px 14px;
color: var(--text);
font: inherit;
font-size: 15px;
outline: none;
transition: border-color 0.12s, box-shadow 0.12s;
}
.auth-input:hover:not(:focus):not(:disabled) {
border-color: rgba(255, 255, 255, 0.16);
}
.auth-input:focus {
border-color: var(--fleet);
/* Stronger ring than border-color alone — matches Dawn's emphasis on
* accent halos (BotsDesktop avatar shadow / hero-status.ok glow). */
box-shadow: 0 0 0 3px rgba(149, 128, 255, 0.18);
}
.auth-card.error .auth-input {
border-color: var(--rose);
}
.auth-card.error .auth-input:focus {
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
}
.auth-input.code,
.auth-input.password {
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
letter-spacing: 4px;
font-size: 20px;
}
.password-row {
display: flex;
align-items: stretch;
gap: 6px;
flex: 1;
min-width: 0;
}
.btn-icon {
background: transparent;
border: 1px solid var(--divider);
border-radius: 8px;
color: var(--muted);
padding: 0 12px;
font-size: 13px;
flex-shrink: 0;
}
.btn-icon:hover {
color: var(--text);
border-color: var(--hairline);
}
.btn-primary {
background: var(--fleet);
color: #0c0c0e;
border: none;
border-radius: 8px;
padding: 10px 18px;
font-size: 13px;
font-weight: 600;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-text {
background: transparent;
border: none;
color: var(--muted);
padding: 10px 12px;
font-size: 13px;
}
.btn-text:hover:not(:disabled) {
color: var(--text);
}
.auth-card-error {
font-size: 13px;
line-height: 18px;
color: var(--rose);
}
.auth-card-warn {
font-size: 13px;
line-height: 18px;
color: var(--amber);
}
.auth-card-waiting {
font-size: 13px;
color: var(--faint);
line-height: 18px;
}
/* Countdown text on the code form: same baseline tone as waiting hint
* but a touch more prominent because it carries an actual number. The
* color tween softens the muted→amber transition at expiry — without it
* the line jumps between palettes mid-sentence, which reads broken
* against Dawn's measured aesthetic. */
.auth-card-countdown {
font-size: 13px;
color: var(--muted);
line-height: 18px;
font-variant-numeric: tabular-nums;
transition: color 0.2s ease-out;
}
.auth-card-countdown.expired {
color: var(--amber);
}
/* ── QR-login panel ─────────────────────────────────────────────── */
/* Override the auth-card row layout — QR panel stacks vertically with the
* matrix as the visual anchor. Keeps the same outer chrome (border, radius,
* padding) so it reads as a sibling to the phone/code/password forms. */
.auth-card-qr {
align-items: stretch;
}
/* The QR matrix sits on a hard #fff plate regardless of theme — phone
* camera scanners need maximum contrast, and the bridge's PNG fallback
* also bakes in a white background. The frame is centered, fixed-size,
* with a soft inner padding so the quiet zone (already 4 modules in the
* SVG itself) is reinforced visually for low-contrast displays. */
.auth-card-qr-frame {
align-self: center;
background: #fff;
border-radius: 12px;
padding: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
/* Lock the inner box to the SVG's rendered size so the placeholder
* variant doesn't collapse to zero height while the matrix is being
* computed (`buildQrModules` is synchronous but the first React commit
* after `start_qr_login` flips state with tgUrl='', and we want the
* placeholder to occupy the same footprint). */
min-width: 260px;
min-height: 260px;
/* Drop a subtle outer shadow so the white plate visually separates from
* the surrounding dark surface — without this the corners look
* paste-on-paper. */
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.32);
}
/* Placeholder while we wait for the bridge's first qr_displayed event.
* Same visual vocabulary as `.section-status.checking`: amber dot + muted
* text — but inverted onto the white plate so the colors work. */
.auth-card-qr-placeholder {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(26, 26, 29, 0.62);
font-size: 13px;
line-height: 20px;
padding: 96px 16px;
}
.auth-card-qr-placeholder .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--amber);
flex-shrink: 0;
}
/* Step list under the QR — explicit phone-side instructions matter more
* here than for SMS, because Telegram's «Link Device» menu isn't a place
* users hit often (vs the typing-an-SMS-code muscle memory). */
.auth-card-qr-steps {
margin: 0;
padding-left: 1.4em;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
line-height: 19px;
color: var(--muted);
}
.auth-card-qr-steps li::marker {
color: var(--faint);
}
@media (max-width: 600px) {
.auth-card-row {
flex-direction: column;
}
.btn-primary,
.btn-text {
width: 100%;
}
/* Compact .command-card on mobile — preserves the «two-row title +
* chevron» structure but trims padding so a single login/logout card
* doesn't dominate a phone-height viewport. */
.command-card {
padding: 12px 14px;
border-radius: 8px;
}
.command-card-name {
font-size: 14px;
margin-bottom: 2px;
}
.command-card-desc {
font-size: 13px;
line-height: 17px;
}
.command-grid {
grid-template-columns: minmax(0, 1fr);
}
/* Mobile QR plate — keep edge-to-edge readable. The 232px SVG matches
* desktop, but the surrounding plate gets a smaller min-size to fit
* narrower viewports without horizontal scroll. */
.auth-card-qr-frame {
min-width: 232px;
min-height: 232px;
padding: 10px;
}
.auth-card-qr-placeholder {
padding: 80px 12px;
}
}
/* ── Linkified transcript bodies ─────────────────────────────────── */
.transcript-line a {
color: var(--fleet-soft);
text-decoration: underline;
}
.transcript-line a:hover {
color: var(--text);
}
/* ── Hint text ───────────────────────────────────────────────────── */
.hint {
font-size: 12px;
color: var(--faint);
margin-top: 8px;
line-height: 17px;
}
/* ── Diagnostic banner (pre-bootstrap failure) ───────────────────── */
.error-banner {
margin: var(--section-pad-x);
padding: 14px 16px;
background: rgba(192, 142, 123, 0.08);
border: 1px solid var(--rose);
border-radius: 10px;
color: var(--rose);
font-size: 13px;
line-height: 19px;
}
.error-banner strong {
display: block;
margin-bottom: 4px;
color: var(--rose);
font-weight: 600;
}
.error-banner code {
background: var(--bg2);
padding: 1px 6px;
border-radius: 4px;
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text);
}
/* ── About modal ─────────────────────────────────────────────────── */
/* Lightweight modal — fixed inside the widget iframe, not crossing into
* the host. Backdrop click + Escape close; no focus-trap library (the
* widget is a small surface — a heavier mechanism would be overkill). */
.about-overlay {
position: fixed;
inset: 0;
background: rgba(13, 14, 17, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
/* Animate in so the panel doesn't feel like a hard pop — matches the
* reassuring tone of the body copy itself. */
animation: about-fade 0.15s ease-out;
}
@keyframes about-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.about-panel {
background: var(--bg);
border: 1px solid var(--hairline);
border-radius: 14px;
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.about-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--divider);
}
.about-title {
flex: 1;
font-size: 17px;
font-weight: 600;
color: var(--text);
margin: 0;
line-height: 1.3;
}
.about-close-x {
background: transparent;
border: none;
color: var(--muted);
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font: inherit;
font-size: 24px;
line-height: 1;
transition: background 0.12s, color 0.12s;
}
.about-close-x:hover {
background: var(--surface);
color: var(--text);
}
.about-body {
padding: 16px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.about-body p {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text);
}
.about-body a {
color: var(--fleet-soft);
text-decoration: underline;
overflow-wrap: anywhere;
}
.about-body a:hover {
color: var(--text);
}
.about-footer {
padding: 12px 18px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--divider);
}