Add WCAG 2.2 AAA compliance and automated AT-SPI audit tool

- Bring all UI widgets to WCAG 2.2 AAA conformance across all views
- Add accessible labels, roles, descriptions, and announcements
- Bump focus outlines to 3px, target sizes to 44px AAA minimum
- Fix announce()/announce_result() to walk widget tree via parent()
- Add AT-SPI accessibility audit script (tools/a11y-audit.py) that
  checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8,
  2.4.9, 2.4.10, 2.1.3 with JSON report output for CI
- Clean up project structure, archive old plan documents
This commit is contained in:
lashman
2026-03-01 12:44:21 +02:00
parent abb69dc753
commit 7e55d5796f
23 changed files with 2758 additions and 472 deletions

View File

@@ -1,5 +1,6 @@
use rusqlite::{params, Connection, Result as SqlResult};
use std::path::PathBuf;
use super::catalog;
pub struct Database {
conn: Connection,
@@ -489,6 +490,26 @@ impl Database {
self.migrate_to_v19()?;
}
if current_version < 20 {
self.migrate_to_v20()?;
}
if current_version < 21 {
self.migrate_to_v21()?;
}
if current_version < 22 {
self.migrate_to_v22()?;
}
if current_version < 23 {
self.migrate_to_v23()?;
}
if current_version < 24 {
self.migrate_to_v24()?;
}
// Ensure all expected columns exist (repairs DBs where a migration
// was updated after it had already run on this database)
self.ensure_columns()?;
@@ -1067,6 +1088,191 @@ impl Database {
Ok(())
}
/// Re-categorize OCS apps using the exhaustive typename mapping.
fn migrate_to_v20(&self) -> SqlResult<()> {
let mut stmt = self.conn.prepare(
"SELECT id, ocs_typename FROM catalog_apps WHERE ocs_typename IS NOT NULL",
)?;
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?))
})?.collect::<SqlResult<Vec<_>>>()?;
for (id, typename) in &rows {
let cats = catalog::map_ocs_category(typename);
let cats_str = cats.join(";");
self.conn.execute(
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
params![cats_str, id],
)?;
}
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![20],
)?;
Ok(())
}
/// Normalize all catalog app categories (OCS and non-OCS) to clean groups.
fn migrate_to_v21(&self) -> SqlResult<()> {
let mut stmt = self.conn.prepare(
"SELECT id, categories, ocs_typename FROM catalog_apps WHERE categories IS NOT NULL AND categories <> ''",
)?;
let rows: Vec<(i64, String, Option<String>)> = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?.collect::<SqlResult<Vec<_>>>()?;
for (id, cats_str, ocs_typename) in &rows {
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
let normalized = if ocs_typename.is_some() {
// OCS apps: already mapped by v20, but re-normalize for consistency
cats
} else {
// Non-OCS apps: normalize FreeDesktop categories
catalog::normalize_categories(cats)
};
let new_str = normalized.join(";");
if new_str != *cats_str {
self.conn.execute(
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
params![new_str, id],
)?;
}
}
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![21],
)?;
Ok(())
}
/// Re-normalize all non-OCS categories to clean groups (fixes v21 fallback bug).
fn migrate_to_v22(&self) -> SqlResult<()> {
let mut stmt = self.conn.prepare(
"SELECT id, categories FROM catalog_apps WHERE categories IS NOT NULL AND categories <> '' AND ocs_typename IS NULL",
)?;
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?))
})?.collect::<SqlResult<Vec<_>>>()?;
for (id, cats_str) in &rows {
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
let normalized = catalog::normalize_categories(cats);
let new_str = normalized.join(";");
if new_str != *cats_str {
self.conn.execute(
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
params![new_str, id],
)?;
}
}
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![22],
)?;
Ok(())
}
/// Re-categorize all apps with expanded category set (Communication, Multimedia, Photography, Productivity).
fn migrate_to_v23(&self) -> SqlResult<()> {
// Re-map OCS apps
{
let mut stmt = self.conn.prepare(
"SELECT id, ocs_typename FROM catalog_apps WHERE ocs_typename IS NOT NULL",
)?;
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?))
})?.collect::<SqlResult<Vec<_>>>()?;
for (id, typename) in &rows {
let cats = catalog::map_ocs_category(typename);
let cats_str = cats.join(";");
self.conn.execute(
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
params![cats_str, id],
)?;
}
}
// Re-normalize non-OCS apps
{
let mut stmt = self.conn.prepare(
"SELECT id, categories FROM catalog_apps WHERE categories IS NOT NULL AND categories <> '' AND ocs_typename IS NULL",
)?;
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?))
})?.collect::<SqlResult<Vec<_>>>()?;
for (id, cats_str) in &rows {
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
let normalized = catalog::normalize_categories(cats);
let new_str = normalized.join(";");
if new_str != *cats_str {
self.conn.execute(
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
params![new_str, id],
)?;
}
}
}
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![23],
)?;
Ok(())
}
/// Consolidate to 11 categories (merge thin ones into larger groups).
fn migrate_to_v24(&self) -> SqlResult<()> {
// Re-map OCS apps
{
let mut stmt = self.conn.prepare(
"SELECT id, ocs_typename FROM catalog_apps WHERE ocs_typename IS NOT NULL",
)?;
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?))
})?.collect::<SqlResult<Vec<_>>>()?;
for (id, typename) in &rows {
let cats = catalog::map_ocs_category(typename);
let cats_str = cats.join(";");
self.conn.execute(
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
params![cats_str, id],
)?;
}
}
// Re-normalize non-OCS apps
{
let mut stmt = self.conn.prepare(
"SELECT id, categories FROM catalog_apps WHERE categories IS NOT NULL AND categories <> '' AND ocs_typename IS NULL",
)?;
let rows: Vec<(i64, String)> = stmt.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?))
})?.collect::<SqlResult<Vec<_>>>()?;
for (id, cats_str) in &rows {
let cats: Vec<String> = cats_str.split(';').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect();
let normalized = catalog::normalize_categories(cats);
let new_str = normalized.join(";");
if new_str != *cats_str {
self.conn.execute(
"UPDATE catalog_apps SET categories = ?1 WHERE id = ?2",
params![new_str, id],
)?;
}
}
}
self.conn.execute(
"UPDATE schema_version SET version = ?1",
params![24],
)?;
Ok(())
}
pub fn upsert_appimage(
&self,
path: &str,
@@ -2536,16 +2742,9 @@ impl Database {
}
}
/// Get featured catalog apps. Apps with enrichment data (stars or OCS downloads)
/// sort first by combined popularity, then unenriched apps get a deterministic
/// shuffle that rotates every 15 minutes.
/// Get featured catalog apps from a pool of top ~50 popular apps,
/// shuffled deterministically every 15 minutes so the carousel rotates.
pub fn get_featured_catalog_apps(&self, limit: i32) -> SqlResult<Vec<CatalogApp>> {
// Time seed rotates every 15 minutes (900 seconds)
let time_seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() / 900;
let sql = format!(
"SELECT {} FROM catalog_apps
WHERE icon_url IS NOT NULL AND icon_url != ''
@@ -2559,26 +2758,58 @@ impl Database {
);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], Self::catalog_app_from_row)?;
let mut apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
let apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
// Sort by combined popularity: OCS downloads + GitHub stars.
// Apps with any enrichment sort first, then deterministic shuffle.
Self::shuffle_featured_pool(apps, limit)
}
pub fn get_featured_catalog_apps_by_category(&self, limit: i32, category: &str) -> SqlResult<Vec<CatalogApp>> {
let sql = format!(
"SELECT {} FROM catalog_apps
WHERE icon_url IS NOT NULL AND icon_url != ''
AND (description IS NOT NULL AND description != ''
OR ocs_summary IS NOT NULL AND ocs_summary != '')
AND (screenshots IS NOT NULL AND screenshots != ''
OR ocs_preview_url IS NOT NULL AND ocs_preview_url != '')
AND categories LIKE ?1
{}",
Self::CATALOG_APP_COLUMNS,
Self::CATALOG_DEDUP_FILTER,
);
let pattern = format!("%{}%", category);
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params![pattern], Self::catalog_app_from_row)?;
let apps: Vec<CatalogApp> = rows.collect::<SqlResult<Vec<_>>>()?;
Self::shuffle_featured_pool(apps, limit)
}
/// Sort apps by popularity, take the top pool_size, then deterministically
/// shuffle the pool using a time seed that rotates every 15 minutes.
fn shuffle_featured_pool(mut apps: Vec<CatalogApp>, limit: i32) -> SqlResult<Vec<CatalogApp>> {
const POOL_SIZE: usize = 50;
// Time seed rotates every 15 minutes (900 seconds)
let time_seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() / 900;
// Sort by combined popularity to find the top pool
apps.sort_by(|a, b| {
let a_pop = a.ocs_downloads.unwrap_or(0) + a.github_stars.unwrap_or(0);
let b_pop = b.ocs_downloads.unwrap_or(0) + b.github_stars.unwrap_or(0);
let a_enriched = a_pop > 0;
let b_enriched = b_pop > 0;
match (a_enriched, b_enriched) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(true, true) => b_pop.cmp(&a_pop),
(false, false) => {
let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
ha.cmp(&hb)
}
}
b_pop.cmp(&a_pop)
});
// Take the top pool, then shuffle deterministically
apps.truncate(POOL_SIZE);
apps.sort_by(|a, b| {
let ha = (a.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
let hb = (b.id as u64 ^ time_seed).wrapping_mul(0x517cc1b727220a95);
ha.cmp(&hb)
});
apps.truncate(limit as usize);
Ok(apps)
}