Cloudwiki

Web sandbox 프로젝트/구상/시작 화면과 도감 페이지

정보: 이 문서는 **구상 문서**다
여기 적힌 것 중 **구현된 것은 하나도 없다.** 현행 코드 상태는 "현행" 이라고 명시한 절과 표에만 있고, 나머지는 전부 아직 만들지 않은 것에 대한 제안이다. 수치는 조사 시점 코드 기준이며, 줄번호는 의도적으로 쓰지 않았다(코드가 밀리면 곧 거짓이 된다).


## 배경과 문제 정의

지금 이 게임은 **접속하는 순간 캔버스가 곧바로 노출된다.** 로딩 화면도, 시작 버튼도, 튜토리얼도, "무엇을 하는 게임인지" 알려 주는 한 줄도 없다. 첫 방문자는 검은 화면에 사이드바 하나를 보고 스스로 알아내야 한다.

동시에 게임 안에는 **물질이 무엇인지 설명하는 UI가 한 줄도 없다.** 물질 138종이 등록돼 있고 그중 126종이 팔레트에 노출되는데, 팔레트 칩의 `title` 속성은 라벨과 **똑같은 이름을 반복할 뿐**이다. 융점·밀도·발화점·전도성 같은 물성도, 반응 사슬(산+금속→수소, 갈륨+알루미늄 취화 같은)도 게임 안에서는 **직접 해 보는 것 외에 알 방법이 없다.**

이 문서는 그 둘을 각각 **별도 페이지**로 두는 구상이다.


홈(시작) 페이지
게임 제목 · 게임 시작하기 · 이어하기 · 불러오기 · 툴팁(도감) · 언어 전환 · 설정

툴팁(물질 도감) 페이지
인게임 물질 툴팁을 페이지로. 아이콘 · 수치 스펙 · 설명 산문 · 반응 사슬



---

## 현행 부팅 흐름

### 라우트는 둘뿐이다


| 경로 | 소스 | 산출물 |
|---|---|---|
| `/` | `src/pages/index.astro` | `dist/index.html` |
| (unmatched) | `src/pages/404.astro` | `dist/404.html` |

동적 라우트(`[param].astro`)도 `getStaticPaths` 사용처도 **없다.** 레이아웃은 `src/layouts/Base.astro` 하나뿐이다.

`index.astro`의 본문은 사실상 세 줄이다 — `<canvas id="game">` 하나, `<ControlPanel client:load />` 하나, 그리고 `startGame(canvas)`를 호출하는 deferred module script 하나. Svelte 아일랜드는 **ControlPanel 단 하나**이고 나머지 8개 컴포넌트는 전부 그 자식이다.

### 부팅 시퀀스


1. 브라우저 언어 판정
`<head>`의 **인라인 스크립트**가 `particle-sandbox:settings:v1`의 `locale` 또는 `navigator.languages`를 읽어 `<html lang>`을 세팅한다. 하이드레이션 전 언어 깜빡임을 막는 장치다. (`src/layouts/Base.astro`)

2. 외부 CDN 스타일시트
jsdelivr의 Bootstrap Icons 1.13.1 CSS. **이 프로젝트의 유일한 외부 네트워크 요청이다.**

3. 캔버스가 이미 화면에 있다
`#game`은 초기 HTML에 존재하고 `position: fixed`로 사이드바 옆 전 영역을 채운다. 배경 `#101014`. **첫 페인트부터 보인다.**

4. 설정 하이드레이트
`initSettingsPersistence()` — 모든 atom을 localStorage에서 복원. 구독보다 **먼저** 돌아야 엔진이 복원값으로 시딩된다.

5. 열 커널 비동기 로드
`initHeatWasm()` — `heat.wasm`을 fetch. 실패해도 JS 경로로 계속한다. **코드베이스에서 진짜 지연 로딩은 이것 하나뿐이다.**

6. 레이아웃 → 그리드 해상도 유도
`SandboxLayout`이 `canvas.clientWidth/Height`에서 격자 해상도를 계산한다. 캔버스 크기가 곧 월드 크기다.

7. 저장 월드 복원
`loadWorld()` → `grid.resizeFrom(...)` → `randomizeTints()`. **벤치 모드가 아닌 한 무조건 실행된다.**

8. 스냅샷 모듈에 그리드 등록
`registerGridForSnapshots(grid, appliedCb, captureCb)` — **여기서 스냅샷 계통이 살아 있는 그리드를 얻는다.** 이 절이 뒤에 나올 "불러오기" 설계의 핵심이다.

9. rAF 루프 시작
고정 틱 스텝 + 매 프레임 렌더 + 3초마다 월드 자동 저장. **시뮬레이션은 첫 rAF에 이미 돌고 있다.**



주의: 여기서 나오는 결론
**로딩 화면도 게이트도 전혀 없다.** 시작 화면을 끼우는 것은 "기존 게이트를 바꾸는 일"이 아니라 **처음부터 새로 만드는 일**이다. 지금 코드에는 "게임이 시작되기 전"이라는 상태 자체가 존재하지 않는다.


---

## 페이지 추가 비용

### 라우팅 자체는 거의 공짜다

`astro.config.mjs`가 `output: 'static'`이고 어댑터가 없다. `site`/`base`/`trailingSlash`도 미지정. 즉 **파일 기반 정적 라우팅 그대로**다.

```
src/pages/start.astro       → dist/start/index.html      (URL /start)
src/pages/codex.astro       → dist/codex/index.html      (URL /codex)
src/pages/codex/[id].astro  → dist/codex/<id>/index.html (getStaticPaths, 126장)
```

배포도 손댈 게 없다. `wrangler.toml`은 `main`이 없는 **assets-only** 설정(`directory = "./dist"`, `not_found_handling = "404-page"`)이라 Worker 스크립트 자체가 없고 엣지가 `dist`를 통째로 서빙한다. 공유 청크는 Rolldown이 자동 분리하므로 두 페이지가 같은 `_astro/*.js`를 캐시 공유한다.

### 그런데 부속은 공짜가 아니다 — 조용히 걸리는 다섯 곳


① PWA 진입점 — 오프라인에서 도감을 열면 게임이 뜬다
`public/manifest.webmanifest`의 `start_url`·`scope`·`id`가 **전부 `/`** 다. 그리고 `public/sw.js`는 install 시점에 `cache.add('/')` **한 개만** 앱 셸로 예열하고, 오프라인 내비게이션 실패 시 `cache.match(request) ?? cache.match('/')`로 폴백한다.

**결과**: 온라인으로 한 번도 방문한 적 없는 `/codex`를 오프라인에서 열면 404도 아니고 오류도 아니고 **조용히 게임 화면이 뜬다.** 디버깅이 극도로 어려운 부류의 실패다.

**해야 할 일 (세트로 묶임)**: 프리캐시를 배열로 확장 → 오프라인 내비 폴백 분기를 경로별로 분기 → `SW_VERSION` 을 올림. `docs/PWA.md`는 "fetch 전략 자체를 바꿀 때만 올린다"고 못박고 있는데, 프리캐시 목록·폴백 로직 변경은 **캐시 구조 변경에 해당하므로 올려야 한다.** 올리면 activate가 옛 캐시를 전부 삭제한다.

[주의] `sw.js` 경로는 절대 바꾸지 말 것. `public/`에서 해시 없이 `/sw.js`로 고정 배포되는 것이 브라우저 SW 갱신 절차의 전제다.

② 메타 태그 — `Base.astro`는 `title` prop 하나뿐이다
`description`·OG·Twitter 카드가 **전무하다.** 지금까지는 페이지가 게임 하나뿐이라 문제가 안 됐지만, **시작 화면은 공유될 것을 전제로 하는 페이지**다. 링크를 붙였을 때 미리보기가 비면 첫인상이 그것으로 끝난다.

선행 작업: `Base.astro`에 `description` / `ogImage` / `noIndex` prop 추가. 매니페스트에 이미 한국어 description이 있으니 문구는 거기서 승계할 수 있다.

③ 스크롤 — `html, body { overflow: hidden }`
`src/styles/global.css`가 전역으로 스크롤을 잠근다(캔버스 게임이라 당연한 선택이었다). **스크롤되는 도감 페이지는 페이지 스코프 `<style>`로 반드시 덮어써야 한다.** `#game` 셀렉터는 `position: fixed`라 캔버스가 없는 페이지에 영향이 없지만, `--sidebar-w` / `--bottombar-h` / `--bottom-deadzone` 변수는 `:root`에 그대로 남는다.

