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')));