import React, { useState, useEffect, useRef } from 'react'; import { flushSync } from 'react-dom'; import { useNavigate } from 'react-router'; import { Info, AlertTriangle, AlertOctagon, X, ChevronLeft, ChevronRight, Coffee } from 'lucide-react'; import * as LucideIcons from 'lucide-react'; import remarkGfm from 'remark-gfm'; import rehypeSanitize from 'rehype-sanitize'; import { useSystemNoticeStore } from '../../store/systemNoticeStore.js'; import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js'; import { useTranslation, isRtlLanguage } from '../../i18n/index.js'; import { runNoticeAction } from './noticeActions.js'; import { ReleaseNoticeModal } from './ReleaseNoticeModal.js'; import { lockBodyScroll } from '../../utils/bodyScrollLock.js'; const ReactMarkdown = React.lazy(() => import('react-markdown').then(m => ({ default: m.default })) ); /** Safe rAF shim — falls back to setTimeout(0) in environments without rAF (e.g. jsdom). */ function scheduleFrame(cb: () => void): () => void { if (typeof requestAnimationFrame !== 'undefined') { const id = requestAnimationFrame(cb); return () => cancelAnimationFrame(id); } const id = setTimeout(cb, 0); return () => clearTimeout(id); } const SEVERITY_ICONS: Record = { info: Info, warn: AlertTriangle, critical: AlertOctagon, }; const SEVERITY_ACCENT: Record = { info: 'text-blue-500 dark:text-blue-400 bg-blue-50 dark:bg-blue-950', warn: 'text-amber-500 dark:text-amber-400 bg-amber-50 dark:bg-amber-950', critical: 'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-950', }; // Real brand marks (simple-icons single-path logos) for the support buttons, so the // Buy Me a Coffee / Ko-fi buttons carry their actual logo instead of a generic // lucide glyph. Tinted via currentColor. const BRAND_ICON_PATHS: Record = { buymeacoffee: 'M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z', kofi: 'M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298', }; function brandForHref(href?: string): string | null { if (!href) return null; if (href.includes('buymeacoffee')) return 'buymeacoffee'; if (href.includes('ko-fi.com') || href.includes('kofi')) return 'kofi'; return null; } function BrandIcon({ brand, size = 18, className }: { brand: string; size?: number; className?: string }) { const d = BRAND_ICON_PATHS[brand]; if (!d) return null; return ( ); } interface Props { notices: SystemNoticeDTO[]; } // Inner content shared between desktop and mobile layouts interface ContentProps { notice: SystemNoticeDTO; title: string; body: string; ctaLabel: string | null; secondaryCtaLabel: string | null; titleId: string; bodyId: string; isDark: boolean; onDismissAll: () => void; onCTA: () => void; onSecondaryCTA: () => void; // Pager total: number; currentPage: number; canPage: boolean; onPrev: () => void; onNext: () => void; onGoto: (i: number) => void; } function NoticeContent({ notice, title, body, ctaLabel, secondaryCtaLabel, titleId, bodyId, isDark, onDismissAll, onCTA, onSecondaryCTA, total, currentPage, canPage, onPrev, onNext, onGoto }: ContentProps) { const { t } = useTranslation(); const isLastPage = total <= 1 || currentPage === total - 1; const DefaultIcon = SEVERITY_ICONS[notice.severity] ?? Info; const LucideIcon: React.ElementType = notice.icon ? ((LucideIcons as Record)[notice.icon] as React.ElementType) ?? DefaultIcon : DefaultIcon; // Real brand logo for each support button, detected from the link target. const primaryBrand = notice.cta?.kind === 'link' ? brandForHref(notice.cta.href) : null; const secondaryBrand = notice.secondaryCta?.kind === 'link' ? brandForHref(notice.secondaryCta.href) : null; return (
{/* Dismiss X button — only on last page so users read all notices */} {notice.dismissible && isLastPage && ( )} {/* Scrollable content — vertically centered when shorter than available space */}
{/* Hero image (not inline) */} {notice.media && notice.media.placement !== 'inline' && (
{t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} />
)} {/* Special warm header for Heart icon (thank-you notice) */} {notice.icon === 'Heart' && !notice.media && (

{title}

)}
{/* Severity icon (when no hero and not Heart) */} {!notice.media && notice.icon !== 'Heart' && (
)} {/* Title (not for Heart — rendered in gradient header) */} {(notice.icon !== 'Heart' || notice.media) && (

{title}

)} {/* Body — markdown */}
{body}

}> ( {children} ), p: ({ children }) => { // Signature line styling (e.g. "— Maurice") const text = typeof children === 'string' ? children : Array.isArray(children) ? children.find(c => typeof c === 'string') : ''; if (typeof text === 'string' && text.trim().startsWith('—') && text.trim().length < 30) { return

{children}

; } return

