Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7e92ad6
🎨 Palette: 키보드 사용자를 위한 메트릭 카드 툴팁 접근성 개선
seonghobae Aug 15, 2026
7f29b0b
test(a11y): require visible metric explanations
seonghobae Aug 15, 2026
114f36b
fix(a11y): expose metric help as visible copy
seonghobae Aug 15, 2026
2b0192a
docs(a11y): record metric explanation boundary
seonghobae Aug 15, 2026
999d528
fix(a11y): style visible metric explanations
seonghobae Aug 15, 2026
87f2ad7
docs(a11y): correct static tooltip guidance
seonghobae Aug 15, 2026
eced588
test(a11y): require AA metric description contrast
seonghobae Aug 15, 2026
7ddfa87
test(a11y): reject any metric title fallback
seonghobae Aug 15, 2026
235a23c
test(a11y): align legacy metric-card expectations
seonghobae Aug 15, 2026
42901c9
fix(a11y): enforce AA metric-card contrast
seonghobae Aug 15, 2026
5078078
docs(a11y): trace visible-help and contrast acceptance
seonghobae Aug 15, 2026
63e9e37
test(a11y): remove unused contrast locator
seonghobae Aug 15, 2026
17aafc5
🎨 Palette: 키보드 사용자를 위한 메트릭 카드 툴팁 접근성 개선
seonghobae Aug 15, 2026
f2c31d2
test(a11y): restore persistent metric explanation contract
seonghobae Aug 15, 2026
9684632
fix(a11y): restore persistent metric explanations
seonghobae Aug 15, 2026
5bd8b09
fix(a11y): restore reviewed accessibility semantics
seonghobae Aug 15, 2026
96844d0
fix(security): sanitize project name in document.title to prevent XSS
seonghobae Aug 16, 2026
0443bfe
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] Aug 16, 2026
016feae
test(a11y): preserve literal project name in browser title
seonghobae Aug 16, 2026
009307b
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] Aug 16, 2026
876fc30
fix(a11y): restore persistent metric explanations on current develop
seonghobae Aug 16, 2026
c8f7c5c
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] Aug 16, 2026
7cd0ec0
merge(develop): reconcile metric explanations with OpenCode config
seonghobae Aug 17, 2026
112f9b7
Merge branch 'develop' into palette/tooltip-accessibility-97398005977…
opencode-agent[bot] Aug 18, 2026
81a22ca
fix(stack): inherit current protected dependency baseline
seonghobae Aug 20, 2026
b2acb21
chore: reconcile metric accessibility with protected develop
seonghobae Aug 21, 2026
57004e4
test(a11y): require static metric card semantics
seonghobae Aug 21, 2026
ea90277
test(a11y): align legacy metric assertions with visible help
seonghobae Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,6 @@
## $(date +%Y-%m-%d) - Prevent accidental data loss in inline editors
**Learning:** Forms that take a long time to fill out (like a WBS editor) are prone to accidental closure by users pressing `Escape` or clicking cancel. This causes immediate data loss without any warning, resulting in frustration.
**Action:** When working on editors that can be dismissed, track whether the user has modified any fields compared to their initial state. If there are changes, intercept the close action and present a confirmation dialog (`window.confirm`) to ensure they really want to discard their edits. Bypass this for intentional saves or explicit data overrides.
## 2026-08-15 - Prefer persistent visible help for static metric explanations
**Learning:** A `title` fallback on static content is not reliably discoverable by sighted keyboard or touch users, and making an otherwise non-interactive card a synthetic tab stop solely to expose that tooltip adds navigation cost without adding an action.
**Action:** For short explanatory copy on static metric cards, prefer persistent visible text. Do not add `tabindex` or an ARIA role only to make help discoverable. If a genuinely supplemental popup is needed, implement and test an explicit tooltip or disclosure interaction with the appropriate accessible relationship instead of relying on `title` alone.
43 changes: 43 additions & 0 deletions docs/doctoring/metric-card-explanations-accessibility.md
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/
9 changes: 6 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,23 @@ <h1>ScopeWeave Planner</h1>
<span>기준일</span>
<input data-testid="base-date-input" id="base-date" type="date" />
</label>
<div class="meta-value-card" title="프로젝트의 작업 기간(일수) 합계입니다.">
<div class="meta-value-card">
<span class="meta-label">전체일수</span>
<strong data-testid="summary-total-days" id="summary-total-days">0일</strong>
<span class="meta-description">프로젝트의 작업 기간(일수) 합계입니다.</span>
</div>
</div>

