Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/added-issue-4169.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- 3D Brain and Memory graphs now briefly explain their touch rotation gesture.
15 changes: 14 additions & 1 deletion client/src/components/brain/tabs/BrainGraph.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -629,6 +632,16 @@ export default function BrainGraph() {
</Canvas>
)}

{touchHintVisible && (
<div
role="status"
aria-live="polite"
className="absolute top-3 inset-x-3 z-20 flex justify-center pointer-events-none"
>
<span className="port-media-overlay rounded-lg px-3 py-2 text-xs">Drag to rotate</span>
</div>
)}

{!graph && (
<div className="flex items-center justify-center h-full text-gray-500 text-sm">
No nodes match the current filters.
Expand Down
17 changes: 16 additions & 1 deletion client/src/components/cos/tabs/MemoryGraph.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -230,7 +232,10 @@ export default function MemoryGraph() {
desktop, floors at 240px so it stays usable on a short viewport. */}
<div
className="relative bg-port-card border border-port-border rounded-lg overflow-hidden h-[clamp(240px,45vh,500px)]"
onPointerDown={(e) => { 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 && (
Expand All @@ -251,6 +256,16 @@ export default function MemoryGraph() {
</Canvas>
)}

{touchHintVisible && (
<div
role="status"
aria-live="polite"
className="absolute top-3 inset-x-3 z-20 flex justify-center pointer-events-none"
>
<span className="port-media-overlay rounded-lg px-3 py-2 text-xs">Drag to rotate</span>
</div>
)}

{/* 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
Expand Down
1 change: 1 addition & 0 deletions client/src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 2 additions & 0 deletions client/src/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions client/src/hooks/useFirstTouchHint.js
Original file line number Diff line number Diff line change
@@ -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 };
}
29 changes: 29 additions & 0 deletions client/src/hooks/useFirstTouchHint.test.jsx
Original file line number Diff line number Diff line change
@@ -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);
});
});