④ 외부 의존 — 아이콘이 비는 첫 오프라인 방문
`Base.astro`의 jsdelivr Bootstrap Icons가 **이 프로젝트의 유일한 외부 네트워크 요청**이다. 새 페이지가 `bi-*` 글리프를 쓰면 첫 오프라인 방문에서 아이콘 자리가 전부 빈다. 하필 **시작 화면이 "첫 인상" 페이지**라 가장 취약한 지점에 정확히 걸린다.

대응 후보 셋: (가) 시작 화면에서만 `bi-*` 대신 인라인 SVG를 쓴다, (나) 아이콘 웹폰트를 저장소로 서브셋 자가호스팅한다, (다) 그대로 두고 SW 프리캐시 목록에 CDN URL을 넣는다(cross-origin opaque 응답이라 신뢰도가 낮다).

⑤ JS 없는 다국어 — 이미 선례가 있고, 죽은 키도 있다
`404.astro`가 **"양 언어를 다 렌더하고 `<head>` 인라인 스크립트로 한쪽을 숨기는"** 패턴을 이미 만들어 뒀다. Svelte 아일랜드 없이 정적 다국어를 하는 유일한 선례이고, JS가 꺼져 있으면 한국어로 폴백하는 CSS 규칙까지 갖춰져 있다.

동시에 **`notFound.*` i18n 키 4개가 `ui.en.ts`/`ui.ko.ts`에 정의돼 있지만 아무 데서도 안 쓰인다** — 404가 문구를 하드코딩했기 때문이다. 새 정적 페이지에서 같은 이원화를 반복하면 죽은 키가 계속 늘어난다.



### 제안: 선행 작업 3건을 페이지보다 먼저

제안 P-1 — `Base.astro`에 메타 prop 확장
`title` 외에 `description` / `ogImage` / `bodyClass`(스크롤 해제용) prop을 받게 한다.

> **제안 근거**
(a) **지금 비어 있는 것**: description·OG·Twitter 카드가 0개. 페이지가 하나뿐이라 드러나지 않았을 뿐이다.
(b) **재사용하는 인프라**: `Base.astro`가 이미 모든 페이지의 유일한 레이아웃이고, 매니페스트에 한국어 description 문구가 이미 있다.
(c) **비용/리스크**: prop 3개 추가, 기존 호출부는 기본값으로 무변경. 리스크 거의 0.


제안 P-2 — 정적 페이지 i18n 패턴을 하나로 확정하고 `notFound.*` 죽은 키를 정리
`404.astro`의 "양 언어 렌더 + 인라인 스크립트" 패턴을 **Astro 컴포넌트 하나로 추출**하고(`<LocaleSplit>` 같은), `404.astro`가 그것을 쓰게 바꾸면서 `notFound.*` 키를 실제로 소비하게 만든다.

> **제안 근거**
(a) **지금 비어 있는 것**: 패턴은 있는데 재사용 가능한 형태가 아니다. i18n 키 4개가 정의만 되고 죽어 있다.
(b) **재사용하는 인프라**: `404.astro`의 검증된 CSS·스크립트를 그대로 승계. `ui.en.ts`/`ui.ko.ts`는 평탄화 키 264개가 완전히 일치하는 깨끗한 상태라 새 섹션을 붙이는 비용이 낮다.
(c) **비용/리스크**: 컴포넌트 1개 + 404 리팩터. 다만 **키가 늘어나면 조용한 누락 위험도 늘어난다** — `materialNamesKo`가 `Record<number, string>` 인덱스 시그니처라 빠뜨려도 타입체크가 안 잡는 것과 같은 부류의 함정이다.


제안 P-3 — SW 프리캐시·폴백을 "경로 목록" 구조로 리팩터
지금의 `cache.add('/')` 하드코딩을 `SHELL_ROUTES = ['/', '/start', '/codex']` 같은 배열로 바꾸고, 내비 폴백을 **요청 경로에 대응하는 셸이 있으면 그것을, 없으면 `/`** 로 고른다.

> **제안 근거**
(a) **지금 비어 있는 것**: 페이지가 하나뿐이라는 전제가 SW에 하드코딩돼 있다.
(b) **재사용하는 인프라**: `networkFirst`/`cacheFirst` 두 갈래 구조는 그대로. 해시 자산 cache-first는 손댈 필요 없다.
(c) **비용/리스크**: `SW_VERSION` 상승이 강제되고, 올리면 기존 사용자의 캐시가 전부 무효화된다(다음 방문이 온라인이면 무해). **페이지를 추가하기로 결정한 그 순간에 같이 해야 하는 작업**이지 나중으로 미룰 수 없다.


---

## 시작 페이지 설계

### 요구 요소


| 요소 | 지금 상태 | 새로 필요한 것 |
|---|---|---|
| 게임 제목 | 사이드바 `.head`에 h1으로 존재(모바일에선 숨김) | 큰 타이포 + 아이덴티티 |
| **게임 시작하기** | 없음 — "새로 시작"이라는 개념 자체가 없다 | 월드 클리어 진입점 |
| **이어하기** | 사실상 이미 동작(무조건 복원) | **선택지로 노출**만 하면 됨 |
| **불러오기** | `SaveSlots` 모달 안에 전부 있음 | 캔버스 없는 환경으로 이식 |
| **툴팁(도감)** | 없음 | 새 페이지 |
| 언어 전환 | 설정 모달 안 | 시작 화면에 노출 |
| 설정 | 설정 모달 | 링크 또는 축약판 |

### 라우팅 3안 비교


| 안 | PWA `start_url` 영향 | 설치본 호환 | 구현 비용 | 공유 링크 |
|---|---|---|---|---|
| **(가)** `/`=시작화면
`/play`=게임 | `start_url`을 `/play`로 바꿔야 설치본이 게임에서 시작
그대로 두면 **매번 시작화면부터** | `id`가 `/`로 같아 업데이트로 인식
단 진입 지점 의미가 바뀐다 | **높음** — 게임 코드를 새 페이지로 이사 + SW 셸 재구성 | 최상
`/`가 곧 소개 페이지 |
| **(나)** `/`=게임 유지
`/start`=시작화면 | 무변경 | 무변경 — 설치본은 지금과 똑같이 동작 | **낮음** — 파일 추가만 | 애매
`/`를 공유하면 시작화면을 못 본다 |
| **(다)** `/` 안에서 오버레이
(페이지 추가 없음) | 무변경 | 무변경 | **중간** — 게임 부팅에 게이트 삽입(현재 게이트 없음) | 최악
URL로 상태를 가리킬 수 없다 |

성공: 권고 — **(나) 로 시작해서 (가) 로 승격**
**1단계는 (나)다.** `/start`를 새로 만들되 게임은 `/`에 그대로 둔다. PWA 진입점·설치본·SW 셸을 전혀 건드리지 않고 시작 화면의 내용·동선·핸드오프 설계를 **실물로 검증**할 수 있다. 위험이 사실상 0인 구간에서 배울 것을 다 배우는 순서다.

**시작 화면이 실제로 좋다고 판정되면 (가)로 승격한다.** 그때 게임을 `/play`로 옮기고 `start_url`·`scope`·SW 프리캐시·`/start` → `/` 정리를 **한 PR로 묶어서** 한다. 이렇게 나누면 되돌리기가 쉽고, "① 재미" 기준에서 시작 화면이 별로였을 때 아무 부채 없이 접을 수 있다.

**(다)를 기각하는 이유**: 오버레이는 URL로 상태를 가리킬 수 없어 "친구에게 도감 링크 보내기" 같은 동선이 원천 봉쇄된다. 게다가 게임 부팅에 게이트를 넣는 것은 **지금 존재하지 않는 상태를 엔진 부팅 경로에 새로 만드는 일**이라 오히려 (나)보다 침습적이다. 게임 코드에 손을 대면서 얻는 게 URL 하나 아끼는 것뿐이면 남는 장사가 아니다.


### 스냅샷 API 핸드오프 — "불러오기"는 절반만 성립한다


