journal: speak the page text, not just the entry title

Opening the journal and selecting an entry announced its title and stopped,
which is an index into content the player could never reach. Page flipping was
silent too.

The screen is three widgets at once: the tab row (q/e), the list of entry
titles down the left (up/down), and the page on the right holding the actual
text (left/right). The descriptor read only the first two.

It now reads options[selectedOption].pages[selectedPageId] and speaks its text
blocks. A page is bounded by what fits on screen, so it is safe as a single
utterance. Layout newlines are flattened first -- splitTextByPages joins the
game's own wrapped lines with \n, so left in they make the reader pause
mid-sentence at every wrap point. Page position is announced only when there is
more than one page, and the blocks are pushed into the console history so [ and
] walk back through a long lore entry after the utterance has gone by.

The tab leads only when the tab changed and the title only when the entry
changed, so flipping pages does not bury the new page under a repeated heading.

That suppression made read() stateful, which is a trap: it is called for the
same focus from four places -- the per-frame poll, the post-keypress announce,
repeatFocus, and the testbed's before/after probes -- so a poll would consume
the "changed" flag and the real announcement would come out headless. The
descriptor now caches its last (key, text) and replays it whenever the key is
unchanged, making read() idempotent; the suppression state advances exactly
once per genuine focus change.

Adds a generic descriptor.reset hook, called from menus.announceScreen. The
memory lives in file-locals that outlive the screen, so without it reopening
the journal on the entry you left it on would read the page with no title.

