Web sandbox 프로젝트/구상/UI UX 개선안
주의: 이 문서는 구상 문서다
여기 적힌 제안은 **아직 하나도 구현되지 않았다.** 이미 구현된 기능의 기록은 저장소 `docs/FEATURES.md`에 있다. 이 문서에서 "지금 …이다" 형태의 서술은 조사 시점 코드에서 확인한 현재 상태이고, **"제안"** 표시가 붙은 항목만이 구상이다. 두 가지를 섞어 읽지 말 것.
또한 이 문서의 UX 판단은 **정적 코드 조사에 근거한다.** 실기기(안드로이드/iOS 브라우저, 터치 태블릿) 실사용 검증은 아직 없으므로, 착수 전 실기 확인이 선행돼야 하는 항목에는 별도로 표시했다.
## 개요
이 프로젝트의 UI는 **단일 Svelte 아일랜드**로 되어 있다. `src/pages/index.astro`가 렌더하는 것은 `<canvas id="game">` 하나와 `<ControlPanel client:load />` 하나가 전부이며, 나머지 8개 컴포넌트(`MaterialPalette`, `MaterialPicker`, `InspectPanel`, `Modal`, `SaveSlots`, `SnapshotLoad`, `BlendBrush`, `HeatCoolSettings`)는 전부 그 자식이다. 구조가 단순한 만큼 **손댈 지점이 적고 회귀 범위가 좁다** — 이 문서의 제안 대부분이 값싼 이유다.
우선순위는 프로젝트 규약대로 **① 재미 ② 편의성 ③ 과학적 고증**이다. UI/UX는 ②가 본진이지만, 되돌리기 부재나 물질 정보 부재처럼 **"과감하게 실험해 볼 마음을 꺾는" 항목은 ①을 직접 깎는다.** 그런 항목을 이 문서에서는 우선순위 최상위로 올린다.
규모
138 (등록 물질)
126 (팔레트 노출)
14+1 (카테고리 탭(+오브젝트))
부재
0 (게임플레이 단축키)
0 (되돌리기·줌·소리)
1 (도움말 문구(`hint.draw`))
---
## 현재 화면 구성
### 데스크톱 (>768px)
좌측에 **216px 고정 사이드바**(`--sidebar-w`, `src/styles/global.css`)가 `position:fixed`로 서고, 캔버스가 `left: var(--sidebar-w); width: calc(100vw - 216px)`로 그 옆을 채운다. 사이드바 세로 순서는 다음과 같다.
| 블록 | 내용 |
|---|---|
| `.head` | 아이콘 + "Particle Sandbox" + 헤더 3버튼(일반색 / 열지도 / 설정) |
| `.bar-row.primary` | 도구·재생 버튼 **15개**(playback 4 · mode 2 · special 8 · observe 1) |
| `.bar-row.palette-row` | `MaterialPalette` — 검색창 · 빠른접근 9칸 · 카테고리 15탭 |
| `.inline-settings` | 자주 쓰는 설정(브러시 크기·모양·채우기·덮어쓰기·배속·중력·연기) |
캔버스 위에 겹치는 것은 돋보기 카드(`InspectPanel`, z=8, `pointer-events:none`), 브러시 커서 아웃라인과 영역 마키(z=9)뿐이다.
### 모바일 (≤768px)
사이드바가 **하단 2줄 바**로 전환된다. 1행은 도구 아이콘 가로스크롤, 2행은 검색창 + 빠른접근 + 카테고리 가로스크롤이다. 이때 일어나는 일이 문제의 대부분을 만든다.
- `.head` **완전 숨김** → 데스크톱의 일반색/열지도 원터치 토글이 **모바일엔 없다.**
- `.bar .label{display:none}` → 아이콘 전용. 예외는 물질명과 "계속하시겠습니까?" 확인 문구 2개뿐.
- `.inline-settings{display:none}` → **자주 쓰는 설정 전부가 설정 모달 뒤로.**
- 빠른접근 9칸 중 **앞 3칸만 표시**(나머지 6칸은 DOM에 존재하되 `display:none`).
- 카테고리 라벨과 개수 숨김 → **14개 카테고리를 아이콘만으로 구분.**
### 반응형 분기점 — 브레이크포인트가 하나뿐이다
위험: 미디어 쿼리가 프로젝트 전체에 5개다
`ControlPanel.svelte` · `MaterialPalette.svelte` · `InspectPanel.svelte` · `global.css`의 `max-width:768px` 네 개와, `SaveSlots.svelte`의 `(hover: none)` 하나. **`pointer:coarse` 분기는 없고, JS 쪽 터치 감지(`maxTouchPoints`/`ontouchstart`/UA)도 전무하다.**
결과: **768px를 넘는 터치 태블릿은 전부 데스크톱 취급**을 받아, 호버 전제 UI(팔레트 플라이아웃 호버 오픈, 브러시 커서 아웃라인, 휠 브러시 크기, 우클릭 지우개, `title` 툴팁 89개)를 **하나도 쓸 수 없는 상태로** 데스크톱 레이아웃을 받는다. `docs/FEATURES.md`가 이 문제를 인지하고 있으나 대응은 "아래 데드존 슬라이더"라는 수동 우회뿐이다.
---
## 데스크톱 개선안
### 사이드바 접기 [최우선]
**현재**: `.panel`은 `width: var(--sidebar-w)` 고정 216px이고, 접기/펼치기 상태나 토글이 코드에 **없다**(`collapse`/`sidebarOpen` 관련 코드 0건).
이게 단순한 화면 낭비가 아닌 이유는, **그리드 해상도가 캔버스 크기에서 유도되기 때문**이다(`src/game/layout.ts`). 1280px 폭에서 216px는 **가로의 약 17%**이고, 그 17%는 곧 **월드가 그만큼 좁다**는 뜻이다. UI를 접는 순간 놀이터가 넓어진다 — 편의성 제안인 동시에 재미 제안이다.
제안 근거
**(a) 비어 있는 것** — 접기 상태·토글·전환 애니메이션 전부 없음. 사이드바 폭 조절 수단도 없음.
**(b) 재사용 인프라** — 폭이 이미 CSS 변수 `--sidebar-w` 하나로 캔버스·InspectPanel까지 전파된다(`global.css`, `InspectPanel.svelte` 3곳이 이 변수를 읽는다). 변수 값만 0(또는 44px 아이콘 레일)으로 바꾸면 레이아웃이 따라온다. 리사이즈 시 그리드 재구성 경로도 이미 `resize` 핸들러(`Game.ts`)로 존재한다.
**(c) 비용·리스크** — 낮음. 리스크는 접기/펼치기 때마다 **그리드가 리사이즈되어 내용이 리맵**되는 것. 접힌 폭을 0이 아니라 아이콘 레일 44px로 두고, 접기 상태를 `localStorage`에 영속화해 **전환 빈도 자체를 낮추는** 설계를 권한다.
**세부 제안**: ① 접힘 상태 3단(전체 / 아이콘 레일 / 숨김), ② Tab 아닌 전용 토글 버튼을 `.head`에, ③ 접힌 상태에서 팔레트는 캔버스 위 플로팅 플라이아웃으로, ④ 사이드바 경계 드래그로 폭 자유 조절(216~320px) — 물질명이 긴 한국어 라벨에서 특히 유용하다.
### 상시 HUD [최우선]
**현재**: 입자 수·FPS·해상도를 보여 주는 `.hud` 블록은 **설정 모달 내부에만** 있다. 플레이 중 성능 저하나 입자 폭증을 볼 방법이 없다. 데스크톱 사이드바는 세로 여유가 남는데도 HUD가 없다.
제안 근거
**(a)** 상시 표시 HUD 0건. 프레임 시간·패스별 비용은 URL 쿼리(`?perf`)로만 볼 수 있어 일반 사용자에겐 존재하지 않는다.
**(b)** `$fps`·`$fpsPeak`·`$particleCount`·`$frameMs`·`$gridDims`·`$perfPasses` 아톰이 **이미 전부 존재하고 매 프레임 갱신된다**(`src/state/store.ts`). 값을 구하는 코드가 아니라 **표시할 자리만** 없다.
**(c)** 매우 낮음. 리스크는 캔버스를 가리는 것 → 사이드바 하단(데스크톱) / 캔버스 우상단 반투명 배지(모바일), 3단 토글(끔 / 간단 = FPS+입자수 / 상세)로 회피.
### 게임플레이 키보드 단축키 [최우선]
**현재**: 전 코드베이스의 키 처리는 8개 문맥뿐이고, **그중 게임플레이 단축키는 0개다.** Enter/Escape는 영역 선택 모드 전용, 나머지는 모달·팔레트·저장 슬롯 내부 처리다. 즉 **캔버스에 포커스가 있을 때 눌러서 뭔가 되는 키가 사실상 없다.**
없는 것을 확인한 목록: Space 일시정지 · 숫자키 도구/물질 · [ ] 브러시 크기 · Ctrl+Z · Ctrl+S · 도구 문자키(E/B/H/C) · 그리드/열지도 토글.
| 키 | 동작 | 근거 |
|---|---|---|
| Space | 재생/일시정지 | 샌드박스 장르 관용. `$running` 아톰 그대로 |
| . | 한 프레임 스텝 | `requestStep()` 이미 존재 |
| [ ] | 브러시 크기 ∓1 | 휠 핸들러와 동일 경로(`BRUSH_MIN`=0 · `BRUSH_MAX`=12) |
| 1~9 | 빠른접근 9칸 선택 | **9칸이 이미 있는데 모바일에선 3칸만 보인다** — 데스크톱에서 숫자키를 주면 9칸이 살아난다 |
| E B H C M V | 지우개·재료·가열·냉각·섞기·보기 | `$tool` 유니온 10종에 그대로 대응 |
| Ctrl+Z / Ctrl+Shift+Z | 되돌리기 / 다시하기 | 아래 별도 절 |
| Ctrl+S | 스냅샷 저장 | `SaveSlots` 모달 열기로 대체 가능 |
| X | 스포이드(호버 셀 물질 선택) | 아래 별도 절 |
| ? | 단축키 치트시트 모달 | 온보딩 부재의 최소 대안 |
제안 근거
**(a)** 게임플레이 단축키 0개. 데스크톱 파워유저 동선이 전부 마우스 왕복이다.
**(b)** `window`의 `keydown` 리스너가 `PointerPainter.ts`에 **이미 등록돼 있고**(영역 모드 Enter/Escape용) 입력 요소 포커스 여부를 가리는 가드도 그 안에 있다. 그 가드를 재사용해 분기를 늘리는 작업이다. 도구·브러시 크기·재생 전부 nanostores 아톰이라 `.set()` 한 줄씩이다.
**(c)** 낮음. 리스크는 ① 모달/검색창 포커스 시 오발화 → 기존 가드 확장, ② 브라우저 기본 동작 충돌(Ctrl+S) → `preventDefault`, ③ **키 목록을 어디서도 알려주지 않으면 없는 것과 같다** → ? 치트시트 및 툴팁에 단축키 병기가 세트로 필요하다.
### 일시정지 상태의 캔버스 표시 [최우선]
**현재**: `$running=false`일 때 렌더러에 아무 표시가 없다(`CanvasRenderer.ts`에 `paused`/`running` 참조 0건). 유일한 단서는 재생 버튼 아이콘 하나이고, **모바일에선 라벨도 숨겨져 아이콘 하나**다. "정지된 모래시계 장면"과 "일시정지된 장면"이 구분되지 않는다.
**제안**: 캔버스 테두리 2px 강조 + 우상단 일시정지 배지 + (선택) 매우 옅은 스캔라인. **근거** — (a) 표시 0건 (b) 렌더 루프에 이미 `$running`이 도달해 있어 오버레이 한 겹 추가 (c) 비용 최소, 리스크는 스크린샷/스냅샷 썸네일에 배지가 찍히는 것 → 썸네일 캡처 경로(`snapshots.ts`)에서만 제외.
### 설정 모달 → 하위 모달 복귀 경로
**현재**: `openHeatCoolFromSettings` / `openBlendFromSettings`가 `settingsOpen=false` → `await tick()` → `heatCoolOpen=true` 순으로 동작한다. 포커스 경쟁을 피하려는 의도적 설계지만, 사용자에겐 **설정이 사라졌다가 다른 창이 뜨고, 그 창을 닫으면 설정으로 돌아가지 않는다.** 감도를 조절한 뒤 다른 설정을 만지려면 설정 버튼을 다시 눌러야 한다.
**제안**: 하위 모달을 닫을 때 **부모 모달로 복귀**시킨다. **근거** — (a) 복귀 경로 0건 (b) `Modal.svelte`에 **이미 모듈 레벨 `openModals[]` 스택이 있다**(Escape가 최상위 1장만 닫도록). 이 스택에 "부모" 참조를 얹으면 되고, 설계 의도가 이미 스택형이다 (c) 낮음. 리스크는 포커스 복귀 경쟁 — `Modal`이 열 때 진입/닫을 때 복귀 로직을 이미 갖고 있으므로 그 위에서 다룬다.
### 팔레트 플라이아웃 키보드 내비게이션 — ARIA 거짓말 해소
위험: 자매 컴포넌트끼리 어긋나 있다
`MaterialPalette.svelte`의 플라이아웃은 `role="menu"`를, 칩은 `role="menuitem"`을 **선언한다.** 그런데 **방향키 핸들러가 없다** — 이 컴포넌트의 키 처리는 Escape 둘(플라이아웃 닫기 / 검색어 지우기)뿐이다. 보조기술에 "메뉴다"라고 약속해 놓고 메뉴 키보드 규약을 지키지 않는 것이다.
같은 프로젝트의 `MaterialPicker.svelte`는 **정확히 같은 UI에 로빙 포커스(←→↑↓HomeEnd)를 이미 구현해 두었다.** 즉 이건 "새로 만들어야 하는 기능"이 아니라 **두 구현이 갈라진 것**이다.
게다가 플라이아웃은 `<body>`로 portal되므로 DOM 순서상 문서 맨 끝에 붙는다 → 카테고리 버튼에서 Tab을 누르면 **포커스가 문서 끝으로 점프**하고, 126개 칩을 Tab으로 훑어야 도달한다.
**제안**: `MaterialPicker`의 로빙 포커스 구현을 **공용 액션(`use:roving`)으로 추출해 양쪽이 공유**한다. **근거** — (a) 팔레트 쪽 방향키 0건 + ARIA 약속 위반 (b) 동작하는 참조 구현이 같은 저장소 안에 있다 (c) 낮음. 리스크는 추출 과정에서 Picker 회귀 → 추출 전후 Picker 동작이 동일한지가 검증 포인트. `role="menu"`를 유지할지 `role="listbox"`/`role="grid"`로 바꿀지도 함께 결정해야 한다(칩 격자는 사실상 그리드다).
### 그 밖의 데스크톱 항목
| 제안 | 지금 비어 있는 것 | 재사용 인프라 | 비용·리스크 |
|---|---|---|---|
| 휠 조합키 확장(Shift=브러시 모양, Alt=덮어쓰기 레벨) | 휠은 브러시 크기 **한 가지뿐**, 조합 변형 없음 | `wheel` 핸들러가 이미 `{passive:false}`로 등록되고 `ctrlKey`는 브라우저 줌에 양보 중 | 낮음 / 학습 부담 → 툴팁에 병기 필요 |
| 중클릭 = 스포이드 | 중클릭·사이드 버튼 **미사용** | `pointerdown`에서 `e.button` 이미 분기(우클릭 지우개) | 낮음 / 브라우저 자동스크롤 억제 필요 |
| 도구 전환 시 마키 재타깃 경고 | 팔레트에서 **물질을 고르는 것만으로** `tool.set('material')`이 불려 가열용 마키가 재료 채우기로 바뀐다. 피드백은 마키 테두리 색 변화 하나 | 마키 상태와 `$tool.listen`이 이미 연결돼 있음 | 낮음 / 잦은 경고는 소음 → 대기 중 마키가 있을 때만 |
| 영역 거부 팝오버 개선 | 2.6초 후 사라지고 `pointer-events:none`이라 붙잡아 읽을 수 없으며 `aria-live`가 없어 스크린리더에 안 읽힘 | `role="tooltip"` 마크업이 이미 있음 | 낮음 / 아래 "토스트 통합"과 묶는 게 낫다 |
| 삭제 확인을 2단계 arm으로 통일 | 스냅샷 삭제만 **브라우저 네이티브 `confirm()`** — 다크 테마를 깨고 3번째 레이어로 뜬다 | 전체 지우기·기본값 복원이 쓰는 **2초 arm 패턴이 이미 있다** | 매우 낮음 / 없음 |
| 검색 결과에 카테고리 배지 | 검색 결과 그리드는 칩만 나열, 소속 카테고리 미표시 | `categoryOf(m)` + `categoryLabel()` 즉시 사용 가능 | 매우 낮음 |
| `.hint`/`.field-note` 10~11px 상향 | 본문 최소 글자가 10px | CSS 한 줄 | 매우 낮음 / 레이아웃 재측정 |
---
## 모바일 개선안
### 브러시 크기를 모달 밖으로 [최우선]
위험: 가장 잦은 조작이 가장 깊은 곳에 있다
브러시 크기 슬라이더는 `frequentSettings()` 안에 있고, 모바일에서는 `.inline-settings{display:none}`이므로 **설정 모달 안에서만** 조작할 수 있다. 데스크톱의 대체 경로인 휠은 터치에서 발생하지 않고, **핀치 제스처도 없다**(`TouchEvent`/`touches`/`gesturestart` 코드 0건).
→ 모바일에서 브러시 크기를 한 번 바꾸는 데 **설정 열기 → 슬라이더 드래그 → 모달 닫기 = 3동작.** 그리기 중 가장 빈번한 조절인데도 그렇다. 같은 이유로 **채우기 모드·브러시 모양·덮어쓰기·배속·중력·연기·열지도가 전부 모달 뒤에** 있다.
**제안 A — 온캔버스 브러시 크기 위젯**: 캔버스 우측 가장자리에 얇은 수직 슬라이더(또는 +/− 스텝 버튼)를 상시 배치. **제안 B — 두 손가락 핀치 = 브러시 크기**: 화면을 줌할 수 없으니(viewport가 확대를 막고 `touch-action:none`) **핀치 제스처가 통째로 비어 있다.** 그 빈 제스처를 브러시 크기에 배정하면 0동작이 된다.
제안 근거
**(a)** 터치에서 브러시 크기를 바꾸는 직접 경로가 0개. 핀치·롱프레스·엣지 스와이프 전부 미사용.
**(b)** `$brushSize` 아톰과 `BRUSH_MIN`/`BRUSH_MAX` 클램프, 브러시 커서 미리보기가 이미 있다. Pointer Events만 쓰고 있어 **두 번째 포인터를 구분할 자리도 이미 열려 있다**(현재는 구분을 안 해서 두 손가락이 그림을 덮어쓴다 — 아래 항목).
**(c)** 중간. 리스크는 ① 핀치 인식이 그리기 스트로크를 오염시키는 것 → 두 번째 포인터가 내려오면 진행 중 스트로크를 **취소**하고 제스처 모드로 전환, ② 실기 검증 필수(브라우저별 포인터 이벤트 차이).
### 자주 쓰는 설정을 하단바로 승격
**제안**: 하단바 1행 우측에 "설정" 버튼 하나만 두는 대신, **사용 빈도 상위 3~4개(브러시 크기 · 채우기 모드 · 배속 · 렌더 모드)를 바 위에 직접 노출**하고 나머지를 모달에 남긴다. 데스크톱에서 사라진 헤더의 일반색/열지도 토글도 여기서 되살린다(모바일에선 `.head`가 통째로 숨겨져 **렌더 모드 원터치 토글이 아예 없다**).
**근거** — (a) 모바일 하단바에 설정류 컨트롤 0개 (b) `frequentSettings()` 스니펫이 **이미 데스크톱/모달 양쪽에 렌더되는 구조**라 세 번째 렌더 위치를 추가하는 형태 (c) 낮음~중간. 리스크는 세로 공간 — 2줄 바가 3줄이 되면 캔버스가 줄어든다. **접이식 3행**(끌어올리면 펼쳐지는 시트)이 타협안이다.
### 카테고리 아이콘에 라벨 붙이기
**현재**: 모바일에서 `.cat-label`과 `.count`를 숨겨 **14개 카테고리를 아이콘만으로** 구분한다. 아이콘은 `bi-box-fill`(고체) · `bi-hourglass-split`(가루) · `bi-droplet-fill`(액체) · `bi-cloud-fill`(기체) · `bi-fire` · `bi-hammer`(제련) · `bi-fuel-pump-fill`(석유) · `bi-hexagon-fill`(고분자) · `bi-asterisk`(폭발) · `bi-snow`(냉각) · `bi-lightning-charge-fill`(전기) · `bi-flower1`(생명) · `bi-radioactive` · `bi-stars`(특수) + 오브젝트 탭. `title` 속성은 있지만 **터치에서 호버 툴팁은 존재하지 않는다.**
특히 `bi-asterisk`(폭발) · `bi-hexagon-fill`(고분자) · `bi-stars`(특수)는 의미를 유추하기 어렵다.
**제안**: 아이콘 아래 9~10px 라벨 한 줄을 붙이고, 대신 개수 배지만 숨긴다. 또는 아이콘 대신 **첫 글자 + 색 칩**. **근거** — (a) 터치 라벨 0개, 툴팁 대체 경로 0개 (b) 라벨 문자열은 `categoryLabel(key)`로 i18n에 이미 있고 데스크톱에서 렌더 중 (c) 매우 낮음. 리스크는 가로 스크롤 길이 증가 — 어차피 가로스크롤이라 스크롤 거리만 늘어난다. **후술하는 카테고리 재분류(4탭 스킴)를 함께 도입하면 이 문제가 상당 부분 자연 해소된다.**
### 빠른접근 9칸 전부 노출
**현재**: 빠른접근 9칸 중 앞 3칸만 보이고 **나머지 6칸은 DOM에 존재하면서 CSS로 숨겨져 있다.** 즉 데이터도 마크업도 이미 다 있는데 화면에만 없다.
**제안**: 6칸을 숨기지 말고 같은 가로스크롤에 흘려보낸다. **근거** — (a) 6/9가 도달 불가 (b) DOM·상태(`$favorites`·`$recentMaterials`) 전부 존재, CSS 규칙 한 줄 제거 (c) 거의 0. 리스크는 카테고리 버튼이 스크롤 뒤로 밀리는 것 → 빠른접근과 카테고리를 **서로 다른 스크롤 컨테이너**로 분리하거나, 빠른접근을 접이식으로.
### 더블클릭 어포던스의 터치 대체 경로
**현재**: 더블클릭에만 걸린 기능이 넷이다 — 혼합·가열·냉각 버튼의 세부 설정 모달 3개, 그리고 **팔레트 칩의 Clone 프리래치**. 모바일 브라우저에서 `dblclick`은 발화하긴 하나 지연·확대 억제 등으로 신뢰도가 떨어지고, 앞의 3개는 설정 모달 → "브러시 세부 설정"이라는 우회로가 있지만 **Clone 프리래치는 터치 대체 경로가 아예 없다.**
**제안**: **롱프레스(500ms) = 더블클릭**을 전역 규칙으로 세운다. **근거** — (a) 롱프레스 제스처 0건, Clone 프리래치 터치 도달 불가 (b) `pointerdown`/`pointerup`이 이미 전 컨트롤에 있고 `setPointerCapture`도 쓴다. 타이머 하나짜리 Svelte 액션(`use:longpress`)으로 공용화 가능 (c) 낮음. 리스크는 ① 스크롤 중 오발화 → 이동 임계값(8px) 취소, ② 발견 가능성 — 롱프레스는 **보이지 않는 제스처**이므로 반드시 툴팁/힌트에 명시해야 한다. ③ iOS의 롱프레스 컨텍스트 메뉴 억제(`-webkit-touch-callout`) 확인 필요(실기 검증 항목).
### 터치에서 `title` 89개가 전부 죽어 있다
위험: 설명의 대부분이 모바일에 존재하지 않는다
컴포넌트별 `title=` 속성 개수는 ControlPanel 49 / SaveSlots 16 / MaterialPalette 7 / InspectPanel 5 / MaterialPicker 4 / BlendBrush 3 / HeatCoolSettings 2 / SnapshotLoad 2 / Modal 1 = **총 89개**. i18n에도 `Tooltip` 접미 키가 62개나 준비돼 있다.
그런데 전부 **브라우저 네이티브 `title`**이다. 터치에는 호버가 없으므로 **모바일 사용자에게 이 89개 설명은 존재하지 않는 것과 같다.** 도구 15개의 의미도 여기에만 있어, 모바일에서는 아이콘 15개를 시행착오로 익혀야 한다.
→ 이 항목은 아래 **인게임 툴팁 시스템 신설** 절에서 통합해 다룬다.
### 그 밖의 모바일 항목
| 제안 | 지금 비어 있는 것 | 재사용 인프라 | 비용·리스크 |
|---|---|---|---|
| 두 번째 손가락 무시(멀티터치 거부) | 포인터를 1개만 추적하는데 **구분은 안 한다** — 두 손가락을 올리면 두 번째가 그대로 덮어 그린다 | `pointerdown`에서 `e.pointerId`를 저장해 첫 포인터만 처리하면 끝 | 매우 낮음 / 위 핀치 제안과 **반드시 함께 설계** |
| `hint.draw` 문구 분기 | 유일한 조작 설명이 "오른쪽 클릭이나 지우개 브러시로 지웁니다" — **모바일엔 우클릭이 없는데 같은 문구가 뜬다** | i18n 키 추가 한 줄 + `(hover:none)` 분기 | 매우 낮음 |
| 렌더 모드(일반색/열지도) 하단바 노출 | `.head` 숨김으로 **원터치 토글이 통째로 사라짐**, 모달 3동작으로 대체됨 | `$heatOverlay` 아톰, 버튼 마크업 존재 | 매우 낮음 |
| 가로(landscape) 모드 대응 | 방향 분기 0건. 가로에서 2줄 바가 세로 공간의 큰 비중을 먹는다 | `--bottombar-h` 변수, 데드존 슬라이더 | 낮음 / 실기 검증 필요 |
| 플라이아웃이 돋보기 카드를 덮는 문제 | 플라이아웃 z=20, InspectPanel z=8. 모바일은 위로 열려 겹칠 소지 | z-index 지도가 이미 정리돼 있음 | 매우 낮음 |
| 물질 선택 후 플라이아웃 400ms 지연 제거 | `dblclick` 보존을 위한 의도적 지연이라 연타 시 잔상 | 롱프레스 대체가 도입되면 **지연 이유 자체가 사라진다** | 낮음 / 롱프레스 제안에 종속 |
---
## 양쪽 공통 — 아예 없는 기능
다음은 grep으로 부재를 확정한 항목이다. 순서는 재미 기여도 순이다.
### 되돌리기 / 다시하기 [최우선]
위험: 실험이 곧 되돌릴 수 없는 손실이다
`undo|redo` 계통이 **UI·상태·엔진 어디에도 없다.** 히스토리 스택도, 커맨드 패턴도, 스냅샷 링버퍼도 없다. `Grid`는 평행 TypedArray를 직접 변조한다.
더 나쁜 것은 **새로고침으로도 복구가 안 된다**는 점이다. 월드 자동저장이 `WORLD_AUTOSAVE_MS = 3000`, 즉 **3초마다 현재 상태를 덮어쓰기** 때문이다. 실수로 지운 구조물은 3초 안에 탭을 닫지 않는 한 사라진다. 유일한 수동 백업은 명명 스냅샷 슬롯이고, 가장 가까운 안전장치는 "전체 지우기"의 2초 arm 확인뿐이다.
이건 편의성 문제로 보이지만 **재미 문제다.** 되돌릴 수 없으면 사람은 과감한 실험을 하지 않는다. 폭발물을 실험용 구조물 옆에서 터뜨려 보는 놀이가 억제된다.
제안 근거
**(a)** 히스토리 계통 전무. 스트로크 한 획을 무를 방법이 없다.
**(b)** 재사용 인프라가 의외로 충분하다 — 월드 직렬화(RLE + base64)가 `persistence.ts`에 이미 있고, 스냅샷 계통이 압축·복원 왕복을 검증까지 하고 있다(`test/snapshot-file`). 셀당 저장 평면(cells 8bit / aux 16bit / temp 32bit float / overlay 8bit / overlayAux 16bit)이 명확해 **스트로크 영향 영역(bounding box)만 잘라 저장하는 부분 스냅샷**이 자연스럽다.
**(c)** 중간. 메모리가 유일한 진짜 리스크다: `MAX_CELLS = 600,000`, 셀당 영속 평면 합계는 8bit+16bit+32bit+8bit+16bit ≈ 10 B이므로 **전체 스냅샷 1장이 최대 약 6 MB**다. 20단계 전체 스냅샷은 비현실적이다. 따라서 **범위 한정 + 상한 있는 링버퍼**(예: 브러시 스트로크 단위 diff, 총 예산 32 MB 또는 20단계 중 먼저 닿는 쪽)로 설계해야 한다.
**설계 제안**: ① 되돌리기 단위 = **한 스트로크**(pointerdown~pointerup) 또는 한 번의 영역 확정. 시뮬레이션 자체의 진행은 되돌리지 않는다(그건 리플레이 영역이고 별개 문제다). ② 일시정지 중에는 완전 복원, 진행 중에는 **"방금 그린 것을 지우는" 역연산**만으로도 체감 대부분을 커버한다. ③ "전체 지우기"는 항상 1단계 되돌리기 대상 — 지금 2초 arm으로 막는 것보다 **되돌릴 수 있게 만드는 편이 낫다.**
### 카메라 줌 / 팬
**현재**: 카메라 개념이 코드에 없다. 확대 수단은 `$cellScale`(`CELL_SCALES = [2, 1.5, 1, 0.75, 0.5]`)뿐인데, 이건 카메라가 아니라 **격자 해상도를 바꾸는 것**이다. 확대하면 세계가 커지는 게 아니라 **셀이 굵어지고 그리드가 리사이즈되며 내용이 리맵된다.** 성형작약 방향 지정이나 전선 배선처럼 픽셀 단위 정밀 작업에 확대가 필요한데 수단이 없다.
**제안**: 렌더 전용 뷰포트 변환(`scale`, `offsetX`, `offsetY`)을 도입한다. **근거** — (a) 줌/팬 0건 (b) `src/game/render/viewport.ts`가 이미 존재하고(`centeredRect` 하나뿐이지만) 렌더 경로가 한 곳으로 모여 있다. 스냅샷 "맞춤"(`SnapshotFit`) 기능이 **이미 크기가 다른 장면을 캔버스에 앉히는 좌표 변환을 다루고 있어** 개념적 선례가 된다 (c) **높음.** 리스크가 실제로 크다: 포인터 좌표→셀 좌표 변환이 `PointerPainter`·브러시 커서·마키·돋보기 전부에 흩어져 있어 한 곳이라도 변환을 빠뜨리면 **그리는 위치가 어긋난다.** 그리고 시뮬레이션은 월드 전체를 계속 돌리므로 성능 이득은 없다(오히려 확대 시 픽셀 보간 비용 추가). → **단계적 도입 권장**: 1단계 정수배 확대(2×/4×)만 + 팬은 드래그, 2단계 자유 줌.
### 소리
**현재**: `AudioContext`/`new Audio` 0건. 폭발·전기·연소·물 흐름이 **전부 무음**이다.
**제안**: 절차적 합성(WebAudio 오실레이터 + 노이즈 + 엔벨로프)으로 6~10종 이벤트 사운드. **근거** — (a) 완전 백지 (b) 이벤트 훅은 이미 촘촘하다 — 폭발(`blast.ts`), 스파크, 점화, 충격파 발행 지점이 명확하다 (c) 중간. 리스크 셋: ① **오프라인 PWA 규율** — `docs/PWA.md`가 "런타임 로드 이미지 없음"을 규율로 두고 있어 오디오도 **자산 파일이 아니라 코드에서 합성**해야 한다, ② 자동재생 정책 — 첫 사용자 제스처에서 `AudioContext` 재개, ③ **소음 문제** — 입자 시뮬레이터는 동일 이벤트가 초당 수백 번 발생한다. **이벤트 풀링/쿨다운과 동시발음 상한**이 설계의 본체이지 합성은 곁가지다. 기본값은 "꺼짐"이 안전하다.
이 항목은 **①재미 축에서 가장 값싼 미개척 영역**일 가능성이 있다. 다만 위 ③ 때문에 프로토타입 없이 확정하기 어렵다.
### 온보딩 / 튜토리얼
**현재**: `firstrun|onboard|welcome|tour` 0건. 전체 도움말 문구는 i18n의 **`hint.draw` 단 하나**이고, 그것도 설정 모달 맨 아래에 렌더된다.
**제안 3안**: ① **첫 실행 3장 코치마크**(그리기 / 팔레트 / 재생·정지) — 가장 값싸다. ② **시작 화면에 "해보기" 시나리오 카드** — [[Web sandbox 프로젝트/구상/시작 화면과 도감 페이지]]와 겹치므로 그쪽에서 상세히 다룬다. ③ **내장 예제 빌드**(아래 항목) — 튜토리얼 없이 "만져 보면 알게 되는" 경로.
**근거** — (a) 온보딩 표면 0 (b) `Modal` 컴포넌트와 포커스 트랩이 이미 견고하고, `$locale` 기반 i18n 배선이 264키 완전 일치 상태로 깨끗하다 (c) 낮음(①) ~ 높음(②). 리스크는 **온보딩이 재미를 늦춘다**는 것 — 우선순위 ①이 재미이므로 **건너뛰기가 항상 1탭 거리**여야 하고, 기본은 "만지면 사라지는" 비침습형이 맞다.
### 내장 예제 빌드 [값싼 승리]
성공: 이미 있는 포맷을 채우기만 하면 된다
스냅샷은 `.psbx.json` 포맷으로 **내보내기/가져오기가 이미 구현돼 있고**, 썸네일(160px JPEG q0.55)·설명(200자)·최대 50슬롯까지 갖춰져 있다. 즉 **예제 장면을 몇 개 만들어 저장소에 동봉하고 "예제" 탭에서 불러오게 하는 것은 코드 변경이 거의 없다.**
**제안**: 8~12개 예제 빌드(테르밋 절단 · 증류탑 · 중합 라인 · 전자석 크레인 · 핵반응로 · 불꽃놀이 · 산호초 수조 · 골드버그 장치). **근거** — (a) 예제 0건. 138종 물질의 상호작용을 발견할 실마리가 게임 안에 없다 (b) `.psbx.json` 포맷 · 로드 UI · 크기 맞춤(`SnapshotFit`) · 썸네일 전부 존재. 새 코드는 "번들된 예제 목록을 읽어 오는 경로" 하나 (c) **낮음.** 리스크는 ① 번들 크기 — 예제는 RLE 압축된 JSON이라 작지만 **PWA 프리캐시 목록에 들어가면 첫 설치 용량이 늘어난다**, ② **호환성** — 물질 id가 바뀌면 예제가 깨진다. 은퇴 id 8개(14·15·30·35·64·72·76·142)를 재사용하지 않는 현재 규율이 이걸 지켜 주므로, **예제 동봉은 그 규율을 더 중요하게 만든다.**
이 항목은 **재미 대비 비용 비율이 이 문서 전체에서 가장 좋다.**
### 그리기 도구 자체가 비어 있다
| 도구 | 지금 비어 있는 것 | 재사용 인프라 | 비용·리스크 |
|---|---|---|---|
| **스포이드**(호버 셀의 물질을 선택) | 없음. 캔버스의 물질을 다시 고르려면 팔레트에서 찾아야 한다 | 돋보기가 **이미 커서 아래 셀의 물질을 읽고 있다**(`inspectCells`) — 읽기 경로가 완성돼 있고 `$selectedMaterial.set()`만 붙이면 된다 | 매우 낮음 / 없음 |
| **직선 그리기**(Shift+드래그) | 없음 | 스트로크 보간이 **이미 Bresenham**이다. 시작점을 고정하기만 하면 된다 | 매우 낮음 |
| **사각/원 채우기·테두리** | 없음 | 영역 마키(`.rect-select`)가 이미 사각 영역을 잡고 확정하는 UI를 갖고 있다 | 낮음 |
| **영역 복사 / 붙여넣기** | 없음. 만든 구조물을 복제할 방법이 없다 | 영역 마키 + 스냅샷 직렬화(부분 영역 잘라내기) | 중간 / 붙여넣기 시 aux·temp·overlay 4평면을 함께 옮겨야 한다(Coral처럼 **aux 16비트를 완전 소진한 물질**이 있어 aux 누락은 조용한 파손이 된다) |
| **대칭 그리기**(좌우/상하 미러) | 없음 | 스탬프 좌표 변환 한 겹 | 낮음 / 재미 기여 큼(대칭 구조물) |
| **채우기(플러드 필)** | 없음 | 폭발·장치가 각자 플러드 알고리즘을 이미 갖고 있으나 **공용화돼 있지 않다** | 중간 / 공용 플러드가 없다는 것 자체가 엔진 항목 → [[Web sandbox 프로젝트/구상/엔진 개선안]] |
### 토스트 / 알림 통합
**현재**: 알림 시스템이 없다. `SaveSlots`의 로컬 `flash`, `ControlPanel`의 2.6초 팝오버가 **각자 구현**돼 있고, 삭제 확인만 네이티브 `confirm()`이다.
**제안**: 단일 토스트 스토어(`$toasts`) + 컴포넌트. **근거** — (a) 통합 알림 0건, 세 가지 패턴이 공존 (b) `Modal`의 portal 패턴을 그대로 재사용, i18n 키 체계도 있음 (c) 낮음. 리스크는 캔버스 가림 → 하단(모바일은 바 위) 고정. **`aria-live="polite"`를 여기 한 곳에 두면 영역 거부 팝오버의 접근성 문제도 함께 해결된다.**
### 라이트 테마
**현재**: `:root { color-scheme: dark }` 하나뿐이고 `prefers-color-scheme` 분기가 없다 — **라이트 테마가 없다.**
**제안**: CSS 변수 팔레트를 라이트/다크 두 벌로. **근거** — (a) 분기 0건 (b) 색이 이미 CSS 변수로 상당 부분 추출돼 있음(`--sidebar-w` 같은 레이아웃 변수의 선례) (c) **중간.** 리스크가 특이하다 — **캔버스 자체가 어두운 배경 전제로 그려진다.** 물질 색·발광(`glow`)·열지도 LUT가 전부 다크 배경 대비로 설계됐다. 따라서 라이트 테마는 **UI 크롬만** 밝게 하고 캔버스는 다크로 두는 절충이 현실적이다. 이건 밝은 환경에서의 가독성 문제이지 취향 문제가 아니므로 접근성 절과 함께 판단한다.
---
## 인게임 툴팁 시스템 신설
주의: 이건 "기존 툴팁 개선"이 아니다
이 프로젝트에 **커스텀 툴팁 컴포넌트는 존재하지 않는다.** 설명 표면의 전부는 다음 셋이다.
1. **네이티브 `title` 속성 89개** — 터치에서 안 뜨고, 지연이 브라우저 고정이며, 스타일 제어가 불가능하고, 스크린리더 지원이 들쭉날쭉하다.
2. **i18n `hint.draw` 문구 1개** — 설정 모달 맨 아래.
3. **돋보기 오버레이(`InspectPanel`)** — 유일한 인게임 정보 표시. 다만 **명시적 토글이 필요하고**, 보여 주는 것은 **현재 온도와 구성 비율뿐**이다.
즉 이 항목은 **툴팁 시스템을 처음 만드는 일**이다.
### 1계층 — 공용 툴팁 컴포넌트
`title` 89개를 **호버 + 롱프레스 + 키보드 포커스** 세 경로로 뜨는 커스텀 툴팁으로 교체한다.
제안 근거
**(a)** 커스텀 툴팁 0건. 터치 사용자에게 89개 설명이 전부 부재.
**(b)** 문구가 **이미 i18n에 62개 `Tooltip` 접미 키로 준비돼 있다.** 마크업의 `title={...}`를 `use:tip={...}`로 바꾸는 기계적 치환이며, portal 패턴은 `Modal`/플라이아웃이 이미 쓰고 있다. 롱프레스 액션은 위 모바일 절의 제안과 **같은 구현을 공유**한다.
**(c)** 낮음~중간. 리스크는 ① 89곳 치환의 물리적 양(다만 회귀 위험은 낮다), ② **접근성 회귀** — 네이티브 `title`은 부실해도 일부 보조기술이 읽는다. 대체 시 `aria-describedby`를 정확히 연결하지 않으면 **지금보다 나빠질 수 있다.** ③ z-index 지도(8/9/10/20/40/50)에 툴팁 층을 어디 끼울지 결정 필요.
**설계 규칙 제안**: 툴팁에 **단축키를 병기**한다("일시정지 · Space"). 위 단축키 제안이 발견되지 않으면 무의미한데, 툴팁이 그 발견 경로가 된다. 두 제안은 묶어서 착수하는 게 맞다.
### 2계층 — 물질 정보 카드 [핵심]
물질 칩을 **호버(데스크톱) / 롱프레스(모바일)** 하면 그 물질이 무엇인지 보여 주는 카드.
위험: 지금 팔레트 칩의 `title`은 이름을 그대로 반복한다
`title={materialName(m.id, m.name)}` — 라벨과 **완전히 같은 문자열**이다. 즉 126종 물질에 대해 게임이 알려 주는 정보는 이름뿐이고, 융점·끓는점·밀도·발화점·전도성 같은 물성은 **UI에 전혀 노출되지 않는다.** 반응 사슬(산+금속→수소, 갈륨의 알루미늄 취화 등)은 `docs/MATERIALS.md`·`docs/MATERIAL-SYSTEMS.md`에만 있고, 게임 안에서는 **직접 해 보는 것 외에 알 방법이 없다.**
**표시 항목 제안 — 데이터 출처별로 3등급으로 나뉜다:**
| 항목 | 출처 | 가용성 |
|---|---|---|
| 이름 · 상(phase) · 색 · 밀도 | `Material`의 **필수 5필드**(id/name/phase/color/density) | [즉시] 추가 데이터 0 |
| 태그 배지(전도성·자성·가연성·폭발성·다공성·레이저 반사·산-수소) | 기존 옵셔널 태그 필드 | [즉시] 라벨 문자열만 i18n에 추가 |
| 상전이 온도(융점·응고점·끓는점·자연발화점) | 물질 파일의 `*_TEMP` 상수 · `Combustible`의 `autoIgniteTemp`/`burnTemp` | [중간] **필드가 아니라 파일 내 상수**라 물질마다 위치가 다르다. 카드에 띄우려면 `Material`에 임계 온도 필드를 추가하거나 물질별 노출 함수가 필요 |
| 반응 상대 | 선언형 `ReactionRule` | [부분] **138종 중 6종만** 선언형 반응 테이블을 쓴다. 나머지는 각 물질 `update`에 하드코딩돼 있어 **자동 추출이 불가능하다** |
| 산문 설명 | 없음 | [부재] `Material` **74개 필드(필수 5 + 옵셔널 69)에 `description`이 없다.** 새 스키마 결정이 선행 |
제안 근거
**(a)** 물질 설명 UI 0건. 126종의 상호작용을 발견할 실마리가 게임 안에 없다 — 이건 **①재미를 직접 깎는다.**
**(b)** 위 표의 상위 2행은 **오늘 당장 공짜로 나온다.** 렌더 쪽도 유리하다 — 칩 아이콘을 만드는 `materialSvgFor(m)`은 **순수 함수**이고 그 import 그래프에 `document`/`window`가 한 번도 등장하지 않아(Node 안에서 126종을 굽는 검증 하네스 `test/materialicons.ts`가 그 증거다) 카드 안에 큰 미리보기를 넣는 것도 값싸다.
**(c)** 중간. **진짜 작업량은 렌더링이 아니라 "126×2 언어의 설명문을 어디에 어떤 스키마로 둘 것인가"의 결정이다.** 세 선택지: ① `Material.description` 필드 추가(물질 = 파일 하나 규칙 유지, 대신 74→75필드이고 새 물질마다 누락 가능), ② 별도 i18n 테이블(한 파일에서 리뷰 가능, 대신 **누락이 조용히 지나간다** — `materialNamesKo`가 `Record<number, string>`이라 빠뜨려도 영문명으로 폴백하는 것과 같은 함정), ③ 빌드타임 데이터 파일. **어느 쪽이든 커버리지 검증 스크립트를 짝지어야 한다.**
**단계적 제안**: 1단계는 **산문 없이** 이름·상·밀도·태그 배지·상전이 온도만 보여 주는 "물성 카드". 이것만으로도 지금(이름뿐)과 비교하면 큰 도약이고, **추가 데이터 없이 대부분 나온다.** 산문 설명과 반응 상대는 2단계로 미루고, 그 자리에 **"자세히 보기 → 도감"** 링크를 둔다.
### 3계층 — 별도 도감 페이지
물질 전수 표·반응 사슬·계통도처럼 카드에 담기 어려운 것은 별도 페이지로 보낸다. 상세 설계는 [[Web sandbox 프로젝트/구상/시작 화면과 도감 페이지]]에서 다룬다. 이 문서의 관심사는 **인게임 카드에서 그 페이지로 가는 링크가 있어야 한다**는 것 하나다.
---
## 물질 팔레트 카테고리 재분류
이 절이 이 문서의 핵심이다.
### 현행 분류의 구조적 진단
현재 카테고리는 **14개**다: `solid` · `powder` · `liquid` · `gas` · `fire` · `smelt` · `oil` · `polymer` · `explosive` · `cooling` · `electric` · `life` · `radioactive` · `exotic`. 여기에 팔레트 UI가 **오브젝트 탭 1개를 더 붙여 화면상 15탭**이다.
정보: 문서 오류 정정
소개문서가 "주제별 13개 카테고리"라고 적은 것은 **오류다** — **🧬 고분자(polymer)** 하나가 목록에서 통째로 빠져 있다. 정작 같은 문서 본문은 고분자 라인(에틸렌·촉매·폴리에틸렌)을 설명하고 있다. 이 재분류 작업과 함께 고쳐야 한다(아래 "부수 정리 대상").
문제의 본질은 개별 물질의 오분류가 아니라 **상태 탭 4개(고체/가루/액체/기체)의 성격**에 있다.
위험: 상태 탭이 상태를 담고 있지 않다 — 잔여 버킷 구조
팔레트 126종의 **실제 상(phase) 분포**와, **상태 탭에 실제로 들어 있는 수**는 이렇게 어긋난다.
| 상태 | 실제 phase 분포 | 해당 상태 탭 소속 | 다른 탭에 빼앗긴 수 |
|---|---|---|---|
| Solid | **48** | 14 | 34 |
| Powder | **33** | 14 | 19 |
| Liquid | **29** | 12 | 17 |
| Gas | **16** | 9 | 7 |
즉 상태 탭은 "그 상태인 물질을 모으는 곳"이 아니라 **"주제 탭 10개가 안 가져간 나머지가 떨어지는 곳"**이다. 사용자가 "가루를 찾자"고 가루 탭을 열면 33종 중 14종만 있고, 톱밥은 불 탭에, 유황은 폭발 탭에, 씨앗은 생명 탭에, 반물질은 특수 탭에 있다.
이건 개별 물질을 옮겨서 고칠 수 있는 문제가 아니다 — **주제 탭을 유지하는 한 상태 탭은 구조적으로 잔여 버킷일 수밖에 없다.**
```mermaid
flowchart LR
M["물질 138종"] --> C{"category 선언?"}
C -- "있음 114종" --> T["주제 탭이 먼저 가져감"]
C -- "없음 12종" --> F["PHASE_FALLBACK"]
T --> R["상태 탭 = 남은 것"]
F --> R
R --> P["팔레트 15탭"]
```
### 오분류 후보
"상태 탭이 있는데 주제 탭에 빨려 들어간" 물질들. `phase`는 코드가 선언한 값이므로 이 표의 3열은 **논쟁의 여지가 없는 사실**이다.
| 물질 | 현재 탭 | 실제 phase | 메모 |
|---|---|---|---|
| **Sawdust**(톱밥) | fire | Powder | 가루인데 가루 탭에서 안 보인다 |
| **White Phosphorus**(백린) | fire | Powder | 가루 또는 폭발 쪽이 자연스럽다 |
| **Coal**(석탄) | fire | Solid | 고체 연료 |
| **Wood**(나무) | fire | Solid | 고체 |
| **Sulfur**(유황) | explosive | Powder | **`explosive` 태그가 없다** — 화약 원료일 뿐 |
| **Saltpeter**(초석) | explosive | Powder | 동일. `explosive` 태그 없음 |
| **Methane**(메탄) | explosive | Gas | 기체 탭에 산소·수소가 있는데 메탄만 폭발 탭 |
| **LPG · Petroleum Vapor** | oil | Gas | 둘 다 기체 |
| **Asphalt**(아스팔트) | oil | Solid | 고체 |
| **Ethylene**(에틸렌) | polymer | Gas | 기체 |
| **Polyethylene**(폴리에틸렌) | polymer | Powder | 가루 |
| **Iron Ore · Limestone · Coal Powder** | smelt | Powder | 셋 다 가루 |
| **Snow · Ammonium Nitrate** | cooling | Powder | 둘 다 가루 |
| **Seed · Yeast**(씨앗·효모) | life | Powder | 둘 다 가루 |
| **Nuke Waste**(핵폐기물) | radioactive | Powder | 가루 |
| **Antimatter**(반물질) | exotic | Powder | 가루 |
### 계통이 흩어진 것
같은 물질의 다른 상태, 또는 명확한 진화·가공 관계에 있는 형제가 **서로 다른 탭에** 있다.
| 계통 | 흩어진 탭 |
|---|---|
| **Coal** | Coal → fire / Coal Powder → smelt |
| **용융 금속** | Molten Iron · Molten Aluminum → fire / Molten Iron Ore → smelt / Liquid Gallium → liquid / Molten U235 · U238 → radioactive — **한 계통이 4개 탭에 분산** |
| **Glass** | Glass → solid / Molten Glass → fire / Broken Glass → powder |
| **Salt** | Salt → powder(폴백) / Saltwater → liquid(폴백) / Molten Salt → fire |
| **Aluminum** | Aluminum → solid / Molten Aluminum → fire / Aluminum Powder · Activated Aluminum → powder / Flash Powder · Ammonal → explosive |
| **Iron** | Iron → solid / Metal Powder(철가루) → powder / Iron Ore → smelt |
| **Rust** | Rust → solid / Rust Powder → powder |
| **수지** | Resin(송진) → liquid / Amber(호박) → solid — 진화 관계인데 갈림 |
알루미늄 계통은 **하나의 통합 검증 스크립트(`npm run test:aluminum`)로 묶어 관리할 만큼 코드상 한 계통인데, 팔레트에서는 4개 탭에 흩어져 있다.** 플레이어가 "알루미늄으로 뭘 할 수 있나"를 팔레트만 보고 파악할 방법이 없다.
### 탭이 태그를 배신하는 것
탭 이름이 약속하는 성질과 실제 `Material` 태그가 어긋나는 사례.
| 물질 | 현재 탭 | 실제 성질 |
|---|---|---|
| **Thermite** | explosive | **`explosive: true` 없음** — 소이제(발광 + 연소 타이머) |
| **Fuse**(도화선) | explosive | **`explosive: true` 없음**, `combustible: true` |
| **Sodium**(나트륨) | **powder** | **`explosive: true` 있음** |
| **Ammonium Nitrate** | **cooling** | **`explosive: true` 있음** (냉각팩 용도로 의도적 이관 — `docs/PHYSICS.md`에 기록) |
| **Nanobot** | **life** | 코드 주석이 **"기계이지 생명이 아니다"라고 명시**하고 `radiationDeath`/`radiationHit` 둘 다 미선언 |
| **Conveyor**(컨베이어) | **exotic** | Fan · Pump · Turbine과 같은 산업 기계인데 **혼자만 특수 탭** |
| **Slime · Acid Slime** | life | 둘 다 `conductive: true` — 전기 계통에 참여하는데 탭은 생명 |
| **Blast** | explosive | `packedTemp: true` **이펙트 셀**인데 팔레트에 노출됨. 같은 성격의 Flash는 팔레트에서 제외됐다 |
| **Heatpipe · Mesh** | solid | 열수송 장치와 `porous` 부품 — 같은 `porous`인 Turbine·Pump는 electric인데 이 둘만 일반 고체 |
이 표에서 **Sulfur · Saltpeter · Thermite · Fuse**는 "폭발 탭에 있는데 `explosive` 태그가 없는" 4종이고, **Sodium · Ammonium Nitrate**는 "`explosive` 태그가 있는데 폭발 탭이 아닌" 2종이다. 즉 폭발 탭은 태그가 아니라 **"화약 냄새가 나는 것"이라는 주관으로 구성돼 있다.** 재미 우선순위에서 이 주관이 반드시 나쁜 것은 아니다 — 다만 **일관된 기준이 아니라는 사실을 인정하고 설계해야** 한다.
### 제안: 분류 기준 설정 제공 (런타임 전환) [핵심 제안]
성공: 결론부터
**현행 주제 분류를 지우지 않는다.** 대신 설정에 **"팔레트 분류 기준"** 항목을 만들어, 사용자가 **주제(기본값) / 물질 상태 / 물질 종류** 중에서 고르게 한다. `Material.category` 원본 값은 **한 글자도 바꾸지 않고**, 분류 함수에 모드 파라미터를 넘기는 방식으로 간다.
이 선택은 편의성 제안이면서 동시에 **가장 값싼 안전 경로**다 — 이유는 아래 "가장 큰 함정"에서 설명한다.
| 스킴 | 필요한 새 데이터 | 즉시 가능? | 최대 위험 |
|---|---|---|---|
| **주제**(현행, 기본값) | 없음 | [이미 동작] | 위 세 표의 불일치가 그대로 남음 — **의도적으로 남긴다** |
| **물질 상태**(4탭) | **없음 — 0** | [즉시 가능] | **"불꽃이 기체 탭에 들어간다"** |
| **물질 종류**(금속/유기/무기…) | **필수** | [불가] | 새 메타데이터 없이는 시작 자체가 불가능 |
#### 스킴 A — 주제 (현행, 기본값)
**그대로 둔다.** 오분류 표를 근거로 개별 물질을 옮기는 작업은 **권하지 않는다.**
**근거** — (a) 비어 있는 것 없음 (b) — (c) **리스크가 이득보다 크다.** ① `category` 값을 바꾸면 아래 "가장 큰 함정"의 소비자 5곳을 하나씩 점검해야 하고, ② 주제 분류는 **"재미의 지도"** 역할을 한다 — 톱밥이 불 탭에 있는 것은 물리적으로는 틀렸지만 "이걸로 불을 낼 수 있다"는 힌트다. 우선순위 ①이 재미이므로 **주제 분류의 주관성 자체는 자산이다.** 사용자가 상태 기준을 원할 때 **전환할 수 있게 해 주는 것**이 옳은 해법이다.
다만 **명백한 사고 2건은 예외로 검토할 만하다**: **Blast**(이펙트 셀인데 팔레트에 노출 — 자매 격인 Flash는 제외돼 있다)와 **Conveyor**(산업 기계 3종과 떨어져 혼자 특수 탭). 둘 다 검증 스크립트가 읽는 카테고리(`life`/`electric`/`radioactive`)를 건드리지 않으므로 상대적으로 안전하다. **Conveyor를 `electric`으로 옮기면 `test:electricity`가 대상 집합을 `m.directPulse && m.category === 'electric'`으로 정의하므로 영향 여부를 먼저 확인해야 한다.**
#### 스킴 B — 물질 상태 (4탭)
성공: 추가 데이터 0으로 오늘 당장 가능하다
`Material.phase`는 **필수 필드**다(옵셔널이 아니다). 138종 전부가 값을 갖고 있고, 엔진이 기본 거동을 결정하는 데 쓰므로(`registry.ts`의 `defaultUpdate(def.phase)`) **이미 검증되고 신뢰할 수 있는 값**이다.
게다가 `categories.ts`에 **`PHASE_FALLBACK`이라는 phase→탭키 매핑이 이미 존재한다**(현재는 `category` 미선언 12종을 받는 용도). `categoryOf`가 `m.category ?? PHASE_FALLBACK[m.phase]`이므로, **`?? ` 앞을 건너뛰고 `PHASE_FALLBACK[m.phase]`만 쓰면 상태 기준 분류가 그 자리에서 완성된다.**
결과는 **고체 48 / 가루 33 / 액체 29 / 기체 16**의 4탭이다. 탭이 15개에서 5개(+오브젝트)로 줄어드는 것 자체가 **모바일 이득이 크다** — 위 "카테고리 14개를 아이콘만으로 구분" 문제가 상당 부분 해소된다.
주의: phase는 "겉보기 상태"가 아니라 "물리 거동"이다
이게 이 스킴의 최대 위험 지점이다. 코드가 phase를 정할 때 쓴 기준은 렌더링이 아니라 시뮬레이션 거동이다.
- **Fire · Blue Flame · Blast · Smoke · Steam이 `Phase.Gas`** → **불꽃이 "기체" 탭에 들어간다.** 사용자 직관과 정면 충돌한다.
- **Napalm이 `Phase.Solid`** — 끈적함을 표현하려고. 파일 주석이 이유를 밝히고 있다.
- **Termite · Nanobot이 `Phase.Solid`** — "쌓이지 않고 걸어다녀서" + `shockLoose`.
- **Lava · Mud · Slime · Acid Slime이 `Phase.Liquid`**.
**'에너지·특수' 5번째 버킷을 만들면 이 문제가 해결되지만, 기존 필드로는 불가능하다.** 조사한 후보 신호는 전부 부분집합만 잡는다: `packedTemp`는 팔레트 안에 **Blast 하나뿐**, `life:{ticks}`는 **Smoke 하나뿐**, `decorTemp`·`blastInert` 해당 물질은 전부 팔레트 밖, `glow`는 16종인데 **정작 Fire와 Blue Flame에는 `glow`가 없다.**
→ **Fire · Blue Flame · Lava를 묶는 기존 필드는 존재하지 않는다.** 지금 이 셋을 묶는 유일한 근거가 바로 `category: 'fire'`다. 5탭 체계를 원하면 `Material.energy?: boolean` 같은 **새 태그를 소수 물질(Fire · Blue Flame · Blast · Lava 등)에만** 다는 게 최소 변경이다.
제안 근거 — 스킴 B
**(a)** 상태 기준으로 물질을 찾을 방법이 없다(상태 탭은 잔여 버킷). "가루 33종을 한눈에" 같은 질의가 불가능.
**(b)** `Material.phase` 필수 필드 + `PHASE_FALLBACK` 매핑이 **이미 다 있다.** 추가 데이터 0.
**(c)** **낮음.** 리스크 둘: ① "불꽃이 기체 탭" — `Material.energy` 새 태그로 5탭 만들면 해소되나 소수 물질 태깅 작업이 붙는다, ② **탭 내부 정렬** — 탭 순서는 `MATERIALS` 배열 순서에서 나오므로 고체 48종이 **주제별로 흩어진 순서**로 나열된다. 스킴별 정렬(id순 / 밀도순)을 함께 정해야 하며, **밀도순 정렬은 상태 스킴에서 특히 자연스럽고 그 자체가 학습 도구가 된다**(뜨는 것과 가라앉는 것이 팔레트에서 보인다 — ③고증 축의 부수 이득).
#### 스킴 C — 물질 종류 (금속 / 유기물 / 무기물 …)
위험: 불가능하다 — 종류를 표현하는 필드가 코드에 전혀 없다
`Material` 인터페이스 **74개 필드를 전수 확인**했다(필수 5: `id`/`name`/`phase`/`color`/`density` + 옵셔널 69). 필드는 id·이름·상·색·밀도 + 거동 태그 + 렌더 힌트뿐이며, `kind`·`family`·`element`·`organic`·`metal` 류의 분류 필드는 **없다.**
기존 태그 조합으로 금속을 판별해 보려 하면 이렇게 실패한다.
- **금속 후보 신호**(`conductive`/`wiring`/`magnetic`/`laserReflective`/`acidHydrogen`)의 **오탐 8종**: Iron Ore·Rust Powder(산화물인데 `magnetic`), Water·Saltwater·Acid(전해질이라 `conductive`), Slime·Acid Slime(생물인데 `conductive`), Nanobot(기계인데 `magnetic`).
- **누락 6종 이상**: **Sodium**(금속인데 전도 태그가 없고 `acidHydrogen`도 의도적 미선언), **Molten Iron · Molten Aluminum · Molten U235 · Molten U238 · Slag**(전부 용융 금속인데 전도 태그 없음).
- **유기물 후보 신호**(`combustible`/`flammable`/`petroleum`)도 Sulfur(비금속 원소)·Aluminum Powder(금속)를 오탐한다.
- 결정타는 **비금속 무기물**(Sand·Stone·Salt·Limestone·Glass·Cement·Concrete·Diamond·Saltpeter…)을 **긍정적으로 식별할 신호가 하나도 없다**는 것이다. "아무 태그도 없음 = 무기물"이라는 소거법밖에 안 남는데, 그러면 Water·Steam·Helium도 같이 떨어진다.
**→ 새 메타데이터가 필수다.** 두 방안:
| 방안 | 장점 | 단점 |
|---|---|---|
| **① 물질 파일마다 `kind` 한 줄**
(`Material.kind?: 'metal' \| 'organic' \| 'mineral' \| 'machine' \| 'life' \| 'energy'`) | "물질 추가 = 파일 하나" 규칙 유지. 물질을 아는 사람이 그 자리에서 판단 | **114개 파일을 건드려야** 하고, 새 물질마다 빠뜨릴 수 있다. `add-material-or-object` 스킬 체크리스트에 항목이 하나 늘어난다 |
| **② `categories.ts`에 id→kind 테이블** | 파일 126개를 안 건드리고 **한 파일에서 통째로 리뷰 가능**. 분류 일관성을 눈으로 검토하기 쉽다 | 새 물질이 테이블에서 **조용히 누락된다** — 이 프로젝트가 반복해서 데인 실패 유형 |
제안 근거 — 스킴 C
**(a)** 종류 필드 0. 자동 판별 불가(오탐 8종·누락 6종 이상).
**(b)** 재사용할 인프라는 **검증 방식**뿐이다 — `scripts/check-material-ids.mjs`(id 중복 검사, `npm run build` 첫 단계로 물려 있음)와 `test/radiation.ts`의 **커버리지 검사**(레지스트리를 훑어 선언 누락을 잡고 새 명단을 출력하는 방식)가 이미 선례다.
**(c)** **중간~높음.** 어느 방안이든 **커버리지 검증 스크립트를 짝지어야 한다** — 그게 이 프로젝트가 "조용한 실패"를 다루는 확립된 방식이다. 그리고 진짜 비용은 코드가 아니라 **판정 자체다**: 슬라임은 유기물인가 생명인가, 컨베이어는 기계인가 광물인가, 소금물은 무기물인가 용액인가. **판정 기준을 문서로 먼저 못박지 않으면 리뷰가 끝나지 않는다.** 세 스킴 중 착수 우선순위가 가장 낮다.
### 구현 지점
분류 로직이 **한 파일에 완전히 격리돼 있어** 이 작업이 값싸다. `buildCategories` 소비자는 **정확히 두 컴포넌트**뿐이다.
1. `categories.ts` — 분류 함수
`CATEGORY_META`를 **스킴별 배열**로 바꾼다(`SCHEMES: Record<Mode, {key, icon}[]>`). `categoryOf(m)` → `categoryOf(m, mode)`: `'theme'`은 `m.category ?? PHASE_FALLBACK[m.phase]`, `'state'`는 `PHASE_FALLBACK[m.phase]`, `'kind'`는 새 메타데이터. `buildCategories(materials)` → `buildCategories(materials, mode)` — **내부의 `orderedKeys` 계산이 `CATEGORY_META`를 직접 참조하므로 여기도 스킴을 받아야 한다.** `iconFor(key)`도 스킴별 조회로. ⚠️ **미등록 키는 `bi-tag-fill`로 조용히 폴백하므로 아이콘 누락이 에러를 내지 않는다** — 리뷰에서 놓치기 쉽다.
2. `store.ts` + `persistence.ts` — 상태 영속화
`store.ts`에 `$categoryMode` 아톰 추가(`$gridDivision`·`$snapshotFit`과 같은 패턴). `persistence.ts`는 **3곳 전부** 배선해야 한다: ① `hydrateSettings()`의 검증 복원, ② `saveSettings()`의 JSON 필드, ③ `initSettingsPersistence()`의 `.listen(schedule)` 목록. **세 곳 중 하나만 빠지면 조용히 저장되지 않는다.** `resetSettings()`에 포함할지도 결정할 것(즐겨찾기·최근은 "사용자 데이터"라 일부러 제외돼 있다 — 분류 기준은 설정 쪽에 가깝다).
3. `MaterialPalette.svelte` — 탭 생성
`categories` derived에 `$categoryMode` 의존성 추가 + 모드 전달. **검색 경로도 같은 모드를 써야 한다** — 검색 매칭이 `categoryLabel(categoryOf(m))`을 쓰므로, 여기만 놓치면 "가루"로 검색했을 때 탭과 다른 결과가 나온다. `aria-controls`가 `cat-btn-${index}`/`cat-flyout-${index}` **위치 기반**이라 스킴이 바뀌어도 충돌하진 않지만 재계산이 필요하다.
4. `MaterialPicker.svelte` — 블렌드 브러시 선택기
동일 모드 전달. `activeCat`이 `?? null` 폴백이라 모드 전환 시 자동으로 카테고리 목록으로 돌아가지만, **명시적 리셋이 낫다.**
5. `ControlPanel.svelte` + i18n — UI 토글
`fixedSettings()`의 "set-once settings" 블록에, **언어 전환·테두리 모드와 동일한 세그먼트 패턴**(`.field > .seg > button.ctl`)을 복제한다. 새 문자열은 `ui.en.ts`/`ui.ko.ts`의 `palette:` 섹션에 추가.
위험: 놓치면 "죽은 상태"가 되는 함정
플라이아웃의 `hovered`/`pinned`가 **카테고리 키 문자열**이다. 모드를 바꾸면 그 키가 사라져 `categories.find(c => c.key === open)`이 `undefined`가 되고, `{#if cat}`가 **조용히 아무것도 그리지 않는다.** → **버튼은 `active`인데 플라이아웃이 안 뜨는 죽은 상태.** 모드 변경 시 `pinned = null; hovered = null;` 리셋이 필수다(검색 진입 시 쓰는 `$effect`와 같은 패턴).
### ⚠️ 가장 큰 함정 — `category`는 순수 UI 힌트가 아니다
위험: 주석이 거짓말을 하고 있다
`src/game/materials/categories.ts` 헤더 주석과 `src/game/engine/types.ts`의 `category` 필드 주석은 **"The simulation ignores `category` — it's purely a UI grouping"**이라고 말한다. 시뮬레이션 루프가 안 읽는 건 맞지만, **시뮬레이션 밖 코드 5곳이 `category`를 의미론적 선택자로 읽는다.**
| 위치 | 무엇을 하는가 | 값을 바꾸면 |
|---|---|---|
| `src/game/render/materialSvg.ts` | `hasHazardMark(m) = m.category === 'radioactive'` | **방사능 삼엽 표식이 카테고리로 붙는다.** 우라늄 5종을 다른 탭으로 옮기면 팔레트 칩에서 표식이 사라진다 |
| `test/radiation.ts` | `filter(m => m.category === 'radioactive')` / `'life'`로 **커버리지 검사 대상 집합을 정의** | `npm run test:radiation` 실패 |
| `test/radiation.ts` | `m.radiationDeath !== undefined && m.category !== 'life'` | 동일 |
| `test/electricity.ts` | `m.directPulse && m.category === 'electric'` — 전기 장치 하네스 대상 선정 | `npm run test:electricity` 실패 |
| `test/materialicons.ts` | 표식이 `radioactive` 카테고리에 **정확히** 붙는지 양방향 검증 | 즉시 실패 |
성공: 그래서 런타임 모드 파라미터 방식이 옳다
**`categoryOf(m, mode)`로 가면 `m.category` 원본 필드가 그대로 남으므로 위 5곳이 전부 무사하다.**
반대로 `category` 값 자체를 재작성하는 방식(예: Sawdust를 `'powder'`로 변경)은 소비자를 하나씩 점검해야 한다. Sawdust·Coal·Wood 이동만으로는 위 5곳이 안 걸리지만, **`life`/`electric`/`radioactive` 탭 구성원을 건드리는 순간 검증 스크립트 3종이 깨진다.**
부수 효과로, 런타임 방식은 **현행 주제 분류를 기본값으로 보존**하므로 기존 사용자의 근육기억도 깨지 않는다. 안전성과 사용자 경험이 같은 방향을 가리키는 드문 경우다.
### 부수 정리 대상
이 작업 PR에서 함께 고쳐야 하는 문서 스테일 3건. 전부 **고분자(polymer) 추가가 반영되지 않은 것**이다.
- [ ] `docs/I18N.md` — 카테고리 키 목록에 **`polymer` 누락**(13개만 나열)
- [ ] `docs/FEATURES.md` — 카테고리 나열이 **12개**(고분자·방사성 누락)
- [ ] [[Web sandbox 프로젝트]] 소개문서 — **"13개 카테고리"**를 14개로, 이모지 목록에 🧬 고분자 추가
- [ ] `docs/MATERIALS.md` — 섹션 헤더가 카테고리 탭과 1:1이므로 스킴 추가 시 관계 재확인
- [ ] `.claude/skills/add-material-or-object/SKILL.md` — 카테고리 키 목록
**물질명 번역은 손댈 필요가 없다.** `materialNamesKo`가 **숫자 id 키**이고 `materialNamesEn`은 빈 객체(영어는 `Material.name`을 폴백으로 씀)라, **카테고리를 아무리 재편해도 물질명 테이블은 무관하다.** 즐겨찾기·최근사용(물질 id 배열)과 월드 세이브·스냅샷(셀은 물질 id로 저장), 블렌드 브러시 저장값(id+ratio)도 전부 무관하다.
---
## 접근성
### 잘 되어 있는 것
공정하게 먼저 적는다. 이 프로젝트의 접근성이 전면적으로 방치된 것은 아니다.
- **`Modal.svelte`의 포커스 트랩과 모달 스택** — `role="dialog"` + `aria-modal="true"`, 열 때 포커스 진입 / 닫을 때 원래 요소로 복귀, 모듈 레벨 `openModals[]` 스택으로 Escape가 최상위 1장만 닫는다. 다층 모달을 다루는 코드로서 정석이다.
- **`role="group"` / `radiogroup` + `aria-label`이 13개 컨트롤 묶음에 붙어 있다.** `aria-` 속성이 총 185개다.
- **`InspectPanel`의 `role="status" aria-live="polite"`** — 인게임 정보를 보조기술에 전달하려는 시도가 있다.
- **토글에 `aria-pressed`** 일관 적용.
- **`<html lang>`이 로케일에 동기화**되고, 하이드레이션 전에 `<head>` 인라인 스크립트가 값을 맞춘다.
- **`MaterialPicker`의 로빙 포커스** — 방향키/Home/End 완비. (문제는 자매 컴포넌트가 이걸 안 쓴다는 것뿐이다.)
- **물질 이름이 절대 잘리지 않는 정책** — 칩 라벨과 돋보기 행 모두 줄바꿈을 허용하고 말줄임을 쓰지 않는다.
- **`pointercancel` 안전 해제** — 제스처 강탈 시 스트로크가 매달리지 않는다.
### 고쳐야 할 것
| 항목 | 지금 상태 | 재사용 인프라 | 비용·리스크 |
|---|---|---|---|
| **viewport 핀치 확대 차단** [최우선] | `maximum-scale=1, user-scalable=no` — **핀치 확대가 원천 차단**된다(WCAG 1.4.4 위반). 저해상도 픽셀아트 칩과 10~12px 라벨을 쓰는 UI에서 실사용 장벽 | 캔버스가 확대되지 않아야 하는 이유는 `#game{touch-action:none}`으로 **이미 개별 보장**돼 있다 | **매우 낮음** — 메타 태그에서 두 토큰 제거. 리스크는 UI 크롬이 확대돼 레이아웃이 깨지는 것 → 실기 검증 필요 |
| **`prefers-reduced-motion` 미대응** | 0건. 대기 중 마키의 `rect-pulse` 무한 애니메이션이 항상 돈다 | 애니메이션 선언 지점이 적다 | 매우 낮음 |
| **`prefers-contrast` / `forced-colors` 미대응** | 각 0건 | CSS 변수 팔레트 | 낮음 |
| **캔버스가 완전히 접근 불가** | `<canvas id="game">`에 `role`·`aria-label`·`tabindex`·대체 콘텐츠 전무. **게임 본체를 키보드로 조작할 방법이 0** | 위 단축키 제안이 그대로 이 문제의 절반을 해결한다 | 낮음(`aria-label`+`tabindex`) ~ 높음(완전한 키보드 그리기) |
| **포커스 링을 지우고 border 색만 바꾸는 곳** | `MaterialPalette`·`SaveSlots`가 `outline:none` 후 다크 배경 위 `#2a2a33 → #6ea8fe` 테두리 변화로만 포커스를 알린다. `:focus-visible` 선언은 프로젝트 전체에 6곳뿐 | 전역 `:focus-visible` 규칙 한 벌 | 매우 낮음 / 시각 디자인 합의 필요 |
| **768px 초과 터치 태블릿이 데스크톱 취급** | `pointer:coarse` 분기 0건, JS 터치 감지 0건 | `(hover:none)` 분기가 SaveSlots에 **선례로 존재** | 중간 / **입력 방식 감지를 레이아웃 폭과 분리**하는 리팩터가 본체 |
| **영역 거부 팝오버가 스크린리더에 안 읽힘** | `role="tooltip"`이지만 `aria-live` 없음 + 2.6초 후 소멸 + `pointer-events:none` | 위 토스트 통합 제안 | 낮음 |
| **`aria-live` 무한 낭독** | `InspectPanel`이 `aria-live="polite"`인데 시뮬레이션이 도는 동안 프레임 단위로 내용이 바뀐다 → 물질이 흐르는 장면에서 **스크린리더가 끝없이 읽는다** | 이미 "직전 결과와 동일하면 스킵" 로직이 있음 | 낮음 / **좋은 의도가 역효과를 내는 사례** — 갱신 스로틀(1초) 또는 `aria-live="off"` + 명시적 낭독 버튼 |
| **라이트 테마 없음** | `color-scheme: dark` 하나, `prefers-color-scheme` 분기 0 | CSS 변수 | 중간 / **캔버스 자체가 다크 전제** — UI 크롬만 밝게 하는 절충 권장 |
---
## 색약 대응
`colorblind|deuteran|protan` 관련 코드 **0건**. 문제는 장식이 아니라 **기능 식별**에 있다.
### 도구 식별이 색 하나에 의존한다
브러시 커서와 마키 테두리가 **도구별 색으로만** 구분된다.
| 도구 | 색 | 혼동 대상 |
|---|---|---|
| 가열 | 주황 `rgba(255,138,60)` | **혼합(연두)** — 적록색약에서 근접 |
| 혼합 | 연두 `rgba(120,220,160)` | **가열(주황)** |
| 냉각 | 파랑 `rgba(96,176,255)` | **충격파(청록)** |
| 충격파 | 청록 `rgba(120,230,240)` | **냉각(파랑)** |
| 섞기 | 보라 `rgba(198,128,255)` | — |
| 전기 | 노랑 `rgba(255,235,130)` | 가열(주황)과 근접 소지 |
형태 차이는 지우개·보기의 `border-style: dashed` **2개뿐**이다. 나머지 6종은 형태가 동일하다. **가열과 냉각은 정반대 효과인데 인접 도구와 혼동되면 실험이 파괴적으로 실패한다** — 이건 재미 문제이기도 하다.
### 그 밖의 단일 색 신호
- **활성 상태 표시**가 `border-color:#6ea8fe` + `background:#23324a` 단일 신호다. 아이콘 변화나 체크 표시가 없다.
- **열지도 LUT가 청→적 램프**라 적록 구분에 불리하다.
- **플래시 메시지의 성공/실패**가 색 차이뿐이고 아이콘 구분이 없다.
- 126종 물질 칩은 색·패턴 SVG로 구분되지만 **라벨이 항상 함께 있어** 이 부분은 완화돼 있다.
### 제안 — 형태·아이콘 이중 부호화
제안 근거
**(a)** 색 외 부호화가 사실상 없다(dashed 2종). 색약 모드·고대비 대응 0건.
**(b)** 브러시 커서와 마키가 **이미 도구별로 `data-mode` 속성을 받아 색을 바꾸는 구조**다. 같은 속성에 테두리 패턴·코너 마커를 추가하는 것이라 새 상태 배선이 필요 없다. 활성 버튼도 클래스가 이미 붙는다.
**(c)** **낮음.** 리스크는 시각적 소음 — 커서에 마커가 많아지면 그림이 안 보인다. **테두리 패턴(실선/점선/파선/이중선/점쇄선)이 가장 값싸고 조용하다.**
**세부 제안**:
1. **커서/마키 테두리 패턴을 도구별로 다르게** — 색과 무관하게 6종이 구분된다. 특히 혼동쌍(가열↔혼합, 냉각↔충격파)을 서로 다른 패턴으로 갈라 둔다.
2. **커서 중앙에 도구 아이콘 미니 글리프** — 반투명 8px. 색이 안 보여도 무엇을 칠하는지 알 수 있다.
3. **활성 버튼에 체크 마크 또는 좌측 4px 인디케이터 바** — 색 + 형태 이중.
4. **열지도 램프 선택지 제공** — 청→적 외에 **명도가 단조 증가하는 램프**(inferno/viridis 계열)를 하나 더. 명도 단조성이 있으면 색각 유형과 무관하게 순서가 읽힌다. **③고증 축에도 유리하다** — 온도의 대소 관계가 흑백 인쇄에서도 보존된다.
5. **열지도에 등온선(isotherm) 옵션** — 특정 온도(0° / 100° / 660° / 1200°)에 얇은 선. 색약 대응이자 **동시에 재미 기능**이다(용융점 도달을 눈으로 본다).
6. **플래시/토스트에 아이콘 병기**(성공 체크 / 실패 경고).
---
## 우선순위 표
효과와 비용은 3단계([상]/[중]/[하]), 우선순위는 재미→편의성 순서로 매겼다.
| 항목 | 대상 | 효과 | 비용 | 우선순위 |
|---|---|---|---|---|
| 되돌리기/다시하기 | 양쪽 | 상 (재미 직결) | 중 | **1** |
| 내장 예제 빌드 | 양쪽 | 상 (재미) | 하 | **1** |
| 물질 정보 카드 1단계(물성만) | 양쪽 | 상 (재미) | 하~중 | **1** |
| 모바일 브러시 크기 직접 조작 | 모바일 | 상 | 하~중 | **1** |
| 게임플레이 단축키 세트 | 데스크톱 | 상 | 하 | **1** |
| viewport 핀치 확대 차단 해제 | 모바일 | 상 (접근성) | 하 | **1** |
| 커스텀 툴팁 시스템(89개 `title` 대체) | 양쪽 | 상 | 중 | **2** |
| 팔레트 분류 기준 설정 — 주제/상태 2스킴 | 양쪽 | 중~상 | 하~중 | **2** |
| 상시 HUD | 양쪽 | 중 | 하 | **2** |
| 사이드바 접기 | 데스크톱 | 중~상 (월드 +17%) | 하~중 | **2** |
| 일시정지 캔버스 표시 | 양쪽 | 중 | 하 | **2** |
| 롱프레스 = 더블클릭 대체 경로 | 모바일 | 중~상 | 하 | **2** |
| 스포이드 · 직선 그리기 | 양쪽 | 중 | 하 | **2** |
| 색약 — 커서 패턴·아이콘 이중 부호화 | 양쪽 | 중 (접근성) | 하 | **2** |
| 자주 쓰는 설정 하단바 승격 | 모바일 | 중~상 | 중 | **3** |
| 카테고리 아이콘에 라벨 | 모바일 | 중 | 하 | **3** |
| 빠른접근 9칸 전부 노출 | 모바일 | 중 | 하 | **3** |
| 팔레트 플라이아웃 로빙 포커스 | 데스크톱 | 중 (접근성) | 하 | **3** |
| 설정→하위 모달 복귀 | 양쪽 | 중 | 하 | **3** |
| 토스트 통합 + `aria-live` | 양쪽 | 중 | 하~중 | **3** |
| 멀티터치 두 번째 포인터 무시 | 모바일 | 중 | 하 | **3** |
| `prefers-reduced-motion` 등 3종 대응 | 양쪽 | 중 (접근성) | 하 | **3** |
| 캔버스 `aria-label` · `role` | 양쪽 | 중 (접근성) | 하 | **3** |
| 삭제 확인 2단계 arm 통일 | 양쪽 | 하 | 하 | **3** |
| 문서 스테일 3건 정리(polymer) | — | 하 | 하 | **3** |
| 영역 복사/붙여넣기 | 양쪽 | 상 | 중~상 | **4** |
| 소리(절차적 합성) | 양쪽 | 상 (재미, 불확실) | 중 | **4** |
| 카메라 줌/팬 | 양쪽 | 상 | **상** | **4** |
| 온보딩 코치마크 | 양쪽 | 중 | 하~중 | **4** |
| 라이트 테마 | 양쪽 | 중 | 중 | **4** |
| 터치 태블릿 분기(`pointer:coarse`) | 태블릿 | 중 | 중 | **4** |
| 팔레트 분류 — 종류(kind) 스킴 | 양쪽 | 중 | **상** | **5** |
| 대칭 그리기 · 플러드 필 | 양쪽 | 중 | 중 | **5** |
**착수 묶음 제안**: 1순위 6건 중 **① 내장 예제 빌드 ② 단축키 세트 ③ viewport 수정**은 서로 독립이고 각각 반나절 규모라 먼저 떼는 게 좋다. **툴팁 시스템 · 롱프레스 액션 · 모바일 브러시 위젯**은 같은 포인터 이벤트 기반을 공유하므로 한 PR로 묶는 편이 낫고, **카테고리 재분류**는 `categories.ts`·`store.ts`·`persistence.ts`·두 컴포넌트가 한 덩어리라 **단독 PR**로 가야 검증 스크립트 3종의 무사 여부를 깨끗하게 확인할 수 있다.
---
## 관련 문서
- [[Web sandbox 프로젝트/구상]] — 구상 인덱스(상위)
- [[Web sandbox 프로젝트/구상/새 물질 후보]]
- [[Web sandbox 프로젝트/구상/새 화학반응 공정 후보]]
- [[Web sandbox 프로젝트/구상/엔진 개선안]]
- [[Web sandbox 프로젝트/구상/엔진 최적화 방안]]
- [[Web sandbox 프로젝트/구상/시작 화면과 도감 페이지]] — 이 문서의 "인게임 툴팁" 3계층 중 별도 페이지 쪽
- [[Web sandbox 프로젝트]] — 소개문서 (카테고리 13개 → 14개 정정 대상)
- [[Web sandbox 프로젝트/가이드/물질]] — 물질 도감 가이드 (물질 정보 카드의 산문 후보 출처)