캔버스 없이 되는 것
- `listSnapshots()` — 목록(썸네일 data URL 포함)
- `loadSnapshot(id)` — 역직렬화·소독 완료된 월드
- `updateSnapshot` / `deleteSnapshot`
- `exportSnapshot` / `importSnapshotFromText`
- `snapshotFit.ts` 전부 — `autoPlacement` · `resampleScene` · `sceneClip` · `placeScene` · `fitWorld`. **전부 순수 함수, DOM 불요.**
- `SnapshotLoad.svelte` 미리보기 — 자체 `<canvas>`에 `putImageData`

살아 있는 `Grid`가 필요한 것
- **`applyWorld(world)`** — 실제 적용
- `saveLiveSnapshot(name, desc)`
- `captureThumbnail(canvas, …)`

이들은 `Game.ts`가 부팅 중에 `registerGridForSnapshots(grid, …)`를 호출한 **뒤에만** 동작한다. 등록 전이면 `applyWorld`는 그냥 `false`를 반환한다.



제안 S-1 — id 핸드오프 진입점 신설
시작 화면은 **스냅샷 id만 고르고** `sessionStorage` 또는 `?load=<id>`로 게임 페이지에 넘긴다. 게임 페이지는 `registerGridForSnapshots` **직후** `loadSnapshot` → `fitWorld` → `applyWorld` 를 실행한다.

> **제안 근거**
(a) **지금 비어 있는 것**: `Game.ts`가 읽는 URL 파라미터는 `?perf` · `?bench=` · `?fullscan` **셋뿐**이다. 스냅샷을 지정해 부팅하는 진입점이 없다.
(b) **재사용하는 인프라**: 등록 콜백 자리가 이미 있고(`appliedCb`가 `randomizeTints` + `refreshCursor`를 부른다), 벤치 모드가 이미 **"loadWorld를 건너뛰는 부팅 분기"의 선례**를 만들어 뒀다 — 같은 자리에 분기 하나를 더 붙이는 형태다.
(c) **비용/리스크**: `Game.ts` 부팅부 소폭 수정. **리스크는 자동 저장과의 경합** — 부팅 직후 3초 자동 저장이 돌기 전에 적용이 끝나야 하고, 적용 실패 시 "원래 월드도 잃고 스냅샷도 못 불러온" 상태가 되지 않게 순서를 잡아야 한다.


주의: 잊지 말 것 — 오브젝트 레이어는 어디에도 저장되지 않는다
오브젝트(고무공·드럼통 3종·다이너마이트·연막탄·나무 상자, 7종)는 **월드 자동 저장에도 스냅샷에도 직렬화되지 않는다.** 오히려 `applyWorld`는 적용 전에 `liveGrid.objects.length = 0`으로 **자유 오브젝트를 먼저 지운다.**

시작 화면의 "이어하기"·"불러오기"가 오브젝트를 복원할 것처럼 보이면 안 된다. 문구든 아이콘이든 이 사실을 드러내거나, 아니면 아예 언급하지 않아야 한다 — **"② 편의성" 기준에서 조용히 사라지는 것이 가장 나쁘다.**


제안 S-2 — `?load=` 대신 `sessionStorage`를 기본으로
공유 링크로 남의 스냅샷 id를 넘기는 것은 의미가 없다(스냅샷은 로컬 localStorage에만 있다). URL 파라미터는 **디버깅·자기 자신 재방문용**으로만 두고, 정상 동선은 `sessionStorage`로 한다.

> **제안 근거**
(a) **지금 비어 있는 것**: 세션 간 핸드오프 채널이 없다.
(b) **재사용하는 인프라**: localStorage 키 3종(`settings:v1` / `world:v1` / `snapshots:v1`) 규약이 이미 확립돼 있어 `sessionStorage` 키 하나를 같은 네이밍으로 붙이면 된다.
(c) **비용/리스크**: 거의 0. 다만 **핸드오프 키를 소비 즉시 삭제**하지 않으면 다음 새로고침에서 또 불러와진다 — 반드시 원샷으로 설계할 것.


### "이어하기" 판정 — 이미 되고 있다, 다만 선택지가 아니다

노트: 사실 확인
월드 복원은 **현재 무조건**이다. 벤치 모드가 아닌 한 `loadWorld()`가 항상 실행되고 그리드에 리맵된다. **즉 지금도 새로고침 = 이어하기다.** 시작 화면이 할 일은 새 기능을 만드는 게 아니라 이것을 **선택지로 노출**하는 것이다.

게다가 페이지 이동에도 안전하다 — `pagehide`에 `saveWorld`가 걸려 있어 `/`(게임) ↔ `/codex`(도감) 왕복이 월드를 잃지 않는다.


그런데 두 군데가 비어 있다.


| 필요한 것 | 현재 가능한가 | 비고 |
|---|---|---|
| "이어할 월드가 **있는가**" | 가능 | `localStorage.getItem('particle-sandbox:world:v1') !== null` |
| "이어할 월드가 **비어 있는가**" | **불가능** | 봉투 필드는 `v/w/h/cells/temp/aux/auxHi/ov/ova/ovaHi`뿐 — **파티클 수 필드가 없다.** 알려면 역직렬화 = 엔진 임포트 |
| "이어할 월드의 **미리보기**" | **불가능** | 썸네일은 명명 스냅샷 전용. 자동 저장에는 없다 |
| "**새로 시작**" | **없음** | 클리어 경로는 `$clearSignal` → `grid.clear()` 하나뿐. `loadWorld()`를 건너뛰는 플래그는 `?bench=` 밖에 없다 |

제안 S-3 — 자동 저장 봉투에 경량 메타 필드 추가
`serializeWorld`가 쓰는 봉투에 `n`(비어 있지 않은 셀 수) 하나, 욕심을 낸다면 `thumb`(160px JPEG)까지 추가한다.

> **제안 근거**
(a) **지금 비어 있는 것**: 시작 화면이 "이어할 게 있는지"를 **엔진을 임포트하지 않고는 판정할 수 없다.** 빈 월드에 대고 "이어하기"를 크게 띄우는 것은 "② 편의성" 위반이다.
(b) **재사용하는 인프라**: 썸네일 캡처 함수(`captureThumbnail`, 160px JPEG q0.55)가 이미 있고 명명 스냅샷이 그대로 쓰고 있다. 봉투에 필드를 더하는 것은 **버전 필드 `v`가 이미 있으므로 하위 호환 처리 관례가 확립돼 있다**(구형 세이브에 `auxHi`가 없어도 로드되는 것이 `test/electricity.ts`로 지켜지고 있다).
(c) **비용/리스크**: 자동 저장은 **3초마다** 돈다. 썸네일까지 넣으면 3초마다 JPEG 인코딩 + localStorage 쓰기가 커진다 — **`n` 필드만 먼저 넣고 썸네일은 별도 판단**을 권한다. 파티클 수는 세이브 직렬화 중에 이미 셀을 훑고 있으므로 추가 비용이 사실상 0이다.


제안 S-4 — "새로 시작" 진입점
두 갈래가 있다. **(a)** `Game.ts`에 세션 플래그를 읽어 `loadWorld()`를 건너뛰는 분기 추가, **(b)** 부팅 후 `requestClear()` 호출.

> **판정: (a)를 권한다.** (b)는 침습이 적지만 **한 프레임 동안 옛 월드가 보인다** — "새로 시작"을 눌렀는데 직전 장면이 번쩍 스치는 것은 "① 재미"의 첫인상을 정면으로 깎는다. (a)는 `?bench=`가 이미 `loadWorld`를 건너뛰는 선례를 만들어 둬서 분기 위치가 명확하고, S-1의 `?load=` 진입점과 **같은 자리에 같은 형태로** 들어간다 — 두 제안이 한 번에 처리된다.


### 번들 함정 — 가벼운 시작 화면이 안 가벼워진다

위험: `snapshots.ts` 하나만 임포트해도 엔진 + 물질 138종이 통째로 딸려온다
`snapshots.ts` → `persistence.ts` → `import { getMaterial, MATERIALS } from '../game/materials'`. 물질 배럴은 **모듈 평가 시 138개 `register()`를 실행하는 부수효과 모듈**이라 번들러가 지울 수 없다. 게다가 코드베이스 전체에 **동적 `import()`가 0건**이라 모든 코드가 단일 eager 번들이다.

즉 `listSnapshots()` 하나 쓰려고 임포트하면 **엔진 전체가 시작 화면 번들에 들어온다.**



