Contrast 14 pairs

各トークンを OKLCH から sRGB に変換し、透過色は背景に合成してから WCAG 2.x の比を出す。 文字は 4.5:1 で AA、7:1 で AAA、3:1 は 18pt 相当以上の大きな文字だけ。フォーカスリングや ボタンの輪郭のような非テキストは 3:1。情報を持たない装飾は比だけ記録して「対象外」にする。 左のサンプルは実トークンを color-scheme を固定して描いたもの。

文字

文字色と背景色のコントラスト比(ライト / ダーク)
サンプル前景 / 背景用途ライトダーク
--c-texton --c-bg本文19.48:1AAA17.41:1AAA
--c-subon --c-bg補助テキスト・日付・ナビ5.24:1AA7.19:1AAA
--c-linkon --c-bg本文リンク5.01:1AA7.64:1AAA
--c-link-visitedon --c-bg訪問済みリンク(prose)6.77:1AA6.66:1AA
--c-dangeron --c-bg警告テキスト(逸脱の注記など)5.48:1AA5.03:1AA
--c-surface-texton --c-surfaceサーフェス上の文字(hover 行、絵文字枠)14.01:1AAA15.68:1AAA
--c-subon --c-surfaceサーフェス上の補助テキスト4.68:1AA5.79:1AA
--c-primary-texton --c-primary塗りボタン・現在のカテゴリ11.09:1AAA11.20:1AAA
--c-texton --c-selection選択中のテキスト(::selection)17.16:1AAA8.85:1AAA

非テキスト

UI 部品と背景色のコントラスト比(ライト / ダーク)
サンプル前景 / 背景用途ライトダーク
--ringon --c-bgフォーカスリング3.61:1AA4.00:1AA
--c-primaryon --c-bg塗りボタンの輪郭11.09:1AA13.92:1AA
--c-scrollbaron --c-bgスクロールバーのつまみ1.40:1対象外1.76:1対象外
--c-borderon --c-bg境界線(髪の毛ライン)1.24:1対象外1.25:1対象外
--c-surfaceon --c-bgサーフェス(カード・行の hover)1.12:1対象外1.24:1対象外

境界線・サーフェス・スクロールバーは意図して淡く、区切りは余白と並びでも読めるようにしてある(Principles の「髪の毛ラインで区切る」)。1.4.11 が 3:1 を求めるのは部品の識別に要る境界と状態表示だけなので、 これらは装飾として「対象外」にしている。スクロールバーは scrollbar-color で色を指定して いるぶん厳密には操作部品だが、つまみの位置と長さで足りると判断した。フォーカスリングはそうではないので、--ring は 70% 透過の --c-ring で 3:1 を超える値に決めてある。ダークの選択色も yellow-9 の 45% 透過で本文が AA を保つ。アプリ固有の色(me の alert、trends の star)はこの表に含まれない。

Focus 6 rules

キーボードフォーカスは共有 base.css の :focus-visible で全サイト同じリングになる。 ポインタのクリックでは出ない。実物は Components の Focus ring。 ネイティブの dialog / popover を使っているので、フォーカスの移動と復帰はブラウザが行い、 自前で管理するのは検索ダイアログの入力欄への移動だけ。

フォーカスリングは base.css の :focus-visible 1 本。要素ごとに色や形を変えない

outline なので角丸に追従し、レイアウトを揺らさず、Forced Colors でも消えない。kiso.css の outline-offset 3px を 2px で上書きしている。--ring は --c-ring の 70% 透過で、背景に対して 3:1 を超える(上の表)。

  • outline: var(--ring-width) solid var(--ring)
  • --ring-width: 2px
  • --ring-offset: 2px
outline: none を書くのは、同じ要素で :focus-visible を再宣言するときだけ

.btn と検索ダイアログの入力欄がその形。入力欄は行の中に箱を持たないので、リングの代わりに同じ色の下線を box-shadow で引き、Forced Colors では outline に戻す。リングを消したまま代替を置かない要素を作らない。

hover で現れる UI には :focus-within の対を必ず置く

目次パネルと見出しのアンカーアイコン。ポインタがない環境でも Tab で同じものが出る。

  • .toc:focus-within .toc-panel
  • .heading-link:focus .anchor-wrapper
見た目を消したフォーム部品は display: none ではなく opacity: 0 でツリーに残す

テーマ切替のラジオ。キーボードと読み上げには本物の input が届き、リングは label:has(:focus-visible) で描く。

クリップされる要素のリングは負の offset で内側に描く

full-bleed な行、ピル型のページャ、横スクロールするチップ。Conventions の Interaction & focus と同じ規則。

  • outline-offset: calc(-1 * var(--ring-width))
スキップリンクを body の先頭に置き、main#main へ飛ばす

共有 SkipLink。フォーカスされるまで sr-only で、Tab の 1 回目に左上へ現れる。ラベルはページの言語で渡す。ヘッダーが本文の中にある art と diary には置かない。

  • <SkipLink label="本文へ移動" />
  • <main id="main">

Motion

