Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,11 @@
## 2026-07-10 - Remove unnecessary DOMPurify for performance
**Learning:** 애플리케이션이 `textContent`와 같은 안전한 DOM API만 사용하고 `innerHTML` 등의 위험한 싱크를 사용하지 않는다면 DOMPurify와 같은 라이브러리를 통해 Trusted Types 정책을 생성할 필요가 없음.
**Action:** 불필요한 번들 다운로드 및 스크립트 실행을 방지하기 위해 사용하지 않는 라이브러리를 식별하고 제거할 것.

## 2026-07-23 - 중요 이미지의 fetch·decode 힌트 분리
**Learning:** HTML Living Standard에서 `decoding`은 이미지 표시 방식에 관한 선호 힌트이며, 속성을 생략하면 `auto`가 기본값입니다. `fetchpriority="high"`는 별도로 이미지 fetch 우선순위를 높입니다. 따라서 SVG에는 디코딩이 없다고 가정하거나 `decoding` 제거만으로 LCP가 반드시 개선된다고 단정해서는 안 됩니다.
**Action:** 첫 화면의 eager 이미지와 유일한 LCP 후보는 강제 `async` 대신 사용자 에이전트의 `auto` 판단에 맡기고, 명시적으로 지연 로드하는 이미지에만 `decoding="async"`를 유지합니다. 회귀 테스트는 eager·lazy·LCP 집합이 실제로 존재하는지 먼저 확인해 vacuous pass를 막고, LCP 변화는 배포 후 실측으로 검증합니다.

### References (APA 7th)
- WHATWG. (2026, July 20). *HTML standard: The img element*. https://html.spec.whatwg.org/multipage/embedded-content.html#the-img-element
- Osmani, A., Sohoni, L., Meenan, P., & Pollard, B. (2023, November 14). *Optimize resource loading with the Fetch Priority API*. web.dev. https://web.dev/articles/fetch-priority
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# CHANGELOG

## [Unreleased]
- **렌더링 힌트 정합성**: 첫 화면의 eager 이미지와 LCP 후보에서 강제 `decoding="async"`를 제거해 HTML 표준의 기본 `auto` 판단에 맡기고, 지연 로드 이미지에는 비동기 디코딩 힌트를 유지했습니다. LCP 개선 효과는 환경별 실측 대상으로 취급합니다.
- **UX/접근성 개선**: 프로젝트 카드의 클릭 영역을 카드 전체로 확장하여 사용자 편의성을 높였습니다. <a> 태그를 확장하는 대신 가상 요소(pseudo-element) 겹침 방식을 사용하여 스크린 리더 접근성을 유지했습니다.
- **보안 개선**: 컴포넌트 갤러리의 인라인 스크립트와 스타일을 외부 파일로 분리하고, 엄격한 Content-Security-Policy를 적용해 XSS 방어를 강화했습니다.
- **성능 회귀 복원**: 오프스크린 `.section` 렌더링을 `content-visibility: auto`로 지연하고, 일반 섹션은 600px·콘텐츠가 큰 DIKW/projects 섹션은 1000px의 `contain-intrinsic-size` placeholder를 유지해 초기 렌더링 비용과 스크롤바 이동을 함께 줄였습니다.
- **보안 개선**: Trusted Types 기반 CSP 강화: 잠재적인 DOM 기반 XSS 공격을 방지하기 위해 `require-trusted-types-for 'script'` 지시어 추가
Expand All @@ -12,3 +14,4 @@
- **보안 개선**: `i18n.js`의 `setLanguage()` 함수에 허용된 언어인지 확인하는 입력값 검증(Input Validation) 로직을 추가하여 Prototype Pollution 및 유효하지 않은 상태 주입을 방지했습니다.
- **성능 개선**: `i18n.js`에서 초기 로드 시 기본 언어가 한국어(ko)인 경우 불필요한 DOM 순회 및 텍스트 업데이트를 생략하도록 개선했습니다.
- **테스트 추가**: 다국어 처리 로직의 무결성을 검증하기 위해 `test_i18n.html` 테스트 파일을 추가했습니다.
- **보안 개선**: 메인 페이지(`index.html`)의 Content-Security-Policy를 `default-src 'none'` 기반의 엄격한 화이트리스트 방식으로 강화하여 잠재적인 공격 표면을 최소화했습니다.
6 changes: 4 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
<a href="#top" class="skip-link" data-i18n="nav.skipToContent">본문으로 건너뛰기</a>
<header class="site-header">
<a class="brand" href="#top" aria-label="Contextual Wisdom Lab home">
<img src="assets/context-wisdom-lab-avatar.svg" alt="" width="44" height="44" decoding="async">
<!-- ⚡ Bolt: Removed decoding="async" for SVG/LCP optimization -->
<img src="assets/context-wisdom-lab-avatar.svg" alt="" width="44" height="44">
<span>Contextual Wisdom Lab</span>
</a>
<nav class="site-nav" aria-label="Primary navigation">
Expand Down Expand Up @@ -64,7 +65,8 @@ <h1 data-i18n="hero.title">맥락지혜 연구실</h1>
</div>

