Forte Keyboard Navigation Step 4 Implementation Summary

Stage 1 - Board folder tree: roving tabindex and ARIA scaffolding

  • Changes:

    • templates/partials/paned_folder_tree.php: container gets role="listbox"; each item gets role="option", aria-selected, and roving tabindex (0 on the selected item, -1 on the rest), computed server-side from $selectedTag (consistent with the tag-filter feature's SSR-computed initial state).

    • public/assets/paned_board_reader.js: selectFolder() now also sets aria-selected/tabindex on every folder item in the same loop that already toggles the --selected class, so mouse clicks keep the roving-tabindex state in sync automatically - no separate code path to drift out of sync.

    • public/assets/site.css: added .paned-folder-item:focus-visible with a high-contrast amber outline, chosen specifically because it stays visible against both the unselected (light) and selected (dark navy) backgrounds - a plain --paned-select-bg-colored outline would have been invisible on an already-selected+focused item.

  • Verification:

    • php -l / node --check / CSS brace-balance: clean.

    • Raw HTML check (curl): the initially-selected folder item carries role="option" aria-selected="true" tabindex="0" in the server response.

    • Headless-browser check: pressing Tab from page load lands on the folder tree after 3 tabs (past the New/Prev/Next toolbar buttons, which is expected/correct), landing on an element with role="option"; clicking a different tag moves tabindex="0"/aria-selected="true" to exactly that one item and off all others.

    • Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.

  • Notes: none.

Stage 2 - Board folder tree: arrow-key navigation

  • Changes:

    • public/assets/paned_board_reader.js: extracted applyFolderSelection(tag) (the existing selectFolder() call plus the existing conditional pushState) so both the click handler and a new keydown handler share exactly one "select + sync URL" path. A keydown listener on the folder tree handles ArrowUp/ArrowDown, computes the adjacent item by index, calls applyFolderSelection() on it, and moves real focus to it; movement clamps at the first/last item (no wrap, no Home/End, both explicitly out of scope per Step 2).

  • Verification:

    • node --check: no syntax errors.

    • Headless-browser check, starting with "All Threads" focused: ArrowDown → #general (509 visible rows, matching the known real count), ArrowDown → #like (130 visible), ArrowUp → back to #general, ArrowUp → back to "All Threads" (513 visible), one more ArrowUp at the top is a no-op (stays on "All Threads", no error). URL and aria-selected tracked correctly at every step. Zero console errors.

  • Notes: none.

Stage 3 - Board thread list: same pattern, second pane

  • Changes:

    • templates/partials/paned_board_thread_list.php: .paned-list-body gets role="listbox"; each row gets role="option", aria-selected="false" (nothing is selected on initial load), and roving tabindex assigned to the first visible row (since there's no selection yet to anchor it to).

    • public/assets/paned_board_reader.js: selectThread() now also sets aria-selected/tabindex on rows in its existing loop; added firstVisibleRow(); resetContentPane() now also clears aria-selected/tabindex on all rows and re-anchors the roving tabindex to the first visible row; selectFolder() now separately tracks whether the row currently holding tabindex="0" just became hidden by the filter change (independent of whether it was the selected row) and recovers the tabindex to a still-visible row in that case too - the case where nothing was selected yet but the tab-stop row got filtered out. A new keydown listener on the thread list handles ArrowUp/ArrowDown scoped to currently-visible rows only, reusing selectThread() as the activation step.

    • public/assets/site.css: added .paned-list-row:focus-visible (same high-contrast outline as the folder tree).

  • Verification:

    • php -l / node --check / CSS brace-balance: clean.

    • Raw HTML check: the first row in the server response carries role="option" aria-selected="false" tabindex="0".

    • Headless-browser check: focusing the first row and pressing ArrowDown/ArrowDown/ArrowUp moved focus, updated the content-pane preview, and updated aria-selected together at every step (matching click-driven selection exactly). Then, with a row focused and selected via arrow keys, clicking a different tag folder that hides that exact row correctly moved tabindex="0" to a still-visible row and correctly reset the content pane to its placeholder - the tabindex-recovery edge case Stage 3 was written to cover.

    • Re-ran the full 39-real-tag filter check and the popstate back/forward check: both still pass with zero mismatches after these changes.

    • Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.

  • Notes: none.

Stage 4 - Single-thread reply list: same pattern, second file

  • Changes:

    • templates/pages/forte.php / templates/partials/paned_list_pane.php: the page now passes rootPostId through so the recursive row-rendering closure can mark the root post's row role="option" aria-selected="true" tabindex="0" (matching the content pane's own default-selected post) and every other row tabindex="-1"; the list body container gets role="listbox".

    • public/assets/paned_reader.js: extracted visibleRows() and switched both the existing stepSelection() (used by the Prev/Next toolbar buttons) and the new keydown handler to call it, so the two can't compute "visible" differently, per the Step 3 plan's explicit risk note. selectPost() now also sets aria-selected/tabindex in its existing loop. toggleCollapse() now also detects whether collapsing a branch just hid the row currently holding the roving tabindex and, if so, moves it to the toggled (ancestor) row - a necessary addition beyond the plan's literal wording: without it, a keyboard user who arrowed into a deeply nested reply and then had a mouse-driven collapse hide that row would lose the ability to Tab back into the list at all (no row would have tabindex="0"), a worse regression than anything explicitly listed as a Stage 4 risk.

  • Verification:

    • php -l / node --check: clean.

    • Raw HTML check: on a real thread, the root post's row carries role="option" aria-selected="true" tabindex="0" and its reply carries tabindex="-1", in the server response.

    • Headless-browser check on a real 2-reply thread: ArrowDown from the root moved focus+selection through both replies correctly, with the content pane updating to match at each step. Selecting a reply via keyboard, then real-mouse-clicking the root's collapse toggle (hiding that reply), correctly moved tabindex="0" to the root row (still visible) rather than leaving it stranded on the now-hidden reply - confirmed via page.click() (real coordinate-based clicks), after an initial pass with synthetic .click() calls gave a false negative (synthetic clicks don't reliably move real DOM focus the way a real click does, the same lesson learned earlier in this feature's development).

    • Re-ran the full 39-real-tag filter check on the board view: zero mismatches, confirming the shared .paned-list-row CSS/behavior changes didn't cross-contaminate the two pages.

    • Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.

  • Notes: none.

Stage 5 - End-to-end Tab-order and regression pass

  • Changes (one real defect found and fixed, beyond pure verification):

    • templates/partials/paned_list_pane.php: the single-thread reply list's "From" column was still rendering a real <a href="/user/..."> link via $author($post) for approved authors. That anchor is natively focusable regardless of its ancestor row's tabindex, so it added an uncontrolled extra Tab stop inside what was supposed to be a single, roving-tabindex-controlled option - breaking the "exactly one Tab stop per pane" guarantee for any thread whose author has a profile link (not visible in the small local fixture, which only has non-linked "guest" authors, but confirmed on the live 513-thread instance). Replaced with a plain-text $authorText() closure, mirroring the identical fix already applied to the board view's thread list for an earlier, unrelated request ("remove links from usernames in thread listing") - the same underlying problem, found independently here via the Tab-order walk rather than a user report this time.

  • Verification:

    • php -l: clean.

    • Headless-browser Tab-order walk on /forte: Prev → Next → folder tree (1 stop) → thread list (1 stop) → page exit (correct - the placeholder content pane has nothing focusable when no thread is selected yet, matching Step 2's "no artificial stop for a pane with nothing interactive in it").

    • Headless-browser Tab-order walk on /threads/root-001/forte: Prev → Next → reply list (1 stop, now truly one stop after the fix above) → content pane's author link → page exit (that thread's one reply has no further links or reply-toggle, since it's a single-reply thread with no nested replies).

    • Pure-keyboard end-to-end walk on the live 513-thread board instance (no mouse at any point): Tab into the folder tree, Tab into the thread list, arrow down until landing on a thread with replies, Tab → correctly reached the content pane's author link, Tab again → correctly reached the "Show N replies" toggle button. This exercises every success criterion from Step 2 in one pass, using only the keyboard.

    • Re-ran the full 39-real-tag filter check, the popstate back/forward check, and the collapse tabindex-recovery check from Stage 4: all still pass with zero regressions after the Stage 5 fix.

    • Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.

  • Notes: none.