From d185135dd7abdd9ffb20f00bedf3973c4c129c00 Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 20 Jul 2026 15:59:31 -0400 Subject: [PATCH] theme: add WCAG data --- core/internal/server/themes/list.go | 1 + core/internal/server/themes/search.go | 1 + core/internal/server/themes/types.go | 29 +++++---- core/internal/themes/registry.go | 38 +++++++++++ core/internal/themes/registry_test.go | 66 ++++++++++++++++++++ quickshell/Modules/Settings/ThemeBrowser.qml | 30 +++++++++ 6 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 core/internal/themes/registry_test.go diff --git a/core/internal/server/themes/list.go b/core/internal/server/themes/list.go index 73f3c1174..a0b7e62fe 100644 --- a/core/internal/server/themes/list.go +++ b/core/internal/server/themes/list.go @@ -40,6 +40,7 @@ func HandleList(conn *models.Conn, req models.Request) { SourceDir: t.SourceDir, Installed: installed, FirstParty: isFirstParty(t.Author), + WCAG: t.WCAG, } addVariantsInfo(&info, t.Variants) result[i] = info diff --git a/core/internal/server/themes/search.go b/core/internal/server/themes/search.go index f46618f59..fa0e244c3 100644 --- a/core/internal/server/themes/search.go +++ b/core/internal/server/themes/search.go @@ -45,6 +45,7 @@ func HandleSearch(conn *models.Conn, req models.Request) { Description: t.Description, Installed: installed, FirstParty: isFirstParty(t.Author), + WCAG: t.WCAG, } } diff --git a/core/internal/server/themes/types.go b/core/internal/server/themes/types.go index 0ad19b4f1..8f150cb85 100644 --- a/core/internal/server/themes/types.go +++ b/core/internal/server/themes/types.go @@ -1,5 +1,9 @@ package themes +import ( + "github.com/AvengeMedia/DankMaterialShell/core/internal/themes" +) + type VariantInfo struct { ID string `json:"id"` Name string `json:"name"` @@ -32,16 +36,17 @@ type VariantsInfo struct { } type ThemeInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Author string `json:"author,omitempty"` - Description string `json:"description,omitempty"` - PreviewPath string `json:"previewPath,omitempty"` - SourceDir string `json:"sourceDir,omitempty"` - Installed bool `json:"installed,omitempty"` - FirstParty bool `json:"firstParty,omitempty"` - HasUpdate bool `json:"hasUpdate,omitempty"` - HasVariants bool `json:"hasVariants,omitempty"` - Variants *VariantsInfo `json:"variants,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + PreviewPath string `json:"previewPath,omitempty"` + SourceDir string `json:"sourceDir,omitempty"` + Installed bool `json:"installed,omitempty"` + FirstParty bool `json:"firstParty,omitempty"` + HasUpdate bool `json:"hasUpdate,omitempty"` + HasVariants bool `json:"hasVariants,omitempty"` + Variants *VariantsInfo `json:"variants,omitempty"` + WCAG *themes.ThemeWCAG `json:"wcag,omitempty"` } diff --git a/core/internal/themes/registry.go b/core/internal/themes/registry.go index 5a39913f2..157788218 100644 --- a/core/internal/themes/registry.go +++ b/core/internal/themes/registry.go @@ -104,6 +104,28 @@ type ThemeVariants struct { Accents []ThemeAccent `json:"accents,omitempty"` } +type ThemeWCAGGroup struct { + Level string `json:"level"` + MinRatio float64 `json:"minRatio"` + WorstPair []string `json:"worstPair,omitempty"` +} + +type ThemeWCAGMode struct { + Level string `json:"level"` + MinRatio float64 `json:"minRatio"` + WorstPair []string `json:"worstPair,omitempty"` + Body *ThemeWCAGGroup `json:"body,omitempty"` + Accent *ThemeWCAGGroup `json:"accent,omitempty"` + NonText *ThemeWCAGGroup `json:"nonText,omitempty"` + Variants map[string]string `json:"variants,omitempty"` +} + +type ThemeWCAG struct { + Level string `json:"level"` + Dark *ThemeWCAGMode `json:"dark,omitempty"` + Light *ThemeWCAGMode `json:"light,omitempty"` +} + type Theme struct { ID string `json:"id"` Name string `json:"name"` @@ -113,6 +135,7 @@ type Theme struct { Dark ColorScheme `json:"dark"` Light ColorScheme `json:"light"` Variants *ThemeVariants `json:"variants,omitempty"` + WCAG *ThemeWCAG `json:"wcag,omitempty"` PreviewPath string `json:"-"` SourceDir string `json:"sourceDir,omitempty"` } @@ -240,6 +263,7 @@ func (r *Registry) loadThemes() error { theme.ID = entry.Name() } theme.SourceDir = entry.Name() + theme.WCAG = loadThemeWCAG(r.fs, themeDir) previewPath := filepath.Join(themeDir, "preview.svg") if exists, _ := afero.Exists(r.fs, previewPath); exists { @@ -252,6 +276,20 @@ func (r *Registry) loadThemes() error { return nil } +func loadThemeWCAG(fs afero.Fs, themeDir string) *ThemeWCAG { + data, err := afero.ReadFile(fs, filepath.Join(themeDir, "wcag.json")) + if err != nil { + return nil + } + + var wcag ThemeWCAG + if err := json.Unmarshal(data, &wcag); err != nil { + return nil + } + + return &wcag +} + func (r *Registry) List() ([]Theme, error) { if len(r.themes) == 0 { if err := r.Update(); err != nil { diff --git a/core/internal/themes/registry_test.go b/core/internal/themes/registry_test.go new file mode 100644 index 000000000..eeaf92df0 --- /dev/null +++ b/core/internal/themes/registry_test.go @@ -0,0 +1,66 @@ +package themes + +import ( + "testing" + + "github.com/spf13/afero" +) + +func TestLoadThemeWCAG(t *testing.T) { + fs := afero.NewMemMapFs() + themeDir := "/themes/example" + wcagJSON := `{ + "level": "AA", + "dark": { + "level": "AAA", "minRatio": 8.5, "worstPair": ["surfaceText", "surface"], + "body": {"level": "AAA", "minRatio": 8.5}, + "accent": {"level": "AAA", "minRatio": 9.1} + }, + "light": { + "level": "AA", "minRatio": 5.2, + "body": {"level": "AAA", "minRatio": 7.4}, + "accent": {"level": "AA", "minRatio": 5.2}, + "variants": {"blue": "AA", "red": "fail"} + } + }` + if err := afero.WriteFile(fs, themeDir+"/wcag.json", []byte(wcagJSON), 0o644); err != nil { + t.Fatal(err) + } + + wcag := loadThemeWCAG(fs, themeDir) + if wcag == nil { + t.Fatal("expected wcag report, got nil") + } + if wcag.Level != "AA" { + t.Fatalf("expected level AA, got %s", wcag.Level) + } + if wcag.Dark.Level != "AAA" || wcag.Dark.MinRatio != 8.5 { + t.Fatalf("unexpected dark mode report: %+v", wcag.Dark) + } + if wcag.Light.Variants["red"] != "fail" { + t.Fatalf("unexpected light variants: %+v", wcag.Light.Variants) + } + if wcag.Light.Body == nil || wcag.Light.Body.Level != "AAA" { + t.Fatalf("expected light body AAA, got %+v", wcag.Light.Body) + } + if wcag.Light.Accent == nil || wcag.Light.Accent.Level != "AA" { + t.Fatalf("expected light accent AA, got %+v", wcag.Light.Accent) + } +} + +func TestLoadThemeWCAGMissingFile(t *testing.T) { + if wcag := loadThemeWCAG(afero.NewMemMapFs(), "/themes/none"); wcag != nil { + t.Fatalf("expected nil for missing wcag.json, got %+v", wcag) + } +} + +func TestLoadThemeWCAGInvalidJSON(t *testing.T) { + fs := afero.NewMemMapFs() + if err := afero.WriteFile(fs, "/themes/bad/wcag.json", []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + + if wcag := loadThemeWCAG(fs, "/themes/bad"); wcag != nil { + t.Fatalf("expected nil for invalid wcag.json, got %+v", wcag) + } +} diff --git a/quickshell/Modules/Settings/ThemeBrowser.qml b/quickshell/Modules/Settings/ThemeBrowser.qml index db6a37fdd..d448da242 100644 --- a/quickshell/Modules/Settings/ThemeBrowser.qml +++ b/quickshell/Modules/Settings/ThemeBrowser.qml @@ -500,6 +500,36 @@ FloatingWindow { font.weight: Font.Medium } } + + Rectangle { + id: wcagBadge + + // Rated for the mode being viewed, since a theme + // often passes in one mode and not the other. + readonly property var modeReport: Theme.isLightMode ? modelData.wcag?.light : modelData.wcag?.dark + readonly property string level: modeReport?.level ?? "" + readonly property string bodyLevel: modeReport?.body?.level ?? "" + readonly property bool fullPass: level === "AA" || level === "AAA" + readonly property bool bodyPass: bodyLevel === "AA" || bodyLevel === "AAA" + + height: 18 + width: wcagText.implicitWidth + Theme.spacingS + radius: 9 + color: Theme.withAlpha(fullPass ? Theme.info : Theme.surfaceVariantText, 0.15) + border.color: Theme.withAlpha(fullPass ? Theme.info : Theme.surfaceVariantText, 0.4) + border.width: 1 + visible: fullPass || bodyPass + anchors.verticalCenter: parent.verticalCenter + + StyledText { + id: wcagText + anchors.centerIn: parent + text: wcagBadge.fullPass ? "WCAG " + wcagBadge.level : I18n.tr("WCAG %1 body", "contrast badge when only body text passes").arg(wcagBadge.bodyLevel) + font.pixelSize: Theme.fontSizeSmall + color: wcagBadge.fullPass ? Theme.info : Theme.surfaceVariantText + font.weight: Font.Medium + } + } } StyledText {