# AI 꾸러미 — 테마 갈아입히기 (공통 틀 + CSS 변수 옷) — Theming with CSS custom properties
> 화면 틀(HUD · 패널 · 단추)은 하나로 두고, 색 · 모서리 · 무늬를 CSS 변수로 빼서 data-game 값만 바꾸면 게임마다 다른 옷을 입힌다.  
> 견본: https://ai-techstudio.web.app/#t/u61

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 테마 갈아입히기 (공통 틀 + CSS 변수 옷) — Theming with CSS custom properties

#### 1. 목표
게임 HUD · 미션 패널 · 단추 줄에 「공통 틀 + 게임마다 테마 옷」 구조를 만들어 줘 — 틀의 HTML 은 하나, 색 · 모서리 · 무늬는 CSS 변수로, data-game 값만 바꾸면 얼음 · 나무 · 별빛 세 벌 옷으로 갈아입게.

#### 2. 핵심 기술 용어
- **Theming with CSS custom properties** — CSS 변수로 테마 바꾸기
- **Attribute selector scoping ([data-game=…])** — data-game 값으로 그 게임에만 걸리는 규칙
- **Lazy-loaded skin stylesheet (import.meta.glob)** — 그 게임을 열 때만 옷 CSS 내려받기
- **CSS transition** — 옷을 갈아입을 때 색이 스르르 바뀌기

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 옷 CSS 의 모든 규칙은 [data-game='게임 id'] 아래에만 (다른 게임에 새지 않게)
- 틀의 HTML · 동작(JS)은 고치지 않는다 — 색 · 질감 · 장식만
- 변수 이름은 역할로 (--pan 패널 바탕 · --edge 테두리 · --ink 글자 · --btn 단추), 색 이름으로 짓지 않는다
- 옷이 없는 게임은 기본 옷 그대로 보여야 한다

#### 5. 완성 기준 (이게 보이면 성공)
- data-game 값만 바꾸면 같은 HUD · 패널 · 단추가 얼음 · 나무 · 별빛 옷으로 바뀐다
- 바뀔 때 색 · 모서리가 0.6초 동안 부드럽게 넘어간다
- 옷 CSS 파일을 지워도 그 게임은 기본 파란 틀로 멀쩡히 열린다
- 개발자 도구 네트워크 탭에서 옷 CSS 가 그 게임을 열 때만 받아진다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 틀의 모든 색 · 테두리 · 모서리 반지름을 직접 쓰지 않고 var(--pan) · var(--edge) · var(--rad) 처럼 변수로 쓴다.
- 기본 옷은 틀 자체에, 게임 옷은 [data-game=warehouse] { --pan: …; --edge: … } 처럼 변수만 다시 정의한다.
- 변수로 안 되는 장식(눈 모자 · 나뭇결 줄 · 금빛 그림자)은 같은 선택자 아래 ::before · background-image 로 덧붙인다.
- 사이트는 옷 CSS 를 게임 폴더의 skin.css 로 두고, 그 게임을 처음 열 때만 import.meta.glob 으로 내려받는다.

## 핵심 코드 — 역할 변수 · 게임 옷 · 그 게임 열 때 옷 내려받기
(발췌: demos/demosDraw2d.ts u61 의 CSS 와 shell/gameSkin.ts loadSkin() 을 정리)
```ts
// ① 틀: 변수만 쓴다 (기본 옷)
const css = '.frame{--bg:#bfe6ff;--pan:#ffffffd9;--edge:#7cc4f0;--ink:#1d4a74;--btn:#4fb3ff;--btn2:#1e7fd0;--rad:4cqmin}' +
  '.frame *{transition:background .6s,border-color .6s,color .6s,border-radius .6s}' +
  '.frame .chip,.frame .side{background:var(--pan);border:.9cqmin solid var(--edge);color:var(--ink);border-radius:var(--rad)}' +
  '.frame .btn{background:linear-gradient(var(--btn),var(--btn2));border-radius:99px}' +
  // ② 게임 옷: 변수만 다시 정의 + 변수로 안 되는 장식
  ".frame[data-game=warehouse]{--pan:#e9c58f;--edge:#7a4a22;--ink:#4a2a10;--btn:#e08a3a;--btn2:#9a4f18;--rad:1.5cqmin}" +
  ".frame[data-game=warehouse] .side{background-image:repeating-linear-gradient(0deg,#0000 0 3cqmin,#7a4a2218 3cqmin 3.5cqmin)}" +
  ".frame[data-game=mirrorlab]{--pan:#141a45e6;--edge:#e8c25a;--ink:#ffe7a6;--btn:#3a4fa8;--btn2:#e8c25a;--rad:3cqmin}";

// ③ 옷 갈아입기 = 속성 하나
function wear(root: HTMLElement, gameId: string) {
  root.dataset['game'] = gameId;
}

// ④ 옷 CSS 는 게임 폴더에 — 그 게임을 처음 열 때만 내려받는다 (vite)
const skins = import.meta.glob('../game/games/*/skin.css');
export function loadSkin(gameId: string): Promise<unknown> {
  const load = skins['../game/games/' + gameId + '/skin.css'];
  return load ? load().catch(() => undefined) : Promise.resolve();   // 옷이 없으면 기본 옷
}
```

