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:
parent
e8d8f3a1ee
commit
8cdc41cc15
8 changed files with 102 additions and 8 deletions
|
|
@ -574,6 +574,14 @@ session with **no other memory of this project**. So:
|
||||||
calls are suppressed so partial JSON never renders. Pollinations' `mistral`
|
calls are suppressed so partial JSON never renders. Pollinations' `mistral`
|
||||||
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
|
slug is dead (404); only `openai` (gpt-oss-20b) is live. `findCoaches` +
|
||||||
`getCommunityStats` cover **all** Lab roles (kinds 35001–35014).
|
`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
|
- **ContextVM (CVM) server** (`cvm-server/`, Bun): standalone MCP-over-Nostr
|
||||||
endpoint for *external* agents. Runs on this machine as a **system** service
|
endpoint for *external* agents. Runs on this machine as a **system** service
|
||||||
(`/etc/systemd/system/skatespots-cvm.service`, `sudo systemctl restart
|
(`/etc/systemd/system/skatespots-cvm.service`, `sudo systemctl restart
|
||||||
|
|
|
||||||
|
|
@ -1136,6 +1136,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
<LazySasquatchGuide
|
<LazySasquatchGuide
|
||||||
message={currentMessage}
|
message={currentMessage}
|
||||||
onDismiss={dismissCurrentMessage}
|
onDismiss={dismissCurrentMessage}
|
||||||
|
isFullBleed={isFullBleed}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
|
import { X, Minimize2, HelpCircle, Send, Loader2, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useCVMClient, type SpotResult } from '@/hooks/useCVMClient';
|
import { useCVMClient, type SpotResult } from '@/hooks/useCVMClient';
|
||||||
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
import { usePersonalContext } from '@/hooks/usePersonalContext';
|
||||||
|
|
@ -116,6 +117,8 @@ 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 {
|
||||||
|
|
@ -658,7 +661,7 @@ function ChatText({ text }: { text: string }) {
|
||||||
|
|
||||||
// ─── Main component ───────────────────────────────────────────────────────────
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
export function SasquatchGuide({ message, onDismiss, isFullBleed }: SasquatchGuideProps) {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { t, language } = useTranslation();
|
const { t, language } = useTranslation();
|
||||||
const cvm = useCVMClient();
|
const cvm = useCVMClient();
|
||||||
|
|
@ -675,6 +678,7 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
|
||||||
const [proDialogOpen, setProDialogOpen] = useState(false);
|
const [proDialogOpen, setProDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
|
const [isMinimized, setIsMinimized] = useLocalStorage('sasquatch-minimized', false);
|
||||||
|
const [hidden, setHidden] = useSasquatchHidden();
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
// Fetched only while the panel is open (owner-only).
|
// Fetched only while the panel is open (owner-only).
|
||||||
const aiStatus = useSasquatchAiStatus({ enabled: isOwner && isVisible && !isMinimized });
|
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.
|
// 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
|
||||||
|
// 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
|
// Notify the rest of the app (e.g. MapPage FAB) when the panel opens or closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const panelOpen = shouldShowPanel && !isMinimized;
|
const panelOpen = shouldShowPanel && !isMinimized;
|
||||||
|
|
@ -1574,10 +1583,12 @@ Instructions:
|
||||||
enableChat: true,
|
enableChat: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (hidden) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Minimized bubble — always visible, draggable ── */}
|
{/* ── Minimized bubble — visible unless the user hid the assistant ── */}
|
||||||
{(!shouldShowPanel || isMinimized) && (
|
{!hidden && (!shouldShowPanel || isMinimized) && (
|
||||||
<div
|
<div
|
||||||
ref={dragRef}
|
ref={dragRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -1596,22 +1607,40 @@ Instructions:
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
isDragging ? 'scale-110 border-[#ccff00] cursor-grabbing' : 'hover:border-[#ccff00] hover:scale-110 active:scale-95 cursor-grab',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
|
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')}
|
aria-label={t('sasquatchUi.openGuideAria')}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/images/sasquatch-guide.webp"
|
src="/images/sasquatch-guide.webp"
|
||||||
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={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]" />
|
{/* Pulse only when there's an unread tip, never on the map, and never
|
||||||
<span className="absolute top-0 right-0 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
31
src/hooks/useSasquatchHidden.ts
Normal file
31
src/hooks/useSasquatchHidden.ts
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,7 @@ const sasquatchUi = {
|
||||||
// ── Bubble + header ──
|
// ── Bubble + header ──
|
||||||
bubbleTitle: 'Press and drag to move · Tap to open',
|
bubbleTitle: 'Press and drag to move · Tap to open',
|
||||||
openGuideAria: 'Open Sasquatch guide',
|
openGuideAria: 'Open Sasquatch guide',
|
||||||
|
hideAria: 'Hide 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',
|
||||||
|
|
@ -160,6 +161,7 @@ const sasquatchUi = {
|
||||||
// ── Bubble + header ──
|
// ── Bubble + header ──
|
||||||
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
|
bubbleTitle: 'Pulsa y arrastra para mover · Toca para abrir',
|
||||||
openGuideAria: 'Abrir la guía del Sasquatch',
|
openGuideAria: 'Abrir la guía del Sasquatch',
|
||||||
|
hideAria: 'Ocultar 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',
|
||||||
|
|
|
||||||
|
|
@ -5072,6 +5072,9 @@ settingsUi: {
|
||||||
myStashSub: 'Private gear inventory',
|
myStashSub: 'Private gear inventory',
|
||||||
relaysSub: 'Nostr relay connections',
|
relaysSub: 'Nostr relay connections',
|
||||||
appearanceSub: 'Language and theme',
|
appearanceSub: 'Language and theme',
|
||||||
|
assistant: 'Assistant',
|
||||||
|
showSasquatch: 'Show Sasquatch assistant',
|
||||||
|
showSasquatchDesc: 'Show the floating Sasquatch button on the map and other screens.',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
notificationsSub: 'Alerts for DMs, crews and zaps',
|
notificationsSub: 'Alerts for DMs, crews and zaps',
|
||||||
coordinator: 'Coordinator',
|
coordinator: 'Coordinator',
|
||||||
|
|
|
||||||
|
|
@ -5072,6 +5072,9 @@ settingsUi: {
|
||||||
myStashSub: 'Inventario privado de material',
|
myStashSub: 'Inventario privado de material',
|
||||||
relaysSub: 'Conexiones a relays de Nostr',
|
relaysSub: 'Conexiones a relays de Nostr',
|
||||||
appearanceSub: 'Idioma y tema',
|
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',
|
notifications: 'Notificaciones',
|
||||||
notificationsSub: 'Avisos de MD, crews y zaps',
|
notificationsSub: 'Avisos de MD, crews y zaps',
|
||||||
coordinator: 'Coordinador',
|
coordinator: 'Coordinador',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { EditProfileForm } from '@/components/EditProfileForm';
|
import { EditProfileForm } from '@/components/EditProfileForm';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
|
||||||
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
||||||
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
|
||||||
import { RelayListManager } from '@/components/RelayListManager';
|
import { RelayListManager } from '@/components/RelayListManager';
|
||||||
|
|
@ -609,6 +611,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
|
||||||
|
|
||||||
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
|
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
|
||||||
|
|
@ -619,6 +622,20 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
</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="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
|
||||||
<div className="text-3xl mb-3">🌙</div>
|
<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>
|
<p className="font-black text-white uppercase tracking-wide text-sm mb-2">{t('settingsUi.settingsPage.darkModeOnly')}</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue