knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE) options(repos = c(CRAN = “https://cloud.r-project.org”)) pkgs <- c(“tidyverse”,“janitor”,“readr”,“gt”,“knitr”,“psych”,“pheatmap”,“reshape2”) to_install <- pkgs[!pkgs %in% installed.packages()[,“Package”]] if(length(to_install)) install.packages(to_install) invisible(lapply(pkgs, library, character.only = TRUE)) # Paths (put your real CSVs here when ready)
path_scores_a <- “data/scores_A.csv” path_scores_b <- “data/scores_B.csv” path_evidence <- “data/evidence.csv”
crit <- c(“1.1.1”,“1.3.1”,“1.4.3”,“1.4.10”,“1.4.11”,“1.4.12”,“1.4.13”, “2.1.1”,“2.1.2”,“2.4.1”,“2.4.3”,“2.4.4”,“2.4.7”,“2.5.3”, “3.1.1”,“3.1.2”,“3.2.3”,“3.3.1”,“3.3.2”,“3.3.3”,“4.1.2”,“4.1.3”)
crit_meta <- tibble::tribble( ~code, ~level, ~group, ~criterion, “1.1.1”,“A”,“Perceivable”,“Non-text content (Alt text)”, “1.3.1”,“A”,“Perceivable”,“Info & relationships (semantic structure)”, “1.4.3”,“AA”,“Perceivable”,“Contrast (text)”, “1.4.10”,“AA”,“Perceivable”,“Reflow (320px)”, “1.4.11”,“AA”,“Perceivable”,“Non-text contrast”, “1.4.12”,“AA”,“Perceivable”,“Text spacing”, “1.4.13”,“AA”,“Perceivable”,“Content on hover/focus”, “2.1.1”,“A”,“Operable”,“Keyboard accessible”, “2.1.2”,“A”,“Operable”,“No keyboard trap”, “2.4.1”,“A”,“Operable”,“Bypass blocks (Skip link)”, “2.4.3”,“A”,“Operable”,“Focus order”, “2.4.4”,“A”,“Operable”,“Link purpose (in context)”, “2.4.7”,“AA”,“Operable”,“Focus visible”, “2.5.3”,“A”,“Operable”,“Label in name”, “3.1.1”,“A”,“Understandable”,“Language of page”, “3.1.2”,“AA”,“Understandable”,“Language of parts”, “3.2.3”,“AA”,“Understandable”,“Consistent navigation”, “3.3.1”,“A”,“Understandable”,“Error identification”, “3.3.2”,“A”,“Understandable”,“Labels or instructions”, “3.3.3”,“AA”,“Understandable”,“Error suggestion”, “4.1.2”,“A”,“Robust”,“Name, role, value”, “4.1.3”,“AA”,“Robust”,“Status messages” )
max_points <- length(crit) * 2
have_files <- file.exists(path_scores_a) && file.exists(path_scores_b)
if (have_files) { A <- readr::read_csv(path_scores_a) %>% clean_names() B <- readr::read_csv(path_scores_b) %>% clean_names() } else { message(“CSV not found. Generating demo data…”) set.seed(2025) portals <- c(“National”,“MOJ”,“VSS”,“Hanoi”,“HCMC”,“Da Nang”,“Quang Ninh”,“Thua Thien Hue”) tasks <- c(“Procedure search”,“Multi-step form”,“Status lookup”,“Authentication / eID / OTP”,“Payment”) grid <- expand.grid(portal=portals, task=tasks, stringsAsFactors = FALSE)
rnd_scores <- function(n) sample(c(0,1,2), n, replace=TRUE, prob=c(0.2,0.35,0.45)) make_scores <- function() { m <- as_tibble(matrix(rnd_scores(nrow(grid)*length(crit)), ncol=length(crit))) names(m) <- crit bind_cols(grid, m, sev_count = sample(1:7, nrow(grid), replace=TRUE)) } A <- make_scores() B <- make_scores() }
needed <- c(“portal”,“task”, crit, “sev_count”) stopifnot(all(needed %in% names(A)), all(needed %in% names(B))) A <- A %>% select(all_of(needed)) B <- B %>% select(all_of(needed))
keys <- c(“portal”,“task”)
AB <- A %>% rename_with(~ paste0(.x,“_a”), all_of(crit)) %>% rename(sev_a = sev_count) %>% inner_join( B %>% rename_with(~ paste0(.x,“_b”), all_of(crit)) %>% rename(sev_b = sev_count), by = keys )
reconciled <- AB %>% rowwise() %>% mutate(across(all_of(crit), ~ min(c_across(c(paste0(cur_column(),“_a”), paste0(cur_column(),“_b”))), na.rm=TRUE), .names = “{.col}_recon”)) %>% ungroup() %>% rowwise() %>% mutate(wcag_total_recon = sum(c_across(ends_with(“_recon”)), na.rm=TRUE), wcag_index_recon = 100 * wcag_total_recon / max_points, severe_recon = pmin(sev_a, sev_b)) %>% ungroup()
kappa_row <- function(row) { A_pass <- sapply(crit, function(c) ifelse(row[[paste0(c,“_a”)]]==2,1,0)) B_pass <- sapply(crit, function(c) ifelse(row[[paste0(c,“_b”)]]==2,1,0)) psych::cohen.kappa(cbind(A_pass, B_pass))$kappa } kappa_df <- AB %>% rowwise() %>% mutate(kappa = kappa_row(cur_data())) %>% ungroup() %>% select(all_of(keys), kappa)
recon_full <- reconciled %>% select(all_of(keys), wcag_total_recon, wcag_index_recon, severe_recon) %>% left_join(kappa_df, by = keys) summary_portal <- recon_full %>% group_by(portal) %>% summarise( mean_wcag = mean(wcag_index_recon), ci_low = mean_wcag - 1.96sd(wcag_index_recon)/sqrt(n()), ci_high= mean_wcag + 1.96sd(wcag_index_recon)/sqrt(n()), severe_median = median(severe_recon), mean_kappa = mean(kappa, na.rm=TRUE), n_task = n(), .groups=“drop” ) %>% arrange(desc(mean_wcag))
summary_portal %>% gt::gt() %>% gt::fmt_number(columns = c(mean_wcag, ci_low, ci_high, mean_kappa), decimals = 1) %>% gt::tab_header(title = “Portal-level WCAG Index (Recon), CI, Severe median, and κ”) summary_task <- recon_full %>% group_by(task) %>% summarise( mean_wcag = mean(wcag_index_recon), sd_wcag = sd(wcag_index_recon), severe_median = median(severe_recon), n = n(), .groups=“drop” ) %>% arrange(desc(mean_wcag))
summary_task %>% gt::gt() %>% gt::fmt_number(columns = c(mean_wcag, sd_wcag), decimals = 1) %>% gt::tab_header(title = “Task-level WCAG Index (Recon) and Severe median”) mat <- reconciled %>% select(all_of(keys), ends_with(“_recon”)) %>% rename_with(~ sub(“_recon$“,”“,.x)) %>% unite(pt, portal, task, sep=” | “) %>% column_to_rownames(”pt”) %>% as.matrix()
pheatmap::pheatmap( mat, cluster_rows = TRUE, cluster_cols = TRUE, main = “Reconciled WCAG Criterion Scores (0–2)”, color = colorRampPalette(c(“#f44336”,“#ffeb3b”,“#4caf50”))(50) ) crit_meta %>% select(code, level, group, criterion) %>% gt::gt() %>% gt::tab_header(title = “Table A1. Priority WCAG 2.1 A/AA Checklist”) %>% gt::cols_label(code=“Code”, level=“Level”, group=“POUR Group”, criterion=“Criterion”) scorecards_example <- reconciled %>% select(all_of(keys), ends_with(“_recon”)) %>% rename_with(~ sub(“_recon$“,”“,.x)) %>% pivot_longer(cols = all_of(crit), names_to =”code”, values_to = “score”) %>% left_join(crit_meta, by=“code”) %>% group_by(portal, task) %>% slice_head(n = 10) %>% ungroup()
scorecards_example %>% select(portal, task, code, criterion, score) %>% gt::gt() %>% gt::tab_header(title = “Table Bx. Example Scorecard Rows (first 10 criteria)”) if (file.exists(path_evidence)) { evidence <- readr::read_csv(path_evidence) %>% clean_names() } else { evidence <- tibble::tribble( ~evidence_id, ~portal, ~task, ~url_full, ~timestamp, ~description, ~file_name, “HCMC_AUTH_007”,“HCMC”,“Authentication / eID / OTP”,“https://example/login”,”2025-10-20 10:05”,“Focus trapped in OTP modal; Esc does not close”,“HCMC_AUTH_007.png”, “HCMC_AUTH_009”,“HCMC”,“Authentication / eID / OTP”,“https://example/login”,”2025-10-20 10:07”,“Low text contrast (#777 on white, 3.6:1)”,“HCMC_AUTH_009.png”, “HCMC_AUTH_015”,“HCMC”,“Authentication / eID / OTP”,“https://example/otp”,”2025-10-20 10:12”,“No status message on submit; no aria-live”,“HCMC_AUTH_015.png” ) } evidence %>% gt::gt() %>% gt::tab_header(title = “Table C1. Evidence Register (Screenshots/URLs/Timestamps)”) kappa_summary <- kappa_df %>% summarise(mean_kappa = mean(kappa, na.rm=TRUE), sd_kappa = sd(kappa, na.rm=TRUE), n = n()) kappa_summary %>% gt::gt() %>% gt::tab_header(title = “Inter-rater Agreement (Cohen’s κ) — Summary”) scorecards_full <- reconciled %>% select(all_of(keys), ends_with(“_recon”)) %>% rename_with(~ sub(“_recon$“,”“,.x)) %>% pivot_longer(cols = all_of(crit), names_to =”code”, values_to = “recon_score”) %>% left_join(crit_meta, by=“code”) readr::write_csv(scorecards_full, “output/scorecards_reconciled.csv”)