| 갈래 | 방법 | 판정 |
|---|---|---|
| **(A) 받아들인다** | 그냥 `snapshots.ts`를 쓴다 | 어차피 게임 페이지와 **같은 해시 청크를 공유**하고 SW가 cache-first로 캐시한다. 두 번째 방문부터는 비용 0 |
| **(B) 우회한다** | `localStorage['particle-sandbox:snapshots:v1']`를 **직접 JSON 파싱**해 메타만 읽는다 | 레코드는 `SnapshotMeta` + `world: string` 형태라 **메타(`id/name/desc/createdAt/w/h/thumb`)를 읽는 데 역직렬화가 전혀 필요 없다.** 엔진 임포트 0 |

제안 S-5 — 시작 화면은 (B), 실제 적용은 게임 페이지
시작 화면은 localStorage를 직접 읽어 **목록·썸네일만** 그린다. 선택된 id를 핸드오프하고, 역직렬화·`fitWorld`·`applyWorld`는 전부 게임 페이지가 한다.

> **제안 근거**
(a) **지금 비어 있는 것**: "가벼운 진입 페이지"라는 개념이 없어 번들 분리 전략도 없다.
(b) **재사용하는 인프라**: 저장 스키마가 이미 안정적이고 `MAX_SNAPSHOTS = 50` 상한이 있어 파싱 비용이 유계다.
(c) **비용/리스크**: **스키마 이중화가 부채다** — `snapshots.ts`의 레코드 형태가 바뀌면 시작 화면 파서가 조용히 깨진다. 이 저장소의 확립된 대응은 **검사 스크립트**다(`check:material-ids`, `test/acidmetal.ts`의 태그 명단 검사와 같은 발상). 파서를 쓴다면 **"두 코드가 같은 필드를 본다"를 확인하는 작은 테스트를 같이 만들 것.**


### 미리보기 크기 함정

주의: `$gridDims` 기본값은 360×203 하드코딩이다
`SnapshotLoad.svelte`는 `dstW`/`dstH`를 `$gridDims`에서 받는데, `$gridDims`의 초기값은 `config.ts`의 `GRID_W`/`GRID_H` = **360×203**이고 실제 값은 게임이 부팅한 **뒤에** 레이아웃에서 채워진다.

**즉 시작 화면에서 배치 미리보기를 그리면 목적지 크기가 거짓말이 된다.** 사용자가 맞춰 놓은 배치가 게임에 들어가면 어긋난다.


대응 후보:

- **(가) 시작 화면에서는 배치 UI를 아예 안 보여준다.** 스냅샷 카드만 고르고, 배치는 게임 페이지가 부팅 후 띄운다. **← 권고.** 가장 단순하고 거짓말을 하지 않는다.
- (나) `SandboxLayout`(순수 클래스)에 **게임 페이지에서 캔버스가 갖게 될 CSS px 크기를 직접 넣어** `gw/gh`를 계산한다. 그 크기는 `--sidebar-w: 216px` / `--bottombar-h` / `--bottom-deadzone`과 768px 브레이크포인트에 달려 있어 **시작 화면이 게임의 CSS 레이아웃을 재현해야 한다** — 조용히 어긋날 여지가 크다.
- (다) 마지막 부팅 시의 `gw/gh`를 설정에 저장해 두고 시작 화면이 그것을 읽는다. 창 크기가 바뀌면 여전히 거짓말이지만 (나)보다 싸다.

### 시작 화면 구성 제안 모음


제안 S-6 — 배경에 "살아 있는" 데모 장면
시작 화면 배경에 캔버스를 깔고 미리 만들어 둔 작은 장면(모래시계·용암폭포·불꽃놀이 루프)을 저속으로 돌린다.

> **근거** — (a) 지금은 **첫인상에 "이게 뭘 하는 게임인지" 보여 주는 것이 0개**다. (b) 엔진 + 스냅샷 적용 경로를 그대로 쓴다. 데모 장면은 `.psbx.json` 파일을 저장소에 커밋해 두면 된다(포맷이 이미 있고 32MB 상한, 파서도 있다). (c) **번들이 무거워진다** — S-5의 "가벼운 시작 화면" 방침과 정면충돌한다. 배속을 낮추고 그리드를 작게 잡아 발열을 억제하는 게 전제. **① 재미 기준에서 가장 값이 큰 제안이지만 ② 편의성(로딩 체감)과 맞바꾸는 것**이라 배타적 판단이 필요하다.

제안 S-7 — "오늘의 물질" 카드
시작 화면에 물질 하나를 아이콘 + 한 줄 설명으로 띄우고, 누르면 도감의 해당 항목으로 간다.

> **근거** — (a) 126종 중 대부분이 발견되지 않는다. 팔레트 검색이 **한 번에 한 언어만** 매칭하고(한국어 UI에서 `sand`는 0건) 태그·별칭 검색이 없어 탐색 경로가 좁다. (b) 아이콘은 `materialSvgFor(m)`으로 빌드타임 인라인이 가능하고, 도감 페이지가 생기면 링크 대상도 공짜다. (c) 설명 산문이 필요하다 → 뒤의 도감 스키마 결정에 종속된다. **날짜 기반 결정론적 선택**으로 하면 상태 저장도 불필요하다.

제안 S-8 — 시작 화면에서 언어 전환을 1클릭으로
지금 언어 전환은 설정 모달 안에 있다(모바일은 ⚙ → 스크롤 → 세그 = 3동작).

> **근거** — (a) 첫 방문자가 언어를 바꾸려면 게임 UI를 먼저 이해해야 하는 순환. (b) `$locale`이 이미 설정에 영속화되고 `<html lang>` 동기화 인라인 스크립트도 이미 있다. 시작 화면에서 바꾼 값이 게임에 그대로 승계된다. (c) 비용 거의 0. **다만 시작 화면이 Svelte 아일랜드 없이 정적이라면** `404.astro` 패턴(양 언어 렌더 + 인라인 토글)과 조합할 방법을 정해야 한다.

제안 S-9 — "처음이신가요?" 3줄 안내
게임 전체를 통틀어 조작 설명은 **i18n의 `hint.draw` 한 줄**이고, 그것도 **설정 모달 맨 아래**에 있다. `tutorial|onboard|firstrun|welcome` grep은 0건이다.

> **근거** — (a) 온보딩이 통째로 없다. 게다가 `hint.draw`는 우클릭을 안내하는데 **모바일엔 우클릭이 없다** — 같은 텍스트가 양쪽에 그대로 뜬다. (b) i18n 섹션 추가 방식이 확립돼 있다(`ui.en.ts`/`ui.ko.ts` 264키 완전 일치, 누락 0). (c) 비용 낮음. **모바일/데스크톱 분기 문구를 따로 둘 것** — 지금 `hint.draw`가 정확히 그 실수를 하고 있다.

제안 S-10 — 스냅샷 `.psbx.json` 가져오기를 시작 화면에서
파일 드래그 앤 드롭으로 `.psbx.json`을 받아 슬롯에 넣고 바로 이어서 부팅한다.

> **근거** — (a) 지금 가져오기는 저장 모달 → 슬롯 UI 안쪽에 있어 **"친구가 보낸 장면 파일을 열기"** 동선이 4단계 이상이다. (b) `parseSnapshotFile` / `importSnapshotFromText`가 **캔버스 없이 완전히 동작**하고, 썸네일 data URL 검증(`^data:image/(jpeg|png|webp);base64,…$`)까지 이미 들어 있다. `downloadTextFile`만 DOM이 필요한데 가져오기엔 불필요하다. (c) 비용 낮음. **32MB 상한과 `invalid`/`limit`/`storage`/`write` 4종 오류를 사용자에게 어떻게 보여줄지**가 유일한 설계 지점 — 현재 프로젝트에는 토스트/알림 시스템이 없고 컴포넌트마다 로컬 `flash`를 각자 구현하고 있다.



---

## 툴팁(도감) 페이지 설계

### 데이터 소스 현황 — 그림은 되고 산문은 안 된다


그림 — 즉시 재사용, 클라이언트 JS 0
`materialSvgFor(m)`은 **순수 함수**다. 그 import 그래프 전체에 `document`·`window`·`navigator`·`localStorage`가 **한 번도 등장하지 않는다.**

증거는 정황이 아니라 **실행**이다 — `test/materialicons.ts`가 이 함수로 126종 아이콘을 **Node 안에서** 전부 굽고 래스터화해 골든과 비교한다.

