aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2023-10-27 16:12:29 +0200
committerGitHub <noreply@github.com>2023-10-27 16:12:29 +0200
commit32eaf57e015089fbb698d804fee8e18b43f81b2b (patch)
treebb014aab8a1515b03f177067103bcfc5f6449f83
parent66f76396906548fdcab99ed9e83eb65bddc5921f (diff)
parent3378a73e99171ac31860ccca81b1b3197e63417f (diff)
downloadnextcloud-server-32eaf57e015089fbb698d804fee8e18b43f81b2b.tar.gz
nextcloud-server-32eaf57e015089fbb698d804fee8e18b43f81b2b.zip
Merge pull request #40773 from nextcloud/fix/contrast-maxcontrast-vs-hover
fix(theming): Ensure all text colors have enough contrast for accessibility
-rw-r--r--apps/theming/__tests__/accessibility.cy.ts128
-rw-r--r--apps/theming/css/default.css38
-rw-r--r--apps/theming/lib/Service/BackgroundService.php2
-rw-r--r--apps/theming/lib/Themes/CommonThemeTrait.php6
-rw-r--r--apps/theming/lib/Themes/DarkTheme.php4
-rw-r--r--apps/theming/lib/Themes/DefaultTheme.php9
-rw-r--r--apps/theming/tests/Themes/DefaultThemeTest.php2
-rw-r--r--cypress/e2e/theming/themingUtils.ts2
-rw-r--r--cypress/support/component.ts2
-rw-r--r--package-lock.json68
-rw-r--r--package.json4
-rw-r--r--tsconfig.json2
12 files changed, 235 insertions, 32 deletions
diff --git a/apps/theming/__tests__/accessibility.cy.ts b/apps/theming/__tests__/accessibility.cy.ts
new file mode 100644
index 00000000000..3bbf8a64972
--- /dev/null
+++ b/apps/theming/__tests__/accessibility.cy.ts
@@ -0,0 +1,128 @@
+// eslint-disable-next-line import/no-webpack-loader-syntax, import/no-unresolved
+import style from '!raw-loader!../css/default.css'
+
+const testCases = {
+ 'Main text': {
+ foregroundColors: [
+ 'color-main-text',
+ // 'color-text-light', deprecated
+ // 'color-text-lighter', deprecated
+ 'color-text-maxcontrast',
+ 'color-text-maxcontrast-default',
+ ],
+ backgroundColors: [
+ 'color-background-main',
+ 'color-background-hover',
+ 'color-background-dark',
+ // 'color-background-darker', this should only be used for elements not for text
+ ],
+ },
+ Primary: {
+ foregroundColors: [
+ 'color-primary-text',
+ ],
+ backgroundColors: [
+ // 'color-primary-default', this should only be used for elements not for text!
+ // 'color-primary-hover', this should only be used for elements and not for text!
+ 'color-primary',
+ ],
+ },
+ 'Primary light': {
+ foregroundColors: [
+ 'color-primary-light-text',
+ ],
+ backgroundColors: [
+ 'color-primary-light',
+ 'color-primary-light-hover',
+ ],
+ },
+ 'Primary element': {
+ foregroundColors: [
+ 'color-primary-element-text',
+ 'color-primary-element-text-dark',
+ ],
+ backgroundColors: [
+ 'color-primary-element',
+ 'color-primary-element-hover',
+ ],
+ },
+ 'Primary element light': {
+ foregroundColors: [
+ 'color-primary-element-light-text',
+ ],
+ backgroundColors: [
+ 'color-primary-element-light',
+ 'color-primary-element-light-hover',
+ ],
+ },
+ 'Servity information texts': {
+ foregroundColors: [
+ 'color-error-text',
+ 'color-warning-text',
+ 'color-success-text',
+ 'color-info-text',
+ ],
+ backgroundColors: [
+ 'color-background-main',
+ 'color-background-hover',
+ ],
+ },
+}
+
+/**
+ * Create a wrapper element with color and background set
+ *
+ * @param foreground The foreground color (css variable without leading --)
+ * @param background The background color
+ */
+function createTestCase(foreground: string, background: string) {
+ const wrapper = document.createElement('div')
+ wrapper.innerText = `${foreground} ${background}`
+ wrapper.style.color = `var(--${foreground})`
+ wrapper.style.backgroundColor = `var(--${background})`
+ wrapper.style.padding = '4px'
+ wrapper.setAttribute('data-cy-testcase', '')
+ return wrapper
+}
+
+describe('Accessibility of Nextcloud theming', () => {
+ before(() => {
+ cy.injectAxe()
+
+ const el = document.createElement('style')
+ el.innerText = style
+ document.head.appendChild(el)
+ })
+
+ beforeEach(() => {
+ cy.document().then(doc => {
+ const root = doc.querySelector('[data-cy-root]')
+ if (root === null) {
+ throw new Error('No test root found')
+ }
+ for (const child of root.children) {
+ root.removeChild(child)
+ }
+ })
+ })
+
+ for (const [name, { backgroundColors, foregroundColors }] of Object.entries(testCases)) {
+ context(`Accessibility of CSS color variables for ${name}`, () => {
+ for (const foreground of foregroundColors) {
+ for (const background of backgroundColors) {
+ it(`color contrast of ${foreground} on ${background}`, () => {
+ const element = createTestCase(foreground, background)
+ cy.document().then(doc => {
+ const root = doc.querySelector('[data-cy-root]')
+ // eslint-disable-next-line no-unused-expressions
+ expect(root).not.to.be.undefined
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ root!.appendChild(element)
+ cy.checkA11y('[data-cy-testcase]')
+ })
+ })
+ }
+ }
+ })
+ }
+})
diff --git a/apps/theming/css/default.css b/apps/theming/css/default.css
index 05e21e79b96..3e36e03f894 100644
--- a/apps/theming/css/default.css
+++ b/apps/theming/css/default.css
@@ -6,16 +6,20 @@
--filter-background-blur: blur(25px);
--gradient-main-background: var(--color-main-background) 0%, var(--color-main-background-translucent) 85%, transparent 100%;
--color-background-hover: #f5f5f5;
+ /** Can be used e.g. to colorize selected table rows */
--color-background-dark: #ededed;
+ /** This should only be used for elements, not as a text background! Otherwise it will not work for accessibility. */
--color-background-darker: #dbdbdb;
--color-placeholder-light: #e6e6e6;
--color-placeholder-dark: #cccccc;
--color-main-text: #222222;
- --color-text-maxcontrast: #767676;
- --color-text-maxcontrast-default: #767676;
- --color-text-maxcontrast-background-blur: #646464;
- --color-text-light: #222222;
- --color-text-lighter: #767676;
+ --color-text-maxcontrast: #6b6b6b;
+ --color-text-maxcontrast-default: #6b6b6b;
+ --color-text-maxcontrast-background-blur: #595959;
+ /** @deprecated use ` --color-main-text` instead */
+ --color-text-light: var(--color-main-text);
+ /** @deprecated use `--color-text-maxcontrast` instead */
+ --color-text-lighter: var(--color-text-maxcontrast);
--color-scrollbar: rgba(34,34,34, .15);
--color-error: #d91812;
--color-error-rgb: 217,24,18;
@@ -24,7 +28,7 @@
--color-warning: #c28900;
--color-warning-rgb: 194,137,0;
--color-warning-hover: #cea032;
- --color-warning-text: #996c00;
+ --color-warning-text: #8f6500;
--color-success: #2d7b41;
--color-success-rgb: 45,123,65;
--color-success-hover: #448955;
@@ -64,20 +68,20 @@
--background-invert-if-bright: invert(100%);
--background-image-invert-if-bright: no;
--primary-invert-if-bright: no;
- --color-primary: #006aa3;
+ --color-primary: #00679e;
--color-primary-default: #0082c9;
--color-primary-text: #ffffff;
- --color-primary-hover: #3287b5;
- --color-primary-light: #e5f0f5;
- --color-primary-light-text: #002a41;
- --color-primary-light-hover: #dbe5ea;
- --color-primary-element: #006aa3;
- --color-primary-element-hover: #1f7cae;
+ --color-primary-hover: #3285b1;
+ --color-primary-light: #e5eff5;
+ --color-primary-light-text: #00293f;
+ --color-primary-light-hover: #dbe4ea;
+ --color-primary-element: #00679e;
+ --color-primary-element-hover: #1674a6;
--color-primary-element-text: #ffffff;
- --color-primary-element-light: #e5f0f5;
- --color-primary-element-light-hover: #dbe5ea;
- --color-primary-element-light-text: #002a41;
- --color-primary-element-text-dark: #ededed;
+ --color-primary-element-text-dark: #f0f0f0;
+ --color-primary-element-light: #e5eff5;
+ --color-primary-element-light-hover: #dbe4ea;
+ --color-primary-element-light-text: #00293f;
--gradient-primary-background: linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
--image-background-default: url('/apps/theming/img/background/kamil-porembinski-clouds.jpg');
--color-background-plain: #0082c9;
diff --git a/apps/theming/lib/Service/BackgroundService.php b/apps/theming/lib/Service/BackgroundService.php
index d5bc4296b5b..c385bc7b072 100644
--- a/apps/theming/lib/Service/BackgroundService.php
+++ b/apps/theming/lib/Service/BackgroundService.php
@@ -46,7 +46,7 @@ class BackgroundService {
// true when the background is bright and need dark icons
public const THEMING_MODE_DARK = 'dark';
public const DEFAULT_COLOR = '#0082c9';
- public const DEFAULT_ACCESSIBLE_COLOR = '#006aa3';
+ public const DEFAULT_ACCESSIBLE_COLOR = '#00679e';
public const BACKGROUND_SHIPPED = 'shipped';
public const BACKGROUND_CUSTOM = 'custom';
diff --git a/apps/theming/lib/Themes/CommonThemeTrait.php b/apps/theming/lib/Themes/CommonThemeTrait.php
index 9b516bf6c70..66775500f65 100644
--- a/apps/theming/lib/Themes/CommonThemeTrait.php
+++ b/apps/theming/lib/Themes/CommonThemeTrait.php
@@ -64,15 +64,15 @@ trait CommonThemeTrait {
// used for buttons, inputs...
'--color-primary-element' => $colorPrimaryElement,
- '--color-primary-element-hover' => $this->util->mix($colorPrimaryElement, $colorMainBackground, 75),
+ '--color-primary-element-hover' => $this->util->mix($colorPrimaryElement, $colorMainBackground, 82),
'--color-primary-element-text' => $this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff',
+ // mostly used for disabled states
+ '--color-primary-element-text-dark' => $this->util->darken($this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff', 6),
// used for hover/focus states
'--color-primary-element-light' => $colorPrimaryElementLight,
'--color-primary-element-light-hover' => $this->util->mix($colorPrimaryElementLight, $colorMainText, 90),
'--color-primary-element-light-text' => $this->util->mix($colorPrimaryElement, $this->util->invertTextColor($colorPrimaryElementLight) ? '#000000' : '#ffffff', -20),
- // mostly used for disabled states
- '--color-primary-element-text-dark' => $this->util->darken($this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff', 7),
// to use like this: background-image: var(--gradient-primary-background);
'--gradient-primary-background' => 'linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',
diff --git a/apps/theming/lib/Themes/DarkTheme.php b/apps/theming/lib/Themes/DarkTheme.php
index 80c65cd0eb6..1def7f378a7 100644
--- a/apps/theming/lib/Themes/DarkTheme.php
+++ b/apps/theming/lib/Themes/DarkTheme.php
@@ -84,8 +84,8 @@ class DarkTheme extends DefaultTheme implements ITheme {
'--color-text-maxcontrast' => $colorTextMaxcontrast,
'--color-text-maxcontrast-default' => $colorTextMaxcontrast,
'--color-text-maxcontrast-background-blur' => $this->util->lighten($colorTextMaxcontrast, 2),
- '--color-text-light' => $this->util->darken($colorMainText, 10),
- '--color-text-lighter' => $this->util->darken($colorMainText, 20),
+ '--color-text-light' => 'var(--color-main-text)', // deprecated
+ '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated
'--color-error' => $colorError,
'--color-error-rgb' => join(',', $this->util->hexToRGB($colorError)),
diff --git a/apps/theming/lib/Themes/DefaultTheme.php b/apps/theming/lib/Themes/DefaultTheme.php
index 70a7b5fdff8..e2bd31548ca 100644
--- a/apps/theming/lib/Themes/DefaultTheme.php
+++ b/apps/theming/lib/Themes/DefaultTheme.php
@@ -103,7 +103,8 @@ class DefaultTheme implements ITheme {
public function getCSSVariables(): array {
$colorMainText = '#222222';
$colorMainTextRgb = join(',', $this->util->hexToRGB($colorMainText));
- $colorTextMaxcontrast = $this->util->lighten($colorMainText, 33);
+ // Color that still provides enough contrast for text, so we need a ratio of 4.5:1 on main background AND hover
+ $colorTextMaxcontrast = '#6b6b6b'; // 4.5 : 1 for hover background and background dark
$colorMainBackground = '#ffffff';
$colorMainBackgroundRGB = join(',', $this->util->hexToRGB($colorMainBackground));
$colorBoxShadow = $this->util->darken($colorMainBackground, 70);
@@ -137,8 +138,8 @@ class DefaultTheme implements ITheme {
'--color-text-maxcontrast' => $colorTextMaxcontrast,
'--color-text-maxcontrast-default' => $colorTextMaxcontrast,
'--color-text-maxcontrast-background-blur' => $this->util->darken($colorTextMaxcontrast, 7),
- '--color-text-light' => $colorMainText,
- '--color-text-lighter' => $this->util->lighten($colorMainText, 33),
+ '--color-text-light' => 'var(--color-main-text)', // deprecated
+ '--color-text-lighter' => 'var(--color-text-maxcontrast)', // deprecated
'--color-scrollbar' => 'rgba(' . $colorMainTextRgb . ', .15)',
@@ -150,7 +151,7 @@ class DefaultTheme implements ITheme {
'--color-warning' => $colorWarning,
'--color-warning-rgb' => join(',', $this->util->hexToRGB($colorWarning)),
'--color-warning-hover' => $this->util->mix($colorWarning, $colorMainBackground, 60),
- '--color-warning-text' => $this->util->darken($colorWarning, 8),
+ '--color-warning-text' => $this->util->darken($colorWarning, 10),
'--color-success' => $colorSuccess,
'--color-success-rgb' => join(',', $this->util->hexToRGB($colorSuccess)),
'--color-success-hover' => $this->util->mix($colorSuccess, $colorMainBackground, 78),
diff --git a/apps/theming/tests/Themes/DefaultThemeTest.php b/apps/theming/tests/Themes/DefaultThemeTest.php
index 6044f5c10d3..0d86a8d6b28 100644
--- a/apps/theming/tests/Themes/DefaultThemeTest.php
+++ b/apps/theming/tests/Themes/DefaultThemeTest.php
@@ -157,6 +157,8 @@ class DefaultThemeTest extends TestCase {
$css = ":root {" . PHP_EOL . "$variables}" . PHP_EOL;
$fallbackCss = file_get_contents(__DIR__ . '/../../css/default.css');
+ // Remove comments
+ $fallbackCss = preg_replace('/\s*\/\*[\s\S]*?\*\//m', '', $fallbackCss);
$this->assertEquals($css, $fallbackCss);
}
diff --git a/cypress/e2e/theming/themingUtils.ts b/cypress/e2e/theming/themingUtils.ts
index 2cf92c47e6a..532cee46911 100644
--- a/cypress/e2e/theming/themingUtils.ts
+++ b/cypress/e2e/theming/themingUtils.ts
@@ -22,7 +22,7 @@
import { colord } from 'colord'
export const defaultPrimary = '#0082c9'
-export const defaultAccessiblePrimary = '#006aa3'
+export const defaultAccessiblePrimary = '#00679e'
export const defaultBackground = 'kamil-porembinski-clouds.jpg'
/**
diff --git a/cypress/support/component.ts b/cypress/support/component.ts
index b1e0a1b2c0f..da56f124826 100644
--- a/cypress/support/component.ts
+++ b/cypress/support/component.ts
@@ -19,6 +19,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
+import 'cypress-axe'
+
/* eslint-disable */
import { mount } from '@cypress/vue2'
diff --git a/package-lock.json b/package-lock.json
index 62286b993e3..e4748226ba7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -40,7 +40,6 @@
"camelcase": "^8.0.0",
"cancelable-promise": "^4.3.1",
"clipboard": "^2.0.11",
- "colord": "^2.9.3",
"core-js": "^3.33.0",
"davclient.js": "github:owncloud/davclient.js.git#0.2.1",
"debounce": "^1.2.1",
@@ -113,8 +112,10 @@
"babel-jest": "^29.6.4",
"babel-loader": "^9.1.0",
"babel-loader-exclude-node-modules-except": "^1.2.1",
+ "colord": "^2.9.3",
"css-loader": "^6.8.1",
"cypress": "^13.3.0",
+ "cypress-axe": "^1.5.0",
"cypress-if": "^1.10.5",
"cypress-split": "^1.15.3",
"cypress-wait-until": "^2.0.1",
@@ -141,6 +142,7 @@
"karma-viewport": "^1.0.9",
"node-polyfill-webpack-plugin": "^2.0.1",
"puppeteer": "^21.0.3",
+ "raw-loader": "^4.0.2",
"regextras": "^0.8.0",
"sass": "^1.66.1",
"sass-loader": "^13.2.2",
@@ -7729,6 +7731,16 @@
"integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==",
"dev": true
},
+ "node_modules/axe-core": {
+ "version": "4.8.2",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.8.2.tgz",
+ "integrity": "sha512-/dlp0fxyM3R8YW7MFzaHWXrf4zzbr0vaYb23VBFCl83R7nWNPg/yaQw2Dc8jzCMmDVLhSdzH8MjrsuIUuvX+6g==",
+ "dev": true,
+ "peer": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/axios": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
@@ -9222,7 +9234,8 @@
"node_modules/colord": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
- "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
+ "dev": true
},
"node_modules/colorette": {
"version": "2.0.20",
@@ -10004,6 +10017,19 @@
"node": "^16.0.0 || ^18.0.0 || >=20.0.0"
}
},
+ "node_modules/cypress-axe": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/cypress-axe/-/cypress-axe-1.5.0.tgz",
+ "integrity": "sha512-Hy/owCjfj+25KMsecvDgo4fC/781ccL+e8p+UUYoadGVM2ogZF9XIKbiM6KI8Y3cEaSreymdD6ZzccbI2bY0lQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "axe-core": "^3 || ^4",
+ "cypress": "^10 || ^11 || ^12 || ^13"
+ }
+ },
"node_modules/cypress-if": {
"version": "1.10.5",
"resolved": "https://registry.npmjs.org/cypress-if/-/cypress-if-1.10.5.tgz",
@@ -22145,6 +22171,44 @@
"node": ">= 0.8"
}
},
+ "node_modules/raw-loader": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-4.0.2.tgz",
+ "integrity": "sha512-ZnScIV3ag9A4wPX/ZayxL/jZH+euYb6FcUinPcgiQW0+UBtEv0O6Q3lGd3cqJ+GHH+rksEv3Pj99oxJ3u3VIKA==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^2.0.0",
+ "schema-utils": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ },
+ "peerDependencies": {
+ "webpack": "^4.0.0 || ^5.0.0"
+ }
+ },
+ "node_modules/raw-loader/node_modules/schema-utils": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
+ "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==",
+ "dev": true,
+ "dependencies": {
+ "@types/json-schema": "^7.0.8",
+ "ajv": "^6.12.5",
+ "ajv-keywords": "^3.5.2"
+ },
+ "engines": {
+ "node": ">= 10.13.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
diff --git a/package.json b/package.json
index 9ef91ae6584..807cc0cc4b9 100644
--- a/package.json
+++ b/package.json
@@ -67,7 +67,6 @@
"camelcase": "^8.0.0",
"cancelable-promise": "^4.3.1",
"clipboard": "^2.0.11",
- "colord": "^2.9.3",
"core-js": "^3.33.0",
"davclient.js": "github:owncloud/davclient.js.git#0.2.1",
"debounce": "^1.2.1",
@@ -140,8 +139,10 @@
"babel-jest": "^29.6.4",
"babel-loader": "^9.1.0",
"babel-loader-exclude-node-modules-except": "^1.2.1",
+ "colord": "^2.9.3",
"css-loader": "^6.8.1",
"cypress": "^13.3.0",
+ "cypress-axe": "^1.5.0",
"cypress-if": "^1.10.5",
"cypress-split": "^1.15.3",
"cypress-wait-until": "^2.0.1",
@@ -167,6 +168,7 @@
"karma-viewport": "^1.0.9",
"node-polyfill-webpack-plugin": "^2.0.1",
"puppeteer": "^21.0.3",
+ "raw-loader": "^4.0.2",
"regextras": "^0.8.0",
"sass": "^1.66.1",
"sass-loader": "^13.2.2",
diff --git a/tsconfig.json b/tsconfig.json
index 47b75a076d8..ea4818103c6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,7 +2,7 @@
"extends": "@vue/tsconfig/tsconfig.json",
"include": ["./apps/**/*.ts", "./core/**/*.ts", "./*.d.ts"],
"compilerOptions": {
- "types": ["cypress", "jest", "node", "vue"],
+ "types": ["cypress", "cypress-axe", "jest", "node", "vue"],
"outDir": "./dist/",
"target": "ESNext",
"module": "esnext",