diff --git a/AGENTS.md b/AGENTS.md index c42b731..fd59173 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,10 @@ metadata remain authenticated. Inspect only a URL the user explicitly shares, then immediately `DELETE /api/debug/captures/{id}`. The server also expires captures after 24 hours and caps the directory at 50 files. +After the browser's required share picker closes, capture waits five seconds so +the user can restore a hover tooltip. `Alt+Shift+C` starts the same delayed flow +without clicking the status-bar button. + Diagnostic mode also shows a compact projection readout for visible manual trendlines: historical/future canonical price changes, their screen slopes, and whether the future canvas point exists. Include it in a capture when a line diff --git a/docs/implementation.md b/docs/implementation.md index e3cab6e..7cd0987 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -697,6 +697,12 @@ inspect the pixels. `DELETE /api/debug/captures/{id}` is public too, so an agent can remove an inspected screenshot immediately. The existing 24-hour expiry and 50-capture cap remain the backstop. +The screen-share picker steals focus, so an immediate frame cannot contain a +hover tooltip. Capture now waits five seconds after the picker closes and shows +the countdown in the status bar; the user can move back over the target before +zero. `Alt+Shift+C` starts the same flow from the keyboard, though the picker is +still mandatory browser security. + The public image and cleanup handlers live in `meta.py`, the deliberately unauthenticated router. Capture creation and metadata remain in `routes.py`, behind the normal chart authentication. diff --git a/static/app.js b/static/app.js index 8ffad01..2213ddc 100644 --- a/static/app.js +++ b/static/app.js @@ -137,6 +137,7 @@ createApp({ const drawingTf = ref('all'); const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); const captureBusy = ref(false); + const captureCountdown = ref(0); const snap = ref(true); const selectedDrawing = ref(null); const selectedDrawings = ref([]); @@ -630,6 +631,12 @@ createApp({ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(() => resolve()); else requestAnimationFrame(() => resolve()); }); + for (let seconds = 5; seconds > 0; seconds -= 1) { + captureCountdown.value = seconds; + await new Promise(resolve => setTimeout(resolve, 1000)); + } + captureCountdown.value = 0; + await new Promise(resolve => requestAnimationFrame(resolve)); const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; @@ -671,6 +678,7 @@ createApp({ addEvent(cancelled ? 'CAPTURE CANCELLED' : `CAPTURE FAILED · ${error.message}`, 'capture-error'); } finally { stream?.getTracks().forEach(track => track.stop()); + captureCountdown.value = 0; captureBusy.value = false; } } @@ -1306,6 +1314,12 @@ createApp({ } function handleKeydown(event) { + if (diagnosticMode.value && event.altKey && event.shiftKey + && event.code === 'KeyC' && !captureBusy.value) { + event.preventDefault(); + captureDiagnostic(); + return; + } if (event.key === 'Escape') { const palettes = [...document.querySelectorAll('.color-picker[open]')]; palettes.forEach(palette => { palette.open = false; }); @@ -1542,6 +1556,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, hideLowerTfDrawings, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, autoScrollLivePrice, extraDetail, hideLowerTfDrawings, barAge, dataUpdatedAt, buildStamp, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, eventsMore, loadOlderEvents, diagnosticMode, captureBusy, captureCountdown, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, drawingList, startDrawingListResize, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, maValue, maAlertOn, toggleMaAlert, deleteSelected, deleteLine, toggleDrawingSelection, activateDrawing, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, drawingTf, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol, optionExpirations, optionExpiryId, optionSide, optionMode, optionMin, optionMax, optionContracts, optionUnderlying, optionBusy, optionError, optionSearched, optionCopied, onOptionsToggle, searchOptions, copyOption, canUndo, undoTitle, undo }; }, }).mount('#app'); diff --git a/static/index.html b/static/index.html index 04804a4..d26bed5 100644 --- a/static/index.html +++ b/static/index.html @@ -37,7 +37,7 @@ Drag from one swing to the other Drawing selected — Delete removes it BUILD {{ buildStamp }}UPDATED {{ dataUpdatedAt }} diff --git a/tests/e2e/chart.test.mjs b/tests/e2e/chart.test.mjs index 32697a1..8f67480 100644 --- a/tests/e2e/chart.test.mjs +++ b/tests/e2e/chart.test.mjs @@ -72,10 +72,7 @@ test('an existing browser token is migrated once and removed from local storage' }); test('diagnostic capture uploads a PNG and adds its capability ID to Events', - { - timeout: 180000, - skip: 'quarantined: display-media mocks and async upload completion are not yet reliable', - }, async () => { + { timeout: 180000 }, async () => { const { browser, page } = await launch(); let uploaded = null; try { @@ -113,7 +110,10 @@ test('diagnostic capture uploads a PNG and adds its capability ID to Events', }); HTMLMediaElement.prototype.play = async function play() {}; HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); }; - HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} }); + const realGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function getContext(...args) { + return this.closest('#chart') ? realGetContext.apply(this, args) : { drawImage() {} }; + }; HTMLCanvasElement.prototype.toBlob = function toBlob(done) { done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], { type: 'image/png', @@ -121,7 +121,9 @@ test('diagnostic capture uploads a PNG and adds its capability ID to Events', }; }); - await page.locator('.diag-capture').click(); + await page.keyboard.press('Alt+Shift+C'); + await page.waitForFunction(() => + document.querySelector('.diag-capture')?.textContent.includes('CAPTURE IN')); await page.waitForFunction(() => [...document.querySelectorAll('.alert-entry')] .some(entry => entry.textContent.includes('c-TESTCAPTURE1')));