{children}

; }, hr: () => (
♡
), strong: ({ children }) => {children}, ul: ({ children }) =>
    {children}
, ol: ({ children }) =>
    {children}
, }} > {body}
{/* Inline image */} {notice.media?.placement === 'inline' && (
{t(notice.media.altKey)} { (e.target as HTMLImageElement).style.display = 'none'; }} />
)} {/* Highlights — compact pills */} {notice.highlights && notice.highlights.length > 0 && (
{notice.highlights.map((h, i) => { const HIcon: React.ElementType | null = h.iconName ? ((LucideIcons as Record)[h.iconName] as React.ElementType) ?? null : null; return ( {HIcon ? : ✓ } {t(h.labelKey)} ); })}
)}
{/* Sticky footer — pager + CTA, always anchored at the bottom of the slot */}
{/* Pager — dots, arrows, counter (only when multiple notices) */} {total > 1 && (
{Array.from({ length: total }, (_, i) => (
{t('system_notice.pager.counter') .replace('{current}', String(currentPage + 1)) .replace('{total}', String(total))}
)} {/* CTA(s) + dismiss link */}
{ctaLabel && isLastPage ? (
{secondaryCtaLabel && ( )}
) : (notice.dismissible || isLastPage) && ( )}
); } /** * Drives the system-notice modal: pager index + visibility, mobile/dark/reduced- * motion detection, body-scroll lock, keyboard (ESC + arrows) and the page-slide * animation refs. Exposes dismiss/CTA/pager handlers + the touch-drag refs used * by the mobile bottom sheet. The two layout components below render from it. */ function useSystemNoticeModal(notices: SystemNoticeDTO[]) { const [idx, setIdx] = useState(0); const [visible, setVisible] = useState(false); const [pageAnnouncement, setPageAnnouncement] = useState(''); const navigate = useNavigate(); const { dismiss } = useSystemNoticeStore(); const { t, language } = useTranslation(); const [isMobile, setIsMobile] = useState( () => typeof window !== 'undefined' && (window.matchMedia?.('(max-width: 639px)')?.matches ?? false) ); const [isDark, setIsDark] = useState( () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark') ); const prefersReducedMotion = typeof window !== 'undefined' && (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false); const notice = notices[idx] ?? null; // Non-dismissible notices lock the pager so users must act before advancing. const canPage = notice?.dismissible !== false; const touchStartX = useRef(null); const touchStartY = useRef(null); // 'h' once we classify the gesture as horizontal, 'v' for vertical, null = unclassified const dragLockRef = useRef<'h' | 'v' | null>(null); // Sheet scroll offset at the moment the touch began — used to suppress dismiss-drag // when the user is scrolled into content and pans down to scroll back up. const scrollTopAtTouchStart = useRef(0); // Page-slide animation refs. // isPageNavRef: set to true just before a user-initiated page change so the // grace-delay effect knows to run a slide instead of hide+show. // slideDirRef: 'right' = new content enters from the right (Next), 'left' = from the left (Prev). // contentWrapperRef: the div wrapping NoticeContent — we animate its transform directly. const isPageNavRef = useRef(false); const slideDirRef = useRef<'left' | 'right'>('right'); // Mobile drag strip — wraps all 3 slots and is translated to reveal prev/current/next const stripRef = useRef(null); // The sheet element itself — animated on vertical drag-to-dismiss const sheetRef = useRef(null); const clipRef = useRef(null); // Individual slot scroll containers (prev / center / next) const prevSlotRef = useRef(null); const contentWrapperRef = useRef(null); // center slot const nextSlotRef = useRef(null); // Mobile breakpoint useEffect(() => { const mq = window.matchMedia?.('(max-width: 639px)'); if (!mq) return; const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); // Dark mode observer useEffect(() => { const obs = new MutationObserver(() => { setIsDark(document.documentElement.classList.contains('dark')); }); obs.observe(document.documentElement, { attributeFilter: ['class'] }); return () => obs.disconnect(); }, []); // Clamp idx when notices array shrinks (e.g. after dismiss of the last page) useEffect(() => { if (notices.length > 0 && idx >= notices.length) { setIdx(notices.length - 1); } }, [notices.length, idx]); // Fires on every notice-id change. Branches on whether this is a user-initiated // page navigation (slide the content wrapper) or a modal appear/dismiss-advance // (grace-delay the whole modal). useEffect(() => { if (!notice) return; // ── Page navigation: slide new content in, keep modal visible ──────────── if (isPageNavRef.current) { isPageNavRef.current = false; const el = contentWrapperRef.current; if (el && !prefersReducedMotion) { // The handler already set el.style.transform to the start position // synchronously before setIdx was called. Trigger the transition here. requestAnimationFrame(() => { el.style.transition = 'transform 260ms ease-out'; el.style.transform = 'translateX(0)'; const onEnd = () => { el.style.transition = ''; el.style.transform = ''; el.removeEventListener('transitionend', onEnd); }; el.addEventListener('transitionend', onEnd); }); } return; } // ── Modal appearing / dismiss-advance: grace delay ──────────────────────── setVisible(false); let cancelled = false; let timerId: ReturnType | undefined; const cancel1 = scheduleFrame(() => { const cancel2 = scheduleFrame(() => { timerId = setTimeout(() => { if (!cancelled) setVisible(true); }, 500); }); if (cancelled) cancel2(); }); return () => { cancelled = true; cancel1(); if (timerId !== undefined) clearTimeout(timerId); }; }, [notice?.id]); // eslint-disable-line react-hooks/exhaustive-deps // ESC key — closes all modal notices (only on last page so users read all notices) const isLastPage = notices.length <= 1 || idx === notices.length - 1; useEffect(() => { if (!visible || !notice?.dismissible || !isLastPage) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleDismissAll(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [visible, notice?.dismissible, isLastPage]); // eslint-disable-line react-hooks/exhaustive-deps // Arrow-key pager navigation useEffect(() => { if (!visible || notices.length <= 1) return; const handler = (e: KeyboardEvent) => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; if (!canPage) return; // In RTL layouts the directional meaning of arrows is flipped const forward = isRtlLanguage(language) ? e.key === 'ArrowLeft' : e.key === 'ArrowRight'; if (forward && idx < notices.length - 1) { triggerPageSlide('right'); setIdx(idx + 1); announceIndex(idx + 1, notices.length); } else if (!forward && idx > 0) { triggerPageSlide('left'); setIdx(idx - 1); announceIndex(idx - 1, notices.length); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [visible, idx, notices.length, canPage, language]); // eslint-disable-line react-hooks/exhaustive-deps // Body scroll lock. Ref-counted and keyed on a boolean, not on the notice // object: the old version cleared body.overflow on every run of this effect, // which since #1809 would let the page scroll away behind an open sheet. const locksScroll = visible && !!notice; useEffect(() => { if (!locksScroll) return; return lockBodyScroll(); }, [locksScroll]); // Reset center slot scroll to top on navigation (keyboard / pager buttons). useEffect(() => { if (!isMobile) return; contentWrapperRef.current?.scrollTo({ top: 0 }); }, [idx, isMobile]); function announceIndex(newIdx: number, total: number) { setPageAnnouncement( t('system_notice.pager.position') .replace('{current}', String(newIdx + 1)) .replace('{total}', String(total)), ); } // Dismiss every notice in the current modal list — used by the X button and ESC. function handleDismissAll() { setVisible(false); notices.forEach(n => dismiss(n.id)); } function runCta(cta: NonNullable) { if (cta.kind === 'nav') { navigate(cta.href); if (notice?.dismissible !== false) handleDismissAll(); } else if (cta.kind === 'link') { // External link (e.g. Buy Me a Coffee / Ko-fi): open in a new tab and leave the // notice open so the user can use the other button too. window.open(cta.href, '_blank', 'noopener,noreferrer'); } else { runNoticeAction(cta.actionId, { navigate }); if (cta.dismissOnAction !== false) handleDismissAll(); } } // Both buttons only render when the notice carries the matching cta. function handleCTA() { runCta(notice!.cta!); } function handleSecondaryCTA() { runCta(notice!.secondaryCta!); } function animatedDismissAll() { const sheet = sheetRef.current; if (!sheet || prefersReducedMotion) { handleDismissAll(); return; } sheet.style.transition = 'transform 300ms ease-out'; sheet.style.transform = 'translateY(110%)'; sheet.addEventListener('transitionend', function onDone() { sheet.removeEventListener('transitionend', onDone); handleDismissAll(); }, { once: true }); } // Sets up the content wrapper's start transform SYNCHRONOUSLY (before React // re-renders with the new notice), then flags the grace-delay effect to slide // rather than hide+show. function triggerPageSlide(dir: 'left' | 'right') { isPageNavRef.current = true; slideDirRef.current = dir; if (!prefersReducedMotion) { const el = contentWrapperRef.current; if (el) { el.style.transition = 'none'; el.style.transform = dir === 'right' ? 'translateX(100%)' : 'translateX(-100%)'; } } } function handlePrev() { if (!canPage || idx <= 0) return; const next = idx - 1; triggerPageSlide('left'); setIdx(next); announceIndex(next, notices.length); } function handleNext() { if (!canPage || idx >= notices.length - 1) return; const next = idx + 1; triggerPageSlide('right'); setIdx(next); announceIndex(next, notices.length); } function handleGoto(i: number) { if (!canPage || i === idx) return; triggerPageSlide(i > idx ? 'right' : 'left'); setIdx(i); announceIndex(i, notices.length); } // Animation classes const dur = prefersReducedMotion ? 'duration-[120ms]' : 'duration-[260ms]'; const ease = visible ? 'ease-out' : 'ease-in'; return { notices, idx, setIdx, visible, pageAnnouncement, isMobile, isDark, prefersReducedMotion, notice, canPage, isLastPage, language, t, dur, ease, touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, isPageNavRef, stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef, announceIndex, handleDismissAll, handleCTA, handleSecondaryCTA, animatedDismissAll, handlePrev, handleNext, handleGoto, }; } type NoticeState = ReturnType; // Build the NoticeContent props for a given notice + pager slot index. function makeContentProps(S: NoticeState, n: SystemNoticeDTO, slotIdx: number): ContentProps { const { t, isDark, canPage, notices, handleDismissAll, handleCTA, handleSecondaryCTA, handlePrev, handleNext, handleGoto } = S; const rawBody = t(n.bodyKey); const body = n.bodyParams ? Object.entries(n.bodyParams).reduce( (s, [k, v]) => s.replace(new RegExp(`\\{${k}\\}`, 'g'), v), rawBody ) : rawBody; return { notice: n, title: t(n.titleKey), body, ctaLabel: n.cta ? t(n.cta.labelKey) : null, secondaryCtaLabel: n.secondaryCta ? t(n.secondaryCta.labelKey) : null, titleId: `notice-title-${n.id}`, bodyId: `notice-body-${n.id}`, isDark, onDismissAll: handleDismissAll, onCTA: handleCTA, onSecondaryCTA: handleSecondaryCTA, total: notices.length, currentPage: slotIdx, canPage, onPrev: handlePrev, onNext: handleNext, onGoto: handleGoto, }; } function MobileNoticeSheet(S: NoticeState) { const { notice, idx, notices, visible, dur, ease, prefersReducedMotion, pageAnnouncement, language, canPage, setIdx, announceIndex, isPageNavRef, animatedDismissAll, touchStartX, touchStartY, dragLockRef, scrollTopAtTouchStart, stripRef, sheetRef, prevSlotRef, contentWrapperRef, nextSlotRef, } = S; if (!notice) return null; const titleId = `notice-title-${notice.id}`; const bodyId = `notice-body-${notice.id}`; const mobileMotion = prefersReducedMotion ? (visible ? 'opacity-100' : 'opacity-0') : (visible ? 'opacity-100 translate-y-0' : 'opacity-100 translate-y-full'); const prevNotice = notices[idx - 1] ?? null; const nextNotice = notices[idx + 1] ?? null; return (
{/* Screen-reader page announcements */} {pageAnnouncement} {/* Backdrop */}
{/* Bottom sheet */}
{ touchStartX.current = e.touches[0].clientX; touchStartY.current = e.touches[0].clientY; dragLockRef.current = null; scrollTopAtTouchStart.current = contentWrapperRef.current?.scrollTop ?? 0; }} onTouchMove={e => { if (prefersReducedMotion) return; const startX = touchStartX.current; const startY = touchStartY.current; if (startX === null || startY === null) return; const dx = e.touches[0].clientX - startX; const dy = e.touches[0].clientY - startY; // Classify gesture direction on first significant movement if (!dragLockRef.current) { if (Math.abs(dx) > 8 || Math.abs(dy) > 8) { dragLockRef.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v'; // Reset adjacent slots to top before they slide into view. if (dragLockRef.current === 'h') { prevSlotRef.current?.scrollTo({ top: 0 }); nextSlotRef.current?.scrollTo({ top: 0 }); } } return; } if (dragLockRef.current === 'h') { const strip = stripRef.current; if (!strip) return; strip.style.transition = 'none'; // Strip base = -33.333% (center slot visible); dx offsets from there strip.style.transform = `translateX(calc(-33.333% + ${dx}px))`; } else if (dragLockRef.current === 'v' && notice.dismissible) { // Only intercept downward drag for dismiss when the sheet is scrolled to the top. // If scrolled into content, let native pan-y scroll it back up. if (scrollTopAtTouchStart.current > 0) return; const sheet = sheetRef.current; if (!sheet || dy <= 0) return; sheet.style.transition = 'none'; sheet.style.transform = `translateY(${dy}px)`; } }} onTouchEnd={e => { const startX = touchStartX.current; const startY = touchStartY.current; touchStartX.current = null; touchStartY.current = null; const lock = dragLockRef.current; dragLockRef.current = null; if (lock === 'h') { if (startX === null) return; const deltaX = e.changedTouches[0].clientX - startX; const strip = stripRef.current; if (!strip) return; const goNext = isRtlLanguage(language) ? deltaX > 50 : deltaX < -50; const goPrev = isRtlLanguage(language) ? deltaX < -50 : deltaX > 50; const canGoNext = canPage && idx < notices.length - 1; const canGoPrev = canPage && idx > 0; if ((goNext && canGoNext) || (goPrev && canGoPrev)) { // Animate strip to the adjacent slot (-66.666% = next, 0% = prev) strip.style.transition = 'transform 200ms ease-out'; strip.style.transform = goNext ? 'translateX(-66.666%)' : 'translateX(0%)'; strip.addEventListener('transitionend', function onDone() { strip.removeEventListener('transitionend', onDone); strip.style.transition = 'none'; // Render new content into the center slot BEFORE moving the strip, // so the browser never paints old content at the center position. const newIdx = goNext ? idx + 1 : idx - 1; flushSync(() => { isPageNavRef.current = true; setIdx(newIdx); announceIndex(newIdx, notices.length); }); // Reset all slot scrolls so the new center starts at top. prevSlotRef.current?.scrollTo({ top: 0 }); contentWrapperRef.current?.scrollTo({ top: 0 }); nextSlotRef.current?.scrollTo({ top: 0 }); strip.style.transform = 'translateX(-33.333%)'; }, { once: true }); } else { // Spring back to center strip.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)'; strip.style.transform = 'translateX(-33.333%)'; strip.addEventListener('transitionend', function onSnap() { strip.removeEventListener('transitionend', onSnap); strip.style.transition = ''; strip.style.transform = 'translateX(-33.333%)'; }, { once: true }); } return; } // Vertical drag — animated dismiss or spring back (only when at scroll top) if (lock === 'v' && startY !== null && scrollTopAtTouchStart.current === 0) { const deltaY = e.changedTouches[0].clientY - startY; const sheet = sheetRef.current; if (deltaY > 80 && notice.dismissible) { animatedDismissAll(); } else if (sheet && deltaY > 0) { sheet.style.transition = 'transform 300ms cubic-bezier(0.34,1.56,0.64,1)'; sheet.style.transform = 'translateY(0)'; sheet.addEventListener('transitionend', function onSnap() { sheet.removeEventListener('transitionend', onSnap); sheet.style.transition = ''; sheet.style.transform = ''; }, { once: true }); } } }} > {/* Drag handle — fixed, does not scroll */}
{/* Clip container — fills remaining sheet height, hides adjacent slots */}
{/* 3-slot strip: [prev][current][next] — starts at -33.333% to show current */}
{/* The side slots are drag previews only: their pager and CTA are a second copy of the centre ones, so they stay out of the a11y tree and the tab order until they become the centre slot. */}
); } function DesktopNoticeModal(S: NoticeState) { const { notice, idx, visible, dur, ease, prefersReducedMotion, pageAnnouncement, isLastPage, handleDismissAll, contentWrapperRef } = S; if (!notice) return null; const titleId = `notice-title-${notice.id}`; const bodyId = `notice-body-${notice.id}`; const maxWidth = notice.severity === 'critical' ? 'max-w-[680px]' : 'max-w-[620px]'; const desktopMotion = prefersReducedMotion ? (visible ? 'opacity-100' : 'opacity-0') : (visible ? 'opacity-100 scale-100' : 'opacity-0 scale-[0.97]'); return (
{/* Screen-reader page announcements */} {pageAnnouncement}
e.stopPropagation()} >
); } export function ModalRenderer({ notices }: Props) { const S = useSystemNoticeModal(notices); // No notice to show if (!S.notice) return null; // A notice carrying `release` draws its own two-column panel, but keeps this // hook's ESC handling, scroll lock and dismissal. if (S.notice.release && !S.isMobile) { return ( ); } return S.isMobile ? : ; }