Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions .Jules/palette.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@
## 2026-08-22 - Add aria-labelledby to section landmarks
**Learning:** `<section>`์€ ์ ‘๊ทผ์„ฑ ์ด๋ฆ„์ด ์žˆ์„ ๋•Œ๋งŒ `region` ๋žœ๋“œ๋งˆํฌ๋กœ ๋…ธ์ถœ๋˜๊ณ , ์ด๋ฆ„์ด ์—†์œผ๋ฉด `generic`์œผ๋กœ ๋งคํ•‘๋˜์–ด ํ™”๋ฉด ํƒ์ƒ‰ ๋žœ๋“œ๋งˆํฌ ๋ชฉ๋ก์— ๋‚˜ํƒ€๋‚˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. `id` ์†์„ฑ๋งŒ์œผ๋กœ๋Š” ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.
**Action:** `<section>`์—๋Š” ๊ณ ์œ ํ•œ `id`๋ฅผ ๊ฐ€์ง„ ๋‚ด๋ถ€ ํ—ค๋”ฉ(`<h2>`)์„ `aria-labelledby`๋กœ ์ฐธ์กฐ์‹œ์ผœ ์ ‘๊ทผ์„ฑ ์ด๋ฆ„์„ ๋ถ€์—ฌํ•ฉ๋‹ˆ๋‹ค. ํšŒ๊ท€ ํ…Œ์ŠคํŠธ๊ฐ€ ์ฐธ์กฐ ๋Œ€์ƒ id์˜ ์‹ค์žฌ ์—ฌ๋ถ€์™€ ์•„์ด๋””๊ฐ€ ์žˆ๋Š” ๋ชจ๋“  ์„น์…˜์˜ ๋ ˆ์ด๋ธ”๋ง์„ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.

## 2026-09-13 - ์™ธ๋ถ€ ๋งํฌ ์ ‘๊ทผ์„ฑ ํŒจํ„ด์˜ ์ผ๊ด€์„ฑ ์œ ์ง€
**Learning:** index.html์— ์ž˜ ์ ์šฉ๋œ ์™ธ๋ถ€ ๋งํฌ ์•ˆ๋‚ด(aria-describedby="new-window-desc") ํŒจํ„ด์ด 404.html์—๋Š” ๋ˆ„๋ฝ๋˜์–ด ์žˆ์—ˆ๋‹ค. ๋ณด์กฐ ํŽ˜์ด์ง€(404 ๋“ฑ)๋ผ๋„ ๋ฉ”์ธ ํŽ˜์ด์ง€์™€ ๋™์ผํ•œ ์ˆ˜์ค€์˜ ์ ‘๊ทผ์„ฑ์„ ์œ ์ง€ํ•˜์ง€ ์•Š์œผ๋ฉด ์Šคํฌ๋ฆฐ ๋ฆฌ๋” ์‚ฌ์šฉ์ž์—๊ฒŒ ํ˜ผ๋ž€์„ ์ค„ ์ˆ˜ ์žˆ๋‹ค.
**Action:** ์•ž์œผ๋กœ ์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ๋ฆฌ๋ทฐํ•  ๋•Œ, ๋ฉ”์ธ ํŽ˜์ด์ง€์˜ ์ ‘๊ทผ์„ฑ ๊ธฐ์ค€(์˜ˆ: ์™ธ๋ถ€ ๋งํฌ ์‹œ๊ฐ์ /๋ณด์กฐ์  ์•ˆ๋‚ด)์ด ๋ชจ๋“  HTML ๋ฌธ์„œ์— ์ผ๊ด€๋˜๊ฒŒ ์ ์šฉ๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•œ๋‹ค.
67 changes: 67 additions & 0 deletions .github/workflows/static_site_regression.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Static Site Regression

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
static_contracts:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout exact source revision
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'

- name: Verify exact-head static accessibility contracts
shell: bash --noprofile --norc -e -o pipefail {0}
run: |
test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}"
python - <<'PY'
import inspect
from pathlib import Path
import runpy