→ **Astro frontmatter에서 빌드타임에 호출해 126장을 인라인 SVG로 구울 수 있다.** 런타임 JS 0, 네트워크 요청 0, 아이콘당 4 KB 상한이 하네스로 강제돼 있다.

산문 — 코드에 없다
`Material` 인터페이스 필드는 **74개**(필수 5: `id`/`name`/`phase`/`color`/`density` + 옵셔널 69). 이 중 `description`·`desc`·`tooltip`·`flavor`·`summary` **어느 것도 없다.**

현재 UI가 물질에 대해 보여 주는 텍스트는 **이름 하나뿐**이다. 팔레트 칩의 `title`은 라벨과 **동일한 문자열**을 반복한다.

돋보기(`InspectPanel`)도 이름·평균온도·개수·비율만 보여 준다. **현재 온도는 알려 주지만 임계값은 알려 주지 않는다.**



### 코드에서 100% 자동 생성 가능한 것


| 자산 | 출처 | 언어 |
|---|---|---|
| 물질 한국어명 | `src/i18n/materials.ts`의 `materialNamesKo` | ko |
| 물질 영어명 | 각 물질의 `Material.name` (영어 테이블은 **의도적으로 빈 객체**, 폴백으로 동작) | en |
| 카테고리 라벨·순서·아이콘 | `src/i18n/materials.ts` + `src/game/materials/categories.ts`의 `CATEGORY_META` | ko/en |
| 오브젝트 라벨 (7종) | `src/i18n/materials.ts` | ko/en |
| 밀도 · 상전이 온도 · 점성 · 마찰 · 탄성 | `Material` 필드 | — |
| 태그 (`flammable`/`conductive`/`radiation`/`acidHydrogen`/`magnetic`/`laserReflective`/…) | `Material` 필드 69개 | — |
| 아이콘 SVG | `materialSvgFor(m)` | — |

카테고리는 **14개**(solid, powder, liquid, gas, fire, smelt, oil, **polymer**, explosive, cooling, electric, life, radioactive, exotic)이고, 팔레트 UI가 오브젝트 탭 하나를 더 붙여 화면상 15탭이 된다.

위험: 소개문서 정정 필요
Cloudwiki 소개문서가 카테고리를 **13개**로 적고 있는데 **오류다** — 🧬 **고분자(polymer)** 가 통째로 빠져 있다. 정작 같은 문서가 본문에서 고분자 라인(에틸렌·촉매·폴리에틸렌)을 설명하고 있다. 도감 페이지가 소개문서 문구를 재사용할 계획이라면 **이것부터 고쳐야 한다.**


### 진짜 작업량 — 126종 × 2언어의 설명문을 어디에 둘 것인가

쓸 만한 산문은 딱 둘이고 **둘 다 저장소 밖이거나 형식이 안 맞는다.**


| 자산 | 규모·커버리지 | 문체 | 언어 | 그대로 쓸 수 있나 |
|---|---|---|---|---|
| Cloudwiki [[Web sandbox 프로젝트/가이드/물질]] | 팔레트 **126종 전수**, 물질당 1~4문장 | 플레이어 대상 존댓말 | **ko 전용** | 저장소 밖. 위키 전용 마크업(`{br}`, `{color:…}`, `[[슬러그]]`) 제거 필요. **오브젝트 7종 누락** |
| `docs/MATERIALS.md` | 물질당 1~15문장 | 개발 문서 평서형 | ko 전용 | **구현·색값·튜닝 이력이 섞여 있다.** 그대로는 플레이어용 도감이 안 된다 |

즉 **진짜 작업량은 렌더링이 아니라 스키마 결정이다.**


| 안 | 위치 | 장점 | 단점 |
|---|---|---|---|
| **(가)** `Material.description` 필드 | `register({ … })` 안 | 물질 정의와 설명이 **한 파일에** — 새 물질 추가 시 빠뜨리기 어렵다 | **런타임 번들에 126종 × 2언어 산문이 통째로 실린다.** `Material`이 이미 74필드인데 하나 더 늘고, i18n을 어떻게 태울지 별도 설계가 필요 |
| **(나)** i18n 테이블 `materialDesc.ko.ts` / `.en.ts` | `src/i18n/` | 기존 i18n 규약을 그대로 승계. `materialNamesKo`와 **같은 자리, 같은 형태** | **조용히 샌다** — `Record<number, string>` 인덱스 시그니처는 빠뜨려도 타입체크가 안 잡는다(`materialNamesKo`가 정확히 그 함정) |
| **(다)** 빌드타임 데이터 파일 | `src/data/materialDesc.*.json` 등, 도감 페이지에서만 임포트 | **게임 번들에 안 실린다.** 도감 페이지만 무거워지고 게임은 무변경 | 물질 정의와 물리적으로 멀어져 동기화 부채가 가장 크다 |

성공: 권고 — **(나) + 커버리지 검사 스크립트**
i18n 테이블이 이 저장소의 확립된 관례에 가장 잘 맞는다. 다만 **(나)의 유일한 결함인 "조용한 누락"을 검사 스크립트로 막는 것이 세트 조건**이다.

이 저장소는 **정확히 같은 문제를 이미 같은 방법으로 풀고 있다** — `npm run check:material-ids`가 물질 id 중복을 정적 스캔해 빌드를 실패시키고, `test/acidmetal.ts`는 `acidHydrogen` 태그 명단을 레지스트리에서 훑어 비교하며 어긋나면 **새 명단을 출력**한다. `test/radiation.ts`는 방사능 전 물질과 생명 전 물질의 태그 커버리지를 훑는다.

→ `npm run check:material-desc`: 팔레트 126종 전부가 ko/en 설명을 갖고 있는지 훑고, 없으면 **명단을 출력하고 종료 코드 1**. `check:material-ids`·`check:hand-icons`처럼 `npm run build`의 앞단에 묶으면 Cloudflare 빌드가 자동으로 막아 준다.


주의: 영어 설명은 전량 신규 집필이고, 이것이 i18n 정책과 충돌한다
`docs/I18N.md`의 정책상 **영어가 진실의 원천**이다. 그런데 물질 설명은 사정이 정반대다 — 한국어 126종이 위키에 이미 있고 **영어는 한 줄도 없다.**

게다가 구조적으로도 비대칭이다: 영어 물질명 테이블은 **존재하지 않고** `Material.name`이 그대로 UI 라벨이 된다. 즉 이름은 en→ko 방향인데 설명은 ko→en 방향이 된다.

**결정이 필요한 지점**: 도감 설명에 한해 "ko 우선, en 미비 시 ko 폴백" 예외를 둘 것인가, 아니면 126종 영어 설명을 먼저 다 쓰고 나서 도감을 열 것인가. 후자는 착수 장벽이 매우 높다. **"① 재미"를 기준으로 하면 전자를 택하고 en을 점진적으로 채우는 쪽이 맞다** — 다만 그러면 검사 스크립트를 `ko는 필수, en은 경고` 두 등급으로 나눠야 한다.


### 아이콘 크기 함정

위험: 18 CSS px 정렬 논거는 도감에서 무효가 된다
`MATERIAL_ICON_CELLS = 9`는 **18 CSS px에서 device px에 딱 떨어지도록** 고른 값이다. 손그림 26종은 **24셀**이라 18px에서 이미 나누어떨어지지 않는다(소스 주석에 경고가 있다).

**도감에서 64px·128px 같은 큰 칩으로 키우면 그 정렬 논거가 통째로 무효다.** 흐릿함·서브픽셀 어긋남이 나올 수 있고, 어떤 배수가 깨끗한지는 **실제 렌더로 확인해야만 알 수 있다.**

[프로젝트 규칙] 브라우저 테스트는 에이전트가 직접 하지 않고 사용자에게 요청한다. 도감 페이지 작업에는 이 확인이 **필수 단계**로 들어간다.


참고로 손그림 아이콘은 현재 **26종**이다. 저장소 문서(`docs/MATERIAL-ICONS.md` §7)가 "25종"으로 적고 있는데 **오류**다 — `fan`이 목록에서 누락돼 있었다. 규격은 `scripts/icon-svg.mjs`가 강제한다: **24×24 타일 · rect ≤ 100개 · 색 ≤ 5가지 · 기본색 ≥ 60% · 아이콘당 4 KB 상한**. 주석·`id`·`class`·`style`·`opacity`·`stroke`·`fill="none"` 전면 금지.