## 흔한 실수 · 확인 목록
- [ ] **옷 CSS 에 선택자 범위를 빼먹으면 다른 게임 화면까지 바뀐다** — 모든 규칙을 .game-overlay[data-game='id'] 아래에 쓴다. 옷을 연 뒤 다른 게임을 열어 확인한다.
- [ ] **틀에 색을 직접 쓴 곳이 남아 있으면 그 칸만 옷을 안 갈아입는다** — 틀 CSS 에서 색 · 반지름 값을 모두 변수로 바꾸고, 변수 없이 쓴 색이 없는지 찾아본다.
- [ ] **게임 폴더 이름과 게임 id 가 다르면 옷을 못 찾는다** — 이름이 다른 게임은 id → 폴더 표(FOLDER)를 한 줄 둔다 (창고지기 warehouse → sokoban).
- [ ] **배경 그림은 바꿨는데 틀은 그대로라 따로 놀아 보인다** — 배경에서 바탕색 · 테두리색 · 글자색을 뽑아 옷 변수로 맞춘다.

## 완성 기준 체크리스트
- [ ] data-game 값만 바꾸면 같은 HUD · 패널 · 단추가 얼음 · 나무 · 별빛 옷으로 바뀐다
- [ ] 바뀔 때 색 · 모서리가 0.6초 동안 부드럽게 넘어간다
- [ ] 옷 CSS 파일을 지워도 그 게임은 기본 파란 틀로 멀쩡히 열린다
- [ ] 개발자 도구 네트워크 탭에서 옷 CSS 가 그 게임을 열 때만 받아진다

