From 620b9559c1ece579b8871a910f0fe19af1c86883 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 05:22:00 +0000 Subject: [PATCH] feat: explain 3D graph touch rotation (#4169) --- .changelog/next/added-issue-4169.md | 1 + .../src/components/brain/tabs/BrainGraph.jsx | 15 ++++++++- .../src/components/cos/tabs/MemoryGraph.jsx | 17 +++++++++- client/src/hooks/README.md | 1 + client/src/hooks/index.js | 2 ++ client/src/hooks/useFirstTouchHint.js | 32 +++++++++++++++++++ client/src/hooks/useFirstTouchHint.test.jsx | 29 +++++++++++++++++ 7 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .changelog/next/added-issue-4169.md create mode 100644 client/src/hooks/useFirstTouchHint.js create mode 100644 client/src/hooks/useFirstTouchHint.test.jsx diff --git a/.changelog/next/added-issue-4169.md b/.changelog/next/added-issue-4169.md new file mode 100644 index 0000000000..2f0793169b --- /dev/null +++ b/.changelog/next/added-issue-4169.md @@ -0,0 +1 @@ +- 3D Brain and Memory graphs now briefly explain their touch rotation gesture. diff --git a/client/src/components/brain/tabs/BrainGraph.jsx b/client/src/components/brain/tabs/BrainGraph.jsx index 8449fdefa2..821414646d 100644 --- a/client/src/components/brain/tabs/BrainGraph.jsx +++ b/client/src/components/brain/tabs/BrainGraph.jsx @@ -13,6 +13,7 @@ import EntityCombobox from '../../EntityCombobox'; import InlineConfirmRow from '../../ui/InlineConfirmRow'; import BrailleSpinner from '../../BrailleSpinner'; import useHoverTooltip from '../../../hooks/useHoverTooltip'; +import useFirstTouchHint from '../../../hooks/useFirstTouchHint'; import { formatDateNumeric } from '../../../utils/formatters'; const EDGE_COLORS = { @@ -201,6 +202,7 @@ export default function BrainGraph() { // 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); const [syncing, setSyncing] = useState(false); const [confirmingRefresh, setConfirmingRefresh] = useState(false); @@ -363,13 +365,14 @@ export default function BrainGraph() { const handlePointerDown = useCallback((e) => { if (!isCanvasGesture(e)) return; + showOnFirstTouch(e); // A second finger is a pinch-zoom or two-finger pan, never a tap — drop the // recorded start so neither the threshold pick nor the miss-clear can fire // for it (both gate on `isTapGesture`, which is false without a start). const secondFinger = touchGestureRef.current && !e.isPrimary; dragStartRef.current = secondFinger ? null : { x: e.clientX, y: e.clientY }; touchGestureRef.current = e.pointerType === 'touch'; - }, []); + }, [showOnFirstTouch]); // Touch selection. The raw mesh raycast needs the ray to hit a ~10px sphere; // instead project every node and take the nearest within a finger-sized @@ -629,6 +632,16 @@ export default function BrainGraph() { )} + {touchHintVisible && ( +
+ Drag to rotate +
+ )} + {!graph && (
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); + }); +});