### 팔레트 비노출 12종을 넣을 것인가

시스템 파티클 12종(Spark, Ember, Blast를 제외한 Debris, Nuclear Ray, Heat Ray, Flash, Bomblet, Firework Star, Firework Burst, Bubble, Napalm Gel, Empty 등)은 팔레트에 노출되지 않는다. 즐겨찾기·최근 검증도 큐레이트 명단(`PALETTE_IDS`)을 화이트리스트로 쓴다.


| 선택 | 근거 | 리스크 |
|---|---|---|
| **넣지 않는다** | Cloudwiki 물질 도감이 이미 이 방침(단 `Blast`는 팔레트 물질이라 포함). `docs/MATERIALS.md` 서두가 제외 사유를 이미 설명해 둠 | 돋보기로 `Nuclear Ray`를 봤는데 도감에 없는 상황이 생긴다 |
| **넣되 별도 섹션** | "직접 만들 수는 없지만 세계에 존재하는 것" 섹션. **① 재미 기준에서 발견의 즐거움이 있다** | 자동 생성이므로 비용은 거의 0. 다만 설명 산문 12종이 더 필요 |
| **전부 균등하게 넣는다** | 가장 단순 | 플레이어가 팔레트에서 찾다가 못 찾는다 → **② 편의성 위반** |

> **권고**: **넣되 별도 섹션으로 분리하고, 팔레트에서 못 고른다는 것을 배지로 명시한다.** 자동 생성 파이프라인에서는 12종을 빼는 것이 오히려 예외 처리 비용이고, "돋보기에 보이는데 도감에 없는" 구멍이 사라지는 이득이 크다.

### 도감 페이지 기능 제안 모음


제안 C-1 — 반응 사슬을 그래프로
"이 물질은 무엇과 만나면 무엇이 되는가"를 물질 페이지마다 표시.

> **근거** — (a) 반응 사슬은 `docs/MATERIALS.md`·`MATERIAL-SYSTEMS.md`에만 있고 **게임 안에서는 직접 해 보는 것 외에 알 방법이 없다.** ① 재미의 핵심이 여기 있는데 발견 경로가 없다. (b) **부분적으로만 자동 생성된다** — 선언형 반응 테이블(`ReactionRule`)을 쓰는 물질은 **138종 중 6종뿐**이고 나머지 상호작용은 전부 각 물질의 `update` 하드코딩이다. 즉 6종은 코드에서 뽑히고 나머지는 **손으로 데이터화**해야 한다. (c) **비용이 크고 부채도 크다.** 손으로 적은 사슬은 코드가 바뀌어도 안 따라온다. → **1차는 "관련 물질" 수동 링크 목록** 정도로 시작하고, 자동 추출은 반응 테이블 채택률이 올라간 뒤로 미루는 것을 권한다.

제안 C-2 — 태그 기반 필터·검색
"가연성" "도체" "방사성" "산에 녹음" 같은 태그로 물질을 거른다.

> **근거** — (a) 현재 팔레트 검색은 `표시명.includes(q) || 카테고리라벨.includes(q)`가 전부다. **한 번에 한 언어만** 매칭하고(한국어 UI에서 `sand`는 0건, 영어 UI에서 `모래`도 0건) 화학식·별칭·태그 검색이 전혀 없다. 게다가 **오브젝트 7종은 검색 대상 자체가 아니다.** (b) 태그는 `Material` 필드 69개에 이미 다 있다 — **빌드타임에 전수 인덱싱하면 런타임 비용 0**이다. (c) 비용 낮음. 도감에서 검증한 필터를 나중에 **인게임 팔레트로 역수입**할 수 있다는 것이 부수 이득.

제안 C-3 — 온도 사다리 시각화
물질별 상전이 온도를 하나의 세로 축에 얹어 "몇 도에서 무슨 일이 일어나는가"를 한눈에.

> **근거** — (a) 돋보기는 **현재 온도만** 보여 주고 임계값을 알려 주지 않는다. 플레이어가 "얼마나 더 데워야 하는가"를 알 수 없다. (b) 임계 온도는 전부 `Material` 필드와 물질별 상수에 있고, 코드에서 뽑은 사다리(2°부터 2800°까지)가 이미 조사돼 있다. (c) 비용 중간 — **자동 추출이 물질별 하드코딩 상수까지는 못 닿는다.** `Material` 필드로 표현된 것만 우선 그리고, 나머지는 설명 산문에 맡기는 단계적 접근을 권한다.

제안 C-4 — 인게임 돋보기에서 도감으로 점프
`InspectPanel`의 물질 행을 누르면 해당 도감 항목이 열린다.

> **근거** — (a) 게임과 도감이 **완전히 분리된 두 페이지**가 되면 서로를 못 부른다. (b) `InspectPanel`이 이미 물질 id를 갖고 행을 그리고 있고, `pagehide` 자동 저장 덕에 **페이지 이동이 월드를 잃지 않는다.** (c) **`InspectPanel`은 `pointer-events: none`이다** — 클릭 가능하게 만들면 캔버스 조작을 가릴 수 있다. 새 탭으로 열거나, 돋보기 카드에 작은 링크 버튼 하나만 `pointer-events: auto`로 예외를 두는 설계가 필요하다.

제안 C-5 — 물질별 개별 URL (`/codex/<slug>`)
`getStaticPaths`로 126장(+오브젝트 7장)을 프리렌더.

> **근거** — (a) 지금은 **물질 하나를 가리키는 URL이 존재하지 않는다.** "친구에게 우라늄 설명 보내기"가 불가능하다. (b) `getStaticPaths`가 static output에서 정상 동작하고, 아이콘·수치가 빌드타임 생성이라 페이지당 추가 비용이 낮다. (c) **HTML 133장이 늘어난다** — 배포 크기와 SW 프리캐시 목록에 영향. 프리캐시는 목록 페이지 1장만 하고 개별 페이지는 방문 시 캐시되게 두는 절충을 권한다. 슬러그는 **id가 아니라 영문명 kebab-case**를 권한다(`iconKey(m.name)`가 이미 같은 규칙을 쓴다).

제안 C-6 — 도감을 인게임 모달로도 재사용
같은 데이터로 인게임 "물질 정보" 팝오버를 만든다.

> **근거** — (a) 현재 인게임 설명 표면은 네이티브 `title` 속성 89개뿐이고 **터치에서는 아예 뜨지 않는다.** 즉 모바일 사용자에게는 89개 설명이 존재하지 않는 것과 같다. 게다가 커스텀 툴팁 컴포넌트가 없다. (b) 도감 데이터가 코드에서 자동 생성되면 **같은 함수를 게임 번들에서도 호출할 수 있다.** `Modal.svelte`에 포커스 트랩·모달 스택이 이미 있다. (c) **번들 증가가 직격이다** — 126종 × 2언어 산문이 게임 번들에 들어간다. → **설명 스키마 3안 중 (다) 빌드타임 데이터 파일을 골랐다면 이 제안은 성립하지 않는다.** 스키마 결정과 이 제안은 **묶여 있다.**



---

## Cloudwiki 문법 렌더러를 게임에서 쓸 것인가

이 절이 이 문서의 핵심 결정이다. 도감 페이지는 "표·카드·탭·콜아웃이 잔뜩 있는 문서형 페이지"인데, **Cloudwiki에는 그것을 그리는 렌더러가 이미 있고 외부 이식용으로 분리돼 있다.**

### 실측한 것


코어는 실제로 분리돼 있다
npm 패키지 **`@eoeoe2/wiki-shared`** 의 서브패스 export **`./render/render`**.

실체는 `packages/wiki-shared/src/render/render.ts` **약 7,200줄** + `render.css` **약 2,960줄**.

백엔드 결합은 거의 없다
파일 전체에서 `fetch` 호출이 **3곳뿐**이고 전부 `{{틀:…}}` 트랜스클루전과 카테고리 목록용이다.

게다가 주입점이 **이미 열려 있다**:
`window.configureWikiRender({ templateApiBase, categoryApiBase, wikiLinkBase, imageDocLinkBase, disableExtensions })`

→ **틀과 카테고리 목록을 안 쓰면 백엔드 요청 0으로 동작한다.**




