+
setIsToolbarOpen(value => !value) }
+ className="tb-toggle pointer-events-auto mr-2 flex-shrink-0"
+ onClick={ handleToggleClick }
whileTap={ { scale: 0.9 } }>
-
+
-
- CreateLinkEvent('friends/toggle') } className="tb-icon" />
- { (requests.length > 0) &&
- }
-
}
-
- { /* Desktop backplate. Always mounted; opacity-driven. */ }
-
- { /* Left nav — desktop. Container variant inheritance staggers items in/out. */ }
+ className={ `tb-nav-clip fixed bottom-0 left-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center pl-3 ${ desktopFlexClasses }` }>
@@ -250,7 +242,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
setMeExpanded(value => !value);
event.stopPropagation();
} }>
-
+
{ (getTotalUnseen > 0) &&
}
@@ -279,14 +271,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
}
-
- { /* Right nav — desktop */ }
+ className={ `tb-nav-clip fixed bottom-0 z-40 h-[52px] max-w-[calc(50vw-242px)] items-center pr-3 ${ desktopFlexClasses } ${ isInRoom ? 'right-0' : 'right-3' }` }>
@@ -303,14 +293,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
-
- { /* Mobile nav. Two staggered halves split by the Me avatar. */ }
+ className={ `fixed left-1/2 bottom-0 z-40 flex w-[95vw] -translate-x-1/2 items-center overflow-visible ${ mobileOnlyClasses } ${ isInRoom ? 'rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] px-[6px] py-[4px] mb-[3px] shadow-[0_-6px_18px_rgba(0,0,0,0.18)]' : '' }` }>
@@ -359,7 +347,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
setMeExpanded(value => !value);
event.stopPropagation();
} }>
-
+
{ (getTotalUnseen > 0) &&
}
@@ -389,12 +377,11 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
}
- { !isInRoom &&
-
- CreateLinkEvent('friends/toggle') } className="tb-icon" />
- { (requests.length > 0) &&
- }
- }
+
+ CreateLinkEvent('friends/toggle') } className="tb-icon" />
+ { (requests.length > 0) &&
+ }
+
>
@@ -402,6 +389,34 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
};
const TOOLBAR_STYLES = `
+ /* The frame's background / border / shadow swap when the toolbar
+ toggles is a plain class change, so without an explicit
+ transition the visuals snap instantly while framer-motion is
+ still animating the nav children — looked broken on rapid
+ toggles. Easing it over the same timing as the spring smooths
+ the burst-click case out. (No 'will-change' here — those props
+ change about once per toggle, but a permanent compositor layer
+ would be re-rasterised on every browser-window resize tick,
+ which is what made dragging the window corner feel sluggish.) */
+ .tb-frame {
+ transition: background-color 220ms ease, border-color 220ms ease, box-shadow 220ms ease, border-radius 220ms ease;
+ }
+
+ /* Left + right nav containers shrink with the viewport, but the icons
+ inside don't. Without horizontal clipping they overflow into the
+ centred chat input around the md breakpoint. 'overflow-x: clip'
+ clips horizontally WITHOUT creating a scroll container the way
+ 'overflow-x: hidden' would — so the Me popover that animates
+ upwards from the avatar still escapes vertically, and the browser
+ doesn't render a stray vertical scrollbar thumb on the nav.
+ Negative inset margins on the clip path keep vertical breathing
+ room for the popover even on engines that fall back to 'hidden'. */
+ .tb-nav-clip {
+ overflow-x: clip;
+ overflow-y: visible;
+ overflow-clip-margin: 0 0 200px 0;
+ }
+
.tb-icon {
opacity: 1;
transition: transform 0.15s ease;
diff --git a/src/hooks/catalog/useSellablePetPalette.ts b/src/hooks/catalog/useSellablePetPalette.ts
index 700cffc..f1f8556 100644
--- a/src/hooks/catalog/useSellablePetPalette.ts
+++ b/src/hooks/catalog/useSellablePetPalette.ts
@@ -1,32 +1,44 @@
import { GetSellablePetPalettesComposer, SellablePetPalettesMessageEvent } from '@nitrots/nitro-renderer';
-import { UseQueryResult } from '@tanstack/react-query';
-import { CatalogPetPalette } from '../../api';
-import { useNitroQuery } from '../../api/nitro-query';
+import { useCallback, useEffect, useState } from 'react';
+import { CatalogPetPalette, SendMessageComposer } from '../../api';
+import { useMessageEvent } from '../events';
+
+const palettesCache = new Map
();
-/**
- * Sellable palettes for a given pet breed, as returned by
- * GetSellablePetPalettesComposer(breed) → SellablePetPalettesMessageEvent.
- * The renderer multiplexes one event type for every breed; accept()
- * keeps each query slot listening only for the matching productCode.
- *
- * Replaces the per-breed accumulator that previously lived in
- * useCatalog (writing to catalogOptions.petPalettes). The catalog pet
- * page now reads via `useSellablePetPalette(productData.type)`.
- *
- * The breed identifier is the localization product code string
- * (e.g. 'pet_egg', 'pet_dog', ...). Disabled while breed is empty so
- * we don't spam composers at mount before the offer is known.
- */
export const useSellablePetPalette = (
breed: string,
options: { enabled?: boolean } = {}
-): UseQueryResult =>
- useNitroQuery({
- key: [ 'nitro', 'catalog', 'petPalette', breed ],
- request: () => new GetSellablePetPalettesComposer(breed),
- parser: SellablePetPalettesMessageEvent,
- accept: event => (event.getParser().productCode === breed),
- select: event => new CatalogPetPalette(event.getParser().productCode, event.getParser().palettes.slice()),
- enabled: (options.enabled ?? true) && !!breed,
- staleTime: Infinity
- });
+): { data: CatalogPetPalette | null } =>
+{
+ const enabled = (options.enabled ?? true) && !!breed;
+ const [ data, setData ] = useState(() => breed ? (palettesCache.get(breed) ?? null) : null);
+ const [ trackedBreed, setTrackedBreed ] = useState(breed);
+
+ if(trackedBreed !== breed)
+ {
+ setTrackedBreed(breed);
+ setData(breed ? (palettesCache.get(breed) ?? null) : null);
+ }
+
+ const handler = useCallback((event: SellablePetPalettesMessageEvent) =>
+ {
+ const parser = event.getParser();
+ if(!parser || parser.productCode !== breed) return;
+
+ const palette = new CatalogPetPalette(parser.productCode, parser.palettes.slice());
+ palettesCache.set(breed, palette);
+ setData(palette);
+ }, [ breed ]);
+
+ useMessageEvent(SellablePetPalettesMessageEvent, handler);
+
+ useEffect(() =>
+ {
+ if(!enabled) return;
+ if(palettesCache.has(breed)) return;
+
+ SendMessageComposer(new GetSellablePetPalettesComposer(breed));
+ }, [ enabled, breed ]);
+
+ return { data };
+};