No nodes match the current filters.
diff --git a/client/src/components/cos/tabs/MemoryGraph.jsx b/client/src/components/cos/tabs/MemoryGraph.jsx
index 92c0d36932..0b03422f62 100644
--- a/client/src/components/cos/tabs/MemoryGraph.jsx
+++ b/client/src/components/cos/tabs/MemoryGraph.jsx
@@ -8,6 +8,7 @@ import { MEMORY_TYPES, MEMORY_TYPE_COLORS } from '../constants';
import { buildGraph } from '../../../lib/graphSimulation';
import BrailleSpinner from '../../BrailleSpinner';
import useHoverTooltip from '../../../hooks/useHoverTooltip';
+import useFirstTouchHint from '../../../hooks/useFirstTouchHint';
import { formatDateNumeric } from '../../../utils/formatters';
// Widest the hover tooltip renders. Single source for both its max-width and
@@ -129,6 +130,7 @@ export default function MemoryGraph() {
// Hover tooltip state + the ref-gated pointer tracking that keeps a plain
// mouse move from re-rendering this component when nothing can paint.
const { hoveredNode, tooltipPos, handleHover, handlePointerMove } = useHoverTooltip();
+ const { visible: touchHintVisible, showOnFirstTouch } = useFirstTouchHint();
const [layoutKey, setLayoutKey] = useState(0);
// Mobile-only: the legend auto-shows on a roomy viewport (CSS, not this flag).
const [legendOpen, setLegendOpen] = useState(false);
@@ -230,7 +232,10 @@ export default function MemoryGraph() {
desktop, floors at 240px so it stays usable on a short viewport. */}
{ dragStartRef.current = { x: e.clientX, y: e.clientY }; }}
+ onPointerDown={(e) => {
+ dragStartRef.current = { x: e.clientX, y: e.clientY };
+ if (e.target?.tagName === 'CANVAS') showOnFirstTouch(e);
+ }}
onPointerMove={handlePointerMove}
>
{graph && (
@@ -251,6 +256,16 @@ export default function MemoryGraph() {
)}
+ {touchHintVisible && (
+
+ Drag to rotate
+
+ )}
+
{/* Legend. Its ~200px blankets a short canvas, so it auto-shows only on
a `roomy-viewport` (wide AND tall — see index.css); otherwise it
collapses behind a toggle and expands upward from the corner. The
diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md
index b5ee66495b..6c58bd523d 100644
--- a/client/src/hooks/README.md
+++ b/client/src/hooks/README.md
@@ -108,6 +108,7 @@ grep -i "what you want to do" client/src/hooks/README.md
| `useRowDraft` | Multi-column row draft (analogue of `useFieldDraft`). | Multi-column row that commits as a unit. |
| `usePendingListRows` | List-of-rows where a new row is held client-side until a required column fills, then promoted to `onChange`. | Editable list whose nameless rows would otherwise be dropped by the server sanitizer (WardrobeSection, CharacterDetailEditor list sections). |
| `useKeyboardControls` | Keyboard binding for CyberCity mode toggle. | CyberCity-specific. |
+| `useFirstTouchHint` | `{ visible, showOnFirstTouch }` — reveals a brief one-time touch gesture hint, then auto-hides it. | 3D canvases whose primary one-finger gesture needs an initial explanation. |
| `useKeyboardShortcuts` | `useKeyboardShortcuts(active, bindings, opts?)` — fire a `{ key: handler }` map while `active`; ignores events from editable fields (`isEditableTarget` also exported), ⌘/Ctrl/Alt chords, OS key auto-repeat (`{ ignoreRepeat: false }` to opt back in), and any keystroke while an `aria-modal` dialog is open (`{ enabledInDialog: true }` for a modal-owned shortcut); a falsy handler disables that key. | Single-surface action shortcuts (editorial comment card prev/next + accept/dismiss/generate, #1603). Not for held-key/game input (that's `useKeyboardControls`). |
| `useHfTokenStatus` | Reads the central HuggingFace token status (`GET /image-gen/setup/hf-token-status` → `server/lib/hfToken.js`: stored → env → `hf auth login`). Returns `{ present, source, refresh }` where `present` is TRI-state — `null` means "unknown/failed", not "absent" — so a slow fetch can't flash a token nag at a user who has one. `{ enabled }` gates the fetch (false resets to unknown, so a modal re-checks per open); `{ errorAs: 'absent' }` opts a surface into offering the paste form when the status call fails. | Any gated-HuggingFace surface (3D page, MIDI gated modal, Image Gen banners) — use this instead of re-rolling the fetch, or the same blip renders differently per page. |
| `useKeyboardHelp` | Esc closes, even from inputs/textareas. | Help/cheatsheet modals. |
diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js
index 5a622670be..564f93e0d1 100644
--- a/client/src/hooks/index.js
+++ b/client/src/hooks/index.js
@@ -153,6 +153,8 @@ export { default as useDrawerTab } from './useDrawerTab.js';
export { default as useChordPlayer } from './useChordPlayer.js';
export { default as useDrumPlayer } from './useDrumPlayer.js';
export * from './useHfTokenStatus.js';
+export { default as useFirstTouchHint } from './useFirstTouchHint.js';
+export * from './useFirstTouchHint.js';
export * from './useKeyboardHelp.js';
export * from './useLockToggle.js';
export { default as usePersistedOptions } from './usePersistedOptions.js';
diff --git a/client/src/hooks/useFirstTouchHint.js b/client/src/hooks/useFirstTouchHint.js
new file mode 100644
index 0000000000..6d492059dc
--- /dev/null
+++ b/client/src/hooks/useFirstTouchHint.js
@@ -0,0 +1,32 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+export const FIRST_TOUCH_HINT_DURATION_MS = 2500;
+
+/**
+ * Shows a short-lived hint once, when a canvas first receives a touch gesture.
+ *
+ * Canvas controls intentionally use `touch-action: none`, so a first finger
+ * drag rotates the scene instead of scrolling the page. Keeping this state in
+ * one hook makes that gesture explicit without changing the control itself.
+ */
+export default function useFirstTouchHint({ durationMs = FIRST_TOUCH_HINT_DURATION_MS } = {}) {
+ const [visible, setVisible] = useState(false);
+ const shownRef = useRef(false);
+ const timeoutRef = useRef(null);
+
+ useEffect(() => () => {
+ if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
+ }, []);
+
+ const showOnFirstTouch = useCallback((event) => {
+ if (event.pointerType !== 'touch' || shownRef.current) return;
+ shownRef.current = true;
+ setVisible(true);
+ timeoutRef.current = window.setTimeout(() => {
+ timeoutRef.current = null;
+ setVisible(false);
+ }, durationMs);
+ }, [durationMs]);
+
+ return { visible, showOnFirstTouch };
+}
diff --git a/client/src/hooks/useFirstTouchHint.test.jsx b/client/src/hooks/useFirstTouchHint.test.jsx
new file mode 100644
index 0000000000..f9a5861ee7
--- /dev/null
+++ b/client/src/hooks/useFirstTouchHint.test.jsx
@@ -0,0 +1,29 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import useFirstTouchHint from './useFirstTouchHint.js';
+
+afterEach(() => vi.useRealTimers());
+
+describe('useFirstTouchHint', () => {
+ it('shows a brief hint for the first touch only', () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() => useFirstTouchHint({ durationMs: 100 }));
+
+ act(() => result.current.showOnFirstTouch({ pointerType: 'touch' }));
+ expect(result.current.visible).toBe(true);
+
+ act(() => vi.advanceTimersByTime(100));
+ expect(result.current.visible).toBe(false);
+
+ act(() => result.current.showOnFirstTouch({ pointerType: 'touch' }));
+ expect(result.current.visible).toBe(false);
+ });
+
+ it('does not show the touch guidance for mouse input', () => {
+ const { result } = renderHook(() => useFirstTouchHint());
+
+ act(() => result.current.showOnFirstTouch({ pointerType: 'mouse' }));
+
+ expect(result.current.visible).toBe(false);
+ });
+});