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.
9.8 KiB
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 |