diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 76412e6c5e37abe25464862c99aeee46cdbccedb..8aa19612a71d77e35ed4610232250121e7e065e5 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -122,6 +122,7 @@ Fixed
 * The OpenID Connect Discovery endpoint now returns the issuer data directly
   under the URI without a trailing `/`.
 * Not-logged in users were able to access all PDF files (CVE-2025-25683).
+* Accessibility issues with new frontend.
 
 Removed
 ~~~~~~~
diff --git a/aleksis/core/data_checks.py b/aleksis/core/data_checks.py
index 382c037b7a03e41baeaf3c6be1426c9826d9032d..409435f529b57e3390b5d1f1a9d7d4d709a3775d 100644
--- a/aleksis/core/data_checks.py
+++ b/aleksis/core/data_checks.py
@@ -14,6 +14,7 @@ from django.utils.text import slugify
 from django.utils.translation import gettext as _
 
 import reversion
+from color_contrast import AccessibilityLevel, ModulationMode, check_contrast, modulate
 from reversion import set_comment
 from tqdm import tqdm
 
@@ -432,3 +433,194 @@ class EmailUniqueDataCheck(DataCheck):
             if person.email and person.email in known_email_addresses:
                 cls.register_result(person)
             known_email_addresses.add(person.email)
+
+
+def accessible_colors_factory(
+    app_name: str,
+    model_name: str,
+    fg_field_name: str = None,
+    bg_field_name: str = None,
+    fg_color: str = "#ffffff",
+    bg_color: str = "#000000",
+    modulation_mode: ModulationMode = ModulationMode.BOTH,
+) -> None:
+    ColorAccessibilityDataCheck.models.append(
+        (
+            app_name,
+            model_name,
+            fg_field_name,
+            bg_field_name,
+            fg_color,
+            bg_color,
+            modulation_mode,
+        )
+    )
+
+
+def _get_colors_from_model_instance(instance, fg_field_name, bg_field_name, fg_color, bg_color):
+    colors: list[str] = [fg_color, bg_color]
+    if fg_field_name is not None:
+        colors[0] = getattr(instance, fg_field_name)
+
+    if bg_field_name is not None:
+        colors[1] = getattr(instance, bg_field_name)
+
+    # Transparency is not support for checking contrasts, so simply truncate it
+    for index, color in enumerate(colors):
+        if not color.startswith("#"):
+            continue
+        if len(color) == 5:
+            # color is of format "#RGBA"
+            colors[index] = color[:-1]
+        elif len(color) == 9:
+            # color is of format "#RRGGBBAA"
+            colors[index] = color[:-2]
+
+    return colors
+
+
+class ModulateColorsSolveOption(SolveOption):
+    name = "modulate_colors"
+    verbose_name = _("Auto-adjust Colors")
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult"):
+        instance = check_result.related_object
+        ctype = check_result.content_type
+
+        model_info = list(
+            filter(
+                lambda m: m[0] == ctype.app_label and m[1] == ctype.model,
+                ColorAccessibilityDataCheck.models,
+            )
+        )
+
+        if len(model_info) == 0:
+            check_result.solved = False
+            check_result.save()
+            logging.error(f"Modulate Colors check failed for {check_result}: Model Info not found")
+            return
+        elif len(model_info) > 1:
+            check_result.solved = False
+            check_result.save()
+            logging.error(f"Modulate Colors check failed for {check_result}: Duplicate Model Info")
+            return
+
+        [_, _, fg_field_name, bg_field_name, fg_color, bg_color, modulation_mode] = model_info[0]
+
+        colors = _get_colors_from_model_instance(
+            instance, fg_field_name, bg_field_name, fg_color, bg_color
+        )
+
+        fg_new, bg_new, success = modulate(*colors, mode=modulation_mode)
+
+        if not success:
+            check_result.solved = False
+            check_result.save()
+            logging.error(
+                f"Modulate Colors check failed for {check_result}: Modulation not possible"
+            )
+            return
+
+        if fg_field_name:
+            setattr(instance, fg_field_name, fg_new)
+        if bg_field_name:
+            setattr(instance, bg_field_name, bg_new)
+        instance.save()
+        check_result.solved = True
+        check_result.save()
+
+
+class ColorAccessibilityDataCheck(DataCheck):
+    name = "colors_accessibility_datacheck"
+    verbose_name = _("Validate contrast accessibility of colors of customizable objects.")
+    problem_name = _("The colors of this object are not accessible.")
+    solve_options = {
+        IgnoreSolveOption._class_name: IgnoreSolveOption,
+        ModulateColorsSolveOption._class_name: ModulateColorsSolveOption,
+    }
+    models = []
+
+    @classmethod
+    def check_data(cls):
+        from django.apps import apps
+
+        for [
+            app_name,
+            model_name,
+            fg_field_name,
+            bg_field_name,
+            fg_color,
+            bg_color,
+            _modulation_mode,
+        ] in cls.models:
+            model: Model = apps.get_model(app_name, model_name)
+            for obj in model.objects.all():
+                colors = _get_colors_from_model_instance(
+                    obj, fg_field_name, bg_field_name, fg_color, bg_color
+                )
+
+                if not check_contrast(*colors, level=AccessibilityLevel.AA):
+                    logging.info(f"Insufficient contrast in {app_name}.{model_name}.{obj}")
+                    cls.register_result(obj)
+
+
+class ModulateThemeColorsSolveOption(SolveOption):
+    name = "modulate_theme_colors"
+    verbose_name = _("Auto-adjust Color")
+
+    @classmethod
+    def solve(cls, check_result: "DataCheckResult"):
+        instance = check_result.related_object
+
+        preference = f"{instance.section}__{instance.name}"
+
+        prefs = get_site_preferences()
+
+        color = prefs[preference]
+
+        fg_new, color_new, success = modulate("#fff", color, mode=ModulationMode.BACKGROUND)
+
+        if not success:
+            check_result.solved = False
+            check_result.save()
+            logging.error(f"Modulate {instance.name} theme color failed: Modulation not possible.")
+            return
+
+        prefs[preference] = str(color_new)
+
+        check_result.solved = True
+        check_result.save()
+
+
+class AccessibleThemeColorsDataCheck(DataCheck):
+    name = "accessible_themes_colors_datacheck"
+    verbose_name = _("Validate that theme colors are accessible.")
+    problem_name = _("The color does not provide enough contrast")
+    solve_options = {
+        IgnoreSolveOption._class_name: IgnoreSolveOption,
+        ModulateThemeColorsSolveOption._class_name: ModulateThemeColorsSolveOption,
+    }
+
+    @classmethod
+    def check_data(cls):
+        from dynamic_preferences.models import GlobalPreferenceModel
+
+        from .util.core_helpers import get_site_preferences
+
+        prefs = get_site_preferences()
+
+        primary = prefs["theme__primary"]
+        secondary = prefs["theme__secondary"]
+
+        # White text on primary colored background
+        if not check_contrast("#fff", primary, level=AccessibilityLevel.AA):
+            logging.info("Insufficient contrast in primary color")
+            obj = GlobalPreferenceModel.objects.get(section="theme", name="primary")
+            cls.register_result(obj)
+
+        # White text on secondary colored background
+        if not check_contrast("#fff", secondary, level=AccessibilityLevel.AA):
+            logging.info("Insufficient contrast in primary color")
+            obj = GlobalPreferenceModel.objects.get(section="theme", name="secondary")
+            cls.register_result(obj)
diff --git a/aleksis/core/frontend/app/vuetify.js b/aleksis/core/frontend/app/vuetify.js
index fa633f088bc0aea6b884705e1f3b14713e203ec8..b10c696946e6310a293b408a9e1c9aae050ddd6d 100644
--- a/aleksis/core/frontend/app/vuetify.js
+++ b/aleksis/core/frontend/app/vuetify.js
@@ -37,6 +37,15 @@ const vuetifyOpts = {
       updatePwa: "mdi-update",
     },
   },