動きは transition に限り、@keyframes はスピナーの回転とスクロール駆動の端フェードだけ。prefers-reduced-motion: reduce では共有 base.css が全 transition を 0.01ms に潰し、 smooth scroll を止める(packages/styles/base.css:112)。animation-durationは触らない。唯一の時間ベースでないアニメーションが端フェードのマスクで、これは利用者のスクロールに従うだけだから。

装飾的な動きは局所で opt-in する。art のページ間 view transition はprefers-reduced-motion: no-preference の中でだけ宣言され(apps/art/src/layouts/base-layout.astro:30)、 me の 404 の WebGL 演出は matchMedia で同じ設定を読む(apps/me/src/features/pages/components/not-found-art.astro:191)。 popover と dialog の開閉は 0.2s の移動 4〜8px。自動再生や点滅、視差はどのサイトにもない。

Text

文字サイズは全て rem で、html の 16px を基準にする。viewport にmaximum-scale や user-scalable=no は置かず(packages/ui/src/head-meta.astro:19)、 ブラウザのズームと文字サイズ設定がそのまま効く。本文は --fs-sm(14px)、注釈は--fs-xs(12px)で、最小の --fs-2xs は 10.4px。読み物の行送りは--lh-base 1.75、prose 本文は 2 で、WCAG 1.4.12 の 1.5 倍を上回る。

日本語の折り返しは BudouX が <wbr> を入れ、CSS は word-break: keep-all とoverflow-wrap: anywhere の 2 行だけ(packages/styles/base.css:57)。 狭い幅でも文節の途中で切れず、長い URL は溢れない。見出しは text-wrap: balance。 Web フォントは読まないので、OS の文字設定(太さ・字形)がそのまま反映される。

Structure 8 rules

html lang は文書の言語。日本語サイトは ja、英語だけの art と lgtm は en

lgtm の法務ページは日本語版があるので、レイアウトが lang を prop で受ける。

本文は main#main に入れる。ヘッダー・ナビ・目次・フッターはその外

レイアウトが main を持つのが基本形。trends はページ側(digest-view、About、Archive)が持つ。

nav には aria-label を付け、同じページの複数の nav を名前で区別する。ラベルはページの言語

サイトナビ・フッターナビ・目次・ページャ・日付ナビが 1 ページに同居する。共有コンポーネントはラベルを prop で受ける(SiteFooter の navLabel、SiteHeader の backLabel)。

ページに h1 を 1 つ。他に見出しのないページは SiteHeader の heading でタイトルを h1 にする

lgtm の一覧・詳細と memo のフィード・スレッド・タグがその形。memo のプロフィールは名前が h1 なので heading を切る。lgtm の法務ページはタイトルを h1 として描く。

日付は time[datetime]、折りたたみは details/summary、検索 UI は search 要素

意味を持つ HTML 要素があるものは role で作り直さない。

list-style を消したリストには role="list"

Safari は list-style: none のリストをリストとして読まない。kiso.css は list-style-type: "" で全体を守っているので、明示は art のグリッドだけ。

アンカーの飛び先には scroll-margin。見出しへのリンクは見出し全体

Images 4 rules

alt は 3 通り: 内容を表す文、空、キャプションに任せて空

art の一覧は作品名、diary は日付と番号、アバターは人名。figcaption が本文を持つ図版と装飾は alt="" で二重読みを避ける。me の図版はキャプションが alt と同文のときだけ空にし、別文なら alt を残す。memo の添付は frontmatter の images[].alt、lgtm はエントリの description.txt を "LGTM over …" にして alt にする。

  • images: - file: 01.jpg alt: 干し芋の袋
  • description.txt
  • LGTM over <description>
JS 無効でも画像が見えるよう、全レイアウトが BlurLoadNoscript でぼかしを解く
アイコンだけの操作は親に aria-label、SVG は aria-hidden

Conventions の Icons と同じ。lgtm はさらに title で hover 時の説明を重ねる。

Keyboard & pointer 11 rules

モーダルとメニューはネイティブ dialog / popover。フォーカストラップ・Esc・背景クリックはブラウザ任せ

自前で書くのは「開いたら入力欄へ」「Esc で入力を保持したまま閉じる」の 2 点だけ。

リスト内の移動は ↑↓。IME 変換中はキーを奪わない

isComposing のあいだは Esc も矢印も候補選択に使われるので、ダイアログは何もしない。

JS 前提の UI は hidden 属性で出荷し、初期化が終わってから外す

JS なしでは効かないトグルを、効かないまま見せない。

localStorage は try/catch で包み、読めないときは既定表示に落とす
カード全体クリックはポインタ向けの拡張。キーボードには中に本物の a を置く

link-card はカード全体が a。memo の data-href 委譲は選択中テキストと内部リンクを除外し、日時を投稿へのリンクにしてキーボードの入口にする。

スクロールする領域はキーボードでも届くようにする。tabindex="0" か、中にフォーカスできるもの
ターゲットは 24px 角が下限。文字だけのボタンは ::before で当たり判定を広げる

24px は WCAG 2.2 の 2.5.8 の最小値。丸アイコンボタンは 1.5rem、主要ボタンは 2.25〜2.5rem。見た目を変えたくない小さなトグルは inset: -0.25rem 0 の擬似要素で足りる。

  • height: 2.25rem
  • width: 1.5rem
  • &::before { inset: -0.25rem 0 }
