Skip to content

Skip link, focus-visible baseline, and a keyboard-navigable header #39

Open

Description

@abrcrmb

Task

Make the app fully usable by keyboard: a skip-to-content link in the root layout, a visible focus indicator that is not the browser default on every interactive element, and a header whose mobile menu can be opened, navigated and closed without a mouse.

Context

  • app/layout.tsx:15-25 renders <html><body> → a testnet banner div → <SiteHeader /> → {children}. No skip link exists (I grepped).
  • components/nav/SiteHeader.tsx is "use client" (:1), has the hamburger with an aria-label (:96), and is the only chrome on every page.
  • app/claim/[id]/ClaimView.tsx:163-166,182 is the one place in the app with focus management — the pattern to copy, but the file is out of scope.
  • Form labels are in good shape already (app/send/SendForm.tsx:49,87,126 use htmlFor; app/verify/VerifyForm.tsx:15 wraps). This issue is about focus and traversal order, not labels.
  • Reduced-motion is handled (app/globals.css:48,78, motion-reduce: on the header) — keep it working.

Before you start: mandatory context review

Kinlock moves other people's money, so the docs are binding constraints, not background reading.
They record decisions this task must not silently reopen. Read them in this order and tick each box:

  • docs/ARCHITECTURE_ESSENTIALS.md — start here, it is short (AGENTS.md §1)
  • docs/ARCHITECTURE.md — only the sections named in Context above
  • docs/PRD.md — if this task touches user-facing behaviour or scope
  • AGENTS.md §3 (hard rules), §4 (deferred list), §8 for this repo, §12 (roadmap rule)
  • ROADMAP.md — the row(s) listed under Related roadmap rows, including their Depends on

In your first PR comment, name the hard-rule numbers or doc sections that constrain this task. That
line is how we know the review happened.

Then:

  • If code and docs disagree, or two docs disagree: stop and say so on this issue. Do not pick one
    silently, and do not "fix" either side without approval (AGENTS.md §1).
  • If the task looks like it needs anything from the deferred list (§4), a change to a hard rule (§3),
    or a new dependency (§2): raise it here first. Those need a human; deciding them is not your job.
  • If a row you are told to finish is BLOCKED or depends on a row that is not DONE, say so here
    instead of starting.

Why we need this

  • AGENTS.md §8.3: "Accessibility: keyboard navigation, labels, contrast. Treat as required, not polish."
  • A sender on a phone with a Bluetooth keyboard, a screen-reader user, and anyone who lost their mouse all need to reach the form without tabbing through the whole nav twice.
  • The banner above the header is currently the first tab stop on every page, which is exactly the thing a skip link exists to escape.

Scope

In scope

  • app/layout.tsx and a new components/a11y/SkipLink.tsx
  • components/nav/SiteHeader.tsx and its menu
  • app/globals.css for the focus-visible styles
  • messages/en.json for the skip-link label
  • A test that the skip link is the first focusable element