test_files = (
Path("tests/test_404_page.py"),
Path("tests/test_external_links.py"),
)
executed = 0
for path in test_files:
namespace = runpy.run_path(str(path))
checks = sorted(
(name, value)
for name, value in namespace.items()
if name.startswith("test_") and callable(value)
)
if not checks:
raise AssertionError(f"{path} contains no executable test functions")
for name, check in checks:
if inspect.signature(check).parameters:
raise AssertionError(
f"{path}:{name} requires a test fixture; the dependency-free runner cannot execute it"
)
check()
executed += 1
if executed == 0:
raise AssertionError("no static accessibility contracts executed")
print(f"executed {executed} static accessibility contracts")
PY
python -m compileall -q tests/test_404_page.py tests/test_external_links.py
git diff --exit-code
5 changes: 3 additions & 2 deletions 404.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<link rel="stylesheet" href="404.css">
</head>
<body>
<span id="new-window-desc" class="visually-hidden">์ƒˆ ์ฐฝ์—์„œ ์—ด๋ฆผ</span>
<a href="#top" class="skip-link">๋ณธ๋ฌธ์œผ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ</a>
<header class="site-header">
<a class="brand" href="/" aria-label="Contextual Wisdom Lab home">
Expand All @@ -31,7 +32,7 @@
<a href="/#projects">ํ”„๋กœ์ ํŠธ</a>
<a href="/#forks">Fork</a>
<a href="/#work">์ž‘์—…</a>
<a target="_blank" rel="noopener noreferrer" href="https://github.com/ContextualWisdomLab">GitHub</a>
<a aria-describedby="new-window-desc" target="_blank" rel="noopener noreferrer" href="https://github.com/ContextualWisdomLab" title="์ƒˆ ์ฐฝ์—์„œ ์—ด๋ฆผ">GitHub</a>
</nav>
</header>

Expand Down Expand Up @@ -59,7 +60,7 @@ <h1 class="not-found-title">ํŽ˜์ด์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค</h1>
>
<p>
<span>Founded by</span>
<a target="_blank" rel="noopener noreferrer" href="https://github.com/seonghobae">Seongho Bae</a>.
<a aria-describedby="new-window-desc" target="_blank" rel="noopener noreferrer" href="https://github.com/seonghobae" title="์ƒˆ ์ฐฝ์—์„œ ์—ด๋ฆผ">Seongho Bae</a>.
<span>Context into judgment. Judgment into action.</span>
</p>
</footer>
Expand Down
43 changes: 43 additions & 0 deletions tests/test_404_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,46 @@ def test_404_assets_referenced_exist_on_disk() -> None:
"""Local image/icon assets referenced by the 404 page must be present."""
for asset in re.findall(r'(?:href|src)="(assets/[^"#?]+)"', _page()):
assert (ROOT / asset).is_file(), f"404.html references missing asset {asset}"


def test_404_page_external_links_accessible() -> None:
"""External links on the 404 page must be accessible."""
html = _page()

# Check visually hidden description span
assert '<span id="new-window-desc" class="visually-hidden">์ƒˆ ์ฐฝ์—์„œ ์—ด๋ฆผ</span>' in html, (
"404 page must contain the visually hidden description for new window links"
)

# Verify all external links reference it
from html.parser import HTMLParser

class _LinkParser(HTMLParser):
def __init__(self) -> None:
super().__init__()
self.links: list[dict[str, str | None]] = []

def handle_starttag(self, tag, attrs) -> None:
if tag == "a":
self.links.append(dict(attrs))

parser = _LinkParser()
parser.feed(html)

external_links = [a for a in parser.links if a.get("target") == "_blank"]
assert len(external_links) > 0, "404 page should have external links"

for anchor in external_links:
assert anchor.get("aria-describedby") == "new-window-desc", (
f"External link {anchor.get('href')} must reference #new-window-desc"
)
assert anchor.get("title") == "์ƒˆ ์ฐฝ์—์„œ ์—ด๋ฆผ", (
f"External link {anchor.get('href')} must have the correct title"
)
rel_tokens = {token.lower() for token in (anchor.get("rel") or "").split()}
assert "noopener" in rel_tokens, (
f"External link {anchor.get('href')} must keep opener isolation"
)
assert "noreferrer" in rel_tokens, (
f"External link {anchor.get('href')} must keep the product referrer policy"
)
95 changes: 62 additions & 33 deletions tests/test_external_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,43 +32,56 @@ def handle_starttag(self, tag, attrs) -> None:
self.links.append(attributes)


