diff --git a/js/desktop/src/features/particles/stream-view.tsx b/js/desktop/src/features/particles/stream-view.tsx index 6abe7e3..319710f 100644 --- a/js/desktop/src/features/particles/stream-view.tsx +++ b/js/desktop/src/features/particles/stream-view.tsx @@ -449,6 +449,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) { paused={paused} onEnded={handleParticleEnded} onProgress={setProgress} + immersive /> ); default: diff --git a/js/desktop/src/features/particles/task-particle-view.tsx b/js/desktop/src/features/particles/task-particle-view.tsx index cc73772..76c2209 100644 --- a/js/desktop/src/features/particles/task-particle-view.tsx +++ b/js/desktop/src/features/particles/task-particle-view.tsx @@ -28,6 +28,8 @@ import { useFixedDwell } from '@/hooks/use-fixed-dwell'; import { useLiveDraftField } from '@/hooks/use-live-draft-field'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { resolveHumanDisplay } from '@/lib/humans'; +import { isTypingTarget } from '@/lib/keyboard'; +import { KeyHint } from '@/components/key-hint'; type TaskParticle = Extract; @@ -37,11 +39,28 @@ interface TaskParticleViewProps { paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; + /** + * Enable focus mode while a field is focused: dim and blur the surrounding + * stream, and trap stream keys (Escape leaves the editor, navigation keys + * stay put). Off in the standalone leaf view, which has no stream chrome. + */ + immersive?: boolean; } const DWELL_DURATION_S = 8; const UNASSIGNED = 'unassigned'; +// Stream-navigation keys to swallow while editing so they can't pull focus to +// another particle (only when focus isn't already in a text field). +const NAV_KEYS = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'l', + 'L', +]); + function useParticleDocPath( containerPath: ParticlePath, particleId: string, @@ -56,6 +75,7 @@ export function TaskParticleView({ paused, onEnded, onProgress, + immersive = false, }: TaskParticleViewProps) { const { networkId } = parseParticlePath(containerPath); const network = useNetwork(networkId); @@ -74,6 +94,33 @@ export function TaskParticleView({ const [editing, setEditing] = useState(false); useSuspendPlayback(editing, `task-edit-${particle.id}`); + const exitFocus = useCallback(() => { + (document.activeElement as HTMLElement | null)?.blur(); + }, []); + + // While editing, the card acts like the app's other focus overlays: a + // capture-phase listener consumes Escape (which blurs the field — flushing + // the draft and resuming playback) and swallows stream-navigation keys, so + // the stream's own handlers never see them. Self-contained, so no editing + // state has to be threaded back up to the stream. + useEffect(() => { + if (!immersive || !editing) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + exitFocus(); + } else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) { + // Arrows still move the caret inside text fields; only block them when + // focus is on a non-text control (checkbox, assignee select). + e.stopPropagation(); + } + }; + window.addEventListener('keydown', onKeyDown, { capture: true }); + return () => + window.removeEventListener('keydown', onKeyDown, { capture: true }); + }, [immersive, editing, exitFocus]); + useFixedDwell({ id: particle.id, durationS: DWELL_DURATION_S, @@ -166,8 +213,20 @@ export function TaskParticleView({ return (
+ {immersive && editing && ( +
+ )}
setEditing(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); @@ -251,6 +310,16 @@ export function TaskParticleView({
+ {immersive && editing && ( + + to finish + + )}
); }