| 의존 | 필요 시점 | 비고 |
|---|---|---|
| `marked` | 항상 | 마크다운 파서 |
| `DOMPurify` | 항상 | 소독 |
| `Prism` | 항상 | 코드 하이라이트 |
| `mermaid` | 해당 블록이 있을 때 | 지연 로드 |
| `Chart.js` | 해당 블록이 있을 때 | 지연 로드 |
| `bootstrap` | 탭/아코디언 | **전부 `window.bootstrap && …` 가드가 있다** |
| `Swal` | 일부 상호작용 | |
| `appConfig` 전역 | 항상 | 셰임 필요 |

주의: CSS 비용이 예상보다 크다
`render.css`가 참조하는 **CSS 커스텀 프로퍼티가 94개**이고, 그중 **77개가 위키 본체의 `style.css`(약 3,700줄)에 정의돼 있다.**

즉 **CSS는 파일 하나를 복사하는 것으로 끝나지 않고 토큰 정의까지 옮겨야 한다.** 반대로 좋은 소식도 있다 — `var(--bs-…)` 참조는 **0건**이라 Bootstrap 변수에는 묶여 있지 않다.


팁: 덤 — 익스텐션 SDK
`window.defineExtension(manifest, renderer)`가 있어 `{{name:인자}}` 문법으로 커스텀 렌더러를 꽂을 수 있다.

**아이디어**: "물질 카드"를 위키 문법 한 줄로 부르는 확장.

```
{{material:sand}}
{{material:uranium|compact}}
```

이게 흥미로운 이유는 **양방향**이기 때문이다.

- **게임 → 위키 방향**: 확장을 Cloudwiki 쪽에 설치하면, 위키의 물질 가이드 문서가 **저장소에서 생성한 물질 카드 데이터를 문법 한 줄로 부를 수 있다.** 지금 위키 도감은 126행을 손으로 관리하고 있어 물질을 추가할 때마다 조용히 새는 항목(`add-material-or-object` 스킬이 A-10으로 세고 있다)인데, 이걸 데이터 기반으로 바꾸면 **동기화 부채 하나가 통째로 사라진다.**
- **위키 → 게임 방향**: 게임의 도감 페이지가 렌더러를 싣는다면 같은 확장으로 같은 카드를 그린다.

**두 방향은 독립이다.** 게임이 렌더러를 안 써도 위키 쪽 확장은 성립하고, 오히려 이쪽이 비용 대비 이득이 크다.


### 활용안 — 게임이 Cloudwiki 렌더러를 싣는다


장점
- **문법 가이드의 대부분을 그대로 사용**: 카드 · 그리드 · 탭 · 아코디언 · 콜아웃 6종 · 표 병합 · 표 옵션(정렬·고정헤더) · 차트 · Mermaid · 각주 · 펼치기 · `{stat:}` · `{badge:}` · `{kbd:}` · 체크리스트
- **작성 생산성**: 물질 설명을 마크다운으로 쓰면 그대로 렌더된다. 표·카드 스타일을 처음부터 짤 필요가 없다
- **위키와 문체·구조가 일관**: 같은 프로젝트의 두 문서 표면이 같은 룩을 갖는다
- 익스텐션 SDK로 물질 카드 문법 확장 가능
- **틀/카테고리를 안 쓰면 네트워크 요청 0** — `configureWikiRender`로 주입점이 이미 열려 있다

단점
- **현 Cloudwiki 문서와 내용이 많이 겹친다** — 도감 페이지와 위키 물질 가이드가 같은 것을 두 벌 관리하게 된다
- **번들에 약 10,000줄 + CDN 전역 4종**(marked·DOMPurify·Prism·bootstrap)이 추가된다
- **이 프로젝트의 성격과 정면충돌**: 지금 외부 네트워크 요청은 아이콘 CSS **하나**뿐이고, 런타임에 로드되는 이미지 파일이 **0개**이며, 오프라인 PWA로 완결돼 있다. 전역 4종을 CDN에서 받으면 그 성질이 사라진다
- **CSP·오프라인 캐시 부담**: SW 프리캐시 목록에 cross-origin 스크립트가 들어가야 하고 opaque 응답 문제가 따라온다
- **CSS 토큰 77개 이식** — `style.css` 3,700줄에서 골라 옮겨야 한다
- **유지보수가 상류 위키 변경에 묶인다** — `@eoeoe2/wiki-shared`가 바뀌면 게임이 따라가야 한다



### 미활용안 — 자체 페이지로 짠다


장점
- **Cloudwiki 틀에 얽매이지 않는 자유로운 페이지 구성.** 도감은 위키 문서가 아니라 **게임 UI**다 — 카테고리 탭·필터·검색·아이콘 그리드 같은 것은 위키 문법으로 표현하는 게 오히려 어색하다
- **번들 최소.** 도감 본문(수치·태그·아이콘)이 코드 자동 생성이라 애초에 산문 마크업이 적게 필요하다
- **오프라인 완결.** 현재 성격 그대로 유지
- `global.css`의 디자인 언어(`#101014` 배경, 다크 단일 테마)와 자연스럽게 이어진다

단점
- 페이지 작성 생산성이 약간 저하
- 표·카드 스타일을 처음부터 짜야 한다
- 콜아웃·아코디언 같은 것을 쓰고 싶어지면 직접 만들어야 한다



### 절충안 둘


절충 A — 미니 렌더러
**도감 본문(수치·태그·아이콘)은 코드 자동 생성**으로 하고, 물질별 **설명 산문만** 아주 작은 마크다운 부분집합으로 처리하는 자체 미니 렌더러를 만든다.

지원 범위를 **굵게 · 인라인 코드 · 링크(`[[물질슬러그]]` 포함) · 줄바꿈** 정도로 제한한다.

- 구현 규모: 수십 줄. 정규식 몇 개.
- **빌드타임에 돌려 정적 HTML로 굽는다** → 런타임 JS 0.
- 소독 문제 없음 — 입력이 **저장소에 체크인된 자기 산문**이라 `materialSvg.ts`가 `{@html}` 안전성을 주장하는 것과 **정확히 같은 논거**가 성립한다.
- `[[물질슬러그]]` 링크가 도감 내부 상호참조가 되어 **반응 사슬 탐색이 공짜로 생긴다**(제안 C-1의 값싼 1차 버전).

절충 B — 빌드타임 베이크
빌드타임에만 Cloudwiki 렌더러를 돌려 **정적 HTML을 굽고 런타임에는 아무것도 싣지 않는다.**

매력적으로 들리지만, 검토하면 이득이 생각보다 작다.

| 항목 | 없어지나 |
|---|---|
| 렌더러 JS ~7,200줄 | **없어진다** |
| `render.css` ~2,960줄 | **안 없어진다** — 구운 HTML을 꾸미려면 런타임에 필요 |
| CSS 토큰 77개 이식 | **안 없어진다** |
| CDN 전역 4종 | marked/DOMPurify/Prism은 빌드타임으로 이동 — **없어진다** |
| bootstrap (탭·아코디언 동작) | **안 없어진다** — 구운 HTML의 탭이 실제로 전환되려면 런타임 JS가 필요 |
| mermaid / Chart.js | 도감에 쓸 일이 거의 없다 |
| 상류 변경 추종 부담 | **안 없어진다** |
| 새 개발 의존 | **생긴다** — `render.ts`는 브라우저 타깃이라 Node에서 돌리려면 DOM 셰임(jsdom/linkedom 류)이 필요하다 |

게다가 이 저장소의 테스트 하네스는 **`esbuild`가 `package.json`에 선언조차 안 된 채 전이 의존으로만 존재하는** 상태다. 여기에 DOM 셰임을 얹으면 빌드 파이프라인의 취약점이 하나 더 는다.



### 최종 권고

성공: 권고 — **미활용 + 절충 A(미니 렌더러)**. 위키 렌더러는 게임 번들에 싣지 않는다.
**근거를 프로젝트 우선순위 순으로 정리한다.**

**① 재미** — 도감이 재미있으려면 필요한 것은 "위키 문법의 표현력"이 아니라 **아이콘 · 수치 · 필터 · 반응 사슬**이다. 이 넷은 전부 **코드에서 자동 생성**되고, 위키 렌더러는 그 어느 것도 도와주지 않는다. 오히려 위키 문법으로 표현하기 어색한 쪽이다.