## 이 기술 정보
- id: `u61` · 분류: 2D · 화면 › CSS · 화면 틀 · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — CSS 변수만 바꾸므로 거의 공짜. 옷 파일은 그 게임을 열 때 한 번만 받는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u61
- 쓰면 좋을 때: 게임이 많아도 틀 · 동작은 하나로 고치고 싶을 때 / 게임 배경과 틀 색이 따로 놀아 보일 때
- 쓰지 말 때: 게임마다 틀 HTML 을 복사해 고치기 — 하나를 고치면 수십 개를 고쳐야 한다 / 옷 CSS 가 data-game 선택자 없이 전역 규칙을 쓰기 — 다른 게임 화면까지 바뀐다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u61 견본 항목 — `src/demos/demosDraw2d.ts:1233`
```ts
  u61: {
    kind: 'dom',
    caption: '같은 공통 틀(HUD · 미션 패널 · 단추)에 data-game 만 바꾸면 CSS 변수로 얼음 · 나무 · 별빛 옷이 갈아입혀져요',
    make(box) {
      const SK = [
        ['icestar', '얼음 별'],
        ['warehouse', '창고지기'],
        ['mirrorlab', '천문대'],
      ] as const;
      const root = domRoot(
        box,
        'd2-skin',
        `.d2-skin{--bg:#bfe6ff;--bg2:#eaf7ff;--pan:#ffffffd9;--edge:#7cc4f0;--ink:#1d4a74;--btn:#4fb3ff;--btn2:#1e7fd0;--acc:#ffffff;--rad:4cqmin;
            background:linear-gradient(var(--bg2),var(--bg));transition:background .6s}
         .d2-skin[data-game=warehouse]{--bg:#7cc25a;--bg2:#a9dc7a;--pan:#e9c58f;--edge:#7a4a22;--ink:#4a2a10;--btn:#e08a3a;--btn2:#9a4f18;--acc:#ffe9b8;--rad:1.5cqmin}
         .d2-skin[data-game=mirrorlab]{--bg:#0b1033;--bg2:#2a2f6e;--pan:#141a45e6;--edge:#e8c25a;--ink:#ffe7a6;--btn:#3a4fa8;--btn2:#e8c25a;--acc:#ffe7a6;--rad:3cqmin}
         .d2-skin *{transition:background .6s,border-color .6s,color .6s,border-radius .6s,box-shadow .6s}
         .d2-skin .hud{position:absolute;left:3%;right:3%;top:4%;height:15%;display:flex;gap:2%;align-items:center}
         .d2-skin .chip{height:100%;display:flex;align-items:center;padding:0 3cqmin;border-radius:var(--rad);background:var(--pan);border:.9cqmin solid var(--edge);color:var(--ink);font-weight:900;font-size:6cqmin;position:relative}
         .d2-skin .chip.sc{margin-left:auto}
         .d2-skin .board{position:absolute;left:4%;top:25%;width:52%;bottom:22%;border-radius:var(--rad);background:var(--pan);border:.9cqmin solid var(--edge);display:grid;grid-template-columns:repeat(5,1fr);gap:1.2cqmin;padding:2cqmin}
         .d2-skin .board i{border-radius:calc(var(--rad)*.5);background:var(--edge);opacity:.35}
         .d2-skin .board i.on{background:var(--btn);opacity:1}
         .d2-skin .side{position:absolute;right:3%;top:25%;width:36%;bottom:22%;border-radius:var(--rad);background:var(--pan);border:.9cqmin solid var(--edge);color:var(--ink);padding:2.5cqmin 3cqmin;font-size:5cqmin;font-weight:700;position:absolute}
         .d2-skin .side b{display:block;font-size:6cqmin;margin-bottom:1.5cqmin}
         .d2-skin .side p{margin:.6em 0}
         .d2-skin .btns{position:absolute;left:0;right:0;bottom:3.5%;height:14%;display:flex;justify-content:center;gap:3cqmin}
         .d2-skin .btns span{aspect-ratio:1;height:100%;border-radius:50%;display:grid;place-items:center;font-size:7cqmin;font-weight:900;color:#fff;
            background:linear-gradient(var(--btn),var(--btn2));border:.8cqmin solid var(--acc);box-shadow:0 1.2cqmin 0 var(--btn2)}
         .d2-skin .btns span.big{aspect-ratio:auto;border-radius:99px;padding:0 5cqmin;font-size:6cqmin}
         .d2-skin[data-game=icestar] .chip::before,.d2-skin[data-game=icestar] .side::before{content:'';position:absolute;left:8%;right:8%;top:-2.2cqmin;height:3.5cqmin;border-radius:99px;background:#fff;box-shadow:0 .6cqmin 0 #d6eefc}
         .d2-skin[data-game=warehouse] .chip,.d2-skin[data-game=warehouse] .side,.d2-skin[data-game=warehouse] .board{background-image:repeating-linear-gradient(0deg,#0000 0 3cqmin,#7a4a2218 3cqmin 3.5cqmin)}
         .d2-skin[data-game=mirrorlab] .chip,.d2-skin[data-game=mirrorlab] .side,.d2-skin[data-game=mirrorlab] .board{box-shadow:0 0 3cqmin #e8c25a55, inset 0 0 0 .4cqmin #0b1033}
         .d2-skin .stars{position:absolute;inset:0;opacity:0;transition:opacity .6s;background:radial-gradient(1px 1px at 20% 30%,#fff,#0000),radial-gradient(1.5px 1.5px at 70% 12%,#fff,#0000),radial-gradient(1px 1px at 85% 80%,#fff,#0000),radial-gradient(1.5px 1.5px at 40% 90%,#fff,#0000),radial-gradient(1px 1px at 60% 55%,#fff,#0000)}
         .d2-skin[data-game=mirrorlab] .stars{opacity:1}
         .d2-skin .tabs{position:absolute;left:50%;top:4%;transform:translateX(-50%);display:flex;gap:1cqmin;height:9%}
         .d2-skin .tabs button{all:unset;cursor:pointer;padding:0 2.2cqmin;border-radius:99px;font-size:4.2cqmin;font-weight:800;background:rgba(0,0,0,.35);color:#fff;display:flex;align-items:center}
         .d2-skin .tabs button.on{background:#ff6fa8}`,
        `<div class="stars"></div>
         <div class="hud"><div class="chip">3단계</div><div class="chip sc">★ 120</div></div>
         <div class="tabs">${SK.map(([k, n]) => `<button data-k="${k}">${n}</button>`).join('')}</div>
         <div class="board">${Array.from({ length: 15 }, () => '<i></i>').join('')}</div>
         <div class="side"><b>미션</b><p>○ 3수 안에</p><p>○ 별 모두</p></div>
         <div class="btns"><span>↺</span><span class="big">힌트</span><span>▶</span></div>`,
      );
      const cells = qa(root, '.board i');
      const tabs = qa<HTMLButtonElement>(root, '.tabs button');
      let manualUntil = -1;
      let now = 0;
      let cur = '';
      const set = (k: string): void => {
        if (k === cur) return;
        cur = k;
        root.dataset['game'] = k;
        tabs.forEach((b) => b.classList.toggle('on', b.dataset['k'] === k));
      };
      tabs.forEach((b) =>
        b.addEventListener('click', () => {
          manualUntil = now + 8;
          set(b.dataset['k']!);
        }),
      );
      set('icestar');
      return {
        update(t) {
          now = t;
          if (t > manualUntil) set(SK[Math.floor(t / 2.4) % 3]![0]);
          const k = Math.floor(t * 3);
          cells.forEach((c, i) => c.classList.toggle('on', (i * 7 + k) % 15 < 4));
        },
      };
    },
  }
```

## 관련 기술
- 다음에 해 볼 기술: [CSS 무늬 (그러데이션 · 데이터 SVG)](https://ai-techstudio.web.app/ai/t/u62.md) `u62` · [마스크 · 섞기 · 잘라내기](https://ai-techstudio.web.app/ai/t/u63.md) `u63`
