- Restore the step-0 interview as a mechanical gate the skill explicitly depends on; add harness.md (per-harness question tools, limits, fallbacks) and brief-interview.md (slots, question cards, rounds). - Add 10 reference docs absorbed from external design skills (accessibility, interaction-feel, elevation, color, icons, product-copy, component-systems, critique, change-review, print-email) and extend existing references. - Add a review-only route and two hard-gate clauses (truncated content reachability, three-flashes limit). - design-gate: split tap targets into WCAG 2.5.8 and 44px contract layers, run axe-core when available, and fix false positives found on a real site (decorative alt="", stacked wordmark line count, url-only pages). - lint-skill: fail if the interview gate section or its links disappear. - Ship agents/openai.yaml and THIRD_PARTY_NOTICES.md.
563 lines
42 KiB
Markdown
563 lines
42 KiB
Markdown
# accessibility.md — 접근성 구현 계약
|
||
|
||
4단계 구현부터 참조하고, 5단계 프리플라이트의 접근성 표([preflight.md](preflight.md) §1)가 가리키는 정본이다. `preflight.md`는 체크리스트 행만 두고 여기를 가리킨다 — 이 문서는 체크리스트를 반복하지 않고 **왜 실패하는지, 어떤 코드로 고치는지, 무엇을 참조하는지**를 다룬다.
|
||
|
||
라벨은 셋 중 하나다. **하드 게이트**는 위반하면 통과시키지 않는다 — WCAG AA·키보드·포커스·대비·감소 모션·진실성처럼 규범 또는 기능에 근거한다. **프로젝트 계약**은 브리프·`design.md`·플랫폼이 그 조건을 선택했을 때만 적용된다 — AAA 기준, HIG·M3의 44/48px 같은 모바일 앱 수준 목표가 여기 속한다. **관찰 후보**는 스타일 휴리스틱의 시작값이며 실제 렌더에서 확인해 조정한다. 외부 스킬에서 가져온 수치는 "시작값"이라 적고, 근거가 약하면 "실측 조정"을 덧붙인다.
|
||
|
||
## 이 문서를 읽는 법
|
||
|
||
| 상황 | 읽을 곳 |
|
||
|---|---|
|
||
| AA와 AAA, 자동 점수와 실제 준수의 관계가 헷갈린다 | §0 |
|
||
| `<div onClick>` 대신 뭘 써야 하는지, ARIA를 언제 쓰는지 | §1 |
|
||
| 아이콘 전용 버튼, 인라인 SVG의 이름을 어떻게 붙이는지 | §2 |
|
||
| 포커스 링 스타일·대비·가림 방지 | §3 |
|
||
| 탭·메뉴·콤보박스·리스트박스·다이얼로그의 키보드 계약, 모달, SPA 라우팅 | §4 |
|
||
| 스킵 링크·랜드마크·링크 텍스트 | §5 |
|
||
| 라벨·자동완성·입력 타입·비활성화 규칙·오류 처리 | §6 |
|
||
| 토스트·로딩 상태를 어떻게 알리는지 | §7 |
|
||
| 터치 타깃 크기와 히트 영역 확장 | §8 |
|
||
| 깜빡임·자동재생·호버 콘텐츠 | §9 |
|
||
| 드래그로만 되는 조작의 대안 | §10 |
|
||
| 비디오·오디오 자막 | §11 |
|
||
| `prefers-*` 미디어 쿼리 중 뭐가 하드 게이트고 뭐가 점진적 향상인지 | §12 |
|
||
| 감사를 어떤 순서로 돌리는지, MCP가 없을 때 뭘 쓰는지 | §13 |
|
||
| 발견한 문제를 어떤 형식으로 보고하는지 | §14 |
|
||
| 색·모션 단독 전달 금지(다른 문서가 이 문서를 정본으로 지목한 규칙) | 부록 A |
|
||
|
||
---
|
||
|
||
## 0. 판정 원칙
|
||
|
||
**하드 게이트.** WCAG 2.2는 A·AA·AAA 세 적합성 수준을 정의하며, 셋은 서로 다른 요구다 — AAA를 만족한다고 모두에게 접근 가능해지지는 않는다[WCAG-22]. designpaca는 **AA를 하드 게이트**로 삼는다. 이 문서의 모든 절은 별도 표기가 없는 한 AA 이하 기준을 하드 게이트로 취급한다.
|
||
|
||
**프로젝트 계약.** AAA 기준([WCAG-FOCUS-APPEAR] 2.4.13 등)과 HIG·M3의 44/48px 같은 플랫폼 권장은 브리프가 그 수준을 선택했을 때만 적용된다. "모바일 앱 수준"이라는 말이 브리프에 있으면 project contract로 올라간다(§8).
|
||
|
||
**자동 점수는 준수가 아니다.** Lighthouse 접근성 100점이나 axe 통과는 도구가 검출할 수 있는 하위 집합만 확인한 것이다. 100점이 WCAG 준수와 같지 않고, 낮은 점수도 그 자체로 이슈 증거를 대체하지 않는다[SKILL-WEB-A11Y]. 통과 판정은 점수가 아니라 §13의 감사 루프(키보드 조작·스크린리더 확인 포함)로 내린다.
|
||
|
||
**표준 기반 평가는 사용자 평가로 보완한다.** W3C 자신이 표준 기반 적합성 평가와 실제 사용자 평가를 함께 써야 한다고 설명한다 — 적은 표본이나 한 번의 평가를 모든 사용자에게 일반화하지 않는다[W3C-USER-EVAL]. 자동 검사·수동 APG 대조·스크린리더 확인을 전부 통과했더라도, 실사용자 피드백이 오면 그것으로 규칙을 다시 검토한다.
|
||
|
||
**APCA는 규범이 아니다.** 대비의 하드 게이트는 WCAG 2 비율이다. APCA는 2026년 기준으로도 WCAG 3 초안에 이름조차 실려 있지 않고 자체 문서가 스스로를 beta로 표시하는 평가 후보일 뿐이다[APCA-STATUS] — 상세는 [color.md](color.md) §6.
|
||
|
||
---
|
||
|
||
## 1. 시맨틱·네이티브 우선
|
||
|
||
**하드 게이트.** ARIA를 쓰기 전에 같은 의미·동작을 가진 네이티브 HTML 요소가 있는지 먼저 확인한다. 다섯 가지 ARIA 원칙이다[SKILL-BETTER-A11Y]:
|
||
|
||
1. 필요한 시맨틱·동작을 가진 네이티브 요소가 있으면 다른 요소를 ARIA로 흉내 내지 않고 그것을 쓴다.
|
||
2. 정말 필요하지 않으면 네이티브 시맨틱을 바꾸지 않는다.
|
||
3. 인터랙티브 ARIA 컨트롤은 반드시 키보드로 조작할 수 있어야 한다 — role은 그 위젯의 전체 키보드 모델을 지키겠다는 약속이다.
|
||
4. 포커스를 받는 요소에 `role="presentation"`이나 `aria-hidden="true"`를 걸지 않는다.
|
||
5. 모든 인터랙티브 요소는 접근 가능한 이름을 가져야 한다(§2).
|
||
|
||
나쁜 ARIA보다 ARIA가 없는 편이 낫다. 스크린리더는 role을 그대로 믿으므로, 틀린 role은 아무것도 없는 것보다 더 나쁘다.
|
||
|
||
| 요소 | 쓰는 곳 | 이유 |
|
||
|---|---|---|
|
||
| `<a href>` | 이동 — 어딘가로 가거나 URL이 바뀌는 모든 것 | Cmd/Ctrl/가운데 클릭, 우클릭으로 링크 복사, Enter로 활성화가 전부 공짜다 |
|
||
| `<button>` | 액션 — 제출·토글·열기·삭제 | 포커스, Enter와 Space 둘 다로 활성화, 폼 시맨틱이 전부 공짜다 |
|
||
| `<div onClick>` | 아무 데도 쓰지 않는다 | role도 포커스도 키보드도 없다. 스크린리더에는 그냥 텍스트다 |
|
||
|
||
```html
|
||
<!-- 나쁨: 키보드와 스크린리더에서 보이지 않는다 -->
|
||
<div onclick="openSettings()">설정</div>
|
||
|
||
<!-- 좋음: 포커스·Enter/Space 활성화·시맨틱이 전부 공짜다 -->
|
||
<button onclick="openSettings()">설정</button>
|
||
```
|
||
|
||
보이는 게 클릭 가능하면 실제로 클릭 가능해야 하고, 클릭 가능하면 실제 인터랙티브 요소여야 한다. 링크를 버튼으로, 버튼을 링크로 다시 만들면 사용자의 기대(가운데 클릭으로 새 탭 열기 등)가 깨진다. 이동하는 "버튼"은 스타일만 버튼인 `<a>`다[SKILL-BETTER-A11Y].
|
||
|
||
**네이티브 요소를 정말 쓸 수 없을 때만** `role="button"` + `tabindex="0"`의 완전한 폴리필을 쓴다(코드는 §4). 코드량 자체가 네이티브 요소보다 항상 많다는 사실이 네이티브를 우선해야 하는 실무적 이유이기도 하다.
|
||
|
||
**흔한 ARIA 실수**[SKILL-BETTER-A11Y]
|
||
|
||
| 실수 | 왜 실패하는가 |
|
||
|---|---|
|
||
| `<div>`나 `<span>`에 `aria-label` | 대부분의 스크린리더가 인터랙티브하지 않고 role도 없는 요소의 이름은 무시한다 |
|
||
| `<button role="button">` | 중복 role. 잡음만 추가하고 이득이 없다 |
|
||
| 포커스 가능한 요소에 걸린 `aria-hidden="true"` | Tab으로는 갈 수 있지만 스크린리더에는 존재하지 않는 요소가 생긴다 |
|
||
| 존재하지 않는 id를 가리키는 `aria-labelledby`/`aria-describedby` | 조용히 이름·설명이 없는 것으로 처리된다 |
|
||
| 내비게이션 목록에 `role="menu"` | `menu`는 앱 스타일 화살표 키 동작을 약속한다. 사이트 내비게이션은 `<nav>` + 리스트다 |
|
||
|
||
---
|
||
|
||
## 2. 접근 가능한 이름
|
||
|
||
**하드 게이트.** 모든 인터랙티브 컨트롤은 접근 가능한 이름이 있어야 한다[WCAG-NRV]. 이름의 우선순위는 `aria-labelledby` > `aria-label` > 네이티브 라벨(`<label>`·텍스트 콘텐츠·`alt`) > `title` 속성이다. 가능하면 보이는 텍스트나 `aria-labelledby`를 쓴다 — `aria-label`은 화면에 보이지 않아 UI와 어긋나기 쉽고 번역 도구가 일관되게 처리하지 못한다[SKILL-BETTER-A11Y].
|
||
|
||
**아이콘 전용 버튼**은 텍스트 라벨이 없으므로 항상 이름이 필요하다. 아이콘 자체는 장식이므로 `aria-hidden="true"`로 접근성 트리에서 뺀다.
|
||
|
||
```html
|
||
<!-- 좋음: 보이는 텍스트에서 이름이 나오고 아이콘은 숨긴다 -->
|
||
<button>
|
||
<svg aria-hidden="true">…</svg> 삭제
|
||
</button>
|
||
|
||
<!-- 좋음: 아이콘만 있을 때는 명시적 이름 -->
|
||
<button aria-label="삭제">
|
||
<svg aria-hidden="true">…</svg>
|
||
</button>
|
||
```
|
||
|
||
**Label in Name — [WCAG-LABEL-NAME] (2.5.3).** 보이는 라벨과 접근 가능한 이름이 다르면 음성 제어 사용자가 깨진다. 버튼에 "보내기"라고 쓰여 있는데 `aria-label="메시지 전송"`을 걸면, "보내기 클릭"이라고 말하는 사용자의 명령이 인식되지 않는다. 접근 가능한 이름은 보이는 텍스트를 반드시 포함해야 한다[SKILL-BETTER-A11Y].
|
||
|
||
**인라인 SVG**는 장식인지 의미가 있는지로 마크업이 갈린다[SKILL-BETTER-A11Y].
|
||
|
||
```html
|
||
<!-- 장식 아이콘: 이름을 가진 버튼 안에 있다 -->
|
||
<button aria-label="닫기">
|
||
<svg aria-hidden="true" focusable="false">…</svg>
|
||
</button>
|
||
|
||
<!-- 독립된 의미 있는 아이콘 -->
|
||
<svg role="img" aria-label="인증된 계정">…</svg>
|
||
```
|
||
|
||
`focusable="false"`는 레거시 IE·Edge가 SVG를 탭 순서에 넣는 것을 막기 위한 것이다. 단순한 경우에는 `<img src="icon.svg" alt="…">`가 가장 안정적으로 전달된다.
|
||
|
||
**브랜드명·코드 토큰에는 `translate="no"`를 붙인다.** 한국어 로케일 프로젝트에서 영문 브랜드명·식별자가 브라우저 자동번역 확장에 의해 깨지는 것을 막는다[SKILL-BETTER-A11Y].
|
||
|
||
```html
|
||
<span translate="no">designpaca</span>
|
||
```
|
||
|
||
**모달·시트·드로어의 제목.** 열리는 표면은 항상 접근 가능한 제목을 가져야 한다. 시각적으로 숨기려면 `.sr-only`(정의는 [motion.md](motion.md) 코드 G)로라도 존재해야 하며, 완전히 생략하는 것은 [WCAG-NRV] 위반이다[SKILL-SHADCN]. 컴포넌트 시스템을 쓰는 프로젝트의 합성 규칙은 [component-systems.md](component-systems.md)를 따른다.
|
||
|
||
---
|
||
|
||
## 3. 포커스
|
||
|
||
**하드 게이트.** `:focus-visible`만 스타일링하고 `:focus` 단독을 쓰지 않는다. 브라우저는 키보드·보조기술 포커스에서만 `:focus-visible`을 보여 주고 마우스 클릭에서는 억제한다 — 클릭에서는 이미 포커스가 명백하기 때문이다. 검증된 대체 없이 `outline: none`을 쓰지 않는다. 이는 눈으로 보는 키보드 사용자의 내비게이션 자체를 지운다[SKILL-BETTER-A11Y].
|
||
|
||
```css
|
||
/* 가장 안전: 브라우저 기본 링을 유지하고 여백만 준다 */
|
||
:focus-visible { outline-offset: 2px; }
|
||
|
||
/* 브랜드 링이 필요할 때: 검증된 토큰을 쓴다 */
|
||
:focus-visible {
|
||
outline: 2px solid var(--ring, currentColor);
|
||
outline-offset: 2px;
|
||
}
|
||
```
|
||
|
||
브라우저의 기본 포커스 링은 플랫폼·고대비 설정에 맞춰 스스로 적응한다. `outline-offset`만 얹으면 그 적응력을 유지한 채 여백만 얻는다. 커스텀 링을 쓰기로 했다면 `currentColor`나 토큰이 **닿는 모든 인접 색**(카드·히어로·다크모드 표면)에서 3:1 이상인지 직접 검증한다 — [WCAG-NONTEXT] (1.4.11)이 요구하는 것은 텍스트 대비가 아니라 인접 배경과의 대비다.
|
||
|
||
`:focus-within`은 래퍼가 켜져야 하는 경우에 쓴다 — 테두리 안에 아이콘이 있는 검색창처럼, 내부 입력이 포커스를 받으면 바깥 래퍼도 함께 밝아져야 할 때다[SKILL-BETTER-A11Y].
|
||
|
||
**포커스 가림 방지 — [WCAG-FOCUS-OBSCURED] (2.4.11, WCAG 2.2 신규, AA).** 키보드 포커스를 받은 요소가 스티키 헤더·고정 CTA 바에 완전히 가려지면 안 된다. 앵커 타깃과 포커스 가능 요소에 `scroll-margin-top`을 주되, 값을 손으로 맞추지 않고 [tokens.md](tokens.md)의 `--header-h` 토큰과 연결한다.
|
||
|
||
```css
|
||
:focus-visible, [data-anchor-target] { scroll-margin-top: var(--header-h); }
|
||
```
|
||
|
||
**`forced-colors: active` (Windows 고대비 모드).** 이 미디어 쿼리는 Widely available 상태다(확인 2026-09-24)[WEB-BASELINE]. 기본 색 조정을 유지하거나 `Highlight` 같은 시스템 색을 명명한다. `forced-color-adjust: none`은 저작한 색을 그대로 고정하므로, 강제 색 치환 후에도 실제로 지각 가능함을 검증한 곳에서만 쓴다[SKILL-BETTER-A11Y].
|
||
|
||
**프로젝트 계약 — [WCAG-FOCUS-APPEAR] (2.4.13, AAA).** 포커스 표시 영역이 비포커스 상태 컴포넌트 둘레 2px 두께 이상이고, 포커스·비포커스 상태 사이 대비가 3:1 이상이어야 한다는 더 엄격한 기준이다. 브리프가 AAA를 명시했을 때만 적용한다.
|
||
|
||
---
|
||
|
||
## 4. 키보드
|
||
|
||
### 4.1 tabindex 규칙
|
||
|
||
**하드 게이트.** `tabindex="0"`은 자연 탭 순서에 합류시킬 때만 쓴다 — 네이티브로 포커스되지 않는 커스텀 인터랙티브 요소 전용이다. `tabindex="-1"`은 JS로만 포커스할 대상(헤딩·모달 컨테이너·roving tabindex 멤버)에 쓴다. **양수 tabindex는 절대 쓰지 않는다** — 페이지 전체의 탭 순서를 납치한다. 순서가 틀렸으면 tabindex가 아니라 DOM 순서를 고친다[SKILL-BETTER-A11Y].
|
||
|
||
### 4.2 roving tabindex — 복합 위젯
|
||
|
||
탭·메뉴·툴바·라디오 그룹처럼 하나로 묶여 Tab 정지점 하나만 차지해야 하는 위젯은 활성 항목만 `tabindex="0"`, 나머지는 `tabindex="-1"`로 두고 화살표 키가 포커스와 `0`을 함께 옮긴다[SKILL-BETTER-A11Y].
|
||
|
||
```html
|
||
<div role="tablist">
|
||
<button role="tab" id="tab-1" tabindex="0" aria-selected="true">개요</button>
|
||
<button role="tab" id="tab-2" tabindex="-1" aria-selected="false">가격</button>
|
||
<button role="tab" id="tab-3" tabindex="-1" aria-selected="false">FAQ</button>
|
||
</div>
|
||
```
|
||
|
||
```js
|
||
// ArrowLeft/ArrowRight로 activeIndex를 옮기고, 옮긴 탭에만 tabindex=0을 준다
|
||
function onTabsKeydown(e, tabs, activeIndex) {
|
||
const delta = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
|
||
if (!delta) return;
|
||
const next = (activeIndex + delta + tabs.length) % tabs.length; // 끝에서 wrap
|
||
tabs[activeIndex].tabIndex = -1;
|
||
tabs[next].tabIndex = 0;
|
||
tabs[next].focus();
|
||
return next;
|
||
}
|
||
```
|
||
|
||
### 4.3 커스텀 인터랙티브 요소의 키보드 핸들러
|
||
|
||
네이티브 요소를 정말 쓸 수 없을 때, `role="button"` + `tabindex="0"`의 완전한 폴리필은 클릭 리스너에 더해 `keydown`에서 Enter·Space를 처리한다. **기본 동작을 막지 않으면 Space가 페이지를 스크롤한다.**
|
||
|
||
```js
|
||
el.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
activate();
|
||
}
|
||
});
|
||
```
|
||
|
||
**함정 — 네이티브 `<button>`에는 이 핸들러를 절대 추가하지 않는다.** 네이티브 버튼은 이미 Enter와 Space에서 `click` 이벤트를 발생시킨다. 같은 자리에 `keydown` 핸들러로 같은 액션을 또 호출하면, 키보드 사용자만 클릭이 두 번 실행되는(이중 트리거) 버그가 생긴다. 이 핸들러는 `role="button"`으로 흉내 낸 커스텀 요소 전용이다[SKILL-WEB-A11Y].
|
||
|
||
### 4.4 ARIA APG 키보드 패턴표
|
||
|
||
네이티브 요소는 아래 동작을 공짜로 갖는다. 커스텀 위젯에 role을 붙였다면 (선택)으로 표시한 동작을 뺀 나머지는 전부 구현해야 한다 — role은 그 위젯의 전체 키보드 모델을 지키겠다는 약속이다[SKILL-BETTER-A11Y]. 각 행은 [WAI-ARIA APG](https://www.w3.org/WAI/ARIA/apg/)의 해당 패턴 페이지로 1차 확인됐다[ARIA-APG].
|
||
|
||
| 위젯 | 키 계약 |
|
||
|---|---|
|
||
| Dialog | 여는 순간 다이얼로그 안 요소로 포커스 이동(파괴적 확인은 가장 덜 위험한 액션으로). Tab/Shift+Tab이 안에서만 순환(끝에서 wrap). Escape로 닫힘 |
|
||
| Tabs | 화살표 키(세로형은 Up/Down)로 탭 간 이동(wrap). Tab은 탭 목록을 벗어나 패널로 간다. Home/End로 처음/끝 탭. 활성화는 자동(포커스 즉시 전환) 또는 수동(Enter/Space로 전환) 중 선택 |
|
||
| Menu button | Enter/Space가 열고 첫 항목에 포커스. (선택) ArrowDown이 열고 첫 항목, ArrowUp이 열고 마지막 항목. Escape가 닫고 버튼에 포커스 복귀 |
|
||
| Disclosure/accordion | 헤더는 `<button aria-expanded>`. Enter·Space가 토글 |
|
||
| Combobox | ArrowDown이 팝업을 열거나 안으로 이동. Enter가 자동완성 제안을 받아들임. Escape가 팝업을 닫고 입력으로 돌아감. 타이핑이 필터링. 팝업이 listbox/grid/tree/dialog인지에 따라 내부 탐색 방식이 달라진다 |
|
||
| Listbox/radio group | 화살표 키가 선택을 옮긴다(단일 선택 시). Home/End(5개 이상일 때 권장)로 처음/끝. 그룹 전체가 Tab 정지점 하나 |
|
||
|
||
보편 규칙: Escape는 가장 최근에 연 것부터 닫는다(툴팁 → 메뉴 → 다이얼로그 순). 화살표 키는 위젯 **내부**를 움직이고, Tab은 위젯 **사이**를 움직인다. `<textarea>`에서 Enter는 줄바꿈이고 ⌘/Ctrl+Enter가 제출이다[SKILL-BETTER-A11Y].
|
||
|
||
### 4.5 모달 포커스 트랩·inert·복귀
|
||
|
||
**네이티브 `<dialog>` + `showModal()`을 우선 고려한다.** 트랩·배경 `inert`·Escape 처리를 공짜로 얻는다. `<dialog>`는 Widely available 상태다(확인 2026-09-24, baseline 2022-03-14)[WEB-BASELINE]. 기하 계약(위치·중심·scroll owner)은 [audit-gate.md](audit-gate.md)와 [preflight.md](preflight.md) §4-1이 정하므로 여기서 반복하지 않는다. 이 절은 **포커스 관리**만 다룬다.
|
||
|
||
네이티브를 쓸 수 없는 커스텀 오버레이는 `role="dialog"` + `aria-modal="true"` + `aria-labelledby`(제목 id)를 갖추고, 아래를 구현한다[SKILL-BETTER-A11Y]:
|
||
|
||
```js
|
||
// 열 때: 배경을 inert로 만들고 첫 포커스 가능 요소로 이동한다
|
||
document.getElementById('app-content').inert = true; // 배경을 탭 순서·보조기술 양쪽에서 제거한다
|
||
const dialog = dialogRef;
|
||
(dialog.querySelector('[autofocus]') ??
|
||
dialog.querySelector('button, [href], input, select, textarea'))?.focus();
|
||
|
||
// 닫을 때: 배경을 복구하고 포커스를 연 요소로 되돌린다
|
||
document.getElementById('app-content').inert = false;
|
||
triggerEl?.focus();
|
||
```
|
||
|
||
`inert` 속성은 Widely available 상태다(확인 2026-09-24, baseline 2023-04-11)[WEB-BASELINE]. 파괴적 확인 다이얼로그는 첫 포커스를 가장 덜 위험한 액션(대개 취소)에 둔다. 다이얼로그에 `overscroll-behavior: contain`을 걸어, 안에서 스크롤해도 배경 페이지가 함께 스크롤되지 않게 한다.
|
||
|
||
### 4.6 SPA 라우트 변경
|
||
|
||
**언제:** 클라이언트 사이드 라우팅(SPA)을 쓸 때. 라우트 전환은 포커스와 알림을 저절로 리셋하지 않는다. 라우트가 바뀌면 `document.title`을 새 맥락에 맞게 갱신하고, 새 뷰의 `<h1 tabindex="-1">` 또는 `<main>`으로 포커스를 옮긴다. 뒤로/앞으로 내비게이션은 스크롤 위치를 복원하고, 앞으로 이동은 맨 위로 스크롤한다[SKILL-BETTER-A11Y].
|
||
|
||
---
|
||
|
||
## 5. 구조
|
||
|
||
**하드 게이트 — 스킵 링크.** 반복되는 내비게이션을 건너뛸 수 있는 링크를 페이지 첫 포커스 가능 요소로 둔다. 포커스 전에는 화면 밖에 숨긴다.
|
||
|
||
```css
|
||
.skip-link { position: absolute; inset-inline-start: -999px; }
|
||
.skip-link:focus { inset-inline-start: 16px; top: 16px; }
|
||
```
|
||
|
||
```html
|
||
<body>
|
||
<a class="skip-link" href="#main-content">본문으로 건너뛰기</a>
|
||
<header>…</header>
|
||
<main id="main-content" tabindex="-1">…</main>
|
||
</body>
|
||
```
|
||
|
||
**하드 게이트 — 랜드마크.** 보이는 주 `<main>` 랜드마크 하나를 노출한다. `<header>`·`<nav>`·`<aside>`·`<footer>`는 스크린리더 사용자가 랜드마크 사이를 건너뛰며 이동할 수 있게 한다. 같은 타입 랜드마크가 여러 개면 구분 레이블을 단다.
|
||
|
||
```html
|
||
<nav aria-label="주 메뉴">…</nav>
|
||
<nav aria-label="브레드크럼">…</nav>
|
||
```
|
||
|
||
`div`로 짠 레이아웃을 시맨틱 랜드마크로 바꾸는 것 자체가 흔히 빠뜨리는 항목이다[SKILL-WEB-A11Y].
|
||
|
||
**헤딩 순서.** h1 하나, 레벨을 건너뛰지 않는다(기준은 [preflight.md](preflight.md) §1). 헤딩은 구조이지 스타일이 아니다 — 크기 때문에 태그를 고르지 말고, 레벨은 문서 구조로 정한 뒤 시각적 크기는 CSS로 별도로 입힌다[SKILL-BETTER-A11Y].
|
||
|
||
**링크 텍스트 — [WCAG-LINK] (2.4.4).** 링크의 목적은 링크 텍스트만으로, 또는 텍스트와 프로그램적으로 확인 가능한 맥락을 합쳐서 알 수 있어야 한다. 스크린리더의 링크 목록 모드는 맥락 없이 텍스트만 읽으므로, "더보기"·"여기"·"클릭"을 단독으로 쓰지 않고 목적어를 포함한다("가격표 보기"). 실제 카피 문구·톤은 [product-copy.md](product-copy.md)를 따른다. 새 탭으로 여는 링크는 `rel="noopener"`와 함께 "새 탭에서 열림"을 알리는 숨김 텍스트(`.sr-only`)를 둔다.
|
||
|
||
---
|
||
|
||
## 6. 폼
|
||
|
||
### 6.1 레이블
|
||
|
||
**하드 게이트.** 모든 컨트롤은 프로그램적 레이블이 있어야 한다 — `id`를 가리키는 `<label for>` 또는 감싸는 `<label>`. **placeholder는 레이블이 아니다.** 입력하는 순간 사라지고 대개 대비 기준도 통과하지 못한다[SKILL-BETTER-A11Y].
|
||
|
||
```html
|
||
<label for="email">이메일</label>
|
||
<input id="email" type="email" autocomplete="email" />
|
||
|
||
<!-- 감싸는 레이블: 레이블과 컨트롤이 같은 히트 영역을 공유한다 -->
|
||
<label>
|
||
<input type="checkbox" /> 소식 받기
|
||
</label>
|
||
```
|
||
|
||
필수 필드는 네이티브 `required`와 폼당 한 번 설명하는 보이는 표시("* 필수")를 함께 쓴다. 레이블에 더해 입력 형식 예시를 보여주는 것은 placeholder의 정당한 용도다(`placeholder="name@company.com"`).
|
||
|
||
### 6.2 자동완성 — [WCAG-INPUT-PURPOSE] (1.3.5, AA)
|
||
|
||
**하드 게이트.** 사용자 정보를 묻는 입력은 의미 있는 `autocomplete` 값을 가져야 한다는 WCAG 요구다. 값을 지어내지 않고 아래 표를 따른다[SKILL-BETTER-A11Y].
|
||
|
||
| 필드 | `autocomplete` |
|
||
|---|---|
|
||
| 이름 | `name`(또는 `given-name`/`family-name`) |
|
||
| 이메일 | `email` |
|
||
| 전화번호 | `tel` |
|
||
| 주소 | `street-address`, `address-line1`, `postal-code`, `country` |
|
||
| 카드 | `cc-number`, `cc-exp`, `cc-csc`, `cc-name` |
|
||
| 로그인 | `username`, `current-password` |
|
||
| 가입/재설정 | `new-password` |
|
||
| 2단계 인증 코드 | `one-time-code` |
|
||
|
||
섹션이 여러 개일 때는 접두어를 붙인다: `autocomplete="shipping street-address"`.
|
||
|
||
### 6.3 입력 타입·inputmode
|
||
|
||
올바른 `type`·`inputmode`가 맞는 모바일 키보드를 소환한다[SKILL-BETTER-A11Y].
|
||
|
||
| 입력 | 쓸 것 |
|
||
|---|---|
|
||
| 이메일·URL·전화 | `type="email"`, `type="url"`, `type="tel"` |
|
||
| OTP/PIN/카드번호 | `type="text" inputmode="numeric"`(텍스트 시맨틱 유지, 스피너 없음) |
|
||
| 금액·소수 | `type="text" inputmode="decimal"` |
|
||
| 진짜 수량(정수 스피너가 맞는 값) | `type="number"` |
|
||
|
||
이메일·코드·사용자명에는 `spellcheck="false"`를 건다. 값 자체는 사용자가 붙여넣게 두고, 타이핑을 막거나 문자를 필터링하지 않는다 — 비밀번호·OTP는 붙여넣기가 표준 사용 방식이다. 진짜 `<form>`·정확한 `autocomplete`·가짜 입력 금지로 비밀번호 관리자·2FA 자동입력과 계속 호환되게 유지한다[SKILL-BETTER-A11Y].
|
||
|
||
### 6.4 접근 가능한 인증 — [WCAG-AUTH] (3.3.8, AA, WCAG 2.2 신규)
|
||
|
||
**언제:** 로그인·가입 플로우를 구현할 때. 로그인은 인지 기능 테스트(비밀번호 암기·퍼즐)만으로 막지 않는다. 다음 중 최소 하나가 함께 있어야 한다: (a) 인지 기능 테스트에 의존하지 않는 대안(패스키·SSO·이메일 링크), (b) 테스트를 돕는 메커니즘(붙여넣기 허용, 자동완성), (c) 객체 인식이나 사용자가 제공한 콘텐츠 인식 기반 테스트[WCAG-AUTH]. 최소한 붙여넣기와 정확한 `autocomplete`는 항상 지킨다.
|
||
|
||
### 6.5 중복 입력 방지 — [WCAG-REDUNDANT] (3.3.7, A, WCAG 2.2 신규)
|
||
|
||
**언제:** 다단계 폼(체크아웃·가입 등)을 구현할 때. 같은 세션에서 이미 받은 정보를 다시 입력하라고 강요하지 않는다 — 배송지=청구지 자동 채움 체크박스처럼 자동으로 채우거나 선택할 수 있게 한다. 예외는 보안 재확인과 만료된 정보뿐이다.
|
||
|
||
### 6.6 제출 버튼
|
||
|
||
**프로젝트 계약** — WCAG가 직접 요구하지는 않는 강한 기본값이라는 이유로 하드 게이트가 아니라 프로젝트 계약으로 둔다. 제출 버튼은 요청이 시작되기 전까지 활성 상태를 유지한다. **폼이 유효해질 때까지 미리 비활성화하지 않는다** — 사용자가 어떤 필드가 문제인지 알 방법이 없어진다. 요청이 시작된 뒤에만 비활성화하고, 원래 레이블 옆에 스피너를 둔다("저장" + 스피너이지, 스피너만이 아니다. 레이블이 있어야 보조기술이 어떤 버튼이 바쁜지 안다)[SKILL-BETTER-A11Y].
|
||
|
||
**하드 게이트** — 비활성화 때문에 오류를 확인할 방법 자체가 사라지면 오류 식별 실패로 하드 게이트다.
|
||
|
||
### 6.7 disabled vs aria-disabled — 결정표
|
||
|
||
**프로젝트 계약** — WCAG가 직접 요구하지는 않는 강한 기본값이라는 이유로 하드 게이트가 아니라 프로젝트 계약으로 둔다.
|
||
|
||
| 상황 | 쓸 것 | 결과 |
|
||
|---|---|---|
|
||
| 컨트롤이 진짜로 쓸 수 없는 상태(네이티브 지원 있음) | `disabled` | 탭 순서에서 빠짐, 활성화 억제, `:disabled` 스타일 적용, 폼 제출에서 제외됨 — 전부 자동 |
|
||
| 이유를 옆에 텍스트로 보여줘야 하거나, 비활성 상태에서도 탭 순서·hover에 남겨야 함(툴팁이 필요할 때) | `aria-disabled="true"` | 상태만 announce된다. 포커스 가능성·동작·스타일은 바뀌지 않으므로 코드·CSS로 직접 막아야 한다 |
|
||
| 커스텀 컨트롤이라 네이티브 `disabled`가 없음 | `aria-disabled="true"` | 위와 동일 |
|
||
|
||
**함정 — 네이티브 `disabled` 위의 툴팁은 절대 열리지 않는다.** `disabled`는 포인터 이벤트를 억제하고 탭 순서에서 빠지므로, 키보드·터치에서는 열릴 방법이 없고 마우스에서도 신뢰할 수 없다. 이유를 설명해야 한다면 `aria-disabled="true"`로 바꾸거나 이유를 옆에 보이는 텍스트로 둔다. **`disabled`와 `aria-disabled`를 같은 요소에 동시에 설정하지 않는다.** `aria-disabled`를 쓸 때는 핸들러에서 포인터·키보드 활성화를 직접 막고, 폼 제출도 필요하면 직접 막고, forced-colors 대응 스타일을 포함한다. 비활성 컨트롤은 대비 최소 기준의 예외지만 그래도 읽을 수 있게 유지한다(역할 정의는 [color.md](color.md) §8 "비활성 텍스트" 역할)[SKILL-BETTER-A11Y].
|
||
|
||
### 6.8 오류 처리
|
||
|
||
**하드 게이트.** 완전한 오류 패턴이다[SKILL-BETTER-A11Y]:
|
||
|
||
```html
|
||
<label for="email">이메일</label>
|
||
<input
|
||
id="email" type="email" autocomplete="email"
|
||
aria-invalid="true" aria-describedby="email-error"
|
||
/>
|
||
<p id="email-error">올바른 이메일 주소를 입력하세요.</p>
|
||
```
|
||
|
||
- `aria-invalid="true"`는 실패한 필드에만, 고치면 제거한다.
|
||
- `aria-describedby`가 필드와 인라인 오류를 연결해 스크린리더가 필드와 함께 오류를 읽게 한다.
|
||
- 오류는 필드 옆에 인라인으로, 아이콘이나 텍스트와 함께 렌더한다. **빨간 테두리만으로는 안 된다** — 색상 단독 신호다.
|
||
- 제출 시 **첫 번째로 실패한 필드에 포커스를 이동한다.** 포커스 이동 자체가 announcement이므로 별도 알림이 필요 없다.
|
||
- 제출 전 값은 trim한다 — 자동완성과 텍스트 확장이 뒤 공백을 남긴다.
|
||
- 폼 레벨 오류(특정 필드에 묶이지 않는 실패)에만 `role="alert"`를 예약한다(§7).
|
||
|
||
**관찰 후보 — 검증 시점 [SKILL-APPLE-DESIGN].** 필드를 벗어날 때(blur) 또는 타이핑 중 짧은 디바운스 후 검증하고, 제출 버튼을 누른 뒤에야 오류를 보여주지 않는다. 불완전한 상태로도 제출을 허용해 검증이 드러나게 한다 — 타이핑 자체를 막거나 문자를 필터링하지 않는다.
|
||
|
||
### 6.9 세션 타이밍
|
||
|
||
**언제:** 세션 타임아웃이 있는 웹앱일 때. 시간 제한은 연장할 수 있어야 한다 — 만료 전 경고 모달에 "연장" / "로그아웃" 선택지를 준다.
|
||
|
||
---
|
||
|
||
## 7. 상태 메시지 — [WCAG-STATUS] (4.1.3, AA)
|
||
|
||
**하드 게이트.** 페이지가 새로고침 없이 바뀌는 콘텐츠(토스트·검증 결과·검색 결과 수·로딩 상태)를 어떻게 알릴지는 아래 순서로 판정하고, **첫 번째로 맞는 항목에서 멈춘다**[SKILL-BETTER-A11Y]:
|
||
|
||
1. **포커스가 이미 그곳으로 이동한다** — 열린 모달, 첫 실패 필드처럼. 포커스 이동 자체가 announcement다. 추가 조치가 필요 없다.
|
||
2. **특정 컨트롤에 묶여 있다** — 필드 오류, 글자 수 카운터처럼. 해당 컨트롤의 `aria-describedby`로 연결한다.
|
||
3. **긴급하지 않고 특정 컨트롤에 묶이지 않는다** — 토스트, "저장됨", 결과 개수, 로딩 상태처럼. `role="status"`(polite, 말이 멈추길 기다림).
|
||
4. **긴급하고 특정 컨트롤에 묶이지 않는다** — 폼 레벨 실패, 세션 만료처럼. `role="alert"`(assertive, 즉시 인터럽트).
|
||
|
||
| 메커니즘 | 결합 | 언제 |
|
||
|---|---|---|
|
||
| `role="status"` | `aria-live="polite"` + `aria-atomic="true"` | 토스트, "저장됨", 결과 수, 로딩 갱신 |
|
||
| `role="alert"` | `aria-live="assertive"` + `aria-atomic="true"` | 긴급한 오류뿐. 과용이 가장 흔한 실수다 — 사용자가 읽던 것을 인터럽트한다 |
|
||
|
||
**재알림 트릭.** 반복되는 polite 업데이트는 DOM에 **안정된 빈 영역**을 미리 두고 그 텍스트만 바꾼다. 매번 콘텐츠를 담은 새 polite 영역을 삽입하면 announce가 일관되지 않는다.
|
||
|
||
```html
|
||
<!-- 처음부터 렌더된 영역, 메시지는 나중에 주입 -->
|
||
<div role="status" class="sr-only" id="status-region"></div>
|
||
```
|
||
|
||
로딩 상태는 갱신되는 영역에 `aria-busy="true"`를 걸고, "불러오는 중…"을 polite로 알린 뒤 결과("12개 결과를 불러왔습니다")를 announce한다.
|
||
|
||
**토스트 특화 규칙**[SKILL-BETTER-A11Y]:
|
||
|
||
- **토스트로 포커스를 옮기지 않는다.** announce만 하고 포커스는 사용자가 작업하던 곳에 둔다.
|
||
- hover 또는 focus가 타이머를 일시정지한다.
|
||
- 자동 소멸은 저위험 확인에만 어울린다. 타임아웃을 쓴다면 **5초가 바닥값**이다.
|
||
- **액션·오류·놓치면 안 되는 정보를 담은 토스트는 자동으로 사라지지 않는다.** 유일한 undo 경로가 담긴 토스트가 시간이 지나 사라지는 것은 예정된 데이터 손실이다.
|
||
|
||
---
|
||
|
||
## 8. 히트 영역 — [WCAG-TARGET] (2.5.8, AA)
|
||
|
||
**하드 게이트.** 포인터 입력 대상은 최소 **24×24 CSS px**이거나 아래 예외 중 하나에 해당해야 한다.
|
||
|
||
**간격 예외의 정확한 정의**: 24px 미만인 대상도, 그 바운딩박스 중심에 24px 지름 원을 그렸을 때 그 원이 다른 대상이나 다른 미달 대상의 원과 겹치지 않으면 통과한다[WCAG-TARGET]. 단순한 경우로는 20px 대상 사이에 4px 간격이 있으면 충분하다. 그 밖의 예외는 동등 기능을 제공하는 다른 컨트롤(Equivalent), 문장 안 인라인 텍스트(Inline), 사용자 에이전트가 정한 크기(User Agent Control), 특정 표현이 필수이거나 법적으로 요구되는 경우(Essential)다.
|
||
|
||
**프로젝트 계약.** HIG의 44×44pt[PLATFORM-HIG-TARGET], Material Design의 48×48dp[PLATFORM-M3-TARGET]는 "모바일 앱 수준"을 브리프가 요구할 때의 계약이다 — WCAG AA의 보편 요건으로 확대하지 않는다. 상세는 [mobile-app-ux.md](mobile-app-ux.md).
|
||
|
||
**히트 영역 확장.** 보이는 요소가 작을 때(20×20 체크박스 등), 감싸는 `<label>`이나 `<button>`에 의사요소로 히트 영역을 넓힌다. **`<input>` 자체에는 걸지 않는다** — 대체 요소는 `::before`/`::after`를 안정적으로 렌더하지 않는다[SKILL-BETTER-A11Y].
|
||
|
||
아래 44px는 모바일 앱 수준 계약일 때의 값이다. 그 밖에는 24px와 간격 예외로 충분하다.
|
||
|
||
```css
|
||
.checkbox-label {
|
||
position: relative;
|
||
width: 20px; height: 20px;
|
||
}
|
||
.checkbox-label::after {
|
||
content: "";
|
||
position: absolute;
|
||
top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%); /* 물리적 중앙 정렬 — 방향과 무관 */
|
||
width: 44px; height: 44px;
|
||
}
|
||
```
|
||
|
||
실제 박스 크기를 키울 여유가 있으면 의사요소 대신 박스 자체를 키운다 — 브라우저가 스크롤·제스처에 쓸 진짜 geometry를 갖게 된다.
|
||
|
||
```css
|
||
.icon-button {
|
||
min-width: 44px; min-height: 44px;
|
||
display: inline-grid; place-items: center;
|
||
}
|
||
```
|
||
|
||
**충돌 규칙.** 확장된 히트 영역이 다른 인터랙티브 요소와 겹치면, 충돌하지 않는 최대 크기로 의사요소를 줄인다. 두 인터랙티브 요소의 히트 영역은 절대 겹치지 않는다.
|
||
|
||
**장식 레이어.** 인터랙티브 콘텐츠 위에 그려진 그라디언트 스크림·글로우·블러 시트 같은 장식 레이어는 자신이 덮은 영역의 포인터 이벤트를 전부 삼킨다 — 아래 컨트롤은 살아 있는 것처럼 보이지만 아무 반응이 없다. `pointer-events: none`으로 이벤트가 아래로 통과하게 하고, `aria-hidden="true"`로 접근성 트리에서도 뺀다[SKILL-BETTER-A11Y]. `antipatterns.md`의 "컬러 글로우" 항목이 시각 판단만 다루는 곳에 이 규칙을 더한다.
|
||
|
||
```css
|
||
.card-glow {
|
||
position: absolute; inset: 0;
|
||
pointer-events: none;
|
||
}
|
||
```
|
||
|
||
클릭으로 닫는 모달 스크림처럼 사용자가 실제로 눌러야 하는 레이어는 예외다 — 그건 장식이 아니라 컨트롤이다.
|
||
|
||
**터치 동작.** 인터랙티브 요소에 `touch-action: manipulation`을 걸어 모바일의 더블탭 확대 지연을 없앤다. 자체 팬·줌·드래그를 구현하는 표면에는 `touch-action: none`을 그 표면에만 스코프해서 건다(페이지 레벨에 걸면 스크롤 자체가 사라진다). hover 전용 스타일은 `@media (hover: hover)`로 게이트한다 — 터치에서는 `:hover`가 탭 뒤에 눌러붙어 고정된 선택 상태처럼 보인다[SKILL-BETTER-A11Y].
|
||
|
||
---
|
||
|
||
## 9. 움직임·깜빡임·호버 콘텐츠
|
||
|
||
**하드 게이트 — 깜빡임 한도 [WCAG-FLASH] (2.3.1, A).** 초당 3회를 넘게 깜빡이거나 일반/적색 섬광 임계값을 넘는 콘텐츠는 만들지 않는다. 펄스형 상태 점, 글로우 애니메이션([svg-filters.md](svg-filters.md)), 스트로브형 모션이 이 한도 안에 있는지 확인한다. 5초 넘게 자동으로 움직이는 콘텐츠의 정지 수단(2.2.2)은 [motion.md](motion.md)가 정본이므로 여기서 반복하지 않는다.
|
||
|
||
**하드 게이트 — 호버·포커스로 나타나는 콘텐츠 [WCAG-HOVER] (1.4.13, AA).** 툴팁·팝오버처럼 호버나 포커스로 나타나는 부가 콘텐츠는 세 조건을 모두 만족해야 한다:
|
||
|
||
- **Dismissible(해제 가능)** — 추가 호버·포커스 없이 사용자가 닫을 수 있다(Esc 등). [preflight.md](preflight.md) §4-1이 이미 다룬다.
|
||
- **Hoverable(호버 가능)** — 포인터가 트리거를 벗어나 콘텐츠 쪽으로 이동해도 사라지지 않는다.
|
||
- **Persistent(지속)** — 호버·포커스가 유지되는 한, 시간 제한 없이 유지된다(사용자가 닫거나 정보가 무효해질 때까지).
|
||
|
||
```css
|
||
/* 호버 가능: 트리거와 콘텐츠 사이에 죽은 공간이 없어야 포인터 이동 중 사라지지 않는다 */
|
||
.tooltip-trigger:hover .tooltip,
|
||
.tooltip-trigger:focus-within .tooltip,
|
||
.tooltip:hover { visibility: visible; }
|
||
```
|
||
|
||
---
|
||
|
||
## 10. 드래그 대안 — [WCAG-DRAG] (2.5.7, AA, WCAG 2.2 신규)
|
||
|
||
**언제:** 정렬 리스트·슬라이더·지도 팬·컬러피커처럼 드래그 제스처로 조작하는 위젯을 만들 때. 드래그 동작을 요구하는 모든 기능은 드래그 없이 단일 포인터(클릭 등)로도 실행할 수 있어야 한다. 예외는 드래그 자체가 본질적으로 필요한 경우(자유 곡선 그리기 등)와, 사용자 에이전트가 정한 동작(브라우저 스크롤·pull-to-refresh 등 저작자가 수정하지 않은 것)이다[WCAG-DRAG].
|
||
|
||
```html
|
||
<!-- 드래그로 순서를 바꾸는 리스트에는 버튼 대안을 함께 둔다 -->
|
||
<li>
|
||
<span>항목 A</span>
|
||
<button aria-label="위로 이동">↑</button>
|
||
<button aria-label="아래로 이동">↓</button>
|
||
</li>
|
||
```
|
||
|
||
---
|
||
|
||
## 11. 미디어 자막·트랜스크립트
|
||
|
||
**언제:** 비디오·오디오 콘텐츠를 프로젝트에 포함할 때. 사전 녹화된 비디오는 자막이 필요하고, 오디오는 트랜스크립트를 제공한다. 소리가 있는 콘텐츠는 절대 자동재생하지 않고, 컨트롤은 항상 렌더한다[SKILL-BETTER-A11Y].
|
||
|
||
```html
|
||
<video controls>
|
||
<source src="demo.mp4" type="video/mp4" />
|
||
<track kind="captions" src="demo.ko.vtt" srclang="ko" label="한국어" default />
|
||
</video>
|
||
|
||
<audio controls src="podcast.mp3"></audio>
|
||
<details>
|
||
<summary>트랜스크립트 보기</summary>
|
||
<p>…전문…</p>
|
||
</details>
|
||
```
|
||
|
||
이미지의 `alt` 5분류(장식·정보성·기능성·텍스트 이미지·복합)는 [images.md](images.md) §5가 정본이다.
|
||
|
||
---
|
||
|
||
## 12. 사용자 선호 신호
|
||
|
||
브라우저 지원과 WCAG 근거에 따라 하드 게이트·프로젝트 계약·점진적 향상으로 갈린다(확인 2026-09-24)[WEB-BASELINE].
|
||
|
||
| 신호 | 지원 상태 | 판정 |
|
||
|---|---|---|
|
||
| `prefers-reduced-motion` | Widely available | **하드 게이트.** 무엇을 끄고 무엇을 남기는지는 [motion.md](motion.md)가 정본이다 |
|
||
| `prefers-contrast` | Widely available(2022-05부터) | **프로젝트 계약.** `more` 값에 반응해 대비를 높이는 것은 요구되면 지킨다 |
|
||
| `forced-colors` | Widely available(2022-09부터) | **하드 게이트** — §3의 규칙을 따른다 |
|
||
| `prefers-reduced-transparency` | Limited — Chrome/Edge만 구현하고 Firefox·Safari는 미지원, Apple이 표준화에 반대 의견을 낸 상태 | **점진적 향상** — 유리 재질을 쓰는 프로젝트에서는 프로젝트 계약([elevation.md](elevation.md) §8). 반투명 표면을 불투명하게 낮추는 구체 레시피는 [elevation.md](elevation.md)·[svg-filters.md](svg-filters.md)가 조건부로 다룬다 |
|
||
|
||
**색만으로 의미를 전달하지 않는다.** 이 규칙 자체는 부록 A에서 다룬다.
|
||
|
||
---
|
||
|
||
## 13. 감사 루프
|
||
|
||
**증거 우선 1~4단계 루프**를 닫힌 상태로 돈다 — 수정 후 같은 감사를 다시 돌리지 않으면 통과라고 말할 수 없다. 5는 그 루프를 통과한 뒤 추가로 확인하는 항목이다.
|
||
|
||
1. **자동 감사.** Chrome DevTools MCP가 있으면 `lighthouse_audit`(accessibility 카테고리)를 쓴다. 없으면 `npx lighthouse <url> --only-categories=accessibility` 또는 `npx @axe-core/cli <url>`로 대체한다. 프로젝트에 `axe-core` 가 있으면 [audit-gate.md](audit-gate.md)의 게이트가 뷰마다 axe 를 함께 돌린다. 도구 목록과 선택 기준은 [harness.md](harness.md) §6을 따른다.
|
||
2. **실패 노드 국소화.** 감사가 지목한 노드로 대상 컴포넌트를 좁힌다.
|
||
3. **수동 확인.** Chrome DevTools MCP가 있으면 `take_snapshot`으로 접근성 트리(이름·역할·상태·랜드마크·헤딩)를 검사한다. 영향받는 플로우를 실제로 키보드로 조작한다. 스크린리더를 켤 수 있으면 최소 단축키로 확인한다 — macOS VoiceOver는 `Cmd+F5`(또는 Touch ID 3회 클릭)로 켜고 끄며 `Ctrl+Option+화살표`로 항목을 이동하고 `Ctrl+Option+U`가 로터(헤딩·링크 목록)를 연다. Windows NVDA는 `Ctrl+Alt+N`으로 시작, `Insert+Q`로 종료, `H`가 다음 헤딩, `K`가 다음 링크, `Insert+F7`이 요소 목록을 연다. 스크린리더를 켤 수 없는 환경에서는 [preflight.md](preflight.md)의 대체 절차(읽기 순서 확인)를 따른다.
|
||
4. **수정 후 동일 감사 재실행.** 1~3단계를 그대로 다시 돌려 재발이 없는지 확인한다. 재실행하지 않은 수정은 미검증이다.
|
||
5. **고대비 모드 확인.** `forced-colors: active`(Windows 고대비)를 에뮬레이션하거나 실제로 켜서, 색만으로 전달되던 정보가 시스템 색 치환 후에도 남아 있는지 본다(§3).
|
||
|
||
**증거 출처를 기록한다.** 어떤 도구·엔진·에뮬레이션으로 확인했는지 보고에 적는다 — "확인함"이라고만 쓰면 재현할 수 없다는 원칙은 [harness.md](harness.md) §6과 동일하다.
|
||
|
||
---
|
||
|
||
## 14. 보고
|
||
|
||
발견한 접근성 문제는 이 문서의 규칙을 근거로 삼되, **보고 형식·심각도 랭킹·톤은 [critique.md](critique.md)를 따른다.** 이 문서에서 다시 정의하지 않는다 — 접근성 하드 게이트 위반은 critique.md의 심각도 매핑에서 항상 Blocking이다.
|
||
|
||
---
|
||
|
||
## 부록 A. 색·모션 단독 전달 금지
|
||
|
||
**하드 게이트.** [color.md](color.md)와 [motion.md](motion.md)가 이 규칙의 정본을 이 문서로 지목했으므로 여기서 정의한다[WCAG-22].
|
||
|
||
- **색만으로 상태·의미를 전달하지 않는다.** 성공/실패, 필수/선택, 클릭 가능/불가능 같은 구분은 색만이 아니라 아이콘·텍스트·밑줄 같은 중복 단서를 함께 준다. 비색각 사용자와 회색조로 인쇄되는 화면 모두에 적용된다.
|
||
- **모션만으로 상태 변화를 전달하지 않는다.** 토글·완료·선택 같은 상태 변화는 애니메이션이 꺼지거나 생략돼도(감소 모션 환경, 저사양 기기) 색·아이콘·라벨 중 하나로 남아 있어야 한다. 모션은 강조일 뿐 유일한 전달 수단이면 안 된다[SKILL-BETTER-INTERFACE].
|
||
|
||
두 규칙 모두 구현 위치는 각 도메인 문서다 — 색 역할·토큰은 [color.md](color.md), 모션이 꺼졌을 때 무엇을 남길지는 [motion.md](motion.md) 코드 H를 따른다. 여기서는 **판정**만 내린다.
|