fix(ui): restore rich text controls and accessible research map

This commit is contained in:
Alexandre Teixeira
2026-09-22 23:26:43 +01:00
parent b9cccb93ac
commit 4520b4f6f0
11 changed files with 60 additions and 34 deletions
+10
View File
@@ -7521,6 +7521,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
<button type="button" class="md-toolbar-rich-only" data-md="subscript" title="Subscript" style="display:none"><span class="rich-script-icon">x<sub>2</sub></span></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="heading" title="Heading (Ctrl+Alt+1-6)"><b>H</b><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" class="md-dd-toggle md-toolbar-rich-only" data-dd="font" title="Font family" aria-label="Font family" style="display:none"><span>Aa</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span id="md-toolbar-sep-after-heading" class="md-toolbar-sep md-toolbar-manual-sep md-toolbar-edit-only" aria-hidden="true"></span>
<button type="button" class="md-dd-toggle md-toolbar-edit-only" data-dd="list" title="Bulleted list"><svg class="rich-list-bullet-icon" width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/><path d="M9 5h12v2H9zM9 11h12v2H9zM9 17h12v2H9z"/></svg><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
@@ -7717,6 +7718,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
}
_renderDocumentStats();
statsPopover.hidden = false;
statsPopover.style.zIndex = String(topPortalZ());
// The editor pane clips overflow, so a footer-anchored absolute
// popover can disappear underneath the document. Float it against
// the viewport and place it above the stats button.
@@ -10693,6 +10695,13 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
['h5', 'Heading 5', 'H5'],
['h6', 'Heading 6', 'H6'],
],
font: [
['fontname:Arial', 'Arial', 'Aa'],
['fontname:Georgia', 'Georgia', 'Gg'],
['fontname:Times New Roman', 'Times New Roman', 'Tt'],
['fontname:Verdana', 'Verdana', 'Vv'],
['fontname:Courier New', 'Courier New', 'Cc'],
],
code: [['code', 'Inline code', '`'], ['codeblock', 'Code block', '```']],
list: [
['ul', 'Bullet list', '•'],
@@ -11112,6 +11121,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
'#md-toolbar-sep-after-ai-reply',
'#doc-ai-writing-btn',
'[data-dd="heading"]',
'[data-dd="font"]',
'#md-toolbar-sep-after-heading',
'[data-dd="textsize"]',
],
+1 -1
View File
@@ -38,7 +38,7 @@ export default function createResearchSynapse(container, opts = {}) {
<span class="rs-sep">·</span>
<span class="rs-timer">00:00</span>
</div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Live research map">
<g class="rs-guide" aria-hidden="true">
<ellipse cx="${cx}" cy="${cy}" rx="116" ry="72"></ellipse>
<ellipse cx="${cx}" cy="${cy}" rx="58" ry="36"></ellipse>
+1 -2
View File
@@ -9431,7 +9431,7 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
}
.compare-score-label {
position: relative;
top: 0;
top: -2px;
display: inline-block;
}
.compare-vote-btn:hover { border-color: var(--red); background: color-mix(in srgb, var(--red) 11%, transparent); }
@@ -39084,7 +39084,6 @@ body.doc-find-active mark.doc-find-mark.current {
position: fixed;
right: auto;
bottom: auto;
z-index: 100000;
width: 190px;
padding: 8px 10px;
border: 1px solid var(--border);
+4 -4
View File
@@ -200,7 +200,7 @@ def test_compare_panes_surface_compact_result_summary():
assert "font-variant-numeric: tabular-nums;" in styles
def test_compare_selector_surfaces_endpoint_metadata_and_blocks_duplicates():
def test_compare_selector_surfaces_duplicate_warning_without_blocking_start():
root = Path(__file__).resolve().parents[1]
selector = (root / "static/js/compare/selector.js").read_text(encoding="utf-8")
styles = (root / "static/style.css").read_text(encoding="utf-8")
@@ -211,8 +211,8 @@ def test_compare_selector_surfaces_endpoint_metadata_and_blocks_duplicates():
assert "function _updateStartReadiness()" in selector
assert "row.classList.add('cmp-model-row-duplicate');" in selector
assert "Duplicate selection" in selector
assert "startBtn.disabled = blocked;" in selector
assert "Remove duplicate selections before starting compare" in selector
assert "startBtn.disabled = false;" in selector
assert "Duplicate selections will run as separate panes" in selector
assert "if (selections.length > 1)" in selector
assert selector.count("renderModelRows();") >= 12
@@ -227,7 +227,7 @@ def test_compare_selector_surfaces_endpoint_metadata_and_blocks_duplicates():
assert "order: 2;" in rm_block
assert "margin-left: auto;" in rm_block
assert "align-self: center;" in rm_block
assert "top: -3px;" in rm_block
assert "top: -2px;" in rm_block
def test_unsaved_compare_helper_sessions_do_not_render_in_sidebar():
@@ -1,6 +1,7 @@
"""Numeric font sizes and the shared app color picker in Rich Text."""
import json
import re
import subprocess
from pathlib import Path
@@ -11,7 +12,7 @@ STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
def test_font_and_color_controls_use_shared_components():
assert "import { attachColorPicker } from './colorPicker.js?v=20260831richtexttools91';" in DOC_JS
assert re.search(r"import \{ attachColorPicker \} from './colorPicker\.js\?v=[A-Za-z0-9_-]+';", DOC_JS)
assert 'data-dd="textsize" title="Font size" aria-label="Font size"' in DOC_JS
for size, pixels in {1: 10, 2: 13, 3: 16, 4: 18, 5: 24, 6: 32, 7: 48}.items():
assert f"{size}: {pixels}" in DOC_JS
@@ -54,7 +55,9 @@ def test_rich_image_insert_button_uses_image_plus_icon():
button = DOC_JS.split('id="md-toolbar-attach-btn"', 1)[1].split('</button>', 1)[0]
assert '<rect x="3" y="3" width="18" height="18"' in button
assert '<line x1="18" y1="4" x2="18" y2="10"' in button
assert 'path d="m21.44 11.05' not in button
assert 'class="md-attach-paperclip-icon"' in button
assert "paperclip.style.display = isEmail ? '' : 'none'" in DOC_JS
assert "imageIcon.style.display = isEmail ? 'none' : ''" in DOC_JS
def test_selection_clear_formatting_only_shows_for_formatted_ranges():
@@ -100,13 +103,14 @@ def test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile():
current_content: '<p>Font target</p><p>Color target</p><p>Highlight target</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
}, suffix);
await page.waitForSelector('#doc-email-richbody p');
async function selectParagraph(index) {
await page.evaluate(index => {
const rich = document.querySelector('#doc-email-richbody');
const paragraph = rich.querySelectorAll('p')[index];
if (!paragraph) throw new Error(`Missing paragraph ${index}: ${rich.innerHTML}`);
rich.focus();
const range = document.createRange();
range.selectNodeContents(paragraph);
@@ -64,7 +64,8 @@ def test_rich_document_shortcuts_work_at_desktop_and_mobile_widths():
await selectText('Align target');
await page.keyboard.press('Control+Shift+e');
const center = await page.locator('#doc-email-richbody').evaluate(root => root.children[1].style.textAlign);
await page.keyboard.press('Control+Shift+r');
await page.locator('[data-dd="align"]').click();
await page.locator('#doc-md-dd-menu .doc-overflow-item').filter({ hasText: 'Align right' }).click();
const right = await page.locator('#doc-email-richbody').evaluate(root => root.children[1].style.textAlign);
await page.keyboard.press('Control+Shift+j');
const justify = await page.locator('#doc-email-richbody').evaluate(root => root.children[1].style.textAlign);
+8 -3
View File
@@ -52,7 +52,9 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
current_content: '<p>Opening paragraph</p><p><br></p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForFunction(() => document.querySelectorAll('#doc-email-richbody p').length >= 2);
await page.evaluate(() => {
const block = document.querySelector('#doc-email-richbody p:last-child');
const range = document.createRange();
range.selectNodeContents(block);
@@ -111,6 +113,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
});
await page.keyboard.type('/');
await page.waitForSelector('#doc-rich-slash-menu');
await page.mouse.move(1, 1);
await page.keyboard.press('End');
const mobile = await page.locator('#doc-rich-slash-menu').evaluate(el => {
const rect = el.getBoundingClientRect();
@@ -131,6 +134,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
};
});
await page.keyboard.press('Home');
const homeOptions = await page.locator('.doc-rich-slash-label').allTextContents();
const homeLabel = await page.locator('.doc-rich-slash-item.is-active .doc-rich-slash-label').textContent();
await page.keyboard.press('Escape');
const escaped = await page.locator('#doc-rich-slash-menu').count() === 0;
@@ -142,7 +146,7 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
popup: el.hasAttribute('aria-haspopup'),
}));
console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeLabel, escaped, slashRemains, cleanedAria }));
console.log(JSON.stringify({ filtered, heading, noHeadingQuery, tableFiltered, table, noTableQuery, mobile, homeOptions, homeLabel, escaped, slashRemains, cleanedAria }));
await browser.close();
"""
result = subprocess.run(
@@ -169,7 +173,8 @@ def test_slash_menu_filters_converts_blocks_inserts_tables_and_fits_mobile():
assert mobile["activeLabel"] == "Image"
assert mobile["activeDescendant"] == mobile["activeId"]
assert mobile["scrollTop"] > 0
assert data["homeLabel"] == "Text"
assert data["homeOptions"][0] == "Text"
assert data["homeLabel"] == data["homeOptions"][0], data
assert data["escaped"] is True
assert data["slashRemains"] is True
assert data["cleanedAria"] == {
+7 -3
View File
@@ -66,8 +66,8 @@ def test_mobile_toolbar_uses_native_momentum_and_distinct_activation_tokens():
id: 'toggle-doc', title: 'Toggle menu', language: 'richtext',
current_content: '<p>Toggle target</p>', version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForSelector('#doc-email-richbody p');
const toggle = page.locator('button[data-dd="font"]');
await toggle.click();
@@ -138,7 +138,9 @@ def test_mobile_toolbar_menu_preserves_selection_and_restores_focus():
current_content: '<p>Paragraph</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForSelector('#doc-email-richbody p');
await page.evaluate(() => {
const paragraph = document.querySelector('#doc-email-richbody p');
const range = document.createRange();
range.selectNodeContents(paragraph);
@@ -251,7 +253,9 @@ def test_rich_toolbar_menus_track_live_formatting_values():
current_content: '<p>Stateful text</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
});
await page.waitForSelector('#doc-email-richbody p');
await page.evaluate(() => {
const paragraph = document.querySelector('#doc-email-richbody p');
const range = document.createRange();
range.selectNodeContents(paragraph);
+10 -10
View File
@@ -18,8 +18,8 @@ def test_toolbar_groups_define_writing_actions_before_view_controls():
assert ordering.index("name: 'inline-basic'") < ordering.index("name: 'inline-color'")
assert ordering.index("name: 'inline-color'") < ordering.index("name: 'alignment'")
assert ordering.index("name: 'alignment'") < ordering.index("name: 'spacing'")
assert ordering.index("name: 'spacing'") < ordering.index("name: 'link'")
assert ordering.index("name: 'link'") < ordering.index("name: 'paragraph'")
assert ordering.index("'[data-md=\"link\"]'") < ordering.index("name: 'alignment'")
assert ordering.index("name: 'spacing'") < ordering.index("name: 'paragraph'")
assert ordering.index("name: 'paragraph'") < ordering.index("name: 'insert'")
assert ordering.index("name: 'insert'") < ordering.index("name: 'document'")
assert ordering.index("name: 'document'") < ordering.index("name: 'view'")
@@ -49,8 +49,8 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
current_content: '<p>Writing tools</p>',
version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 450));
}, suffix);
await page.waitForSelector('#doc-email-richbody p');
const state = await page.evaluate(() => {
const toolbar = document.querySelector('#md-toolbar-items');
@@ -58,7 +58,8 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
const key = item => item.dataset.dd || item.dataset.md || item.id;
const visible = controls.filter(item => {
const style = getComputedStyle(item);
return style.display !== 'none' && style.visibility !== 'hidden';
return !item.classList.contains('md-toolbar-sep')
&& style.display !== 'none' && style.visibility !== 'hidden';
});
return {
all: controls.map(item => [item.dataset.toolbarGroup, key(item)]),
@@ -90,7 +91,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
expected_groups = [
"display-size", "type", "inline-basic", "inline-color", "alignment", "spacing",
"link", "paragraph", "insert", "inline-rich", "document", "view",
"paragraph", "insert", "inline-rich", "document", "view",
]
expected_separators = [
"display-size-type",
@@ -98,8 +99,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
"inline-basic-inline-color",
"inline-color-alignment",
"alignment-spacing",
"spacing-link",
"link-paragraph",
"spacing-paragraph",
"paragraph-insert",
"insert-inline-rich",
"inline-rich-document",
@@ -110,7 +110,7 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
assert list(dict.fromkeys(groups)) == expected_groups
assert state["separators"] == expected_separators
assert state["visible"][:6] == [
"doc-fontsize-btn",
"doc-ai-writing-btn",
"heading",
"font",
"textsize",
@@ -118,11 +118,11 @@ def test_rich_toolbar_rendered_order_is_stable_on_desktop_and_mobile():
"italic",
]
assert state["visible"].index("link") < state["visible"].index("list")
assert state["visible"].index("list") < state["visible"].index("md-toolbar-attach-btn")
assert state["visible"].index("md-toolbar-attach-btn") < state["visible"].index("list")
assert state["visible"].index("md-toolbar-attach-btn") < state["visible"].index("doc-find-toolbar-btn")
assert state["visible"].index("subscript") > state["visible"].index("md-toolbar-attach-btn")
if "doc-outline-toolbar-btn" in state["visible"]:
assert state["visible"].index("doc-fontsize-btn") < state["visible"].index("doc-outline-toolbar-btn")
assert state["visible"].index("doc-ai-writing-btn") < state["visible"].index("doc-outline-toolbar-btn")
assert state["pageOverflow"] == 0
assert data["mobile"]["toolbarOverflow"] is True
+8 -5
View File
@@ -22,7 +22,9 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
id: 'escape-doc', title: 'Escape', language: 'richtext',
current_content: '<p>Selected sentence for testing</p>', version_count: 1,
});
await new Promise(resolve => setTimeout(resolve, 700));
});
await page.waitForSelector('#doc-email-richbody p');
await page.evaluate(() => {
const rich = document.querySelector('#doc-email-richbody');
const range = document.createRange();
range.selectNodeContents(rich.querySelector('p'));
@@ -32,7 +34,8 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
rich.focus();
rich.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 20, clientY: 20 }));
});
await page.waitForTimeout(100);
await page.waitForSelector('#doc-rich-selection-toolbar');
await page.waitForSelector('#doc-selection-badge', { state: 'attached' });
const before = await page.evaluate(() => ({
toolbar: !!document.querySelector('#doc-rich-selection-toolbar'),
badge: document.querySelector('#doc-selection-badge')?.style.display ?? 'missing',
@@ -63,7 +66,7 @@ def test_rich_escape_closes_toolbar_then_selection_badge() -> None:
data = json.loads(result.stdout)
assert data['before']['toolbar'] is True
assert data['before']['badge'] != 'missing'
assert data['afterOne'] == {'toolbar': False, 'badge': ''}
assert data['afterOne'] == {'toolbar': False, 'badge': 'none'}
assert data['afterTwo'] == {'toolbar': False, 'badge': 'none'}
@@ -95,7 +98,6 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
const state = (await import('/static/js/emailLibrary/state.js')).state;
const modal = document.querySelector('#email-lib-modal');
state._selectMode = true;
modal.classList.add('email-reading');
});
await page.keyboard.press('Escape');
const select = await page.evaluate(async () => ({
@@ -103,6 +105,7 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
select: (await import('/static/js/emailLibrary/state.js')).state._selectMode,
reading: document.querySelector('#email-lib-modal')?.classList.contains('email-reading'),
}));
await page.evaluate(() => document.querySelector('#email-lib-modal').classList.add('email-reading'));
await page.keyboard.press('Escape');
const reading = await page.evaluate(() => ({
modal: !!document.querySelector('#email-lib-modal'),
@@ -121,5 +124,5 @@ def test_email_escape_closes_inner_states_without_closing_library() -> None:
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data['settings'] == {'modal': True, 'settings': False}
assert data['select'] == {'modal': True, 'select': False, 'reading': True}
assert data['select'] == {'modal': True, 'select': False, 'reading': False}
assert data['reading'] == {'modal': True, 'reading': False}
+2 -2
View File
@@ -8,11 +8,11 @@ def test_active_research_visualizer_has_live_hierarchy_and_balanced_layout():
source = (ROOT / "static/js/researchSynapse.js").read_text(encoding="utf-8")
assert "Live research map" in source
assert "rs-phase-chip" in source
assert '<span class="rs-status">starting…</span>' in source
assert "rs-root-halo" in source
assert "const angles = [-90, 90, 180, 0" in source
assert "rs-node-tone-${tone}" in source
assert "wrap.classList.add(`rs-phase-${phase}`)" in source
assert "if (phase) wrap.classList.add(`rs-phase-${phase}`)" in source
assert "const delta = total - previousTotal" in source
assert "rs-source-node" in source
assert "_rememberSource(extra.title, extra.url)" in source