def _parse_index() -> _LinkParser:
def _parse_html(file_path: Path) -> _LinkParser:
parser = _LinkParser()
parser.feed(INDEX.read_text(encoding="utf-8"))
assert parser.links, "homepage must contain at least one anchor"
parser.feed(file_path.read_text(encoding="utf-8"))
return parser


def _external_links(parser: _LinkParser) -> list[dict[str, str | None]]:
def _external_links(parser: _LinkParser, file_name: str) -> list[dict[str, str | None]]:
external = [a for a in parser.links if a.get("target") == "_blank"]
assert external, "homepage must contain at least one external link"
return external


def test_external_links_reference_the_new_window_description() -> None:
"""Every external link points at the shared visually hidden warning."""
parser = _parse_index()

assert DESC_ID in parser.ids, (
f"homepage must define the #{DESC_ID} description element"
)
for anchor in _external_links(parser):
assert anchor.get("aria-describedby") == DESC_ID, (
f"External link {anchor.get('href')} must reference #{DESC_ID}"
for html_file in ROOT.rglob("*.html"):
if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts:
continue

parser = _parse_html(html_file)
external = _external_links(parser, html_file.name)
if not external:
continue

assert DESC_ID in parser.ids, (
f"{html_file.name} must define the #{DESC_ID} description element"
)
for anchor in external:
assert anchor.get("aria-describedby") == DESC_ID, (
f"External link {anchor.get('href')} in {html_file.name} must reference #{DESC_ID}"
)


def test_external_links_keep_the_localized_title() -> None:
"""The title stays as supplemental hover metadata in both locales."""
parser = _parse_index()
for html_file in ROOT.rglob("*.html"):
if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts:
continue

for anchor in _external_links(parser):
assert anchor.get("title") == EXPECTED["title"], (
f"External link {anchor.get('href')} is missing the Korean title"
)
assert anchor.get("data-i18n-title") == EXPECTED["key"], (
f"External link {anchor.get('href')} is missing data-i18n-title"
)
parser = _parse_html(html_file)
external = _external_links(parser, html_file.name)

for anchor in external:
assert anchor.get("title") == EXPECTED["title"], (
f"External link {anchor.get('href')} in {html_file.name} is missing the Korean title"
)

# 404.html does not load i18n.js, so we only expect data-i18n-title on the homepage
if html_file.name == "index.html":
assert anchor.get("data-i18n-title") == EXPECTED["key"], (
f"External link {anchor.get('href')} is missing data-i18n-title"
)


def test_i18n_has_new_tab_translation() -> None:
Expand All @@ -82,19 +95,35 @@ def test_visually_hidden_class_is_defined() -> None:
"""The description element relies on a CSP-safe external class."""
css = (ROOT / "styles.css").read_text(encoding="utf-8")
assert ".visually-hidden {" in css
index = INDEX.read_text(encoding="utf-8")
assert f'id="{DESC_ID}" class="visually-hidden"' in index
for html_file in ROOT.rglob("*.html"):
if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts:
continue

parser = _parse_html(html_file)
external = _external_links(parser, html_file.name)
if not external:
continue

html_content = html_file.read_text(encoding="utf-8")
assert f'id="{DESC_ID}" class="visually-hidden"' in html_content, (
f"{html_file.name} must define the visually-hidden class on the description element"
)


def test_external_links_keep_opener_and_referrer_policy() -> None:
"""Every new-context link retains explicit opener isolation and referrer policy."""
parser = _parse_index()

for anchor in _external_links(parser):
rel_tokens = {token.lower() for token in (anchor.get("rel") or "").split()}
assert "noopener" in rel_tokens, (
f"External link {anchor.get('href')} must keep opener isolation"
)
assert "noreferrer" in rel_tokens, (
f"External link {anchor.get('href')} must keep the product referrer policy"
)
for html_file in ROOT.rglob("*.html"):
if ".git" in html_file.parts or ".pytest_cache" in html_file.parts or "components" in html_file.parts:
continue

parser = _parse_html(html_file)
external = _external_links(parser, html_file.name)

for anchor in external:
rel_tokens = {token.lower() for token in (anchor.get("rel") or "").split()}
assert "noopener" in rel_tokens, (
f"External link {anchor.get('href')} in {html_file.name} must keep opener isolation"
)
assert "noreferrer" in rel_tokens, (
f"External link {anchor.get('href')} in {html_file.name} must keep the product referrer policy"
)
Loading