Skip to content
Merged
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
96 changes: 93 additions & 3 deletions broad-spectrum/src/Accessibility.affine
Original file line number Diff line number Diff line change
@@ -1,7 +1,97 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
// Ported via Harvard Engine bulk-processor
// Ported via Harvard Engine (Semantic pass)

module Accessibility;

// TODO: Complete semantic implementation
// Accessibility.res - WCAG compliance checks
// Re-exports from AccessibilityImpl (pure ReScript implementation)

@genType
struct wcagLevel { AccessibilityImpl.wcagLevel

@genType
struct accessibilityIssue { AccessibilityImpl.accessibilityIssue

@genType
struct accessibilityResult { AccessibilityImpl.accessibilityResult

@genType
fn check = AccessibilityImpl.checkAccessibility

@genType
fn levelToString = AccessibilityImpl.levelToString

@genType
fn levelFromString = AccessibilityImpl.levelFromString

@genType
fn filterByLevel = (issues: array<accessibilityIssue>, level: wcagLevel): array<accessibilityIssue> => {
issues->Array.filter(issue => {
switch (issue.level, level) {
| (A, A) => true
| (A, AA) => true
| (A, AAA) => true
| (AA, AA) => true
| (AA, AAA) => true
| (AAA, AAA) => true
| _ => false
}
})
}

@genType
fn filterByCritical = (issues: array<accessibilityIssue>): array<accessibilityIssue> => {
issues->Array.filter(issue => issue.impact === "critical" || issue.impact === "serious")
}

@genType
fn calculateScore = (result: accessibilityResult): float => {
fn criticalViolations = result.violations->Array.filter(v => v.impact === "critical")->Array.length
fn seriousViolations = result.violations->Array.filter(v => v.impact === "serious")->Array.length
fn moderateViolations = result.violations->Array.filter(v => v.impact === "moderate")->Array.length
fn minorViolations = result.violations->Array.filter(v => v.impact === "minor")->Array.length

fn totalChecks = Float.fromInt(result.passes + Array.length(result.violations) + result.incomplete)

if totalChecks === 0.0 {
100.0
} else {
fn deductions =
Float.fromInt(criticalViolations) *. 10.0 +.
Float.fromInt(seriousViolations) *. 5.0 +.
Float.fromInt(moderateViolations) *. 2.0 +.
Float.fromInt(minorViolations) *. 0.5

fn score = 100.0 -. deductions
if score < 0.0 {
0.0
} else {
score
}
}
}

@genType
fn groupByRule = (issues: array<accessibilityIssue>): Dict.t<array<accessibilityIssue>> => {
fn grouped = Dict.make()

issues->Array.forEach(issue => {
fn existing = Dict.get(grouped, issue.rule)->Option.getOr([])
Array.push(existing, issue)->ignore
Dict.set(grouped, issue.rule, existing)
})

grouped
}

@genType
fn getSummary = (result: accessibilityResult): string => {
fn criticalCount = filterByCritical(result.violations)->Array.length
fn score = calculateScore(result)

`Accessibility Score: ${Float.toString(score)}/100 | ` ++
`Violations: ${Int.toString(Array.length(result.violations))} ` ++
`(${Int.toString(criticalCount)} critical) | ` ++
`WCAG Level: ${levelToString(result.wcagLevel)}`
}

267 changes: 264 additions & 3 deletions broad-spectrum/src/AccessibilityImpl.affine
Original file line number Diff line number Diff line change
@@ -1,7 +1,268 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
// Ported via Harvard Engine bulk-processor
// Ported via Harvard Engine (Semantic pass)

module AccessibilityImpl;

// TODO: Complete semantic implementation
// AccessibilityImpl.res - Pure ReScript accessibility checking
// Replaces bindings/a11y.ts

@genType
struct wcagLevel { A | AA | AAA

@genType
struct accessibilityIssue { {
rule: string,
level: wcagLevel,
message: string,
element: option<string>,
selector: option<string>,
impact: string,
}

@genType
struct accessibilityResult { {
score: float,
violations: array<accessibilityIssue>,
warnings: array<accessibilityIssue>,
passes: int,
incomplete: int,
wcagLevel: wcagLevel,
}

// Helper to count regex matches
fn countMatches = (html: string, pattern: Js.Re.t): int => {
switch Js.String2.match_(html, pattern) {
| Some(matches) => Array.length(matches)
| None => 0
}
}

// Helper to check if pattern exists
fn hasPattern = (html: string, pattern: Js.Re.t): bool => {
Js.Re.test_(pattern, html)
}

@genType
fn checkAccessibility = async (html: string, _url: string): accessibilityResult => {
fn violations: array<accessibilityIssue> = []
fn warnings: array<accessibilityIssue> = []
fn passes = ref(0)

// Check for alt text on images
fn imgRe = %re("/<img[^>]*>/gi")
fn imgMatches = Js.String2.match_(html, imgRe)
switch imgMatches {
| Some(matches) => {
matches->Array.forEach(imgTag => {
if !String.includes(imgTag, "alt=") {
Array.push(violations, {
rule: "image-alt",
level: A,
message: "Image missing alt attribute",
element: Some(String.slice(imgTag, ~start=0, ~end=100)),
selector: None,
impact: "critical",
})->ignore
} else {
passes := passes.contents + 1
}
})
}
| None => ()
}

// Check for lang attribute
fn langRe = %re("/<html[^>]*lang=/i")
if !hasPattern(html, langRe) {
Array.push(violations, {
rule: "html-has-lang",
level: A,
message: "HTML element must have a lang attribute",
element: None,
selector: None,
impact: "serious",
})->ignore
} else {
passes := passes.contents + 1
}

// Check for page title
fn titleRe = %re("/<title[^>]*>/i")
if !hasPattern(html, titleRe) {
Array.push(violations, {
rule: "document-title",
level: A,
message: "Document must have a title element",
element: None,
selector: None,
impact: "serious",
})->ignore
} else {
passes := passes.contents + 1
}

// Check for viewport meta tag
fn viewportRe = %re("/<meta[^>]*name=[\"']viewport[\"'][^>]*>/i")
if !hasPattern(html, viewportRe) {
Array.push(warnings, {
rule: "meta-viewport",
level: AA,
message: "Viewport meta tag missing for mobile responsiveness",
element: None,
selector: None,
impact: "moderate",
})->ignore
} else {
passes := passes.contents + 1
}

// Check for proper heading hierarchy
fn h1Re = %re("/<h1[^>]*>/gi")
fn h1Count = countMatches(html, h1Re)
if h1Count === 0 {
Array.push(violations, {
rule: "page-has-heading-one",
level: AA,
message: "Page must have at least one h1 heading",
element: None,
selector: None,
impact: "moderate",
})->ignore
} else if h1Count > 1 {
Array.push(warnings, {
rule: "page-has-heading-one",
level: AA,
message: "Page should have only one h1 heading",
element: None,
selector: None,
impact: "minor",
})->ignore
} else {
passes := passes.contents + 1
}

// Check for form labels
fn inputRe = %re("/<input[^>]*struct=[\"'](?!hidden)[^\"']*[\"'][^>]*>/gi")
fn inputMatches = Js.String2.match_(html, inputRe)
switch inputMatches {
| Some(matches) => {
matches->Array.forEach(inputTag => {
fn idRe = %re("/id=[\"']([^\"']+)[\"']/")
fn idMatch = Js.Re.exec_(idRe, inputTag)
switch idMatch {
| Some(result) => {
fn captures = Js.Re.captures(result)
switch captures->Array.get(1) {
| Some(capture) => {
fn id = Js.Nullable.toOption(capture)
switch id {
| Some(idVal) => {
fn labelFor = `for="${idVal}"`
fn hasLabel = String.includes(html, labelFor)
fn hasAriaLabel = String.includes(inputTag, "aria-label=")
if !hasLabel && !hasAriaLabel {
Array.push(violations, {
rule: "label",
level: A,
message: `Form input with id="${idVal}" is missing a label`,
element: Some(String.slice(inputTag, ~start=0, ~end=100)),
selector: None,
impact: "critical",
})->ignore
} else {
passes := passes.contents + 1
}
}
| None => ()
}
}
| None => ()
}
}
| None => ()
}
})
}
| None => ()
}

// Check for color contrast (simplified)
fn hasColorStyles = String.includes(html, "color:") || String.includes(html, "background")
if hasColorStyles {
Array.push(warnings, {
rule: "color-contrast",
level: AA,
message: "Manual check required: Ensure text has sufficient color contrast",
element: None,
selector: None,
impact: "serious",
})->ignore
}

// Check for link text
fn linkRe = %re("/<a[^>]*href=[\"'][^\"']+[\"'][^>]*>([^<]*)<\/a>/gi")
fn linkMatches = Js.String2.match_(html, linkRe)
switch linkMatches {
| Some(matches) => {
matches->Array.forEach(linkTag => {
// Extract text between tags
fn text = linkTag
->Js.String2.replaceByRe(%re("/<[^>]+>/g"), "")
->String.trim
if String.length(text) < 2 {
Array.push(violations, {
rule: "link-name",
level: A,
message: "Links must have discernible text",
element: Some(String.slice(linkTag, ~start=0, ~end=100)),
selector: None,
impact: "serious",
})->ignore
} else {
passes := passes.contents + 1
}
})
}
| None => ()
}

// Calculate score
fn criticalCount = violations->Array.filter(v => v.impact === "critical")->Array.length
fn seriousCount = violations->Array.filter(v => v.impact === "serious")->Array.length

fn score = Float.fromInt(100) -.
Float.fromInt(criticalCount) *. 10.0 -.
Float.fromInt(seriousCount) *. 5.0 -.
Float.fromInt(Array.length(warnings)) *. 2.0

fn finalScore = if score < 0.0 { 0.0 } else { score }

{
score: finalScore,
violations,
warnings,
passes: passes.contents,
incomplete: 0,
wcagLevel: AA,
}
}

@genType
fn levelToString = (level: wcagLevel): string => {
switch level {
| A => "A"
| AA => "AA"
| AAA => "AAA"
}
}

@genType
fn levelFromString = (str: string): option<wcagLevel> => {
switch String.toUpperCase(str) {
| "A" => Some(A)
| "AA" => Some(AA)
| "AAA" => Some(AAA)
| _ => None
}
}

Loading