feat: enhance CSS and components for Telegram Mini App performance

- Updated CSS to utilize viewport variables for safe area insets and stable height, improving layout consistency across devices.
- Introduced haptic feedback triggers in various components to enhance user interaction, mimicking native Telegram behavior.
- Added functionality to detect Android performance class, minimizing animations on low-performance devices for better user experience.
- Refactored components to incorporate new CSS classes for content safety and improved responsiveness.
This commit is contained in:
2026-03-04 19:19:14 +03:00
parent 13aba85e28
commit 07e22079ee
15 changed files with 129 additions and 14 deletions

View File

@@ -174,7 +174,7 @@ html::-webkit-scrollbar {
margin-right: auto; margin-right: auto;
padding: 12px; padding: 12px;
padding-top: 0; padding-top: 0;
padding-bottom: env(safe-area-inset-bottom, 12px); padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px));
border-radius: 12px; border-radius: 12px;
} }
@@ -261,9 +261,25 @@ html::-webkit-scrollbar {
} }
} }
/* Android low-performance devices: minimize animations (Telegram User-Agent). */
[data-perf="low"] *,
[data-perf="low"] *::before,
[data-perf="low"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* Safe area for Telegram Mini App (notch / status bar). */ /* Safe area for Telegram Mini App (notch / status bar). */
.pt-safe { .pt-safe {
padding-top: env(safe-area-inset-top, 0); padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
}
/* Content safe area: top/bottom only to avoid overlap with Telegram header/bottom bar (Bot API 8.0+).
Horizontal padding is left to layout classes (e.g. px-3) so indents are preserved when viewport vars are 0. */
.content-safe {
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
} }
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */ /* Sticky calendar header: shadow when scrolled (useStickyScroll). */
@@ -305,7 +321,7 @@ html::-webkit-scrollbar {
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 100vh; min-height: var(--tg-viewport-stable-height, 100vh);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;

View File

@@ -50,7 +50,7 @@ export default function Home() {
const content = accessDenied ? ( const content = accessDenied ? (
<AccessDeniedScreen primaryAction="reload" /> <AccessDeniedScreen primaryAction="reload" />
) : currentView === "currentDuty" ? ( ) : currentView === "currentDuty" ? (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe"> <div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<CurrentDutyView <CurrentDutyView
onBack={handleBackFromCurrentDuty} onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"} openedFromPin={startParam === "duty"}
@@ -62,9 +62,9 @@ export default function Home() {
return ( return (
<div <div
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
style={{ style={{
visibility: appContentReady ? "visible" : "hidden", visibility: appContentReady ? "visible" : "hidden",
minHeight: "100vh",
}} }}
> >
{content} {content}

View File

@@ -17,6 +17,7 @@ import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList"; import { DutyList } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail"; import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { ErrorState } from "@/components/states/ErrorState"; import { ErrorState } from "@/components/states/ErrorState";
import { triggerHapticLight } from "@/lib/telegram-haptic";
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */ /** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
const STICKY_HEIGHT_FALLBACK_PX = 268; const STICKY_HEIGHT_FALLBACK_PX = 268;
@@ -90,10 +91,12 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
const navDisabled = loading || accessDenied || selectedDay !== null; const navDisabled = loading || accessDenied || selectedDay !== null;
const handlePrevMonth = useCallback(() => { const handlePrevMonth = useCallback(() => {
if (navDisabled) return; if (navDisabled) return;
triggerHapticLight();
prevMonth(); prevMonth();
}, [navDisabled, prevMonth]); }, [navDisabled, prevMonth]);
const handleNextMonth = useCallback(() => { const handleNextMonth = useCallback(() => {
if (navDisabled) return; if (navDisabled) return;
triggerHapticLight();
nextMonth(); nextMonth();
}, [navDisabled, nextMonth]); }, [navDisabled, nextMonth]);
@@ -133,7 +136,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
}, [loading, accessDenied, setAppContentReady]); }, [loading, accessDenied, setAppContentReady]);
return ( return (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe"> <div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<div <div
ref={calendarStickyRef} ref={calendarStickyRef}
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2" className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"

View File

@@ -9,6 +9,7 @@ import React, { useMemo } from "react";
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { dateKeyToDDMM } from "@/lib/date-utils"; import { dateKeyToDDMM } from "@/lib/date-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import type { DutyWithUser } from "@/types"; import type { DutyWithUser } from "@/types";
import { DayIndicators } from "./DayIndicators"; import { DayIndicators } from "./DayIndicators";
@@ -89,6 +90,7 @@ function CalendarDayInner({
)} )}
onClick={(e) => { onClick={(e) => {
if (isOtherMonth) return; if (isOtherMonth) return;
triggerHapticLight();
onDayClick(dateKey, e.currentTarget.getBoundingClientRect()); onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
}} }}
> >

View File

@@ -20,6 +20,7 @@ import {
formatHHMM, formatHHMM,
} from "@/lib/date-utils"; } from "@/lib/date-utils";
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty"; import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { ContactLinks } from "@/components/contact/ContactLinks"; import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -145,10 +146,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
}, [onBack]); }, [onBack]);
const handleBack = () => { const handleBack = () => {
triggerHapticLight();
onBack(); onBack();
}; };
const handleClose = () => { const handleClose = () => {
triggerHapticLight();
if (closeMiniApp.isAvailable()) { if (closeMiniApp.isAvailable()) {
closeMiniApp(); closeMiniApp();
} else { } else {
@@ -213,6 +216,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
if (state === "error") { if (state === "error") {
const handleRetry = () => { const handleRetry = () => {
triggerHapticLight();
setState("loading"); setState("loading");
loadTodayDuties(); loadTodayDuties();
}; };

View File

@@ -27,6 +27,7 @@ import { DayDetailContent } from "./DayDetailContent";
import type { CalendarEvent, DutyWithUser } from "@/types"; import type { CalendarEvent, DutyWithUser } from "@/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { triggerHapticLight } from "@/lib/telegram-haptic";
/** Empty state for day detail: date and "no duties or events" message. */ /** Empty state for day detail: date and "no duties or events" message. */
function DayDetailEmpty({ dateKey }: { dateKey: string }) { function DayDetailEmpty({ dateKey }: { dateKey: string }) {
@@ -120,6 +121,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */ /** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
const requestClose = React.useCallback(() => { const requestClose = React.useCallback(() => {
triggerHapticLight();
setExiting(true); setExiting(true);
}, []); }, []);

View File

@@ -13,6 +13,7 @@ import {
formatHHMM, formatHHMM,
} from "@/lib/date-utils"; } from "@/lib/date-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { ContactLinks } from "@/components/contact/ContactLinks"; import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { DutyWithUser } from "@/types"; import type { DutyWithUser } from "@/types";
@@ -77,6 +78,7 @@ export function DutyTimelineCard({
const flipped = isControlled ? (isFlipped ?? false) : localFlipped; const flipped = isControlled ? (isFlipped ?? false) : localFlipped;
const handleFlipToBack = () => { const handleFlipToBack = () => {
triggerHapticLight();
if (isControlled) { if (isControlled) {
onFlipChange?.(true); onFlipChange?.(true);
} else { } else {
@@ -86,6 +88,7 @@ export function DutyTimelineCard({
}; };
const handleFlipToFront = () => { const handleFlipToFront = () => {
triggerHapticLight();
if (isControlled) { if (isControlled) {
onFlipChange?.(false); onFlipChange?.(false);
} else { } else {

View File

@@ -6,15 +6,20 @@ import {
mountMiniAppSync, mountMiniAppSync,
mountThemeParamsSync, mountThemeParamsSync,
bindThemeParamsCssVars, bindThemeParamsCssVars,
mountViewport,
bindViewportCssVars,
unmountViewport,
} from "@telegram-apps/sdk-react"; } from "@telegram-apps/sdk-react";
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme"; import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
/** /**
* Wraps the app with Telegram Mini App SDK initialization. * Wraps the app with Telegram Mini App SDK initialization.
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars), * Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
* and mounts the mini app. Does not call ready() here — the app calls * mounts the mini app, then mounts viewport and binds viewport CSS vars
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen * (--tg-viewport-stable-height, --tg-viewport-content-safe-area-inset-*, etc.).
* has finished loading, so Telegram keeps its native loading animation until then. * Does not call ready() here — the app calls callMiniAppReadyOnce() from
* lib/telegram-ready when the first visible screen has finished loading.
* Theme is set before first paint by the inline script in layout.tsx (URL hash); * Theme is set before first paint by the inline script in layout.tsx (URL hash);
* useTelegramTheme() in the app handles ongoing theme changes. * useTelegramTheme() in the app handles ongoing theme changes.
*/ */
@@ -39,7 +44,26 @@ export function TelegramProvider({
mountMiniAppSync(); mountMiniAppSync();
} }
return cleanup; applyAndroidPerformanceClass();
let unbindViewportCssVars: (() => void) | undefined;
if (mountViewport.isAvailable()) {
mountViewport()
.then(() => {
if (bindViewportCssVars.isAvailable()) {
unbindViewportCssVars = bindViewportCssVars();
}
})
.catch(() => {
// Viewport not supported (e.g. not in Mini App); ignore.
});
}
return () => {
unbindViewportCssVars?.();
unmountViewport();
cleanup();
};
}, []); }, []);
return <>{children}</>; return <>{children}</>;

View File

@@ -8,6 +8,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { getLang, translate } from "@/i18n/messages"; import { getLang, translate } from "@/i18n/messages";
import { useAppStore } from "@/store/app-store"; import { useAppStore } from "@/store/app-store";
import { triggerHapticLight } from "@/lib/telegram-haptic";
export interface AccessDeniedScreenProps { export interface AccessDeniedScreenProps {
/** Optional detail from API 403 response, shown below the hint. */ /** Optional detail from API 403 response, shown below the hint. */
@@ -40,6 +41,7 @@ export function AccessDeniedScreen({
const hasDetail = Boolean(serverDetail && String(serverDetail).trim()); const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
const handleClick = () => { const handleClick = () => {
triggerHapticLight();
if (primaryAction === "reload") { if (primaryAction === "reload") {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.location.reload(); window.location.reload();

View File

@@ -8,6 +8,7 @@
import { useTranslation } from "@/i18n/use-translation"; import { useTranslation } from "@/i18n/use-translation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { triggerHapticLight } from "@/lib/telegram-haptic";
export interface ErrorStateProps { export interface ErrorStateProps {
/** Error message to display. If not provided, uses generic i18n message. */ /** Error message to display. If not provided, uses generic i18n message. */
@@ -65,7 +66,10 @@ export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
variant="default" variant="default"
size="sm" size="sm"
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2" className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
onClick={onRetry} onClick={() => {
triggerHapticLight();
onRetry();
}}
> >
{t("error.retry")} {t("error.retry")}
</Button> </Button>

View File

@@ -16,6 +16,7 @@ vi.mock("@telegram-apps/sdk-react", () => ({
isThemeParamsDark: vi.fn(), isThemeParamsDark: vi.fn(),
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) }, setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) }, setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
setMiniAppBottomBarColor: { isAvailable: vi.fn(() => false) },
})); }));
describe("getFallbackScheme", () => { describe("getFallbackScheme", () => {

View File

@@ -6,6 +6,7 @@ import {
isThemeParamsDark, isThemeParamsDark,
setMiniAppBackgroundColor, setMiniAppBackgroundColor,
setMiniAppHeaderColor, setMiniAppHeaderColor,
setMiniAppBottomBarColor,
} from "@telegram-apps/sdk-react"; } from "@telegram-apps/sdk-react";
/** /**
@@ -69,6 +70,9 @@ export function applyTheme(scheme?: "dark" | "light"): void {
if (setMiniAppHeaderColor.isAvailable()) { if (setMiniAppHeaderColor.isAvailable()) {
setMiniAppHeaderColor("bg_color"); setMiniAppHeaderColor("bg_color");
} }
if (setMiniAppBottomBarColor.isAvailable()) {
setMiniAppBottomBarColor("bottom_bar_bg_color");
}
} }
/** /**

View File

@@ -0,0 +1,26 @@
/**
* Detects Android Telegram Mini App performance class from User-Agent and sets
* data-perf on document.documentElement so CSS can reduce animations on low-end devices.
* @see https://core.telegram.org/bots/webapps#additional-data-in-user-agent
*/
import { retrieveAndroidDeviceData } from "@telegram-apps/sdk-react";
const DATA_ATTR = "data-perf";
/**
* Runs once: if running in Telegram on Android with LOW performance class,
* sets data-perf="low" on the document root for CSS to minimize animations.
*/
export function applyAndroidPerformanceClass(): void {
if (typeof document === "undefined") return;
try {
const data = retrieveAndroidDeviceData();
const perf = data?.performanceClass;
if (perf === "LOW") {
document.documentElement.setAttribute(DATA_ATTR, "low");
}
} catch {
// Not in Telegram or not Android; ignore.
}
}

View File

@@ -0,0 +1,19 @@
/**
* Triggers Telegram Mini App haptic feedback when available.
* Use on primary actions and key interactions to mimic native Telegram behavior.
*/
import { hapticFeedbackImpactOccurred } from "@telegram-apps/sdk-react";
/**
* Triggers light impact haptic feedback. No-op when not in Telegram or unsupported.
*/
export function triggerHapticLight(): void {
try {
if (hapticFeedbackImpactOccurred.isAvailable()) {
hapticFeedbackImpactOccurred("light");
}
} catch {
// SDK not available; ignore.
}
}

View File

@@ -1,15 +1,17 @@
/** /**
* Single-call wrapper for Telegram Mini App ready(). * Single-call wrapper for Telegram Mini App ready() and expand().
* Called once when the first visible screen has finished loading so Telegram * Called once when the first visible screen has finished loading so Telegram
* hides its native loading animation only after our content is ready. * hides its native loading animation only after our content is ready.
* Also expands the Mini App to full height when supported.
*/ */
import { miniAppReady } from "@telegram-apps/sdk-react"; import { miniAppReady, expandViewport } from "@telegram-apps/sdk-react";
let readyCalled = false; let readyCalled = false;
/** /**
* Calls Telegram miniAppReady() at most once per session. * Calls Telegram miniAppReady() at most once per session, then expandViewport()
* when available so the app opens to full height.
* Safe when SDK is unavailable (e.g. non-Telegram environment). * Safe when SDK is unavailable (e.g. non-Telegram environment).
*/ */
export function callMiniAppReadyOnce(): void { export function callMiniAppReadyOnce(): void {
@@ -19,6 +21,9 @@ export function callMiniAppReadyOnce(): void {
miniAppReady(); miniAppReady();
readyCalled = true; readyCalled = true;
} }
if (expandViewport.isAvailable()) {
expandViewport();
}
} catch { } catch {
// SDK not available or not in Mini App context; no-op. // SDK not available or not in Mini App context; no-op.
} }