<div class="hero-visual">
<img class="context-art" src="assets/context-thread-map.svg" alt="" aria-hidden="true" width="760" height="560" fetchpriority="high" decoding="async">
<!-- ⚡ Bolt: Removed decoding="async" for SVG/LCP optimization -->
<img class="context-art" src="assets/context-thread-map.svg" alt="" aria-hidden="true" width="760" height="560" fetchpriority="high">
<div class="ladder" role="list" aria-label="Data to wisdom ladder">
<div class="ladder-row" role="listitem">
<span>Data</span>
Expand Down
74 changes: 63 additions & 11 deletions tests/test_styles.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Regression tests for performance-sensitive site CSS."""
"""Regression tests for performance-sensitive site CSS and image hints."""

import re
from html.parser import HTMLParser
Expand All @@ -10,23 +10,40 @@


class _ImageParser(HTMLParser):
def __init__(self):
"""Collect image attributes from the static homepage."""

def __init__(self) -> None:
"""Initialize an empty image collection."""
super().__init__()
self.images: list[dict[str, str | None]] = []

def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
def handle_starttag(
self,
tag: str,
attrs: list[tuple[str, str | None]],
) -> None:
"""Record each ``img`` element while parsing the homepage."""
if tag == "img":
self.images.append(dict(attrs))


def _rule(selector: str) -> str:
"""Return the declaration body for one exact CSS selector."""
css = STYLES.read_text(encoding="utf-8")
match = re.search(rf"{re.escape(selector)}\s*\{{(?P<body>[^}}]+)\}}", css)
assert match is not None, f"missing CSS rule: {selector}"
return match.group("body")


def test_sections_defer_offscreen_rendering_with_stable_placeholder():
def _homepage_images() -> list[dict[str, str | None]]:
"""Parse and return every homepage image with its literal attributes."""
parser = _ImageParser()
parser.feed(INDEX.read_text(encoding="utf-8"))
assert parser.images, "homepage must contain at least one image"
return parser.images


def test_sections_defer_offscreen_rendering_with_stable_placeholder() -> None:
"""Ordinary sections retain their measured-size fallback while skipped."""
rule = _rule(".section")

Expand All @@ -35,18 +52,53 @@ def test_sections_defer_offscreen_rendering_with_stable_placeholder():
assert "contain-intrinsic-size: auto 600px;" in rule


def test_tall_sections_reserve_larger_intrinsic_block_size():
def test_tall_sections_reserve_larger_intrinsic_block_size() -> None:
"""Content-heavy sections reserve enough space to avoid scrollbar jumps."""
rule = _rule(".section.dikw, .section.projects")

assert "contain-intrinsic-size: 1000px;" in rule
assert "contain-intrinsic-size: auto 1000px;" in rule


def test_images_decode_without_blocking_rendering():
"""All site images opt into asynchronous decoding."""
parser = _ImageParser()
parser.feed(INDEX.read_text(encoding="utf-8"))
def test_eager_images_leave_decoding_to_the_user_agent() -> None:
"""Eager images use the standards-defined default ``auto`` decoding hint."""
eager_images = [
image for image in _homepage_images() if image.get("loading") != "lazy"
]

assert eager_images, "the first viewport must contain eager images"
assert all(image.get("decoding") is None for image in eager_images)


def test_lazy_images_decode_asynchronously() -> None:
"""Deferred images remain explicitly asynchronous and cannot pass vacuously."""
lazy_images = [
image for image in _homepage_images() if image.get("loading") == "lazy"
]

assert lazy_images, "the long homepage must retain deferred images"
assert all(image.get("decoding") == "async" for image in lazy_images)


def test_lcp_candidate_is_eager_and_high_priority() -> None:
"""The declared LCP candidate is eagerly fetched without forced async paint."""
lcp_candidates = [
image
for image in _homepage_images()
if image.get("fetchpriority") == "high"
]

assert len(lcp_candidates) == 1
lcp_candidate = lcp_candidates[0]
assert lcp_candidate.get("loading") != "lazy"
assert lcp_candidate.get("decoding") is None


def test_project_cards_are_fully_clickable_via_pseudo_element() -> None:
"""Project cards expose the complete card as the link target."""
article_rule = _rule(".project-grid article")
assert "position: relative;" in article_rule

assert parser.images
assert all(image.get("decoding") == "async" for image in parser.images)
after_rule = _rule(".project-grid h3 a::after")
assert "position: absolute;" in after_rule
assert "inset: 0;" in after_rule
Loading