+  theme: {
+    options: {
+      customProperties: true,
+      themeCache: {
+        get: (key) => localStorage.getItem(key),
+        set: (key, value) => localStorage.setItem(key, value),
+      },
+    },
+  },
 };
 
 export default vuetifyOpts;
diff --git a/aleksis/core/frontend/components/LegacyBaseTemplate.vue b/aleksis/core/frontend/components/LegacyBaseTemplate.vue
index 877d2ce85a4bc9f525a1ff6a5d4618148ab8a888..d61338fc17a8e1c4cda469b71e2b5851e9fd9db4 100644
--- a/aleksis/core/frontend/components/LegacyBaseTemplate.vue
+++ b/aleksis/core/frontend/components/LegacyBaseTemplate.vue
@@ -82,6 +82,7 @@ export default {
       // Write title of iframe to SPA window
       const title = this.$refs.contentIFrame.contentWindow.document.title;
       this.$root.$setPageTitle(title);
+      this.$refs.contentIFrame.title = title;
 
       // Adapt height of IFrame according to the height of its contents once and observe height changes
       if (
diff --git a/aleksis/core/frontend/components/app/AccountMenu.vue b/aleksis/core/frontend/components/app/AccountMenu.vue
index c830df8458eb26ea1748c7ba507cc90c4a310a69..ac136f937642f03b766b1372c1e2f1bf476c80b1 100644
--- a/aleksis/core/frontend/components/app/AccountMenu.vue
+++ b/aleksis/core/frontend/components/app/AccountMenu.vue
@@ -1,7 +1,13 @@
 <template>
-  <v-menu offset-y>
+  <v-menu offset-y max-height="80vh">
     <template #activator="{ on, attrs }">
-      <v-avatar v-bind="attrs" v-on="on">
+      <v-avatar
+        v-bind="attrs"
+        v-on="on"
+        tag="button"
+        tabindex="0"
+        :aria-label="$t('actions.account_menu')"
+      >
         <img
           v-if="
             systemProperties.sitePreferences.accountPersonPreferPhoto &&
diff --git a/aleksis/core/frontend/components/app/App.vue b/aleksis/core/frontend/components/app/App.vue
index 8fbc210b3fc65a1a3c380cdbe4906e06534d70e3..12d25ebd0a424feabfca9c71ab9ecf175863bacf 100644
--- a/aleksis/core/frontend/components/app/App.vue
+++ b/aleksis/core/frontend/components/app/App.vue
@@ -17,6 +17,7 @@
     />
     <div v-else>
       <side-nav
+        ref="sidenav"
         v-model="drawer"
         :system-properties="systemProperties"
         :side-nav-menu="sideNavMenu"
@@ -25,14 +26,19 @@
         app
         :color="$vuetify.theme.dark ? undefined : 'primary white--text'"
       >
-        <v-app-bar-nav-icon @click="drawer = !drawer" color="white" />
+        <v-app-bar-nav-icon
+          @click="drawer = !drawer"
+          color="white"
+          :aria-label="$t('actions.toogle_sidenav')"
+        />
 
-        <v-toolbar-title
-          tag="a"
-          class="white--text text-decoration-none"
-          @click="$router.push({ name: 'dashboard' })"
-        >
-          {{ $root.toolbarTitle }}
+        <v-toolbar-title>
+          <router-link
+            class="white--text text-decoration-none"
+            :to="{ name: 'dashboard' }"
+          >
+            {{ $root.toolbarTitle }}
+          </router-link>
         </v-toolbar-title>
 
         <v-progress-linear
@@ -41,7 +47,8 @@
           absolute
           bottom
           :color="$vuetify.theme.dark ? 'primary' : 'grey lighten-3'"
-        ></v-progress-linear>
+          aria-hidden="true"
+        />
 
         <v-spacer />
         <v-btn
@@ -355,6 +362,12 @@ export default {
       },
       immediate: true,
     },
+    drawer: function (newValue) {
+      if (newValue) {
+        // Drawer was opened, → focus sidenav
+        this.$refs.sidenav.focusList();
+      }
+    },
   },
   name: "App",
   components: {
@@ -382,7 +395,7 @@ export default {
 <style>
 div[aria-required="true"] .v-input .v-label::after {
   content: " *";
-  color: red;
+  color: var(--v-error-base);
 }
 
 .main-container {
diff --git a/aleksis/core/frontend/components/app/ErrorPage.vue b/aleksis/core/frontend/components/app/ErrorPage.vue
index c45967829ba37f8d07081e2b70b17be77c833e4d..c0f5b710aff94f0946ad1972b774b23080b58a23 100644
--- a/aleksis/core/frontend/components/app/ErrorPage.vue
+++ b/aleksis/core/frontend/components/app/ErrorPage.vue
@@ -1,5 +1,6 @@
 <script setup>
 import Mascot from "../generic/mascot/Mascot.vue";
+import PrimaryActionButton from "../generic/buttons/PrimaryActionButton.vue";
 </script>
 
 <template>
@@ -15,14 +16,13 @@ import Mascot from "../generic/mascot/Mascot.vue";
     />
     <h1 class="text-h2">{{ $t(shortErrorMessageKey) }}</h1>
     <div>{{ $t(longErrorMessageKey) }}</div>
-    <v-btn
+    <primary-action-button
       color="secondary"
       :to="{ name: redirectRouteName }"
       v-if="!hideButton"
-    >
-      <v-icon left>{{ redirectButtonIcon }}</v-icon>
-      {{ $t(redirectButtonTextKey) }}
-    </v-btn>
+      :icon-text="redirectButtonIcon"
+      :i18n-key="redirectButtonTextKey"
+    />
   </div>
 </template>
 
diff --git a/aleksis/core/frontend/components/app/LanguageForm.vue b/aleksis/core/frontend/components/app/LanguageForm.vue
index d5d1e71a7e92a9feff5fea35b888fcb53dc535ae..89a8479c4da73f5d25d369d5b49b51d051c4436e 100644
--- a/aleksis/core/frontend/components/app/LanguageForm.vue
+++ b/aleksis/core/frontend/components/app/LanguageForm.vue
@@ -43,6 +43,7 @@ export default {
       document.cookie = languageOption.cookie;
       this.$i18n.locale = languageOption.code;
       this.$vuetify.lang.current = languageOption.code;
+      document.getElementsByTagName("html")[0].lang = languageOption.code;
       this.language = languageOption;
     },
     nameForMenu: function (item) {
diff --git a/aleksis/core/frontend/components/app/SideNav.vue b/aleksis/core/frontend/components/app/SideNav.vue
index 6cf3fa740a6c6090733553a58f6754fa11dc1c30..4a810047835fe5d717919a7e16dfe4d93409cbf2 100644
--- a/aleksis/core/frontend/components/app/SideNav.vue
+++ b/aleksis/core/frontend/components/app/SideNav.vue
@@ -4,16 +4,17 @@
     :value="value"
     height="100dvh"
     @input="$emit('input', $event)"
+    tag="aside"
   >
-    <v-list nav dense shaped>
-      <v-list-item class="logo">
-        <a
-          id="logo-container"
-          @click="$router.push({ name: 'dashboard' })"
-          class="brand-logo"
-        >
-          <brand-logo :site-preferences="systemProperties.sitePreferences" />
-        </a>
+    <v-list nav dense shaped tag="nav">
+      <v-list-item
+        class="focusable"
+        ref="listItem"
+        :to="{ name: 'dashboard' }"
+        exact
+        color="transparent"
+      >
+        <brand-logo :site-preferences="systemProperties.sitePreferences" />
       </v-list-item>
       <v-list-item v-if="checkPermission('core.search_rule')" class="search">
         <sidenav-search />
@@ -146,6 +147,15 @@ export default {
     comparator(array, value) {
       return Array.isArray(array) && array.includes(value);
     },
+    focusList() {
+      this.$nextTick(() => {
+        // console.log(this.$refs.listItem)
+        console.log(this.$refs.listItem.$el);
+        this.$refs.listItem.$el.focus();
+        // let el = document.querySelector(".focusable")
+        // el.focus()
+      });
+    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/components/calendar/CalendarControlBar.vue b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue
index 640b067425767b595fdc555cba4ca6771c9bb33b..91aba1f0e380fa438caf3dd88d14e796749af576 100644
--- a/aleksis/core/frontend/components/calendar/CalendarControlBar.vue
+++ b/aleksis/core/frontend/components/calendar/CalendarControlBar.vue
@@ -14,14 +14,20 @@ export default {
 
 <template>
   <div class="d-flex justify-center mx-2">
-    <v-btn icon @click="$emit('prev')" :small="small">
-      <v-icon>$prev</v-icon>
-    </v-btn>
+    <icon-button
+      @click="$emit('prev')"
+      :small="small"
+      icon-text="$prev"
+      i18n-key="actions.scroll_prev"
+    />
     <v-btn outlined text class="mx-1" @click="$emit('today')" :small="small">
       {{ $t("calendar.today") }}
     </v-btn>
-    <v-btn icon @click="$emit('next')" :small="small">
-      <v-icon>$next</v-icon>
-    </v-btn>
+    <icon-button
+      @click="$emit('next')"
+      :small="small"
+      icon-text="$next"
+      i18n-key="actions.scroll_next"
+    />
   </div>
 </template>
diff --git a/aleksis/core/frontend/components/calendar/CalendarSelect.vue b/aleksis/core/frontend/components/calendar/CalendarSelect.vue
index 71bb96254f4da986067932c1c761c40ed571a69a..11f87dcc694e4dd36faed0b3ee001654f77c5347 100644
--- a/aleksis/core/frontend/components/calendar/CalendarSelect.vue
+++ b/aleksis/core/frontend/components/calendar/CalendarSelect.vue
@@ -4,12 +4,14 @@
       v-for="calendarFeed in calendarFeeds"
       :key="calendarFeed.name"
       :value="calendarFeed.name"
+      :tabindex="-1"
     >
       <template #default="{ active }">
         <v-list-item-action>
           <v-checkbox
             :input-value="active"
             :color="calendarFeed.color"
+            class="focusable"
           ></v-checkbox>
         </v-list-item-action>
 
@@ -20,23 +22,22 @@
         </v-list-item-content>
 
         <v-list-item-action>
-          <v-menu bottom>
-            <template #activator="{ on, attrs }">
-              <v-btn fab x-small icon v-bind="attrs" v-on="on">
-                <v-icon>mdi-dots-vertical</v-icon>
-              </v-btn>
-            </template>
-            <v-list dense>
-              <v-list-item :href="calendarFeed.url">
-                <v-list-item-icon>
-                  <v-icon>mdi-calendar-export</v-icon>
-                </v-list-item-icon>
-                <v-list-item-title>
-                  {{ $t("calendar.download_ics") }}
-                </v-list-item-title>
-              </v-list-item>
-            </v-list>
-          </v-menu>
+          <button-menu
+            icon-only
+            :outlined="false"
+            icon="mdi-dots-vertical"
+            :text="false"
+            text-translation-key="actions.more_actions"
+          >
+            <v-list-item :href="calendarFeed.url">
+              <v-list-item-icon>
+                <v-icon>mdi-calendar-export</v-icon>
+              </v-list-item-icon>
+              <v-list-item-title>
+                {{ $t("calendar.download_ics") }}
+              </v-list-item-title>
+            </v-list-item>
+          </button-menu>
         </v-list-item-action>
       </template>
     </v-list-item>
diff --git a/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue
index 41993c50108a0112518e1fcec777eee1497ce228..85454c5301eeaa6decca1711edd8e0fa11e8ad77 100644
--- a/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue
+++ b/aleksis/core/frontend/components/calendar/CalendarTypeSelect.vue
@@ -54,6 +54,7 @@ export default {
       v-for="calendarType in availableCalendarTypes"
       :value="calendarType.type"
       :key="calendarType.type"
+      :aria-label="nameForMenu(calendarType)"
     >
       <v-icon v-if="$vuetify.breakpoint.smAndDown">{{
         calendarType.type === innerValue
diff --git a/aleksis/core/frontend/components/generic/BackButton.vue b/aleksis/core/frontend/components/generic/BackButton.vue
index f446f5d6f84d7427654b646785ac1c08e9cbaa40..535bb23cfc4f4c607989e814429540663e51f127 100644
--- a/aleksis/core/frontend/components/generic/BackButton.vue
+++ b/aleksis/core/frontend/components/generic/BackButton.vue
@@ -1,12 +1,24 @@
-<template>
-  <v-btn color="secondary" v-bind="$attrs">
-    <v-icon left>$prev</v-icon>
-    {{ $t("actions.back") }}
-  </v-btn>
-</template>
-
 <script>
+import PrimaryActionButton from "./buttons/PrimaryActionButton.vue";
+
 export default {
   name: "BackButton",
+  extends: PrimaryActionButton,
+  props: {
+    iconText: {
+      type: String,
+      required: false,
+      default: "$prev",
+    },
+    i18nKey: {
+      type: String,
+      required: false,
+      default: "actions.back",
+    },
+    color: {
+      required: false,
+      default: "secondary",
+    },
+  },
 };
 </script>
diff --git a/aleksis/core/frontend/components/generic/ButtonMenu.vue b/aleksis/core/frontend/components/generic/ButtonMenu.vue
index f1ef728503695747b278003d85e405a76787a65d..79192950408d3103ffe7cbeb36a5f95d1da9c202 100644
--- a/aleksis/core/frontend/components/generic/ButtonMenu.vue
+++ b/aleksis/core/frontend/components/generic/ButtonMenu.vue
@@ -4,14 +4,26 @@
     offset-y
     :close-on-content-click="closeOnContentClick"
   >
-    <template #activator="{ on, attrs }">
-      <slot name="activator" v-bind="{ on, attrs }">
-        <v-btn outlined text v-bind="attrs" v-on="on">
-          <v-icon :left="!!textTranslationKey" :center="!textTranslationKey">
-            {{ icon }}
-          </v-icon>
-          <span v-if="textTranslationKey">{{ $t(textTranslationKey) }}</span>
-        </v-btn>
+    <template #activator="menu">
+      <slot name="activator" v-bind="menu">
+        <v-tooltip bottom :disabled="!iconOnly">
+          <template #activator="tooltip">
+            <v-btn
+              :outlined="outlined"
+              :text="text"
+              :icon="!outlined && iconOnly"
+              v-bind="{ ...tooltip.attrs, ...menu.attrs }"
+              v-on="{ ...tooltip.on, ...menu.on }"
+              :aria-label="$t(textTranslationKey)"
+            >
+              <v-icon :left="!iconOnly" :center="iconOnly">
+                {{ icon }}
+              </v-icon>
+              <span v-if="!iconOnly">{{ $t(textTranslationKey) }}</span>
+            </v-btn>
+          </template>
+          <span v-if="iconOnly">{{ $t(textTranslationKey) }}</span>
+        </v-tooltip>
       </slot>
     </template>
 
@@ -32,14 +44,26 @@ export default {
     },
     textTranslationKey: {
       type: String,
+      required: true,
+    },
+    iconOnly: {
+      type: Boolean,
       required: false,
-      default: "",
+      default: false,
     },
     closeOnContentClick: {
       type: Boolean,
       required: false,
       default: true,
     },
+    outlined: {
+      type: Boolean,
+      default: true,
+    },
+    text: {
+      type: Boolean,
+      default: true,
+    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/components/generic/DateSelectFooter.vue b/aleksis/core/frontend/components/generic/DateSelectFooter.vue
index 952a586d917a0d232728e03ba69253eea78c076f..414b4a9cce8ded961bcdb6e13e16003f0178de90 100644
--- a/aleksis/core/frontend/components/generic/DateSelectFooter.vue
+++ b/aleksis/core/frontend/components/generic/DateSelectFooter.vue
@@ -26,9 +26,13 @@ export default {
     <v-card tile class="full-width">
       <v-card-title class="auto-margin">
         <div class="d-flex align-center justify-center full-width">
-          <v-btn icon large class="me-4" @click="$emit('prev')">
-            <v-icon>$prev</v-icon>
-          </v-btn>
+          <icon-button
+            large
+            class="me-4"
+            @click="$emit('prev')"
+            icon-text="$prev"
+            i18n-key="actions.scroll_prev"
+          />
           <div class="flex-grow-0">
             <date-field
               solo-inverted
@@ -41,9 +45,13 @@ export default {
               readonly
             />
           </div>
-          <v-btn icon large class="ms-4" @click="$emit('next')">
-            <v-icon>$next</v-icon>
-          </v-btn>
+          <icon-button
+            large
+            class="ms-4"
+            @click="$emit('next')"
+            icon-text="$next"
+            i18n-key="actions.scroll_next"
+          />
         </div>
       </v-card-title>
     </v-card>
diff --git a/aleksis/core/frontend/components/generic/SlideIterator.vue b/aleksis/core/frontend/components/generic/SlideIterator.vue
index b3bf02da80b7710ca2ba50e81399b6f4a8c4ed09..62b69d6b5943201f9a082b329990e30f33d184ff 100644
--- a/aleksis/core/frontend/components/generic/SlideIterator.vue
+++ b/aleksis/core/frontend/components/generic/SlideIterator.vue
@@ -1,9 +1,6 @@
 <script>
-import SecondaryActionButton from "./buttons/SecondaryActionButton.vue";
-
 export default {
   name: "SlideIterator",
-  components: { SecondaryActionButton },
   extends: "v-data-iterator",
   data() {
     return {
@@ -113,9 +110,12 @@ export default {
                 />
               </v-list-item-content>
               <v-list-item-action>
-                <v-btn icon large :disabled="disabled">
-                  <v-icon large>$next</v-icon>
-                </v-btn>
+                <icon-button
+                  large
+                  :disabled="disabled"
+                  icon-text="$next"
+                  i18n-key="actions.open_details"
+                />
               </v-list-item-action>
             </template>
           </v-list-item>
diff --git a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue
index e3b17a23bc3be166a1be05c378bdae54027ba1bf..d7d49d84786c8e4be8c0795cc4d2b610567022f5 100644
--- a/aleksis/core/frontend/components/generic/buttons/BaseButton.vue
+++ b/aleksis/core/frontend/components/generic/buttons/BaseButton.vue
@@ -1,17 +1,28 @@
 <template>
-  <v-btn v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
-    <slot>
-      <v-icon v-if="iconText" :left="!icon">{{ iconText }}</v-icon>
-      <span v-if="!icon" v-t="i18nKey" />
-    </slot>
-  </v-btn>
+  <v-tooltip bottom :disabled="!icon && !forceTooltip" eager tag="div">
+    <template #activator="{ on, attrs }">
+      <v-btn
+        v-bind="{ ...$props, ...attrs, ...$attrs }"
+        v-on="{ ...on, ...$listeners }"
+        :aria-label="$t(i18nKey)"
+      >
+        <slot>
+          <v-icon v-if="iconText" :left="!icon">{{ iconText }}</v-icon>
+          <span v-if="!icon" v-t="i18nKey" />
+        </slot>
+      </v-btn>
+    </template>
+    <span v-if="forceTooltip || icon" v-t="i18nKey" />
+  </v-tooltip>
 </template>
 
 <script>
+import VBtn from "@/vuetify/lib/components/VBtn";
+
 export default {
   name: "BaseButton",
   inheritAttrs: true,
-  extends: "v-btn",
+  extends: VBtn,
   props: {
     i18nKey: {
       type: String,
@@ -31,6 +42,11 @@ export default {
       required: false,
       default: false,
     },
+    forceTooltip: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
   },
 };
 </script>
diff --git a/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue b/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue
index 5998f13f9895bb61583ee5daf5ad3c48eb67c0a2..08452f6daab63059cbccff61af3a134794006fad 100644
--- a/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue
+++ b/aleksis/core/frontend/components/generic/buttons/DialogCloseButton.vue
@@ -1,13 +1,30 @@
 <template>
-  <v-btn v-bind="{ ...$props, ...$attrs }" v-on="$listeners" icon large>
-    <v-icon>$close</v-icon>
-  </v-btn>
+  <icon-button
+    v-bind="{ ...$props, ...$attrs }"
+    v-on="$listeners"
+    :icon-text="iconText"
+    :i18n-key="i18nKey"
+    large
+  />
 </template>
 
 <script>
+import IconButton from "./IconButton.vue";
+
 export default {
   name: "DialogCloseButton",
+  components: { IconButton },
   inheritAttrs: true,
-  extends: "v-btn",
+  extends: IconButton,
+  props: {
+    iconText: {
+      required: false,
+      default: "$close",
+    },
+    i18nKey: {
+      required: false,
+      default: "actions.close",
+    },
+  },
 };
 </script>
diff --git a/aleksis/core/frontend/components/generic/buttons/FilterButton.vue b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue
index 54d6dc626ba7b039361bc8e590a927ce3313157d..eae873d64d75bb2ec16afe987a9774e4d1b5c3a6 100644
--- a/aleksis/core/frontend/components/generic/buttons/FilterButton.vue
+++ b/aleksis/core/frontend/components/generic/buttons/FilterButton.vue
@@ -8,24 +8,24 @@
     <v-badge color="secondary" :value="numFilters" :content="numFilters" inline>
       <span v-t="i18nKey" />
     </v-badge>
-    <v-btn
-      icon
+    <icon-button
       @click.stop="$emit('clear')"
       small
       v-if="numFilters"
       class="mr-n1"
-    >
-      <v-icon>$clear</v-icon>
-    </v-btn>
+      icon-text="$clear"
+      i18n-key="actions.clear_filters"
+    />
   </secondary-action-button>
 </template>
 
 <script>
 import SecondaryActionButton from "./SecondaryActionButton.vue";
+import IconButton from "./IconButton.vue";
 
 export default {
   name: "FilterButton",
-  components: { SecondaryActionButton },
+  components: { IconButton, SecondaryActionButton },
   extends: SecondaryActionButton,
   computed: {
     filterIcon() {
diff --git a/aleksis/core/frontend/components/generic/buttons/IconButton.vue b/aleksis/core/frontend/components/generic/buttons/IconButton.vue
new file mode 100644
index 0000000000000000000000000000000000000000..98a819df9f33a361585cba0510439441ba975126
--- /dev/null
+++ b/aleksis/core/frontend/components/generic/buttons/IconButton.vue
@@ -0,0 +1,14 @@
+<script>
+import BaseButton from "./BaseButton.vue";
+export default {
+  name: "IconButton",
+  components: { BaseButton },
+  extends: BaseButton,
+  props: {
+    icon: {
+      required: false,
+      default: true,
+    },
+  },
+};
+</script>
diff --git a/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue
index 16fd418c34bbc3f3ffe3549e3a86635ffd45ec11..72c05c922b3a7437fe81e311202bf03c27fd3d5a 100644
--- a/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue
+++ b/aleksis/core/frontend/components/generic/dialogs/ClosableSnackbar.vue
@@ -2,9 +2,12 @@
   <v-snackbar v-bind="$attrs" v-on="$listeners">
     <slot />
     <template #action="{ attrs }">
-      <v-btn v-bind="attrs" @click="close()" icon>
-        <v-icon>$close</v-icon>
-      </v-btn>
+      <icon-button
+        v-bind="attrs"
+        @click="close()"
+        icon-text="$close"
+        i18n-key="actions.close"
+      />
     </template>
   </v-snackbar>
 </template>
diff --git a/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue b/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue
index dc71aa8a0933e7d0491c01b3e5a79857fe8e8178..bde22c162dd64c87b539d3ecf91b68a7cb8cb90a 100644
--- a/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue
+++ b/aleksis/core/frontend/components/generic/dialogs/FullscreenDialogPage.vue
@@ -49,9 +49,7 @@ export default {
       <v-card-title v-if="isDialog" class="pa-0">
         <v-toolbar>
           <slot name="cancel">
-            <v-btn icon @click="handleClose">
-              <v-icon>$cancel</v-icon>
-            </v-btn>
+            <dialog-close-button @click="handleClose" />
           </slot>
 
           <v-toolbar-title>
diff --git a/aleksis/core/frontend/components/generic/forms/FileField.vue b/aleksis/core/frontend/components/generic/forms/FileField.vue
index abf8a9c9ddf01cee0705a49b09c475769bae7337..f7cfd89453e2e72558df191c31207ccc4eaeec54 100644
--- a/aleksis/core/frontend/components/generic/forms/FileField.vue
+++ b/aleksis/core/frontend/components/generic/forms/FileField.vue
@@ -8,9 +8,12 @@
     :hint="hint"
   >
     <template #append>
-      <v-btn v-if="showClear" icon @click.stop="clearOrDelete">
-        <v-icon>$clear</v-icon>
-      </v-btn>
+      <icon-button
+        v-if="showClear"
+        @click.stop="clearOrDelete"
+        icon-text="$clear"
+        i18n-key="actions.clear"
+      />
     </template>
     <template #append-outer>
       <v-expand-x-transition>
@@ -21,9 +24,12 @@
           <div v-if="!internalState && initialState?.url" class="mr-1">
             <slot name="append-outer" :file-url="initialState?.url" />
           </div>
-          <v-btn v-if="showDelete" icon @click.stop="clearOrDelete">
-            <v-icon>$deleteContent</v-icon>
-          </v-btn>
+          <icon-button
+            v-if="showDelete"
+            @click.stop="clearOrDelete"
+            icon-text="$deleteContent"
+            i18n-key="actions.delete"
+          />
         </div>
       </v-expand-x-transition>
     </template>
diff --git a/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue
index aaf2a6277002dc66fa0cbf094798025bfdec759c..0a148d284e139521f4fec528c7ce95905169cf3f 100644
--- a/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue
+++ b/aleksis/core/frontend/components/generic/forms/ForeignKeyField.vue
@@ -26,9 +26,11 @@
       <slot name="progress" />
     </template>
     <template #append-outer v-if="enableCreate">
-      <v-btn icon @click="createMode = true">
-        <v-icon>$plus</v-icon>
-      </v-btn>
+      <icon-button
+        @click="createMode = true"
+        icon-text="$plus"
+        i18n-key="actions.create"
+      />
 
       <slot
         name="createComponent"
diff --git a/aleksis/core/frontend/components/group/GroupActions.vue b/aleksis/core/frontend/components/group/GroupActions.vue
index b9f5d606999822f4fbd4bde66dfcb7a0de18de11..fce07fb955800c0a4c48d0e0f29d2b911941ffb0 100644
--- a/aleksis/core/frontend/components/group/GroupActions.vue
+++ b/aleksis/core/frontend/components/group/GroupActions.vue
@@ -6,7 +6,12 @@
       :to="{ name: 'core.editGroup', params: { id: group.id } }"
     />
 
-    <button-menu :close-on-content-click="false" v-if="actions.length">
+    <button-menu
+      :close-on-content-click="false"
+      v-if="actions.length"
+      icon-only
+      text-translation-key="actions.more_actions"
+    >
       <component
         :is="action.component"
         v-for="action in actions"
diff --git a/aleksis/core/frontend/components/group/GroupMembers.vue b/aleksis/core/frontend/components/group/GroupMembers.vue
index e35b896cdf6746bd838973ed3e50e3ca833bb3ec..c64fb96689ffd197f637f0a74565858bc17063ea 100644
--- a/aleksis/core/frontend/components/group/GroupMembers.vue
+++ b/aleksis/core/frontend/components/group/GroupMembers.vue
@@ -92,6 +92,7 @@ export default {
             v-on="on"
             icon
             icon-text="mdi-open-in-new"
+            i18n-key="actions.open_in_new"
             :outlined="false"
             target="_blank"
             :to="{
diff --git a/aleksis/core/frontend/components/notifications/NotificationItem.vue b/aleksis/core/frontend/components/notifications/NotificationItem.vue
index 171c7f728dd9232ae4e5f4c4934817c87b1a0245..930d40c67730e558db8f328ab18defa8a80a7b39 100644
--- a/aleksis/core/frontend/components/notifications/NotificationItem.vue
+++ b/aleksis/core/frontend/components/notifications/NotificationItem.vue
@@ -42,37 +42,21 @@
         </v-list-item-content>
 
         <v-list-item-action>
-          <v-tooltip bottom>
-            <template #activator="{ on, attrs }">
-              <v-btn
-                icon
-                color="secondary"
-                v-if="!notification.read"
-                @click="mutate"
-                v-bind="attrs"
-                v-on="on"
-              >
-                <v-icon>mdi-email-outline</v-icon>
-              </v-btn>
-            </template>
-            <span>{{ $t("notifications.mark_as_read") }}</span>
-          </v-tooltip>
+          <icon-button
+            icon-text="mdi-email-outline"
+            color="secondary"
+            i18n-key="notifications.mark_as_read"
+            v-if="!notification.read"
+            @click="mutate"
+          />
 
-          <v-tooltip bottom>
-            <template #activator="{ on, attrs }">
-              <v-btn
-                icon
-                color="accent"
-                :href="notification.link"
-                v-if="notification.link"
-                v-bind="attrs"
-                v-on="on"
-              >
-                <v-icon>mdi-open-in-new</v-icon>
-              </v-btn>
-            </template>
-            <span>{{ $t("notifications.more_information") }}</span>
-          </v-tooltip>
+          <icon-button
+            icon-text="mdi-open-in-new"
+            color="accent"
+            i18n-key="notifications.more_information"
+            :href="notification.link"
+            v-if="notification.link"
+          />
         </v-list-item-action>
       </v-list-item>
     </template>
@@ -81,6 +65,7 @@
 
 <script>
 import { DateTime } from "luxon";
+
 export default {
   props: {
     notification: {
diff --git a/aleksis/core/frontend/components/notifications/NotificationList.vue b/aleksis/core/frontend/components/notifications/NotificationList.vue
index 47b741fbeb122f49d6fdbcb0d276df4fcf0d3761..02e4c9acf3f890f3492fc2c8319ab5cb47219240 100644
--- a/aleksis/core/frontend/components/notifications/NotificationList.vue
+++ b/aleksis/core/frontend/components/notifications/NotificationList.vue
@@ -14,6 +14,7 @@
         v-on="on"
         :loading="$apollo.queries.myNotifications.loading"
         class="mx-2"
+        :aria-label="$t('actions.list_notifications')"
       >
         <v-icon
           v-if="
@@ -27,14 +28,14 @@
         <v-icon color="white" v-else>mdi-bell-outline</v-icon>
       </v-btn>
     </template>
-    <v-skeleton-loader
-      v-if="$apollo.queries.myNotifications.loading"
-      class="mx-auto"
-      type="paragraph"
-    ></v-skeleton-loader>
-    <v-list v-else nav three-line dense class="overflow-y-auto">
+    <v-list nav three-line dense class="overflow-y-auto">
+      <v-skeleton-loader
+        v-if="$apollo.queries.myNotifications.loading"
+        class="mx-auto"
+        type="paragraph"
+      ></v-skeleton-loader>
       <template
-        v-if="
+        v-else-if="
           myNotifications.person &&
           myNotifications.person.notifications &&
           myNotifications.person.notifications.length
@@ -57,16 +58,14 @@
           ></v-divider>
         </template>
       </template>
-      <template v-else>
-        <v-list-item>
-          <div class="d-flex justify-center align-center flex-column">
-            <div class="mb-4">
-              <mascot type="no_notifications" width="min(200px, 30vw)" />
-            </div>
-            <div>{{ $t("notifications.no_notifications") }}</div>
+      <v-list-item v-else value="empty">
+        <div class="d-flex justify-center align-center flex-column">
+          <div class="mb-4">
+            <mascot type="no_notifications" width="min(200px, 30vw)" />
           </div>
-        </v-list-item>
-      </template>
+          <div>{{ $t("notifications.no_notifications") }}</div>
+        </div>
+      </v-list-item>
     </v-list>
   </v-menu>
 </template>
diff --git a/aleksis/core/frontend/components/person/PersonActions.vue b/aleksis/core/frontend/components/person/PersonActions.vue
index 0929fde34b3d918f9fd23df8e381a74b32182934..59fcb0f49d0784e5ddd131296b39379dd257a0fd 100644
--- a/aleksis/core/frontend/components/person/PersonActions.vue
+++ b/aleksis/core/frontend/components/person/PersonActions.vue
@@ -36,6 +36,8 @@ import PersonForm from "./PersonForm.vue";
         person.canInvitePerson ||
         person.canDelete
       "
+      icon-only
+      text-translation-key="actions.more_actions"
     >
       <v-list-item
         v-if="person.canImpersonatePerson"
diff --git a/aleksis/core/frontend/components/person/PersonOverview.vue b/aleksis/core/frontend/components/person/PersonOverview.vue
index 761bb24da034d62c1e64271ced82c9cefe114190..326fac4704c1ecd67bf4ad725b4f660ff56b4154 100644
--- a/aleksis/core/frontend/components/person/PersonOverview.vue
+++ b/aleksis/core/frontend/components/person/PersonOverview.vue
@@ -276,9 +276,9 @@
                   <v-list-item-icon>
                     <v-icon>mdi-account-tie-hat-outline</v-icon>
                   </v-list-item-icon>
-                  <v-list-item-title>{{
-                    $tc("group.owner_of_n", person.ownerOf.length)
-                  }}</v-list-item-title>
+                  <v-list-item-title>
+                    {{ $tc("group.owner_of_n", person.ownerOf.length) }}
+                  </v-list-item-title>
                 </template>
                 <group-collection :groups="person.ownerOf" dense />
               </v-list-group>
diff --git a/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue
index e9c0543837faf74b89c6f314bc11c8079bdc70a6..de79b0d078b2b34d5357a02bb9200de130e22718 100644
--- a/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue
+++ b/aleksis/core/frontend/components/school_term/ActiveSchoolTermSelect.vue
@@ -114,6 +114,7 @@ export default {
         v-bind="{ ...$attrs, ...attrs }"
         v-on="on"
         :loading="$apollo.queries.activeSchoolTerm.loading"
+        :aria-label="$t('actions.select_school_term')"
       >
         <v-icon v-if="activeSchoolTerm?.current">$schoolTerm</v-icon>
         <v-icon v-else>mdi-calendar-alert-outline</v-icon>
diff --git a/aleksis/core/frontend/css/global.scss b/aleksis/core/frontend/css/global.scss
index 135927a8572c1c38f805eab2027ad7a9bc58224d..dda4e1193ffe3cb95b51b642ac3e7857d7420ae7 100644
--- a/aleksis/core/frontend/css/global.scss
+++ b/aleksis/core/frontend/css/global.scss
@@ -44,3 +44,10 @@ h6,
   display: flex;
   flex-direction: column;
 }
+
+.focusable:focus,
+.focusable:focus-visible,
+.focusable:focus-within {
+  outline: 2px solid var(--v-primary-base);
+  outline-offset: 4px;
+}
diff --git a/aleksis/core/frontend/messages/en.json b/aleksis/core/frontend/messages/en.json
index 14013b9d550dcbde776eaa10204e769e2a8ef5e5..aec2bae346f92746621bf461173ee72324f73785 100644
--- a/aleksis/core/frontend/messages/en.json
+++ b/aleksis/core/frontend/messages/en.json
@@ -74,6 +74,7 @@
   "actions": {
     "back": "Back",
     "cancel": "Cancel",
+    "clear": "Clear field",
     "clear_filters": "Clear Filters",
     "close": "Close",
     "confirm": "Confirm",
@@ -94,7 +95,16 @@
     "title": "Actions",
     "update": "Update",
     "next": "Next",
-    "type_to_search": "Start typing to search"
+    "type_to_search": "Start typing to search",
+    "toggle_sidenav": "Toggle side navigation panel",
+    "select_school_term": "Open Active School Term Select",
+    "list_notifications": "Open Notification List",
+    "account_menu": "Open account menu",
+    "more_actions": "More actions",
+    "scroll_prev": "Scroll to previous",
+    "scroll_next": "Scroll to next",
+    "open_details": "View details",
+    "open_in_new": "Open in new tab"
   },
   "administration": {
     "backend_admin": {
diff --git a/aleksis/core/frontend/plugins/aleksis.js b/aleksis/core/frontend/plugins/aleksis.js
index 22bf3e16551989d60c26e76ca9e2043d353d22bb..e268fc6c344c31a5720f9378dc68316c633b49c0 100644
--- a/aleksis/core/frontend/plugins/aleksis.js
+++ b/aleksis/core/frontend/plugins/aleksis.js
@@ -54,39 +54,39 @@ AleksisVue.install = function (Vue) {
    * Register all global components that shall be reusable by apps.
    */
   Vue.$registerGlobalComponents = function () {
-    Vue.component(
-      "MessageBox",
-      () => import("../components/generic/MessageBox.vue"),
-    );
-    Vue.component(
-      "SmallContainer",
-      () => import("../components/generic/SmallContainer.vue"),
-    );
-    Vue.component(
-      "BackButton",
-      () => import("../components/generic/BackButton.vue"),
-    );
-    Vue.component(
-      "AvatarClickbox",
-      () => import("../components/generic/AvatarClickbox.vue"),
-    );
-    Vue.component(
-      "DetailView",
-      () => import("../components/generic/DetailView.vue"),
-    );
-    Vue.component(
-      "ListView",
-      () => import("../components/generic/ListView.vue"),
-    );
-    Vue.component(
-      "ButtonMenu",
-      () => import("../components/generic/ButtonMenu.vue"),
-    );
-    Vue.component("ErrorPage", () => import("../components/app/ErrorPage.vue"));
-    Vue.component(
-      "CalendarWithControls",
-      () => import("../components/calendar/CalendarWithControls.vue"),
-    );
+    const globalComponents = {
+      // General stuff
+      AvatarClickbox: "../components/generic/AvatarClickbox.vue",
+      CalendarWithControls: "../components/calendar/CalendarWithControls.vue",
+      ErrorPage: "../components/app/ErrorPage.vue",
+      MessageBox: "../components/generic/MessageBox.vue",
+      SmallContainer: "../components/generic/SmallContainer.vue",
+
+      // Layout
+      DetailView: "../components/generic/DetailView.vue",
+      ListView: "../components/generic/ListView.vue",
+
+      // Buttons:
+      BackButton: "../components/generic/BackButton.vue",
+      ButtonMenu: "../components/generic/ButtonMenu.vue",
+      CancelButton: "../components/generic/buttons/CancelButton.vue",
+      CreateButton: "../components/generic/buttons/CreateButton.vue",
+      DeleteButton: "../components/generic/buttons/DeleteButton.vue",
+      DialogCloseButton: "../components/generic/buttons/DialogCloseButton.vue",
+      EditButton: "../components/generic/buttons/EditButton.vue",
+      FabButton: "../components/generic/buttons/FabButton.vue",
+      FilterButton: "../components/generic/buttons/FilterButton.vue",
+      IconButton: "../components/generic/buttons/IconButton.vue",
+      PrimaryActionButton:
+        "../components/generic/buttons/PrimaryActionButton.vue",
+      SaveButton: "../components/generic/buttons/SaveButton.vue",
+      SecondaryActionButton:
+        "../components/generic/buttons/SecondaryActionButton.vue",
+    };
+
+    for (let [name, path] of Object.entries(globalComponents)) {
+      Vue.component(name, () => import(path));
+    }
   };
 
   /**
diff --git a/aleksis/core/templates/core/vue_index.html b/aleksis/core/templates/core/vue_index.html
index 19e192432ac050d51ca23ed3a13bead25099a346..5f510d39060110baf282c3c9e2823830b144bf9a 100644
--- a/aleksis/core/templates/core/vue_index.html
+++ b/aleksis/core/templates/core/vue_index.html
@@ -3,7 +3,7 @@
 {% get_current_language as LANGUAGE_CODE %}
 <!DOCTYPE html>
 
-<html>
+<html lang="{{ LANGUAGE_CODE }}">
   <head>
     <base href="{{ BASE_URL }}">
 
diff --git a/aleksis/core/util/pdf.py b/aleksis/core/util/pdf.py
index 0cc782008a7388a2664c939bb5f54d8df08b55dc..7660b52c1cd1cf66ec17d5300404277507bc85e8 100644
--- a/aleksis/core/util/pdf.py
+++ b/aleksis/core/util/pdf.py
@@ -40,16 +40,12 @@ def _generate_pdf_with_webdriver(temp_dir, pdf_path, html_url, lang):
     if settings.SELENIUM_URL is None:
         driver_manager = GeckoDriverManager()
         service = FirefoxService(
-            driver_manager.install(),
-            service_args=["--profile-root", temp_dir]
+            driver_manager.install(), service_args=["--profile-root", temp_dir]
         )
 
         driver = webdriver.Firefox(service=service, options=driver_options)
     else:
-        driver = webdriver.Remote(
-            command_executor=settings.SELENIUM_URL,
-            options=driver_options
-        )
+        driver = webdriver.Remote(command_executor=settings.SELENIUM_URL, options=driver_options)
 
     driver.get(html_url)
     print_options = PrintOptions()
diff --git a/pyproject.toml b/pyproject.toml
index f9fbbc216ae6b9531628c11e77dd443fc5a7bd17..3b7bcb3a1dbbb7332d75557fc88e51c90fcea95f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -136,6 +136,7 @@ defusedxml = "^0.7.1"
 graphene-file-upload = "^1.3.0"
 django-countries = "^7.6.1"
 webdriver-manager = "^4.0.2"
+color-contrast = "^0.1.1"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]