Sasquatch: restore the pulse, hide by dragging left, cleaner Go Pro

- Restore the always-on blink/pulse (still motion-reduce:hidden for a11y).
- Drag the bubble off the left edge to hide it; a tiny left-edge tab brings it
  back. (The ✕/panel/Settings Hide paths remain.)
- Replaced the cramped inline "Go Pro" header pill with a clean full-width
  strip at the top of the panel body.
This commit is contained in:
SkateSpots 2026-10-04 17:54:42 +07:00
parent 0e405eaa00
commit d82ecf4adf
3 changed files with 54 additions and 25 deletions

View file

@ -1136,7 +1136,6 @@ export function Layout({ children, noPadding }: LayoutProps) {
<LazySasquatchGuide <LazySasquatchGuide
message={currentMessage} message={currentMessage}
onDismiss={dismissCurrentMessage} onDismiss={dismissCurrentMessage}
isFullBleed={isFullBleed}
/> />
</Suspense> </Suspense>

View file

@ -117,8 +117,6 @@ export interface GuideMessage {
interface SasquatchGuideProps { interface SasquatchGuideProps {
message?: GuideMessage; message?: GuideMessage;
onDismiss?: () => void; onDismiss?: () => void;
/** True on full-bleed routes (the map) — the bubble runs compact/no-pulse. */
isFullBleed?: boolean;
} }
interface ChatMessage { interface ChatMessage {
@ -661,7 +659,7 @@ function ChatText({ text }: { text: string }) {
// ─── Main component ─────────────────────────────────────────────────────────── // ─── Main component ───────────────────────────────────────────────────────────
export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGuideProps) { export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { t, language } = useTranslation(); const { t, language } = useTranslation();
const cvm = useCVMClient(); const cvm = useCVMClient();
@ -693,6 +691,9 @@ export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGui
// Draggable position state for minimized bubble // Draggable position state for minimized bubble
const [dragPos, setDragPos] = useLocalStorage<{ x: number; y: number } | null>('sasquatch-pos', null); const [dragPos, setDragPos] = useLocalStorage<{ x: number; y: number } | null>('sasquatch-pos', null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// Mirror of dragPos for the pointer-up handler (state may be stale in closures).
const dragPosRef = useRef(dragPos);
useEffect(() => { dragPosRef.current = dragPos; }, [dragPos]);
const dragRef = useRef<HTMLDivElement>(null); const dragRef = useRef<HTMLDivElement>(null);
const dragStart = useRef<{ mx: number; my: number; bx: number; by: number } | null>(null); const dragStart = useRef<{ mx: number; my: number; bx: number; by: number } | null>(null);
const dragMoved = useRef(false); const dragMoved = useRef(false);
@ -741,6 +742,15 @@ export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGui
dragStart.current = null; dragStart.current = null;
dragMoved.current = false; dragMoved.current = false;
setTimeout(() => setIsDragging(false), 50); setTimeout(() => setIsDragging(false), 50);
// Drag it off the left edge to hide it — a tiny tab brings it back.
if (wasDrag) {
const x = dragPosRef.current?.x;
if (x != null && x <= 28) {
setHidden(true);
setDragPos(null);
return;
}
}
// If this pointer sequence was a genuine tap (no real drag), open here. // If this pointer sequence was a genuine tap (no real drag), open here.
// A parallel onClick handler also opens as a reliable fallback for mobile // A parallel onClick handler also opens as a reliable fallback for mobile
// WebViews where the pointer-capture tap can be flaky on first interaction; // WebViews where the pointer-capture tap can be flaky on first interaction;
@ -748,7 +758,7 @@ export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGui
if (!wasDrag) { if (!wasDrag) {
openPanel(); openPanel();
} }
}, [openPanel]); }, [openPanel, setHidden, setDragPos]);
// Reliable open path: plain click. On mobile WebViews the pointer-capture tap // Reliable open path: plain click. On mobile WebViews the pointer-capture tap
// detection can miss the very first interaction after mount (the "1st tap does // detection can miss the very first interaction after mount (the "1st tap does
@ -1549,10 +1559,8 @@ Instructions:
// opt-in auto-show message (e.g. onboarding) — never automatically on / or /lab. // opt-in auto-show message (e.g. onboarding) — never automatically on / or /lab.
const shouldShowPanel = isVisible; const shouldShowPanel = isVisible;
// A proactive tip is waiting (drives the pulse/dot), and the map route runs // A proactive tip is waiting (drives the unread dot).
// the bubble in a quieter, compact form.
const hasUnread = !!message; const hasUnread = !!message;
const compact = !!isFullBleed;
// Notify the rest of the app (e.g. MapPage FAB) when the panel opens or closes // Notify the rest of the app (e.g. MapPage FAB) when the panel opens or closes
useEffect(() => { useEffect(() => {
@ -1583,12 +1591,26 @@ Instructions:
enableChat: true, enableChat: true,
}; };
if (hidden) return null; // Hidden by dragging to the left edge (or via the ✕ / panel Hide button). A
// tiny edge tab brings it back onto the screen.
if (hidden) {
return (
<button
type="button"
aria-label={t('sasquatchUi.showAria')}
title={t('sasquatchUi.showAria')}
onClick={() => setHidden(false)}
className="fixed left-0 top-1/2 z-50 -translate-y-1/2 rounded-r-full border border-l-0 border-[#ccff00]/40 bg-black/70 py-3 pl-0.5 pr-1.5 transition-colors hover:bg-black/90"
>
<span className="block h-5 w-1.5 rounded-full bg-[#ccff00]/70" />
</button>
);
}
return ( return (
<> <>
{/* ── Minimized bubble — visible unless the user hid the assistant ── */} {/* ── Minimized bubble — always visible, draggable ── */}
{!hidden && (!shouldShowPanel || isMinimized) && ( {(!shouldShowPanel || isMinimized) && (
<div <div
ref={dragRef} ref={dragRef}
className={cn( className={cn(
@ -1621,11 +1643,9 @@ Instructions:
alt="The Sasquatch" alt="The Sasquatch"
className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover pointer-events-none" className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover pointer-events-none"
/> />
{/* Pulse only when there's an unread tip, never on the map, and never {/* Pulse like the original — always on, except under
under prefers-reduced-motion (the motion is the nuisance). */} prefers-reduced-motion. */}
{hasUnread && !compact && ( <span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
)}
{hasUnread && ( {hasUnread && (
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" /> <span className="absolute -top-0.5 -right-0.5 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
)} )}
@ -1702,15 +1722,6 @@ Instructions:
> >
⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''} ⚡ Pro{proDaysLeft !== null ? ` · ${proDaysLeft}d` : ''}
</span> </span>
) : user ? (
<button
onClick={() => setProDialogOpen(true)}
className="ml-0.5 inline-flex items-center gap-1 rounded-full border border-[#ccff00]/40 bg-[#ccff00]/10 px-2 py-0.5 text-[9px] font-black uppercase tracking-wider text-[#ccff00] transition-colors hover:border-[#ccff00]/70 hover:bg-[#ccff00]/20"
title={t('sasquatchUi.upgradeTitle')}
>
<span aria-hidden>⚡</span>
{t('sasquatchUi.goPro')}
</button>
) : null} ) : null}
</div> </div>
{/* Context + (owner-only) AI balance on one tidy line */} {/* Context + (owner-only) AI balance on one tidy line */}
@ -1765,6 +1776,21 @@ Instructions:
{/* Body — scrolls internally so the card never outgrows the viewport */} {/* Body — scrolls internally so the card never outgrows the viewport */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3">
{/* Go Pro — a clean full-width strip (the inline header pill looked
cramped). Only for signed-in non-Pro users. */}
{!isPro && user && (
<button
onClick={() => setProDialogOpen(true)}
className="mb-3 flex w-full items-center gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/10 px-3 py-2 text-left transition-colors hover:border-[#ccff00]/60 hover:bg-[#ccff00]/15"
title={t('sasquatchUi.upgradeTitle')}
>
<span aria-hidden className="text-sm">⚡</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-black uppercase tracking-wider text-[#ccff00]">{t('sasquatchUi.goPro')}</span>
<span className="block truncate text-[10px] text-[#ccff00]/60">{t('sasquatchUi.goProBlurb')}</span>
</span>
</button>
)}
{/* Greeting — only shown before the user starts chatting, so the {/* Greeting — only shown before the user starts chatting, so the
same wall of welcome text doesn't reappear on every open once same wall of welcome text doesn't reappear on every open once
a conversation is underway. */} a conversation is underway. */}

View file

@ -31,10 +31,12 @@ const sasquatchUi = {
openGuideAria: 'Open Sasquatch guide', openGuideAria: 'Open Sasquatch guide',
hideAria: 'Hide the Sasquatch assistant', hideAria: 'Hide the Sasquatch assistant',
hide: 'Hide assistant', hide: 'Hide assistant',
showAria: 'Show the Sasquatch assistant',
activeUntil: 'Active until {{date}}', activeUntil: 'Active until {{date}}',
platformOwner: 'Platform owner', platformOwner: 'Platform owner',
upgradeTitle: 'Upgrade to Sasquatch Pro', upgradeTitle: 'Upgrade to Sasquatch Pro',
goPro: 'Go Pro', goPro: 'Go Pro',
goProBlurb: 'Personal AI · from 2k sats',
aiBalanceTitle: 'Sasquatch AI credit balance (owner-only) — top up at {{provider}}', aiBalanceTitle: 'Sasquatch AI credit balance (owner-only) — top up at {{provider}}',
aiChip: '⚡ AI {{balance}}', aiChip: '⚡ AI {{balance}}',
minimizeToBubbleAria: 'Minimize to bubble', minimizeToBubbleAria: 'Minimize to bubble',
@ -164,10 +166,12 @@ const sasquatchUi = {
openGuideAria: 'Abrir la guía del Sasquatch', openGuideAria: 'Abrir la guía del Sasquatch',
hideAria: 'Ocultar el asistente Sasquatch', hideAria: 'Ocultar el asistente Sasquatch',
hide: 'Ocultar asistente', hide: 'Ocultar asistente',
showAria: 'Mostrar el asistente Sasquatch',
activeUntil: 'Activo hasta el {{date}}', activeUntil: 'Activo hasta el {{date}}',
platformOwner: 'Propietario de la plataforma', platformOwner: 'Propietario de la plataforma',
upgradeTitle: 'Mejora a Sasquatch Pro', upgradeTitle: 'Mejora a Sasquatch Pro',
goPro: 'Hazte Pro', goPro: 'Hazte Pro',
goProBlurb: 'IA personal · desde 2k sats',
aiBalanceTitle: 'Saldo de crédito de la IA del Sasquatch (solo propietario) — recarga en {{provider}}', aiBalanceTitle: 'Saldo de crédito de la IA del Sasquatch (solo propietario) — recarga en {{provider}}',
aiChip: '⚡ IA {{balance}}', aiChip: '⚡ IA {{balance}}',
minimizeToBubbleAria: 'Minimizar a la burbuja', minimizeToBubbleAria: 'Minimizar a la burbuja',