diff --git a/cypress/e2e/link-accessibility.cy.js b/cypress/e2e/link-accessibility.cy.js new file mode 100644 index 00000000..6723537c --- /dev/null +++ b/cypress/e2e/link-accessibility.cy.js @@ -0,0 +1,35 @@ +describe('link interaction hierarchy', () => { + it('keeps prose links identifiable while navigation and CTAs stay quiet', () => { + cy.viewport(1280, 900) + cy.visit('/about') + + cy.get('a[href="mailto:hello@openadapt.ai"]') + .should('be.visible') + .and('have.css', 'text-decoration-line', 'underline') + .focus() + .should('have.css', 'outline-style', 'solid') + .and('have.css', 'outline-width', '3px') + + cy.get('header a').first() + .should('be.visible') + .and('have.css', 'text-decoration-line', 'none') + + cy.get('a.btn-ink').first() + .should('be.visible') + .and('have.css', 'text-decoration-line', 'none') + }) + + it('retains the inline-link affordance without horizontal overflow on mobile', () => { + cy.viewport(375, 667) + cy.visit('/about') + + cy.get('a[href="mailto:hello@openadapt.ai"]') + .scrollIntoView() + .should('be.visible') + .and('have.css', 'text-decoration-line', 'underline') + + cy.document().then((document) => { + expect(document.documentElement.scrollWidth).to.be.at.most(375) + }) + }) +}) diff --git a/styles/globals.css b/styles/globals.css index f54b112f..588be2db 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -14,6 +14,11 @@ --ink-2: #4c523f; /* secondary text */ --ink-3: #5a6050; /* tertiary / captions */ --accent: #3e6b4f; /* surgical green — labels, pills, links */ + --accent-hover: #2f5340; + --link-visited: #76512f; + --link-underline: rgba(62, 107, 79, 0.58); + --link-visited-underline: rgba(118, 81, 47, 0.58); + --focus-ring: #a74612; --inset-bg: #14171a; /* dark terminal insets */ --inset-text: #d9dee6; --inset-ok: #86d9a8; /* phosphor green */ @@ -60,13 +65,51 @@ h4 { a { color: var(--accent); text-decoration: none; + text-decoration-thickness: 0.08em; + text-underline-offset: 0.18em; + transition: color 0.16s ease, text-decoration-color 0.16s ease; } a:hover { - color: #2f5340; + color: var(--accent-hover); text-decoration: underline; } +/* Keep navigation, cards, and buttons visually quiet. Links embedded in prose + or data stay identifiable without requiring a hover interaction. */ +main :where(p, li, dd, blockquote, figcaption, td) a:not(.btn-ink):not(.btn-ghost-ink) { + text-decoration-line: underline; + text-decoration-color: var(--link-underline); + text-decoration-thickness: 0.08em; + text-underline-offset: 0.2em; +} + +main :where(p, li, dd, blockquote, figcaption, td) a:not(.btn-ink):not(.btn-ghost-ink):visited { + color: var(--link-visited); + text-decoration-color: var(--link-visited-underline); +} + +main :where(p, li, dd, blockquote, figcaption, td) a:not(.btn-ink):not(.btn-ghost-ink):hover { + color: var(--accent-hover); + text-decoration-color: currentColor; + text-decoration-thickness: 0.12em; +} + +main :where(p, li, dd, blockquote, figcaption, td) a:not(.btn-ink):not(.btn-ghost-ink):active { + color: var(--ink); + text-decoration-color: currentColor; +} + +:where(a, button, input, textarea, select, summary, [tabindex]):focus-visible { + outline: 3px solid var(--focus-ring) !important; + outline-offset: 3px !important; +} + +a:focus-visible { + text-decoration-color: currentColor !important; + text-decoration-thickness: 0.12em; +} + /* TODO: figure out why this doesn't work */ html body a, html body a *, @@ -115,6 +158,12 @@ button:not(:disabled), text-decoration: none; } +.btn-ink:active { + background: var(--ink); + border-color: var(--ink); + transform: translateY(1px); +} + /* Ghost button: ink outline pill */ .btn-ghost-ink { display: inline-block; @@ -135,6 +184,11 @@ button:not(:disabled), text-decoration: none; } +.btn-ghost-ink:active { + background: rgba(35, 40, 31, 0.11); + transform: translateY(1px); +} + .fade-in { animation: fadeInAnimation 1s forwards; } @@ -160,3 +214,18 @@ button:not(:disabled), opacity: 0; } } + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto !important; + } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/tests/linkStyles.test.js b/tests/linkStyles.test.js new file mode 100644 index 00000000..27b67b5a --- /dev/null +++ b/tests/linkStyles.test.js @@ -0,0 +1,56 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') + +const css = fs.readFileSync( + path.join(process.cwd(), 'styles', 'globals.css'), + 'utf8' +) + +function luminance(hex) { + const channels = [1, 3, 5].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16) / 255 + ) + const linear = channels.map((channel) => + channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4 + ) + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] +} + +function contrast(foreground, background) { + const values = [luminance(foreground), luminance(background)].sort( + (left, right) => right - left + ) + return (values[0] + 0.05) / (values[1] + 0.05) +} + +test('prose links use persistent non-color affordances without flattening UI links', () => { + assert.match( + css, + /main :where\(p, li, dd, blockquote, figcaption, td\) a:not\(\.btn-ink\):not\(\.btn-ghost-ink\)/ + ) + assert.match(css, /text-decoration-line: underline/) + assert.match(css, /text-underline-offset: 0\.2em/) + assert.match(css, /:visited/) + assert.match(css, /\.btn-ink:hover[\s\S]*text-decoration: none/) +}) + +test('keyboard focus and reduced-motion behavior are global interaction contracts', () => { + assert.match(css, /:focus-visible[\s\S]*outline: 3px solid var\(--focus-ring\)/) + assert.match(css, /@media \(prefers-reduced-motion: reduce\)/) + assert.match(css, /scroll-behavior: auto !important/) + assert.match(css, /transition-duration: 0\.01ms !important/) +}) + +test('link and focus colors retain AA contrast on the paper ground', () => { + const ground = '#f2f1ec' + for (const color of ['#3e6b4f', '#2f5340', '#76512f', '#a74612']) { + assert.ok( + contrast(color, ground) >= 4.5, + `${color} must retain 4.5:1 contrast on ${ground}` + ) + } +})