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`
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1136,6 +1136,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
<LazySasquatchGuide
|
||||
message={currentMessage}
|
||||
onDismiss={dismissCurrentMessage}
|
||||
isFullBleed={isFullBleed}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
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 ──
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue