From ea473f41318721542816bd6b248c1e71dce31a26 Mon Sep 17 00:00:00 2001 From: tester Date: Tue, 12 May 2026 02:07:33 -0700 Subject: [PATCH] feat: add accessibility linter for TUI/CLI surfaces Adds scripts/a11y-lint, a Go-based static analyzer that scans pkg/monitor and cmd/ for TUI accessibility smells (NO_COLOR support, adaptive color contrast, icon-only labels, missing cobra Short help) and emits a narrative report at docs/a11y-report.md. Wired into the Makefile via 'make a11y-lint' for CI use. Nightshift-Task: a11y-lint Nightshift-Ref: https://github.com/marcus/nightshift --- Makefile | 5 +- docs/a11y-report.md | 830 ++++++++++++++++++++++++++++++++++++++ scripts/a11y-lint/main.go | 251 ++++++++++++ 3 files changed, 1085 insertions(+), 1 deletion(-) create mode 100644 docs/a11y-report.md create mode 100644 scripts/a11y-lint/main.go diff --git a/Makefile b/Makefile index 18da511f..7383b3f7 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -51,6 +51,9 @@ release: tag @git remote get-url origin >/dev/null 2>&1 || (echo "Error: no 'origin' remote configured" && exit 1) git push origin "$(VERSION)" +a11y-lint: + @go run ./scripts/a11y-lint -out docs/a11y-report.md + install-hooks: @echo "Installing git pre-commit hook..." @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit diff --git a/docs/a11y-report.md b/docs/a11y-report.md new file mode 100644 index 00000000..3605c9c8 --- /dev/null +++ b/docs/a11y-report.md @@ -0,0 +1,830 @@ +# Accessibility Report — td + +This is a narrative analysis of TUI/CLI accessibility heuristics. It is +intentionally not a checkbox list — each finding includes context and a +recommendation. Treat severities as guidance, not gates. + +## Environment signals + +- NO_COLOR is **not** referenced in the scanned tree. Users who set + `NO_COLOR=1` (per https://no-color.org) expect colored output to be + suppressed. Add a single check at TUI bootstrap and gate lipgloss styles. + +## Summary by category + +- color/no-color: 2 finding(s) +- contrast/adaptive: 131 finding(s) +- icon-only label: 2 finding(s) + +## Findings + +### [HIGH] color/no-color — pkg/monitor/styles.go:360 + + bgCode := "\x1b[48;5;237m" // Background color 237 + +Hardcoded ANSI escape; route color through lipgloss + honor NO_COLOR (https://no-color.org). + +### [HIGH] color/no-color — pkg/monitor/styles.go:361 + + reset := "\x1b[0m" + +Hardcoded ANSI escape; route color through lipgloss + honor NO_COLOR (https://no-color.org). + +### [MEDIUM] contrast/adaptive — cmd/stats_analytics.go:18 + + analyticsHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — cmd/stats_analytics.go:19 + + analyticsLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — cmd/stats_analytics.go:20 + + analyticsValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("45")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — cmd/stats_analytics.go:21 + + analyticsErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — cmd/stats_analytics.go:22 + + analyticsWarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — cmd/sync_tail.go:19 + + pushArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("→") // green + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — cmd/sync_tail.go:20 + + pullArrow = lipgloss.NewStyle().Foreground(lipgloss.Color("45")).Render("←") // cyan + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — cmd/sync_tail.go:21 + + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] icon-only label — cmd/ws.go:698 + + statusMark = " ✓" + +Pair the icon with a short text label so screen readers / NO_COLOR users get the meaning. + +### [MEDIUM] contrast/adaptive — pkg/monitor/kanban.go:61 + + return lipgloss.Color("183") // light purple (pending review) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/kanban.go:67 + + return lipgloss.Color("255") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/keymap/help.go:14 + + Foreground(lipgloss.Color("212")) // Primary color (purple/magenta) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1569 + + return style.Background(lipgloss.Color("27")).Foreground(lipgloss.Color("255")).Render("PROGRESS") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1571 + + return style.Background(lipgloss.Color("135")).Foreground(lipgloss.Color("255")).Render("DECISION") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1573 + + return style.Background(lipgloss.Color("196")).Foreground(lipgloss.Color("255")).Render("BLOCKER") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1575 + + return style.Background(lipgloss.Color("208")).Foreground(lipgloss.Color("255")).Render("HYPOTHESIS") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1577 + + return style.Background(lipgloss.Color("214")).Foreground(lipgloss.Color("255")).Render("TRIED") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1579 + + return style.Background(lipgloss.Color("40")).Foreground(lipgloss.Color("255")).Render("RESULT") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1581 + + return style.Background(lipgloss.Color("39")).Foreground(lipgloss.Color("255")).Render("ORCHESTRATION") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1583 + + return style.Background(lipgloss.Color("160")).Foreground(lipgloss.Color("255")).Render("SECURITY") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal.go:1585 + + return style.Background(lipgloss.Color("240")).Foreground(lipgloss.Color("255")).Render(strings.ToUpper(string(logType))) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:11 + + Primary = lipgloss.Color("212") // primaryColor + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:12 + + Error = lipgloss.Color("196") // errorColor + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:13 + + Warning = lipgloss.Color("214") // warningColor + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:14 + + Info = lipgloss.Color("45") // cyan + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:15 + + Muted = lipgloss.Color("241") // mutedColor + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:16 + + BgSecondary = lipgloss.Color("235") // modal background + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:17 + + TextMuted = lipgloss.Color("241") // mutedColor + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:18 + + BorderNormal = lipgloss.Color("240") // default border + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:24 + + Foreground(lipgloss.Color("252")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:25 + + Background(lipgloss.Color("238")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:29 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:35 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:36 + + Background(lipgloss.Color("245")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:40 + + Foreground(lipgloss.Color("252")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:41 + + Background(lipgloss.Color("238")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:45 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:51 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:52 + + Background(lipgloss.Color("203")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:66 + + Foreground(lipgloss.Color("252")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:69 + + Background(lipgloss.Color("237")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:70 + + Foreground(lipgloss.Color("255")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:73 + + Background(lipgloss.Color("237")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/modal/styles.go:74 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/overlay.go:14 + + var DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:22 + + primaryColor = lipgloss.Color("212") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:23 + + secondaryColor = lipgloss.Color("141") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:24 + + mutedColor = lipgloss.Color("241") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:25 + + successColor = lipgloss.Color("42") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:26 + + warningColor = lipgloss.Color("214") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:27 + + errorColor = lipgloss.Color("196") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:28 + + cyanColor = lipgloss.Color("45") + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:33 + + BorderForeground(lipgloss.Color("240")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:44 + + BorderForeground(lipgloss.Color("245")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:49 + + Background(lipgloss.Color("237")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:50 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:57 + + timestampStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:66 + + models.StatusOpen: lipgloss.NewStyle().Foreground(lipgloss.Color("45")), + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:75 + + models.StatusOpen: lipgloss.NewStyle().Foreground(lipgloss.Color("45")), + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:76 + + models.StatusInProgress: lipgloss.NewStyle().Foreground(lipgloss.Color("214")), + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:77 + + models.StatusBlocked: lipgloss.NewStyle().Foreground(lipgloss.Color("196")), + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:78 + + models.StatusInReview: lipgloss.NewStyle().Foreground(lipgloss.Color("141")), + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:86 + + models.PriorityP2: lipgloss.NewStyle().Foreground(lipgloss.Color("45")), + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:94 + + commentBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("45")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:99 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:110 + + Foreground(lipgloss.Color("45")). // Cyan when focused + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:114 + + Background(lipgloss.Color("237")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:115 + + Foreground(lipgloss.Color("255")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:122 + + Background(lipgloss.Color("237")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:129 + + Foreground(lipgloss.Color("196")). // Red when focused + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:133 + + Background(lipgloss.Color("237")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:134 + + Foreground(lipgloss.Color("255")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:138 + + Foreground(lipgloss.Color("45")). // Cyan when focused + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:142 + + Background(lipgloss.Color("237")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:143 + + Foreground(lipgloss.Color("255")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:147 + + Foreground(lipgloss.Color("244")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:153 + + Foreground(lipgloss.Color("0")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:158 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:163 + + models.TypeEpic: lipgloss.NewStyle().Foreground(lipgloss.Color("212")), // Purple/magenta + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:164 + + models.TypeFeature: lipgloss.NewStyle().Foreground(lipgloss.Color("42")), // Green + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:165 + + models.TypeBug: lipgloss.NewStyle().Foreground(lipgloss.Color("196")), // Red + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:166 + + models.TypeTask: lipgloss.NewStyle().Foreground(lipgloss.Color("45")), // Cyan + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:167 + + models.TypeChore: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), // Gray + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] icon-only label — pkg/monitor/styles.go:174 + + models.TypeBug: "✗", // X mark - defect + +Pair the icon with a short text label so screen readers / NO_COLOR users get the meaning. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:183 + + BorderForeground(lipgloss.Color("45")). // Cyan + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:189 + + BorderForeground(lipgloss.Color("214")). // Orange/Yellow + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:194 + + Foreground(lipgloss.Color("252")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:195 + + Background(lipgloss.Color("238")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:199 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:205 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:206 + + Background(lipgloss.Color("245")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:211 + + Foreground(lipgloss.Color("252")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:212 + + Background(lipgloss.Color("238")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:216 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:222 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:223 + + Background(lipgloss.Color("203")). // Lighter red + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:229 + + Foreground(lipgloss.Color("255")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:232 + + Background(lipgloss.Color("237")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:235 + + kanbanTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:236 + + kanbanHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:237 + + kanbanSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/styles.go:242 + + BorderForeground(lipgloss.Color("240")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:1610 + + BorderForeground(lipgloss.Color("42")). // Green for handoffs + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:1674 + + BorderForeground(lipgloss.Color("212")). // Purple + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:1825 + + borderColor := lipgloss.Color("45") // Cyan + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2006 + + borderColor = lipgloss.Color("45") // Cyan + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2008 + + borderColor = lipgloss.Color("214") // Orange for depth 3+ + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2199 + + pinkStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")) // Pink + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2233 + + BorderForeground(lipgloss.Color("240")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2354 + + filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2358 + + filterStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2410 + + BorderForeground(lipgloss.Color("141")). // Purple for help + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2601 + + readyColor = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2602 + + reviewColor = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2603 + + blockedColor = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2604 + + reworkColor = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // Orange/warning + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2605 + + inProgressColor = lipgloss.NewStyle().Foreground(lipgloss.Color("45")) // Cyan + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2606 + + pendingReviewColor = lipgloss.NewStyle().Foreground(lipgloss.Color("183")) // Light purple + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2611 + + Foreground(lipgloss.Color("0")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2612 + + Background(lipgloss.Color("141")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2617 + + Foreground(lipgloss.Color("0")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2618 + + Background(lipgloss.Color("42")) // Green bg + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2622 + + Foreground(lipgloss.Color("255")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2623 + + Background(lipgloss.Color("196")) // Red bg + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2627 + + Foreground(lipgloss.Color("0")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2628 + + Background(lipgloss.Color("45")) // Cyan bg + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2632 + + Foreground(lipgloss.Color("0")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2633 + + Background(lipgloss.Color("183")) // Light purple bg + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2638 + + Foreground(lipgloss.Color("0")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2639 + + Background(lipgloss.Color("42")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2643 + + Foreground(lipgloss.Color("45")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2648 + + Foreground(lipgloss.Color("0")). + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + +### [MEDIUM] contrast/adaptive — pkg/monitor/view.go:2649 + + Background(lipgloss.Color("214")) + +Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals. + diff --git a/scripts/a11y-lint/main.go b/scripts/a11y-lint/main.go new file mode 100644 index 00000000..2b68726a --- /dev/null +++ b/scripts/a11y-lint/main.go @@ -0,0 +1,251 @@ +// a11y-lint scans the td TUI/CLI codebase for accessibility smells. +// +// Heuristics (non-exhaustive): +// - hardcoded ANSI escape sequences without NO_COLOR awareness +// - lipgloss color usage without a high-contrast / adaptive variant +// - emoji or icon-only labels lacking accompanying text +// - cobra commands missing Short/Long help text +// +// Output: prose findings to stdout (or to -out file). Non-zero exit only on +// internal error; findings themselves are advisory. +package main + +import ( + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +type Severity string + +const ( + SevHigh Severity = "high" + SevMedium Severity = "medium" + SevLow Severity = "low" +) + +type Finding struct { + File string + Line int + Severity Severity + Category string + Snippet string + Recommendation string +} + +var ( + reANSI = regexp.MustCompile(`\\x1b\[[0-9;]+m|\\033\[[0-9;]+m`) + reLipColor = regexp.MustCompile(`lipgloss\.Color\(`) + reAdaptive = regexp.MustCompile(`lipgloss\.AdaptiveColor`) + reEmoji = regexp.MustCompile(`[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}]`) + reCobraShort = regexp.MustCompile(`Short:\s*"`) + reCobraCmd = regexp.MustCompile(`&cobra\.Command\{`) +) + +func main() { + out := flag.String("out", "", "write report to this file (default stdout)") + roots := flag.String("roots", "pkg/monitor,cmd", "comma-separated roots to scan") + flag.Parse() + + var findings []Finding + hasNoColorCheck := false + + for _, r := range strings.Split(*roots, ",") { + r = strings.TrimSpace(r) + if r == "" { + continue + } + _ = filepath.WalkDir(r, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") { + return nil + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + if strings.Contains(string(data), "NO_COLOR") { + hasNoColorCheck = true + } + scanFile(path, string(data), &findings) + return nil + }) + } + + report := renderReport(findings, hasNoColorCheck) + + if *out == "" { + fmt.Print(report) + return + } + if err := os.WriteFile(*out, []byte(report), 0o644); err != nil { + fmt.Fprintln(os.Stderr, "write:", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "wrote %d findings to %s\n", len(findings), *out) +} + +func scanFile(path, content string, out *[]Finding) { + lines := strings.Split(content, "\n") + inCobraBlock := false + cobraStart := 0 + for i, line := range lines { + ln := i + 1 + + if reANSI.MatchString(line) { + *out = append(*out, Finding{ + File: path, Line: ln, Severity: SevHigh, + Category: "color/no-color", + Snippet: strings.TrimSpace(line), + Recommendation: "Hardcoded ANSI escape; route color through lipgloss + honor NO_COLOR (https://no-color.org).", + }) + } + + if reLipColor.MatchString(line) && !reAdaptive.MatchString(line) { + // flag plain Color(...) usages — they don't adapt to light terminals + *out = append(*out, Finding{ + File: path, Line: ln, Severity: SevMedium, + Category: "contrast/adaptive", + Snippet: strings.TrimSpace(line), + Recommendation: "Prefer lipgloss.AdaptiveColor{Light, Dark} so contrast holds on both light and dark terminals.", + }) + } + + if reEmoji.MatchString(line) && looksLikeIconOnlyLabel(line) { + *out = append(*out, Finding{ + File: path, Line: ln, Severity: SevMedium, + Category: "icon-only label", + Snippet: strings.TrimSpace(line), + Recommendation: "Pair the icon with a short text label so screen readers / NO_COLOR users get the meaning.", + }) + } + + if reCobraCmd.MatchString(line) { + inCobraBlock = true + cobraStart = ln + continue + } + if inCobraBlock { + if strings.TrimSpace(line) == "}" { + inCobraBlock = false + } else if reCobraShort.MatchString(line) { + inCobraBlock = false // satisfied + } else if ln-cobraStart > 40 { + *out = append(*out, Finding{ + File: path, Line: cobraStart, Severity: SevLow, + Category: "cli help", + Snippet: "cobra.Command{...} block", + Recommendation: "Cobra command appears to lack a Short: help string within ~40 lines; --help output suffers.", + }) + inCobraBlock = false + } + } + } +} + +func looksLikeIconOnlyLabel(line string) bool { + // crude: a quoted string that is just an emoji + maybe whitespace + q := regexp.MustCompile(`"([^"]{1,8})"`) + for _, m := range q.FindAllStringSubmatch(line, -1) { + s := strings.TrimSpace(m[1]) + if s == "" { + continue + } + stripped := reEmoji.ReplaceAllString(s, "") + if strings.TrimSpace(stripped) == "" { + return true + } + } + return false +} + +func renderReport(findings []Finding, hasNoColor bool) string { + sort.SliceStable(findings, func(i, j int) bool { + if findings[i].Severity != findings[j].Severity { + return sevRank(findings[i].Severity) < sevRank(findings[j].Severity) + } + if findings[i].File != findings[j].File { + return findings[i].File < findings[j].File + } + return findings[i].Line < findings[j].Line + }) + + byCat := map[string]int{} + for _, f := range findings { + byCat[f.Category]++ + } + + var b strings.Builder + fmt.Fprintln(&b, "# Accessibility Report — td") + fmt.Fprintln(&b) + fmt.Fprintln(&b, "This is a narrative analysis of TUI/CLI accessibility heuristics. It is") + fmt.Fprintln(&b, "intentionally not a checkbox list — each finding includes context and a") + fmt.Fprintln(&b, "recommendation. Treat severities as guidance, not gates.") + fmt.Fprintln(&b) + + fmt.Fprintln(&b, "## Environment signals") + fmt.Fprintln(&b) + if hasNoColor { + fmt.Fprintln(&b, "- NO_COLOR is referenced somewhere in the scanned tree — good. Verify the") + fmt.Fprintln(&b, " check is honored at every render path, not just startup.") + } else { + fmt.Fprintln(&b, "- NO_COLOR is **not** referenced in the scanned tree. Users who set") + fmt.Fprintln(&b, " `NO_COLOR=1` (per https://no-color.org) expect colored output to be") + fmt.Fprintln(&b, " suppressed. Add a single check at TUI bootstrap and gate lipgloss styles.") + } + fmt.Fprintln(&b) + + fmt.Fprintln(&b, "## Summary by category") + fmt.Fprintln(&b) + cats := make([]string, 0, len(byCat)) + for c := range byCat { + cats = append(cats, c) + } + sort.Strings(cats) + for _, c := range cats { + fmt.Fprintf(&b, "- %s: %d finding(s)\n", c, byCat[c]) + } + if len(findings) == 0 { + fmt.Fprintln(&b, "- No automated findings. Manual review still recommended.") + } + fmt.Fprintln(&b) + + fmt.Fprintln(&b, "## Findings") + fmt.Fprintln(&b) + if len(findings) == 0 { + fmt.Fprintln(&b, "No findings to report.") + return b.String() + } + for _, f := range findings { + fmt.Fprintf(&b, "### [%s] %s — %s:%d\n\n", strings.ToUpper(string(f.Severity)), f.Category, f.File, f.Line) + fmt.Fprintf(&b, " %s\n\n", truncate(f.Snippet, 160)) + fmt.Fprintf(&b, "%s\n\n", f.Recommendation) + } + return b.String() +} + +func sevRank(s Severity) int { + switch s { + case SevHigh: + return 0 + case SevMedium: + return 1 + case SevLow: + return 2 + } + return 3 +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +}