From 8ce5e5cdadd0ffc02c1bade6b5f8c239f4f84118 Mon Sep 17 00:00:00 2001 From: Hunter Jozwiak Date: Tue, 3 Nov 2026 00:17:08 -0500 Subject: [PATCH] Expand embedded objects in braille document lines Structural navigation to a heading whose native text is a single embedded-object placeholder for its child link showed only that placeholder in braille while speech used the expanded title. Braille line reads now expand embedded objects like speech capture, preserving RawText and native offsets; expanded lines carry no cell routes or native selection/attribute dots and only a line-start cursor. Frame assembly strips any remaining placeholders and falls back to displayed text so a placeholder never reaches the display. Regression tests cover placeholder-only, mixed, expanded, assembled, and empty headings plus native linked-heading and Firefox mixed-line braille. Maintainer confirms physical braille and speech agree on YouTube headings. --- internal/app/braille_presentation.go | 96 +++++++++++++--- internal/app/braille_presentation_test.go | 119 ++++++++++++++++++++ internal/app/linked_headings_native_test.go | 13 +++ internal/atspi/adapter_document_native.go | 14 +++ internal/atspi/document_caret.go | 17 ++- internal/atspi/firefox_native_test.go | 5 + testdata/parity-ledger.csv | 2 +- 7 files changed, 245 insertions(+), 21 deletions(-) diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go index e81d522..49e8b5b 100644 --- a/internal/app/braille_presentation.go +++ b/internal/app/braille_presentation.go @@ -32,19 +32,27 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c return linkedHeadingBraille(frame, result.DocumentPosition.LinkedHeading, options, textLength) } frame := braille.Frame{Cursor: -1, WordWrap: options.WordWrap} - appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int) { + // native reports that raw maps one-to-one onto native offsets at + // sourceStart. Expanded or placeholder-stripped text keeps display + // order but gets no routes or native-offset indicators, and only a + // caret at the very start of the span can be placed honestly. + appendContent := func(source atspi.Snapshot, raw, separator string, sourceStart int, native bool) { frame.Text += separator start := utf8.RuneCountInString(frame.Text) frame.Text += raw length := utf8.RuneCountInString(raw) if source.ID() == snapshot.ID() && result.DocumentPosition.Offset >= sourceStart && result.DocumentPosition.Offset <= sourceStart+length { - frame.Cursor = start + result.DocumentPosition.Offset - sourceStart + if native { + frame.Cursor = start + result.DocumentPosition.Offset - sourceStart + } else if result.DocumentPosition.Offset == sourceStart { + frame.Cursor = start + } } - if source.ID().Valid() && source.Role != "password text" && length > 0 { + if native && source.ID().Valid() && source.Role != "password text" && length > 0 { frame.Routes = append(frame.Routes, braille.TextRoute{Start: start, End: start + length, SourceStart: sourceStart, TextLength: length, BusName: source.BusName, Path: source.Path, SourceText: raw}) } - if options.SelectionIndicator != 0 && source.SelectionEnd > source.SelectionStart { + if native && options.SelectionIndicator != 0 && source.SelectionEnd > source.SelectionStart { frame.Dots = append(frame.Dots, make([]byte, utf8.RuneCountInString(frame.Text)-len(frame.Dots))...) for offset := max(source.SelectionStart, sourceStart); offset < min(source.SelectionEnd, sourceStart+length); offset++ { frame.Dots[start+offset-sourceStart] |= byte(options.SelectionIndicator) @@ -60,7 +68,7 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c frame.Dots[offset] |= byte(options.LinkIndicator) } } - if len(configured) != 0 { + if native && len(configured) != 0 { if len(source.TextAttributeRuns) != 0 { for _, run := range source.TextAttributeRuns { markTextAttributeRun(&frame, run, sourceStart, start, length, configured[0]) @@ -79,12 +87,38 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c if content.Snapshot.Role == "password text" { continue } - appendContent(content.Snapshot, raw, content.Separator, content.Start) + // A placeholder fragment stands for child objects presented + // as their own contents; drop it instead of duplicating them. + native := !strings.ContainsRune(raw, '\ufffc') + if !native { + raw = stripEmbeddedObjects(raw) + } + appendContent(content.Snapshot, raw, content.Separator, content.Start, native) + } + if strings.TrimSpace(frame.Text) == "" { + // Nothing but placeholders survived assembly; fall back to + // the displayed text speech would use for this object. + if text := brailleDisplayedText(snapshot); text != "" { + frame.Text, frame.Routes, frame.Dots = text, nil, nil + if frame.Cursor > 0 { + frame.Cursor = 0 + } + } } } else { - snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd - snapshot.TextAttributeRuns = line.TextAttributeRuns - appendContent(snapshot, strings.TrimRight(line.Text, "\r\n"), "", line.Start) + text := strings.TrimRight(line.Text, "\r\n") + native := !line.Expanded && !strings.ContainsRune(text, '\ufffc') + if !native { + text = stripEmbeddedObjects(text) + if strings.TrimSpace(text) == "" { + text = brailleDisplayedText(snapshot) + } + } + if native { + snapshot.SelectionStart, snapshot.SelectionEnd = line.SelectionStart, line.SelectionEnd + snapshot.TextAttributeRuns = line.TextAttributeRuns + } + appendContent(snapshot, text, "", line.Start, native) } textLength := utf8.RuneCountInString(frame.Text) if frame.Cursor < 0 || frame.Cursor > textLength { @@ -163,6 +197,23 @@ func brailleFocusFrameWithOptions(s atspi.Snapshot, options config.BrailleOption return braille_context_frame(braille_object_frame(s, options, configured...), s, options) } +// Embedded-object placeholders stand for child accessibles in native text. +// They are structure, not displayed characters, and must never reach a frame. +func stripEmbeddedObjects(value string) string { + return strings.ReplaceAll(value, "\ufffc", "") +} + +// brailleDisplayedText returns the text speech uses for an object whose +// native line is only placeholders: expanded text first, then the name. +func brailleDisplayedText(snapshot atspi.Snapshot) string { + for _, candidate := range []string{snapshot.Text, snapshot.Name} { + if candidate = strings.TrimSpace(candidate); candidate != "" && !strings.ContainsRune(candidate, '\ufffc') { + return candidate + } + } + return "" +} + func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, configured ...config.TextAttributeSettings) braille.Frame { if frame, handled := libreOfficeBrailleFrame(s, options); handled { return frame @@ -174,6 +225,19 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi label = "" } line := strings.TrimRight(s.BrailleLine, "\r\n") + // A line containing embedded-object placeholders cannot map cells + // to native offsets. Prefer the expanded capture of the same line, + // then real surrounding text, then the accessible name. + native := !strings.ContainsRune(line, '\ufffc') + if !native { + if expanded := strings.TrimSpace(s.Text); expanded != "" && !strings.ContainsRune(expanded, '\ufffc') { + line = expanded + } else if stripped := stripEmbeddedObjects(line); strings.TrimSpace(stripped) != "" { + line = stripped + } else { + line = brailleDisplayedText(s) + } + } if label == line { label = "" } @@ -186,7 +250,11 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi cursor := -1 relative := s.BrailleCaret - s.BrailleLineStart if s.TextCaretKnown && relative >= 0 && relative <= utf8.RuneCountInString(s.BrailleLine) { - cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line)) + if native { + cursor = utf8.RuneCountInString(prefix) + min(relative, utf8.RuneCountInString(line)) + } else if relative == 0 { + cursor = utf8.RuneCountInString(prefix) + } } if role != "terminal" && role != "heading" && !options.DisableEOL { text += " $l" @@ -194,7 +262,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi text += " " } frame := braille.Frame{Text: text, Cursor: cursor, WordWrap: options.WordWrap} - if options.SelectionIndicator != 0 && s.SelectionEnd > s.SelectionStart { + if native && options.SelectionIndicator != 0 && s.SelectionEnd > s.SelectionStart { frame.Dots = make([]byte, utf8.RuneCountInString(frame.Text)) start := utf8.RuneCountInString(prefix) for source := max(s.SelectionStart, s.BrailleLineStart); source < min(s.SelectionEnd, s.BrailleLineStart+utf8.RuneCountInString(line)); source++ { @@ -216,7 +284,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi break } } - if len(configured) != 0 { + if native && len(configured) != 0 { if len(s.TextAttributeRuns) != 0 { for _, run := range s.TextAttributeRuns { markTextAttributeRun(&frame, run, s.BrailleLineStart, utf8.RuneCountInString(prefix), utf8.RuneCountInString(line), configured[0]) @@ -226,7 +294,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi } } // Only actual text and its EOL marker route; labels and state suffixes do not. - if s.TextCaretKnown && s.ID().Valid() { + if native && s.TextCaretKnown && s.ID().Valid() { frame.Routes = []braille.TextRoute{{Start: utf8.RuneCountInString(prefix), End: utf8.RuneCountInString(text), SourceStart: s.BrailleLineStart, TextLength: utf8.RuneCountInString(line), BusName: s.BusName, Path: s.Path, SourceText: s.BrailleLine}} @@ -341,7 +409,7 @@ func braille_object_frame(s atspi.Snapshot, options config.BrailleOptions, confi case "progress bar": text = brailleJoin(name, value, roleName) default: - displayed := s.Text + displayed := stripEmbeddedObjects(s.Text) if displayed == name { displayed = "" } diff --git a/internal/app/braille_presentation_test.go b/internal/app/braille_presentation_test.go index ae93a45..e86473a 100644 --- a/internal/app/braille_presentation_test.go +++ b/internal/app/braille_presentation_test.go @@ -362,6 +362,125 @@ func TestLinkedHeadingBrailleKeepsHeadingAndLinkIndicators(t *testing.T) { } } +// A heading whose native text is a single embedded-object placeholder for +// its child link must braille the same displayed title speech uses, never +// the placeholder itself. +func TestBrowseBrailleEmbeddedObjectHeadingUsesDisplayedText(t *testing.T) { + options := config.DefaultSettings().Braille + heading := atspi.NewSnapshot("/heading", "Cool video title", "heading", "", "") + heading.BusName = ":1.999" + heading.HeadingLevel = 3 + link := atspi.NewSnapshot("/link", "Cool video title", "link", "", "") + link.BusName = ":1.999" + result := review.Result{ + DocumentPosition: review.DocumentPosition{Snapshot: heading, LinkedHeading: &link}, + DocumentBrailleLine: &atspi.DocumentCaretText{Start: 0, End: 1, Text: "\ufffc", RawText: "\ufffc"}, + } + frame := brailleDocumentFrame(result, options) + if frame.Text != "Cool video title h3 lnk" || frame.Cursor != 0 || len(frame.Routes) != 0 { + t.Fatalf("embedded-object heading frame = %+v", frame) + } + // An expanded line on the snapshot wins over the accessible name. + heading.Text = "Expanded title" + result.DocumentPosition.Snapshot = heading + frame = brailleDocumentFrame(result, options) + if frame.Text != "Expanded title h3 lnk" || len(frame.Routes) != 0 { + t.Fatalf("expanded heading frame = %+v", frame) + } + // No displayed text anywhere: role markers only, no placeholder. + heading.Name, heading.Text = "", "" + result.DocumentPosition.Snapshot = heading + result.DocumentPosition.LinkedHeading = nil + frame = brailleDocumentFrame(result, options) + // The leading cell is the usual empty-line cursor cell. + if strings.ContainsRune(frame.Text, '\ufffc') || strings.TrimSpace(frame.Text) != "h3" { + t.Fatalf("empty heading frame = %+v", frame) + } +} + +// An expanded braille line no longer maps cells to native offsets: it must +// carry no routes and no native selection dots, and only a caret at the +// start of the native line can be shown. +func TestBrowseBrailleExpandedLineHasNoNativeMapping(t *testing.T) { + options := config.DefaultSettings().Braille + options.SelectionIndicator, options.LinkIndicator = 0x40, 0x80 + heading := atspi.NewSnapshot("/heading", "Cool video title", "heading", "", "") + heading.BusName = ":1.999" + heading.HeadingLevel = 3 + link := atspi.NewSnapshot("/link", "Cool video title", "link", "", "") + link.BusName = ":1.999" + doc := atspi.Snapshot{BusName: heading.BusName, Path: "/document", Role: "document web"} + tree, err := atspi.NewAccessibleTree(doc.ID(), []atspi.TreeNode{{Snapshot: doc, Children: []atspi.ObjectID{heading.ID()}}, {Snapshot: heading}}) + if err != nil { + t.Fatal(err) + } + docRef, _ := tree.Reference(doc.ID()) + result := review.Result{DocumentEpoch: 1, DocumentFocus: doc.ID(), + DocumentPosition: review.DocumentPosition{Document: docRef, Snapshot: heading, LinkedHeading: &link}, + DocumentBrailleLine: &atspi.DocumentCaretText{Start: 0, End: 1, Text: "Cool video title", RawText: "\ufffc", Expanded: true, SelectionStart: 0, SelectionEnd: 1}} + frame := brailleDocumentFrame(result, options) + if frame.Text != "Cool video title h3 lnk" || frame.Cursor != 0 || len(frame.Routes) != 0 || frame.Document == nil { + t.Fatalf("expanded frame = %+v", frame) + } + // Link dots are display-relative and survive; selection dots are + // native-offset based and must not appear on expanded text. + if len(frame.Dots) != utf8.RuneCountInString(frame.Text) || frame.Dots[0] != 0x80 || frame.Dots[15] != 0x80 || frame.Dots[16] != 0 { + t.Fatalf("expanded dots = %v", frame.Dots) + } + result.DocumentPosition.Offset = 1 + frame = brailleDocumentFrame(result, options) + if frame.Cursor != -1 { + t.Fatalf("interior native offset must hide the cursor on expanded text: %+v", frame) + } +} + +func TestBrailleFocusEmbeddedObjectLineUsesDisplayedText(t *testing.T) { + heading := atspi.NewSnapshot("/heading", "Cool video title", "heading", "", "Cool video title") + heading.HeadingLevel = 3 + heading.TextCaretKnown = true + heading.BrailleLine = "\ufffc" + heading.BrailleCaret = 0 + frame := brailleFocusFrameWithOptions(heading, config.DefaultSettings().Braille) + if frame.Text != "Cool video title h3" || frame.Cursor != 0 || len(frame.Routes) != 0 { + t.Fatalf("focus heading frame = %+v", frame) + } + // Mixed native text keeps its real surrounding text; only the + // placeholder is dropped when no expansion is available. + heading.Text = "Before \ufffc after" + heading.BrailleLine = "Before \ufffc after" + frame = brailleFocusFrameWithOptions(heading, config.DefaultSettings().Braille) + if strings.ContainsRune(frame.Text, '\ufffc') || !strings.Contains(frame.Text, "Before") || !strings.Contains(frame.Text, "after") || len(frame.Routes) != 0 { + t.Fatalf("mixed focus frame = %+v", frame) + } +} + +func TestBrowseBrailleAssembledLineDropsEmbeddedObjectPlaceholders(t *testing.T) { + heading := atspi.NewSnapshot("/heading", "Cool video title", "heading", "", "") + heading.BusName = ":1.999" + heading.HeadingLevel = 3 + static := atspi.NewSnapshot("/static", "", "static", "", "") + static.BusName = ":1.999" + result := review.Result{ + DocumentPosition: review.DocumentPosition{Snapshot: static}, + DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{ + {Snapshot: heading, Start: 0, End: 1, RawText: "\ufffc"}, + {Snapshot: static, Start: 0, End: 16, RawText: "Cool video title"}, + }}, + DocumentCaretContext: []atspi.Snapshot{{Role: "heading", HeadingLevel: 3}}, + } + frame := brailleDocumentFrame(result, config.DefaultSettings().Braille) + if strings.ContainsRune(frame.Text, '\ufffc') || strings.Count(frame.Text, "Cool video title") != 1 { + t.Fatalf("assembled frame = %+v", frame) + } + // A placeholder-only assembled line falls back to displayed text. + result.DocumentCaretLine.Contents = result.DocumentCaretLine.Contents[:1] + result.DocumentPosition.Snapshot = heading + frame = brailleDocumentFrame(result, config.DefaultSettings().Braille) + if frame.Text != "Cool video title h3" || len(frame.Routes) != 0 { + t.Fatalf("placeholder-only assembled frame = %+v", frame) + } +} + func (*tableBrailleCapturer) ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { return atspi.DocumentCaretText{}, nil } diff --git a/internal/app/linked_headings_native_test.go b/internal/app/linked_headings_native_test.go index 51dfef9..256a451 100644 --- a/internal/app/linked_headings_native_test.go +++ b/internal/app/linked_headings_native_test.go @@ -11,6 +11,7 @@ import ( "time" "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) @@ -170,6 +171,7 @@ func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter * if err != nil { t.Fatal(err) } + worker.SetDocumentBrailleEnabled(true) worker.SyncFocus(focus) if err := worker.Start(ctx); err != nil { t.Fatal(err) @@ -207,6 +209,17 @@ func testLinkedHeadingSoundGeometry(t *testing.T, ctx context.Context, adapter * if !result.DocumentPosition.Snapshot.SoundGeometry.ObjectKnown { t.Fatal("scrolled heading lacks fresh geometry") } + // Braille must show the same heading text speech uses, never + // the native embedded-object placeholder for the child link. + frame := brailleDocumentFrame(result, config.DefaultSettings().Braille) + if strings.ContainsRune(frame.Text, '\ufffc') || !strings.Contains(frame.Text, step.name) { + t.Fatalf("scrolled heading braille = %q, want displayed name %q", frame.Text, step.name) + } + if step.target == review.DocumentHeading { + if result.DocumentBrailleLine == nil || strings.ContainsRune(result.DocumentBrailleLine.Text, '\ufffc') || !strings.Contains(result.DocumentBrailleLine.Text, step.name) { + t.Fatalf("heading braille line not expanded: %+v (err %v)", result.DocumentBrailleLine, result.DocumentBrailleErr) + } + } cues := presentation.SoundCues(*link, presentation.Options{ EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: true, }) diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go index c26bc9c..4f83f13 100644 --- a/internal/atspi/adapter_document_native.go +++ b/internal/atspi/adapter_document_native.go @@ -13,6 +13,7 @@ import ( "context" "errors" "strings" + "time" "unicode/utf8" "unsafe" ) @@ -328,6 +329,19 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob return DocumentCaretText{}, err } line.RawText = line.Text + // Braille must show the same descendant text speech expands, not + // embedded-object placeholders. RawText and native offsets stay + // unexpanded; Expanded tells consumers the mapping is degraded. + if unit == DocumentCaretBrailleLine && !snapshot.Editable && strings.ContainsRune(line.Text, '\ufffc') { + deadline := time.Now().Add(100 * time.Millisecond) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + budget := embeddedTextBudget{nodes: 32, characters: maxSnapshotFieldLength, deadline: deadline} + if expanded := expandNativeEmbeddedText(source, line.Text, line.Start, 0, &budget); expanded != line.Text { + line.Text, line.Expanded = expanded, true + } + } if unit == DocumentCaretBrailleLine && adapter.captureTextAttributes.Load() { line.TextAttributeRuns = native_text_attribute_runs(text, line.Start, line.End, TextAttributeRun{}) } diff --git a/internal/atspi/document_caret.go b/internal/atspi/document_caret.go index ab8b285..a39f425 100644 --- a/internal/atspi/document_caret.go +++ b/internal/atspi/document_caret.go @@ -22,12 +22,17 @@ func (unit DocumentCaretUnit) Valid() bool { } type DocumentCaretText struct { - TextAttributeRuns []TextAttributeRun - Start int - End int - Text string - RawText string - Misspelled bool + TextAttributeRuns []TextAttributeRun + Start int + End int + Text string + RawText string + Misspelled bool + // Expanded reports that embedded-object placeholders in Text were + // replaced by their descendant text. Start, End, selections, attribute + // runs, and RawText still describe the unexpanded native line, so + // native offsets no longer map one-to-one into Text. + Expanded bool CharacterCount int SelectionStart, SelectionEnd int // Bounds describe this native line, including offscreen document text. diff --git a/internal/atspi/firefox_native_test.go b/internal/atspi/firefox_native_test.go index f786fc2..9fc5ea7 100644 --- a/internal/atspi/firefox_native_test.go +++ b/internal/atspi/firefox_native_test.go @@ -164,6 +164,11 @@ chromeFocus: if err != nil || mixedLine.RawText != "Mixed before \ufffc after." { t.Fatalf("mixed native line=%q err=%v", mixedLine.RawText, err) } + // RawText keeps native offsets; Text carries the expanded + // descendant text braille displays, with no placeholder. + if !mixedLine.Expanded || strings.ContainsRune(mixedLine.Text, '\ufffc') || !strings.Contains(mixedLine.Text, "Inline message link") { + t.Fatalf("mixed braille line not expanded: %+v", mixedLine) + } var paragraph ObjectReference captured, err := page.Document(ctx, ref) if err != nil { diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index d6864f6..9137460 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -56,7 +56,7 @@ editable-text-selection,src/cthulhu/scripts/default.py;src/cthulhu/script_utilit editable-cut-paste-feedback,src/cthulhu/scripts/default.py;src/cthulhu/script_utilities.py,editing,Announce cut and paste after editable text mutations,,internal/input internal/app,implemented,go test ./internal/app ./internal/input -run 'TestClipboardFeedback|TestIntent' -count=1; CTHULHU_NATIVE_BROWSER_TESTS=selection python3 tests/native_browser.py,Private Brave/Xvfb fixture verified Ctrl+X deletion and Ctrl+V insertion events.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,Physical short-message braille pending.,Go says cut or pasted from clipboard once after a matching focused editable mutation; subsequent paste text and caret events do not interrupt the message. Python uses full or brief clipboard messages. See docs/testing/editing-selection.md. editable-copy-feedback,src/cthulhu/scripts/default.py,editing,Confirm a successful copy to the clipboard,,internal/input internal/platform/xorg internal/app,implemented,go test ./internal/input ./internal/app -run 'TestIntent|TestCopyFeedback' -count=1; CTHULHU_NATIVE_X11=1 go test ./internal/platform/xorg -run TestNativeClipboardOwnerChange -count=1,Private Xvfb observed XFixes clipboard ownership change after copy.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,Short-message braille pending.,Go says copied to clipboard once after Ctrl+C and an X11 clipboard ownership change; it stays silent if ownership does not change. See docs/testing/editing-selection.md. web-browse-selection,src/cthulhu/scripts/web/script.py;src/cthulhu/script_utilities.py,web,Select document text with Shift and Ctrl+Shift arrows in browse mode,,internal/review internal/atspi internal/app,implemented,Private Brave modes fixture verifies selected and unselected speech plus braille dots within one page text object and selected speech from a heading into a paragraph.,,,Maintainer confirms correct browser selection and deselection feedback using Shift+arrows and Ctrl+Shift+arrows on 2026-10-22. This supersedes the 2026-10-03 missing-announcement report. See docs/testing/release-acceptance.md.,,The browser can perform native selection without a Go selection command. Go follows native selection within text objects and across the tested heading-to-paragraph transition. The maintainer checks clipboard contents to confirm selection. See docs/testing/reading-selection-diagnostics.md. -web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py,braille,Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor,,internal/atspi internal/review internal/app,implemented,"go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py",Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending.,,Existing browse speech assertions pass in the private fixture.,Queued native line and cursor frames pass. Maintainer confirms physical browser reading and panning pass on Orbit on 2026-10-22. Physical HumanWare NLS eReader acceptance and cursor routing remain unverified. See docs/testing/release-acceptance.md.,Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md. +web-browse-braille-line,src/cthulhu/caret_navigation.py;src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/braille_generator.py,braille,Follow accepted browse-caret and structural navigation with the current native text line and virtual cursor,,internal/atspi internal/review internal/app,implemented,"go test -race ./internal/app ./internal/review ./internal/atspi ./internal/braille -count=1; CTHULHU_NATIVE_BROWSER_TESTS=modes,tables,keys python3 tests/native_browser.py",Private Brave/Xvfb mode/table/key checks pass on 2026-10-02; physical Xorg pending.,,Existing browse speech assertions pass in the private fixture.,Queued native line and cursor frames pass. Maintainer confirms physical browser reading and panning pass on Orbit on 2026-10-22. Physical HumanWare NLS eReader acceptance and cursor routing remain unverified. See docs/testing/release-acceptance.md. Maintainer confirms physical braille matches speech on YouTube linked headings after the embedded-object fix.,Reads run on the bounded navigation worker only while braille is enabled; preserve spaces and Unicode offsets. Table frames retain existing cell presentation. Complete cross-object visual-line assembly and virtual-caret routing remain open. See docs/testing/braille.md. November 2 embedded-object fix: structural navigation to a heading whose native text is a single placeholder for its child link showed only that placeholder in braille while speech used the expanded title. Braille line reads now expand embedded objects like speech capture with RawText and native offsets preserved; expanded lines carry no cell routes or native selection/attribute dots and only a line-start cursor; frame assembly strips any remaining placeholders and falls back to displayed text. Generic regression tests cover placeholder-only and mixed and expanded and assembled and empty headings; native linked-heading and Firefox mixed-line braille assertions added. Maintainer confirms physical braille and speech agree on YouTube headings after this fix. web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObjectAsWhole,web,Read explicitly named links as whole objects without losing descendant badges or ordinary link navigation,Storm Dragon,internal/atspi internal/review internal/app,in-progress,"go test -race ./...; CTHULHU_NATIVE_BROWSER_TESTS=namedlinks,browser,live,keys python3 tests/native_browser.py",Private Brave/Xorg named-link and key checks passed,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Steam-specific named-link and game-card listening remains unconfirmed.,Named-link browse braille uses the full accessible label; physical check pending,docs/testing/steam-browser.md; actual Steam explicit-name exposure and game-card acceptance remain live gates steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,implemented,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation pass. Maintainer confirms Steam notifications work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass. Maintainer confirms basic cell navigation and editing work in live use on 2026-10-22. Selection and sheet-switching acceptance await real-world testing. See docs/testing/release-acceptance.md.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic row and column headers now use session choices scoped by provider bus document view window and exact sheet name; native reads are bounded to two cells. Four assignable commands preserve existing shortcuts. Private i3 Calc checks cover heading edits independent axis clearing sheet round trips both workbook-return directions with distinct headings and Find and Replace typing/cancellation. Frame activation and X11 foreground recovery avoid stale descendant searches; physical speech/input/Braille acceptance remains pending. Full-row reading Writer workflows and full topology reporting for large selections remain open. See LIBREOFFICE_SUPPORT.md.