From db4d1060a5a1ce850a1d1266a19e9a0c6c9abec6 Mon Sep 17 00:00:00 2001 From: witbrock Date: Mon, 14 Sep 2026 18:58:27 -0400 Subject: [PATCH] Use accessible copy icon in Messages and record UI audit --- .../message_copy_icon_20260914.md | 54 ++++++++++++ .../static/js/components/messagePanel.js | 15 +++- .../static/js/utils/copyJsonButtonState.js | 4 + .../web/von_interface/static/styles.css | 35 ++++++++ tests/browser/messageCopyIcon.cjs | 86 +++++++++++++++++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 docs/engineering/implementation_reports/message_copy_icon_20260914.md create mode 100644 tests/browser/messageCopyIcon.cjs diff --git a/docs/engineering/implementation_reports/message_copy_icon_20260914.md b/docs/engineering/implementation_reports/message_copy_icon_20260914.md new file mode 100644 index 000000000..e154686a7 --- /dev/null +++ b/docs/engineering/implementation_reports/message_copy_icon_20260914.md @@ -0,0 +1,54 @@ +# Messages copy icon and UI opportunity audit + +- Kind: dated implementation and acceptance record +- Task: `#V#task_agent_27a0e3d1ae4e3b36411438c649870271` +- Scope: Messages copy affordance; bounded UI audit, 14 September 2026 +- Source: `#V#computer_file_copy_ee0e4aeb7f354f8ca9996c01e816759e`, + image.png, 524×240, SHA-256 + `4dcaeed7b3bfe3bd787e902ed662d6728fb424b9bd7b1119efdb5b95ad5a38bb`. + Controller-staged bytes were available and inspected; this was a referenced + file copy, not a native task attachment. + +## Outcome and decisions + +The screenshot's Messages Copy button now uses the overlapping-sheets outline +already used by the concept tab copy control. This familiar cross-platform +shape needs no OS detection or platform-dependent emoji. The native button, +accessible name and Markdown payload remain. A title supplies hover guidance; +touch/coarse-pointer devices retain visible Copy text and a 44px target. +Desktop targets are at least 32px. Keyboard focus has an explicit outline. +A separate live status shows Copied or Copy failed without removing the icon or +changing the action's name. The existing shared clipboard fallback now restores +focus after removing its temporary textarea. + +| Candidate inspected | Decision and rationale | +| --- | --- | +| Messages bubble Copy (`messagePanel.js`) | Changed: the adjacent message is an unambiguous target; preserve touch text and accessible guidance. | +| Discuss with Von / Review recommendations (`messagePanel.js`) | Retained text: neither has a standard icon that explains its product-specific action. | +| Message composition and send controls (`messagePanel.js`) | Existing plus icons already support compact composition; retain Send because it commits the message. | +| Copy transcript (Markdown), Copy information (JSON), Copy JSON, Copy ID, Copy reference (`chat_tab.html`, `chatTab.js`, `annotationTab.js`) | Retained text: users need to distinguish payload and scope; identical copy icons would conceal this distinction. | +| Concept summary Copy (`conceptSummaryRendererPanel.js`) | Retained text: it copies full text, which may extend beyond the visible summary. | +| Task Copy link (`taskPanel.js`) | Already an icon with a title and accessible name; no additional replacement needed. | +| Refresh controls (`chat_tab.html`) | Retained text: refresh scope varies between situation, reference inspector and task panels; no demonstrated discoverability benefit from removing the label. | +| Concept tab copy (`dynamicTabs.js`) | Already uses overlapping sheets; reused its visual convention. | + +This is a bounded audit of the adjacent messaging, chat, task and concept +surfaces, not a claim that every button in Von was inspected. + +## Acceptance (Tier 1) + +- 18 tests passed in `messageExchangeView.test.js` and + `messagePanelCartoucheRender.test.js`. +- Changed-module static frontend lint and `git diff --check` passed. +- `tests/browser/messageCopyIcon.cjs` passed using Chromium, the actual Messages + renderer and stylesheet, and synthetic HTTP/clipboard responses at 320px + touch and 1280px desktop. +- Browser assertions cover sent/received icons, names/titles, touch text and + target size, visible keyboard focus, Enter and Space, exact original Markdown, + clipboard fallback success/failure, restored focus and persistent icons. +- Screenshots inspected: `.run/message-copy/copy-320.png` and `copy-1280.png`; + machine-readable evidence: `.run/message-copy/result.json`. + +Merge decision: ready after the above evidence. No live authentication, real +message writes, OS clipboard integration or public deployment was exercised. +No deployment was requested by this task. diff --git a/src/frontend/web/von_interface/static/js/components/messagePanel.js b/src/frontend/web/von_interface/static/js/components/messagePanel.js index 66cb7a662..0054f1f0c 100644 --- a/src/frontend/web/von_interface/static/js/components/messagePanel.js +++ b/src/frontend/web/von_interface/static/js/components/messagePanel.js @@ -317,6 +317,7 @@ const NEW_MESSAGE_DELIVERY_ATTEMPT_SCHEMA_VERSION = 'direct_message_new_delivery const MESSAGE_THREAD_PREDICATE_ID = '#V#is_part_of_thread'; const MESSAGE_PANEL_ICONS = Object.freeze({ + copy: '', chat: '', compose: '', mail: '', @@ -1157,7 +1158,8 @@ async function renderMessages() { ` : ''}
${timestamp ? formatTime(timestamp) : ''} - + + ${msg.concept_id ? `` : ''} @@ -1197,7 +1199,16 @@ async function renderMessages() { } }); }); - contentEl.querySelectorAll('.message-copy-markdown').forEach(button => { button.onclick = async () => { const message = _currentMessages.find(m => m.concept_id === button.dataset.messageId); if (message) { const copied = await copyTextWithClipboardFallback(message.concept_data?.content_fallback || ''); button.textContent = copied ? 'Copied' : 'Copy failed'; } }; }); + contentEl.querySelectorAll('.message-copy-markdown').forEach(button => { + button.onclick = async () => { + const message = _currentMessages.find(m => m.concept_id === button.dataset.messageId); + if (!message) return; + const status = button.parentElement.querySelector('.message-copy-status'); + status.textContent = ''; + const copied = await copyTextWithClipboardFallback(message.concept_data?.content_fallback || ''); + status.textContent = copied ? 'Copied' : 'Copy failed'; + }; + }); contentEl.querySelectorAll('.message-discuss-btn').forEach((button) => { button.addEventListener('click', (event) => { event.preventDefault(); diff --git a/src/frontend/web/von_interface/static/js/utils/copyJsonButtonState.js b/src/frontend/web/von_interface/static/js/utils/copyJsonButtonState.js index 727b1337d..a9450c3d3 100644 --- a/src/frontend/web/von_interface/static/js/utils/copyJsonButtonState.js +++ b/src/frontend/web/von_interface/static/js/utils/copyJsonButtonState.js @@ -209,6 +209,7 @@ function copyTextFallback(text) { return false; } + const previousFocus = document.activeElement; const textArea = document.createElement('textarea'); textArea.value = String(text ?? ''); textArea.style.position = 'fixed'; @@ -228,6 +229,9 @@ function copyTextFallback(text) { copied = false; } finally { document.body.removeChild(textArea); + if (previousFocus?.isConnected && typeof previousFocus.focus === 'function') { + previousFocus.focus({ preventScroll: true }); + } } return copied; } diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 01aff3aa0..7b3245150 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -4465,6 +4465,41 @@ button.unified-search-conversation-main { } } +.message-copy-markdown { + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + gap: 5px; + min-width: 32px; + min-height: 32px; + padding: 5px; +} + +.message-copy-markdown svg { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} + +.message-copy-label { + display: none; +} + +.message-copy-markdown:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +/* Touch users cannot hover for the native tooltip. */ +@media (hover: none), (pointer: coarse) { + .message-copy-label { display: inline; } + .message-copy-markdown { min-width: 44px; min-height: 44px; } +} + .message-empty-state { display: flex; flex-direction: column; diff --git a/tests/browser/messageCopyIcon.cjs b/tests/browser/messageCopyIcon.cjs new file mode 100644 index 000000000..59d78a320 --- /dev/null +++ b/tests/browser/messageCopyIcon.cjs @@ -0,0 +1,86 @@ +// Actual Messages renderer and CSS; synthetic HTTP data, no live state changes. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = process.argv[2] || '.run/message-copy'; +fs.mkdirSync(evidence, { recursive: true }); +const content = '**Research update**\nCopy the original Markdown.'; +const server = http.createServer((req, res) => { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html'); + return res.end('
'); + } + if (req.url.startsWith('/static/')) { + const file = path.resolve(root, `.${req.url}`); + if (file.startsWith(root + '/static/') && fs.existsSync(file)) { + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + return res.end(fs.readFileSync(file)); + } + } + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/api/messages/exchange') return res.end(JSON.stringify({ current_user_id: '#V#alice', messages: ['alice', 'bob'].map(sender => ({ + concept_id: `#V#message_${sender}`, created_at: '2026-09-14T12:23:00Z', + relationships: { '#V#has_sender': [`#V#${sender}`] }, + concept_data: { content_fallback: content, read_by: ['#V#alice'] } + })) })); + res.end(JSON.stringify({ success: true, profiles: [], messages: [] })); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch(); + try { + const results = []; + for (const width of [320, 1280]) { + const page = await browser.newPage({ viewport: { width, height: 850 }, hasTouch: width < 800 }); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(async () => { + window.IntersectionObserver = undefined; + window.copied = []; + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.copied.push(text) } }); + const { openMessageExchange } = await import('/static/js/components/messagePanel.js'); + await openMessageExchange({ session_id: 'fixture', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#bob'], other_participant_ids: ['#V#bob'], session_name: 'Copy icon fixture' }); + }); + const buttons = page.getByRole('button', { name: 'Copy message as Markdown', exact: true }); + await expect(buttons).toHaveCount(2); + for (const button of await buttons.all()) { + await expect(button).toHaveAttribute('title', 'Copy message as Markdown'); + await expect(button.locator('svg')).toBeVisible(); + if (width < 800) await expect(button.locator('.message-copy-label')).toBeVisible(); + else await expect(button.locator('.message-copy-label')).toBeHidden(); + const box = await button.boundingBox(); + assert(box.width >= (width < 800 ? 44 : 32) && box.height >= (width < 800 ? 44 : 32)); + } + await buttons.first().focus(); + assert.notEqual(await buttons.first().evaluate(el => getComputedStyle(el).outlineStyle), 'none'); + await page.screenshot({ path: path.join(evidence, `copy-${width}.png`) }); + await page.keyboard.press('Enter'); + await expect(page.locator('.message-copy-status').first()).toHaveText('Copied'); + await expect(buttons.first()).toBeFocused(); + assert.equal(await page.evaluate(() => window.copied[0]), content); + await page.keyboard.press('Space'); + await expect.poll(() => page.evaluate(() => window.copied.length)).toBe(2); + // Force fallback, checking actual selected text and restored focus. + await page.evaluate(() => { + navigator.clipboard.writeText = async () => { throw new Error('Clipboard unavailable'); }; + document.execCommand = () => { window.fallbackText = document.activeElement.value; return true; }; + }); + await page.keyboard.press('Enter'); + await expect.poll(() => page.evaluate(() => window.fallbackText)).toBe(content); + await expect(buttons.first()).toBeFocused(); + await page.evaluate(() => { document.execCommand = () => false; }); + await page.keyboard.press('Enter'); + await expect(page.locator('.message-copy-status').first()).toHaveText('Copy failed'); + await expect(buttons.first()).toBeFocused(); + await expect(buttons.first().locator('svg')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Discuss with Von', exact: true })).toHaveCount(2); + assert.deepEqual(errors, []); + results.push({ width, sentAndReceived: true, keyboardCopy: true, rawMarkdown: true, fallbackFocus: true, visibleFailure: true, touchTextFallback: width < 800 }); + await page.close(); + } + fs.writeFileSync(path.join(evidence, 'result.json'), JSON.stringify({ fixture: true, results }, null, 2)); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });