Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help fmt test install tag release check-clean check-version install-hooks
.PHONY: help fmt test install tag release check-clean check-version install-hooks a11y-lint

SHELL := /bin/sh

Expand Down Expand Up @@ -55,3 +55,8 @@ install-hooks:
@echo "Installing git pre-commit hook..."
@ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit
@echo "Done. Hook installed at .git/hooks/pre-commit"

a11y-lint:
@mkdir -p docs
go run ./scripts/a11y-lint -o docs/a11y-report.md
@echo "Accessibility report written to docs/a11y-report.md"
84 changes: 84 additions & 0 deletions docs/a11y-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Accessibility Report — td TUI/CLI

This report is a narrative accessibility review of the td codebase. td is a terminal-first application; the surfaces that matter for a11y are the Bubble Tea monitor in `pkg/monitor` and the Cobra CLI in `cmd/`. Web/HTML a11y heuristics (ARIA, alt text, semantic landmarks) do not apply here, so this analyzer targets TUI-equivalents: color contrast and theming, NO_COLOR support, keybinding discoverability, and icon-only labels.

## NO_COLOR Environment Support

**HIGH**: No reference to `NO_COLOR` was found. Low-vision users and screen-reader pipelines rely on this env var (https://no-color.org) to suppress ANSI styling. Add a check at startup that disables lipgloss color rendering when `NO_COLOR` is set.

## Findings


### raw-ansi

- **HIGH** `pkg/monitor/overlay_test.go:82` — Raw ANSI escape sequence. Bypasses lipgloss/NO_COLOR handling and breaks screen readers.
- **HIGH** `pkg/monitor/overlay_test.go:239` — Raw ANSI escape sequence. Bypasses lipgloss/NO_COLOR handling and breaks screen readers.
- **HIGH** `pkg/monitor/overlay_test.go:242` — Raw ANSI escape sequence. Bypasses lipgloss/NO_COLOR handling and breaks screen readers.
- **HIGH** `pkg/monitor/overlay_test.go:245` — Raw ANSI escape sequence. Bypasses lipgloss/NO_COLOR handling and breaks screen readers.
- **HIGH** `pkg/monitor/styles.go:360` — Raw ANSI escape sequence. Bypasses lipgloss/NO_COLOR handling and breaks screen readers.
- **HIGH** `pkg/monitor/styles.go:361` — Raw ANSI escape sequence. Bypasses lipgloss/NO_COLOR handling and breaks screen readers.

### icon-only-label

- **WARN** `cmd/board.go:446` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/board.go:448` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/board.go:450` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/board.go:452` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/board.go:454` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/board.go:456` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/stats_analytics.go:26` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/stats_analytics.go:27` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `cmd/stats_analytics.go:28` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output.go:337` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output.go:338` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output.go:339` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output.go:340` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output.go:341` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:578` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:579` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:580` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:581` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:582` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:712` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:726` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/output_test.go:740` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/tree_test.go:28` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/tree_test.go:57` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/tree_test.go:62` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/tree_test.go:67` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/tree_test.go:222` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/tree_test.go:233` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `internal/output/tree_test.go:238` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/kanban.go:331` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/kanban.go:337` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/kanban.go:453` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/kanban.go:462` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/kanban.go:464` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:177` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:295` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:296` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:297` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:319` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:437` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:438` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/markdown.go:439` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/styles.go:103` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/styles.go:104` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/styles.go:172` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/styles.go:173` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/styles.go:174` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/styles.go:175` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/styles.go:176` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/view.go:1788` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/view.go:1789` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/view.go:2201` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/view.go:2205` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.
- **WARN** `pkg/monitor/view.go:2355` — String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers.

## Recommendations

1. Centralize color tokens in a single theme package and define a high-contrast variant selectable via env or config.
2. Honor `NO_COLOR` at the lipgloss renderer level (`lipgloss.SetColorProfile(termenv.Ascii)` when set).
3. Every `key.NewBinding` should carry a matching `key.WithHelp` so the help pane is complete.
4. Never use emoji or unicode symbols as the sole indicator of state — pair with a short text label.
5. Avoid raw ANSI escape sequences in source; route all styling through lipgloss so it can be globally disabled.
163 changes: 163 additions & 0 deletions scripts/a11y-lint/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Command a11y-lint scans the td TUI/CLI codebase for accessibility smells.
//
// This is a narrative analyzer, not a checkbox linter. It produces severity-
// tagged findings about color usage, keybinding discoverability, icon-only
// labels, and NO_COLOR support. Output is a markdown report to stdout or to
// the path given by -o.
package main

import (
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)

type finding struct {
severity string // info | warn | high
file string
line int
category string
message string
}

var (
reHexColor = regexp.MustCompile(`lipgloss\.Color\("#[0-9a-fA-F]{3,8}"\)`)
reANSIRaw = regexp.MustCompile(`"\\x1b\[[0-9;]*m"`)
reEmojiOnly = regexp.MustCompile(`"[\x{2300}-\x{27BF}\x{1F300}-\x{1FAFF}\x{2600}-\x{26FF}]+\s*"`)
reNoColorEnv = regexp.MustCompile(`NO_COLOR`)
reKeyBinding = regexp.MustCompile(`key\.NewBinding\(`)
reKeyHelp = regexp.MustCompile(`key\.WithHelp\(`)
)

func main() {
out := flag.String("o", "", "write report to file instead of stdout")
root := flag.String("root", ".", "repo root")
flag.Parse()

var findings []finding
hasNoColorSupport := false

walkErr := filepath.Walk(*root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
name := info.Name()
if name == "vendor" || name == ".git" || name == "node_modules" || strings.HasPrefix(name, ".") && name != "." {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
rel, _ := filepath.Rel(*root, path)
if !(strings.HasPrefix(rel, "pkg/monitor") || strings.HasPrefix(rel, "cmd/") || strings.HasPrefix(rel, "internal/")) {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
text := string(data)
if reNoColorEnv.MatchString(text) {
hasNoColorSupport = true
}
lines := strings.Split(text, "\n")
bindings := 0
helps := 0
for i, ln := range lines {
if reHexColor.MatchString(ln) {
findings = append(findings, finding{"warn", rel, i + 1, "color-contrast",
"Hardcoded hex color via lipgloss.Color. Ensure pair has a documented contrast ratio and a high-contrast fallback for low-vision users."})
}
if reANSIRaw.MatchString(ln) {
findings = append(findings, finding{"high", rel, i + 1, "raw-ansi",
"Raw ANSI escape sequence. Bypasses lipgloss/NO_COLOR handling and breaks screen readers."})
}
if reEmojiOnly.MatchString(ln) {
findings = append(findings, finding{"warn", rel, i + 1, "icon-only-label",
"String appears to contain only emoji/symbol characters. Pair icons with text labels for screen readers."})
}
if reKeyBinding.MatchString(ln) {
bindings++
}
if reKeyHelp.MatchString(ln) {
helps++
}
}
if bindings > 0 && helps < bindings {
findings = append(findings, finding{"warn", rel, 0, "keybinding-help",
fmt.Sprintf("%d key.NewBinding calls but only %d WithHelp entries. Undocumented bindings hurt keyboard discoverability.", bindings, helps)})
}
return nil
})
if walkErr != nil {
fmt.Fprintln(os.Stderr, "walk error:", walkErr)
}

sort.Slice(findings, func(i, j int) bool {
if findings[i].severity != findings[j].severity {
rank := map[string]int{"high": 0, "warn": 1, "info": 2}
return rank[findings[i].severity] < rank[findings[j].severity]
}
if findings[i].file != findings[j].file {
return findings[i].file < findings[j].file
}
return findings[i].line < findings[j].line
})

var b strings.Builder
b.WriteString("# Accessibility Report — td TUI/CLI\n\n")
b.WriteString("This report is a narrative accessibility review of the td codebase. ")
b.WriteString("td is a terminal-first application; the surfaces that matter for a11y are the Bubble Tea monitor in `pkg/monitor` and the Cobra CLI in `cmd/`. ")
b.WriteString("Web/HTML a11y heuristics (ARIA, alt text, semantic landmarks) do not apply here, so this analyzer targets TUI-equivalents: color contrast and theming, NO_COLOR support, keybinding discoverability, and icon-only labels.\n\n")

b.WriteString("## NO_COLOR Environment Support\n\n")
if hasNoColorSupport {
b.WriteString("The codebase references `NO_COLOR`, which is the standard low-vision / screen-reader-friendly opt-out. Verify it is honored at the lipgloss/render layer, not just read into a flag.\n\n")
} else {
b.WriteString("**HIGH**: No reference to `NO_COLOR` was found. Low-vision users and screen-reader pipelines rely on this env var (https://no-color.org) to suppress ANSI styling. Add a check at startup that disables lipgloss color rendering when `NO_COLOR` is set.\n\n")
}

b.WriteString("## Findings\n\n")
if len(findings) == 0 {
b.WriteString("No automated findings. A manual review of color palette contrast and focus indicators is still recommended.\n\n")
} else {
cur := ""
for _, f := range findings {
if f.category != cur {
fmt.Fprintf(&b, "\n### %s\n\n", f.category)
cur = f.category
}
if f.line > 0 {
fmt.Fprintf(&b, "- **%s** `%s:%d` — %s\n", strings.ToUpper(f.severity), f.file, f.line, f.message)
} else {
fmt.Fprintf(&b, "- **%s** `%s` — %s\n", strings.ToUpper(f.severity), f.file, f.message)
}
}
b.WriteString("\n")
}

b.WriteString("## Recommendations\n\n")
b.WriteString("1. Centralize color tokens in a single theme package and define a high-contrast variant selectable via env or config.\n")
b.WriteString("2. Honor `NO_COLOR` at the lipgloss renderer level (`lipgloss.SetColorProfile(termenv.Ascii)` when set).\n")
b.WriteString("3. Every `key.NewBinding` should carry a matching `key.WithHelp` so the help pane is complete.\n")
b.WriteString("4. Never use emoji or unicode symbols as the sole indicator of state — pair with a short text label.\n")
b.WriteString("5. Avoid raw ANSI escape sequences in source; route all styling through lipgloss so it can be globally disabled.\n")

report := b.String()
if *out != "" {
if err := os.WriteFile(*out, []byte(report), 0644); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "wrote %s (%d findings)\n", *out, len(findings))
return
}
fmt.Print(report)
}