Quieter Sasquatch bubble: no perpetual pulse, hideable, map-aware

Addresses "the floating Sasquatch flashing is annoying":

- Pulse is now state-driven: the animate-ping halo shows only when there's an
  unread tip (message), never perpetually; the unread dot matches.
- Honors prefers-reduced-motion (motion-reduce:hidden).
- Map-aware: Layout passes isFullBleed, so on the map the bubble runs compact
  (smaller, no glow, no pulse) instead of competing with map controls.
- Hideable: a ✕ on the FAB hides it (useSasquatchHidden, localStorage
  sasquatch-hidden, same-tab synced via an event), with a "Show Sasquatch
  assistant" toggle in Settings → Appearance to bring it back.
This commit is contained in:
SkateSpots 2026-10-04 14:20:46 +07:00
parent e8d8f3a1ee
commit 8cdc41cc15
8 changed files with 102 additions and 8 deletions

View file

@ -574,6 +574,14 @@ session with **no other memory of this project**. So:
calls are suppressed so partial JSON never renders. Pollinations' `mistral`
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
`getCommunityStats` cover **all** Lab roles (kinds 35001–35014).
**Bubble visibility (2026-10-04):** the floating Sasquatch FAB no longer
pulses forever — the `animate-ping` halo shows **only when an unread tip
exists**, is `motion-reduce:hidden`, and is suppressed on **full-bleed routes**
(`isFullBleed` passed from `Layout`), where the bubble also runs **compact**
(smaller, no glow) so it stops competing with the map. A ✕ on the FAB hides
it (`useSasquatchHidden`, localStorage `sasquatch-hidden`, same-tab synced via
a `sasquatch:hiddenchange` event), and **Settings → Appearance** has a "Show
Sasquatch assistant" toggle to bring it back.
- **ContextVM (CVM) server** (`cvm-server/`, Bun): standalone MCP-over-Nostr
endpoint for *external* agents. Runs on this machine as a **system** service
(`/etc/systemd/system/skatespots-cvm.service`, `sudo systemctl restart

View file

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

View file

@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCVMClient, type SpotResult } from '@/hooks/useCVMClient';
import { usePersonalContext } from '@/hooks/usePersonalContext';
@ -116,6 +117,8 @@ export interface GuideMessage {
interface SasquatchGuideProps {
message?: GuideMessage;
onDismiss?: () => void;
/** True on full-bleed routes (the map) — the bubble runs compact/no-pulse. */
isFullBleed?: boolean;
}
interface ChatMessage {
@ -658,7 +661,7 @@ function ChatText({ text }: { text: string }) {
// ─── Main component ───────────────────────────────────────────────────────────
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGuideProps) {
const { user } = useCurrentUser();
const { t, language } = useTranslation();
const cvm = useCVMClient();
@ -675,6 +678,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const [proDialogOpen, setProDialogOpen] = useState(false);
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
const [hidden, setHidden] = useSasquatchHidden();
const [isVisible, setIsVisible] = useState(false);
// Fetched only while the panel is open (owner-only).
const aiStatus = useSasquatchAiStatus({ enabled: isOwner && isVisible && !isMinimized });
@ -1545,6 +1549,11 @@ Instructions:
// opt-in auto-show message (e.g. onboarding) — never automatically on / or /lab.
const shouldShowPanel = isVisible;
// A proactive tip is waiting (drives the pulse/dot), and the map route runs
// the bubble in a quieter, compact form.
const hasUnread = !!message;
const compact = !!isFullBleed;
// Notify the rest of the app (e.g. MapPage FAB) when the panel opens or closes
useEffect(() => {
const panelOpen = shouldShowPanel && !isMinimized;
@ -1574,10 +1583,12 @@ Instructions:
enableChat: true,
};
if (hidden) return null;
return (
<>
{/* ── Minimized bubble — always visible, draggable ── */}
{(!shouldShowPanel || isMinimized) && (
{/* ── Minimized bubble — visible unless the user hid the assistant ── */}
{!hidden && (!shouldShowPanel || isMinimized) && (
<div
ref={dragRef}
className={cn(
@ -1596,22 +1607,40 @@ Instructions:
>
<div
className={cn(
'relative group flex items-center justify-center w-12 h-12 md:w-14 md:h-14 rounded-full shadow-2xl border-2 border-[#ccff00]/60 transition-all duration-200',
'relative group flex items-center justify-center rounded-full shadow-2xl border-2 border-[#ccff00]/60 transition-all duration-200',
compact ? 'w-9 h-9' : 'w-12 h-12 md:w-14 md:h-14',
isDragging ? 'scale-110 border-[#ccff00] cursor-grabbing' : 'hover:border-[#ccff00] hover:scale-110 active:scale-95 cursor-grab',
)}
style={{
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
boxShadow: compact
? '0 2px 10px rgba(0,0,0,0.8)'
: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
}}
aria-label={t('sasquatchUi.openGuideAria')}
>
<img
src="/images/sasquatch-guide.webp"
alt="The Sasquatch"
className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover pointer-events-none"
className={cn('rounded-full object-cover pointer-events-none', compact ? 'w-6 h-6' : 'w-8 h-8 md:w-9 md:h-9')}
/>
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00]" />
<span className="absolute top-0 right-0 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
{/* Pulse only when there's an unread tip, never on the map, and never
under prefers-reduced-motion (the mount animation is the nuisance). */}
{hasUnread && !compact && (
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00] motion-reduce:hidden" />
)}
{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]" />
)}
<button
type="button"
aria-label={t('sasquatchUi.hideAria')}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setHidden(true); }}
className="absolute -top-1.5 -left-1.5 z-10 flex h-5 w-5 items-center justify-center rounded-full border border-white/20 bg-black/80 text-gray-400 opacity-100 transition-opacity hover:text-white md:opacity-0 md:group-hover:opacity-100"
>
<X className="h-3 w-3" />
</button>
</div>
</div>
)}

View file

@ -0,0 +1,31 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
const KEY = 'sasquatch-hidden';
const EVENT = 'sasquatch:hiddenchange';
/**
* Whether the floating Sasquatch assistant is hidden. Device-local (like the
* other visibility prefs) and synced **within the same tab** via a custom event
* — `useLocalStorage` only syncs across tabs, so the Settings toggle and the
* guide itself need this bridge to stay in step.
*/
export function useSasquatchHidden(): [boolean, (value: boolean) => void] {
const [hidden, setHiddenRaw] = useLocalStorage(KEY, false);
useEffect(() => {
const handler = (e: Event) => setHiddenRaw(!!(e as CustomEvent<boolean>).detail);
window.addEventListener(EVENT, handler);
return () => window.removeEventListener(EVENT, handler);
}, [setHiddenRaw]);
const setHidden = useCallback(
(value: boolean) => {
setHiddenRaw(value);
window.dispatchEvent(new CustomEvent(EVENT, { detail: value }));
},
[setHiddenRaw],
);
return [hidden, setHidden];
}

View file

@ -29,6 +29,7 @@ const sasquatchUi = {
// ── Bubble + header ──
bubbleTitle: 'Press and drag to move · Tap to open',
openGuideAria: 'Open Sasquatch guide',
hideAria: 'Hide the Sasquatch assistant',
activeUntil: 'Active until {{date}}',
platformOwner: 'Platform owner',
upgradeTitle: 'Upgrade to Sasquatch Pro',
@ -160,6 +161,7 @@ const sasquatchUi = {
// ── Bubble + header ──
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
openGuideAria: 'Abrir la guía del Sasquatch',
hideAria: 'Ocultar el asistente Sasquatch',
activeUntil: 'Activo hasta el {{date}}',
platformOwner: 'Propietario de la plataforma',
upgradeTitle: 'Mejora a Sasquatch Pro',

View file

@ -5072,6 +5072,9 @@ settingsUi: {
myStashSub: 'Private gear inventory',
relaysSub: 'Nostr relay connections',
appearanceSub: 'Language and theme',
assistant: 'Assistant',
showSasquatch: 'Show Sasquatch assistant',
showSasquatchDesc: 'Show the floating Sasquatch button on the map and other screens.',
notifications: 'Notifications',
notificationsSub: 'Alerts for DMs, crews and zaps',
coordinator: 'Coordinator',

View file

@ -5072,6 +5072,9 @@ settingsUi: {
myStashSub: 'Inventario privado de material',
relaysSub: 'Conexiones a relays de Nostr',
appearanceSub: 'Idioma y tema',
assistant: 'Asistente',
showSasquatch: 'Mostrar el asistente Sasquatch',
showSasquatchDesc: 'Muestra el botón flotante del Sasquatch en el mapa y otras pantallas.',
notifications: 'Notificaciones',
notificationsSub: 'Avisos de MD, crews y zaps',
coordinator: 'Coordinador',

View file

@ -1,6 +1,8 @@
import { useSeoMeta } from '@unhead/react';
import { Link, useSearchParams } from 'react-router-dom';
import { EditProfileForm } from '@/components/EditProfileForm';
import { Switch } from '@/components/ui/switch';
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
import { RelayListManager } from '@/components/RelayListManager';
@ -609,6 +611,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
function AppearanceScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
return (
<div>
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
@ -619,6 +622,20 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
<LanguageSelector />
</div>
</div>
{/* Sasquatch assistant — the floating map bubble can be distracting;
let people turn it off and back on. Device-local. */}
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">{t('settingsUi.settingsPage.assistant')}</p>
<div className="rounded-2xl border border-white/8 p-4 bg-white/2 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.showSasquatch')}</p>
<p className="text-xs text-muted-foreground">{t('settingsUi.settingsPage.showSasquatchDesc')}</p>
</div>
<Switch checked={!sasquatchHidden} onCheckedChange={(v) => setSasquatchHidden(!v)} aria-label={t('settingsUi.settingsPage.showSasquatch')} />
</div>
</div>
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
<div className="text-3xl mb-3">🌙</div>
<p className="font-black text-white uppercase tracking-wide text-sm mb-2">{t('settingsUi.settingsPage.darkModeOnly')}</p>