From 27c60b0601b980103fb28f31308d7d2eae79a0d9 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 18:51:48 +0300 Subject: [PATCH 01/18] feat(hint-mode): add type definitions --- .../renderer/src/contexts/hint-mode/types.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/desktop/src/renderer/src/contexts/hint-mode/types.ts diff --git a/apps/desktop/src/renderer/src/contexts/hint-mode/types.ts b/apps/desktop/src/renderer/src/contexts/hint-mode/types.ts new file mode 100644 index 000000000..b5dfd90d6 --- /dev/null +++ b/apps/desktop/src/renderer/src/contexts/hint-mode/types.ts @@ -0,0 +1,20 @@ +export interface HintTarget { + element: HTMLElement + label: string + rect: DOMRect + text: string +} + +export interface HintModeState { + isActive: boolean + hints: HintTarget[] + typedChars: string +} + +export interface HintModeContextType { + state: HintModeState + activate: () => void + deactivate: () => void + typeChar: (char: string) => void + backspace: () => void +} From c028079c377e4015ed2f5d040f7f82fab724e8a9 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 18:56:27 +0300 Subject: [PATCH 02/18] feat(hint-mode): add DOM scanner with visibility filters --- .../src/renderer/src/lib/dom-scanner.test.ts | 137 ++++++++++++++++++ .../src/renderer/src/lib/dom-scanner.ts | 61 ++++++++ 2 files changed, 198 insertions(+) create mode 100644 apps/desktop/src/renderer/src/lib/dom-scanner.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/dom-scanner.ts diff --git a/apps/desktop/src/renderer/src/lib/dom-scanner.test.ts b/apps/desktop/src/renderer/src/lib/dom-scanner.test.ts new file mode 100644 index 000000000..71eac8ed0 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/dom-scanner.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { scanClickableElements } from './dom-scanner' + +const HINT_OVERLAY_ID = 'hint-mode-overlay' + +const mockRect = (el: HTMLElement, rect: Partial = {}): void => { + el.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 100, + height: 30, + top: 0, + left: 0, + bottom: 30, + right: 100, + toJSON: () => ({}), + ...rect + }) as DOMRect +} + +const mockOffsetParent = (el: HTMLElement, parent: Element | null = document.body): void => { + Object.defineProperty(el, 'offsetParent', { value: parent, configurable: true }) +} + +const createEl = (tag: string, attrs: Record = {}, text = ''): HTMLElement => { + const el = document.createElement(tag) + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v) + if (text) el.textContent = text + document.body.appendChild(el) + mockRect(el) + mockOffsetParent(el) + return el +} + +describe('scanClickableElements', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('finds buttons', () => { + createEl('button', {}, 'Click me') + const results = scanClickableElements() + expect(results).toHaveLength(1) + expect(results[0].tagName).toBe('BUTTON') + }) + + it('finds anchor links', () => { + createEl('a', { href: '/test' }, 'Link') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('finds role=button elements', () => { + createEl('div', { role: 'button' }, 'Fake button') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('finds role=tab elements', () => { + createEl('div', { role: 'tab' }, 'Tab') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('finds role=treeitem elements', () => { + createEl('div', { role: 'treeitem' }, 'Tree item') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('finds role=menuitem elements', () => { + createEl('div', { role: 'menuitem' }, 'Menu item') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('finds role=option elements', () => { + createEl('div', { role: 'option' }, 'Option') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('finds tabindex elements (not -1)', () => { + createEl('div', { tabindex: '0' }, 'Focusable') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('excludes tabindex=-1', () => { + createEl('div', { tabindex: '-1' }, 'Not focusable') + const results = scanClickableElements() + expect(results).toHaveLength(0) + }) + + it('finds data-hint elements', () => { + createEl('span', { 'data-hint': '' }, 'Custom hint') + const results = scanClickableElements() + expect(results).toHaveLength(1) + }) + + it('excludes disabled elements', () => { + createEl('button', { disabled: '' }, 'Disabled') + const results = scanClickableElements() + expect(results).toHaveLength(0) + }) + + it('excludes aria-disabled elements', () => { + createEl('button', { 'aria-disabled': 'true' }, 'Disabled') + const results = scanClickableElements() + expect(results).toHaveLength(0) + }) + + it('excludes elements inside hint overlay', () => { + const overlay = document.createElement('div') + overlay.id = HINT_OVERLAY_ID + const btn = document.createElement('button') + btn.textContent = 'Inside overlay' + mockRect(btn) + mockOffsetParent(btn, overlay) + overlay.appendChild(btn) + document.body.appendChild(overlay) + + const results = scanClickableElements() + expect(results).toHaveLength(0) + }) + + it('returns empty array when no clickable elements exist', () => { + createEl('div', {}, 'Plain text') + const results = scanClickableElements() + expect(results).toHaveLength(0) + }) +}) diff --git a/apps/desktop/src/renderer/src/lib/dom-scanner.ts b/apps/desktop/src/renderer/src/lib/dom-scanner.ts new file mode 100644 index 000000000..d58190e4f --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/dom-scanner.ts @@ -0,0 +1,61 @@ +const CLICKABLE_SELECTOR = [ + 'button', + 'a[href]', + '[role="button"]', + '[role="tab"]', + '[role="treeitem"]', + '[role="menuitem"]', + '[role="option"]', + '[tabindex]:not([tabindex="-1"])', + '[data-hint]' +].join(', ') + +const HINT_OVERLAY_ID = 'hint-mode-overlay' + +const MIN_SIZE = 8 + +const isVisible = (el: HTMLElement): boolean => { + if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') return false + if (getComputedStyle(el).pointerEvents === 'none') return false + return true +} + +const isInViewport = (rect: DOMRect): boolean => { + return ( + rect.width >= MIN_SIZE && + rect.height >= MIN_SIZE && + rect.bottom > 0 && + rect.right > 0 && + rect.top < window.innerHeight && + rect.left < window.innerWidth + ) +} + +const isEnabled = (el: HTMLElement): boolean => { + if (el.hasAttribute('disabled')) return false + if (el.getAttribute('aria-disabled') === 'true') return false + return true +} + +const isInsideOverlay = (el: HTMLElement): boolean => { + const overlay = document.getElementById(HINT_OVERLAY_ID) + return overlay !== null && overlay.contains(el) +} + +export const scanClickableElements = (): HTMLElement[] => { + const candidates = document.querySelectorAll(CLICKABLE_SELECTOR) + const results: HTMLElement[] = [] + + for (const el of candidates) { + if (!isEnabled(el)) continue + if (isInsideOverlay(el)) continue + if (!isVisible(el)) continue + const rect = el.getBoundingClientRect() + if (!isInViewport(rect)) continue + results.push(el) + } + + return results +} + +export { HINT_OVERLAY_ID } From 0930bc0d695001271e2fa4e02c57ddd37ed413c2 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 18:59:54 +0300 Subject: [PATCH 03/18] feat(hint-mode): add mnemonic-first label assigner --- .../renderer/src/lib/label-assigner.test.ts | 134 ++++++++++++++++++ .../src/renderer/src/lib/label-assigner.ts | 121 ++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 apps/desktop/src/renderer/src/lib/label-assigner.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/label-assigner.ts diff --git a/apps/desktop/src/renderer/src/lib/label-assigner.test.ts b/apps/desktop/src/renderer/src/lib/label-assigner.test.ts new file mode 100644 index 000000000..3a5f8234b --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/label-assigner.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest' +import { assignLabels, extractElementText } from './label-assigner' + +const mockRect = (el: HTMLElement): void => { + el.getBoundingClientRect = () => + ({ + x: 0, + y: 0, + width: 100, + height: 30, + top: 0, + left: 0, + bottom: 30, + right: 100, + toJSON: () => ({}) + }) as DOMRect +} + +const mockElement = (text: string, ariaLabel?: string): HTMLElement => { + const el = document.createElement('button') + el.textContent = text + if (ariaLabel) el.setAttribute('aria-label', ariaLabel) + mockRect(el) + return el +} + +describe('extractElementText', () => { + it('returns textContent trimmed', () => { + const el = mockElement(' Hello World ') + expect(extractElementText(el)).toBe('Hello World') + }) + + it('prefers aria-label over textContent', () => { + const el = mockElement('X', 'Close dialog') + expect(extractElementText(el)).toBe('Close dialog') + }) + + it('falls back to title attribute', () => { + const el = document.createElement('button') + el.setAttribute('title', 'Settings') + expect(extractElementText(el)).toBe('Settings') + }) + + it('returns empty string when no text found', () => { + const el = document.createElement('button') + expect(extractElementText(el)).toBe('') + }) +}) + +describe('assignLabels', () => { + it('assigns single-char mnemonic for unique first letters', () => { + const elements = [mockElement('Inbox'), mockElement('Journal'), mockElement('Tasks')] + const hints = assignLabels(elements) + expect(hints.map((h) => h.label)).toEqual(['I', 'J', 'T']) + }) + + it('assigns two-char labels when first letters conflict', () => { + const elements = [mockElement('Tags'), mockElement('Tasks')] + const hints = assignLabels(elements) + expect(hints[0].label).toBe('TA') + expect(hints[1].label).toBe('TS') + }) + + it('falls back to sequential codes for three-way conflicts with same second letter', () => { + const elements = [mockElement('AA'), mockElement('AB'), mockElement('AA')] + const hints = assignLabels(elements) + const labels = hints.map((h) => h.label) + expect(labels).toHaveLength(3) + expect(new Set(labels).size).toBe(3) + }) + + it('assigns sequential codes for elements with no text', () => { + const el1 = document.createElement('button') + const el2 = document.createElement('button') + ;[el1, el2].forEach(mockRect) + const hints = assignLabels([el1, el2]) + expect(hints[0].label).toMatch(/^[A-Z]{2}$/) + expect(hints[1].label).toMatch(/^[A-Z]{2}$/) + expect(hints[0].label).not.toBe(hints[1].label) + }) + + it('avoids prefix collisions between single and two-char labels', () => { + const elements = [ + mockElement('Inbox'), + mockElement('Journal'), + mockElement(''), + mockElement('') + ] + const hints = assignLabels(elements) + const singleChars = hints.filter((h) => h.label.length === 1).map((h) => h.label) + const twoChars = hints.filter((h) => h.label.length === 2).map((h) => h.label) + + for (const sc of singleChars) { + for (const tc of twoChars) { + expect(tc.startsWith(sc)).toBe(false) + } + } + }) + + it('is case insensitive — labels are uppercase', () => { + const elements = [mockElement('inbox')] + const hints = assignLabels(elements) + expect(hints[0].label).toBe('I') + }) + + it('skips non-ASCII first letters and assigns sequential code', () => { + const elements = [mockElement('日本語')] + const hints = assignLabels(elements) + expect(hints[0].label).toMatch(/^[A-Z]{2}$/) + }) + + it('handles single element', () => { + const hints = assignLabels([mockElement('Only')]) + expect(hints).toHaveLength(1) + expect(hints[0].label).toBe('O') + }) + + it('handles empty input', () => { + const hints = assignLabels([]) + expect(hints).toHaveLength(0) + }) + + it('preserves element references in output', () => { + const el = mockElement('Test') + const hints = assignLabels([el]) + expect(hints[0].element).toBe(el) + }) + + it('includes rect in output', () => { + const hints = assignLabels([mockElement('Test')]) + expect(hints[0].rect).toBeDefined() + expect(hints[0].rect.width).toBe(100) + }) +}) diff --git a/apps/desktop/src/renderer/src/lib/label-assigner.ts b/apps/desktop/src/renderer/src/lib/label-assigner.ts new file mode 100644 index 000000000..dfcc8e63c --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/label-assigner.ts @@ -0,0 +1,121 @@ +import type { HintTarget } from '@/contexts/hint-mode/types' + +const ASCII_UPPER = /^[A-Z]$/ + +export const extractElementText = (el: HTMLElement): string => { + const ariaLabel = el.getAttribute('aria-label') + if (ariaLabel?.trim()) return ariaLabel.trim() + + const textContent = el.textContent?.trim() + if (textContent) return textContent + + const title = el.getAttribute('title') + if (title?.trim()) return title.trim() + + return '' +} + +const getFirstLetter = (text: string): string | null => { + const char = text.charAt(0).toUpperCase() + return ASCII_UPPER.test(char) ? char : null +} + +const asciiLettersAfter = (text: string): string[] => { + const letters: string[] = [] + const seen = new Set() + for (let i = 1; i < text.length; i++) { + const char = text.charAt(i).toUpperCase() + if (ASCII_UPPER.test(char) && !seen.has(char)) { + letters.push(char) + seen.add(char) + } + } + return letters +} + +const generateSequentialCode = ( + index: number, + usedLabels: Set, + singleCharLabels: Set +): string => { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + let seqIndex = 0 + for (let i = 0; i < 26; i++) { + for (let j = 0; j < 26; j++) { + const code = letters[i] + letters[j] + if (singleCharLabels.has(code[0])) continue + if (usedLabels.has(code)) continue + if (seqIndex === index) return code + seqIndex++ + } + } + return 'ZZ' +} + +export const assignLabels = (elements: HTMLElement[]): HintTarget[] => { + if (elements.length === 0) return [] + + const texts = elements.map((el) => extractElementText(el)) + const firstLetters = texts.map((t) => getFirstLetter(t)) + + const letterGroups = new Map() + const noLetterIndices: number[] = [] + + firstLetters.forEach((letter, i) => { + if (!letter) { + noLetterIndices.push(i) + return + } + const group = letterGroups.get(letter) ?? [] + group.push(i) + letterGroups.set(letter, group) + }) + + const labels = new Array(elements.length).fill('') + const singleCharLabels = new Set() + + for (const [letter, indices] of letterGroups) { + if (indices.length === 1) { + labels[indices[0]] = letter + singleCharLabels.add(letter) + } + } + + const usedLabels = new Set(singleCharLabels) + const needsSequential: number[] = [] + + for (const [letter, indices] of letterGroups) { + if (indices.length <= 1) continue + + for (const idx of indices) { + const candidates = asciiLettersAfter(texts[idx]) + let assigned = false + for (const char of candidates) { + const candidate = letter + char + if (!usedLabels.has(candidate)) { + labels[idx] = candidate + usedLabels.add(candidate) + assigned = true + break + } + } + if (!assigned) needsSequential.push(idx) + } + } + + const allNeedSequential = [...noLetterIndices, ...needsSequential].filter((i) => labels[i] === '') + let seqCounter = 0 + for (const idx of allNeedSequential) { + const code = generateSequentialCode(seqCounter, usedLabels, singleCharLabels) + labels[idx] = code + usedLabels.add(code) + seqCounter++ + } + + return elements.map((element, i) => ({ + element, + label: labels[i], + rect: element.getBoundingClientRect(), + text: texts[i] + })) +} From cfc9db5e7b2f832ae8ef9a41b2f5bd07ffce8fa8 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:01:24 +0300 Subject: [PATCH 04/18] feat(hint-mode): add HintModeProvider context with module-level ref --- .../src/contexts/hint-mode/context.tsx | 76 +++++++++++++++++++ .../renderer/src/contexts/hint-mode/index.ts | 2 + 2 files changed, 78 insertions(+) create mode 100644 apps/desktop/src/renderer/src/contexts/hint-mode/context.tsx create mode 100644 apps/desktop/src/renderer/src/contexts/hint-mode/index.ts diff --git a/apps/desktop/src/renderer/src/contexts/hint-mode/context.tsx b/apps/desktop/src/renderer/src/contexts/hint-mode/context.tsx new file mode 100644 index 000000000..efdd220d6 --- /dev/null +++ b/apps/desktop/src/renderer/src/contexts/hint-mode/context.tsx @@ -0,0 +1,76 @@ +import { createContext, useContext, useState, useCallback, useRef, type ReactNode } from 'react' +import type { HintModeState, HintModeContextType } from './types' +import { scanClickableElements } from '@/lib/dom-scanner' +import { assignLabels } from '@/lib/label-assigner' + +export const hintModeActiveRef: { current: boolean } = { current: false } + +const INITIAL_STATE: HintModeState = { + isActive: false, + hints: [], + typedChars: '' +} + +const HintModeContext = createContext(null) + +export const HintModeProvider = ({ children }: { children: ReactNode }): React.JSX.Element => { + const [state, setState] = useState(INITIAL_STATE) + const stateRef = useRef(state) + stateRef.current = state + + const deactivate = useCallback(() => { + hintModeActiveRef.current = false + setState(INITIAL_STATE) + }, []) + + const activate = useCallback(() => { + if (stateRef.current.isActive) { + deactivate() + return + } + + const elements = scanClickableElements() + if (elements.length === 0) return + + const hints = assignLabels(elements) + hintModeActiveRef.current = true + setState({ isActive: true, hints, typedChars: '' }) + }, [deactivate]) + + const typeChar = useCallback( + (char: string) => { + const upper = char.toUpperCase() + const next = stateRef.current.typedChars + upper + const matching = stateRef.current.hints.filter((h) => h.label.startsWith(next)) + + if (matching.length === 0) return + + if (matching.length === 1 && matching[0].label === next) { + matching[0].element.click() + matching[0].element.focus() + deactivate() + return + } + + setState((prev) => ({ ...prev, typedChars: next })) + }, + [deactivate] + ) + + const backspace = useCallback(() => { + setState((prev) => ({ + ...prev, + typedChars: prev.typedChars.slice(0, -1) + })) + }, []) + + const value: HintModeContextType = { state, activate, deactivate, typeChar, backspace } + + return {children} +} + +export const useHintModeContext = (): HintModeContextType => { + const ctx = useContext(HintModeContext) + if (!ctx) throw new Error('useHintModeContext must be inside HintModeProvider') + return ctx +} diff --git a/apps/desktop/src/renderer/src/contexts/hint-mode/index.ts b/apps/desktop/src/renderer/src/contexts/hint-mode/index.ts new file mode 100644 index 000000000..cc9f0cca3 --- /dev/null +++ b/apps/desktop/src/renderer/src/contexts/hint-mode/index.ts @@ -0,0 +1,2 @@ +export { HintModeProvider, useHintModeContext, hintModeActiveRef } from './context' +export type { HintTarget, HintModeState, HintModeContextType } from './types' From 05b08a6baaf9829dca7a3703d3398de7a7bddb1d Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:02:49 +0300 Subject: [PATCH 05/18] feat(hint-mode): suspend shortcuts when hint mode is active --- apps/desktop/src/renderer/src/hooks/use-chord-shortcuts.ts | 3 +++ .../src/renderer/src/hooks/use-keyboard-shortcuts-base.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/apps/desktop/src/renderer/src/hooks/use-chord-shortcuts.ts b/apps/desktop/src/renderer/src/hooks/use-chord-shortcuts.ts index 8fef16847..49b8a870a 100644 --- a/apps/desktop/src/renderer/src/hooks/use-chord-shortcuts.ts +++ b/apps/desktop/src/renderer/src/hooks/use-chord-shortcuts.ts @@ -7,6 +7,7 @@ import { useState, useEffect, useCallback } from 'react' import { useTabs } from '@/contexts/tabs' import { isMac } from './use-keyboard-shortcuts-base' import { calculateGroupPositions, type GroupPosition } from './use-pane-navigation' +import { hintModeActiveRef } from '@/contexts/hint-mode' // ============================================================================= // TYPES @@ -204,6 +205,8 @@ export const useChordShortcuts = (): boolean => { // Event listener for chord keys useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (hintModeActiveRef.current) return + const metaOrCtrl = isMac ? e.metaKey : e.ctrlKey // Ignore if typing in input diff --git a/apps/desktop/src/renderer/src/hooks/use-keyboard-shortcuts-base.ts b/apps/desktop/src/renderer/src/hooks/use-keyboard-shortcuts-base.ts index d34adebb4..d7c6bedb6 100644 --- a/apps/desktop/src/renderer/src/hooks/use-keyboard-shortcuts-base.ts +++ b/apps/desktop/src/renderer/src/hooks/use-keyboard-shortcuts-base.ts @@ -4,6 +4,7 @@ */ import { useEffect, useCallback, useMemo } from 'react' +import { hintModeActiveRef } from '@/contexts/hint-mode' // ============================================================================= // TYPES @@ -71,6 +72,8 @@ export const useKeyboardShortcuts = (shortcuts: KeyboardShortcut[]): void => { const handleKeyDown = useCallback( (e: KeyboardEvent) => { + if (hintModeActiveRef.current) return + const target = e.target as HTMLElement // Check if typing in input/textarea From ac168d107a8600ed218728421af9bb6971fc2435 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:03:18 +0300 Subject: [PATCH 06/18] feat(hint-mode): add activation hook with F / Alt+F / Esc handling --- .../renderer/src/hooks/use-hint-activation.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/desktop/src/renderer/src/hooks/use-hint-activation.ts diff --git a/apps/desktop/src/renderer/src/hooks/use-hint-activation.ts b/apps/desktop/src/renderer/src/hooks/use-hint-activation.ts new file mode 100644 index 000000000..211ee923a --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-hint-activation.ts @@ -0,0 +1,66 @@ +import { useEffect } from 'react' +import { useHintModeContext } from '@/contexts/hint-mode' + +const isEditorFocused = (): boolean => { + const el = document.activeElement as HTMLElement | null + if (!el) return false + return el.isContentEditable || el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' +} + +export const useHintActivation = (): void => { + const { state, activate, deactivate, typeChar, backspace } = useHintModeContext() + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (state.isActive) { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + deactivate() + return + } + + if (e.key === 'Backspace') { + e.preventDefault() + e.stopPropagation() + backspace() + return + } + + if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault() + e.stopPropagation() + typeChar(e.key) + return + } + + e.preventDefault() + e.stopPropagation() + return + } + + if (e.key === 'f' && e.altKey && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + e.stopPropagation() + activate() + return + } + + if (e.key === 'f' && !e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + if (!isEditorFocused()) { + e.preventDefault() + e.stopPropagation() + activate() + return + } + } + + if (e.key === 'Escape' && isEditorFocused()) { + ;(document.activeElement as HTMLElement)?.blur() + } + } + + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) + }, [state.isActive, activate, deactivate, typeChar, backspace]) +} From fabc0a0763f701f7a4274d7d828a61aa8cbcbfa2 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:03:47 +0300 Subject: [PATCH 07/18] feat(hint-mode): add HintBadge component with narrowing feedback --- .../components/hint-overlay/hint-badge.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx diff --git a/apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx b/apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx new file mode 100644 index 000000000..63704a351 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx @@ -0,0 +1,41 @@ +import type { HintTarget } from '@/contexts/hint-mode' + +interface HintBadgeProps { + hint: HintTarget + typedChars: string +} + +export const HintBadge = ({ hint, typedChars }: HintBadgeProps): React.JSX.Element => { + const isMatching = hint.label.startsWith(typedChars) + const matchedLength = typedChars.length + + return ( + + {hint.label.split('').map((char, i) => ( + + {char} + + ))} + + ) +} From a8243eaeac2f0eed0dec9312bd301e9010d2087b Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:04:23 +0300 Subject: [PATCH 08/18] feat(hint-mode): add HintOverlay portal rendering positioned badges --- .../components/hint-overlay/hint-overlay.tsx | 27 +++++++++++++++++++ .../src/components/hint-overlay/index.ts | 2 ++ 2 files changed, 29 insertions(+) create mode 100644 apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx create mode 100644 apps/desktop/src/renderer/src/components/hint-overlay/index.ts diff --git a/apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx b/apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx new file mode 100644 index 000000000..f55d0c3d9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx @@ -0,0 +1,27 @@ +import { createPortal } from 'react-dom' +import { useHintModeContext } from '@/contexts/hint-mode' +import { HINT_OVERLAY_ID } from '@/lib/dom-scanner' +import { HintBadge } from './hint-badge' + +export const HintOverlay = (): React.JSX.Element | null => { + const { state } = useHintModeContext() + + if (!state.isActive) return null + + return createPortal( +
+ {state.hints.map((hint, i) => ( + + ))} +
, + document.body + ) +} diff --git a/apps/desktop/src/renderer/src/components/hint-overlay/index.ts b/apps/desktop/src/renderer/src/components/hint-overlay/index.ts new file mode 100644 index 000000000..2464f82c9 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/hint-overlay/index.ts @@ -0,0 +1,2 @@ +export { HintOverlay } from './hint-overlay' +export { HintBadge } from './hint-badge' From c77c7c591c3b7501594268f9da217d419079de5a Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:05:05 +0300 Subject: [PATCH 09/18] feat(hint-mode): add HINT status indicator --- .../hint-overlay/hint-indicator.tsx | 26 +++++++++++++++++++ .../src/components/hint-overlay/index.ts | 1 + 2 files changed, 27 insertions(+) create mode 100644 apps/desktop/src/renderer/src/components/hint-overlay/hint-indicator.tsx diff --git a/apps/desktop/src/renderer/src/components/hint-overlay/hint-indicator.tsx b/apps/desktop/src/renderer/src/components/hint-overlay/hint-indicator.tsx new file mode 100644 index 000000000..a60a4becb --- /dev/null +++ b/apps/desktop/src/renderer/src/components/hint-overlay/hint-indicator.tsx @@ -0,0 +1,26 @@ +import { useHintModeContext } from '@/contexts/hint-mode' +import { cn } from '@/lib/utils' + +export const HintIndicator = (): React.JSX.Element | null => { + const { state } = useHintModeContext() + + if (!state.isActive) return null + + return ( +
+
+ HINT + {state.typedChars && ( + {state.typedChars} + )} +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/components/hint-overlay/index.ts b/apps/desktop/src/renderer/src/components/hint-overlay/index.ts index 2464f82c9..ad35d613e 100644 --- a/apps/desktop/src/renderer/src/components/hint-overlay/index.ts +++ b/apps/desktop/src/renderer/src/components/hint-overlay/index.ts @@ -1,2 +1,3 @@ export { HintOverlay } from './hint-overlay' export { HintBadge } from './hint-badge' +export { HintIndicator } from './hint-indicator' From 9609703971bba3b39224732e138124f5fad4b448 Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:07:02 +0300 Subject: [PATCH 10/18] feat(hint-mode): wire HintModeProvider and overlay into App --- apps/desktop/src/renderer/src/App.tsx | 42 ++++++++++++-------- apps/desktop/src/renderer/src/hooks/index.ts | 3 ++ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 3b224d27d..9e7aa8e68 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -34,8 +34,11 @@ import { useNewNoteShortcut, useUndoKeyboardShortcut, useReminderNotifications, - useSearchShortcut + useSearchShortcut, + useHintActivation } from '@/hooks' +import { HintModeProvider } from '@/contexts/hint-mode' +import { HintOverlay, HintIndicator } from '@/components/hint-overlay' import { CommandPalette } from '@/components/search/command-palette' import { SettingsModalProvider, useSettingsModal } from '@/contexts/settings-modal-context' import { SettingsModal } from '@/components/settings-modal' @@ -141,6 +144,7 @@ const AppContent = (): React.JSX.Element => { useFolderViewEvents() // Global cache invalidation for folder-view tabs const toggleSearch = useCallback(() => setSearchOpen((prev) => !prev), []) useSearchShortcut(toggleSearch) + useHintActivation() useEffect(() => { const openSearch = () => setSearchOpen(true) @@ -189,6 +193,10 @@ const AppContent = (): React.JSX.Element => { {/* Chord Indicator */} + {/* Hint Mode Overlay + Indicator */} + + + {/* Keyboard Shortcuts Dialog */} - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/src/renderer/src/hooks/index.ts b/apps/desktop/src/renderer/src/hooks/index.ts index d61ca3b49..6cca5506d 100644 --- a/apps/desktop/src/renderer/src/hooks/index.ts +++ b/apps/desktop/src/renderer/src/hooks/index.ts @@ -54,3 +54,6 @@ export * from './use-sync-status' // Search export * from './use-search-shortcut' export * from './use-search' + +// Hint mode +export * from './use-hint-activation' From e3d7124acda495a63997c7fe99d9f91388ba0cbc Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:07:54 +0300 Subject: [PATCH 11/18] test(hint-mode): add integration tests for HintModeProvider --- .../src/hooks/use-hint-activation.test.tsx | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 apps/desktop/src/renderer/src/hooks/use-hint-activation.test.tsx diff --git a/apps/desktop/src/renderer/src/hooks/use-hint-activation.test.tsx b/apps/desktop/src/renderer/src/hooks/use-hint-activation.test.tsx new file mode 100644 index 000000000..e433c7430 --- /dev/null +++ b/apps/desktop/src/renderer/src/hooks/use-hint-activation.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { type ReactNode } from 'react' +import { HintModeProvider, useHintModeContext } from '@/contexts/hint-mode' + +const wrapper = ({ children }: { children: ReactNode }): React.JSX.Element => ( + {children} +) + +const mockRect: DOMRect = { + x: 10, + y: 10, + width: 100, + height: 30, + top: 10, + left: 10, + bottom: 40, + right: 110, + toJSON: () => ({}) +} as DOMRect + +const addButton = (text: string): HTMLButtonElement => { + const btn = document.createElement('button') + btn.textContent = text + btn.getBoundingClientRect = () => mockRect + Object.defineProperty(btn, 'offsetParent', { value: document.body, configurable: true }) + document.body.appendChild(btn) + return btn +} + +describe('HintModeProvider', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + afterEach(() => { + document.body.innerHTML = '' + }) + + it('activate scans DOM and assigns labels', () => { + addButton('Inbox') + addButton('Journal') + + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + + expect(result.current.state.isActive).toBe(true) + expect(result.current.state.hints).toHaveLength(2) + expect(result.current.state.hints[0].label).toBe('I') + expect(result.current.state.hints[1].label).toBe('J') + }) + + it('deactivate resets state', () => { + addButton('Test') + + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + expect(result.current.state.isActive).toBe(true) + + act(() => result.current.deactivate()) + expect(result.current.state.isActive).toBe(false) + expect(result.current.state.hints).toHaveLength(0) + }) + + it('typeChar narrows matches', () => { + addButton('Tags') + addButton('Tasks') + + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + expect(result.current.state.hints[0].label).toBe('TA') + expect(result.current.state.hints[1].label).toBe('TS') + + act(() => result.current.typeChar('T')) + expect(result.current.state.typedChars).toBe('T') + }) + + it('typeChar triggers click on unique match', () => { + const btn = addButton('Inbox') + const clickSpy = vi.spyOn(btn, 'click') + + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + act(() => result.current.typeChar('I')) + + expect(clickSpy).toHaveBeenCalledOnce() + expect(result.current.state.isActive).toBe(false) + }) + + it('backspace removes last typed char', () => { + addButton('Tags') + addButton('Tasks') + + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + act(() => result.current.typeChar('T')) + expect(result.current.state.typedChars).toBe('T') + + act(() => result.current.backspace()) + expect(result.current.state.typedChars).toBe('') + }) + + it('double activate toggles off', () => { + addButton('Test') + + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + expect(result.current.state.isActive).toBe(true) + + act(() => result.current.activate()) + expect(result.current.state.isActive).toBe(false) + }) + + it('activate with no clickable elements is a no-op', () => { + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + expect(result.current.state.isActive).toBe(false) + }) + + it('ignores non-matching typeChar', () => { + addButton('Inbox') + + const { result } = renderHook(() => useHintModeContext(), { wrapper }) + + act(() => result.current.activate()) + act(() => result.current.typeChar('Z')) + + expect(result.current.state.typedChars).toBe('') + expect(result.current.state.isActive).toBe(true) + }) +}) From 1104007fa8d6055bf2dae6957410b07fd3b5d4fe Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:23:25 +0300 Subject: [PATCH 12/18] fix(hint-mode): use e.code for Alt+F on macOS + clamp badge position --- .../src/renderer/src/components/hint-overlay/hint-badge.tsx | 4 ++-- apps/desktop/src/renderer/src/hooks/use-hint-activation.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx b/apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx index 63704a351..cbf905253 100644 --- a/apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx +++ b/apps/desktop/src/renderer/src/components/hint-overlay/hint-badge.tsx @@ -13,8 +13,8 @@ export const HintBadge = ({ hint, typedChars }: HintBadgeProps): React.JSX.Eleme { return } - if (e.key === 'f' && e.altKey && !e.metaKey && !e.ctrlKey) { + if (e.code === 'KeyF' && e.altKey && !e.metaKey && !e.ctrlKey) { e.preventDefault() e.stopPropagation() activate() return } - if (e.key === 'f' && !e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey) { + if (e.code === 'KeyF' && !e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey) { if (!isEditorFocused()) { e.preventDefault() e.stopPropagation() From 13cdd2c48771f73e52d9bb661c60b3bd777ab64a Mon Sep 17 00:00:00 2001 From: Kaan Karaca Date: Tue, 14 Apr 2026 19:28:09 +0300 Subject: [PATCH 13/18] fix(hint-mode): address adversarial review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard against IME composition eating CJK input - detect BlockNote/ProseMirror editors via closest(), not activeElement - drop wholesale key-swallowing — let DevTools/reload/menu accelerators through - guard focus() with document.contains() after click - aria-hidden=true on overlay for screen readers - reset hintModeActiveRef on provider unmount - filter out excess labels past 676-element ceiling instead of returning duplicate ZZ --- .../components/hint-overlay/hint-overlay.tsx | 1 + .../src/contexts/hint-mode/context.tsx | 21 ++++++++++++++++--- .../renderer/src/hooks/use-hint-activation.ts | 18 +++++++++------- .../src/renderer/src/lib/label-assigner.ts | 19 ++++++++++------- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx b/apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx index f55d0c3d9..4b7014685 100644 --- a/apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx +++ b/apps/desktop/src/renderer/src/components/hint-overlay/hint-overlay.tsx @@ -11,6 +11,7 @@ export const HintOverlay = (): React.JSX.Element | null => { return createPortal(