@reticular/speakable 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +116 -1
- package/dist/index.js +310 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +340 -0
- package/dist/mcp.js.map +1 -1
- package/dist/storybook/dist/chunk-CL3DJBD7.js +40 -0
- package/dist/storybook/dist/index.js +12 -0
- package/dist/storybook/dist/manager.js +302 -0
- package/dist/storybook/dist/preset.js +38 -0
- package/dist/storybook/dist/preview.js +2129 -0
- package/package.json +29 -3
package/dist/index.d.ts
CHANGED
|
@@ -1333,6 +1333,114 @@ interface BaselineStorage {
|
|
|
1333
1333
|
}
|
|
1334
1334
|
declare function createBaselineStorage(baseDir: string): BaselineStorage;
|
|
1335
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* Verbosity Analyzer
|
|
1338
|
+
*
|
|
1339
|
+
* Detects redundant and duplicate announcement patterns in accessibility
|
|
1340
|
+
* event timelines. While the heuristic analyzer catches "too many announcements
|
|
1341
|
+
* in a time window," this module performs deeper semantic analysis to identify
|
|
1342
|
+
* WHY announcements are being duplicated, and provides specific remediation
|
|
1343
|
+
* guidance.
|
|
1344
|
+
*
|
|
1345
|
+
* Detected patterns:
|
|
1346
|
+
*
|
|
1347
|
+
* 1. **Redundant state + live region** — When aria-selected/aria-checked changes
|
|
1348
|
+
* on an element AND a live region announces the same state change, screen
|
|
1349
|
+
* readers (especially VoiceOver) will read both.
|
|
1350
|
+
*
|
|
1351
|
+
* 2. **ActiveDescendant + state change on same element** — When
|
|
1352
|
+
* aria-activedescendant moves to an element AND that element's state changes
|
|
1353
|
+
* in the same interaction frame, VoiceOver reads the element twice.
|
|
1354
|
+
*
|
|
1355
|
+
* 3. **Name change + live region echo** — When an element's accessible name
|
|
1356
|
+
* changes and a live region repeats that same content.
|
|
1357
|
+
*
|
|
1358
|
+
* 4. **Focus + activedescendant conflict** — When real DOM focus moves AND
|
|
1359
|
+
* aria-activedescendant changes in the same frame, causing double-read.
|
|
1360
|
+
*
|
|
1361
|
+
* 5. **Multiple state changes on one element** — When several ARIA attributes
|
|
1362
|
+
* change simultaneously on a single element, each triggering a separate read.
|
|
1363
|
+
*
|
|
1364
|
+
* @module runtime/verbosity-analyzer
|
|
1365
|
+
*/
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Severity of a verbosity issue.
|
|
1369
|
+
* - "high": Users will definitely hear duplicate content (e.g., VoiceOver triple-read)
|
|
1370
|
+
* - "medium": Users will likely hear repeated info depending on screen reader settings
|
|
1371
|
+
* - "low": Potential redundancy that may only affect certain screen readers
|
|
1372
|
+
*/
|
|
1373
|
+
type VerbositySeverity = 'high' | 'medium' | 'low';
|
|
1374
|
+
/**
|
|
1375
|
+
* Category of the verbosity pattern detected.
|
|
1376
|
+
*/
|
|
1377
|
+
type VerbosityPattern = 'redundant-state-and-live-region' | 'activedescendant-plus-state-change' | 'name-change-plus-live-region' | 'focus-plus-activedescendant' | 'multiple-state-changes-same-element' | 'rapid-identical-announcements';
|
|
1378
|
+
/**
|
|
1379
|
+
* A single verbosity finding with context and remediation guidance.
|
|
1380
|
+
*/
|
|
1381
|
+
interface VerbosityFinding {
|
|
1382
|
+
/** Pattern category */
|
|
1383
|
+
pattern: VerbosityPattern;
|
|
1384
|
+
/** Severity level */
|
|
1385
|
+
severity: VerbositySeverity;
|
|
1386
|
+
/** Human-readable description of the problem */
|
|
1387
|
+
description: string;
|
|
1388
|
+
/** Specific remediation steps */
|
|
1389
|
+
remediation: string[];
|
|
1390
|
+
/** Which screen readers are most affected */
|
|
1391
|
+
affectedReaders: string[];
|
|
1392
|
+
/** The events that triggered this finding */
|
|
1393
|
+
relatedEvents: AccessibilityEvent[];
|
|
1394
|
+
/** Timestamp window where the duplicate occurred */
|
|
1395
|
+
timeWindow: {
|
|
1396
|
+
start: number;
|
|
1397
|
+
end: number;
|
|
1398
|
+
};
|
|
1399
|
+
/** CSS selector of the element involved */
|
|
1400
|
+
elementSelector: string;
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Complete verbosity analysis report for a timeline.
|
|
1404
|
+
*/
|
|
1405
|
+
interface VerbosityReport {
|
|
1406
|
+
/** All findings sorted by severity (high first) */
|
|
1407
|
+
findings: VerbosityFinding[];
|
|
1408
|
+
/** Summary counts by severity */
|
|
1409
|
+
summary: {
|
|
1410
|
+
high: number;
|
|
1411
|
+
medium: number;
|
|
1412
|
+
low: number;
|
|
1413
|
+
total: number;
|
|
1414
|
+
};
|
|
1415
|
+
/** Overall verbosity score (0-100, lower is better) */
|
|
1416
|
+
score: number;
|
|
1417
|
+
}
|
|
1418
|
+
/**
|
|
1419
|
+
* Configuration for the verbosity analyzer.
|
|
1420
|
+
*/
|
|
1421
|
+
interface VerbosityConfig {
|
|
1422
|
+
/** Time window (ms) to group related events into a single interaction frame (default: 150) */
|
|
1423
|
+
interactionFrameWindow: number;
|
|
1424
|
+
/** Minimum text similarity ratio to consider live region content redundant (default: 0.6) */
|
|
1425
|
+
textSimilarityThreshold: number;
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Analyzes a timeline of accessibility events for verbosity issues.
|
|
1429
|
+
*
|
|
1430
|
+
* Groups events into interaction frames (events within a short time window
|
|
1431
|
+
* of each other), then checks each frame for patterns that cause screen
|
|
1432
|
+
* readers to announce redundant or duplicate content.
|
|
1433
|
+
*
|
|
1434
|
+
* @param events - Chronologically ordered accessibility events
|
|
1435
|
+
* @param config - Optional configuration overrides
|
|
1436
|
+
* @returns A VerbosityReport with all findings and a score
|
|
1437
|
+
*/
|
|
1438
|
+
declare function analyzeVerbosity(events: AccessibilityEvent[], config?: Partial<VerbosityConfig>): VerbosityReport;
|
|
1439
|
+
/**
|
|
1440
|
+
* Formats a verbosity report as human-readable text output.
|
|
1441
|
+
*/
|
|
1442
|
+
declare function formatVerbosityReport(report: VerbosityReport): string;
|
|
1443
|
+
|
|
1336
1444
|
/**
|
|
1337
1445
|
* Runtime Accessibility Timeline - Public API
|
|
1338
1446
|
*
|
|
@@ -1380,7 +1488,13 @@ type index_StateChangedPayload = StateChangedPayload;
|
|
|
1380
1488
|
type index_TimelineGenerator = TimelineGenerator;
|
|
1381
1489
|
type index_TimelineGeneratorOptions = TimelineGeneratorOptions;
|
|
1382
1490
|
type index_TimelineMetadata = TimelineMetadata;
|
|
1491
|
+
type index_VerbosityConfig = VerbosityConfig;
|
|
1492
|
+
type index_VerbosityFinding = VerbosityFinding;
|
|
1493
|
+
type index_VerbosityPattern = VerbosityPattern;
|
|
1494
|
+
type index_VerbosityReport = VerbosityReport;
|
|
1495
|
+
type index_VerbositySeverity = VerbositySeverity;
|
|
1383
1496
|
type index_WarningPayload = WarningPayload;
|
|
1497
|
+
declare const index_analyzeVerbosity: typeof analyzeVerbosity;
|
|
1384
1498
|
declare const index_classifyDiff: typeof classifyDiff;
|
|
1385
1499
|
declare const index_createBaselineStorage: typeof createBaselineStorage;
|
|
1386
1500
|
declare const index_createEngine: typeof createEngine;
|
|
@@ -1392,6 +1506,7 @@ declare const index_deserializeTimeline: typeof deserializeTimeline;
|
|
|
1392
1506
|
declare const index_diffTimelines: typeof diffTimelines;
|
|
1393
1507
|
declare const index_executeAction: typeof executeAction;
|
|
1394
1508
|
declare const index_executeSequence: typeof executeSequence;
|
|
1509
|
+
declare const index_formatVerbosityReport: typeof formatVerbosityReport;
|
|
1395
1510
|
declare const index_generateSelector: typeof generateSelector;
|
|
1396
1511
|
declare const index_getBuiltinPattern: typeof getBuiltinPattern;
|
|
1397
1512
|
declare const index_serializeEvent: typeof serializeEvent;
|
|
@@ -1399,7 +1514,7 @@ declare const index_serializeInteractionSequence: typeof serializeInteractionSeq
|
|
|
1399
1514
|
declare const index_serializeTimeline: typeof serializeTimeline;
|
|
1400
1515
|
declare const index_timelinesEqual: typeof timelinesEqual;
|
|
1401
1516
|
declare namespace index {
|
|
1402
|
-
export { type index_AccessibilityEvent as AccessibilityEvent, type index_AccessibilityTimeline as AccessibilityTimeline, type index_AccessibleNameChangedPayload as AccessibleNameChangedPayload, type index_AnnouncementPayload as AnnouncementPayload, type index_BaselineFile as BaselineFile, type index_BaselineStorage as BaselineStorage, type index_BehaviorDiffReport as BehaviorDiffReport, type index_BuiltinPatternName as BuiltinPatternName, type index_ClassifiedDiffEntry as ClassifiedDiffEntry, type index_ClassifiedDiffReport as ClassifiedDiffReport, type index_DialogClosedPayload as DialogClosedPayload, type index_DialogOpenedPayload as DialogOpenedPayload, type index_DiffEntry as DiffEntry, type index_EngineOptions as EngineOptions, type index_EventPayload as EventPayload, type index_EventTarget as EventTarget, type index_EventType as EventType, type index_FocusChangedPayload as FocusChangedPayload, type index_HeuristicAnalyzer as HeuristicAnalyzer, type index_HeuristicConfig as HeuristicConfig, type index_InteractionAction as InteractionAction, type index_InteractionSequence as InteractionSequence, type index_KeyboardActionPayload as KeyboardActionPayload, type index_PatternSelectorMap as PatternSelectorMap, type index_RoleChangedPayload as RoleChangedPayload, type index_RuntimeAccessibilityEngine as RuntimeAccessibilityEngine, type index_SeverityLevel as SeverityLevel, type index_StateChangedPayload as StateChangedPayload, type index_TimelineGenerator as TimelineGenerator, type index_TimelineGeneratorOptions as TimelineGeneratorOptions, type index_TimelineMetadata as TimelineMetadata, type index_WarningPayload as WarningPayload, index_classifyDiff as classifyDiff, index_createBaselineStorage as createBaselineStorage, index_createEngine as createEngine, index_createHeuristicAnalyzer as createHeuristicAnalyzer, index_createTimelineGenerator as createTimelineGenerator, index_deserializeEvent as deserializeEvent, index_deserializeInteractionSequence as deserializeInteractionSequence, index_deserializeTimeline as deserializeTimeline, index_diffTimelines as diffTimelines, index_executeAction as executeAction, index_executeSequence as executeSequence, index_generateSelector as generateSelector, index_getBuiltinPattern as getBuiltinPattern, index_serializeEvent as serializeEvent, index_serializeInteractionSequence as serializeInteractionSequence, index_serializeTimeline as serializeTimeline, index_timelinesEqual as timelinesEqual };
|
|
1517
|
+
export { type index_AccessibilityEvent as AccessibilityEvent, type index_AccessibilityTimeline as AccessibilityTimeline, type index_AccessibleNameChangedPayload as AccessibleNameChangedPayload, type index_AnnouncementPayload as AnnouncementPayload, type index_BaselineFile as BaselineFile, type index_BaselineStorage as BaselineStorage, type index_BehaviorDiffReport as BehaviorDiffReport, type index_BuiltinPatternName as BuiltinPatternName, type index_ClassifiedDiffEntry as ClassifiedDiffEntry, type index_ClassifiedDiffReport as ClassifiedDiffReport, type index_DialogClosedPayload as DialogClosedPayload, type index_DialogOpenedPayload as DialogOpenedPayload, type index_DiffEntry as DiffEntry, type index_EngineOptions as EngineOptions, type index_EventPayload as EventPayload, type index_EventTarget as EventTarget, type index_EventType as EventType, type index_FocusChangedPayload as FocusChangedPayload, type index_HeuristicAnalyzer as HeuristicAnalyzer, type index_HeuristicConfig as HeuristicConfig, type index_InteractionAction as InteractionAction, type index_InteractionSequence as InteractionSequence, type index_KeyboardActionPayload as KeyboardActionPayload, type index_PatternSelectorMap as PatternSelectorMap, type index_RoleChangedPayload as RoleChangedPayload, type index_RuntimeAccessibilityEngine as RuntimeAccessibilityEngine, type index_SeverityLevel as SeverityLevel, type index_StateChangedPayload as StateChangedPayload, type index_TimelineGenerator as TimelineGenerator, type index_TimelineGeneratorOptions as TimelineGeneratorOptions, type index_TimelineMetadata as TimelineMetadata, type index_VerbosityConfig as VerbosityConfig, type index_VerbosityFinding as VerbosityFinding, type index_VerbosityPattern as VerbosityPattern, type index_VerbosityReport as VerbosityReport, type index_VerbositySeverity as VerbositySeverity, type index_WarningPayload as WarningPayload, index_analyzeVerbosity as analyzeVerbosity, index_classifyDiff as classifyDiff, index_createBaselineStorage as createBaselineStorage, index_createEngine as createEngine, index_createHeuristicAnalyzer as createHeuristicAnalyzer, index_createTimelineGenerator as createTimelineGenerator, index_deserializeEvent as deserializeEvent, index_deserializeInteractionSequence as deserializeInteractionSequence, index_deserializeTimeline as deserializeTimeline, index_diffTimelines as diffTimelines, index_executeAction as executeAction, index_executeSequence as executeSequence, index_formatVerbosityReport as formatVerbosityReport, index_generateSelector as generateSelector, index_getBuiltinPattern as getBuiltinPattern, index_serializeEvent as serializeEvent, index_serializeInteractionSequence as serializeInteractionSequence, index_serializeTimeline as serializeTimeline, index_timelinesEqual as timelinesEqual };
|
|
1403
1518
|
}
|
|
1404
1519
|
|
|
1405
1520
|
export { type AccessibilityIssue, type AccessibleNode, type AccessibleRole, type AccessibleState, type AccessibleValue, type AnnouncementModel, type AuditReport, type AuditStatistics, CURRENT_MODEL_VERSION, type ChangeType, type DescriptionComputationResult, type DeserializationOptions, type FocusInfo, type IssueSeverity, type ModelVersion, type NameComputationResult, type NameComputationWarning, type NodeChange, type NodePath, type ParseResult, ParsingError, type ParsingWarning, type PropertyChange, type RoleComputationResult, type RoleComputationWarning, SUPPORTED_ROLES, SelectorError, type SemanticDiff, type SerializationOptions, type StateExtractionResult, type StateExtractionWarning, type TreeBuildResult, type TreeBuildWarning, ValidationError, type ValueExtractionResult, buildAccessibilityTree, buildAccessibilityTreeWithSelector, cloneModel, computeAccessibleDescription, computeAccessibleName, computeRole, createModel, describeChange, deserializeModel, diffAccessibilityTrees, extractFocusInfo, extractState, extractValue, formatDiffAsJSON, formatDiffAsText, formatDiffForCI, generateAuditReport, hasAccessibilityRegression, isAccessible, modelsEqual, parseHTML, parseHTMLStrict, renderAuditReport, renderJAWS, renderNVDA, renderNarrator, renderVoiceOver, index as runtime, serializeModel, validateHTMLNotEmpty, validateModel, validateRole, validateState, validateTreeStructure };
|
package/dist/index.js
CHANGED
|
@@ -2668,6 +2668,7 @@ function createGenericContainer(rootElement, warnings) {
|
|
|
2668
2668
|
// src/runtime/index.ts
|
|
2669
2669
|
var runtime_exports = {};
|
|
2670
2670
|
__export(runtime_exports, {
|
|
2671
|
+
analyzeVerbosity: () => analyzeVerbosity,
|
|
2671
2672
|
classifyDiff: () => classifyDiff,
|
|
2672
2673
|
createBaselineStorage: () => createBaselineStorage,
|
|
2673
2674
|
createEngine: () => createEngine,
|
|
@@ -2679,6 +2680,7 @@ __export(runtime_exports, {
|
|
|
2679
2680
|
diffTimelines: () => diffTimelines,
|
|
2680
2681
|
executeAction: () => executeAction,
|
|
2681
2682
|
executeSequence: () => executeSequence,
|
|
2683
|
+
formatVerbosityReport: () => formatVerbosityReport,
|
|
2682
2684
|
generateSelector: () => generateSelector,
|
|
2683
2685
|
getBuiltinPattern: () => getBuiltinPattern,
|
|
2684
2686
|
serializeEvent: () => serializeEvent,
|
|
@@ -5178,6 +5180,314 @@ function createBaselineStorage(baseDir) {
|
|
|
5178
5180
|
}
|
|
5179
5181
|
};
|
|
5180
5182
|
}
|
|
5183
|
+
|
|
5184
|
+
// src/runtime/verbosity-analyzer.ts
|
|
5185
|
+
var DEFAULT_CONFIG2 = {
|
|
5186
|
+
interactionFrameWindow: 150,
|
|
5187
|
+
textSimilarityThreshold: 0.6
|
|
5188
|
+
};
|
|
5189
|
+
function textSimilarity(a, b) {
|
|
5190
|
+
if (!a || !b) return 0;
|
|
5191
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
|
5192
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
|
5193
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
5194
|
+
let overlap = 0;
|
|
5195
|
+
for (const word of wordsA) {
|
|
5196
|
+
if (wordsB.has(word)) overlap++;
|
|
5197
|
+
}
|
|
5198
|
+
const maxSize = Math.max(wordsA.size, wordsB.size);
|
|
5199
|
+
return overlap / maxSize;
|
|
5200
|
+
}
|
|
5201
|
+
function groupIntoFrames(events, windowMs) {
|
|
5202
|
+
if (events.length === 0) return [];
|
|
5203
|
+
const frames = [];
|
|
5204
|
+
let currentFrame = {
|
|
5205
|
+
start: events[0].timestamp,
|
|
5206
|
+
end: events[0].timestamp,
|
|
5207
|
+
events: [events[0]]
|
|
5208
|
+
};
|
|
5209
|
+
for (let i = 1; i < events.length; i++) {
|
|
5210
|
+
const event = events[i];
|
|
5211
|
+
if (event.timestamp - currentFrame.end <= windowMs) {
|
|
5212
|
+
currentFrame.events.push(event);
|
|
5213
|
+
currentFrame.end = event.timestamp;
|
|
5214
|
+
} else {
|
|
5215
|
+
frames.push(currentFrame);
|
|
5216
|
+
currentFrame = {
|
|
5217
|
+
start: event.timestamp,
|
|
5218
|
+
end: event.timestamp,
|
|
5219
|
+
events: [event]
|
|
5220
|
+
};
|
|
5221
|
+
}
|
|
5222
|
+
}
|
|
5223
|
+
frames.push(currentFrame);
|
|
5224
|
+
return frames;
|
|
5225
|
+
}
|
|
5226
|
+
function detectRedundantStateAndLiveRegion(frame, _config) {
|
|
5227
|
+
const findings = [];
|
|
5228
|
+
const stateChanges = frame.events.filter((e) => e.type === "STATE_CHANGED");
|
|
5229
|
+
const announcements = frame.events.filter((e) => e.type === "ANNOUNCEMENT");
|
|
5230
|
+
for (const stateEvent of stateChanges) {
|
|
5231
|
+
const payload = stateEvent.payload;
|
|
5232
|
+
const announcedAttributes = [
|
|
5233
|
+
"aria-selected",
|
|
5234
|
+
"aria-checked",
|
|
5235
|
+
"aria-pressed",
|
|
5236
|
+
"aria-expanded",
|
|
5237
|
+
"aria-disabled",
|
|
5238
|
+
"aria-invalid",
|
|
5239
|
+
"aria-current"
|
|
5240
|
+
];
|
|
5241
|
+
if (!announcedAttributes.includes(payload.attribute)) continue;
|
|
5242
|
+
for (const announcement of announcements) {
|
|
5243
|
+
const annoPayload = announcement.payload;
|
|
5244
|
+
const annoText = annoPayload.text.toLowerCase();
|
|
5245
|
+
const elementName = stateEvent.target.accessibleName.toLowerCase();
|
|
5246
|
+
const nameInAnnouncement = elementName.length > 0 && annoText.includes(elementName);
|
|
5247
|
+
const stateEchoWords = ["selected", "deselected", "checked", "unchecked", "expanded", "collapsed", "pressed"];
|
|
5248
|
+
const hasStateEcho = stateEchoWords.some((word) => annoText.includes(word));
|
|
5249
|
+
if (nameInAnnouncement && hasStateEcho) {
|
|
5250
|
+
findings.push({
|
|
5251
|
+
pattern: "redundant-state-and-live-region",
|
|
5252
|
+
severity: "high",
|
|
5253
|
+
description: `The ${payload.attribute} state change on "${stateEvent.target.accessibleName}" (${stateEvent.target.role}) is already announced by the screen reader, but a live region also announces similar content ("${annoPayload.text}"). This causes VoiceOver to read the information 2-3 times.`,
|
|
5254
|
+
remediation: [
|
|
5255
|
+
`Remove the live region announcement when ${payload.attribute} changes on the active element, since the screen reader already announces state transitions on the focused/active item.`,
|
|
5256
|
+
`If you need the live region for summary info (e.g., "3 of 5 selected"), debounce it with a 150ms+ delay and ensure the text does NOT repeat the option name.`,
|
|
5257
|
+
`Use a summary-only pattern in the live region: "3 of 5 selected" instead of "JavaScript selected. 3 of 5 selected." The element name is already announced via the state change.`,
|
|
5258
|
+
`Consider removing the live region entirely and relying on aria-selected/aria-checked state changes for per-item feedback.`
|
|
5259
|
+
],
|
|
5260
|
+
affectedReaders: ["VoiceOver (macOS)", "VoiceOver (iOS)"],
|
|
5261
|
+
relatedEvents: [stateEvent, announcement],
|
|
5262
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5263
|
+
elementSelector: stateEvent.target.selector
|
|
5264
|
+
});
|
|
5265
|
+
}
|
|
5266
|
+
}
|
|
5267
|
+
}
|
|
5268
|
+
return findings;
|
|
5269
|
+
}
|
|
5270
|
+
function detectActiveDescendantPlusStateChange(frame, _config) {
|
|
5271
|
+
const findings = [];
|
|
5272
|
+
const activedescendantChanges = frame.events.filter(
|
|
5273
|
+
(e) => e.type === "STATE_CHANGED" && e.payload.attribute === "aria-activedescendant"
|
|
5274
|
+
);
|
|
5275
|
+
const otherStateChanges = frame.events.filter(
|
|
5276
|
+
(e) => e.type === "STATE_CHANGED" && e.payload.attribute !== "aria-activedescendant"
|
|
5277
|
+
);
|
|
5278
|
+
for (const adChange of activedescendantChanges) {
|
|
5279
|
+
const adPayload = adChange.payload;
|
|
5280
|
+
const newValue = String(adPayload.newValue);
|
|
5281
|
+
const previousValue = String(adPayload.previousValue ?? "");
|
|
5282
|
+
const isSameElement = newValue === previousValue;
|
|
5283
|
+
for (const stateChange of otherStateChanges) {
|
|
5284
|
+
const statePayload = stateChange.payload;
|
|
5285
|
+
const targetMatchesAD = stateChange.target.selector.includes(newValue) || stateChange.target.selector === adChange.target.selector;
|
|
5286
|
+
if (!targetMatchesAD) continue;
|
|
5287
|
+
if (isSameElement) {
|
|
5288
|
+
findings.push({
|
|
5289
|
+
pattern: "activedescendant-plus-state-change",
|
|
5290
|
+
severity: "high",
|
|
5291
|
+
description: `aria-activedescendant was re-set to the same value ("${newValue}") AND ${statePayload.attribute} changed on that element within ${frame.end - frame.start}ms. VoiceOver reads the element once for the activedescendant update, then again for the state change.`,
|
|
5292
|
+
remediation: [
|
|
5293
|
+
`When toggling selection on the already-active option, do NOT re-set aria-activedescendant to the same value. Only update it when the user navigates to a different option.`,
|
|
5294
|
+
`Batch DOM attribute changes: set aria-selected BEFORE updating aria-activedescendant, or skip the activedescendant update entirely when the active item hasn't changed.`,
|
|
5295
|
+
`For click interactions on options, avoid calling both setActiveDescendant() and toggleSelection() if the clicked item is already the active descendant.`
|
|
5296
|
+
],
|
|
5297
|
+
affectedReaders: ["VoiceOver (macOS)", "VoiceOver (iOS)", "Narrator"],
|
|
5298
|
+
relatedEvents: [adChange, stateChange],
|
|
5299
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5300
|
+
elementSelector: stateChange.target.selector
|
|
5301
|
+
});
|
|
5302
|
+
} else {
|
|
5303
|
+
findings.push({
|
|
5304
|
+
pattern: "activedescendant-plus-state-change",
|
|
5305
|
+
severity: "low",
|
|
5306
|
+
description: `aria-activedescendant moved to "${stateChange.target.accessibleName}" AND ${statePayload.attribute} changed on that element within ${frame.end - frame.start}ms. VoiceOver may announce the option name twice (once for navigation, once for state).`,
|
|
5307
|
+
remediation: [
|
|
5308
|
+
`If this is a click-to-select interaction, consider separating the activedescendant update and the state change by a microtask (Promise.resolve()) so VoiceOver processes them as separate assertions.`,
|
|
5309
|
+
`Alternatively, accept this as expected behavior for click interactions where navigation and selection happen simultaneously.`
|
|
5310
|
+
],
|
|
5311
|
+
affectedReaders: ["VoiceOver (macOS)"],
|
|
5312
|
+
relatedEvents: [adChange, stateChange],
|
|
5313
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5314
|
+
elementSelector: stateChange.target.selector
|
|
5315
|
+
});
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
}
|
|
5319
|
+
return findings;
|
|
5320
|
+
}
|
|
5321
|
+
function detectNameChangePlusLiveRegion(frame, config) {
|
|
5322
|
+
const findings = [];
|
|
5323
|
+
const nameChanges = frame.events.filter((e) => e.type === "ACCESSIBLE_NAME_CHANGED");
|
|
5324
|
+
const announcements = frame.events.filter((e) => e.type === "ANNOUNCEMENT");
|
|
5325
|
+
for (const nameEvent of nameChanges) {
|
|
5326
|
+
for (const announcement of announcements) {
|
|
5327
|
+
const annoPayload = announcement.payload;
|
|
5328
|
+
const similarity = textSimilarity(nameEvent.target.accessibleName, annoPayload.text);
|
|
5329
|
+
if (similarity >= config.textSimilarityThreshold) {
|
|
5330
|
+
findings.push({
|
|
5331
|
+
pattern: "name-change-plus-live-region",
|
|
5332
|
+
severity: "medium",
|
|
5333
|
+
description: `The accessible name of "${nameEvent.target.accessibleName}" (${nameEvent.target.role}) changed AND a live region announced similar content ("${annoPayload.text}"). Screen readers may announce both the name change and the live region.`,
|
|
5334
|
+
remediation: [
|
|
5335
|
+
`Choose one announcement channel: either update the element's accessible name (which the screen reader reads if the element is focused) OR use a live region, not both.`,
|
|
5336
|
+
`If the element is focused, prefer the name change. If it's not focused, prefer the live region.`,
|
|
5337
|
+
`Add a 200ms+ debounce to the live region update to let the name change announce first.`
|
|
5338
|
+
],
|
|
5339
|
+
affectedReaders: ["VoiceOver (macOS)", "NVDA", "JAWS"],
|
|
5340
|
+
relatedEvents: [nameEvent, announcement],
|
|
5341
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5342
|
+
elementSelector: nameEvent.target.selector
|
|
5343
|
+
});
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
return findings;
|
|
5348
|
+
}
|
|
5349
|
+
function detectFocusPlusActiveDescendant(frame, _config) {
|
|
5350
|
+
const findings = [];
|
|
5351
|
+
const focusEvents = frame.events.filter((e) => e.type === "FOCUS_CHANGED");
|
|
5352
|
+
const adChanges = frame.events.filter(
|
|
5353
|
+
(e) => e.type === "STATE_CHANGED" && e.payload.attribute === "aria-activedescendant"
|
|
5354
|
+
);
|
|
5355
|
+
if (focusEvents.length > 0 && adChanges.length > 0) {
|
|
5356
|
+
findings.push({
|
|
5357
|
+
pattern: "focus-plus-activedescendant",
|
|
5358
|
+
severity: "medium",
|
|
5359
|
+
description: `Both real DOM focus moved AND aria-activedescendant changed within ${frame.end - frame.start}ms. Screen readers may announce the focused element AND then re-announce via activedescendant, causing a double-read.`,
|
|
5360
|
+
remediation: [
|
|
5361
|
+
`Choose one focus management strategy: either move real DOM focus to options (roving tabindex) OR use aria-activedescendant on the container. Do not mix both.`,
|
|
5362
|
+
`If using aria-activedescendant, keep DOM focus on the container (listbox/combobox) and only change the activedescendant attribute.`,
|
|
5363
|
+
`If using roving tabindex, set tabindex="0" on the active item and tabindex="-1" on others, and move focus directly via element.focus().`
|
|
5364
|
+
],
|
|
5365
|
+
affectedReaders: ["VoiceOver (macOS)", "NVDA", "JAWS", "Narrator"],
|
|
5366
|
+
relatedEvents: [...focusEvents, ...adChanges],
|
|
5367
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5368
|
+
elementSelector: focusEvents[0].target.selector
|
|
5369
|
+
});
|
|
5370
|
+
}
|
|
5371
|
+
return findings;
|
|
5372
|
+
}
|
|
5373
|
+
function detectMultipleStateChangesSameElement(frame, _config) {
|
|
5374
|
+
const findings = [];
|
|
5375
|
+
const stateChanges = frame.events.filter((e) => e.type === "STATE_CHANGED");
|
|
5376
|
+
const bySelector = /* @__PURE__ */ new Map();
|
|
5377
|
+
for (const event of stateChanges) {
|
|
5378
|
+
const existing = bySelector.get(event.target.selector) || [];
|
|
5379
|
+
existing.push(event);
|
|
5380
|
+
bySelector.set(event.target.selector, existing);
|
|
5381
|
+
}
|
|
5382
|
+
for (const [selector, events] of bySelector) {
|
|
5383
|
+
if (events.length >= 3) {
|
|
5384
|
+
const attributes = events.map((e) => e.payload.attribute);
|
|
5385
|
+
findings.push({
|
|
5386
|
+
pattern: "multiple-state-changes-same-element",
|
|
5387
|
+
severity: "medium",
|
|
5388
|
+
description: `${events.length} ARIA attributes changed simultaneously on "${events[0].target.accessibleName}" (${events[0].target.role}): ${attributes.join(", ")}. Some screen readers announce each attribute change individually, causing a verbose multi-part announcement.`,
|
|
5389
|
+
remediation: [
|
|
5390
|
+
`Batch attribute changes into a single DOM mutation frame by setting all attributes before the next microtask. Use requestAnimationFrame() or Promise.resolve() to group them.`,
|
|
5391
|
+
`Evaluate which attributes actually need to be communicated. For example, if aria-selected changes, the screen reader already announces it; additional aria-checked or aria-pressed changes may be redundant.`,
|
|
5392
|
+
`Consider using a single composite state (e.g., just aria-selected for listbox options) rather than multiple state attributes.`
|
|
5393
|
+
],
|
|
5394
|
+
affectedReaders: ["VoiceOver (macOS)", "Narrator"],
|
|
5395
|
+
relatedEvents: events,
|
|
5396
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5397
|
+
elementSelector: selector
|
|
5398
|
+
});
|
|
5399
|
+
}
|
|
5400
|
+
}
|
|
5401
|
+
return findings;
|
|
5402
|
+
}
|
|
5403
|
+
function detectRapidIdenticalAnnouncements(frame, _config) {
|
|
5404
|
+
const findings = [];
|
|
5405
|
+
const announcements = frame.events.filter((e) => e.type === "ANNOUNCEMENT");
|
|
5406
|
+
const seen = /* @__PURE__ */ new Map();
|
|
5407
|
+
for (const event of announcements) {
|
|
5408
|
+
const text = event.payload.text.toLowerCase().trim();
|
|
5409
|
+
const existing = seen.get(text) || [];
|
|
5410
|
+
existing.push(event);
|
|
5411
|
+
seen.set(text, existing);
|
|
5412
|
+
}
|
|
5413
|
+
for (const [text, events] of seen) {
|
|
5414
|
+
if (events.length >= 2) {
|
|
5415
|
+
findings.push({
|
|
5416
|
+
pattern: "rapid-identical-announcements",
|
|
5417
|
+
severity: "high",
|
|
5418
|
+
description: `The same live region content ("${text}") was announced ${events.length} times within ${frame.end - frame.start}ms. Users will hear the same information repeated.`,
|
|
5419
|
+
remediation: [
|
|
5420
|
+
`Debounce live region updates. Clear the live region text content first, then set the new value after a 100-200ms delay.`,
|
|
5421
|
+
`If updating a live region's textContent to the same value, the screen reader may ignore it OR re-read it depending on implementation. Clear to empty string first.`,
|
|
5422
|
+
`Avoid updating the live region inside a loop or rapid event handler. Compute the final state and announce once.`
|
|
5423
|
+
],
|
|
5424
|
+
affectedReaders: ["VoiceOver (macOS)", "VoiceOver (iOS)", "NVDA", "Narrator"],
|
|
5425
|
+
relatedEvents: events,
|
|
5426
|
+
timeWindow: { start: frame.start, end: frame.end },
|
|
5427
|
+
elementSelector: events[0].target.selector
|
|
5428
|
+
});
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
5431
|
+
return findings;
|
|
5432
|
+
}
|
|
5433
|
+
function analyzeVerbosity(events, config) {
|
|
5434
|
+
const cfg = { ...DEFAULT_CONFIG2, ...config };
|
|
5435
|
+
const frames = groupIntoFrames(events, cfg.interactionFrameWindow);
|
|
5436
|
+
const findings = [];
|
|
5437
|
+
for (const frame of frames) {
|
|
5438
|
+
findings.push(...detectRedundantStateAndLiveRegion(frame, cfg));
|
|
5439
|
+
findings.push(...detectActiveDescendantPlusStateChange(frame, cfg));
|
|
5440
|
+
findings.push(...detectNameChangePlusLiveRegion(frame, cfg));
|
|
5441
|
+
findings.push(...detectFocusPlusActiveDescendant(frame, cfg));
|
|
5442
|
+
findings.push(...detectMultipleStateChangesSameElement(frame, cfg));
|
|
5443
|
+
findings.push(...detectRapidIdenticalAnnouncements(frame, cfg));
|
|
5444
|
+
}
|
|
5445
|
+
const severityOrder = { high: 0, medium: 1, low: 2 };
|
|
5446
|
+
findings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
|
5447
|
+
const summary = {
|
|
5448
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
5449
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
5450
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
5451
|
+
total: findings.length
|
|
5452
|
+
};
|
|
5453
|
+
const deductions = summary.high * 25 + summary.medium * 10 + summary.low * 3;
|
|
5454
|
+
const score = Math.max(0, 100 - deductions);
|
|
5455
|
+
return { findings, summary, score };
|
|
5456
|
+
}
|
|
5457
|
+
function formatVerbosityReport(report) {
|
|
5458
|
+
const lines = [];
|
|
5459
|
+
lines.push("");
|
|
5460
|
+
lines.push("=== VERBOSITY ANALYSIS REPORT ===");
|
|
5461
|
+
lines.push("");
|
|
5462
|
+
lines.push(`Score: ${report.score}/100 ${report.score >= 80 ? "(Good)" : report.score >= 50 ? "(Needs attention)" : "(Critical verbosity issues)"}`);
|
|
5463
|
+
lines.push(`Findings: ${report.summary.total} (${report.summary.high} high, ${report.summary.medium} medium, ${report.summary.low} low)`);
|
|
5464
|
+
lines.push("");
|
|
5465
|
+
if (report.findings.length === 0) {
|
|
5466
|
+
lines.push("No verbosity issues detected. Announcements appear clean.");
|
|
5467
|
+
return lines.join("\n");
|
|
5468
|
+
}
|
|
5469
|
+
for (let i = 0; i < report.findings.length; i++) {
|
|
5470
|
+
const finding = report.findings[i];
|
|
5471
|
+
const severityIcon = finding.severity === "high" ? "\u2718" : finding.severity === "medium" ? "\u26A0" : "\u2139";
|
|
5472
|
+
lines.push(`${severityIcon} [${finding.severity.toUpperCase()}] ${finding.pattern}`);
|
|
5473
|
+
lines.push(` Element: ${finding.elementSelector}`);
|
|
5474
|
+
lines.push(` Window: ${finding.timeWindow.start}ms - ${finding.timeWindow.end}ms`);
|
|
5475
|
+
lines.push(` ${finding.description}`);
|
|
5476
|
+
lines.push("");
|
|
5477
|
+
lines.push(" Affected: " + finding.affectedReaders.join(", "));
|
|
5478
|
+
lines.push("");
|
|
5479
|
+
lines.push(" Remediation:");
|
|
5480
|
+
for (const step of finding.remediation) {
|
|
5481
|
+
lines.push(` - ${step}`);
|
|
5482
|
+
}
|
|
5483
|
+
lines.push("");
|
|
5484
|
+
if (i < report.findings.length - 1) {
|
|
5485
|
+
lines.push(" " + "\u2500".repeat(60));
|
|
5486
|
+
lines.push("");
|
|
5487
|
+
}
|
|
5488
|
+
}
|
|
5489
|
+
return lines.join("\n");
|
|
5490
|
+
}
|
|
5181
5491
|
export {
|
|
5182
5492
|
CURRENT_MODEL_VERSION,
|
|
5183
5493
|
ParsingError,
|