Group inline links and list items into browse lines

This commit is contained in:
ak47 committed 2026-11-07 20:31:50 -05:00
1 parent 3dbd7f2554
commit d01965c00d
16 files changed
+309 -15

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# Browse visual lines
Cthulhu previously made every link and list item a line boundary, even when
several appeared on the same rendered row. The local Orca reference
`src/orca/scripts/web/script_utilities.py:_get_line_contents_at_offset` uses
geometry plus block ancestry. Its `ax_utilities.py:get_nearest_block_ancestor`
treats links and non-grid list items as inline for this purpose.
Cthulhu now lets ordinary links inherit their block and list items inherit
their list. Native text rectangles still determine whether fragments share
a line. Headings, paragraphs, lists, table cells, non-span sections, hard
newlines, code and editable controls retain their boundaries. Grid list
items stay independent using the captured AT-SPI display attribute. Explicitly
named atomic links and other whole controls retain their existing stops.
This is a focused improvement, not full parity with Orca's layout policy.
The move owns temporary assembled lines; the retained cursor, activation and
braille routing still use actual objects and native offsets. Say All retains
fragments and their privacy ancestry. Speech coalesces plain spans but keeps
inline link roles, visited states and role sounds, plus heading announcements.
## Automated checks
- Review, AT-SPI, presentation and application package tests.
- `CTHULHU_NATIVE_BROWSER_TESTS=fragments,lists,namedlinks,linkedheadings,document,selection python3 tests/native_browser.py`
- `python3 tests/native_firefox.py`
The fragments fixture covers adjacent links, text around a link, a horizontal
list, vertical list items, separate headings, reverse navigation, wrapped
spans, code, Say All and native braille routing. Firefox's existing baseline
covers browser navigation and tabs; the new horizontal fixture is tested in
Brave. Tests use private Xvfb/D-Bus sessions without replacing the live reader.
## Live acceptance
1. Quit the current reader with Cthulhu+Q.
2. Press Ctrl+Escape, then Shift+G. These are separate key presses. The second
rebuilds and launches Cthulhu through `~/devel/cthulhu-go.sh`.
3. Open `file:///home/angus/devel/cthulhu/tests/browser-fragments.html` in the
browser. In browse mode, press Ctrl+Home, then 3 to reach “Line grouping”.
4. Press Down once: hear First link, Second link and trailing text together,
with link announcements according to the configured speech/sound settings.
Down again reads East and West together. Up returns to the full link row.
5. Continue Down: North and South remain separate, followed by Separate
heading and Final paragraph as separate lines. The browser must stay in
browse mode and keyboard focus must not jump into an unrelated control.
6. Use K/Shift+K to select individual links and Enter to activate one. Each
link still targets Split heading. Check Home/End and Left/Right within a
combined row, then Down to leave that whole row.
7. With braille, confirm the row contains both links; route to characters in
each link and check the caret and subsequent activation target. Run Say
All and listen for missing/repeated content and retained link roles.
8. Repeat on the original page in Brave and Firefox. Report exact speech,
braille content, focused control and keys whose behavior differs. Inspect
`~/devel/cthulhu-go.log` if anything is unexpected.
Physical keyboard, audible speech and physical braille acceptance remain
pending. Human review and live testing are required before submission.
+3
View File
@@ -284,6 +284,9 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
ancestor.Name = ""
line.Contents = append(line.Contents, atspi.DocumentContent{Snapshot: ancestor})
}
if result.DocumentCaretLine != nil && result.DocumentCaretCommand != review.CaretNextWord && result.DocumentCaretCommand != review.CaretPreviousWord {
line = presentation.DocumentVisualLineForSpeech(*result.DocumentCaretLine)
}
spoken := presentation.DocumentLineSpeechWithOptions(line, options)
request.Text, request.Chunks = spoken.Text, spoken.Chunks
request.Sound = presentation.DocumentLineSoundCues(line, options)
+28
View File
@@ -26,6 +26,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/review"
"git.stormux.org/storm/cthulhu/internal/sound"
"log/slog"
"strings"
"testing"
"time"
)
@@ -420,3 +421,30 @@ func TestCaretPresentationUsesAncestorRoleSound(t *testing.T) {
t.Fatal("missing link sound")
}
}
func TestCaretVisualLinePresentsEveryLink(t *testing.T) {
plain := atspi.Snapshot{BusName: ":1.20", Path: "/plain", Role: "static"}
link := atspi.Snapshot{BusName: ":1.20", Path: "/link", Role: "link", Visited: true}
line := atspi.DocumentLine{Contents: []atspi.DocumentContent{
{Snapshot: plain, Text: "Before "}, {Snapshot: link, Text: "Example"}, {Snapshot: plain, Text: " after."},
}, SourceContexts: [][]atspi.Snapshot{{plain}, {link}, {plain}}}
result := review.Result{Moved: true, DocumentCaretCommand: review.CaretNextLine,
DocumentCaretText: "Before Example after.", DocumentCaretLine: &line,
DocumentPosition: review.DocumentPosition{Snapshot: plain},
}
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 1)}
presentDocumentCaretResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions())
if got := (<-queue.requests).Text; got != "Before, Example, visited link, after." {
t.Fatalf("line speech = %q", got)
}
result.ReadAllContext, result.ReadAllLines = t.Context(), []atspi.DocumentLine{line}
presentReadAllResult(slog.New(slog.DiscardHandler), queue, result, presentation.DefaultOptions())
request := <-queue.requests
var text []string
for _, part := range request.ReadAll {
text = append(text, part.Text)
}
if got := strings.Join(text, " "); got != "Before Example visited link after." {
t.Fatalf("Say All = %q", got)
}
}
+3
View File
@@ -39,6 +39,9 @@ func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result rev
options.Reading = &reading
options.ReadFullRowInGUITable = false
for index, line := range result.ReadAllLines {
if len(line.Contents) > 1 && len(line.SourceContexts) == len(line.Contents) {
line = presentation.DocumentVisualLineForSpeech(line)
}
if spoken := presentation.DocumentLineSpeechWithOptions(line, options); spoken.Text != "" {
// One request owns the original observation ticket. Conservatively
// keep the entire reading private if any spoken line is sensitive
+1
View File
@@ -575,6 +575,7 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
if (xmlRoles != NULL) {
snapshot.xmlRoles = g_strdup(xmlRoles);
}
snapshot.display = g_strdup(g_hash_table_lookup(attributes, "display"));
const gchar *htmlTag = g_hash_table_lookup(attributes, "tag");
if (htmlTag != NULL) snapshot.htmlTag = g_strdup(htmlTag);
const gchar *elementID = g_hash_table_lookup(attributes, "id");
+1
View File
@@ -53,6 +53,7 @@ typedef struct {
gchar *valueText;
gchar *xmlRoles;
gchar *htmlTag;
gchar *display;
gchar *elementID;
gboolean supportsClick;
gboolean liveRegion;
@@ -162,6 +162,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
snapshot.CharacterCount = characterCount
snapshot.EmbeddedObjectCount = int(captured.embeddedObjectCount)
snapshot.XMLRoles = sanitizeSnapshotField(C.GoString(captured.xmlRoles))
snapshot.Display = sanitizeSnapshotField(C.GoString(captured.display))
snapshot.HTMLTag = sanitizeSnapshotField(C.GoString(captured.htmlTag))
snapshot.ElementID = sanitizeSnapshotField(C.GoString(captured.elementID))
snapshot.CurrentValue = float64(captured.currentValue)
@@ -181,6 +182,7 @@ func snapshotFromNativeWithOptions(source *C.AtspiAccessible, text, caretCharact
C.g_free(C.gpointer(captured.valueText))
C.g_free(C.gpointer(captured.xmlRoles))
C.g_free(C.gpointer(captured.htmlTag))
C.g_free(C.gpointer(captured.display))
C.g_free(C.gpointer(captured.elementID))
return snapshot
}
+1
View File
@@ -140,6 +140,7 @@ type Snapshot struct {
ToolkitName string
XMLRoles string
HTMLTag string
Display string // CSS display attribute used for document line boundaries.
ElementID string
// PositionInSet is one-based; zero means unavailable. SetSize must be
@@ -0,0 +1,64 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import "git.stormux.org/storm/cthulhu/internal/atspi"
// DocumentVisualLineForSpeech coalesces native fragments without losing inline
// link roles. The returned copy is for speech only: braille and activation keep
// the original objects and offsets. SourceContexts retains the privacy evidence.
func DocumentVisualLineForSpeech(line atspi.DocumentLine) atspi.DocumentLine {
result := line
result.Contents = nil
var previous atspi.ObjectID
var heading *atspi.Snapshot
for i, content := range line.Contents {
snapshot := content.Snapshot
var semantic atspi.ObjectID
if snapshot.Role == "heading" && heading == nil {
copy := snapshot
heading = &copy
}
ancestors := snapshot.BrailleContext
if i < len(line.SourceContexts) {
ancestors = line.SourceContexts[i]
}
if snapshot.Role == "link" {
semantic = snapshot.ID()
}
for _, ancestor := range ancestors {
if ancestor.Role == "heading" && heading == nil {
copy := ancestor
heading = &copy
}
if ancestor.Role == "link" && !semantic.Valid() {
snapshot, semantic = ancestor, ancestor.ID()
}
}
value := content.RawText
if value == "" {
value = content.Text
}
if len(result.Contents) > 0 && semantic == previous {
last := &result.Contents[len(result.Contents)-1]
last.Text += content.Separator + value
last.RawText = last.Text
last.Snapshot.Misspelled = last.Snapshot.Misspelled || content.Snapshot.Misspelled
} else {
if !semantic.Valid() {
// Plain spans share a text run; their container is not a role cue.
snapshot.Role = "static"
}
snapshot.Misspelled = content.Snapshot.Misspelled
result.Contents = append(result.Contents, atspi.DocumentContent{Snapshot: snapshot, Text: value, RawText: value})
}
previous = semantic
}
if heading != nil {
heading.Name, heading.Text = "", ""
result.Contents = append(result.Contents, atspi.DocumentContent{Snapshot: *heading})
}
return result
}
@@ -0,0 +1,35 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package presentation
import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"testing"
)
func TestVisualLineSpeechRetainsInlineLinkRoles(t *testing.T) {
plain := atspi.Snapshot{BusName: ":1.20", Path: "/plain", Role: "static"}
link := atspi.Snapshot{BusName: ":1.20", Path: "/link", Role: "link", Visited: true}
other := atspi.Snapshot{BusName: ":1.20", Path: "/other", Role: "link"}
line := atspi.DocumentLine{
Contents: []atspi.DocumentContent{
{Snapshot: plain, Text: "Before "},
{Snapshot: plain, Text: "split"}, {Snapshot: plain, Text: " link", RawText: " link"},
{Snapshot: other, Text: "Next", Separator: " "},
{Snapshot: plain, Text: " after"}, {Snapshot: plain, Text: "."},
},
SourceContexts: [][]atspi.Snapshot{{plain}, {plain, link}, {plain, link}, {other}, {plain}, {plain}},
}
spoken := DocumentVisualLineForSpeech(line)
if got := DocumentLineText(spoken); got != "Before, split link, visited link, Next, link, after." {
t.Fatalf("speech = %q", got)
}
if len(line.Contents) != 6 || line.Contents[1].Snapshot.Role != "static" {
t.Fatal("speech conversion changed routing sources")
}
if len(spoken.SourceContexts) != 6 {
t.Fatal("lost privacy sources")
}
}
+9 -3
View File
@@ -116,17 +116,23 @@ func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atsp
}
// Retain semantic boundaries even if unrelated blocks have matching y values.
// Inline spans/emphasis do not introduce boundaries. Links remain independent
// stops with their existing activation, voice, state and sound presentation.
// Links and same-row list items inherit their surrounding block. Headings,
// paragraphs and table cells remain separate; grid items retain their boundaries.
func visual_line_owners(nodes []atspi.DocumentNode) map[int]atspi.ObjectID {
owners := make(map[int]atspi.ObjectID, len(nodes))
byID := make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))
displays := make(map[atspi.ObjectID]string, len(nodes))
for index, node := range nodes {
owner := byID[node.Parent]
snapshot := node.Snapshot
displays[node.Reference.ID()] = snapshot.Display
switch snapshot.Role {
case "paragraph", "heading", "list item", "table cell", "row header", "column header", "link", "document web", "document frame", "embedded":
case "paragraph", "heading", "list", "table cell", "row header", "column header", "document web", "document frame", "embedded":
owner = node.Reference.ID()
case "list item":
if strings.Contains(displays[node.Parent], "grid") {
owner = node.Reference.ID()
}
case "section":
if snapshot.HTMLTag != "span" {
owner = node.Reference.ID()
@@ -80,9 +80,7 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
{CaretLineEnd, "."},
{CaretNextLine, "Four five six."},
{CaretNextLine, "Next paragraph."},
{CaretNextLine, "Before"},
{CaretNextLine, "ordinary link"},
{CaretNextLine, "after."},
{CaretNextLine, "Before ordinary link after."},
{CaretNextLine, "Split heading"},
{CaretNextLine, "Code first"},
{CaretNextLine, ""}, // Preserve the provider's separate preformatted newline.
@@ -150,7 +148,7 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
t.Fatalf("Read All: %+v", result)
}
for index, want := range []string{"One two three.", "Four five six.", "Next paragraph."} {
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
if got := visual_line_test_text(result.ReadAllLines[index]); got != want {
t.Fatalf("Read All line %d: %q, want %q", index, got, want)
}
}
@@ -211,4 +209,27 @@ func TestNativeBrowserFragmentLines(t *testing.T) {
movement.Position.Snapshot.Role, movement.Position.Snapshot.Path, movement.Position.Snapshot.CharacterCount, movement.Moved, err, step.want)
}
}
if _, err := cursor.MoveCaret(ctx, adapter, CaretDocumentStart); err != nil {
t.Fatal(err)
}
if move, err := cursor.Move(ctx, DocumentHeading, 3, false); err != nil || !move.Moved {
t.Fatalf("line grouping heading: %+v, %v", move, err)
}
for _, step := range []struct {
command DocumentCaretCommand
want string
}{
{CaretNextLine, "First link Second link trailing text."},
{CaretNextLine, "East West"},
{CaretPreviousLine, "First link Second link trailing text."},
{CaretNextLine, "East West"},
{CaretNextLine, "• North"}, {CaretNextLine, "• South"},
{CaretNextLine, "Separate heading"}, {CaretNextLine, "Final paragraph."},
} {
movement, err := cursor.MoveCaret(ctx, adapter, step.command)
if err != nil || strings.TrimSpace(movement.Text) != step.want {
t.Fatalf("grouping %s: %q, %v; want %q", step.command, movement.Text, err, step.want)
}
}
}
+65 -3
View File
@@ -163,16 +163,16 @@ func TestReadAllFragmentLinesAndRemainder(t *testing.T) {
t.Fatalf("read all: %+v", result)
}
for index, want := range []string{"ne two.", "Three four.", "Next paragraph"} {
if got := result.ReadAllLines[index].Contents[0].Text; got != want {
if got := visual_line_test_text(result.ReadAllLines[index]); got != want {
t.Fatalf("line %d: %q, want %q", index, got, want)
}
}
// Say All flattens the fragments into one text value, but must retain
// Say All retains the fragments and must retain
// every original source and ancestry for Remote's privacy decision.
contexts := result.ReadAllLines[0].SourceContexts
if len(contexts) != 2 || len(contexts[0]) < 2 || len(contexts[1]) < 2 ||
contexts[0][0].ID() != webID("/one") || contexts[1][0].ID() != webID("/two") || contexts[1][1].ID() != webID("/span") {
t.Fatal("flattened Say All line lost fragment ancestry")
t.Fatal("Say All line lost fragment ancestry")
}
if cursor.places[0].offset != 1 {
t.Fatal("assembly moved the user's cursor")
@@ -290,3 +290,65 @@ func TestDocumentFragmentLineAcrossWrappedObject(t *testing.T) {
}
}
}
func visual_line_test_text(line atspi.DocumentLine) string {
var text strings.Builder
for _, part := range line.Contents {
text.WriteString(part.Separator)
text.WriteString(part.Text)
}
return text.String()
}
func TestDocumentSameRowLinksAndListItems(t *testing.T) {
for _, role := range []string{"link", "list item"} {
t.Run(role, func(t *testing.T) {
cursor, reader := fragment_cursor(t, "list")
doc, _ := cursor.tree.Document(t.Context(), cursor.places[0].object)
var nodes []atspi.TreeNode
for _, node := range doc.Nodes {
if node.Reference.ID() == webID("/one") || node.Reference.ID() == webID("/two") || node.Reference.ID() == webID("/three") || node.Reference.ID() == webID("/four") {
node.Snapshot.Role = role
}
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children})
}
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
for _, step := range []struct {
command DocumentCaretCommand
want string
}{
{CaretDocumentStart, "One two."}, {CaretNextLine, "Three four."},
{CaretPreviousLine, "One two."}, {CaretLineEnd, "."}, {CaretNextLine, "Three four."},
} {
move, err := cursor.MoveCaret(t.Context(), reader, step.command)
if err != nil || move.Text != step.want {
t.Fatalf("%s: %q, %v; want %q", step.command, move.Text, err, step.want)
}
}
})
}
}
func TestDocumentGridListItemsStaySeparate(t *testing.T) {
cursor, reader := fragment_cursor(t, "list")
doc, _ := cursor.tree.Document(t.Context(), cursor.places[0].object)
var nodes []atspi.TreeNode
for _, node := range doc.Nodes {
if node.Snapshot.Role == "list" {
node.Snapshot.Display = "grid"
}
if node.Reference.ID() == webID("/one") {
node.Snapshot.Role = "list item"
}
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children})
}
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
move, err := cursor.MoveCaret(t.Context(), reader, CaretDocumentStart)
if err != nil || strings.TrimSpace(move.Text) != "One" {
t.Fatalf("grid boundary: %+v, %v", move, err)
}
}
+7 -5
View File
@@ -115,10 +115,7 @@ func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Resul
movement.Position.Snapshot.Misspelled = movement.Misspelled
line := atspi.DocumentLine{Document: movement.Position.Document, Contents: []atspi.DocumentContent{{Snapshot: movement.Position.Snapshot, Text: movement.Text, RawText: movement.RawText}}}
if movement.Line != nil {
// Presentation deliberately flattens a visual line into one spoken
// string. Keep every contributing source for independent privacy
// checks, including sources outside the caret object's ancestry.
line.SourceContexts = movement.Line.SourceContexts
line = *movement.Line
}
owner, err := cursor.tree.DocumentContext(movement.Position.Object)
if err != nil {
@@ -136,7 +133,12 @@ func (worker *Worker) readAll(ctx context.Context, cursor *DocumentCursor) Resul
} else {
line.Context = owner.Ancestors
roles := map[string]bool{movement.Position.Snapshot.Role: true}
for _, ancestor := range owner.Ancestors {
ancestors := owner.Ancestors
if movement.Line != nil {
// Assembled lines carry each fragment's semantic ancestry.
ancestors = nil
}
for _, ancestor := range ancestors {
if (ancestor.Role == "link" || ancestor.Role == "heading") && !roles[ancestor.Role] {
roles[ancestor.Role] = true
ancestor.Name = ""
+1
View File
@@ -76,3 +76,4 @@ atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca
web-message-article-focus,src/cthulhu/scripts/web/speech_generator.py;src/cthulhu/formatting.py,web,Announce named message articles when focus enters their controls,,internal/atspi/speech_metadata_native.go;internal/app/reading_context.go;internal/presentation/reading.go,in-progress,Speech replay covers browse and focus modes plus pending browser discovery; private Chromium article capture regression. See docs/testing/messaging.md.,Private Chromium fixture uses isolated X11 and D-Bus.,,Recorded speech only; live Discord listening pending.,Physical display acceptance pending.,Shared named article handling; no Discord identifiers or child scans. Preserve ordinary container context policy; announce article labels on entry without repeating them on exit.
web-disclosure-refresh,src/cthulhu/action_presenter.py; src/cthulhu/scripts/web/script_utilities.py,web,Discover and keep disclosure options open during Enter or Actions-list activation and caret navigation,,internal/atspi internal/app,implemented,"CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py",Private Chromium/Xorg fixture covers both activation paths; character and line reading; blur and document-scroll dismissal; offscreen text and images. Original public page also checked in a private Chromium profile.,,Original-page audible speech acceptance pending.,Physical Braille acceptance pending.,Preserve target generations and strict ownership; refresh action snapshots and ancestor order. Text caret placement reveals its range without an extra whole-object scroll that dismisses popups; non-text scrolling retained. User installed Brave acceptance pending. Link-sounds native Tab pan baseline fails before and after this fix. See docs/testing/browser-clickables.md.
mumble-message-history,src/cthulhu/scripts/apps/Mumble/chat.py;src/cthulhu/chat.py,messaging,Review or copy twelve recent Mumble Activity log messages with Cthulhu+F1 through F12,,internal/atspi internal/messaging internal/app internal/input,in-progress,Message cleanup and twelve-slot ordering tests; focused messaging race checks; python3 -B tests/native_messaging.py.,Private Qt transcript and unrelated-provider checks; synthetic X11 review and copy chords through F12; clipboard checks.,,Recorded presentation tested; actual Mumble listening pending.,Single flash and review text tested; physical display pending.,Legacy combined history defaults. Strip timestamps and Qt noncharacters; ignore timestamp-only insertions; preserve repeated messages and keyboard focus. Pidgin keeps its nine-message review range. History is session-only and clears on lock or transcript retirement. See docs/testing/messaging.md for live acceptance.
web-inline-visual-lines,Orca src/orca/scripts/web/script_utilities.py;src/orca/ax_utilities.py,web,Group same-row ordinary links and list items during browse line navigation,,internal/review internal/presentation internal/app internal/atspi,in-progress,Review AT-SPI presentation and app package tests; private browser fragments lists namedlinks linkedheadings document selection; Firefox baseline and tabs,Private Brave and Firefox Xvfb sessions,,Speech requests verified; live listening pending,Native fragment routing checked; physical display acceptance pending,See docs/testing/browser-visual-lines.md. Preserve block boundaries and grid items; atomic named controls remain independent. Human review and original-page live acceptance pending.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
76 web-message-article-focus src/cthulhu/scripts/web/speech_generator.py;src/cthulhu/formatting.py web Announce named message articles when focus enters their controls internal/atspi/speech_metadata_native.go;internal/app/reading_context.go;internal/presentation/reading.go in-progress Speech replay covers browse and focus modes plus pending browser discovery; private Chromium article capture regression. See docs/testing/messaging.md. Private Chromium fixture uses isolated X11 and D-Bus. Recorded speech only; live Discord listening pending. Physical display acceptance pending. Shared named article handling; no Discord identifiers or child scans. Preserve ordinary container context policy; announce article labels on entry without repeating them on exit.
77 web-disclosure-refresh src/cthulhu/action_presenter.py; src/cthulhu/scripts/web/script_utilities.py web Discover and keep disclosure options open during Enter or Actions-list activation and caret navigation internal/atspi internal/app implemented CTHULHU_BROWSER=/usr/bin/chromium CTHULHU_NATIVE_BROWSER_TESTS=actions,disclosure,reveal,positions,ticking,document python3 tests/native_browser.py Private Chromium/Xorg fixture covers both activation paths; character and line reading; blur and document-scroll dismissal; offscreen text and images. Original public page also checked in a private Chromium profile. Original-page audible speech acceptance pending. Physical Braille acceptance pending. Preserve target generations and strict ownership; refresh action snapshots and ancestor order. Text caret placement reveals its range without an extra whole-object scroll that dismisses popups; non-text scrolling retained. User installed Brave acceptance pending. Link-sounds native Tab pan baseline fails before and after this fix. See docs/testing/browser-clickables.md.
78 mumble-message-history src/cthulhu/scripts/apps/Mumble/chat.py;src/cthulhu/chat.py messaging Review or copy twelve recent Mumble Activity log messages with Cthulhu+F1 through F12 internal/atspi internal/messaging internal/app internal/input in-progress Message cleanup and twelve-slot ordering tests; focused messaging race checks; python3 -B tests/native_messaging.py. Private Qt transcript and unrelated-provider checks; synthetic X11 review and copy chords through F12; clipboard checks. Recorded presentation tested; actual Mumble listening pending. Single flash and review text tested; physical display pending. Legacy combined history defaults. Strip timestamps and Qt noncharacters; ignore timestamp-only insertions; preserve repeated messages and keyboard focus. Pidgin keeps its nine-message review range. History is session-only and clears on lock or transcript retirement. See docs/testing/messaging.md for live acceptance.
79 web-inline-visual-lines Orca src/orca/scripts/web/script_utilities.py;src/orca/ax_utilities.py web Group same-row ordinary links and list items during browse line navigation internal/review internal/presentation internal/app internal/atspi in-progress Review AT-SPI presentation and app package tests; private browser fragments lists namedlinks linkedheadings document selection; Firefox baseline and tabs Private Brave and Firefox Xvfb sessions Speech requests verified; live listening pending Native fragment routing checked; physical display acceptance pending See docs/testing/browser-visual-lines.md. Preserve block boundaries and grid items; atomic named controls remain independent. Human review and original-page live acceptance pending.
+6
View File
@@ -27,5 +27,11 @@
<p>Review the code.</p><p>Report actionable issues.</p>
</div>
<p>After the suggestion.</p>
<h3>Line grouping</h3>
<p><a href="#after">First link</a> <a href="#after">Second link</a> trailing text.</p>
<ul style="display:flex; gap:24px; list-style:none; padding:0"><li>East</li><li>West</li></ul>
<ul><li>North</li><li>South</li></ul>
<h3>Separate heading</h3>
<p>Final paragraph.</p>
</body>
</html>