<div class="meta-grid meta-grid-secondary">
<div class="meta-value-card accent-card plan-card" title="기간(일수) 가중치가 반영된 프로젝트 전체 계획 진척률입니다.">
<div class="meta-value-card accent-card plan-card">
<span class="meta-label">계획진척률(누적)</span>
<strong data-testid="summary-planned-progress" id="summary-planned-progress">0.00%</strong>
<span class="meta-description">기간(일수) 가중치가 반영된 프로젝트 전체 계획 진척률입니다.</span>
</div>
<div class="meta-value-card accent-card actual-card" title="기간(일수) 가중치가 반영된 프로젝트 전체 실적 진척률입니다.">
<div class="meta-value-card accent-card actual-card">
Comment thread
seonghobae marked this conversation as resolved.
<span class="meta-label">실적진척률(누적)</span>
<strong data-testid="summary-actual-progress" id="summary-actual-progress">0.00%</strong>
<span class="meta-description">기간(일수) 가중치가 반영된 프로젝트 전체 실적 진척률입니다.</span>
</div>
<div class="sync-panel">
<span id="sync-status" role="status" aria-live="polite" aria-atomic="true">브라우저 로컬 자동저장 사용 중</span>
Expand Down
15 changes: 11 additions & 4 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -170,26 +170,33 @@ button {
letter-spacing: -0.02em;
}

.meta-description {
color: var(--text-muted);
font-size: 0.75rem;
line-height: 1.4;
}

.accent-card {
color: #fff;
border: none;
}

.accent-card .meta-label {
color: rgba(255, 255, 255, 0.9);
.accent-card .meta-label,
.accent-card .meta-description {
color: rgba(255, 255, 255, 0.92);
}

.accent-card strong {
color: #ffffff;
}

.plan-card {
background: linear-gradient(135deg, #1e40af, #3b82f6);
background: linear-gradient(135deg, #1e40af, #2563eb);
Comment thread
seonghobae marked this conversation as resolved.
box-shadow: 0 4px 14px 0 rgba(37, 99, 235, 0.25);
}

.actual-card {
background: linear-gradient(135deg, #047857, #10b981);
background: linear-gradient(135deg, #065f46, #047857);
box-shadow: 0 4px 14px 0 rgba(16, 185, 129, 0.25);
}

Expand Down
95 changes: 95 additions & 0 deletions tests/e2e/metric-card-explanations.spec.js
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);
Comment thread
seonghobae marked this conversation as resolved.
});
}
});
12 changes: 12 additions & 0 deletions tests/e2e/project-title-literal.spec.js
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`);
Comment thread
seonghobae marked this conversation as resolved.
});
25 changes: 20 additions & 5 deletions tests/e2e/scopeweave.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1290,13 +1290,28 @@ test.describe('ScopeWeave Planner - Palette UX Enhancements', () => {
page.off('dialog', acceptHandler);
});

test('adds helpful tooltips and ARIA attributes for progress cards and gantt buttons', async ({ page }) => {
test('adds visible metric help and ARIA attributes for gantt controls', async ({ page }) => {
await page.goto('./');

// Verify progress card tooltips
await expect(page.locator('.meta-value-card').first()).toHaveAttribute('title', '프로젝트의 작업 기간(일수) 합계입니다.');
await expect(page.locator('.plan-card')).toHaveAttribute('title', '기간(일수) 가중치가 반영된 프로젝트 전체 계획 진척률입니다.');
await expect(page.locator('.actual-card')).toHaveAttribute('title', '기간(일수) 가중치가 반영된 프로젝트 전체 실적 진척률입니다.');
// Verify progress card explanations are visible without native tooltips.
const metricCards = [
{
card: page.locator('.meta-value-card').first(),
description: '프로젝트의 작업 기간(일수) 합계입니다.'
},
{
card: page.locator('.plan-card'),
description: '기간(일수) 가중치가 반영된 프로젝트 전체 계획 진척률입니다.'
},
{
card: page.locator('.actual-card'),
description: '기간(일수) 가중치가 반영된 프로젝트 전체 실적 진척률입니다.'
}
];
for (const { card, description } of metricCards) {
await expect(card.getByText(description, { exact: true })).toBeVisible();
await expect(card).not.toHaveAttribute('title');
}

// Verify sync status ARIA attributes
const syncStatus = page.locator('#sync-status');
Expand Down
Loading