ショートカットは修飾キー付き(⌘K / Ctrl+K)だけ。単キーは置かない

WCAG 2.1.4 は単キーのショートカットに無効化か付け替えを求める。設定画面のない静的サイトではどちらも用意できないので、検索を開く / は持たない。Alt を除外するのは AltGr で文字を打つ配列のため。

テキスト入力は 16px 未満にしない

iOS がフォーカス時にページをズームするのを防ぐ。拡大を禁止する viewport 設定は持たない。

  • font-size: max(16px, var(--fs-base))
  • width=device-width, initial-scale=1
新しいタブで開くリンクは sr-only で「(新しいタブで開く)」を添える

アイコンは aria-hidden なので、読み上げには文字で伝える。memo の本文リンクは rehype-external-links の content で同じ span を差し込む。

  • <span class="sr-only">(新しいタブで開く)</span>

Announcements 7 rules

コピーなど視覚でしか返らない操作は、sr-only の role="status" に結果を書く

アイコンの差し替えだけでは読み上げに何も起きない。文言はページの言語で、1〜1.5 秒後に消す。

  • <span class="sr-only" role="status">
  • status.textContent = "コピーしました"
非同期の結果は role="status" に書く。空のときも要素を消さない

display: none にするとライブリージョンから外れ、最初の告知が落ちる。空のときは sr-only 相当で残す。

無限スクロールは aria-live="polite"。終端は status、失敗は alert で差をつける

既定の文言は英語。日本語サイトは endMessage / errorMessage / loadingLabel を渡す。

スピナーは role="status" + aria-label。回転だけを見せない

ラベルは label prop。既定は "Loading"、memo は「読み込み中」。

.sr-only は clip-path 版の定型

overflow: clip の祖先の中でも隠れたままになる。

title 属性だけに情報を持たせない

title はポインタの hover でしか出ず、タッチとキーボードには届かない。日時の完全表記のように、見える文字の補足に限る。意味を持つ記号には sr-only の名前を添える。

色だけの目印には sr-only のテキストを添える

一覧の下書きマーク(赤いバー)。

Color & theme

色は light-dark() を持つセマンティックトークンだけを使い、テーマは color-schemeで切り替わる。<meta name="color-scheme"> をスタイルシートより先に置くので、 ダーク設定の環境で白い画面が一瞬出ない。フォーカスリングを box-shadow ではなくoutline で描いているのは、Windows の Forced Colors モードで box-shadow が消えるから (packages/styles/tokens.css:65)。kiso.css も mark をシステムカラーに寄せる。

色だけで情報を伝えている箇所はない。trends の「既出」は文字で示し、星は sr-only の「興味マッチ度 n」が読まれる。 me の下書きマークも sr-only の「下書き」を持つ。リンクは prose では下線と色の両方、chrome では位置と文言で分かる。prefers-contrast への対応はない。上の表の文字とフォーカスリングは、両テーマとも AA 以上。

Known gaps 2 items

重い順。それぞれ WCAG 2.2 の達成基準を添えた。コードで閉じられるものは閉じてあり、残りはコンテンツか 仕様の判断が要るもの。共有トークンを変えるときは VRT の更新まで含めて 1 つの PR にする。

  1. 中alt 未記入の memo 添付画像1.1.1 Non-text Content

    frontmatter に images[].alt を持たない旧 memo(151 件、155 枚)は "Image 1" のような番号だけで、何が写っているか伝わらない。新規投稿は studio が alt を必須にするので増えない。

    対処: memo-content の各 index.md に images[].alt を書く(コンテンツ側の作業)。書いた分から番号の alt が消える。

  2. 低--fs-2xs は 10.4px1.4.4 Resize Text

    キャプション・トークン名・ページャに 29 箇所。拡大はできるので違反ではないが、既定のままでは読みづらい。

    対処: 新規の UI では --fs-xs(12px)を下限にし、--fs-2xs は等幅のメタ情報に限る。

Checklist 10 items

画面を 1 つ足すときに通す項目。上の各節の要約なので、迷ったら該当する節へ戻る。

  1. main#main が 1 つ、h1 が 1 つ、nav には aria-label、body の先頭に SkipLink
  2. Tab だけで全操作に届く。カードは中に本物の a、hover で出るものは focus-within
  3. Tab でリングが見える。クリップされる要素は内側リング
  4. 色はトークンのみ。上の表で AA 以上の組み合わせから選ぶ
  5. 画像の alt は内容・空・キャプションのどれか。アイコンだけの操作は aria-label
  6. トグルは aria-pressed、現在地は aria-current、非同期の結果と操作の結果は role=status
  7. 文字入力は 16px 以上、押せるものは 24px 角以上
  8. 動きは transition だけ。animation を足すなら prefers-reduced-motion で切る
  9. 読み上げテキスト(aria-label、status、新しいタブの注記)はページの言語
  10. VoiceOver(Safari)と 200% ズームで一度通す

規約 36 項目、課題 2 件。根拠のリンクは GitHub の main ブランチを指す。