feat(a11y): cross-platform hardening + modal focus-trap from impeccable audit

Responsive / viewport:
- add .min-h-viewport / .h-viewport utilities (100dvh + Telegram --tg-viewport-stable-height)
- migrate min-h-screen / h-screen / 100vh across 18 files
- reveal hover-only controls on focus-within + touch (desktop nav, admin settings)
- grid breakpoints (TopUpAmount, Contests), larger touch targets, nav aria-labels

Platform routing (Telegram WebView correctness):
- clipboard -> copyToClipboard util with execCommand fallback (10 files)
- window.open -> openTelegramLink / openLink (Profile, Referral, ChannelSubscriptionScreen)
- window.confirm -> useNativeDialog (admin pages); PromoOffersSection -> useDestructiveConfirm
- window.prompt -> usePrompt / PromptDialogHost (TipTap link editors)
- ESLint no-restricted-properties guard against regressions (adapters/clipboard util exempt)

Modal accessibility:
- new useFocusTrap hook (focus trap, Esc, scroll lock, focus restore)
- role=dialog / aria-modal / focus-trap: Polls, SuccessNotificationModal, AdminPolicies,
  AdminPromoGroups, AdminCampaigns, BroadcastPreview (Telegram + Email)
- new global usePrompt store + PromptDialogHost
This commit is contained in:
c0mrade
2026-05-25 23:16:07 +03:00
parent 2bcba3b60f
commit 8e0b63bac8
48 changed files with 584 additions and 173 deletions

View File

