Browse Source

'Prefers-contrast' admin theming (#12348)

Co-authored-by: Victoria Ottah <82820329+Toriasdesign@users.noreply.github.com>
Albina 5 months ago
parent
commit
488c3583b7
37 changed files with 287 additions and 21 deletions
  1. 2 1
      CHANGELOG.txt
  2. 1 0
      CONTRIBUTORS.md
  3. 4 0
      client/scss/components/_a11y-result.scss
  4. 3 1
      client/scss/components/_button.scss
  5. 1 5
      client/scss/components/_chooser.scss
  6. 1 1
      client/scss/components/_dropdown.scss
  7. 1 0
      client/scss/components/_filters.scss
  8. 2 1
      client/scss/components/_form-side.scss
  9. 4 0
      client/scss/components/_header.scss
  10. 4 0
      client/scss/components/_messages.scss
  11. 1 0
      client/scss/components/_panel.scss
  12. 4 0
      client/scss/components/_preview-panel.scss
  13. 1 0
      client/scss/components/forms/_input-base.scss
  14. 60 0
      client/scss/tools/_mixins.general.scss
  15. 1 0
      client/src/components/CommentApp/components/Comment/style.scss
  16. 2 0
      client/src/components/CommentApp/components/CommentHeader/style.scss
  17. 14 0
      client/src/components/Draftail/Draftail.scss
  18. 1 0
      client/src/components/Minimap/CollapseAll.scss
  19. 4 0
      client/src/components/Minimap/MinimapItem.scss
  20. 3 0
      client/src/components/Sidebar/Sidebar.scss
  21. 3 1
      client/src/components/Sidebar/Sidebar.tsx
  22. 3 1
      client/src/components/Sidebar/__snapshots__/Sidebar.test.js.snap
  23. 60 0
      client/src/tokens/colorThemes.js
  24. 14 0
      client/src/tokens/colorVariables.test.js
  25. 10 0
      client/src/tokens/colors.js
  26. 7 0
      client/tailwind.config.js
  27. 7 0
      docs/releases/6.3.md
  28. 1 1
      wagtail/admin/forms/account.py
  29. 1 1
      wagtail/admin/templates/wagtailadmin/pages/listing/_page_header_buttons.html
  30. 1 1
      wagtail/admin/templates/wagtailadmin/shared/headers/slim_header.html
  31. 4 1
      wagtail/admin/templates/wagtailadmin/shared/page_status_tag_new.html
  32. 7 1
      wagtail/admin/templatetags/wagtailadmin_tags.py
  33. 17 1
      wagtail/admin/tests/test_account_management.py
  34. 2 2
      wagtail/admin/tests/test_audit_log.py
  35. 2 2
      wagtail/admin/tests/test_views.py
  36. 23 0
      wagtail/users/migrations/0014_userprofile_contrast.py
  37. 11 0
      wagtail/users/models.py

+ 2 - 1
CHANGELOG.txt

@@ -20,8 +20,9 @@ Changelog
  * Fire `copy_for_translation_done` signal when copying translatable models as well as pages (Coen van der Kamp)
  * Add support for an image `description` field across all images, to better support accessible image descriptions (Chiemezuo Akujobi)
  * Prompt the user about unsaved changes when editing snippets (Sage Abdullah)
- * Implement incremental dashboard design enhancements (Albina Starykova)
+ * Implement incremental dashboard design enhancements (Albina Starykova, Ben Enright)
  * Add support for specifying different preview modes to the "View draft" URL for pages (Robin Varghese)
+ * Add a new enhanced contrast admin theming option for the admin interface (Albina Starykova, Victoria Ottah)
  * Fix: Prevent page type business rules from blocking reordering of pages (Andy Babic, Sage Abdullah)
  * Fix: Improve layout of object permissions table (Sage Abdullah)
  * Fix: Fix typo in aria-label attribute of page explorer navigation link (Sébastien Corbin)

+ 1 - 0
CONTRIBUTORS.md

@@ -840,6 +840,7 @@
 * Gabriel Getzie
 * Rohit Singh
 * Robin Varghese
+* Victoria Ottah
 
 ## Translators
 

+ 4 - 0
client/scss/components/_a11y-result.scss

@@ -8,6 +8,10 @@
   .form-side--checks & {
     display: block;
   }
+
+  @include more-contrast() {
+    border-color: theme('colors.border-furniture-more-contrast');
+  }
 }
 
 .w-a11y-result__header {

+ 3 - 1
client/scss/components/_button.scss

@@ -321,11 +321,13 @@
 }
 
 .w-header-button {
+  @include more-contrast-interactive();
   display: flex;
   align-items: center;
   justify-content: center;
   gap: theme('spacing.1');
-  height: theme('spacing.7');
+  height: theme('spacing.8');
+  min-width: theme('spacing.8');
   appearance: none;
   background-color: initial;
   border: 1px solid transparent;

+ 1 - 5
client/scss/components/_chooser.scss

@@ -15,6 +15,7 @@ $preview-size: 2.625rem; // 42px
 // Very subdued button style specifically for choosers, as there can be a lot of
 // chooser fields left unused on a page editing form.
 .button.chooser__choose-button {
+  @include more-contrast-interactive();
   @apply w-label-3;
   display: flex;
   align-items: center;
@@ -81,11 +82,6 @@ $preview-size: 2.625rem; // 42px
   width: auto;
 }
 
-.chooser .w-dropdown__toggle--icon {
-  width: $preview-size;
-  height: $preview-size;
-}
-
 // Display these as inline block so that action icons such as comments can appear as close as possible
 .w-field--admin_task_chooser,
 .w-field--admin_page_chooser,

+ 1 - 1
client/scss/components/_dropdown.scss

@@ -3,7 +3,7 @@
 }
 
 .w-dropdown__toggle--icon {
-  @apply w-w-8 w-h-8;
+  @apply w-w-8 w-h-8 more-contrast:w-p-0 more-contrast:w-border more-contrast:w-rounded-sm more-contrast:w-border-border-interactive-more-contrast hover:more-contrast:w-border-border-interactive-more-contrast-hover;
 }
 
 .w-dropdown__toggle-icon {

+ 1 - 0
client/scss/components/_filters.scss

@@ -25,6 +25,7 @@
 }
 
 .w-filter-button {
+  @include more-contrast-interactive();
   position: relative;
   width: theme('spacing.10');
   height: theme('spacing.10');

+ 2 - 1
client/scss/components/_form-side.scss

@@ -36,7 +36,8 @@
       sm:w-max-w-[22.5rem]
       md:w-max-w-[35.937rem]
       lg:w-max-w-[31.25rem]
-      xl:w-max-w-[46.875rem];
+      xl:w-max-w-[46.875rem]
+      more-contrast:w-border-border-furniture-more-contrast;
   z-index: calc(theme('zIndex.header') - 10);
   width: var(--side-panel-width, 100%);
 

+ 4 - 0
client/scss/components/_header.scss

@@ -151,6 +151,10 @@
 }
 
 .w-slim-header {
+  @include more-contrast() {
+    border-color: theme('colors.border-furniture-more-contrast');
+  }
+
   &__search-form {
     @apply w-mx-2 w-flex w-items-center w-gap-2;
   }

+ 4 - 0
client/scss/components/_messages.scss

@@ -58,6 +58,10 @@
   .warning {
     background-color: theme('colors.warning.100');
     color: theme('colors.grey.600');
+
+    @include more-contrast() {
+      background-color: theme('colors.warning.75');
+    }
   }
 
   .info {

+ 1 - 0
client/scss/components/_panel.scss

@@ -61,6 +61,7 @@ $header-button-size: theme('spacing.6');
 .w-panel__toggle,
 .w-panel__controls .button.button--icon {
   @include show-focus-outline-inside();
+  @include more-contrast-interactive();
   display: inline-grid;
   justify-content: center;
   align-content: center;

+ 4 - 0
client/scss/components/_preview-panel.scss

@@ -59,6 +59,10 @@
     gap: 0.75rem;
     padding-bottom: 1rem;
     margin-bottom: 1rem;
+
+    @include more-contrast() {
+      border-color: theme('colors.border-furniture-more-contrast');
+    }
   }
 
   &__size-button {

+ 1 - 0
client/scss/components/forms/_input-base.scss

@@ -3,6 +3,7 @@
  * Text input, textarea, checkbox, radio, select, etc.
  */
 @mixin input-base() {
+  @include more-contrast-interactive();
   appearance: none;
   border-radius: theme('borderRadius.DEFAULT');
   color: theme('colors.text-context');

+ 60 - 0
client/scss/tools/_mixins.general.scss

@@ -93,6 +93,21 @@
   }
 }
 
+/**
+ * Apply styles for enhanced contrast theming.
+ */
+@mixin more-contrast() {
+  .w-contrast-more & {
+    @content;
+  }
+
+  @media (prefers-contrast: more) {
+    .w-contrast-system & {
+      @content;
+    }
+  }
+}
+
 /**
  * Apply styles for the light theme only.
  */
@@ -107,3 +122,48 @@
     }
   }
 }
