Skip to content

Commit a20c72b

Browse files
feat(viz): accessibility polish (ARIA, focus, reduced-motion)
Make the generated visualization usable with assistive tech and the keyboard. The canvas gets role="img" with a descriptive aria-label and tabindex so screen-reader users land on it and hear what it is; the view tabs, graph filters, cluster toggle, and dark-mode button expose aria-pressed state synced to their active class; the search input and legend get labels and list semantics. Motion respects the OS preference: the drag reheat of the d3-force simulation is skipped under prefers-reduced-motion (the node still tracks the cursor, its neighbors just do not spring), and a reduced-motion media block neutralizes CSS transitions. Buttons and the canvas get a focus-visible outline so keyboard focus is visible. Frontend-only; regenerates the embedded viz.js/viz.css assets.
1 parent a733e93 commit a20c72b

7 files changed

Lines changed: 99 additions & 7 deletions

File tree

crates/cli/viz-assets/viz.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,28 @@ body {
282282
margin-top: 4px;
283283
opacity: 0.7;
284284
}
285+
286+
/* ── Keyboard focus visibility ─────────────────────────────── */
287+
288+
#fallow-app button:focus-visible,
289+
.view-tab:focus-visible,
290+
.filter-btn:focus-visible,
291+
#fallow-darkmode:focus-visible {
292+
outline: 2px solid #3B82F6;
293+
outline-offset: 2px;
294+
}
295+
296+
#fallow-canvas:focus-visible {
297+
outline: 2px solid #3B82F6;
298+
outline-offset: -2px;
299+
}
300+
301+
/* ── Respect reduced-motion preference ─────────────────────── */
302+
303+
@media (prefers-reduced-motion: reduce) {
304+
* {
305+
transition-duration: 0.01ms !important;
306+
animation-duration: 0.01ms !important;
307+
animation-iteration-count: 1 !important;
308+
}
309+
}

crates/cli/viz-assets/viz.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

viz-frontend/src/graph.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ const FONT_GROUP = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", san
5454
const NODE_R_MIN = 3;
5555
const NODE_R_MAX = 12;
5656

57+
// Respect the OS "reduce motion" setting: skip the force-simulation reheat so a
58+
// drag repositions the node without springing its neighbors around.
59+
const prefersReducedMotion = (): boolean =>
60+
typeof window.matchMedia === "function" &&
61+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
62+
5763
// ── Opacity by status and filter ────────────────────────────────
5864

