-
Notifications
You must be signed in to change notification settings - Fork 0
fix(a11y): make summary metric explanations persistent #509
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
seonghobae
wants to merge
28
commits into
develop
Choose a base branch
from
palette/tooltip-accessibility-9739800597741233261
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
7e92ad6
🎨 Palette: 키보드 사용자를 위한 메트릭 카드 툴팁 접근성 개선
seonghobae 7f29b0b
test(a11y): require visible metric explanations
seonghobae 114f36b
fix(a11y): expose metric help as visible copy
seonghobae 2b0192a
docs(a11y): record metric explanation boundary
seonghobae 999d528
fix(a11y): style visible metric explanations
seonghobae 87f2ad7
docs(a11y): correct static tooltip guidance
seonghobae eced588
test(a11y): require AA metric description contrast
seonghobae 7ddfa87
test(a11y): reject any metric title fallback
seonghobae 235a23c
test(a11y): align legacy metric-card expectations
seonghobae 42901c9
fix(a11y): enforce AA metric-card contrast
seonghobae 5078078
docs(a11y): trace visible-help and contrast acceptance
seonghobae 63e9e37
test(a11y): remove unused contrast locator
seonghobae 17aafc5
🎨 Palette: 키보드 사용자를 위한 메트릭 카드 툴팁 접근성 개선
seonghobae f2c31d2
test(a11y): restore persistent metric explanation contract
seonghobae 9684632
fix(a11y): restore persistent metric explanations
seonghobae 5bd8b09
fix(a11y): restore reviewed accessibility semantics
seonghobae 96844d0
fix(security): sanitize project name in document.title to prevent XSS
seonghobae 0443bfe
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] 016feae
test(a11y): preserve literal project name in browser title
seonghobae 009307b
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] 876fc30
fix(a11y): restore persistent metric explanations on current develop
seonghobae c8f7c5c
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] 7cd0ec0
merge(develop): reconcile metric explanations with OpenCode config
seonghobae 112f9b7
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] 81a22ca
fix(stack): inherit current protected dependency baseline
seonghobae b2acb21
chore: reconcile metric accessibility with protected develop
seonghobae 57004e4
test(a11y): require static metric card semantics
seonghobae ea90277
test(a11y): align legacy metric assertions with visible help
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| # Metric-card explanatory text accessibility | ||
|
|
||
| ## Status | ||
|
|
||
| Active PR evidence only. This record documents the bounded accessibility change on the contributor branch; it does not claim protected `develop` ships this behavior until the PR is integrated. | ||
|
|
||
| ## Buyer-visible problem | ||
|
|
||
| The planner's three summary metric cards exposed calculation explanations only through HTML `title` attributes. That makes the explanation dependent on user-agent tooltip behavior and is not reliably discoverable by sighted keyboard or touch users. Making otherwise static summary cards synthetic tab stops solely to expose those tooltips adds navigation cost without adding an action. | ||
|
|
||
| The new visible copy is normal-size text, so it also needs sufficient foreground/background contrast. The original light ends of the plan and actual gradients did not provide the WCAG 2.2 SC 1.4.3 minimum 4.5:1 contrast for the 12 px explanatory text. | ||
|
|
||
| ## Decision | ||
|
|
||
| Keep the metric cards non-interactive and make each explanation persistent visible text inside its card. Do not add `tabindex="0"`, `role="note"`, or any `title` fallback to these static cards merely to surface help text. | ||
|
|
||
| This follows the W3C Authoring Practices guidance to prefer visible text and avoid relying on browser fallback naming/description mechanisms. The APG specifically notes that `title` tooltips are not particularly discoverable and are not accessible to visual users who do not use a pointing device. WCAG 2.2 SC 1.4.13 governs author-controlled content that appears on hover or focus; persistent visible explanatory copy avoids introducing an additional hover/focus popup interaction entirely. | ||
|
|
||
| For the two accent cards, keep the existing 92% white explanatory foreground but constrain the plan gradient to `#1e40af` → `#2563eb` and the actual gradient to `#065f46` → `#047857`. Browser acceptance coverage samples each rendered gradient and requires at least 4.5:1 contrast for the composited explanatory text, matching WCAG 2.2 SC 1.4.3 for normal text. | ||
|
|
||
| If ScopeWeave later needs genuinely supplemental, non-persistent help, it should use an explicit interaction with a reviewed tooltip/disclosure contract rather than making unrelated static content focusable. WAI-ARIA 1.2 describes a tooltip as a contextual popup associated with an owning element, typically shown on hover or owner focus, and recommends linking it with `aria-describedby`. | ||
|
|
||
| ## TDD traceability | ||
|
|
||
| The discoverability regression was committed before the production correction. `tests/e2e/metric-card-explanations.spec.js` requires all three explanations to be visibly rendered, rejects the synthetic `tabindex="0"` and `role="note"`, and rejects the presence of any `title` attribute on these cards rather than checking only one expected title value. The legacy metric-card assertions in `tests/e2e/scopeweave.spec.js` are aligned to the same visible `.meta-description` contract instead of retaining contradictory title-based expectations. The subsequent production correction in `index.html` replaces the tooltip-only contract with persistent `.meta-description` text. | ||
|
|
||
| A separate RED contrast regression was added before the gradient correction. It reads the browser-computed description color and gradient stops, composites the alpha foreground over 21 samples across each gradient, and requires the minimum contrast to be at least 4.5:1. `styles.css` then darkens only the two gradient endpoints needed to satisfy that acceptance contract. | ||
|
|
||
| Hosted exact-head browser/CI evidence is authoritative. A queued, pending, skipped, stale, predecessor-head, model-only, or otherwise non-terminal result is not promoted to passing evidence. | ||
|
|
||
| ## Rollback | ||
|
|
||
| Rollback reverts the visible descriptions, accessible gradient endpoints, and their regressions together. There is no persisted-data, API, authentication, or schema migration impact. | ||
|
|
||
| ## References | ||
|
|
||
| World Wide Web Consortium. (2023). *Web Content Accessibility Guidelines (WCAG) 2.2*. https://www.w3.org/TR/WCAG22/ | ||
|
|
||
| World Wide Web Consortium, Web Accessibility Initiative. (2026). *Understanding Success Criterion 1.4.13: Content on hover or focus*. https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html | ||
|
|
||
| World Wide Web Consortium, Web Accessibility Initiative. (n.d.). *Providing accessible names and descriptions*. WAI-ARIA Authoring Practices Guide. Retrieved August 15, 2026, from https://www.w3.org/WAI/ARIA/apg/practices/names-and-descriptions/ | ||
|
|
||
| World Wide Web Consortium. (2023). *Accessible Rich Internet Applications (WAI-ARIA) 1.2*. https://www.w3.org/TR/wai-aria-1.2/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import { test, expect } from '@playwright/test'; | ||
|
|
||
| const metricCases = [ | ||
| { | ||
| valueTestId: 'summary-total-days', | ||
| description: '프로젝트의 작업 기간(일수) 합계입니다.', | ||
| }, | ||
| { | ||
| valueTestId: 'summary-planned-progress', | ||
| description: '기간(일수) 가중치가 반영된 프로젝트 전체 계획 진척률입니다.', | ||
| }, | ||
| { | ||
| valueTestId: 'summary-actual-progress', | ||
| description: '기간(일수) 가중치가 반영된 프로젝트 전체 실적 진척률입니다.', | ||
| }, | ||
| ]; | ||
|
|
||
| function relativeLuminance([red, green, blue]) { | ||
| const channels = [red, green, blue].map((channel) => { | ||
| const value = channel / 255; | ||
| return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; | ||
| }); | ||
| return (0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2]); | ||
| } | ||
|
|
||
| function contrastRatio(foreground, background) { | ||
| const foregroundLuminance = relativeLuminance(foreground); | ||
| const backgroundLuminance = relativeLuminance(background); | ||
| return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) | ||
| / (Math.min(foregroundLuminance, backgroundLuminance) + 0.05); | ||
| } | ||
|
|
||
| function blendChannel(foreground, background, alpha) { | ||
| return (alpha * foreground) + ((1 - alpha) * background); | ||
| } | ||
|
|
||
| function blendRgb(foreground, background, alpha) { | ||
| return foreground.map((channel, index) => blendChannel(channel, background[index], alpha)); | ||
| } | ||
|
|
||
| function interpolateRgb(start, end, progress) { | ||
| return start.map((channel, index) => channel + ((end[index] - channel) * progress)); | ||
| } | ||
|
|
||
| test.describe('summary metric explanations', () => { | ||
| test.beforeEach(async ({ page }) => { | ||
| await page.goto('./'); | ||
| }); | ||
|
|
||
| for (const { valueTestId, description } of metricCases) { | ||
| test(`keeps ${valueTestId} help visible without a synthetic keyboard stop`, async ({ page }) => { | ||
| const value = page.getByTestId(valueTestId); | ||
| const card = value.locator('..'); | ||
|
|
||
| await expect(value).toBeVisible(); | ||
| await expect(card.getByText(description, { exact: true })).toBeVisible(); | ||
| await expect(card).not.toHaveAttribute('tabindex'); | ||
| await expect(card).not.toHaveAttribute('role'); | ||
| await expect(card).not.toHaveAttribute('title'); | ||
| }); | ||
| } | ||
|
|
||
| for (const valueTestId of ['summary-planned-progress', 'summary-actual-progress']) { | ||
| test(`${valueTestId} explanation keeps WCAG AA text contrast across its gradient`, async ({ page }) => { | ||
| const card = page.getByTestId(valueTestId).locator('..'); | ||
| const styles = await card.evaluate((element) => { | ||
| const descriptionElement = element.querySelector('.meta-description'); | ||
| return { | ||
| backgroundImage: getComputedStyle(element).backgroundImage, | ||
| color: getComputedStyle(descriptionElement).color, | ||
| }; | ||
| }); | ||
|
|
||
| const colorMatch = styles.color.match(/rgba?\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)/i); | ||
| const gradientMatches = [...styles.backgroundImage.matchAll(/rgb\(\s*([\d.]+)[, ]+\s*([\d.]+)[, ]+\s*([\d.]+)\s*\)/gi)]; | ||
|
|
||
| expect(colorMatch, `expected an rgb/rgba description color, got ${styles.color}`).not.toBeNull(); | ||
| expect(gradientMatches.length, `expected two gradient color stops, got ${styles.backgroundImage}`).toBeGreaterThanOrEqual(2); | ||
|
|
||
| const foreground = colorMatch.slice(1, 4).map(Number); | ||
| const foregroundAlpha = colorMatch[4] === undefined ? 1 : Number(colorMatch[4]); | ||
| const start = gradientMatches[0].slice(1, 4).map(Number); | ||
| const end = gradientMatches.at(-1).slice(1, 4).map(Number); | ||
| let minimumContrast = Number.POSITIVE_INFINITY; | ||
|
|
||
| for (let sample = 0; sample <= 20; sample += 1) { | ||
| const background = interpolateRgb(start, end, sample / 20); | ||
| const renderedForeground = blendRgb(foreground, background, foregroundAlpha); | ||
| minimumContrast = Math.min(minimumContrast, contrastRatio(renderedForeground, background)); | ||
| } | ||
|
|
||
| expect(minimumContrast).toBeGreaterThanOrEqual(4.5); | ||
|
seonghobae marked this conversation as resolved.
|
||
| }); | ||
| } | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { test, expect } from '@playwright/test'; | ||
|
|
||
| const PROJECT_NAME = `R&D <Plan> & "Review" 'Q3'`; | ||
|
|
||
| test('keeps the project name literal in the browser title', async ({ page }) => { | ||
| await page.goto('./'); | ||
|
|
||
| await page.getByTestId('project-name-input').fill(PROJECT_NAME); | ||
| await page.getByTestId('project-name-input').blur(); | ||
|
|
||
| await expect(page).toHaveTitle(`${PROJECT_NAME} - ScopeWeave Planner`); | ||
|
seonghobae marked this conversation as resolved.
|
||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.