Testbed asserts the spoken text contains the page body rather than merely being
non-nil. 29 checks, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Accessibility Mod
2026-08-02 01:04:03 +02:00
co-authored by Claude Opus 5
parent 81a209b600
commit e1d9995c9f
5 changed files with 236 additions and 21 deletions
+20
View File
@@ -96,6 +96,26 @@ genuinely stops being visible.
The tracker only ever reports things you have actually seen. The tracker only ever reports things you have actually seen.
### The journal (`j`)
The journal has three parts, and all three are spoken:
| Key | Moves |
|---|---|
| `q` / `e` | Between tabs — Quests, Lore, Bestiary, Components, Recipes, Tutorials |
| Up / Down | Between entries in the current tab |
| Left / Right | Between pages of the selected entry |
Selecting an entry reads its title, its position in the list, and then the text
of the page itself. The tab name is only spoken when you change tabs, and the
title only when you change entry, so flipping pages doesn't make you sit through
the heading again.
Long entries run to several pages; you are told `page 2 of 3` when there is more
than one. The page text also goes into the message history, so `[` and `]` will
walk back through it and `\` re-reads a line — useful for a long lore entry you
want to take in a piece at a time.
### Your character ### Your character
| Key | Action | | Key | Action |
+45 -3
View File
@@ -7,7 +7,7 @@ playtesting.** Remaining work is polish, mod settings and distribution.
**Game is playable start-to-finish by ear** from main menu through the intro **Game is playable start-to-finish by ear** from main menu through the intro
conversation into gameplay, plus journal, character, actions and inventory — conversation into gameplay, plus journal, character, actions and inventory —
verified by the testbed, **26/26 checks**. verified by the testbed, **29/29 checks**.
### Run the testbed (do this after any change) ### Run the testbed (do this after any change)
``` ```
@@ -463,6 +463,48 @@ so it also bypassed the flush-time logger — meaning damage alerts fired but we
invisible in the testbed transcript. Log prefixes: `!` interrupt, `*` immediate, invisible in the testbed transcript. Log prefixes: `!` interrupt, `*` immediate,
` ` queued. ` ` queued.
### Done — journal page text (bug fix, reported by Talon)
**Symptom:** open the journal with `j`, select an entry, hear nothing useful.
**Cause:** the journal is three widgets at once — the tab row, the list of entry
titles on the left, and the **page** on the right that holds the actual text.
The descriptor read only the first two. So it announced
`Quests, A simple commission, 1 of 1` and stopped, which is an index into
content you could never reach. Page flipping (`left`/`right`) was silent too.
**Fix** — `accessibility/states/journal.lua`:
- Reads `options[selectedOption].pages[selectedPageId]`, a list of text blocks,
and speaks their `text`. Page size is bounded by what fits on screen, so a
page is safe to speak as one utterance.
- **Flattens the newlines** in each block. `journalModel.splitTextByPages`
concatenates the game's own wrapped lines with `\n`, so those are pure layout —
left in, the reader pauses mid-sentence at every wrap point.
- Says `page 2 of 3` only when there is more than one page.
- Pushes the blocks into the console history, so `[` / `]` / `\` re-read the
page after the utterance has gone by — the same trick `_textscreen.lua` uses
for the manual.
- Title leads only when the *entry* changed, tab only when the *tab* changed, so
flipping pages doesn't bury the page you just turned to under a repeated title.
⚠ **`read()` must be idempotent.** It is called for the same focus from four
places: the per-frame poll, the post-keypress announce, `menus.repeatFocus`, and
the testbed's before/after probes. Any "only say this when it changed" state
inside a descriptor is therefore a trap — the poll consumes the flag and the
real announcement comes out headless. The journal descriptor caches its last
`(key, text)` and replays it whenever the key is unchanged, so the suppression
state advances exactly once per genuine focus change.
**New generic hook: `descriptor.reset`**, called by `menus.announceScreen`.
Change-suppression memory lives in file-locals that outlive the screen, so
without it, reopening the journal on the entry you left it on would read the
page with no title in front of it.
Verified live: `Journal. Quests, A simple commission, 1 of 1, Someone's banging
at my door. Whoever it is, this person is very persistent and doesn't know about
doorbells.` **29/29 testbed checks**, including a new one that asserts the spoken
text actually contains the page body rather than merely being non-nil.
### Known gaps (next up) ### Known gaps (next up)
- **Phase 5**: player status key (HP, statuses, current action), popup - **Phase 5**: player status key (HP, statuses, current action), popup
announcements (`popupStack.add`), damage alerts announcements (`popupStack.add`), damage alerts
@@ -527,7 +569,7 @@ utterance — speech is verifiable from the log, not only by ear. `!` after
## HANDOFF — read this if you are resuming ## HANDOFF — read this if you are resuming
### State of play ### State of play
Phases 0–5 complete. **23 state descriptors, 5 patches, 26/26 testbed checks**, Phases 0–5 complete. **23 state descriptors, 5 patches, 29/29 testbed checks**,
history unit tests passing, **one line changed in the game** (`main.lua`). history unit tests passing, **one line changed in the game** (`main.lua`).
Everything in the original brief is implemented: console speech with Everything in the original brief is implemented: console speech with
@@ -538,7 +580,7 @@ exploration, wall tones, and keyboard+speech access to every screen.
``` ```
lua accessibility/tests/test_history.lua # pure Lua, instant lua accessibility/tests/test_history.lua # pure Lua, instant
rm -rf ~/AppData/Roaming/LOVE/Alchemist # clean save rm -rf ~/AppData/Roaming/LOVE/Alchemist # clean save
"C:/Program Files/LOVE/lovec.exe" . --a11y-test # ~4 min, 26 checks "C:/Program Files/LOVE/lovec.exe" . --a11y-test # ~4 min, 29 checks
git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' # must stay: main.lua | 1 + git diff --stat vanilla-0.4.2 -- . ':!accessibility' ':!.gitignore' # must stay: main.lua | 1 +
``` ```
+9
View File
@@ -271,6 +271,15 @@ function menus.announceScreen(stateName)
lastKey = nil lastKey = nil
justSwitched = true justSwitched = true
-- Descriptors that suppress repetition across reads (journal only leads with
-- the tab or the entry title when it CHANGED) keep that memory in file-locals,
-- which outlive the screen. Without this, reopening the journal on the entry
-- you left it on would read the page text with no title in front of it.
local descriptor = loadDescriptor(stateName)
if descriptor and descriptor.reset then
pcall(descriptor.reset)
end
speech.say(stateNames[stateName] or stateName, true) speech.say(stateNames[stateName] or stateName, true)
end end
+126 -16
View File
@@ -1,25 +1,100 @@
-- states/journal.lua — quests, lore, bestiary, knowledge, research, tutorials. -- states/journal.lua — quests, lore, bestiary, components, recipes, tutorials.
-- --
-- Two axes move independently: the tab (left/right) and the entry within it -- Visually the screen is three things at once:
-- (up/down). Both must be announced, and the tab header must lead when it
-- changes, since "Quests" and "Bestiary" list very different things.
-- --
-- Uses getAnyUpvalue because activeTabIndex, options and selectedOption are -- * a row of TABS across the top (Quests / Lore / Bestiary / Components /
-- touched by different functions here (gotoTab, refreshOptions, keypressed). -- Recipes / Tutorials), changed with tabLeft / tabRight;
-- * a scrolling LIST of entries down the left, each an icon plus a title,
-- changed with up / down;
-- * the selected entry's TEXT filling the right-hand page, paginated, with a
-- "< 2 / 3 >" counter under it and left / right flipping pages.
--
-- Only the first two were ever announced, so selecting an entry read its title
-- and then nothing — the page holding the actual content was silent, and page
-- flipping was silent too. That is the whole point of the screen.
--
-- The page is `options[selectedOption].pages[selectedPageId]`, a list of text
-- blocks (`{text = ..., color = ..., icon = ...}`). Its length is bounded by
-- what fits on screen, so a page is safe to speak in one go; it is also pushed
-- into the console history so [ and ] can walk it back, the same trick
-- _textscreen.lua uses for the manual.
--
-- Uses getAnyUpvalue because activeTabIndex, options, selectedOption and
-- selectedPageId are touched by different functions here (gotoTab,
-- prepareOptions, selectPage, keypressed).
local history = require("accessibility.history")
local lastTab local lastTab
local lastEntry
local lastPushedPage
local lastKey
local lastText
-- The page splitter concatenates the game's own wrapped lines with "\n", so a
-- block's text carries hard newlines that are pure layout. Flatten them, or the
-- screen reader pauses mid-sentence at every wrap point.
local function flatten(text)
return (tostring(text):gsub("%s*\n%s*", " "):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function pageBlocks(page)
local blocks = {}
for _, textBlock in ipairs(page) do
if type(textBlock) == "table" and type(textBlock.text) == "string" then
local text = flatten(textBlock.text)
if text ~= "" then
blocks[#blocks + 1] = text
end
end
end
return blocks
end
return { return {
-- Called by menus.announceScreen each time the journal is opened, so the tab
-- and title lead again rather than being suppressed as "unchanged" from the
-- last visit.
reset = function()
lastTab = nil
lastEntry = nil
lastPushedPage = nil
lastKey = nil
lastText = nil
end,
read = function(stateTable, menus) read = function(stateTable, menus)
local tabIndex = menus.getAnyUpvalue(stateTable, "activeTabIndex") local tabIndex = menus.getAnyUpvalue(stateTable, "activeTabIndex")
local tabsList = menus.getAnyUpvalue(stateTable, "tabsList") local tabsList = menus.getAnyUpvalue(stateTable, "tabsList")
local options = menus.getAnyUpvalue(stateTable, "options") local options = menus.getAnyUpvalue(stateTable, "options")
local selected = menus.getAnyUpvalue(stateTable, "selectedOption") local selected = menus.getAnyUpvalue(stateTable, "selectedOption")
local pageId = menus.getAnyUpvalue(stateTable, "selectedPageId")
if type(tabIndex) ~= "number" or type(tabsList) ~= "table" then if type(tabIndex) ~= "number" or type(tabsList) ~= "table" then
return nil, nil return nil, nil
end end
local optionCount = type(options) == "table" and #options or -1
local entry = type(options) == "table" and type(selected) == "number"
and options[selected] or nil
local key = table.concat({
tostring(tabIndex), tostring(selected), tostring(pageId), tostring(optionCount)
}, ":")
-- read() is called from several places for the same focus: the per-frame
-- poll, the post-keypress announce, the "repeat what's focused" key, and
-- the testbed's before/after probes. Only the FIRST read of a given focus
-- may advance the suppression state below -- otherwise a poll would
-- consume the "tab changed" flag and the announcement that follows would
-- come out without its heading. So later reads of the same key replay the
-- text verbatim, which also makes the repeat key say the full thing.
if key == lastKey then
return lastText, key
end
local tab = tabsList[tabIndex] local tab = tabsList[tabIndex]
local tabName = tab and tab.header or ("tab " .. tabIndex) local tabName = tab and tab.header or ("tab " .. tabIndex)
@@ -28,23 +103,58 @@ return {
-- Lead with the tab only when it just changed, so stepping through a long -- Lead with the tab only when it just changed, so stepping through a long
-- quest list doesn't repeat "Quests" on every line. -- quest list doesn't repeat "Quests" on every line.
if tabIndex ~= lastTab then if tabIndex ~= lastTab then
lastTab = tabIndex
parts[#parts + 1] = tabName parts[#parts + 1] = tabName
end end
if type(options) == "table" and type(selected) == "number" and options[selected] then if optionCount == 0 then
local entry = options[selected]
parts[#parts + 1] = entry.title or entry.header or entry.name or "?"
parts[#parts + 1] = selected .. " of " .. #options
elseif type(options) == "table" and #options == 0 then
parts[#parts + 1] = "empty" parts[#parts + 1] = "empty"
elseif entry then
-- Repeating the title on every page flip would bury the page you just
-- turned to, so the title leads only when the entry itself changed.
local entryKey = tostring(tabIndex) .. ":" .. tostring(selected)
if entryKey ~= lastEntry then
parts[#parts + 1] = entry.title or entry.header or entry.name or "?"
parts[#parts + 1] = selected .. " of " .. optionCount
end end
if #parts == 0 then local pages = entry.pages
return nil, nil local page = type(pages) == "table" and type(pageId) == "number" and pages[pageId]
if page then
-- Only worth saying which page you are on when there is more than one.
if #pages > 1 then
parts[#parts + 1] = "page " .. pageId .. " of " .. #pages
end end
return table.concat(parts, ", "), local blocks = pageBlocks(page)
tostring(tabIndex) .. ":" .. tostring(selected) .. ":" .. tostring(options and #options)
for _, block in ipairs(blocks) do
parts[#parts + 1] = block
end
if #blocks == 0 then
parts[#parts + 1] = "no text"
end
-- Mirror the page into the review buffer so [ and ] can walk it
-- block by block after the utterance has gone by.
local pageKey = entryKey .. ":" .. tostring(pageId)
if pageKey ~= lastPushedPage then
lastPushedPage = pageKey
for _, block in ipairs(blocks) do
pcall(history.append, block, "journal")
end
end
end
lastEntry = entryKey
end
lastTab = tabIndex
lastKey = key
lastText = #parts > 0 and table.concat(parts, ", ") or nil
return lastText, key
end, end,
} }
+35 -1
View File
@@ -435,12 +435,42 @@ local function scenario()
end end
end end
-- The journal's whole point is the page of text on the right-hand side; the
-- list of titles on the left is just an index into it. Announcing only the
-- title (which is what it used to do) reads as "there is an entry here"
-- with no way to find out what it says, so assert the body specifically.
local function checkJournalReadsPage()
local spoken = focusText()
local stateTable = menus.getStateTable("journal")
local options = stateTable and menus.getAnyUpvalue(stateTable, "options")
local selected = stateTable and menus.getAnyUpvalue(stateTable, "selectedOption")
local pageId = stateTable and menus.getAnyUpvalue(stateTable, "selectedPageId")
local entry = type(options) == "table" and type(selected) == "number" and options[selected]
local page = entry and type(entry.pages) == "table" and entry.pages[pageId]
local block = page and page[1] and page[1].text
if not block then
print("[testbed] journal: no page text to compare against (skipped)")
return
end
-- Compare on the first few words only: the descriptor flattens the
-- layout newlines the page splitter inserts, so the strings are not
-- byte-identical by design.
local opening = block:gsub("%s+", " "):gsub("^%s+", ""):sub(1, 30)
check("journal speaks the page body, not just the title",
spoken ~= nil and spoken:find(opening, 1, true) ~= nil,
"expected to contain " .. string.format("%q", opening) ..
", spoken=" .. tostring(spoken))
end
-- Phase 4: the menu screens reachable from gameplay. -- Phase 4: the menu screens reachable from gameplay.
-- --
-- Each is opened, navigated once, and closed. `back` is bound to x/escape/ -- Each is opened, navigated once, and closed. `back` is bound to x/escape/
-- backspace, but x also opens examine mode, so use escape. -- backspace, but x also opens examine mode, so use escape.
local screens = { local screens = {
{key = "j", state = "journal", nav = {"down", "right"}}, {key = "j", state = "journal", nav = {"down", "right"}, check = checkJournalReadsPage},
{key = "c", state = "character", nav = {"right", "right"}}, {key = "c", state = "character", nav = {"right", "right"}},
{key = "a", state = "actions_menu", nav = {"down"}}, {key = "a", state = "actions_menu", nav = {"down"}},
{key = "i", state = "inventory", nav = {"right", "down"}}, {key = "i", state = "inventory", nav = {"right", "down"}},
@@ -460,6 +490,10 @@ local function scenario()
check(screen.state .. " describes focus", focusText() ~= nil, check(screen.state .. " describes focus", focusText() ~= nil,
"focus=" .. tostring(focusText())) "focus=" .. tostring(focusText()))
if screen.check then
screen.check()
end
press("escape", "close " .. screen.state) press("escape", "close " .. screen.state)
wait(15) wait(15)
end end