5965
const STATUS_OPACITY: Record<GraphFilter, Record<VizFileStatus, number>> = {
@@ -698,8 +704,10 @@ export const graphDragStart = (state: AppState, nodeIdx: number): void => {
698704
const node = gvs.fileNodes[nodeIdx];
699705
node.fx = node.x;
700706
node.fy = node.y;
701-
// Reheat simulation
702-
if (gvs.simulation) {
707+
// Reheat simulation so neighbors settle around the dragged node, unless the
708+
// user asked for reduced motion (then the node still tracks the cursor via
709+
// fx/fy + the explicit re-render in interactions.ts, with no spring).
710+
if (gvs.simulation && !prefersReducedMotion()) {
703711
gvs.simulation.alphaTarget(0.3).restart();
704712
}
705713
};

viz-frontend/src/interactions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export const setupInteractions = (
237237
breadcrumbEl.style.backgroundColor = state.theme.breadcrumbBg;
238238
breadcrumbEl.style.color = state.theme.breadcrumbText;
239239
darkModeBtn.textContent = state.darkMode ? "☀" : "☾";
240+
darkModeBtn.setAttribute("aria-pressed", String(state.darkMode));
240241
render(state);
241242
});
242243

viz-frontend/src/main.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,21 @@ const init = (): void => {
6363
// View toggle tabs
6464
const viewTabs = document.createElement("div");
6565
viewTabs.id = "fallow-view-tabs";
66+
viewTabs.setAttribute("role", "group");
67+
viewTabs.setAttribute("aria-label", "Visualization view");
6668

6769
const treemapTab = document.createElement("button");
6870
treemapTab.className = "view-tab active";
6971
treemapTab.textContent = "Treemap";
7072
treemapTab.dataset.view = "treemap";
73+
treemapTab.setAttribute("aria-pressed", "true");
7174
viewTabs.appendChild(treemapTab);
7275

7376
const graphTab = document.createElement("button");
7477
graphTab.className = "view-tab";
7578
graphTab.textContent = "Graph";
7679
graphTab.dataset.view = "graph";
80+
graphTab.setAttribute("aria-pressed", "false");
7781
viewTabs.appendChild(graphTab);
7882

7983
controls.appendChild(viewTabs);
@@ -82,6 +86,8 @@ const init = (): void => {
8286
const filterGroup = document.createElement("div");
8387
filterGroup.id = "fallow-graph-filters";
8488
filterGroup.style.display = "none";
89+
filterGroup.setAttribute("role", "group");
90+
filterGroup.setAttribute("aria-label", "Filter graph nodes");
8591

8692
const filterButtons: Array<{ label: string; value: GraphFilter }> = [
8793
{ label: "All", value: "all" },
@@ -91,12 +97,16 @@ const init = (): void => {
9197

9298
for (const fb of filterButtons) {
9399
const btn = document.createElement("button");
94-
btn.className = `filter-btn${fb.value === "all" ? " active" : ""}`;
100+
const isActive = fb.value === "all";
101+
btn.className = `filter-btn${isActive ? " active" : ""}`;
95102
btn.textContent = fb.label;
96103
btn.dataset.filter = fb.value;
104+
btn.setAttribute("aria-pressed", String(isActive));
97105
btn.addEventListener("click", () => {
98106
for (const b of filterGroup.querySelectorAll(".filter-btn")) {
99-
b.classList.toggle("active", (b as HTMLElement).dataset.filter === fb.value);
107+
const on = (b as HTMLElement).dataset.filter === fb.value;
108+
b.classList.toggle("active", on);
109+
b.setAttribute("aria-pressed", String(on));
100110
}
101111
setGraphFilter(state!, fb.value);
102112
});
@@ -108,6 +118,8 @@ const init = (): void => {
108118
const clusterGroup = document.createElement("div");
109119
clusterGroup.id = "fallow-cluster-mode";
110120
clusterGroup.style.display = "none";
121+
clusterGroup.setAttribute("role", "group");
122+
clusterGroup.setAttribute("aria-label", "Graph clustering mode");
111123

112124
const clusterLabel = document.createElement("span");
113125
clusterLabel.className = "cluster-label";
@@ -121,12 +133,16 @@ const init = (): void => {
121133

122134
for (const cb of clusterButtons) {
123135
const btn = document.createElement("button");
124-
btn.className = `filter-btn${cb.value === "directory" ? " active" : ""}`;
136+
const isActive = cb.value === "directory";
137+
btn.className = `filter-btn${isActive ? " active" : ""}`;
125138
btn.textContent = cb.label;
126139
btn.dataset.cluster = cb.value;
140+
btn.setAttribute("aria-pressed", String(isActive));
127141
btn.addEventListener("click", () => {
128142
for (const b of clusterGroup.querySelectorAll(".filter-btn")) {
129-
b.classList.toggle("active", (b as HTMLElement).dataset.cluster === cb.value);
143+
const on = (b as HTMLElement).dataset.cluster === cb.value;
144+
b.classList.toggle("active", on);
145+
b.setAttribute("aria-pressed", String(on));
130146
}
131147
setClusterMode(state!, cb.value);
132148
});
@@ -140,6 +156,14 @@ const init = (): void => {
140156

141157
const canvas = document.createElement("canvas");
142158
canvas.id = "fallow-canvas";
159+
canvas.tabIndex = 0;
160+
canvas.setAttribute("role", "img");
161+
canvas.setAttribute(
162+
"aria-label",
163+
`Interactive visualization of ${data.summary.total_files} files. ` +
164+
"Use the Treemap and Graph buttons to switch views, the search box to find files, " +
165+
"and the number keys 1-3 to set graph focus depth.",
166+
);
143167
app.appendChild(canvas);
144168

145169
const state = createState(data, canvas);
@@ -161,11 +185,15 @@ const init = (): void => {
161185
darkModeBtn.id = "fallow-darkmode";
162186
darkModeBtn.textContent = state.darkMode ? "☀" : "☾";
163187
darkModeBtn.title = "Toggle dark mode";
188+
darkModeBtn.setAttribute("aria-label", "Toggle dark mode");
189+
darkModeBtn.setAttribute("aria-pressed", String(state.darkMode));
164190
controls.appendChild(darkModeBtn);
165191

166192
// Legend
167193
const legend = document.createElement("div");
168194
legend.id = "fallow-legend";
195+
legend.setAttribute("role", "list");
196+
legend.setAttribute("aria-label", "Status legend");
169197
const legendItems: Array<[string, string]> = [
170198
["Clean", state.theme.statusColors.clean],
171199
["Entry point", state.theme.statusColors.entryPoint],
@@ -175,10 +203,12 @@ const init = (): void => {
175203
for (const [label, color] of legendItems) {
176204
const item = document.createElement("span");
177205
item.className = "legend-item";
206+
item.setAttribute("role", "listitem");
178207

179208
const swatch = document.createElement("span");
180209
swatch.className = "legend-swatch";
181210
swatch.style.backgroundColor = color;
211+
swatch.setAttribute("aria-hidden", "true");
182212
item.appendChild(swatch);
183213

184214
const text = document.createElement("span");
@@ -196,6 +226,8 @@ const init = (): void => {
196226
state.activeView = view;
197227
treemapTab.classList.toggle("active", view === "treemap");
198228
graphTab.classList.toggle("active", view === "graph");
229+
treemapTab.setAttribute("aria-pressed", String(view === "treemap"));
230+
graphTab.setAttribute("aria-pressed", String(view === "graph"));
199231
breadcrumbEl.style.display = view === "treemap" ? "" : "none";
200232
filterGroup.style.display = view === "graph" ? "" : "none";
201233
clusterGroup.style.display = view === "graph" ? "" : "none";

viz-frontend/src/search.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const setupSearch = (state: AppState): HTMLInputElement => {
3030
input.id = "fallow-search";
3131
input.placeholder = "Search files… (/)";
3232
input.setAttribute("autocomplete", "off");
33+
input.setAttribute("aria-label", "Search files");
3334

3435
let debounceTimer: ReturnType<typeof setTimeout>;
3536
input.addEventListener("input", () => {

viz-frontend/src/styles.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,28 @@ body {
282282
margin-top: 4px;
283283
opacity: 0.7;
284284
}
285+
286+
/* ── Keyboard focus visibility ─────────────────────────────── */
287+
288+
#fallow-app button:focus-visible,
289+
.view-tab:focus-visible,
290+
.filter-btn:focus-visible,
291+
#fallow-darkmode:focus-visible {
292+
outline: 2px solid #3B82F6;
293+
outline-offset: 2px;
294+
}
295+
296+
#fallow-canvas:focus-visible {
297+
outline: 2px solid #3B82F6;
298+
outline-offset: -2px;
299+
}
300+
301+
/* ── Respect reduced-motion preference ─────────────────────── */
302+
303+
@media (prefers-reduced-motion: reduce) {
304+
* {
305+
transition-duration: 0.01ms !important;
306+
animation-duration: 0.01ms !important;
307+
animation-iteration-count: 1 !important;
308+
}
309+
}

0 commit comments

Comments
 (0)