Visitar URL original
feat(asset): WP-020 스케치 에셋 매니페스트 시스템 구현 (#47) by cgm-16 · Pull Request #53 · cgm-16/weatherpane · GitHub
Skip to content
This repository was archived by the owner on Sep 24, 2026. It is now read-only.

feat(asset): WP-020 스케치 에셋 매니페스트 시스템 구현 (#47) - #53

Merged
cgm-16 merged 22 commits into
mainfrom
feat/wp-020-sketch-assets
Apr 13, 2026
Merged

cgm-16 merged 22 commits into
mainfrom
feat/wp-020-sketch-assets

Conversation

@cgm-16

@cgm-16 cgm-16 commented Apr 13, 2026 •

Copy link
Copy Markdown
Owner

개요

이슈 #47 WP-020을 구현합니다.

시맨틱 키 기반 스케치 에셋 매니페스트 시스템을 추가하고, 22개 WebP 에셋을 Stitch로 생성·후처리해 Home / Detail / Favorites 페이지 배경에 연결합니다.

변경 범위

포함:

  • frontend/entities/asset/ — 시맨틱 키 타입, 선택기(허브 우선 → 제네릭 아키타입 → 폴백 리라이트), baseline 매니페스트, 세션 로더(localStorage pending override + 백그라운드 원격 페치), SketchManifestProvider 컨텍스트, SketchBackground 컴포넌트
  • public/sketches/{hub/seoul,hub/busan,generic/urban}/ — 22개 WebP (2400×1600, ≤400 KB)
  • scripts/stitch/{sketch-batch.json,asset-map.json,process-sketch.ts} — Stitch 배치 워크플로 및 sharp 후처리 파이프라인
  • Home / Detail / Favorites 페이지 hero·compact 표면에 SketchBackground 렌더링
  • tests/asset/ — 선택기, 매니페스트 병합, 세션 로더, 드리프트 가드 단위 테스트
  • tests/*.e2e.ts — 홈·상세 Playwright 스모크에 스케치 이미지 검증 추가
  • docs/skills/asset-manifest-contract.md — Stitch 배치 워크플로 추가

제외:

  • 서비스 워커 스케치 캐싱 (T21 범위)
  • coastal / mountain / rural 허브 (후속 이슈)
  • GET /v1/assets/manifest 서버 엔드포인트 (원격 페치는 404 시 silent fallback)
  • PersistedWeatherSnapshot.sketchKey 지속성 (별도 이슈)

스펙 준수

  • 시맨틱 키만 사용 — 컴포넌트에 하드코딩 URL 없음
  • 원격 override는 다음 로드에만 적용, 세션 중 핫스왑 없음
  • 원격 페치 실패 시 baseline 그대로 유지 (pending 건드리지 않음)
  • 허브 상속: admin1 === '서울특별시' → hub/seoul, '부산광역시' → hub/busan
  • 제네릭 폴백 리라이트: cloudy-night→clear-night, snowy-night→rainy-night
  • 에셋 형식: 3:2 landscape WebP 2400×1600, ≤400 KB

테스트

  • pnpm lint ✅
  • pnpm typecheck ✅
  • pnpm test:unit ✅ — 45개 파일, 442개 테스트
  • pnpm exec playwright test ✅ — 33/33 (design-tokens 플레이크 1건은 WP-020 미관련, 재실행 통과)

리스크 및 롤백

  • 리스크: /v1/assets/manifest 엔드포인트가 없어 모든 세션이 baseline으로 동작 — 의도된 동작이며 baseline 에셋이 완전히 기능함
  • 롤백: SketchManifestProvider 제거 및 세 페이지에서 SketchBackground 언마운트로 즉시 롤백 가능 (표시 레이어만 영향)
  • 22개 WebP 파일(~4 MB 합계)이 리포에 커밋됨 — asset-map.json으로 각 파일의 Stitch 스크린 출처 추적 가능

Closes #47

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Sketch background illustrations now appear across dashboards, cards and detail pages, selected by location and weather; session-level manifest overrides supported.
  • Documentation

    • Added asset generation pipeline guide and clarified asset manifest contract and workflow.
  • Tests

    • Added broad test coverage for key parsing, manifest loading/merge, selector logic, and sketch background behavior.
  • Chores

    • Updated version control ignores, added image processing tooling, and introduced asset configuration/map data.

cgm-16 and others added 18 commits April 13, 2026 17:14
WP-020 1단계: Stitch 배치 생성을 위한 scripts/stitch/ 추가.
- sketch-batch.json: 22개 시맨틱 키(서울/부산 full-8, generic/urban reduced-6)와 프롬프트 템플릿 부품(preamble, compositionRules, negativePrompt, subjects, variants, subjectOverrides)의 단일 출처
- asset-map.json: 키 → Stitch 스크린 ID/URL/로컬 경로 감사 로그(생성 전 빈 상태)
- README.md: 프롬프트 조립 규칙과 생성 루프 문서
- .gitignore: scripts/stitch/_raw/ 스크래치 디렉토리 제외

Refs #47
WP-020 첫 단계로 frontend/entities/asset/ 모듈 골격을 만든다.

- keys.ts: `<family>/<familyId>/<variant>` 형식의 SemanticKey 타입과
  toSemanticKey/parseSemanticKey/isSemanticKey 유틸 구현
- variant.ts: WeatherVisualBucket × day|night 조합의 SketchVariantId 타입과
  generic 6-variant 세트용 폴백 재작성 규칙 정의
  (cloudy-night → clear-night, snowy-night → rainy-night)
- archetypes.ts: 서울/부산 admin1을 허브 familyId로 매핑하는 HUB_BY_ADMIN1과
  기본 generic archetype(urban) 상수
- selector.ts: ResolvedLocation 또는 RawGpsFallbackLocation과
  WeatherCondition으로부터 시맨틱 키를 결정.
  resolved + 허브 admin1 매칭 시 full-8 허브 키(재작성 없음)를,
  그 외에는 generic/urban 폴백을 반환한다.
- index.ts: 엔티티 공개 파사드

테스트는 tests/asset/ 하위에 프로젝트 테스트 컨벤션에 맞춰 콜로케이션
없이 배치했고, 서울/부산 허브 full-8, 비허브 resolved 폴백, raw-gps 폴백,
generic 재작성 규칙, 키 파싱/가드 라운드트립을 모두 커버한다.
- SketchVariantId 선언을 variant.ts 한 곳으로 단일화하고 keys.ts에서는 재사용
- SketchFamilyId를 'seoul' | 'busan' | 'urban' 유니언으로 좁혀 SemanticKey를
  유한한 리터럴 유니언으로 만듦
- parseSemanticKey가 familyId와 variant를 각 유니언 집합으로 검증하도록 갱신
- 테스트 변경 없음 (24/24 통과)
- 5개 캐너리 (seoul clear-day/cloudy-day/clear-night, busan clear-day, generic urban clear-day)
- 두 번째 배치 인라인 반환 2개 (busan clear-night, busan rainy-night)
- 기존 파일럿 (seoul rainy-day) 유지
- README/sketch-batch 리뷰 반영 (schema 레퍼런스 제거, 마크다운 포맷 수정)
generic/urban/cloudy-day 한 개는 재생성 대기 중. 나머지 21개 키를
2400x1600 WebP q85로 변환하여 public/sketches/ 아래 저장하고
asset-map.json에 localPath와 sha256을 기록.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
22/22 완료. 22개 시맨틱 키가 모두 asset-map.json과
public/sketches/ 아래에 존재.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- frontend/entities/asset/data/baseline-manifest.json (22 키)
- SketchManifest 타입과 mergeManifest 병합 로직 추가
- sketch-batch.json ↔ baseline ↔ asset-map.json ↔ public/sketches 정합성 테스트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- loadSessionManifest: pending override를 baseline에 병합해 frozen 매니페스트 반환
- 손상된 pending JSON은 제거 후 baseline으로 폴백
- 백그라운드 remoteFetcher 결과는 다음 세션용 pending으로 기록, 실패 시 기존 값 유지
- fetchRemoteManifest: /v1/assets/manifest 대상 최소 fetch 래퍼

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
세션 시작 시 loadSessionManifest로 매니페스트를 한 번 로드하고,
ActiveLocationProvider 내부에서 SketchManifestProvider로 children을 감싸
모든 페이지에서 useSketchManifest를 사용할 수 있게 한다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
홈/상세 대시보드의 메인 요약 카드와 즐겨찾기 카드 뒤에 SketchBackground를
배치한다. 카드 위치/모서리는 새 wrapper로 옮기고, bg-card는 스케치 워시로
대체했다. 영향받는 테스트의 render 헬퍼를 SketchManifestProvider로 감싸고
hub/seoul/clear-day 키와 compact 사이즈 힌트에 대한 어서션을 추가했다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
홈/상세 페이지 스모크에서 hub/seoul/clear-day 스케치 이미지가 렌더링되고
src가 /sketches/*.webp 경로를 가리키는지 확인한다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
하드코딩된 hub/seoul/clear-day 키가 모의 프로바이더의 결정론적 결과임을
한 줄로 명시해 리뷰 피드백을 반영한다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
sketch-batch.json과 asset-map.json의 역할, 생성 루프, 드리프트 가드를
스킬 문서에 정리해 향후 자산 재생성이 재발견 없이 가능하도록 한다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
외부 소비자가 없는 상수/타입/함수를 배럴에서 내려 향후 리팩터링
여지를 확보한다. 엔티티 내부에서는 상대 경로로 직접 참조한다.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Apr 13, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@cgm-16 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 32 minutes and 12 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 32 minutes and 12 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3edffbf8-8e52-4a58-805c-aaa94333e662

📥 Commits

Reviewing files that changed from the base of the PR and between 3cd50c7 and 4f58ced.

📒 Files selected for processing (4)
  • frontend/entities/asset/api/fetch-remote-manifest.ts
  • frontend/entities/asset/model/manifest.ts
  • scripts/stitch/process-sketch.ts
  • vite.config.ts
📝 Walkthrough

Walkthrough

Adds a semantic sketch asset system: key types, baseline manifest, selector, session+remote manifest loader, React manifest context, SketchBackground component, manifest-driven UI integration across pages, Stitch asset-generation pipeline and scripts, and tests enforcing invariants and fallback behavior.

Changes

Cohort / File(s) Summary
Asset model & manifest
frontend/entities/asset/model/keys.ts, frontend/entities/asset/model/variant.ts, frontend/entities/asset/model/manifest.ts, frontend/entities/asset/model/archetypes.ts, frontend/entities/asset/data/baseline-manifest.json
New semantic key types, variant/fallback rules, baseline manifest JSON, manifest validation (freezeManifest) and merge logic, and hub→archetype mappings.
Selector & UI component
frontend/entities/asset/model/selector.ts, frontend/entities/asset/ui/sketch-background.tsx, frontend/entities/asset/ui/sketch-manifest-context.tsx
Selector chooses semantic key from location+condition; SketchBackground renders img with override→baseline→hide retry logic; context provider + hook expose manifest.
Session & remote loader
frontend/entities/asset/api/fetch-remote-manifest.ts, frontend/entities/asset/api/load-session-manifest.ts
Remote manifest fetcher with 5s timeout and JSON validation; session loader reads pending override from localStorage, merges over baseline, returns frozen manifest and starts non-blocking background fetch to persist remote result.
Public facade & archetypes
frontend/entities/asset/index.ts, frontend/entities/asset/model/archetypes.ts
New index re-exports asset API/types/components; archetype constants (HUB_BY_ADMIN1, DEFAULT_ARCHETYPE) added.
App integration
frontend/app/providers/app-providers.tsx
AppProviders now loads session manifest once and wraps tree with SketchManifestProvider.
Page integrations
frontend/pages/home/ui/home-dashboard.tsx, frontend/pages/location/ui/detail-dashboard.tsx, frontend/pages/favorites/ui/favorite-card.tsx
Injected SketchBackground overlays into Home, Detail, and Favorite cards and adjusted local layout wrappers for absolute positioned backgrounds.
Stitch asset pipeline & tooling
scripts/stitch/sketch-batch.json, scripts/stitch/asset-map.json, scripts/stitch/process-sketch.ts, scripts/stitch/README.md, .gitignore
Batch config and asset-map added; process-sketch CLI (sharp) to crop/resize/encode WebP with quality sweep and update asset-map; docs; gitignore entry for raw downloads.
Package
package.json
Added sharp devDependency for image processing.
Tests (new)
tests/asset/keys.test.ts, tests/asset/variant.test.ts, tests/asset/manifest.test.ts, tests/asset/selector.test.ts, tests/asset/load-session-manifest.test.ts, tests/asset/app-providers-wiring.test.tsx, tests/asset/sketch-background.test.tsx
Comprehensive unit/integration tests for key parsing, variant rewrites, manifest drift guard, selector outputs, session loader behavior, provider wiring, and SketchBackground error handling.
Tests (updated harnesses & assertions)
tests/home-dashboard.test.tsx, tests/home-page.test.tsx, tests/home-page.e2e.ts, tests/home-recents-insertion.test.tsx, tests/detail-dashboard.test.tsx, tests/location-page-states.test.tsx, tests/location-page.e2e.ts, tests/location-recents-insertion.test.tsx, tests/favorites-page.test.tsx, tests/favorites-edit-mode.test.tsx
Test render helpers now wrap components with SketchManifestProvider(BASELINE_MANIFEST) and add assertions for data-sketch-key / data-size-hint where applicable.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant AppProviders
    participant Loader as loadSessionManifest()
    participant LocalStorage
    participant RemoteAPI as /v1/assets/manifest
    participant SketchBackground
    participant Manifest

    User->>AppProviders: Mount
    AppProviders->>Loader: loadSessionManifest()
    Loader->>LocalStorage: read PENDING_MANIFEST_STORAGE_KEY
    alt pending override present
        LocalStorage-->>Loader: pending JSON
        Loader->>Manifest: mergeManifest(BASELINE, override)
    else no pending
        LocalStorage-->>Loader: null
        Loader->>Manifest: BASELINE_MANIFEST
    end
    Loader->>AppProviders: return frozen manifest
    AppProviders->>AppProviders: provide via SketchManifestProvider
    Loader->>RemoteAPI: fetch (background)
    RemoteAPI-->>Loader: remote manifest
    Loader->>LocalStorage: write PENDING_MANIFEST_STORAGE_KEY (for next load)

    User->>SketchBackground: render(location, condition)
    SketchBackground->>Manifest: selectSketchKey -> lookup URL
    Manifest-->>SketchBackground: URL (override or baseline)
    SketchBackground->>User: render img[src=URL]
    alt img errors
        SketchBackground->>SketchBackground: retry with baseline (once)
        alt baseline errors
            SketchBackground->>SketchBackground: hide image
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

type:feature, area:ui, area:assets, area:storage, status:ready

Poem

🐇 I stitched the keys, I hopped through code,

webp skies painted down the road.
Baseline today, remote tomorrow,
Fallback soft so users borrow
a perfect sky with no sorrow. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: implementing a sketch asset manifest system with semantic keys (WP-020), which matches the primary objective of establishing a manifest-based asset system across the codebase.
Linked Issues check ✅ Passed The PR fulfills all coding requirements from issue #47: semantic key types and manifest shape defined, bundled baseline manifest provided, hub inheritance logic implemented via HUB_BY_ADMIN1 and selectSketchKey, generic fallback rewrites (cloudy-night→clear-night, snowy-night→rainy-night) implemented, remote manifest fetching with localStorage pending override, SketchManifestProvider context and SketchBackground component applied to Home/Detail/Favorites, comprehensive unit and integration tests added.
Out of Scope Changes check ✅ Passed All changes are within scope: semantic key definition, bundled baseline manifest, remote fetching, selector logic, manifest-based image application, and test coverage. Out-of-scope items (service worker caching, coastal/mountain/rural hubs, server endpoint, persisted sketchKey) are correctly excluded.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wp-020-sketch-assets

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cgm-16 cgm-16 self-assigned this Apr 13, 2026
@cgm-16 cgm-16 added priority:p2 Normal priority area:assets Sketch assets and manifest type:feature Feature work labels Apr 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (4)
tests/location-recents-insertion.test.tsx (1)

64-69: Unify provider wrapping across all tests in this suite.

Only the first case uses SketchManifestProvider; the other LocationPage renders use a different harness. Consider extracting one shared render helper and reusing it for all three cases to avoid setup drift.

Suggested refactor
+function renderLocationPageWithProviders(
+  LocationPage: (props: { resolvedLocationId: string }) => JSX.Element
+) {
+  return render(
+    <SketchManifestProvider manifest={BASELINE_MANIFEST}>
+      <MemoryRouter>
+        <LocationPage resolvedLocationId="aaa000000001" />
+      </MemoryRouter>
+    </SketchManifestProvider>
+  );
+}
@@
-    render(
-      <SketchManifestProvider manifest={BASELINE_MANIFEST}>
-        <MemoryRouter>
-          <LocationPage resolvedLocationId="aaa000000001" />
-        </MemoryRouter>
-      </SketchManifestProvider>
-    );
+    renderLocationPageWithProviders(LocationPage);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/location-recents-insertion.test.tsx` around lines 64 - 69, The tests
diverge in provider/wrapper setup causing setup drift; unify them by extracting
a shared render helper that wraps LocationPage with SketchManifestProvider
(using BASELINE_MANIFEST) and MemoryRouter. Create a helper function (e.g.,
renderLocationPage) used across all three tests to render <LocationPage
resolvedLocationId="...">, replacing the inline wrappers in each test so all
cases use the same manifest/provider harness (refer to SketchManifestProvider,
MemoryRouter, LocationPage, and BASELINE_MANIFEST to locate the code). Ensure
the helper accepts the resolvedLocationId (and any optional overrides) so tests
remain flexible.
frontend/entities/asset/api/fetch-remote-manifest.ts (1)

7-19: Consider adding a timeout for resilience.

The fetch has no timeout configured. While the PR notes that the server endpoint doesn't exist yet (404 silent fallback), for production hardening consider adding an AbortController timeout to prevent indefinite hangs on slow/unresponsive servers.

♻️ Optional: Add timeout with AbortController
 export const fetchRemoteManifest: RemoteManifestFetcher = async () => {
+  const controller = new AbortController();
+  const timeoutId = setTimeout(() => controller.abort(), 5000);
+
-  const response = await fetch(REMOTE_MANIFEST_URL, {
-    headers: { Accept: 'application/json' },
-  });
+  try {
+    const response = await fetch(REMOTE_MANIFEST_URL, {
+      headers: { Accept: 'application/json' },
+      signal: controller.signal,
+    });
+    clearTimeout(timeoutId);
     if (!response.ok) {
       throw new Error(`remote manifest fetch failed: ${response.status}`);
     }
     // ... rest of validation
+  } catch (err) {
+    clearTimeout(timeoutId);
+    throw err;
+  }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/entities/asset/api/fetch-remote-manifest.ts` around lines 7 - 19,
fetchRemoteManifest currently calls fetch(REMOTE_MANIFEST_URL) with no timeout;
add an AbortController and a short configurable timeout (e.g. via a constant) to
avoid hanging: create an AbortController, start a setTimeout that calls
controller.abort() after the timeout, pass controller.signal to fetch, and clear
the timeout after a successful response; ensure errors from aborted fetch are
handled or rethrown as a descriptive timeout Error so callers of
fetchRemoteManifest can distinguish timeout vs other failures.
frontend/pages/home/ui/home-dashboard.tsx (1)

117-166: LGTM! Optional: Extract duplicated weather summary content.

The SketchBackground integration is correct — it receives the required location and condition props and is properly layered behind the card content. The provider wiring at the app level (per app-providers.tsx) ensures the context is available.

The weather summary content (temperature, condition text, high/low) is duplicated between the Link (lines 130-143) and div (lines 150-163) branches. Consider extracting into a shared component for DRY, though this is optional given the small scope.

♻️ Optional: Extract shared weather summary
// Could extract to a shared component:
function WeatherSummaryContent({ weather }: { weather: CoreWeather }) {
  return (
    <>
      <p className="font-display text-7xl font-extrabold text-foreground">
        {Math.round(weather.current.temperatureC)}°
      </p>
      <p className="font-body text-base text-muted-foreground">
        {weather.current.condition.text}
      </p>
      <div className="flex gap-4">
        <span className="font-body text-sm text-muted-foreground">
          H {Math.round(weather.today.maxC)}°
        </span>
        <span className="font-body text-sm text-muted-foreground">
          L {Math.round(weather.today.minC)}°
        </span>
      </div>
    </>
  );
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/pages/home/ui/home-dashboard.tsx` around lines 117 - 166, The
weather summary JSX is duplicated between the Link and div branches; extract it
into a small shared component (e.g., WeatherSummaryContent) that accepts the
weather prop (signature like WeatherSummaryContent({ weather }: { weather:
CoreWeather }) ) and returns the temperature, condition text, and H/L spans,
then replace the duplicated blocks in home-dashboard.tsx by rendering
<WeatherSummaryContent weather={weather} /> inside both the Link and the div so
the UI and props (weather.current.temperatureC, weather.current.condition.text,
weather.today.maxC/minC) remain unchanged.
tests/asset/load-session-manifest.test.ts (1)

85-97: Consider using vi.waitFor for async failure handling.

The setTimeout(resolve, 10) on line 93 is a timing-based wait that could be flaky on slow CI runners. Consider using vi.waitFor with a condition or fake timers for more deterministic behavior.

♻️ Optional: Use vi.waitFor with flush approach
   it('remoteFetcher 실패 시 기존 pending을 건드리지 않는다', async () => {
     const existing = JSON.stringify({
       'hub/seoul/clear-day': 'https://cdn.example.com/keep.webp',
     });
     window.localStorage.setItem(PENDING_MANIFEST_STORAGE_KEY, existing);
     const remoteFetcher = vi.fn().mockRejectedValue(new Error('network'));
     loadSessionManifest({ remoteFetcher });
-    // 실패가 비동기적으로 처리될 시간을 준다.
-    await new Promise((resolve) => setTimeout(resolve, 10));
+    // 실패가 비동기적으로 처리될 때까지 대기.
+    await vi.waitFor(() => {
+      expect(remoteFetcher).toHaveBeenCalled();
+    });
+    // 마이크로태스크 flush 후에도 pending이 유지되는지 확인.
+    await Promise.resolve();
     expect(window.localStorage.getItem(PENDING_MANIFEST_STORAGE_KEY)).toBe(
       existing
     );
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/asset/load-session-manifest.test.ts` around lines 85 - 97, Replace the
timing-based sleep with a deterministic wait: remove the setTimeout-based pause
and use vi.waitFor to assert the expected state after loadSessionManifest
completes. Specifically, call loadSessionManifest({ remoteFetcher }) and then
vi.waitFor(() =>
expect(window.localStorage.getItem(PENDING_MANIFEST_STORAGE_KEY)).toBe(existing))
(or waitFor(() => expect(remoteFetcher).toHaveBeenCalled()) first) so the test
waits for the async failure to be observed deterministically; reference
PENDING_MANIFEST_STORAGE_KEY, loadSessionManifest, and remoteFetcher when making
the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/skills/asset-manifest-contract.md`:
- Around line 85-87: Add missing language identifiers to the two fenced code
blocks that contain the curl command and the pnpm/tsx command: update the block
with the curl command (the line containing curl -L "<downloadUrl>=w2400" -o
"scripts/stitch/_raw/<safe-key>.png") to use a bash/ shell fence (e.g., ```bash)
and similarly mark the block with the pnpm exec tsx
scripts/stitch/process-sketch.ts --key <semantic-key> --input
scripts/stitch/_raw/<safe-key>.png command as bash/shell to satisfy markdownlint
MD040; no other content changes are required.

In `@frontend/entities/asset/model/keys.ts`:
- Around line 4-7: Ensure parseSemanticKey enforces valid family↔familyId pairs
by defining a mapping of allowed SketchFamilyId values per SketchFamily and
validating that the parsed SketchFamilyId belongs to the allowed set for the
parsed SketchFamily; update parseSemanticKey (and the related
validator/serializer at the other occurrence around lines 62-67) to first split
the semantic key, confirm the family is one of 'hub'|'generic', then check the
familyId against the corresponding allowed list (e.g., 'hub' ->
['seoul','busan'], 'generic' -> ['urban']), and reject or throw if the pair is
incompatible instead of doing independent membership checks.

In `@frontend/entities/asset/model/manifest.ts`:
- Around line 8-20: The freezeManifest function (and similarly mergeManifest)
currently only validates keys; update both to validate values too by rejecting
non-strings, null/undefined, and strings that are empty or only whitespace: in
freezeManifest validate each url with typeof url === 'string' and
url.trim().length > 0 (throw an Error including the key when invalid) before
pushing entries and freezing for BASELINE_MANIFEST, and in mergeManifest perform
the same trimmed non-empty string check on override values before accepting them
(instead of just checking url.length > 0); this ensures only well-formed,
non-empty string URLs enter the returned SketchManifest.

In `@scripts/stitch/process-sketch.ts`:
- Around line 70-88: encodeWebpUnderBudget currently returns an oversized buffer
if no quality in QUALITY_SWEEP meets MAX_BYTES; change it so it never returns a
buffer > MAX_BYTES: inside encodeWebpUnderBudget (and referenced symbols
QUALITY_SWEEP and MAX_BYTES) after exhausting quality values either (a)
iteratively downscale the pipeline (reduce dimensions by a small factor and
re-encode) until buffer.length <= MAX_BYTES, or (b) if downscaling is
undesirable, throw an explicit error indicating the image cannot be produced
under MAX_BYTES; then update main() to treat such an error (or to refuse to
write any buffer whose length > MAX_BYTES) so no WebP asset is written that
violates the 400 KB contract.
- Around line 13-15: Replace using new URL(...).pathname for REPO_ROOT with
fileURLToPath to produce a platform-native path: import fileURLToPath from 'url'
and change the REPO_ROOT calculation to use resolve(fileURLToPath(new URL('.',
import.meta.url)), '../..') (leave ASSET_MAP_PATH unchanged except it should
reference the updated REPO_ROOT); this ensures Windows drive letters and
percent-decoded characters are handled correctly.

---

Nitpick comments:
In `@frontend/entities/asset/api/fetch-remote-manifest.ts`:
- Around line 7-19: fetchRemoteManifest currently calls
fetch(REMOTE_MANIFEST_URL) with no timeout; add an AbortController and a short
configurable timeout (e.g. via a constant) to avoid hanging: create an
AbortController, start a setTimeout that calls controller.abort() after the
timeout, pass controller.signal to fetch, and clear the timeout after a
successful response; ensure errors from aborted fetch are handled or rethrown as
a descriptive timeout Error so callers of fetchRemoteManifest can distinguish
timeout vs other failures.

In `@frontend/pages/home/ui/home-dashboard.tsx`:
- Around line 117-166: The weather summary JSX is duplicated between the Link
and div branches; extract it into a small shared component (e.g.,
WeatherSummaryContent) that accepts the weather prop (signature like
WeatherSummaryContent({ weather }: { weather: CoreWeather }) ) and returns the
temperature, condition text, and H/L spans, then replace the duplicated blocks
in home-dashboard.tsx by rendering <WeatherSummaryContent weather={weather} />
inside both the Link and the div so the UI and props
(weather.current.temperatureC, weather.current.condition.text,
weather.today.maxC/minC) remain unchanged.

In `@tests/asset/load-session-manifest.test.ts`:
- Around line 85-97: Replace the timing-based sleep with a deterministic wait:
remove the setTimeout-based pause and use vi.waitFor to assert the expected
state after loadSessionManifest completes. Specifically, call
loadSessionManifest({ remoteFetcher }) and then vi.waitFor(() =>
expect(window.localStorage.getItem(PENDING_MANIFEST_STORAGE_KEY)).toBe(existing))
(or waitFor(() => expect(remoteFetcher).toHaveBeenCalled()) first) so the test
waits for the async failure to be observed deterministically; reference
PENDING_MANIFEST_STORAGE_KEY, loadSessionManifest, and remoteFetcher when making
the change.

In `@tests/location-recents-insertion.test.tsx`:
- Around line 64-69: The tests diverge in provider/wrapper setup causing setup
drift; unify them by extracting a shared render helper that wraps LocationPage
with SketchManifestProvider (using BASELINE_MANIFEST) and MemoryRouter. Create a
helper function (e.g., renderLocationPage) used across all three tests to render
<LocationPage resolvedLocationId="...">, replacing the inline wrappers in each
test so all cases use the same manifest/provider harness (refer to
SketchManifestProvider, MemoryRouter, LocationPage, and BASELINE_MANIFEST to
locate the code). Ensure the helper accepts the resolvedLocationId (and any
optional overrides) so tests remain flexible.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6c8f3746-25eb-4ec5-8f42-29fbfcb8eda2

📥 Commits

Reviewing files that changed from the base of the PR and between 0359af1 and 98675fe.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (61)
  • .gitignore
  • docs/skills/asset-manifest-contract.md
  • frontend/app/providers/app-providers.tsx
  • frontend/entities/asset/api/fetch-remote-manifest.ts
  • frontend/entities/asset/api/load-session-manifest.ts
  • frontend/entities/asset/data/baseline-manifest.json
  • frontend/entities/asset/index.ts
  • frontend/entities/asset/model/archetypes.ts
  • frontend/entities/asset/model/keys.ts
  • frontend/entities/asset/model/manifest.ts
  • frontend/entities/asset/model/selector.ts
  • frontend/entities/asset/model/variant.ts
  • frontend/entities/asset/ui/sketch-background.tsx
  • frontend/entities/asset/ui/sketch-manifest-context.tsx
  • frontend/pages/favorites/ui/favorite-card.tsx
  • frontend/pages/home/ui/home-dashboard.tsx
  • frontend/pages/location/ui/detail-dashboard.tsx
  • package.json
  • public/sketches/generic/urban/clear-day.webp
  • public/sketches/generic/urban/clear-night.webp
  • public/sketches/generic/urban/cloudy-day.webp
  • public/sketches/generic/urban/rainy-day.webp
  • public/sketches/generic/urban/rainy-night.webp
  • public/sketches/generic/urban/snowy-day.webp
  • public/sketches/hub/busan/clear-day.webp
  • public/sketches/hub/busan/clear-night.webp
  • public/sketches/hub/busan/cloudy-day.webp
  • public/sketches/hub/busan/cloudy-night.webp
  • public/sketches/hub/busan/rainy-day.webp
  • public/sketches/hub/busan/rainy-night.webp
  • public/sketches/hub/busan/snowy-day.webp
  • public/sketches/hub/busan/snowy-night.webp
  • public/sketches/hub/seoul/clear-day.webp
  • public/sketches/hub/seoul/clear-night.webp
  • public/sketches/hub/seoul/cloudy-day.webp
  • public/sketches/hub/seoul/cloudy-night.webp
  • public/sketches/hub/seoul/rainy-day.webp
  • public/sketches/hub/seoul/rainy-night.webp
  • public/sketches/hub/seoul/snowy-day.webp
  • public/sketches/hub/seoul/snowy-night.webp
  • scripts/stitch/README.md
  • scripts/stitch/asset-map.json
  • scripts/stitch/process-sketch.ts
  • scripts/stitch/sketch-batch.json
  • tests/asset/app-providers-wiring.test.tsx
  • tests/asset/keys.test.ts
  • tests/asset/load-session-manifest.test.ts
  • tests/asset/manifest.test.ts
  • tests/asset/selector.test.ts
  • tests/asset/sketch-background.test.tsx
  • tests/asset/variant.test.ts
  • tests/detail-dashboard.test.tsx
  • tests/favorites-edit-mode.test.tsx
  • tests/favorites-page.test.tsx
  • tests/home-dashboard.test.tsx
  • tests/home-page.e2e.ts
  • tests/home-page.test.tsx
  • tests/home-recents-insertion.test.tsx
  • tests/location-page-states.test.tsx
  • tests/location-page.e2e.ts
  • tests/location-recents-insertion.test.tsx

Comment thread docs/skills/asset-manifest-contract.md Resolved
Comment thread frontend/entities/asset/model/keys.ts Resolved
Comment thread scripts/stitch/process-sketch.ts Resolved
Comment thread scripts/stitch/process-sketch.ts Resolved
cgm-16 and others added 2 commits April 13, 2026 20:46
- docs: asset-manifest-contract.md 코드 펜스에 bash 언어 태그 추가 (MD040)
- feat(keys): family↔familyId 교차 검증 추가 (hub→seoul/busan, generic→urban)
- feat(manifest): freezeManifest/mergeManifest에 URL 값 trim 검증 추가
- fix(process-sketch): 400KB 초과 시 파일 쓰기 대신 에러 throw로 계약 위반 방지
- fix(process-sketch): REPO_ROOT를 fileURLToPath로 플랫폼 호환 경로로 변경
- feat(fetch-remote-manifest): AbortController 타임아웃 추가 (5s)
- refactor(home-dashboard): WeatherSummaryContent 컴포넌트로 중복 JSX 제거
- test(asset): setTimeout → vi.waitFor로 결정론적 비동기 검증
- test(recents): renderLocationPage 헬퍼로 테스트 설정 통일 (SketchManifestProvider 포함)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
frontend/entities/asset/model/manifest.ts (1)

14-18: ⚠️ Potential issue | 🟡 Minor

Trim validated URLs before storing them.

Line 14 and Line 36 validate url.trim().length, but Line 17 and Line 37 still persist the untrimmed value. This can leave leading/trailing whitespace in manifest URLs.

Suggested patch
 function freezeManifest(raw: Record<string, string>): SketchManifest {
   const entries: Array<[SemanticKey, string]> = [];
   for (const [key, url] of Object.entries(raw)) {
     if (!isSemanticKey(key)) {
       throw new Error(`baseline manifest has invalid semantic key: ${key}`);
     }
     if (typeof url !== 'string' || url.trim().length === 0) {
       throw new Error(`baseline manifest has invalid url for key: ${key}`);
     }
-    entries.push([key, url]);
+    entries.push([key, url.trim()]);
   }
   return Object.freeze(Object.fromEntries(entries)) as SketchManifest;
 }
@@
   const merged: Record<string, string> = { ...baseline };
   for (const [key, url] of Object.entries(override)) {
     if (typeof url === 'string' && url.trim().length > 0 && isSemanticKey(key)) {
-      merged[key] = url;
+      merged[key] = url.trim();
     }
   }

Also applies to: 36-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/entities/asset/model/manifest.ts` around lines 14 - 18, The manifest
URL validation trims the input but stores the original untrimmed string; update
the storage to persist the trimmed URL by replacing occurrences where you push
or assign url into entries (e.g., entries.push([key, url])) with the trimmed
value (e.g., use url.trim()) and do the same for the other occurrence mentioned
(lines around the second validation), ensuring every place that writes the
manifest entry uses the trimmed url.
🧹 Nitpick comments (1)
scripts/stitch/process-sketch.ts (1)

105-114: Validate sourceSize against actual input dimensions before processing.

At Line 105–Line 114, the script checks key existence and image metadata, but it does not verify that --input matches the key’s expected sourceSize. A mismatched file can still overwrite the canonical output and hash for that semantic key.

🔧 Proposed patch
   const map = await loadAssetMap();
-  if (!map[key]) {
+  const entry = map[key];
+  if (!entry) {
     throw new Error(`asset-map.json has no entry for key: ${key}`);
   }

   const meta = await sharp(absInput).metadata();
   if (!meta.width || !meta.height) {
     throw new Error('could not read source image dimensions');
   }
+  const expectedSize = /^(\d+)x(\d+)$/.exec(entry.sourceSize);
+  if (expectedSize) {
+    const expectedWidth = Number(expectedSize[1]);
+    const expectedHeight = Number(expectedSize[2]);
+    if (meta.width !== expectedWidth || meta.height !== expectedHeight) {
+      throw new Error(
+        `source size mismatch for key ${key}: expected ${entry.sourceSize}, got ${meta.width}x${meta.height}`
+      );
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/stitch/process-sketch.ts` around lines 105 - 114, The script
currently loads the asset map with loadAssetMap(), finds map[key], and reads
image metadata via sharp(absInput).metadata(), but it doesn't verify that the
actual image dimensions match the expected sourceSize for that semantic key; add
a validation step that reads expected sourceSize from map[key] (e.g.,
map[key].sourceSize or similar), compare it to meta.width and meta.height, and
if they differ throw an Error (or exit) before calling computeBottomCrop and
continuing so a mismatched input cannot overwrite the canonical output/hash for
that key.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/entities/asset/api/fetch-remote-manifest.ts`:
- Around line 20-24: In the catch block of fetch-remote-manifest.ts where you
check controller.signal.aborted, preserve the original error when rethrowing by
attaching the caught error as the cause of the new timeout Error (use the
existing err variable) instead of creating a bare Error; update the throw that
mentions `remote manifest fetch timed out after ${FETCH_TIMEOUT_MS}ms` to
include the original err as its cause so lint rule `preserve-caught-error` is
satisfied and debugging context is retained (refer to controller.signal.aborted,
FETCH_TIMEOUT_MS, and the catch scope that defines err).

---

Duplicate comments:
In `@frontend/entities/asset/model/manifest.ts`:
- Around line 14-18: The manifest URL validation trims the input but stores the
original untrimmed string; update the storage to persist the trimmed URL by
replacing occurrences where you push or assign url into entries (e.g.,
entries.push([key, url])) with the trimmed value (e.g., use url.trim()) and do
the same for the other occurrence mentioned (lines around the second
validation), ensuring every place that writes the manifest entry uses the
trimmed url.

---

Nitpick comments:
In `@scripts/stitch/process-sketch.ts`:
- Around line 105-114: The script currently loads the asset map with
loadAssetMap(), finds map[key], and reads image metadata via
sharp(absInput).metadata(), but it doesn't verify that the actual image
dimensions match the expected sourceSize for that semantic key; add a validation
step that reads expected sourceSize from map[key] (e.g., map[key].sourceSize or
similar), compare it to meta.width and meta.height, and if they differ throw an
Error (or exit) before calling computeBottomCrop and continuing so a mismatched
input cannot overwrite the canonical output/hash for that key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 565b4927-a6ff-42d8-9290-3ef7d75b0e7d

📥 Commits

Reviewing files that changed from the base of the PR and between 98675fe and 3cd50c7.

📒 Files selected for processing (8)
  • docs/skills/asset-manifest-contract.md
  • frontend/entities/asset/api/fetch-remote-manifest.ts
  • frontend/entities/asset/model/keys.ts
  • frontend/entities/asset/model/manifest.ts
  • frontend/pages/home/ui/home-dashboard.tsx
  • scripts/stitch/process-sketch.ts
  • tests/asset/load-session-manifest.test.ts
  • tests/location-recents-insertion.test.tsx
✅ Files skipped from review due to trivial changes (3)
  • tests/location-recents-insertion.test.tsx
  • docs/skills/asset-manifest-contract.md
  • tests/asset/load-session-manifest.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/entities/asset/model/keys.ts
  • frontend/pages/home/ui/home-dashboard.tsx

- manifest.ts: freezeManifest·mergeManifest 모두 url.trim() 으로 저장
- process-sketch.ts: sourceSize 계약과 실제 이미지 크기 불일치 시 조기 실패

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
loadSessionManifest가 백그라운드 fetch를 발생시켜 React Router가
"No route matches URL"을 throw하고 HMR 이벤트가 트리거됨.
vite.config.ts에 개발 전용 미들웨어를 추가해 빈 override로 응답,
E2E 타이밍 문제를 방지한다.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@cgm-16
cgm-16 merged commit 56196bc into main Apr 13, 2026
10 checks passed
@cgm-16
cgm-16 deleted the feat/wp-020-sketch-assets branch April 13, 2026 12:16
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area:assets Sketch assets and manifest priority:p2 Normal priority type:feature Feature work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] WP-020 — 스케치 에셋 매니페스트, 선택기, 폴백 로더 구현

1 participant