Files
git.stella-ops.org/docs/modules/ui/accessibility.md
master a4badc275e UI work to fill SBOM sourcing management gap. UI planning remaining functionality exposure. Work on CI/Tests stabilization
Introduces CGS determinism test runs to CI workflows for Windows, macOS, Linux, Alpine, and Debian, fulfilling CGS-008 cross-platform requirements. Updates local-ci scripts to support new smoke steps, test timeouts, progress intervals, and project slicing for improved test isolation and diagnostics.
2025-12-29 19:12:38 +02:00

9.8 KiB

StellaOps UI Accessibility Guide

Overview

StellaOps web interface aims for WCAG 2.1 AA compliance. This document outlines accessibility requirements, testing procedures, and implementation patterns.

WCAG 2.1 AA Compliance Checklist

Perceivable

Criterion Requirement Implementation
1.1.1 Non-text Content Alt text for images All <img> elements have alt attribute
1.3.1 Info and Relationships Semantic HTML Use proper heading hierarchy, lists, tables
1.3.2 Meaningful Sequence Logical reading order DOM order matches visual order
1.4.1 Use of Color Color not sole indicator Use icons + color, patterns + color
1.4.3 Contrast (Minimum) 4.5:1 for text All text meets contrast ratio
1.4.4 Resize Text 200% zoom works No horizontal scroll at 200% zoom
1.4.11 Non-text Contrast 3:1 for UI components Buttons, inputs, focus indicators

Operable

Criterion Requirement Implementation
2.1.1 Keyboard All functionality keyboard accessible Tab navigation, keyboard shortcuts
2.1.2 No Keyboard Trap Users can navigate away Focus trap only in modals
2.4.1 Bypass Blocks Skip navigation link Skip link at top of page
2.4.3 Focus Order Logical focus order Tab order matches visual layout
2.4.6 Headings and Labels Descriptive headings Clear, unique page titles
2.4.7 Focus Visible Focus indicator visible Custom focus styles

Understandable

Criterion Requirement Implementation
3.1.1 Language of Page lang attribute set <html lang="en">
3.2.1 On Focus No unexpected changes No auto-submit on focus
3.3.1 Error Identification Errors identified Error messages with field reference
3.3.2 Labels or Instructions Labels provided All inputs have labels

Robust

Criterion Requirement Implementation
4.1.1 Parsing Valid HTML No duplicate IDs, proper nesting
4.1.2 Name, Role, Value ARIA attributes Proper ARIA for custom widgets

Color Contrast Requirements

Severity Colors (with sufficient contrast)

/* All severity colors meet 4.5:1 contrast on white background */
:root {
  --color-critical: #991b1b;      /* 7.8:1 on white */
  --color-critical-bg: #fef2f2;

  --color-high: #9a3412;          /* 7.5:1 on white */
  --color-high-bg: #fff7ed;

  --color-medium: #854d0e;        /* 7.2:1 on white */
  --color-medium-bg: #fefce8;

  --color-low: #166534;           /* 6.8:1 on white */
  --color-low-bg: #f0fdf4;

  --color-info: #1e40af;          /* 8.2:1 on white */
  --color-info-bg: #eff6ff;
}

Dark Mode Contrast

/* Dark mode maintains contrast ratios */
[data-theme="dark"] {
  --color-critical: #fca5a5;      /* 4.5:1 on dark background */
  --color-high: #fdba74;
  --color-medium: #fde047;
  --color-low: #86efac;
  --color-info: #93c5fd;
}

Keyboard Navigation

Global Shortcuts

Shortcut Action Scope
Cmd/Ctrl+K Open command palette Global
? Show keyboard help Global
Esc Close modal/Clear selection Global
g h Go to Home Navigation
g f Go to Findings Navigation
g p Go to Policy Navigation
g o Go to Ops Navigation

List Navigation

Shortcut Action
j / Move down
k / Move up
Enter Select/Open
Space Toggle checkbox
x Select current
/ Focus search

Implementation Example

@Injectable({ providedIn: 'root' })
export class KeyboardShortcutService {
  private shortcuts = new Map<string, ShortcutHandler>();

  register(key: string, handler: ShortcutHandler): void {
    this.shortcuts.set(key.toLowerCase(), handler);
  }

  @HostListener('window:keydown', ['$event'])
  handleKeydown(event: KeyboardEvent): void {
    // Ignore when typing in inputs
    if (this.isTyping(event)) return;

    const key = this.normalizeKey(event);
    const handler = this.shortcuts.get(key);

    if (handler) {
      event.preventDefault();
      handler.execute();
    }
  }
}

Focus Management

Focus Trap for Modals

