diff --git a/docs/superpowers/plans/2026-04-23-outlook-relook.md b/docs/superpowers/plans/2026-04-23-outlook-relook.md index 26cfa4c..48e36ab 100644 --- a/docs/superpowers/plans/2026-04-23-outlook-relook.md +++ b/docs/superpowers/plans/2026-04-23-outlook-relook.md @@ -23,6 +23,7 @@ outlook-relook/ │ ├── observer.js # MutationObserver: suppress elements based on active settings │ ├── selectors.js # Selector registry: logical name → primary + fallback selectors │ ├── behavior.js # JS behavior patches (auto-advance, hover suppress, etc.) +│ ├── keyboard.js # Gmail-style keyboard navigation & multi-select │ └── injector.js # DOM injection (mark-all-read button, folder jump dialog) ├── popup/ │ ├── popup.html # Settings panel markup @@ -68,6 +69,7 @@ outlook-relook/ "content/selectors.js", "content/observer.js", "content/behavior.js", + "content/keyboard.js", "content/injector.js", "content/content.js" ], @@ -241,6 +243,9 @@ window.OutlookRelook.DEFAULTS = { autoResizeCompose: true, throttleNotifications: false, + // Keyboard Navigation + keyboardMultiSelect: true, + // Quick Actions markAllReadButton: true, quickFolderJump: true, @@ -2722,6 +2727,20 @@ body::-webkit-scrollbar-thumb { + +
+
Keyboard Navigation
+
+
+ +
+
+
+ j/k or arrows to navigate, x/Space to select, # delete, e archive, Shift+i read, Shift+u unread, v move, Esc deselect +
+
+
+
Quick Actions
@@ -2827,6 +2846,7 @@ Handles loading settings into the UI, saving changes on toggle, density preset b stickyReplyBar: true, autoResizeCompose: true, throttleNotifications: false, + keyboardMultiSelect: true, markAllReadButton: true, quickFolderJump: true, }; @@ -3214,6 +3234,7 @@ A Chrome extension that reskins Outlook Web App (outlook.office.com) with minima - **~40 granular toggles** for density, element hiding, readability, and behavior - **MutationObserver** suppresses dynamically injected clutter (Copilot, banners, suggested replies) - **Behavior patches:** auto-collapse ribbon, suppress contact hover cards, auto-advance after delete, toast management +- **Gmail-style keyboard navigation:** j/k to move, x/Space to multi-select, # delete, e archive, Shift+i/u read/unread, v move - **Quick actions:** "Mark all as read" button, Ctrl+Shift+K folder jump - **Settings sync** across Chrome instances via chrome.storage.sync - **Export/Import/Reset** settings @@ -3232,6 +3253,20 @@ Click the extension icon to open the settings panel. Changes apply immediately ### Keyboard Shortcuts +**Message list (Gmail-style):** +- `j` / `Down Arrow` — Next message +- `k` / `Up Arrow` — Previous message +- `x` / `Space` — Toggle select +- `Shift+Down` / `Shift+Up` — Extend selection +- `#` — Delete selected +- `e` — Archive selected +- `Shift+i` — Mark read +- `Shift+u` — Mark unread +- `v` — Move to folder +- `Escape` — Deselect all +- `Enter` / `o` — Open message + +**Global:** - `Ctrl+Shift+K` (or `Cmd+Shift+K` on Mac) — Quick folder jump ## Development @@ -3287,10 +3322,589 @@ git commit -m "docs: README with install, usage, and development guide" --- +### Task 14: Gmail-Style Keyboard Navigation & Multi-Select + +**Files:** +- Create: `content/keyboard.js` +- Modify: `content/content.js` (wire up Keyboard module) +- Modify: `themes/base.css` (add focus/selected styles) + +This is the most complex single feature. It adds a custom focus cursor and multi-select system to OWA's message list, with Gmail-style keyboard shortcuts for bulk actions. + +- [ ] **Step 1: Add keyboard focus/select CSS to base.css** + +Append the following to the end of `themes/base.css`: + +```css +/* ============================================================ + KEYBOARD NAVIGATION + ============================================================ */ + +/* Focus cursor — the message the keyboard is currently pointing at */ +.or-kb-focused { + outline: 2px solid var(--or-accent, #0078d4) !important; + outline-offset: -2px; + position: relative; + z-index: 1; +} + +/* Selected/checked messages */ +.or-kb-selected { + background-color: rgba(0, 120, 212, 0.08) !important; +} + +html[data-outlook-relook-scheme="dark"] .or-kb-selected { + background-color: rgba(100, 181, 246, 0.12) !important; +} + +/* Selection indicator — small left bar */ +.or-kb-selected::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--or-accent, #0078d4); +} + +/* Selection count badge (injected by keyboard.js) */ +.or-kb-selection-count { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: var(--or-bg-primary, #333); + color: var(--or-text-primary, #fff); + border: 1px solid var(--or-border, #555); + padding: 6px 16px; + border-radius: 20px; + font-size: 13px; + font-family: system-ui, sans-serif; + z-index: 999998; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + pointer-events: none; + transition: opacity 0.15s; +} +``` + +- [ ] **Step 2: Create keyboard.js** + +```js +// Outlook Relook — Gmail-Style Keyboard Navigation & Multi-Select +// Adds keyboard focus cursor and multi-select to OWA's message list. +// Gated by the keyboardMultiSelect setting. + +window.OutlookRelook = window.OutlookRelook || {}; + +window.OutlookRelook.Keyboard = (function () { + 'use strict'; + + var OR = window.OutlookRelook; + var currentSettings = {}; + var cleanupFns = []; + + // State + var focusedIndex = -1; // Index of the focused message in the list + var selectedSet = new Set(); // Set of selected message DOM elements + var countBadge = null; // Selection count badge element + + // --- Helpers --- + + function getMessageItems() { + // Get all message items from the message list + var items = document.querySelectorAll( + '[role="listbox"] [role="option"], [role="list"] [role="listitem"]' + ); + return Array.from(items); + } + + function isComposeOrDialogActive() { + var active = document.activeElement; + if (!active) return false; + + // Check if focus is in a compose area, search bar, or dialog + var tag = active.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea') return true; + if (active.getAttribute('contenteditable') === 'true') return true; + if (active.closest('[role="dialog"]')) return true; + if (active.closest('[role="search"]')) return true; + if (active.closest('[aria-label*="compose" i]')) return true; + if (active.closest('[aria-label*="New message" i]')) return true; + + return false; + } + + function setFocus(items, index) { + // Remove old focus + var oldFocused = document.querySelector('.or-kb-focused'); + if (oldFocused) oldFocused.classList.remove('or-kb-focused'); + + if (index < 0 || index >= items.length) return; + + focusedIndex = index; + var item = items[index]; + item.classList.add('or-kb-focused'); + + // Scroll into view if needed + item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + + function toggleSelect(item) { + if (selectedSet.has(item)) { + selectedSet.delete(item); + item.classList.remove('or-kb-selected'); + } else { + selectedSet.add(item); + item.classList.add('or-kb-selected'); + } + updateCountBadge(); + } + + function clearSelection() { + selectedSet.forEach(function (item) { + item.classList.remove('or-kb-selected'); + }); + selectedSet.clear(); + updateCountBadge(); + } + + function getActionTargets(items) { + // If messages are selected, return those. Otherwise return the focused message. + if (selectedSet.size > 0) { + return Array.from(selectedSet); + } + if (focusedIndex >= 0 && focusedIndex < items.length) { + return [items[focusedIndex]]; + } + return []; + } + + // --- Selection count badge --- + + function createCountBadge() { + var badge = document.createElement('div'); + badge.className = 'or-kb-selection-count'; + badge.style.opacity = '0'; + document.body.appendChild(badge); + return badge; + } + + function updateCountBadge() { + if (!countBadge) countBadge = createCountBadge(); + var count = selectedSet.size; + if (count === 0) { + countBadge.style.opacity = '0'; + } else { + countBadge.textContent = count + ' selected'; + countBadge.style.opacity = '1'; + } + } + + // --- Actions --- + // Each action simulates what a user would do in OWA to perform the operation. + // We click the message to select it in OWA, then trigger the toolbar action. + + function performAction(targets, actionFn) { + if (targets.length === 0) return; + + // Process targets one at a time with a small delay between each + var i = 0; + function processNext() { + if (i >= targets.length) { + // After all targets processed, clear selection + clearSelection(); + return; + } + var target = targets[i]; + i++; + + // Click the message to make it OWA-selected + target.click(); + + // Small delay to let OWA register the selection, then perform action + setTimeout(function () { + actionFn(target); + // Delay before next target + setTimeout(processNext, 150); + }, 100); + } + processNext(); + } + + function actionDelete(targets) { + performAction(targets, function () { + // Try keyboard shortcut first (Delete key) + var deleteEvent = new KeyboardEvent('keydown', { + key: 'Delete', + code: 'Delete', + bubbles: true, + cancelable: true + }); + document.activeElement.dispatchEvent(deleteEvent); + + // Fallback: find and click the delete button in the toolbar + var deleteBtn = document.querySelector( + '[aria-label*="Delete" i][role="button"], [aria-label*="delete" i][role="menuitem"]' + ); + if (deleteBtn) deleteBtn.click(); + }); + } + + function actionArchive(targets) { + performAction(targets, function () { + var archiveBtn = document.querySelector( + '[aria-label*="Archive" i][role="button"], [aria-label*="archive" i][role="menuitem"]' + ); + if (archiveBtn) { + archiveBtn.click(); + } else { + console.warn('[Outlook Relook] Archive button not found'); + } + }); + } + + function actionMarkRead(targets) { + performAction(targets, function () { + var readBtn = document.querySelector( + '[aria-label*="Mark as read" i][role="button"], [aria-label*="Mark as read" i][role="menuitem"]' + ); + if (readBtn) { + readBtn.click(); + } else { + // Try context menu approach + triggerContextMenuAction(/mark as read/i); + } + }); + } + + function actionMarkUnread(targets) { + performAction(targets, function () { + var unreadBtn = document.querySelector( + '[aria-label*="Mark as unread" i][role="button"], [aria-label*="Mark as unread" i][role="menuitem"]' + ); + if (unreadBtn) { + unreadBtn.click(); + } else { + triggerContextMenuAction(/mark as unread/i); + } + }); + } + + function actionMove(targets) { + // For move, we just need to trigger OWA's move dialog on the current selection + if (targets.length > 0) { + // Click the first target to ensure something is selected + targets[0].click(); + + setTimeout(function () { + var moveBtn = document.querySelector( + '[aria-label*="Move to" i][role="button"], [aria-label*="Move" i][role="menuitem"]' + ); + if (moveBtn) { + moveBtn.click(); + } else { + triggerContextMenuAction(/move to/i); + } + }, 100); + } + } + + function triggerContextMenuAction(pattern) { + // Open context menu on the focused/selected element + var focused = document.querySelector('.or-kb-focused'); + if (!focused) return; + + var rect = focused.getBoundingClientRect(); + var contextEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: rect.x + 10, + clientY: rect.y + 10, + }); + focused.dispatchEvent(contextEvent); + + setTimeout(function () { + var menuItems = document.querySelectorAll('[role="menuitem"]'); + for (var j = 0; j < menuItems.length; j++) { + if (pattern.test(menuItems[j].textContent)) { + menuItems[j].click(); + return; + } + } + // Close context menu if not found + document.body.click(); + }, 300); + } + + // --- Key handler --- + + function handleKeydown(e) { + // Skip if keyboard mode is off or compose/dialog is active + if (!currentSettings.keyboardMultiSelect) return; + if (isComposeOrDialogActive()) return; + + var items = getMessageItems(); + if (items.length === 0) return; + + var key = e.key; + var shift = e.shiftKey; + + // Initialize focus if not set + if (focusedIndex < 0 || focusedIndex >= items.length) { + // Find the currently OWA-selected item, or start at 0 + for (var f = 0; f < items.length; f++) { + if (items[f].getAttribute('aria-selected') === 'true') { + focusedIndex = f; + break; + } + } + if (focusedIndex < 0) focusedIndex = 0; + } + + var handled = true; + var targets; + + switch (key) { + case 'j': + case 'ArrowDown': + if (shift) { + // Select current and move down + if (focusedIndex >= 0 && focusedIndex < items.length) { + if (!selectedSet.has(items[focusedIndex])) { + toggleSelect(items[focusedIndex]); + } + } + if (focusedIndex < items.length - 1) { + setFocus(items, focusedIndex + 1); + toggleSelect(items[focusedIndex]); + } + } else { + if (focusedIndex < items.length - 1) { + setFocus(items, focusedIndex + 1); + } + } + break; + + case 'k': + case 'ArrowUp': + if (shift) { + // Select current and move up + if (focusedIndex >= 0 && focusedIndex < items.length) { + if (!selectedSet.has(items[focusedIndex])) { + toggleSelect(items[focusedIndex]); + } + } + if (focusedIndex > 0) { + setFocus(items, focusedIndex - 1); + toggleSelect(items[focusedIndex]); + } + } else { + if (focusedIndex > 0) { + setFocus(items, focusedIndex - 1); + } + } + break; + + case 'x': + case ' ': + // Toggle select on focused message + e.preventDefault(); // Prevent page scroll on Space + if (focusedIndex >= 0 && focusedIndex < items.length) { + toggleSelect(items[focusedIndex]); + } + break; + + case '#': + // Delete selected/focused messages + targets = getActionTargets(items); + actionDelete(targets); + break; + + case 'e': + // Archive selected/focused messages + targets = getActionTargets(items); + actionArchive(targets); + break; + + case 'I': + // Shift+i — Mark as read + if (shift) { + targets = getActionTargets(items); + actionMarkRead(targets); + } else { + handled = false; + } + break; + + case 'U': + // Shift+u — Mark as unread + if (shift) { + targets = getActionTargets(items); + actionMarkUnread(targets); + } else { + handled = false; + } + break; + + case 'v': + // Move selected messages + targets = getActionTargets(items); + actionMove(targets); + break; + + case 'Escape': + // Deselect all + clearSelection(); + break; + + case 'Enter': + case 'o': + // Open focused message in reading pane + if (focusedIndex >= 0 && focusedIndex < items.length) { + items[focusedIndex].click(); + } + break; + + default: + handled = false; + } + + if (handled) { + e.stopPropagation(); + // Only preventDefault for keys we handle (except Enter which OWA should also process) + if (key !== 'Enter' && key !== 'o') { + e.preventDefault(); + } + } + } + + // --- Cleanup stale selections when message list re-renders --- + + function setupListObserver() { + var listObserver = new MutationObserver(function () { + // Remove stale entries from selectedSet (elements no longer in DOM) + selectedSet.forEach(function (item) { + if (!document.contains(item)) { + selectedSet.delete(item); + } + }); + updateCountBadge(); + + // Reset focusedIndex if the focused item is gone + var focusedEl = document.querySelector('.or-kb-focused'); + if (!focusedEl || !document.contains(focusedEl)) { + focusedIndex = -1; + } + }); + + // Watch the message list container for child changes + var checkInterval = setInterval(function () { + var list = document.querySelector('[role="listbox"], [role="list"]'); + if (list) { + clearInterval(checkInterval); + listObserver.observe(list, { childList: true, subtree: true }); + } + }, 1000); + + cleanupFns.push(function () { + clearInterval(checkInterval); + listObserver.disconnect(); + }); + } + + // --- Public API --- + + function start(settings) { + currentSettings = settings; + if (!settings.keyboardMultiSelect) return; + + document.addEventListener('keydown', handleKeydown, true); + setupListObserver(); + + console.log('[Outlook Relook] Keyboard navigation started'); + cleanupFns.push(function () { + document.removeEventListener('keydown', handleKeydown, true); + }); + } + + function updateSettings(settings) { + stop(); + currentSettings = settings; + start(settings); + } + + function stop() { + for (var i = 0; i < cleanupFns.length; i++) { + try { cleanupFns[i](); } catch (e) { /* ignore */ } + } + cleanupFns = []; + + // Clean up DOM state + clearSelection(); + var focused = document.querySelector('.or-kb-focused'); + if (focused) focused.classList.remove('or-kb-focused'); + focusedIndex = -1; + + if (countBadge) { + countBadge.remove(); + countBadge = null; + } + } + + return { start: start, updateSettings: updateSettings, stop: stop }; +})(); +``` + +- [ ] **Step 3: Wire keyboard into content.js** + +In `content/content.js`, inside `init()`, after `OR.Behavior.start(settings)` and before `OR.Injector.start(settings)`, add: + +```js + // Start keyboard navigation + OR.Keyboard.start(settings); +``` + +Inside the `chrome.storage.onChanged` listener, after `OR.Behavior.updateSettings(updated)`, add: + +```js + // Update keyboard navigation + OR.Keyboard.updateSettings(updated); +``` + +- [ ] **Step 4: Verify keyboard navigation** + +1. Reload extension +2. Open `outlook.office.com` +3. Verify in console: `[Outlook Relook] Keyboard navigation started` +4. Click on the message list area, then: + - Press `j` — focus cursor (blue outline) should move to the next message + - Press `k` — focus cursor should move to the previous message + - Press `x` — focused message should get a selected highlight (light blue background + left accent bar) + - Press `j`, `x`, `j`, `x` — three messages should now be selected, badge shows "3 selected" + - Press `#` — all selected messages should be deleted + - Select two messages with `x`, press `e` — should archive them + - Press `Shift+i` on a message — should mark as read + - Press `Shift+u` on a message — should mark as unread + - Press `v` — OWA's "Move to" dialog should open + - Press `Escape` — all selections should clear +5. Start typing in the search bar — keyboard shortcuts should NOT fire +6. Open a compose window — keyboard shortcuts should NOT fire +7. Toggle the setting off in the popup — keyboard navigation should stop + +- [ ] **Step 5: Commit** + +```bash +git add content/keyboard.js content/content.js themes/base.css +git commit -m "feat: Gmail-style keyboard navigation and multi-select for message list" +``` + +--- + ## Self-Review Results **Spec coverage:** All spec sections are covered: -- Architecture (Tasks 1-2), Theme System (Tasks 4-6), Selector Strategy (Task 3), Settings Panel (Tasks 10-11), all toggle categories (distributed across Tasks 4, 7, 8, 9), File Structure (Task 1), Development & Testing (Task 12-13). Verified each spec requirement maps to a task. +- Architecture (Tasks 1-2), Theme System (Tasks 4-6), Selector Strategy (Task 3), Settings Panel (Tasks 10-11), all toggle categories (distributed across Tasks 4, 7, 8, 9), Keyboard Navigation (Task 14), File Structure (Task 1), Development & Testing (Task 12-13). Verified each spec requirement maps to a task. **Placeholder scan:** No TBDs or TODOs. All code blocks are complete. OWA-specific selectors are noted with a clear strategy (inspect live DOM) rather than left as placeholders. @@ -3298,6 +3912,7 @@ git commit -m "docs: README with install, usage, and development guide" - `window.OutlookRelook` namespace used consistently - `OR.Observer.start/updateSettings/stop` API matches between `observer.js` (Task 7) and `content.js` (Task 7 wiring) - `OR.Behavior.start/updateSettings/stop` API matches between `behavior.js` (Task 8) and `content.js` (Task 8 wiring) +- `OR.Keyboard.start/updateSettings/stop` API matches between `keyboard.js` (Task 14) and `content.js` (Task 14 wiring) - `OR.Injector.start/updateSettings/stop` API matches between `injector.js` (Task 9) and `content.js` (Task 9 wiring) - `OR.loadSettings`, `OR.saveSettings`, `OR.DEFAULTS`, `OR.DENSITY_PRESETS` match between `settings-defaults.js` (Task 2) and `popup.js` (Task 11, duplicated for popup context) - `OR.resolveSelector`, `OR.resolveSelectorString` match between `selectors.js` (Task 3) and consumers in `observer.js`, `behavior.js`, `injector.js` diff --git a/docs/superpowers/specs/2026-04-23-outlook-relook-design.md b/docs/superpowers/specs/2026-04-23-outlook-relook-design.md index 58fdfab..7c01d2b 100644 --- a/docs/superpowers/specs/2026-04-23-outlook-relook-design.md +++ b/docs/superpowers/specs/2026-04-23-outlook-relook-design.md @@ -175,6 +175,40 @@ Changing the density preset sets all individual density toggles to the preset's | Auto-resize compose window | Boolean | On | | Throttle desktop notifications | Boolean | Off | +### Keyboard Navigation + +Gmail-style keyboard navigation and multi-select for the message list. OWA lacks the ability to select multiple messages and act on them purely from the keyboard — this is the single biggest functionality gap vs Gmail. + +| Toggle | Type | Default | +|--------|------|---------| +| Keyboard multi-select mode | Boolean | On | + +**Key bindings (active when message list is focused, not during compose):** + +| Key | Action | +|-----|--------| +| `j` / `Down Arrow` | Move focus to next message | +| `k` / `Up Arrow` | Move focus to previous message | +| `x` or `Space` | Toggle select on focused message (multi-select) | +| `Shift+j` / `Shift+Down` | Select and move to next (extend selection) | +| `Shift+k` / `Shift+Up` | Select and move to previous (extend selection) | +| `#` | Delete selected message(s) | +| `e` | Archive selected message(s) | +| `Shift+i` | Mark selected as read | +| `Shift+u` | Mark selected as unread | +| `v` | Move selected (open OWA's move-to-folder dialog) | +| `Escape` | Deselect all | +| `Enter` / `o` | Open focused message in reading pane | + +**Implementation approach:** + +- The extension manages its own "focus cursor" on the message list, visually distinguished from OWA's native selection (e.g., a left-border highlight or subtle background tint) +- A Set tracks "checked" (multi-selected) message elements, visually marked with a checkbox indicator or distinct background +- Action keys (`#`, `e`, `Shift+i`, etc.) iterate over checked messages and dispatch OWA's native actions for each (via toolbar button clicks, context menu triggers, or keyboard shortcut forwarding) +- If no messages are checked, actions apply to the currently focused message (single-select behavior, matching Gmail) +- All keyboard handling is suppressed when a compose window, search bar, or dialog has focus — detected via `activeElement` checks +- A new file `content/keyboard.js` encapsulates all keyboard navigation logic, separate from `behavior.js` + ### Quick Actions (injected UI) | Toggle | Type | Default | @@ -202,6 +236,7 @@ outlook-relook/ │ ├── observer.js # MutationObserver logic, element suppression │ ├── selectors.js # Selector registry (logical name -> strategies) │ ├── behavior.js # JS behavior tweaks +│ ├── keyboard.js # Gmail-style keyboard navigation & multi-select │ └── injector.js # DOM injection (mark-all-read, folder jump) ├── themes/ │ ├── base.css # Shared density/spacing/hiding overrides