**② 편의성** — 이 프로젝트의 가장 큰 편의성 자산은 **오프라인으로 완결되는 PWA**라는 성질이다. 지금 외부 네트워크 요청은 아이콘 CSS 하나뿐이고 런타임 로드 이미지가 0개다. 여기에 CDN 전역 4종을 얹으면 **첫 방문·오프라인 방문 품질이 직접 나빠진다.** 얻는 것(문서 작성 생산성)이 잃는 것(앱의 성질)보다 작다.

**③ 고증** — 무관.

**빌드타임 베이크(절충 B)를 기각하는 이유**: `render.css` + 토큰 77개 + bootstrap 런타임이 **그대로 남고**, DOM 셰임이라는 새 개발 의존이 생기며, 상류 추종 부담도 안 사라진다. 정작 도감이 필요로 하는 마크업은 **굵게·링크·줄바꿈 수준**이라 7,200줄 렌더러의 표현력 중 실제 사용률이 극히 낮다. 비용 대비 이득이 절충 A에 명백히 진다.

**언제 이 판정을 뒤집는가**: 게임 안에 **장문 문서를 여러 장** 싣기로 방향이 바뀌면(인게임 위키·시나리오 브리핑·업데이트 노트 같은) 그때는 절충 B가 유력해진다. 그 시점이 오면 이 절을 다시 연다.

**대신 반드시 하라**: 익스텐션 SDK를 이용한 **`{{material:…}}` 확장을 Cloudwiki 쪽에** 만든다. 게임 번들에 아무것도 안 실으면서, 위키 물질 가이드 126행의 손 관리 부채를 데이터 기반으로 바꾼다. **비용은 게임 쪽 0이고 이득은 문서 동기화 부채 하나 제거**다 — 위 판정과 전혀 충돌하지 않는 순이득이다.


---

## 함정 체크리스트

작업에 들어가기 전에 이 목록을 지나갈 것.

**페이지 추가 공통**

- [ ] `global.css`의 `html, body { overflow: hidden }`을 스크롤 페이지에서 페이지 스코프로 덮어쓴다
- [ ] 오프라인에서 새 페이지를 열면 **조용히 게임 화면이 뜬다** — SW 프리캐시 목록 + 내비 폴백 분기 수정
- [ ] SW 프리캐시/폴백을 고쳤으면 **`SW_VERSION`을 올린다**(캐시 구조 변경에 해당). 올리면 옛 캐시가 전부 삭제된다
- [ ] `sw.js` 경로는 `public/`에서 해시 없이 `/sw.js` 고정 — **절대 옮기지 않는다**
- [ ] 매니페스트 `start_url`/`scope`/`id`가 전부 `/`다. `/`의 의미를 바꾸면 **설치된 PWA의 진입점이 바뀐다**
- [ ] 매니페스트에 `orientation`을 **절대 추가하지 않는다**(안드로이드 회전 고정 버그로 의도적 생략)
- [ ] `Base.astro`는 `title` prop 하나뿐 — description·OG·Twitter 카드 전무
- [ ] jsdelivr Bootstrap Icons가 **유일한 외부 요청**이다. 새 페이지가 `bi-*`를 쓰면 첫 오프라인 방문에서 아이콘이 빈다
- [ ] `404.astro`의 "양 언어 렌더 + 인라인 스크립트" 패턴을 반복 구현하지 말고 추출한다
- [ ] **`notFound.*` i18n 키 4개가 죽어 있다**(404가 하드코딩). 같은 이원화를 늘리지 않는다
- [ ] `public/`에 `_headers`/`_redirects`가 **없다** — 페이지별 캐시 헤더 커스터마이즈 수단이 현재 0

**시작 화면**

- [ ] `applyWorld`는 시작 화면에서 **못 쓴다**(살아 있는 `Grid` 필요). id 핸드오프 설계가 선행
- [ ] `Game.ts`가 읽는 URL 파라미터는 `?perf`·`?bench=`·`?fullscan` **셋뿐** — `?load=` 진입점은 신설이다
- [ ] `$gridDims` 기본값 **360×203**이 부팅 전 배치 미리보기를 거짓말로 만든다
- [ ] `snapshots.ts` 임포트 = `persistence.ts` = **물질 배럴 부수효과로 엔진 + 138종 통째 임포트**
- [ ] localStorage 직접 파싱으로 우회한다면 **스키마 이중화를 검사 스크립트로 방어**한다
- [ ] "이어할 월드가 **비어 있는지**"는 봉투에 파티클 수 필드가 없어 **역직렬화 없이는 알 수 없다**
- [ ] 자동 저장에는 **썸네일이 없다**(명명 스냅샷 전용)
- [ ] **오브젝트 레이어는 월드에도 스냅샷에도 저장되지 않는다.** `applyWorld`는 오히려 자유 오브젝트를 먼저 지운다
- [ ] `$running`도 설정에 저장된다 — **시작 화면 뒤에서 게임이 이미 돌고 있을 수 있다**
- [ ] "새로 시작"을 `requestClear()`로 하면 **한 프레임 옛 월드가 보인다**
- [ ] 핸드오프 키는 소비 즉시 삭제 — 안 그러면 다음 새로고침에서 또 불러와진다

**도감 페이지**

- [ ] `Material` 74필드에 **`description`이 없다.** 설명 산문의 스키마 결정이 최우선 작업이다
- [ ] **영어 설명은 전량 신규 집필**이고, "영어가 진실의 원천"이라는 i18n 정책과 충돌한다
- [ ] `Record<number, string>` 인덱스 시그니처는 **빠뜨려도 타입체크가 안 잡는다** → 커버리지 검사 스크립트를 세트로
- [ ] 위키 물질 가이드는 **한국어 전용**이고 **오브젝트 7종이 빠져 있다**. 위키 마크업(`{br}`·`{color:…}`·`[[슬러그]]`) 제거 필요
- [ ] `docs/MATERIALS.md`는 **구현·색값·튜닝 이력이 섞여** 있어 그대로는 플레이어용 도감이 안 된다
- [ ] 아이콘 `N = 9`는 **18 CSS px 정렬 전제**, 손그림은 24셀. **64·128px 확대 시 정렬 논거 무효** → 실제 렌더 확인 필요
- [ ] `InspectPanel`의 11×11이 **의도적으로 단색**인 이유가 있다 — 도감 목록 행을 작게 만들면 같은 함정에 빠진다
- [ ] 아이콘 규격: 24×24 타일 · rect ≤ 100 · 색 ≤ 5 · **아이콘당 4 KB 상한**. `iconKey(m.name)` 불일치는 **무신호 실패**다
- [ ] 손그림은 **26종**이다 — `docs/MATERIAL-ICONS.md`의 "25종"은 오류(`fan` 누락)
- [ ] 카테고리는 **14개**(+오브젝트 탭 = 화면상 15탭) — **소개문서의 "13개"는 오류**(고분자 누락). 인용 전에 고칠 것
- [ ] 선언형 반응 테이블을 쓰는 물질은 **138종 중 6종뿐** — 반응 사슬 자동 추출의 커버리지가 그만큼이다
- [ ] 팔레트 비노출 **12종**을 넣을지 말지가 설계 결정 포인트
- [ ] `InspectPanel`은 `pointer-events: none`이다 — 도감 링크를 붙이려면 예외 처리가 필요

**공통 규율**

- [ ] **브라우저 테스트는 에이전트가 직접 하지 않고 사용자에게 요청한다**(CLAUDE.md 워크플로우)
- [ ] 새 분야 문서를 만들면 `docs/README.md` 인덱스에 **한두 줄 요약으로** 링크를 추가한다
- [ ] Cloudwiki 소개문서는 **소개문서로 유지**한다 — 이 구상의 세부는 여기(구상 문서)와 `docs/`에 둔다

---

## 관련 문서

- [[Web sandbox 프로젝트/구상|구상 인덱스 (상위)]]
- [[Web sandbox 프로젝트/구상/새 물질 후보]]
- [[Web sandbox 프로젝트/구상/새 화학반응 공정 후보]]
- [[Web sandbox 프로젝트/구상/엔진 개선안]]
- [[Web sandbox 프로젝트/구상/엔진 최적화 방안]]
- [[Web sandbox 프로젝트/구상/UI UX 개선안]]
- [[Web sandbox 프로젝트|소개문서]]
- [[Web sandbox 프로젝트/가이드/물질|물질 도감 가이드]]