@Directive({ selector: '[appFocusTrap]' })
export class FocusTrapDirective implements AfterViewInit, OnDestroy {
  private focusableElements: HTMLElement[] = [];
  private previousActiveElement: HTMLElement | null = null;

  ngAfterViewInit(): void {
    this.previousActiveElement = document.activeElement as HTMLElement;
    this.focusableElements = this.getFocusableElements();
    this.focusFirst();
  }

  ngOnDestroy(): void {
    this.previousActiveElement?.focus();
  }

  @HostListener('keydown.tab', ['$event'])
  handleTab(event: KeyboardEvent): void {
    const first = this.focusableElements[0];
    const last = this.focusableElements[this.focusableElements.length - 1];

    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  }
}

Focus Visible Styles

/* Custom focus indicator */
*:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* Remove default outline for mouse users */
*:focus:not(:focus-visible) {
  outline: none;
}

/* High contrast mode focus */
@media (forced-colors: active) {
  *:focus-visible {
    outline: 3px solid CanvasText;
  }
}

Screen Reader Support

ARIA Attributes

<!-- Live regions for dynamic updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
  {{ statusMessage }}
</div>

<!-- Loading states -->
<div aria-busy="true" aria-describedby="loading-message">
  <span id="loading-message" class="sr-only">Loading findings...</span>
</div>

<!-- Error announcements -->
<div role="alert" aria-live="assertive">
  {{ errorMessage }}
</div>

<!-- Navigation landmarks -->
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Breadcrumb">...</nav>
<main aria-labelledby="page-title">...</main>

Screen Reader Announcements

@Injectable({ providedIn: 'root' })
export class AnnouncerService {
  private liveRegion: HTMLElement;

  constructor() {
    this.createLiveRegion();
  }

  announce(message: string, politeness: 'polite' | 'assertive' = 'polite'): void {
    this.liveRegion.setAttribute('aria-live', politeness);
    this.liveRegion.textContent = '';

    // Force reflow
    void this.liveRegion.offsetWidth;

    this.liveRegion.textContent = message;
  }

  private createLiveRegion(): void {
    this.liveRegion = document.createElement('div');
    this.liveRegion.setAttribute('aria-live', 'polite');
    this.liveRegion.setAttribute('aria-atomic', 'true');
    this.liveRegion.className = 'sr-only';
    document.body.appendChild(this.liveRegion);
  }
}

Form Accessibility

Input Labels

<!-- Visible label -->
<label for="cve-search">Search CVEs</label>
<input id="cve-search" type="text" aria-describedby="cve-search-hint">
<span id="cve-search-hint" class="hint">Enter CVE ID like CVE-2024-1234</span>

<!-- Hidden label for icon buttons -->
<button aria-label="Close dialog">
  <svg aria-hidden="true">...</svg>
</button>

<!-- Group labels -->
<fieldset>
  <legend>Severity Filter</legend>
  <label><input type="checkbox" name="severity" value="critical"> Critical</label>
  <label><input type="checkbox" name="severity" value="high"> High</label>
</fieldset>

Error Messages

<div class="form-group" [class.has-error]="control.invalid && control.touched">
  <label for="policy-name">Policy Name</label>
  <input
    id="policy-name"
    [attr.aria-invalid]="control.invalid && control.touched"
    [attr.aria-describedby]="control.invalid ? 'policy-name-error' : null">
  <span id="policy-name-error" role="alert" *ngIf="control.invalid && control.touched">
    {{ getErrorMessage(control) }}
  </span>
</div>

Motion and Animation

Reduced Motion Support

/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* Safe animations (opacity, transform only) */
.fade-in {
  animation: fadeIn 200ms ease-out;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Alternative for reduced motion */
@media (prefers-reduced-motion: reduce) {
  .fade-in {
    animation: none;
    opacity: 1;
  }
}

Testing Procedures

Automated Testing

# axe-core CLI
npm run test:a11y

# Lighthouse accessibility audit
lighthouse --only-categories=accessibility https://localhost:4200

# Pa11y for CI
pa11y https://localhost:4200 --standard WCAG2AA

Manual Testing Checklist

  • Tab through entire page - all interactive elements focusable
  • Keyboard shortcuts work without mouse
  • Focus indicator visible on all focusable elements
  • Screen reader announces page changes
  • Zoom to 200% - no horizontal scroll
  • High contrast mode renders correctly
  • Reduced motion preference respected

Screen Reader Testing

Platform Screen Reader Browser
Windows NVDA Firefox
Windows JAWS Chrome
macOS VoiceOver Safari
iOS VoiceOver Safari
Android TalkBack Chrome