@@ -65,7 +65,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
if (level === 'app') {
return (
<div className="flex min-h-screen items-center justify-center bg-dark-900 p-4">
<div className="min-h-viewport flex items-center justify-center bg-dark-900 p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>

View File

@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon } from './icons';
import { usePlatform } from '@/platform/hooks/usePlatform';
import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog';
// Helper functions
const formatTimeLeft = (
@@ -88,7 +88,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { dialog, capabilities } = usePlatform();
const confirmDeactivate = useDestructiveConfirm();
const [claimingId, setClaimingId] = useState<number | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -160,36 +160,19 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
navigate('/subscription/purchase');
};
const handleDeactivateClick = () => {
const handleDeactivateClick = async () => {
setErrorMessage(null);
setSuccessMessage(null);
if (capabilities.hasNativeDialogs) {
dialog
.popup({
title: t('promo.deactivate.confirmTitle'),
message: t('promo.deactivate.confirmDescription', {
percent: activeDiscount?.discount_percent || 0,
}),
buttons: [
{ id: 'cancel', type: 'cancel', text: '' },
{ id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') },
],
})
.then((buttonId) => {
if (buttonId === 'confirm') {
deactivateMutation.mutate();
}
});
} else {
const confirmed = window.confirm(
t('promo.deactivate.confirmDescription', {
percent: activeDiscount?.discount_percent || 0,
}),
);
if (confirmed) {
deactivateMutation.mutate();
}
const confirmed = await confirmDeactivate(
t('promo.deactivate.confirmDescription', {
percent: activeDiscount?.discount_percent || 0,
}),
t('promo.deactivate.confirm'),
t('promo.deactivate.confirmTitle'),
);
if (confirmed) {
deactivateMutation.mutate();
}
};

View File

@@ -0,0 +1,72 @@
import { useEffect, useState, type FormEvent } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { usePromptStore } from '../store/promptDialog';
/**
* Global host for usePrompt(). Mount once near the app root.
* Renders an accessible text-input dialog (focus-trapped, Esc/Enter, scroll-locked)
* as a cross-platform replacement for window.prompt, which the Telegram WebView ignores.
*/
export function PromptDialogHost() {
const request = usePromptStore((s) => s.request);
const submit = usePromptStore((s) => s.submit);
const cancel = usePromptStore((s) => s.cancel);
const { t } = useTranslation();
const [value, setValue] = useState('');
const open = request !== null;
const dialogRef = useFocusTrap<HTMLFormElement>(open, { onEscape: cancel });
useEffect(() => {
if (request) setValue(request.initialValue ?? '');
}, [request]);
if (!request) return null;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed) {
cancel();
return;
}
submit(trimmed);
};
return createPortal(
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={cancel} aria-hidden="true" />
<form
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={request.title ?? request.label}
onSubmit={handleSubmit}
className="card relative w-full max-w-sm space-y-4"
>
{request.title && <h2 className="text-lg font-semibold text-dark-50">{request.title}</h2>}
<label className="block">
<span className="label">{request.label}</span>
<input
type={request.inputType ?? 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={request.placeholder}
className="input"
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={cancel} className="btn-secondary">
{request.cancelLabel ?? t('common.cancel')}
</button>
<button type="submit" className="btn-primary">
{request.submitLabel ?? t('common.ok', 'OK')}
</button>
</div>
</form>
</div>,
document.body,
);
}

View File

@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router';
import { useSuccessNotification } from '../store/successNotification';
import { useCurrency } from '../hooks/useCurrency';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { useHaptic } from '@/platform';
// Icons
@@ -93,6 +94,9 @@ export default function SuccessNotificationModal() {
hide();
}, [hide]);
// Esc + scroll-lock are handled by the effects below; the trap only manages focus.
const modalRef = useFocusTrap<HTMLDivElement>(isOpen, { lockScroll: false });
// Escape key to close
useEffect(() => {
if (!isOpen) return;
@@ -205,6 +209,11 @@ export default function SuccessNotificationModal() {
{/* Modal */}
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="success-modal-title"
tabIndex={-1}
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
style={{
marginBottom: safeBottom ? `${safeBottom}px` : undefined,
@@ -214,6 +223,7 @@ export default function SuccessNotificationModal() {
{/* Close button */}
<button
onClick={handleClose}
aria-label={t('common.close')}
className="absolute right-3 top-3 z-10 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
>
<CloseIcon />
@@ -224,7 +234,9 @@ export default function SuccessNotificationModal() {
className={`flex flex-col items-center bg-gradient-to-br ${gradientClass} px-6 pb-8 pt-10`}
>
<div className="mb-4 animate-bounce text-white">{icon}</div>
<h2 className="text-center text-2xl font-bold text-white">{title}</h2>
<h2 id="success-modal-title" className="text-center text-2xl font-bold text-white">
{title}
</h2>
{message && <p className="mt-2 text-center text-white/80">{message}</p>}
</div>

View File

@@ -11,6 +11,7 @@ import {
} from '../../api/buttonStyles';
import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify';
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
type StyleValue = 'primary' | 'success' | 'danger' | 'default';
@@ -42,6 +43,7 @@ export function ButtonsTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const notify = useNotify();
const { confirm: confirmDialog } = useNativeDialog();
const { data: serverStyles } = useQuery({
queryKey: ['button-styles'],
@@ -341,8 +343,8 @@ export function ButtonsTab() {
{/* Reset */}
<div className="flex justify-end">
<button
onClick={() => {
if (window.confirm(t('admin.buttons.resetConfirm'))) {
onClick={async () => {
if (await confirmDialog(t('admin.buttons.resetConfirm'))) {
resetMutation.mutate();
}
}}

View File

@@ -29,6 +29,7 @@ import {
} from '../../api/menuLayout';
import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify';
import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -532,6 +533,7 @@ export function MenuEditorTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const notify = useNotify();
const { confirm: confirmDialog } = useNativeDialog();
// Fetch config
const {
@@ -868,8 +870,8 @@ export function MenuEditorTab() {
{/* Reset */}
<div className="flex justify-end">
<button
onClick={() => {
if (window.confirm(t('admin.menuEditor.resetConfirm'))) {
onClick={async () => {
if (await confirmDialog(t('admin.menuEditor.resetConfirm'))) {
resetMutation.mutate();
}
}}

View File

@@ -187,7 +187,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
>
<span className="flex-1 truncate">{currentValue || '-'}</span>
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
<span className="text-dark-500 opacity-0 transition-colors group-focus-within:text-accent-400 group-focus-within:opacity-100 group-hover:text-accent-400 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
<EditIcon />
</span>
</button>

View File

@@ -142,7 +142,7 @@ export function SettingsTableRow({
<button
onClick={onReset}
disabled={isResetting}
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-hover:opacity-100 max-lg:opacity-100"
className="flex-shrink-0 rounded-lg p-1.5 text-dark-500 opacity-0 transition-all hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50 group-focus-within:opacity-100 group-hover:opacity-100 max-lg:opacity-100 [@media(hover:none)]:opacity-100"
title={t('admin.settings.reset')}
aria-label={t('admin.settings.reset')}
>
@@ -157,7 +157,7 @@ export function SettingsTableRow({
'flex-shrink-0 rounded-lg p-1.5 transition-all',
isFavorite
? 'text-warning-400 hover:bg-warning-500/15'
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100 max-lg:opacity-100',
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-focus-within:opacity-100 group-hover:opacity-100 max-lg:opacity-100 [@media(hover:none)]:opacity-100',
)}
title={
isFavorite

View File

@@ -2,21 +2,10 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking';
import { apiClient, isChannelSubscriptionError } from '../../api/client';
import { usePlatform } from '../../platform';
const CHECK_COOLDOWN_SECONDS = 5;
function safeOpenUrl(url: string | undefined | null): void {
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
window.open(url, '_blank', 'noopener');
}
} catch {
// invalid URL, do nothing
}
}
export default function ChannelSubscriptionScreen() {
const { t } = useTranslation();
const channelInfo = useBlockingStore((state) => state.channelInfo);
@@ -25,6 +14,28 @@ export default function ChannelSubscriptionScreen() {
const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState<string | null>(null);
const isCheckingRef = useRef(false);
const { openLink, openTelegramLink } = usePlatform();
// Route channel links through the platform adapter: inside the Telegram
// WebView a raw window.open is intercepted by the client and the link
// silently fails to open. t.me links use openTelegramLink; others openLink.
const openChannel = useCallback(
(url: string | undefined | null) => {
if (!url) return;
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
if (parsed.hostname === 't.me' || parsed.hostname.endsWith('.t.me')) {
openTelegramLink(url);
} else {
openLink(url);
}
} catch {
// invalid URL, do nothing
}
},
[openLink, openTelegramLink],
);
// Cooldown timer
useEffect(() => {
@@ -100,7 +111,7 @@ export default function ChannelSubscriptionScreen() {
<span className="text-sm font-medium text-white">{ch.title || ch.channel_id}</span>
{ch.channel_link && (
<button
onClick={() => safeOpenUrl(ch.channel_link)}
onClick={() => openChannel(ch.channel_link)}
className="rounded-lg bg-blue-500/20 px-3 py-1 text-xs font-medium text-blue-400 hover:bg-blue-500/30"
>
{t('blocking.channel.openChannel')}
@@ -114,7 +125,7 @@ export default function ChannelSubscriptionScreen() {
{/* Fallback: single channel (legacy) */}
{channels.length === 0 && channelInfo?.channel_link && (
<button
onClick={() => safeOpenUrl(channelInfo.channel_link)}
onClick={() => openChannel(channelInfo.channel_link)}
className="mb-6 flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600"
>
{t('blocking.channel.openChannel')}

View File

@@ -1,6 +1,7 @@
import { useMemo, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useFocusTrap } from '../../hooks/useFocusTrap';
interface PreviewButton {
text: string;
@@ -185,6 +186,7 @@ export function TelegramPreview({
}: TelegramPreviewProps) {
const { t } = useTranslation();
const rendered = useMemo(() => tokensToReact(tokenize(text)), [text]);
const dialogRef = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
if (!open) return null;
return createPortal(
<div
@@ -192,6 +194,11 @@ export function TelegramPreview({
onClick={onClose}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
tabIndex={-1}
className="w-full max-w-md rounded-2xl bg-[#17212b] p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
@@ -199,7 +206,11 @@ export function TelegramPreview({
<h3 className="text-base font-semibold text-white">
{t('admin.broadcasts.preview', 'Предпросмотр Telegram')}
</h3>
<button onClick={onClose} className="rounded p-1 text-dark-400 hover:bg-dark-700">
<button
onClick={onClose}
aria-label={t('common.close', 'Закрыть')}
className="flex h-9 w-9 items-center justify-center rounded text-dark-400 hover:bg-dark-700"
>
</button>
</div>
@@ -247,6 +258,7 @@ export function TelegramPreview({
export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPreviewProps) {
const { t } = useTranslation();
const dialogRef = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
if (!open) return null;
const emptyHtml = `<p style="color:#999;font-family:sans-serif">${t('admin.broadcasts.previewEmpty', '— пусто —')}</p>`;
return createPortal(
@@ -255,6 +267,11 @@ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPrevi
onClick={onClose}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={subject || t('admin.broadcasts.emailSubject', 'Email')}
tabIndex={-1}
className="flex h-[80vh] w-full max-w-2xl flex-col rounded-2xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
@@ -269,7 +286,8 @@ export function EmailPreview({ open, onClose, subject, htmlContent }: EmailPrevi
</div>
<button
onClick={onClose}
className="ml-3 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
aria-label={t('common.close', 'Закрыть')}
className="ml-3 flex h-9 w-9 items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-700"
>
</button>

View File

@@ -6,7 +6,7 @@ export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
return (
<div className="flex min-h-screen items-center justify-center">
<div className="min-h-viewport flex items-center justify-center">
<div
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
/>

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { RemnawaveButtonClient, LocalizedText } from '@/types';
import { copyToClipboard } from '@/utils/clipboard';
// eslint-disable-next-line no-script-url
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
@@ -64,16 +65,7 @@ export function BlockButtons({
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async (url: string) => {
try {
await navigator.clipboard.writeText(url);
} catch {
const textarea = document.createElement('textarea');
textarea.value = url;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
await copyToClipboard(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);

View File

@@ -187,7 +187,7 @@ export default function TrafficProgressBar({
{/* Scale labels */}
{!compact && limitGb > 0 && (
<div
className="mt-1.5 flex justify-between px-0.5 font-mono text-[9px] font-medium text-dark-50/20"
className="mt-1.5 flex justify-between px-0.5 font-mono text-[10px] font-medium text-dark-50/20"
aria-hidden="true"
>
{[0, 25, 50, 75, 100].map((v) => (

View File

@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
import WebSocketNotifications from '@/components/WebSocketNotifications';
import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
import { PromptDialogHost } from '@/components/PromptDialogHost';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import TicketNotificationBell from '@/components/TicketNotificationBell';
import { SubscriptionIcon, GiftIcon } from '@/components/icons';
@@ -278,7 +279,7 @@ export function AppShell({ children }: AppShellProps) {
// headerHeight comes from useHeaderHeight() — accounts for TG safe area in fullscreen
return (
<div className="min-h-screen">
<div className="min-h-viewport">
{/* Animated background renders via portal on document.body at z-index: -1 */}
<BackgroundRenderer />
@@ -286,6 +287,7 @@ export function AppShell({ children }: AppShellProps) {
<WebSocketNotifications />
<CampaignBonusNotifier />
<SuccessNotificationModal />
<PromptDialogHost />
{/* Desktop Header */}
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
@@ -322,6 +324,7 @@ export function AppShell({ children }: AppShellProps) {
key={item.path}
to={item.path}
onClick={handleNavClick}
aria-label={item.label}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive(item.path)
@@ -330,7 +333,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{item.label}
</span>
</Link>
@@ -339,6 +342,7 @@ export function AppShell({ children }: AppShellProps) {
<Link
to="/referral"
onClick={handleNavClick}
aria-label={t('nav.referral')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/referral')
@@ -347,7 +351,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<UsersIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.referral')}
</span>
</Link>
@@ -356,6 +360,7 @@ export function AppShell({ children }: AppShellProps) {
<Link
to="/gift"
onClick={handleNavClick}
aria-label={t('nav.gift')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
isActive('/gift')
@@ -364,7 +369,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<GiftIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('nav.gift')}
</span>
</Link>
@@ -375,6 +380,7 @@ export function AppShell({ children }: AppShellProps) {
<Link
to="/admin"
onClick={handleNavClick}
aria-label={t('admin.nav.title')}
className={cn(
'group flex items-center rounded-xl px-2.5 py-2 transition-all duration-200',
location.pathname.startsWith('/admin')
@@ -383,7 +389,7 @@ export function AppShell({ children }: AppShellProps) {
)}
>
<ShieldIcon className="h-[18px] w-[18px] shrink-0" />
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
<span className="max-w-0 overflow-hidden whitespace-nowrap text-xs font-medium opacity-0 transition-all duration-200 group-focus-within:ml-2 group-focus-within:max-w-40 group-focus-within:opacity-100 group-hover:ml-2 group-hover:max-w-40 group-hover:opacity-100">
{t('admin.nav.title')}
</span>
</Link>
@@ -402,6 +408,9 @@ export function AppShell({ children }: AppShellProps) {
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-colors duration-200 hover:bg-dark-700 hover:text-accent-400',
!canToggleTheme && 'pointer-events-none invisible',
)}
aria-label={
isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'
}
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
{isDark ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}

View File

@@ -96,7 +96,7 @@ export function DesktopSidebar({
};
return (
<aside className="fixed left-0 top-0 z-40 flex h-screen w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
<aside className="h-viewport fixed left-0 top-0 z-40 flex w-60 flex-col border-r border-dark-700/30 bg-dark-950/80 backdrop-blur-linear">
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-dark-700/30 px-4">
<Link to="/" className="flex items-center gap-3" onClick={handleNavClick}>