experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(entities): add thumbs up/down feedback for entity quality scoring

Thumbs down hides noisy extractions from the list. Thumbs up marks
entities as correct. Feedback stored in entity metadata, no migration needed.

+584 -10
+209
app/page/youtube-research.md
··· 1 + # YouTube Blank White Screen — Diagnosis & Fix Plan 2 + 3 + ## Summary 4 + 5 + Opening `https://youtube.com/watch?v=Z4cS2Ivg2-M` in the Peek Electron app results in a blank white screen. After thorough investigation, the **most likely root cause** is the Ghostery adblocker (`@ghostery/adblocker-electron`) blocking critical YouTube resources. A secondary contributing factor may be `WebContentsForceDark` (if dark mode is set to "force"). There are also several minor issues around permission handling and error visibility. 6 + 7 + --- 8 + 9 + ## Architecture Context 10 + 11 + The page loading pipeline: 12 + 13 + 1. `window-open` IPC handler in `ipc.ts` (~line 2000) detects `https://` URL, sets `useCanvas = true` 14 + 2. Creates a frameless, transparent `BrowserWindow` with `webviewTag: true` 15 + 3. Loads `peek://app/page/index.html?url=<encoded-youtube-url>&x=...&y=...&width=...&height=...` 16 + 4. `page.js` runs `initWebview()`: 17 + - Fetches session partition via `api.profiles.getPartition()` (returns `persist:<profileId>`) 18 + - Sets `webview.partition = "persist:<profileId>"` 19 + - Sets `webview.src = targetUrl` (the YouTube URL) 20 + 5. The `<webview>` tag in `index.html` (line 358): `<webview id="content" allowpopups></webview>` 21 + 22 + **Electron version**: 40.x (Chromium 144) — this is a January 2026 release, well within YouTube's supported Chromium range. 23 + 24 + --- 25 + 26 + ## Root Cause Analysis 27 + 28 + ### 1. CRITICAL: Adblocker Blocking YouTube Resources 29 + 30 + **File**: `/Users/dietrich/misc/mpeek/backend/electron/adblocker.ts` 31 + 32 + The app uses `@ghostery/adblocker-electron` with `ElectronBlocker.fromPrebuiltAdsAndTracking()` (line 52). This loads EasyList + EasyPrivacy filter lists, which are known to aggressively block YouTube's ad-serving infrastructure. 33 + 34 + **The problem**: YouTube's modern SPA architecture loads its video player, UI components, and video streams through URLs that overlap with ad-serving patterns. The Ghostery blocker intercepts requests at the Electron session level (`session.webRequest`), which means it affects the webview's session directly. When critical YouTube JS bundles or API calls are blocked, the page renders as blank white — YouTube's error handling shows nothing rather than a broken page. 35 + 36 + **Specifically blocked resources likely include**: 37 + - `googlevideo.com` streaming URLs (may match tracker patterns) 38 + - YouTube's analytics/tracking endpoints that are required for the player to initialize 39 + - `play.google.com` or `accounts.google.com` auth-related requests 40 + - Various `*.doubleclick.net` or `*.googlesyndication.com` requests that YouTube's player JS depends on before rendering 41 + 42 + The blocker attaches to the profile session (line 105-106: `getProfileSession()` then `enableBlockingInSession()`), which is the same session the webview uses (set via `webview.partition = "persist:<profileId>"`). 43 + 44 + **Evidence**: There are no permission handlers (`setPermissionRequestHandler`, `setPermissionCheckHandler`) configured anywhere in the codebase — the only request interception is the adblocker's `webRequest` hooks. 45 + 46 + ### 2. MODERATE: WebContentsForceDark May Corrupt YouTube Rendering 47 + 48 + **File**: `/Users/dietrich/misc/mpeek/backend/electron/entry.ts` (lines 679-686) 49 + 50 + If the user's dark mode setting is `"force"`, the app applies: 51 + ``` 52 + app.commandLine.appendSwitch('enable-features', 53 + 'WebContentsForceDark:inversion_method/cielab_based/image_behavior/none' 54 + ); 55 + ``` 56 + 57 + This Chromium feature inverts colors at the compositing level. YouTube has its own dark mode, and forcing dark mode via Chromium's compositor can cause rendering conflicts — potentially resulting in white-on-white or invisible content. YouTube's custom web components and shadow DOMs may not respond well to forced color inversion. 58 + 59 + ### 3. MINOR: No User-Agent Override 60 + 61 + There is **no** user-agent manipulation anywhere in the codebase for webview content. The default Electron user-agent string is used, which includes `Electron/40.x.x` in the UA string. YouTube does check user agents and sometimes blocks or degrades service for non-standard browsers. 62 + 63 + However, Electron 40 / Chromium 144's default UA should be recent enough that YouTube wouldn't outright block it. YouTube's UA detection primarily targets very old Chromium versions or obvious bot strings. 64 + 65 + **The default Electron UA looks like**: 66 + ``` 67 + Mozilla/5.0 (Macintosh; Intel Mac OS X ...) AppleWebKit/537.36 (KHTML, like Gecko) Peek/0.0.1 Chrome/144.0.7559.60 Electron/40.0.0 Safari/537.36 68 + ``` 69 + 70 + The `Electron/40.0.0` token is unusual but YouTube typically doesn't block based on this. 71 + 72 + ### 4. MINOR: `allowServiceWorkers: false` on the `peek://` Scheme 73 + 74 + **File**: `/Users/dietrich/misc/mpeek/backend/electron/protocol.ts` (line 81) 75 + 76 + The custom protocol `peek://` has `allowServiceWorkers: false`. However, this only affects pages loaded via the `peek://` scheme — the webview loads `https://youtube.com` which uses its own session and the standard HTTPS scheme. Service workers for YouTube should work normally within the webview's session. 77 + 78 + **This is NOT the cause** — confirmed by the fact that `allowServiceWorkers` only applies to the scheme registration, not to the webview's guest content. 79 + 80 + ### 5. MINOR: Silent Error Swallowing 81 + 82 + **File**: `/Users/dietrich/misc/mpeek/app/page/page.js` (lines 897-899) 83 + 84 + ```javascript 85 + webview.addEventListener('did-fail-load', (e) => { 86 + if (e.errorCode === -3) return; // ERR_ABORTED — normal for SPA navigations 87 + console.error('[page] Load failed:', e.errorCode, e.errorDescription); 88 + }); 89 + ``` 90 + 91 + The error is logged to console but never shown to the user. If YouTube's main frame fails to load, the user sees a blank white screen with no feedback. The `dom-ready` handler (line 903) also sets a white background as default when no page background is detected — masking the failure further. 92 + 93 + ### 6. NO DRM/Widevine Configuration 94 + 95 + There is **no** Widevine CDM (Content Decryption Module) configuration anywhere in the codebase. Standard Electron does NOT include Widevine by default. However: 96 + 97 + - YouTube's standard video playback does not require DRM for most content 98 + - DRM is only needed for premium/purchased content and some music videos 99 + - A regular YouTube watch page should play non-DRM content without Widevine 100 + - The blank white screen is happening before video playback even begins (the entire page fails to render), so DRM is not the primary issue 101 + 102 + ### 7. NO Permission Handlers 103 + 104 + There are no `setPermissionRequestHandler` or `setPermissionCheckHandler` calls anywhere in the Electron backend. This means: 105 + 106 + - **Media autoplay**: Uses Electron's default behavior (generally allowed) 107 + - **Notifications**: Uses default (generally denied) 108 + - **Geolocation**: Uses default 109 + - **Camera/Microphone**: Uses default (denied unless explicitly allowed) 110 + 111 + For basic YouTube video watching, the lack of explicit permission handlers should not cause a blank page. YouTube doesn't require camera/mic/notification permissions for regular video playback. 112 + 113 + --- 114 + 115 + ## Fix Plan 116 + 117 + ### Fix 1: Add YouTube to Adblocker Allowlist (PRIMARY FIX) 118 + 119 + Add domain-based exception handling to the adblocker for YouTube and its required domains. 120 + 121 + **Option A — Per-site allowlist in adblocker.ts**: 122 + ```typescript 123 + const ADBLOCKER_ALLOWLIST = [ 124 + 'youtube.com', 125 + 'www.youtube.com', 126 + 'googlevideo.com', 127 + 'ytimg.com', 128 + 'yt3.ggpht.com', 129 + 'accounts.google.com', 130 + ]; 131 + ``` 132 + 133 + Before enabling the blocker, configure request filtering to skip these domains. 134 + 135 + **Option B — Disable adblocker entirely for YouTube domains**: 136 + The `@ghostery/adblocker-electron` package supports custom filtering. Use the `isAllowlisted` callback or a custom filter rule like `@@||youtube.com^` and `@@||googlevideo.com^`. 137 + 138 + **Option C — User-facing toggle per site**: 139 + Add a "disable ad blocking for this site" button to the navbar, storing the setting per-domain. 140 + 141 + ### Fix 2: Improve Error Visibility 142 + 143 + Modify `did-fail-load` handler in `page.js` to show a visible error state: 144 + 145 + ```javascript 146 + webview.addEventListener('did-fail-load', (e) => { 147 + if (e.errorCode === -3) return; 148 + console.error('[page] Load failed:', e.errorCode, e.errorDescription); 149 + // Show error in navbar or overlay instead of silent white screen 150 + urlText.value = `Error ${e.errorCode}: ${e.errorDescription}`; 151 + show({ source: 'shortcut' }); 152 + }); 153 + ``` 154 + 155 + ### Fix 3: Guard Against WebContentsForceDark on Complex Sites 156 + 157 + Consider either: 158 + - Not applying `WebContentsForceDark` to webview content (only to peek:// internal pages) 159 + - Adding a per-site override mechanism 160 + - Using CSS `color-scheme` meta tag injection instead 161 + 162 + ### Fix 4: Set Standard User-Agent on Webview (Low Priority) 163 + 164 + If YouTube does begin blocking, set a clean Chrome UA: 165 + 166 + ```javascript 167 + // In page.js initWebview(): 168 + webview.useragent = `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36`; 169 + ``` 170 + 171 + This strips the `Electron` and `Peek` tokens. 172 + 173 + ### Fix 5: Add Permission Handlers (Low Priority) 174 + 175 + For media-rich sites, add explicit permission handling on the profile session: 176 + 177 + ```typescript 178 + profileSession.setPermissionRequestHandler((webContents, permission, callback) => { 179 + const allowedPermissions = ['media', 'mediaKeySystem', 'midi', 'pointerLock', 'fullscreen']; 180 + callback(allowedPermissions.includes(permission)); 181 + }); 182 + ``` 183 + 184 + --- 185 + 186 + ## Diagnostic Steps to Confirm 187 + 188 + To verify the adblocker theory before implementing fixes: 189 + 190 + 1. **Quick test**: Temporarily disable the adblocker in settings, restart the app, and try YouTube again 191 + 2. **Console logging**: Add `DEBUG=1` and watch for `[adblocker] Blocked:` log lines when loading YouTube 192 + 3. **Network inspection**: Open DevTools on the webview guest (right-click the webview in the host page's DevTools, select "Inspect"), check the Network tab for blocked/failed requests 193 + 4. **Isolated test**: Create a test that loads YouTube in a webview with a fresh session (no adblocker), confirm it renders 194 + 195 + --- 196 + 197 + ## Files Referenced 198 + 199 + | File | Role | 200 + |------|------| 201 + | `/Users/dietrich/misc/mpeek/app/page/page.js` | Webview creation, URL loading, error handling | 202 + | `/Users/dietrich/misc/mpeek/app/page/index.html` | Page container with `<webview>` tag | 203 + | `/Users/dietrich/misc/mpeek/backend/electron/ipc.ts` | Window-open handler, canvas setup, guest webContents | 204 + | `/Users/dietrich/misc/mpeek/backend/electron/session-partition.ts` | Session/partition setup | 205 + | `/Users/dietrich/misc/mpeek/backend/electron/main.ts` | App initialization, no permission handlers | 206 + | `/Users/dietrich/misc/mpeek/backend/electron/entry.ts` | `WebContentsForceDark` command-line switch | 207 + | `/Users/dietrich/misc/mpeek/backend/electron/protocol.ts` | `peek://` scheme registration (`allowServiceWorkers: false`) | 208 + | `/Users/dietrich/misc/mpeek/backend/electron/adblocker.ts` | Ghostery adblocker — primary suspect | 209 + | `/Users/dietrich/misc/mpeek/package.json` | Electron 40.x / Chromium 144 |
+191
backend/electron/display-watcher-research.md
··· 1 + # Display Watcher Research: Primary/Main Display Handling on macOS 2 + 3 + Research for improving Peek's display-watcher to handle the "windows stay on laptop when external display is plugged in" problem. 4 + 5 + --- 6 + 7 + ## 1. macOS Display Concepts: "Primary" vs "Main" 8 + 9 + macOS has **two distinct concepts** that are often confused: 10 + 11 + ### Primary Display (NSScreen.screens[0]) 12 + - The display at coordinate origin (0,0) in the global display coordinate space. 13 + - This is the display that has the **menu bar** and **Dock** (by default). 14 + - The user sets this in **System Settings > Displays > Arrangement** by dragging the white menu bar indicator to a display. 15 + - The primary display does **NOT change automatically** when you plug in an external monitor. It stays wherever the user last set it. 16 + - `NSScreen.screens[0]` always returns this display. 17 + - In Electron: `screen.getPrimaryDisplay()` returns this display. 18 + 19 + ### Main Display (NSScreen.main) 20 + - The display that contains the **currently focused window** (the key window). 21 + - This changes dynamically as the user clicks on windows on different displays. 22 + - There is **no Electron API** that directly exposes `NSScreen.main`. Electron only exposes `getPrimaryDisplay()` which maps to the menu-bar display. 23 + 24 + ### Key Insight 25 + **Plugging in an external monitor does NOT change the primary display.** The laptop screen remains primary (has menu bar at 0,0) unless the user manually drags the white bar in System Settings > Displays. This means `screen.getPrimaryDisplay()` returns the laptop screen both before and after connecting an external monitor. 26 + 27 + ### What DOES change when you plug in a monitor: 28 + - A `display-added` event fires in Electron. 29 + - The coordinate space may shift (the new display gets positioned relative to the primary). 30 + - The `bounds` and `workArea` of existing displays may change (e.g., if the arrangement shifts). 31 + - `display-metrics-changed` events fire for affected displays. 32 + 33 + --- 34 + 35 + ## 2. Why Do Other Apps Move Windows to the External Display? 36 + 37 + **Short answer: They mostly don't. macOS doesn't do this automatically either.** 38 + 39 + ### What actually happens: 40 + 1. **macOS does NOT auto-move windows to newly connected displays.** When you plug in an external monitor, all existing windows stay where they are. 41 + 2. **Window restore from sleep/wake**: When a MacBook is in clamshell mode (lid closed with external display), all windows are on the external. When you unplug and reopen, macOS moves them to the laptop screen. When you re-plug, macOS remembers and restores them to the external. This is **macOS window server behavior**, not individual app behavior. 42 + 3. **Some apps remember per-display positions**: Apps like Safari, Xcode, and Terminal remember which display their windows were last on and restore to that display when it reappears. This is standard `NSWindow` state restoration (`restorable` property + `NSWindowRestoration` protocol). 43 + 4. **The perception that "other apps move to the big screen"** likely comes from: 44 + - Clamshell mode wake/sleep cycling (macOS handles this at the window server level) 45 + - Apps that were previously used on that external display and have state restoration 46 + - The user manually having moved windows there in a previous session 47 + 48 + ### What macOS DOES do automatically: 49 + - **Clamshell mode**: When MacBook lid closes with external display connected, all windows move to external. When external disconnects, they move to laptop screen. When external reconnects with lid closed, they move back. This is window server behavior. 50 + - **Display removal**: Windows on a removed display get relocated to the nearest remaining display (shoved into top-left typically). 51 + - **"Displays have separate Spaces"** (System Settings > Desktop & Dock): When enabled, each display has its own Space, and macOS manages window-to-Space assignment. 52 + 53 + --- 54 + 55 + ## 3. Electron Events When External Display is Plugged In 56 + 57 + When an external display is connected, Electron fires these events (in approximate order): 58 + 59 + 1. **`display-added`** - with the new Display object 60 + - `display.id` - unique numeric ID 61 + - `display.bounds` - position in global coordinate space 62 + - `display.workArea` - usable area excluding dock/menu bar 63 + - `display.internal` - `false` for external displays, `true` for built-in 64 + - `display.label` - e.g., "LG UltraFine" (human-readable name) 65 + 66 + 2. **`display-metrics-changed`** - may fire for EXISTING displays 67 + - The laptop display's `workArea` might change (e.g., if arrangement shifts coordinate space) 68 + - `changedMetrics` array tells you what changed: `"bounds"`, `"workArea"`, `"scaleFactor"`, `"rotation"` 69 + 70 + 3. Multiple events may fire in rapid succession (hence the existing 500ms debounce). 71 + 72 + ### The `internal` property (key for our use case) 73 + Electron's Display object has an `internal: boolean` property: 74 + - `true` = built-in laptop display 75 + - `false` = external display 76 + 77 + This is **the critical signal** for detecting "user plugged in an external monitor" vs "display configuration changed for other reasons." 78 + 79 + ### What `getPrimaryDisplay()` returns: 80 + - Before plugging in external: laptop display (assuming user hasn't changed primary) 81 + - After plugging in external: **still the laptop display** (unchanged) 82 + - Only changes if user manually sets a different primary in System Settings 83 + 84 + --- 85 + 86 + ## 4. System Settings > Displays > Arrangement 87 + 88 + ### The white menu bar indicator: 89 + - Dragging it to a display makes that display the **primary display** (coordinate origin 0,0, gets the menu bar). 90 + - This is a **manual user action**, not automatic. 91 + - When changed, Electron fires `display-metrics-changed` for both the old and new primary displays (bounds change because coordinate origin moves). 92 + 93 + ### Display arrangement: 94 + - Users can drag display thumbnails to set relative positioning (left/right/above/below). 95 + - This affects the global coordinate space — display `bounds.x` and `bounds.y` reflect arrangement. 96 + - New displays are initially placed to the right of existing displays by default. 97 + 98 + ### "Mirror Displays": 99 + - When enabled, both displays show the same content. 100 + - Electron sees this as a single display (the mirrored display disappears from `getAllDisplays()`). 101 + 102 + --- 103 + 104 + ## 5. How Other Electron Apps Handle This 105 + 106 + ### VS Code 107 + - Does NOT auto-move windows to external displays on connect. 108 + - Restores window positions from saved state on launch. 109 + - Uses `screen.getDisplayMatching(bounds)` to validate saved positions. 110 + - If a window's saved position is off-screen, it resets to a default position on the primary display. 111 + 112 + ### Slack, Discord 113 + - Do NOT auto-move windows when displays change. 114 + - Standard Electron behavior: windows stay where they are. 115 + 116 + ### General Electron ecosystem 117 + - No mainstream Electron app automatically moves windows to a newly connected external display. 118 + - The standard approach is: save window positions, restore on relaunch, reposition if off-screen. 119 + 120 + --- 121 + 122 + ## 6. Concrete Recommendations for Peek 123 + 124 + ### The Core Question 125 + Should Peek move windows to a newly connected external display even when they weren't previously on that display? 126 + 127 + **Recommendation: No, not by default.** This would be surprising behavior that no other app does. BUT there are several things Peek should do: 128 + 129 + ### Recommendation A: Improve Phase 2 Restore (already exists, verify it works) 130 + The current Phase 2 already handles restoring displaced windows to a re-appearing display. This covers the most common real scenario: 131 + 1. User has windows on external display 132 + 2. User unplugs external (Phase 1 saves home, Phase 1b redistributes to laptop) 133 + 3. User re-plugs external (Phase 2 restores to external) 134 + 135 + **Action**: Verify Phase 2 works reliably with the `internal` property as an additional matching signal. Currently matches by display ID then resolution. Could also match by `internal === false` when there was exactly one external display before. 136 + 137 + ### Recommendation B: Detect "primary display changed" scenario 138 + When `display-metrics-changed` fires and the primary display ID changes (compare `screen.getPrimaryDisplay().id` against saved primary ID), this means the user manually changed their primary display in System Settings. In this case: 139 + 1. The menu bar and Dock have moved. 140 + 2. The user likely wants their "main" workspace on the new primary. 141 + 3. Consider offering to move windows, or at minimum re-run Phase 3 repositioning. 142 + 143 + **Action**: Track `_previousPrimaryDisplayId` and detect when it changes. 144 + 145 + ### Recommendation C: Add a "Move to Display" command 146 + Rather than auto-moving, give the user explicit control: 147 + - Command palette action: "Move all windows to [display name]" 148 + - Could use the `display.label` property for a human-readable display picker. 149 + - This respects user intent while solving the "I want my windows on the big screen" problem. 150 + 151 + ### Recommendation D: Use `display.internal` for smarter defaults 152 + When a new external display is added and there are windows on the internal display, Peek could: 153 + - **Option 1 (Conservative)**: Do nothing extra beyond current Phase 2 restore. This is the safest. 154 + - **Option 2 (Moderate)**: If the external display is significantly larger than the internal display (e.g., >1.5x work area), and there are displaced windows from a previous session, be more aggressive about matching them to the new external display. 155 + - **Option 3 (Aggressive)**: Auto-move all windows to the largest external display. This matches the user's stated desire but is non-standard behavior. 156 + 157 + **Recommended: Option 1 + Recommendation C.** Don't auto-move, but make it easy to move manually. 158 + 159 + ### Recommendation E: Handle the clamshell mode scenario 160 + When the laptop display disappears (`internal: true` display removed) and an external display exists, all windows should already be on the external. When the laptop display reappears (lid opened), don't move windows off the external display back to the laptop. The current Phase 3 may inadvertently do this if it sees windows as being on a "changed" display. 161 + 162 + **Action**: When a display is added and it's `internal: true`, be careful not to pull windows back to it from external displays where they're happily sitting. 163 + 164 + ### Recommendation F: Track and persist display topology 165 + Save the last-known display configuration (display IDs, labels, internal/external, arrangement) to disk. On app launch, compare saved topology to current topology. If they match, restore windows to saved positions. If different (e.g., external display now present that wasn't before), use current Phase 2/3 logic. 166 + 167 + This enables the scenario: User always docks at work with the same external monitor. Peek remembers "when this monitor is connected, windows go here." 168 + 169 + --- 170 + 171 + ## Summary of Key Technical Facts 172 + 173 + | Question | Answer | 174 + |----------|--------| 175 + | Does primary display change on plug-in? | **No.** Only changes via manual System Settings action. | 176 + | Does macOS auto-move windows to new display? | **No.** Only in clamshell wake/sleep scenarios. | 177 + | What Electron events fire? | `display-added`, then `display-metrics-changed` for existing displays | 178 + | Can we detect internal vs external? | **Yes.** `display.internal` property. | 179 + | Can we get display name? | **Yes.** `display.label` property (e.g., "LG UltraFine"). | 180 + | Do other Electron apps auto-move? | **No.** None do this. | 181 + | Best approach for Peek? | Improve restore logic + add explicit "Move to Display" command. | 182 + 183 + --- 184 + 185 + ## Implementation Priority 186 + 187 + 1. **Verify Phase 2 restore works end-to-end** (unplug external, re-plug — windows should return). This is the highest-value fix because it covers the most common real-world scenario. 188 + 2. **Add `display.internal` to matching logic** in `findMatchingNewDisplay()` for better display identification. 189 + 3. **Add "Move all windows to [display]" command** to command palette. 190 + 4. **Track primary display ID changes** for the rare case where the user changes it manually. 191 + 5. **Persist display topology** for cross-session position memory (longer term).
+2 -2
extensions/entities/background.js
··· 12 12 import { extractMicroformatEntities } from './extractors/microformats.js'; 13 13 import { extractStructuredDataEntities, extractPageMetadata } from './extractors/structured-data.js'; 14 14 import { processEntities } from './entity-matcher.js'; 15 - import { getEntities, getObservations } from './entity-store.js'; 15 + import { getEntities, getObservations, setEntityFeedback } from './entity-store.js'; 16 16 import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 17 17 18 18 const api = window.app; ··· 455 455 export default extension; 456 456 457 457 // Export for UI 458 - export { getEntities, getObservations, extractCurrentPage }; 458 + export { getEntities, getObservations, extractCurrentPage, setEntityFeedback };
+20
extensions/entities/entity-store.js
··· 174 174 return { ...item, _meta: metadata }; 175 175 }); 176 176 177 + // Filter out thumbs-down entities unless explicitly requested 178 + if (!options.includeHidden) { 179 + entities = entities.filter(e => (e._meta.feedback?.score ?? 0) !== -1); 180 + } 181 + 177 182 if (options.entityType) { 178 183 entities = entities.filter(e => e._meta.entityType === options.entityType); 179 184 } ··· 248 253 249 254 return api.datastore.updateItem(entityId, { 250 255 metadata: JSON.stringify(metadata) 256 + }); 257 + } 258 + 259 + /** 260 + * Set feedback score on an entity 261 + * @param {string} entityId - Entity item ID 262 + * @param {number} score - -1 (thumbs down/hide), 0 (unscored), 1 (thumbs up/correct) 263 + * @returns {Promise<{success: boolean}>} 264 + */ 265 + export async function setEntityFeedback(entityId, score) { 266 + return updateEntityMetadata(entityId, { 267 + feedback: { 268 + score, 269 + scoredAt: Date.now() 270 + } 251 271 }); 252 272 } 253 273
+85
extensions/entities/home.css
··· 216 216 color: var(--base03); 217 217 } 218 218 219 + /* Feedback buttons */ 220 + .feedback-buttons { 221 + display: flex; 222 + gap: 2px; 223 + flex-shrink: 0; 224 + } 225 + 226 + .feedback-btn { 227 + display: flex; 228 + align-items: center; 229 + justify-content: center; 230 + width: 26px; 231 + height: 26px; 232 + padding: 0; 233 + background: transparent; 234 + border: 1px solid transparent; 235 + border-radius: 4px; 236 + cursor: pointer; 237 + color: var(--base03); 238 + transition: all 0.15s; 239 + opacity: 0; 240 + } 241 + 242 + peek-card:hover .feedback-btn { 243 + opacity: 1; 244 + } 245 + 246 + .feedback-btn.active { 247 + opacity: 1; 248 + } 249 + 250 + .feedback-btn:hover { 251 + background: var(--base02); 252 + color: var(--base05); 253 + } 254 + 255 + .feedback-btn.feedback-up.active { 256 + color: var(--base0B); 257 + background: color-mix(in srgb, var(--base0B) 15%, transparent); 258 + border-color: color-mix(in srgb, var(--base0B) 30%, transparent); 259 + } 260 + 261 + .feedback-btn.feedback-down.active { 262 + color: var(--base08); 263 + background: color-mix(in srgb, var(--base08) 15%, transparent); 264 + border-color: color-mix(in srgb, var(--base08) 30%, transparent); 265 + } 266 + 267 + /* Detail view feedback buttons */ 268 + .detail-feedback-buttons { 269 + display: flex; 270 + gap: 8px; 271 + } 272 + 273 + .detail-feedback-btn { 274 + display: flex; 275 + align-items: center; 276 + gap: 6px; 277 + padding: 8px 14px; 278 + background: var(--base01); 279 + border: 1px solid var(--base02); 280 + border-radius: 6px; 281 + cursor: pointer; 282 + color: var(--base04); 283 + font-size: 13px; 284 + transition: all 0.15s; 285 + } 286 + 287 + .detail-feedback-btn:hover { 288 + background: var(--base02); 289 + color: var(--base05); 290 + } 291 + 292 + .detail-feedback-btn.feedback-up.active { 293 + color: var(--base0B); 294 + background: color-mix(in srgb, var(--base0B) 15%, transparent); 295 + border-color: color-mix(in srgb, var(--base0B) 30%, transparent); 296 + } 297 + 298 + .detail-feedback-btn.feedback-down.active { 299 + color: var(--base08); 300 + background: color-mix(in srgb, var(--base08) 15%, transparent); 301 + border-color: color-mix(in srgb, var(--base08) 30%, transparent); 302 + } 303 + 219 304 /* List view mode */ 220 305 peek-grid[view-mode="list"] peek-card { 221 306 max-width: none;
+5
extensions/entities/home.html
··· 92 92 </div> 93 93 94 94 <div class="detail-section"> 95 + <label class="detail-label">Feedback</label> 96 + <div id="detailFeedback"></div> 97 + </div> 98 + 99 + <div class="detail-section"> 95 100 <label class="detail-label">Source Pages</label> 96 101 <div class="detail-sources" id="detailSources"></div> 97 102 </div>
+63 -2
extensions/entities/home.js
··· 5 5 * Includes detail pane showing full entity metadata and all source pages. 6 6 */ 7 7 8 - import { getEntities, getObservations, extractCurrentPage } from './background.js'; 8 + import { getEntities, getObservations, extractCurrentPage, setEntityFeedback } from './background.js'; 9 9 10 10 const api = window.app; 11 11 ··· 238 238 <div class="detail-attr-row"><span class="detail-attr-label">First Seen</span><span class="detail-attr-value">${entity.createdAt ? new Date(entity.createdAt).toLocaleString() : 'Unknown'}</span></div> 239 239 <div class="detail-attr-row"><span class="detail-attr-label">Last Updated</span><span class="detail-attr-value">${entity.updatedAt ? new Date(entity.updatedAt).toLocaleString() : 'Unknown'}</span></div> 240 240 `; 241 + 242 + // Feedback section 243 + const feedbackEl = document.getElementById('detailFeedback'); 244 + if (feedbackEl) { 245 + const feedbackScore = meta.feedback?.score ?? 0; 246 + feedbackEl.innerHTML = ` 247 + <div class="detail-feedback-buttons"> 248 + <button class="detail-feedback-btn feedback-down ${feedbackScore === -1 ? 'active' : ''}" data-score="-1"> 249 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg> 250 + <span>Hide</span> 251 + </button> 252 + <button class="detail-feedback-btn feedback-up ${feedbackScore === 1 ? 'active' : ''}" data-score="1"> 253 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg> 254 + <span>Correct</span> 255 + </button> 256 + </div> 257 + `; 258 + feedbackEl.querySelectorAll('.detail-feedback-btn').forEach(btn => { 259 + btn.addEventListener('click', async () => { 260 + const newScore = parseInt(btn.dataset.score); 261 + const currentScore = meta.feedback?.score ?? 0; 262 + const score = currentScore === newScore ? 0 : newScore; 263 + await setEntityFeedback(entity.id, score); 264 + if (score === -1) { 265 + showList(); 266 + } else { 267 + showDetailView(entity); 268 + } 269 + }); 270 + }); 271 + } 241 272 242 273 // Source pages 243 274 const sourcesEl = document.getElementById('detailSources'); ··· 429 460 const footerParts = [`${confidence}% confidence`]; 430 461 if (pageCountText) footerParts.push(pageCountText); 431 462 footerParts.push(formatDate(entity.createdAt)); 463 + const feedbackScore = meta.feedback?.score ?? 0; 432 464 footerDiv.innerHTML = ` 433 465 <div class="card-footer"> 434 466 <span class="entity-confidence">${footerParts.filter(Boolean).join(' \u00B7 ')}</span> 467 + <span class="feedback-buttons"> 468 + <button class="feedback-btn feedback-down ${feedbackScore === -1 ? 'active' : ''}" data-score="-1" title="Hide noisy extraction"> 469 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg> 470 + </button> 471 + <button class="feedback-btn feedback-up ${feedbackScore === 1 ? 'active' : ''}" data-score="1" title="Mark as correct"> 472 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg> 473 + </button> 474 + </span> 435 475 </div> 436 476 `; 437 477 card.appendChild(footerDiv); 438 478 439 479 // Click on source link opens the page directly 440 - card.addEventListener('click', (e) => { 480 + card.addEventListener('click', async (e) => { 481 + // Feedback buttons 482 + const feedbackBtn = e.target.closest('.feedback-btn'); 483 + if (feedbackBtn) { 484 + e.stopPropagation(); 485 + const newScore = parseInt(feedbackBtn.dataset.score); 486 + const currentScore = meta.feedback?.score ?? 0; 487 + // Toggle off if clicking the same score 488 + const score = currentScore === newScore ? 0 : newScore; 489 + await setEntityFeedback(entity.id, score); 490 + // If thumbs down, remove card with animation; otherwise re-render 491 + if (score === -1) { 492 + card.style.transition = 'opacity 0.3s, transform 0.3s'; 493 + card.style.opacity = '0'; 494 + card.style.transform = 'scale(0.95)'; 495 + setTimeout(() => renderEntities(), 300); 496 + } else { 497 + renderEntities(); 498 + } 499 + return; 500 + } 501 + 441 502 const sourceLink = e.target.closest('.source-title[data-url]'); 442 503 if (sourceLink) { 443 504 e.stopPropagation();
+9 -6
peek-todo.md
··· 8 8 - This file is not for notes or description - link to documents in ./notes for that 9 9 - Checkbox states: `- [ ]` pending, `- [~]` in-progress (move to WIP.md), `- [x]` done (move to CHANGELOG.md) 10 10 11 - ## Design principles / capablities / etc 11 + ## Design principles / capabilities / etc 12 12 13 13 core 14 14 - feels like home: trust, comfort, control ··· 16 16 - sleep at night because no idea or anything you saw is ever lost 17 17 - create, save, classify at the speed of thought 18 18 19 - what makes a home 19 + what makes a home / kitchen / workshop 20 20 - everything is right where you need it, b/c you control what is where 21 - - when you know what is where, you can make things without frustration 22 - - you generally know what’s happening - who’s around/coming/going, etc, not a lot of surprises 21 + - when you know what is where, you can make things with less frustration 22 + - you generally know what’s happening - who’s around/coming/going, etc, not too much surprise (but some!) 23 23 24 24 what makes magical mind-readingness 25 - - frecency everywhere all of the time: user actions are training data for sorting 25 + - frecency everywhere all of the time 26 + - user actions are training data for sorting 27 + - the thing you were thinking of is already at the top of the list 26 28 27 - what provides awareness 29 + what makes awareness 28 30 - actions tracked at the core 29 31 - metrics generated, rollups displayed, synthesis emergent 32 + - task context with you at the center (vs asking a companion robot - disempowering!) 30 33 31 34 synthesis 32 35 - frecency + adaptive matching gives experience/feeling of magical mind-readingness