+
+/**
+ * Apply styles for the dark theme with increased contrast.
+ */
+@mixin dark-theme-more-contrast() {
+  .w-theme-dark.w-contrast-more & {
+    @content;
+  }
+
+  @media (prefers-color-scheme: dark) {
+    .w-theme-system.w-contrast-more & {
+      @content;
+    }
+  }
+
+  @media (prefers-contrast: more) {
+    .w-theme-dark.w-contrast-system & {
+      @content;
+    }
+  }
+
+  @media (prefers-color-scheme: dark) and (prefers-contrast: more) {
+    .w-theme-system.w-contrast-system & {
+      @content;
+    }
+  }
+}
+
+/**
+* Increased contrast theme styles for interactive components
+*/
+@mixin more-contrast-interactive() {
+  @include more-contrast() {
+    border: 1px solid theme('colors.border-interactive-more-contrast');
+
+    &:hover {
+      border-color: theme('colors.border-interactive-more-contrast-hover');
+    }
+
+    &[disabled],
+    &[disabled]:hover {
+      border-style: dashed;
+    }
+  }
+}

+ 1 - 0
client/src/components/CommentApp/components/Comment/style.scss

@@ -1,5 +1,6 @@
 .comment {
   @include box;
+  @include more-contrast-interactive();
 
   width: 300px;
   display: block;

+ 2 - 0
client/src/components/CommentApp/components/CommentHeader/style.scss

@@ -40,6 +40,8 @@
 
     > button,
     > details > summary {
+      @include more-contrast-interactive();
+      border-radius: theme('borderRadius.sm');
       list-style-type: none; // Hides triangle on Firefox
       width: 30px;
       height: 30px;

+ 14 - 0
client/src/components/Draftail/Draftail.scss

@@ -101,6 +101,11 @@ $draftail-editor-font-family: theme('fontFamily.sans');
   background-color: $draftail-editor-background;
   color: $draftail-placeholder-text;
 
+  &--pin {
+    display: flex;
+    flex-wrap: wrap;
+  }
+
   .Draftail-Editor--focus & {
     color: $draftail-editor-text;
     top: calc(theme('spacing.slim-header') * 2);
@@ -205,11 +210,19 @@ $draftail-editor-font-family: theme('fontFamily.sans');
   }
 }
 
+.Draftail-ToolbarGroup {
+  display: flex;
+}
+
 .Draftail-ToolbarGroup::before {
   display: none;
 }
 
 .Draftail-ToolbarButton {
+  @include more-contrast-interactive();
+  display: flex;
+  align-items: center;
+  justify-content: center;
   height: 1.875rem;
   min-width: 1.875rem;
   padding: 0;
@@ -257,6 +270,7 @@ $draftail-editor-font-family: theme('fontFamily.sans');
   .Draftail-Toolbar & {
     border-color: theme('colors.border-field-default');
     background-color: theme('colors.surface-page');
+    color: currentColor;
     border-top-width: 0;
     border-inline-end-width: 0;
 

+ 1 - 0
client/src/components/Minimap/CollapseAll.scss

@@ -1,4 +1,5 @@
 .w-minimap__collapse-all {
+  @include more-contrast-interactive();
   display: none;
   // Keep the icon at a stable position and reduce the amount of shifting of the button.
   min-width: 110px;

+ 4 - 0
client/src/components/Minimap/MinimapItem.scss

@@ -35,6 +35,10 @@
     @media (forced-colors: active) {
       border-inline-start-width: 3px;
     }
+
+    @include more-contrast() {
+      border-inline-start-width: 3px;
+    }
   }
 
   &:hover {

+ 3 - 0
client/src/components/Sidebar/Sidebar.scss

@@ -45,6 +45,9 @@ $sidebar-toggle-size: 35px;
   @media (forced-colors: active) {
     border-inline-end: 1px solid transparent;
   }
+  @include dark-theme-more-contrast() {
+    border-inline-end: 1px solid theme('colors.border-furniture-more-contrast');
+  }
 
   .icon--menuitem {
     width: 1rem;

+ 3 - 1
client/src/components/Sidebar/Sidebar.tsx

@@ -230,7 +230,9 @@ export const Sidebar: React.FunctionComponent<SidebarProps> = ({
                 w-items-center
                 hover:w-bg-surface-menu-item-active
                 hover:text-white
-                hover:opacity-100`}
+                hover:opacity-100
+                more-contrast:w-border-border-interactive-more-contrast-dark-bg
+                hover:more-contrast:w-border-border-interactive-more-contrast-dark-bg-hover`}
             >
               <Icon
                 name="expand-right"

+ 3 - 1
client/src/components/Sidebar/__snapshots__/Sidebar.test.js.snap

@@ -35,7 +35,9 @@ exports[`Sidebar should render with the minimum required props 1`] = `
                 w-items-center
                 hover:w-bg-surface-menu-item-active
                 hover:text-white
-                hover:opacity-100"
+                hover:opacity-100
+                more-contrast:w-border-border-interactive-more-contrast-dark-bg
+                hover:more-contrast:w-border-border-interactive-more-contrast-dark-bg-hover"
           onClick={[Function]}
           type="button"
         >

+ 60 - 0
client/src/tokens/colorThemes.js

@@ -295,6 +295,36 @@ const light = [
         textUtility: 'w-text-border-button-outline-hover',
         cssVariable: '--w-color-border-button-outline-hover',
       },
+      'border-interactive-more-contrast': {
+        value: 'var(--w-color-grey-500)',
+        bgUtility: 'w-bg-border-interactive-more-contrast',
+        textUtility: 'w-text-border-interactive-more-contrast',
+        cssVariable: '--w-color-border-interactive-more-contrast',
+      },
+      'border-interactive-more-contrast-hover': {
+        value: 'var(--w-color-black)',
+        bgUtility: 'w-bg-border-interactive-more-contrast-hover',
+        textUtility: 'w-text-border-interactive-more-contrast-hover',
+        cssVariable: '--w-color-border-interactive-more-contrast-hover',
+      },
+      'border-interactive-more-contrast-dark-bg': {
+        value: 'var(--w-color-grey-150)',
+        bgUtility: 'w-bg-border-interactive-more-contrast-dark-bg',
+        textUtility: 'w-text-border-interactive-more-contrast-dark-bg',
+        cssVariable: '--w-color-border-interactive-more-contrast-dark-bg',
+      },
+      'border-interactive-more-contrast-dark-bg-hover': {
+        value: 'var(--w-color-white)',
+        bgUtility: 'w-bg-border-interactive-more-contrast-dark-bg-hover',
+        textUtility: 'w-text-border-interactive-more-contrast-dark-bg-hover',
+        cssVariable: '--w-color-border-interactive-more-contrast-dark-bg-hover',
+      },
+      'border-furniture-more-contrast': {
+        value: 'var(--w-color-grey-200)',
+        bgUtility: 'w-bg-border-furniture-more-contrast',
+        textUtility: 'w-text-border-furniture-more-contrast',
+        cssVariable: '--w-color-border-furniture-more-contrast',
+      },
     },
   },
   {
@@ -583,6 +613,36 @@ const dark = [
         textUtility: 'w-text-border-button-outline-hover',
         cssVariable: '--w-color-border-button-outline-hover',
       },
+      'border-interactive-more-contrast': {
+        value: 'var(--w-color-grey-150)',
+        bgUtility: 'w-bg-border-interactive-more-contrast',
+        textUtility: 'w-text-border-interactive-more-contrast',
+        cssVariable: '--w-color-border-interactive-more-contrast',
+      },
+      'border-interactive-more-contrast-hover': {
+        value: 'var(--w-color-white)',
+        bgUtility: 'w-bg-border-interactive-more-contrast-hover',
+        textUtility: 'w-text-border-interactive-more-contrast-hover',
+        cssVariable: '--w-color-border-interactive-more-contrast-hover',
+      },
+      'border-interactive-more-contrast-dark-bg': {
+        value: 'var(--w-color-grey-150)',
+        bgUtility: 'w-bg-border-interactive-more-contrast-dark-bg',
+        textUtility: 'w-text-border-interactive-more-contrast-dark-bg',
+        cssVariable: '--w-color-border-interactive-more-contrast-dark-bg',
+      },
+      'border-interactive-more-contrast-dark-bg-hover': {
+        value: 'var(--w-color-white)',
+        bgUtility: 'w-bg-border-interactive-more-contrast-dark-bg-hover',
+        textUtility: 'w-text-border-interactive-more-contrast-dark-bg-hover',
+        cssVariable: '--w-color-border-interactive-more-contrast-dark-bg-hover',
+      },
+      'border-furniture-more-contrast': {
+        value: 'var(--w-color-grey-400)',
+        bgUtility: 'w-bg-border-furniture-more-contrast',
+        textUtility: 'w-text-border-furniture-more-contrast',
+        cssVariable: '--w-color-border-furniture-more-contrast',
+      },
     },
   },
   {

+ 14 - 0
client/src/tokens/colorVariables.test.js

@@ -143,6 +143,10 @@ describe('generateColorVariables', () => {
         "--w-color-warning-50-hue": "calc(var(--w-color-warning-100-hue) - 2.3)",
         "--w-color-warning-50-lightness": "calc(var(--w-color-warning-100-lightness) + 41.8%)",
         "--w-color-warning-50-saturation": "calc(var(--w-color-warning-100-saturation) - 21.3%)",
+        "--w-color-warning-75": "hsl(var(--w-color-warning-75-hue) var(--w-color-warning-75-saturation) var(--w-color-warning-75-lightness))",
+        "--w-color-warning-75-hue": "calc(var(--w-color-warning-100-hue) + 0.7)",
+        "--w-color-warning-75-lightness": "calc(var(--w-color-warning-100-lightness) + 23.4%)",
+        "--w-color-warning-75-saturation": "calc(var(--w-color-warning-100-saturation) - 2.8%)",
         "--w-color-white": "hsl(var(--w-color-white-hue) var(--w-color-white-saturation) var(--w-color-white-lightness))",
         "--w-color-white-hue": "0",
         "--w-color-white-lightness": "100%",
@@ -200,6 +204,11 @@ describe('generateThemeColorVariables', () => {
         "--w-color-border-field-hover": "var(--w-color-grey-200)",
         "--w-color-border-field-inactive": "var(--w-color-grey-150)",
         "--w-color-border-furniture": "var(--w-color-grey-100)",
+        "--w-color-border-furniture-more-contrast": "var(--w-color-grey-200)",
+        "--w-color-border-interactive-more-contrast": "var(--w-color-grey-500)",
+        "--w-color-border-interactive-more-contrast-dark-bg": "var(--w-color-grey-150)",
+        "--w-color-border-interactive-more-contrast-dark-bg-hover": "var(--w-color-white)",
+        "--w-color-border-interactive-more-contrast-hover": "var(--w-color-black)",
         "--w-color-box-shadow-md": "var(--w-color-black-25)",
         "--w-color-focus": "#00A885",
         "--w-color-icon-primary": "var(--w-color-primary)",
@@ -252,6 +261,11 @@ describe('generateThemeColorVariables', () => {
         "--w-color-border-field-hover": "var(--w-color-grey-200)",
         "--w-color-border-field-inactive": "var(--w-color-grey-500)",
         "--w-color-border-furniture": "var(--w-color-grey-500)",
+        "--w-color-border-furniture-more-contrast": "var(--w-color-grey-400)",
+        "--w-color-border-interactive-more-contrast": "var(--w-color-grey-150)",
+        "--w-color-border-interactive-more-contrast-dark-bg": "var(--w-color-grey-150)",
+        "--w-color-border-interactive-more-contrast-dark-bg-hover": "var(--w-color-white)",
+        "--w-color-border-interactive-more-contrast-hover": "var(--w-color-white)",
         "--w-color-box-shadow-md": "var(--w-color-black-50)",
         "--w-color-focus": "#00A885",
         "--w-color-icon-primary": "var(--w-color-grey-150)",

+ 10 - 0
client/src/tokens/colors.js

@@ -268,6 +268,16 @@ const staticColors = {
       usage: 'Background and icons for potentially dangerous states',
       contrastText: 'primary',
     },
+    75: {
+      hex: '#FDD074',
+      hsl: 'hsl(40.3, 97.2%, 72.4%)',
+      bgUtility: 'w-bg-warning-75',
+      textUtility: 'w-text-warning-75',
+      cssVariable: '--w-color-warning-75',
+      usage:
+        'Background only, for potentially dangerous states, in enhanced-contrast theme',
+      contrastText: 'primary',
+    },
     50: {
       hex: '#FFF5D8',
       hsl: 'hsl(37.3 78.7% 90.8%)',

+ 7 - 0
client/tailwind.config.js

@@ -190,6 +190,13 @@ module.exports = {
     plugin(({ addVariant }) => {
       addVariant('expanded', '&[aria-expanded=true]');
     }),
+    /** Support for increased contrast theme */
+    plugin(({ addVariant }) => {
+      addVariant('more-contrast', [
+        '.contrast-more &',
+        '@media (prefers-contrast: more) { .contrast-system & }',
+      ]);
+    }),
   ],
   corePlugins: {
     ...vanillaRTL.disabledCorePlugins,

+ 7 - 0
docs/releases/6.3.md

@@ -25,6 +25,13 @@ The Wagtail dashboard design evolves towards providing more information and navi
 
 This feature was developed by Albina Starykova based on designs by Ben Enright.
 
+### Enhanced contrast admin theme
+
+CMS users can now control the level of contrast of UI elements in the admin interface.
+This new customization is designed for partially sighted users, complementing existing support for a dark theme and Windows Contrast Themes.
+The new "More contrast" theming can be enabled in account preferences, or will otherwise be derived from operating system preferences.
+
+This feature was designed thanks to feedback from our blind and partially sighted users, and was developed by Albina Starykova based on design input from Victoria Ottah.
 
 ### Other features
 

+ 1 - 1
wagtail/admin/forms/account.py

@@ -140,4 +140,4 @@ class AvatarPreferencesForm(forms.ModelForm):
 class ThemePreferencesForm(forms.ModelForm):
     class Meta:
         model = UserProfile
-        fields = ["theme", "density"]
+        fields = ["theme", "contrast", "density"]

+ 1 - 1
wagtail/admin/templates/wagtailadmin/pages/listing/_page_header_buttons.html

@@ -2,7 +2,7 @@
 
 {% trans "Actions" as title %}
 <nav aria-label="{{ title }}">
-    {% dropdown toggle_icon="dots-horizontal" toggle_aria_label=title toggle_classname="w-p-0 w-w-8 w-h-slim-header hover:w-scale-110 w-transition w-outline-offset-inside w-relative w-z-30" toggle_tooltip_offset="[0, -2]" %}
+    {% dropdown toggle_icon="dots-horizontal" toggle_aria_label=title toggle_classname="w-p-0 w-outline-offset-inside w-relative w-z-30" toggle_tooltip_offset="[0, -2]" %}
         {% block content %}
             {% include "wagtailadmin/pages/listing/_dropdown_items.html" with buttons=buttons only %}
         {% endblock %}

+ 1 - 1
wagtail/admin/templates/wagtailadmin/shared/headers/slim_header.html

@@ -19,7 +19,7 @@
 
 {% endcomment %}
 {% fragment as nav_icon_classes %}w-w-4 w-h-4 group-hover:w-transform group-hover:w-scale-110{% endfragment %}
-{% fragment as nav_icon_button_classes %}w-w-slim-header w-h-slim-header w-bg-transparent w-border-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-text-meta w-transition w-group hover:w-text-text-label focus:w-text-text-label expanded:w-text-text-label expanded:w-border-y-2 expanded:w-border-b-current w-shrink-0{% endfragment %}
+{% fragment as nav_icon_button_classes %}w-w-slim-header w-h-slim-header w-bg-transparent w-border-transparent w-box-border w-py-3 w-px-3 w-flex w-justify-center w-items-center w-outline-offset-inside w-text-text-meta w-transition w-group hover:w-text-text-label focus:w-text-text-label expanded:w-text-text-label expanded:w-border-y-2 expanded:w-border-b-current w-shrink-0 more-contrast:w-border more-contrast:w-border-border-interactive-more-contrast hover:more-contrast:w-border-border-interactive-more-contrast-hover{% endfragment %}
 {% fragment as nav_icon_counter_classes %}-w-mr-3 w-py-0.5 w-px-[0.325rem] w-translate-y-[-8px] rtl:w-translate-x-[4px] w-translate-x-[-4px] w-text-[0.5625rem] w-font-bold w-text-text-button w-border w-border-surface-page w-rounded-[1rem]{% endfragment %}
 {# Z index 99 to ensure header is always above  #}
 <div class="w-sticky w-top-0 w-z-header">

+ 4 - 1
wagtail/admin/templates/wagtailadmin/shared/page_status_tag_new.html

@@ -31,7 +31,10 @@
               w-font-semibold
               hover:w-border-surface-menus
               hover:w-text-text-label
-              w-transition"
+              w-transition
+              more-contrast:w-border
+              more-contrast:w-border-border-interactive-more-contrast
+              hover:more-contrast:w-border-border-interactive-more-contrast-hover"
        aria-label="{% if is_public %}{% trans 'Visible to all. Visit the live page' %}{% else %}{% trans 'Private. Visit the live page' %}{% endif %}"
        data-controller="w-tooltip"
        data-w-tooltip-content-value="{% if is_public %}{% trans 'Visible to all' %}{% else %}{% trans 'Private' %}{% endif %}"

+ 7 - 1
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -683,12 +683,18 @@ def admin_theme_classname(context):
         if hasattr(user, "wagtail_userprofile")
         else "system"
     )
+    contrast_name = (
+        user.wagtail_userprofile.contrast
+        if hasattr(user, "wagtail_userprofile")
+        else "system"
+    )
     density_name = (
         user.wagtail_userprofile.density
         if hasattr(user, "wagtail_userprofile")
         else "default"
     )
-    return f"w-theme-{theme_name} w-density-{density_name}"
+    contrast_name = contrast_name.split("_")[0]
+    return f"w-theme-{theme_name} w-density-{density_name} w-contrast-{contrast_name}"
 
 
 @register.simple_tag

+ 17 - 1
wagtail/admin/tests/test_account_management.py

@@ -232,6 +232,7 @@ class TestAccountSectionUtilsMixin:
             "locale-current_time_zone": "Europe/London",
             "theme-theme": "dark",
             "theme-density": "default",
+            "theme-contrast": "system",
         }
         post_data.update(extra_post_data)
         return self.client.post(reverse("wagtailadmin_account"), post_data)
@@ -480,7 +481,7 @@ class TestAccountSection(WagtailTestUtils, TestCase, TestAccountSectionUtilsMixi
         response = self.client.get(reverse("wagtailadmin_home"))
         self.assertContains(
             response,
-            '<html lang="es" dir="ltr" class="w-theme-dark w-density-default">',
+            '<html lang="es" dir="ltr" class="w-theme-dark w-density-default w-contrast-system">',
         )
 
     def test_unset_language_preferences(self):
@@ -610,6 +611,21 @@ class TestAccountSection(WagtailTestUtils, TestCase, TestAccountSectionUtilsMixi
 
         self.assertEqual(profile.theme, "light")
 
+    def test_change_contrast_post(self):
+        response = self.post_form(
+            {
+                "theme-contrast": "more_contrast",
+            }
+        )
+
+        # Check that the user was redirected to the account page
+        self.assertRedirects(response, reverse("wagtailadmin_account"))
+
+        profile = UserProfile.get_for_user(self.user)
+        profile.refresh_from_db()
+
+        self.assertEqual(profile.contrast, "more_contrast")
+
     def test_change_density_post(self):
         response = self.post_form(
             {

+ 2 - 2
wagtail/admin/tests/test_audit_log.py

@@ -144,8 +144,8 @@ class TestAuditLogAdmin(AdminTemplateTestUtils, WagtailTestUtils, TestCase):
         )
 
         self.assertContains(
-            response, "system", 3
-        )  # create without a user + remove restriction + 1 from unrelated admin color theme
+            response, "system", 4
+        )  # create without a user + remove restriction + 2 from unrelated admin color theme
         self.assertContains(
             response, "the_editor", 9
         )  # 7 entries by editor + 1 in sidebar menu + 1 in filter

+ 2 - 2
wagtail/admin/tests/test_views.py

@@ -81,7 +81,7 @@ class TestLoginView(WagtailTestUtils, TestCase):
         response = self.client.get(reverse("wagtailadmin_login"))
         self.assertContains(
             response,
-            '<html lang="de" dir="ltr" class="w-theme-system w-density-default">',
+            '<html lang="de" dir="ltr" class="w-theme-system w-density-default w-contrast-system">',
         )
 
     @override_settings(LANGUAGE_CODE="he")
@@ -89,7 +89,7 @@ class TestLoginView(WagtailTestUtils, TestCase):
         response = self.client.get(reverse("wagtailadmin_login"))
         self.assertContains(
             response,
-            '<html lang="he" dir="rtl" class="w-theme-system w-density-default">',
+            '<html lang="he" dir="rtl" class="w-theme-system w-density-default w-contrast-system">',
         )
 
     @override_settings(

+ 23 - 0
wagtail/users/migrations/0014_userprofile_contrast.py

@@ -0,0 +1,23 @@
+# Generated by Django 5.0.6 on 2024-09-23 15:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("wagtailusers", "0013_userprofile_density"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userprofile",
+            name="contrast",
+            field=models.CharField(
+                choices=[("system", "System default"), ("more_contrast", "More contrast")],
+                default="system",
+                max_length=40,
+                verbose_name="contrast",
+            ),
+        ),
+    ]

+ 11 - 0
wagtail/users/models.py

@@ -84,6 +84,17 @@ class UserProfile(models.Model):
         max_length=40,
     )
 
+    class AdminContrastThemes(models.TextChoices):
+        SYSTEM = "system", _("System default")
+        MORE_CONTRAST = "more_contrast", _("More contrast")
+
+    contrast = models.CharField(
+        verbose_name=_("contrast"),
+        choices=AdminContrastThemes.choices,
+        default=AdminContrastThemes.SYSTEM,
+        max_length=40,
+    )
+
     class AdminDensityThemes(models.TextChoices):
         DEFAULT = "default", _("Default")
         SNUG = "snug", _("Snug")