Do not touch (security-sensitive or generated; changing these needs a human first — AGENTS.md §2, docs/project_structure.md §8)

  • app/claim/** and lib/claim-links/** — the claim-link fragment (#r=…&s=…) must never reach a server, log or analytics tool (hard rule 5)
  • next.config.ts, proxy.ts / middleware.ts, CSP headers
  • lib/wallet/** and any direct contract call site — money-moving calls go through the SDK and the wallet adapter only
  • app/send/**, app/locks/**, app/payee/** action logic — release, refund and decline paths stay with maintainers
  • package.json, pnpm-lock.yaml (no new dependencies without approval), .github/workflows/**
  • app/claim/** (including copying its focus pattern into it)", "adding an a11y lint plugin — new dependency, ask first

Suggested implementation

  1. Read the app rules in AGENTS.md §8.3 and the WCAG 2.2 focus docs before coding; get the pattern right once in SiteHeader, then apply it.
  2. Skip link: visually hidden until focused, then the first visible focus ring on the page, targeting the main content container. Ensure the target gets tabIndex={-1} so focus actually moves.
  3. Give every interactive element a focus ring that survives both colour schemes (see the contrast issue in this repo — the muted token work may help; sequence accordingly or coordinate on the issue).
  4. Menu: Enter/Space toggle, Escape closes, focus returns to the toggle, and a useEffect cleanup for the listener. Keep it simple — no focus-trap library.
  5. Verify in a real browser: tab through /, /send, /verify, /payee and record where you land. Say what you did.
  6. Do not add a third-party script for testing: AGENTS.md §8.3 bars them on /claim, and the fragment must never reach a server either way (hard rule 5).

A different approach is usually welcome — post it here before building and we will answer. This is a
suggestion, not a spec.

Edge cases and traps

  • The testnet banner renders before SiteHeader in app/layout.tsx:15-25, so it is the current first tab stop. A skip link placed after it does nothing.
  • The target needs tabIndex={-1} or focus stays on the skip link and the screen reader keeps reading the nav.
  • Escape must close the menu and return focus to the toggle, with the document listener cleaned up on unmount.
  • Focus ring must clear 3:1 against adjacent colours in both schemes, and motion-reduce: behaviour on the header must keep working.
  • Another open issue in this repo touches SiteHeader.tsx and the tokens. Say on this issue which one you took first so the two do not collide.

Done means

  • First tab stop on every in-scope page is the skip link, and it moves focus to the content
  • Focus is visible at ≥ 3:1 against adjacent colours in light and dark
  • The mobile menu opens, traverses and closes by keyboard alone, with focus restored
  • No tab traps and no invisible-but-focusable elements in the header
  • A test asserts the skip link renders first; pnpm lint/typecheck/test/build pass
  • You tabbed through the four in-scope routes in a browser and reported the result

Tests

One test for render order. If you need DOM rendering, @testing-library/react is a new dev dependency — ask on the issue; otherwise keep the assertion at the layout-markup level, which the existing node-environment vitest can do.

Documentation

No API change. Mention the focus convention in the component so the pattern is not reinvented per page.

Verify before you open the PR

pnpm install
pnpm lint
pnpm typecheck
pnpm test
pnpm build

Run every command above and paste the real output in the PR. We do not accept "should pass" —
AGENTS.md §6: "You actually ran the commands."

Opening the pull request

  • Assignment is required before you start. Comment on this issue and wait to be assigned. Do not
    open a PR against an issue someone already holds — if it has been quiet, ask us to release it first.
  • Closes #39 in the PR description, so the issue and the PR link automatically.
  • Branch feat/…, fix/…, docs/…, test/… or chore/…. Commit subject example:
    feat(app): skip link, focus-visible baseline and keyboard-navigable header
  • Changelog entry; M3-17 stays IN PROGRESS.
  • Follow .github/PULL_REQUEST_TEMPLATE.md (AGENTS.md §7). In "Hard rules / invariants touched",
    give numbers, not prose.
  • Small and focused: one logical change. Do not mix a refactor into it.
  • Do not add the security-sensitive label — that is the maintainer's flag for contract-logic PRs.
  • Do not reformat or touch files outside Scope. Unrelated diff noise costs review time and can
    break the roadmap merge.
  • Never commit to main; open a PR. Never force-push.
  • Evidence for the PR: Attach screenshots of the skip link in its focused state and the header menu opened by keyboard, light and dark, plus your tab-order notes.

How we review

Two maintainers: one reviews correctness against the hard rules and the roadmap row's Done when, the
other reviews diff scope and whether the tests were actually run. Order is hard-rule correctness → tests
really executed → diff stays inside Scope → style last. First response on a PR: 2 business days.

What fails review fastest: a change outside Scope, a deleted or weakened assertion, a claim with no
command output behind it, and a ROADMAP.md with no Changelog entry (AGENTS.md §12).
For this issue, look first at the first tab stop on each page and that focus actually moves to the content target, not just to a link.

Tier and effort

tier/medium · roughly 1–2 days. Wave complexity tag: Medium = 150 points — the published mapping is Trivial 100 · Medium 150 · High 200
(Drips' maintainer guide); the cycle's
own values are confirmed on drips.network/wave/stellar before
the Wave opens. Kinlock's tier rules and what each tier means for review:
docs/wave-issue-format.md.

Related roadmap rows

M3-17 — Accessibility pass (keyboard, labels, contrast) (IN PROGRESS)

Part of M3-17; leave it IN PROGRESS and list what remains.

Questions

Comment on this issue — a maintainer answers within 2 business days. Say "I'll take this" so we can flag it on
the Wave board and stop two people doing the same work. If you get half way and stall, post what you
have as a draft PR rather than disappearing; a blocked draft is more useful to us than silence.

Activity

  1. added
    accessibilityBarrier affecting people with disabilities
    waveScoped for Drips Wave contributors
    area:appOwnership: kinlock-app
    tier/mediumWave tier: standard feature or fix work, a few hours, tests included
    on Oct 8, 2026
  2. jdjioe5-cpu commented on Oct 17, 2026

    @jdjioe5-cpu

    I'll take this issue. Following up on #38 (where semantic tokens were added in PR #45), I will implement the accessible navigation pass: adding \components/a11y/SkipLink.tsx\ rendered first in \�pp/layout.tsx, updating \components/nav/SiteHeader.tsx\ with full keyboard interactions (Enter/Space toggle, Escape to close with focus restoration), \ ocus-visible\ styling in \�pp/globals.css, \messages/en.json\ label, and automated test coverage.

  3. jdjioe5-cpu commented on Oct 17, 2026

    @jdjioe5-cpu

    Submitted PR #46 addressing this issue.

    Summary of Delivery:

    1. Skip-to-Content Link: Added components/a11y/SkipLink.tsx placed as the first element in <body> before the testnet banner and <SiteHeader />. Uses localized string common.skipToContent. Visually hidden with .sr-only and styled with high-visibility focused pill.
    2. Semantic Landmark Target: Wrapped main page contents in <main id="main-content" tabIndex={-1} className="outline-none">, ensuring focus actually moves into main content upon skip link activation.
    3. Disclosure Menu Keyboard Interaction: In components/nav/SiteHeader.tsx, added toggleRef. Pressing Escape dismisses the mobile menu and restores focus back to the toggle button. Added focus-visible: outline ring classes to all interactive controls.
    4. WCAG 2.2 Focus Appearance: Added :focus-visible rules in app/globals.css using 2px solid --accent (contrast 5.14:1 light, 6.84:1 dark >= 3:1).
    5. Unit Tests: Added components/a11y/SkipLink.test.ts (7 tests) covering all DOM ordering, landmarks, focus restoration, and styles.
    6. All Checks Passed: pnpm lint (85 files clean), pnpm typecheck (clean), pnpm test (175/175 passed across 16 files), and pnpm build (clean).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    accessibilityBarrier affecting people with disabilitiesarea:appOwnership: kinlock-apptier/mediumWave tier: standard feature or fix work, a few hours, tests includedwaveScoped for Drips Wave contributors

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions