Skip to content

Commit 1befd70

Browse files
feat: keyboard-accessible canvas (WCAG 2.2 AA) + installable PWA (#27)
Accessibility (a governance MUST, and a genuine differentiator — a spatial PKM you can drive without a mouse): - Navigation.res: pure geometry to find the nearest note in a direction; arrow keys move the selection, Alt+arrow nudges the selected note. Wired through new NavigateCanvas / NudgeSelectedNote messages so the logic sees current state (the global key handler is registered once). Suppressed while typing in inputs/textareas. - ARIA + focus: sidebar rows are real <button>s inside <li> (valid list semantics); canvas notes are role=button, focusable, Enter/Space edits; toolbar has role=toolbar with aria-pressed toggles; search/zoom/clear controls get accessible names; error banner is role=alert. - styles.css: :focus-visible rings, prefers-reduced-motion, and contrast fixes — a darker --accent-strong behind light text on the primary and active buttons, and a lighter dirty-indicator, clearing the last sub-4.5:1 nodes. PWA (true offline, local-first): - manifest.webmanifest + icon.svg, plain-JS service-worker.js (precache app shell + wasm, cache-first). Registered best-effort at startup; theme-color + manifest/icon links in index.html. build/dev scripts now copy the icon too. Verified: cargo test (5 suites), deno test:ui (7, incl. new Navigation geometry), deno lint. E2E (headless Chromium): ArrowRight moves selection between canvas notes, Alt+ArrowRight nudges +20px, service worker reaches ready, manifest/theme/icon load, and axe-core reports 0 serious/critical WCAG 2.2 AA violations in the canvas-active state. Claude-Session: https://claude.ai/code/session_01PAEqVP7Y3bZDhtAaPUTbps Co-authored-by: Claude <noreply@anthropic.com>
1 parent ccdceeb commit 1befd70

16 files changed

Lines changed: 433 additions & 33 deletions

scripts/build.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ for (
2727
"styles.css",
2828
"manifest.webmanifest",
2929
"service-worker.js",
30+
"icon.svg",
3031
]
3132
) {
3233
try {

scripts/dev.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ for (
3434
"styles.css",
3535
"manifest.webmanifest",
3636
"service-worker.js",
37+
"icon.svg",
3738
]
3839
) {
3940
try {

ui/src/Main.res

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,42 @@ module App = {
3333
let handleKeyDown = (e: DomBindings.keyboardEvent) => {
3434
let key = DomBindings.key(e)
3535
let modKey = DomBindings.ctrlKey(e) || DomBindings.metaKey(e)
36+
let typing = DomBindings.isTyping(e)
3637

37-
switch (modKey, key) {
38-
| (true, "n") => {
38+
// Arrow keys drive keyboard-only canvas navigation. Alt+arrow nudges
39+
// the selected note; a plain arrow moves the selection. Suppressed
40+
// while typing so text fields keep their caret movement.
41+
let arrow = switch key {
42+
| "ArrowUp" => Some(Types.Up)
43+
| "ArrowDown" => Some(Types.Down)
44+
| "ArrowLeft" => Some(Types.Left)
45+
| "ArrowRight" => Some(Types.Right)
46+
| _ => None
47+
}
48+
49+
switch (modKey, key, arrow, typing) {
50+
| (true, "n", _, _) => {
3951
DomBindings.preventDefault(e)
4052
dispatch(Msg.CreateNote)
4153
}
42-
| (true, "s") => {
54+
| (true, "s", _, _) => {
4355
DomBindings.preventDefault(e)
4456
dispatch(Msg.SaveNotebook)
4557
}
46-
| (false, "Escape") => {
58+
| (_, _, Some(direction), false) => {
59+
DomBindings.preventDefault(e)
60+
if DomBindings.altKey(e) {
61+
dispatch(Msg.NudgeSelectedNote(direction))
62+
} else {
63+
dispatch(Msg.NavigateCanvas(direction))
64+
}
65+
}
66+
| (false, "Escape", _, _) => {
4767
dispatch(Msg.ClearSelection)
4868
dispatch(Msg.StopEditingNote)
4969
}
50-
| (false, "Delete") | (false, "Backspace") => dispatch(Msg.DeleteSelectedNotes)
70+
| (false, "Delete", _, false) | (false, "Backspace", _, false) =>
71+
dispatch(Msg.DeleteSelectedNotes)
5172
| _ => ()
5273
}
5374
}
@@ -60,6 +81,15 @@ module App = {
6081
}
6182
}
6283

84+
// Register the offline service worker (best-effort; never blocks startup).
85+
%%raw(`
86+
if ("serviceWorker" in navigator) {
87+
globalThis.addEventListener("load", () => {
88+
navigator.serviceWorker.register("./service-worker.js").catch(() => {});
89+
});
90+
}
91+
`)
92+
6393
let start = async () => {
6494
try {
6595
await WasmStore.init("./wasm/nexia_core_bg.wasm")

ui/src/Msg.res

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ type msg =
3333
| PanCanvas(float, float)
3434
| ZoomCanvas(float)
3535
| ResetViewport
36+
// Keyboard canvas navigation
37+
| NavigateCanvas(direction)
38+
| NudgeSelectedNote(direction)
3639
// Search
3740
| SetSearchQuery(string)
3841
| ClearSearch

ui/src/Navigation.res

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
/// Pure geometry for keyboard navigation on the spatial canvas: given a
3+
/// focused note and a direction, find the nearest note that lies (mostly) in
4+
/// that direction. Kept side-effect-free so it is unit-testable under
5+
/// `deno test` without a DOM.
6+
7+
open Types
8+
9+
let positionOf = (note: note): option<point2D> => note.position
10+
11+
/// Candidate must lie in the half-plane of `direction` from `origin`, then we
12+
/// score by along-axis distance plus a penalty for lateral drift so a note
13+
/// straight ahead beats one far off to the side.
14+
let score = (~origin: point2D, ~target: point2D, ~direction: direction): option<float> => {
15+
let dx = target.x -. origin.x
16+
let dy = target.y -. origin.y
17+
let (along, lateral) = switch direction {
18+
| Up => (-.dy, dx)
19+
| Down => (dy, dx)
20+
| Left => (-.dx, dy)
21+
| Right => (dx, dy)
22+
}
23+
if along <= 0.0 {
24+
None
25+
} else {
26+
Some(along +. Js.Math.abs_float(lateral) *. 2.0)
27+
}
28+
}
29+
30+
/// The nearest positioned note to `fromId` in `direction`, if any.
31+
let nearestInDirection = (
32+
~notes: array<note>,
33+
~fromId: noteId,
34+
~direction: direction,
35+
): option<noteId> => {
36+
switch notes->Array.find(n => n.id == fromId) {
37+
| None => None
38+
| Some(current) =>
39+
switch positionOf(current) {
40+
| None => None
41+
| Some(origin) =>
42+
notes->Array.reduce(None, (best, candidate) => {
43+
if candidate.id == fromId {
44+
best
45+
} else {
46+
switch positionOf(candidate) {
47+
| None => best
48+
| Some(target) =>
49+
switch score(~origin, ~target, ~direction) {
50+
| None => best
51+
| Some(s) =>
52+
switch best {
53+
| Some((_, bestScore)) if bestScore <= s => best
54+
| _ => Some((candidate.id, s))
55+
}
56+
}
57+
}
58+
}
59+
})->Option.map(((id, _)) => id)
60+
}
61+
}
62+
}
63+
64+
/// The step, in canvas units, that a modifier+arrow nudge moves a note.
65+
let nudgeStep = 20.0
66+
67+
/// Apply a nudge to a position in the given direction.
68+
let nudge = (~position: point2D, ~direction: direction): point2D =>
69+
switch direction {
70+
| Up => {...position, y: position.y -. nudgeStep}
71+
| Down => {...position, y: position.y +. nudgeStep}
72+
| Left => {...position, x: position.x -. nudgeStep}
73+
| Right => {...position, x: position.x +. nudgeStep}
74+
}

ui/src/Types.res

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ type selection =
4545
| SingleNote(noteId)
4646
| MultipleNotes(array<noteId>)
4747

48+
/// A cardinal direction for keyboard navigation on the canvas.
49+
type direction =
50+
| Up
51+
| Down
52+
| Left
53+
| Right
54+
4855
/// Canvas viewport state
4956
type viewport = {
5057
offsetX: float,

ui/src/Update.res

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,39 @@ let rec update = (model: model, msg: msg): model => {
197197

198198
| ResetViewport => {...model, viewport: Viewport.initial()}
199199

200+
// Move the selection to the nearest note in a direction (keyboard nav).
201+
| NavigateCanvas(direction) =>
202+
switch model.selection {
203+
| SingleNote(id) =>
204+
switch Navigation.nearestInDirection(~notes=allNotes(model), ~fromId=id, ~direction) {
205+
| Some(next) => {...model, selection: SingleNote(next)}
206+
| None => model
207+
}
208+
| _ =>
209+
// Nothing focused yet: select the first positioned note, if any.
210+
switch allNotes(model)->Array.find(n => n.position->Option.isSome) {
211+
| Some(note) => {...model, selection: SingleNote(note.id)}
212+
| None => model
213+
}
214+
}
215+
216+
// Nudge the selected note (modifier+arrow); the mouse equivalent is PR-D.
217+
| NudgeSelectedNote(direction) =>
218+
switch model.selection {
219+
| SingleNote(id) =>
220+
switch Model.getNote(model, id) {
221+
| Some(note) =>
222+
let base = switch note.position {
223+
| Some(p) => p
224+
| None => {x: 0.0, y: 0.0}
225+
}
226+
let next = Navigation.nudge(~position=base, ~direction)
227+
patchNote(model, WasmStore.moveNote(id, next.x, next.y))
228+
| None => model
229+
}
230+
| _ => model
231+
}
232+
200233
// Search
201234
| SetSearchQuery(query) => {
202235
...model,

ui/src/View.res

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ open Types
55
open Model
66
open Msg
77

8+
// Enter / Space activate an element exposed as role="button".
9+
let onActivateKey = (handler: unit => unit, e: ReactEvent.Keyboard.t) =>
10+
switch ReactEvent.Keyboard.key(e) {
11+
| "Enter" | " " =>
12+
ReactEvent.Keyboard.preventDefault(e)
13+
handler()
14+
| _ => ()
15+
}
16+
817
module Sidebar = {
918
@react.component
1019
let make = (~model: model, ~dispatch: msg => unit) => {
@@ -23,16 +32,18 @@ module Sidebar = {
2332
<input
2433
type_="text"
2534
placeholder="Search notes..."
35+
ariaLabel="Search notes"
2636
value={model.searchQuery}
2737
onChange={e => dispatch(SetSearchQuery(ReactEvent.Form.target(e)["value"]))}
2838
/>
2939
{model.searchQuery != ""
30-
? <button onClick={_ => dispatch(ClearSearch)} className="btn-clear">
40+
? <button
41+
ariaLabel="Clear search" onClick={_ => dispatch(ClearSearch)} className="btn-clear">
3142
{React.string("×")}
3243
</button>
3344
: React.null}
3445
</div>
35-
<ul className="note-list">
46+
<ul className="note-list" ariaLabel="Notes">
3647
{(
3748
model.searchQuery != "" ? model.searchResults : notes->Array.map(n => n.id)
3849
)
@@ -44,18 +55,21 @@ module Sidebar = {
4455
| MultipleNotes(ids) => Array.includes(ids, id)
4556
| NoSelection => false
4657
}
47-
<li
48-
key={id}
49-
className={isSelected ? "note-item selected" : "note-item"}
50-
onClick={_ => dispatch(SelectNote(id))}>
51-
<span className="note-title">
52-
{React.string(note.title != "" ? note.title : "Untitled")}
53-
</span>
54-
<span className="note-meta">
55-
{React.string(
56-
`${Array.length(note.links)->Int.toString} links`,
57-
)}
58-
</span>
58+
let label = note.title != "" ? note.title : "Untitled"
59+
<li key={id}>
60+
<button
61+
type_="button"
62+
ariaPressed={(isSelected) ? #"true" : #"false"}
63+
ariaLabel={label}
64+
className={isSelected ? "note-item selected" : "note-item"}
65+
onClick={_ => dispatch(SelectNote(id))}>
66+
<span className="note-title"> {React.string(label)} </span>
67+
<span className="note-meta">
68+
{React.string(
69+
`${Array.length(note.links)->Int.toString} links`,
70+
)}
71+
</span>
72+
</button>
5973
</li>
6074
| None => React.null
6175
}
@@ -168,22 +182,26 @@ module CanvasView = {
168182
| MultipleNotes(ids) => Array.includes(ids, note.id)
169183
| NoSelection => false
170184
}
185+
let label = note.title != "" ? note.title : "Untitled"
171186
<div
172187
key={note.id}
188+
role="button"
189+
tabIndex={0}
190+
ariaPressed={(isSelected) ? #"true" : #"false"}
191+
ariaLabel={label}
173192
className={isSelected ? "canvas-note selected" : "canvas-note"}
174193
style={ReactDOM.Style.make(
175194
~left=`${pos.x->Float.toString}px`,
176195
~top=`${pos.y->Float.toString}px`,
177196
(),
178197
)}
179198
onClick={_ => dispatch(SelectNote(note.id))}
199+
onKeyDown={e => onActivateKey(() => dispatch(StartEditingNote(note.id)), e)}
180200
onDoubleClick={e => {
181201
ReactEvent.Mouse.stopPropagation(e)
182202
dispatch(StartEditingNote(note.id))
183203
}}>
184-
<div className="canvas-note-title">
185-
{React.string(note.title != "" ? note.title : "Untitled")}
186-
</div>
204+
<div className="canvas-note-title"> {React.string(label)} </div>
187205
{note.content != ""
188206
? <div className="canvas-note-preview">
189207
{React.string(
@@ -197,9 +215,15 @@ module CanvasView = {
197215
->React.array}
198216
</div>
199217
<div className="canvas-controls">
200-
<button onClick={_ => dispatch(ZoomCanvas(1.2))}> {React.string("+")} </button>
201-
<button onClick={_ => dispatch(ZoomCanvas(0.8))}> {React.string("-")} </button>
202-
<button onClick={_ => dispatch(ResetViewport)}> {React.string("Reset")} </button>
218+
<button ariaLabel="Zoom in" onClick={_ => dispatch(ZoomCanvas(1.2))}>
219+
{React.string("+")}
220+
</button>
221+
<button ariaLabel="Zoom out" onClick={_ => dispatch(ZoomCanvas(0.8))}>
222+
{React.string("-")}
223+
</button>
224+
<button ariaLabel="Reset view" onClick={_ => dispatch(ResetViewport)}>
225+
{React.string("Reset")}
226+
</button>
203227
</div>
204228
</div>
205229
}
@@ -208,22 +232,25 @@ module CanvasView = {
208232
module Toolbar = {
209233
@react.component
210234
let make = (~model: model, ~dispatch: msg => unit) => {
211-
<div className="toolbar">
235+
<div className="toolbar" role="toolbar" ariaLabel="Main toolbar">
212236
<div className="toolbar-left">
213237
<button
214238
onClick={_ => dispatch(ToggleSidebar)}
239+
ariaPressed={(model.sidebarOpen) ? #"true" : #"false"}
215240
className={model.sidebarOpen ? "btn-active" : ""}>
216241
{React.string("Sidebar")}
217242
</button>
218243
</div>
219244
<div className="toolbar-center">
220245
<button
221246
onClick={_ => dispatch(SetViewMode(ListView))}
247+
ariaPressed={(model.viewMode == ListView) ? #"true" : #"false"}
222248
className={model.viewMode == ListView ? "btn-active" : ""}>
223249
{React.string("List")}
224250
</button>
225251
<button
226252
onClick={_ => dispatch(SetViewMode(CanvasView))}
253+
ariaPressed={(model.viewMode == CanvasView) ? #"true" : #"false"}
227254
className={model.viewMode == CanvasView ? "btn-active" : ""}>
228255
{React.string("Canvas")}
229256
</button>
@@ -254,9 +281,11 @@ module ErrorBanner = {
254281
let make = (~error: option<string>, ~dispatch: msg => unit) => {
255282
switch error {
256283
| Some(message) =>
257-
<div className="error-banner">
284+
<div className="error-banner" role="alert">
258285
<span> {React.string(message)} </span>
259-
<button onClick={_ => dispatch(ClearError)}> {React.string("×")} </button>
286+
<button ariaLabel="Dismiss error" onClick={_ => dispatch(ClearError)}>
287+
{React.string("×")}
288+
</button>
260289
</div>
261290
| None => React.null
262291
}

0 commit comments

Comments
 (0)