diff --git a/README.md b/README.md
index e2cd101..e465dfc 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,55 @@
# crossword
-러시아 문학 가로세로 낱말풀이 — 정적 단일 페이지 사이트.
+문학 가로세로 낱말풀이 — 정적 사이트. 왼쪽 탭에서 편을 골라 본 프레임에서 바로 푼다.
- 공개 주소: https://crossword.plpark.ectech.co.kr
- 내부 주소: http://192.168.0.216:31380 (NodePort)
+## 수록 편
+
+| 탭 | 경로 | 격자 | 항목 |
+|---|---|---|---|
+| 러시아 문학 | `/russian.html` | 9 × 9 · 40칸 | 13 |
+| 영국 문학 | `/british.html` | 9 × 9 · 39칸 | 12 |
+
+각 페이지는 단독으로 열어도 동작한다. 셸(`/`)의 탭은 해시로 상태를 남기므로
+`/#russian`, `/#british` 로 바로 들어갈 수 있고, 탭을 다시 눌러도 풀던 내용은 유지된다.
+
## 구성
| 경로 | 설명 |
|---|---|
-| `site/index.html` | 게임 본체 (원본: `프로젝트/02_crossword/russian-lit-crossword.html`) |
+| `site/index.html` | 셸 — 왼쪽 탭(좁은 화면에서는 위쪽 탭) + 게임을 띄우는 iframe |
+| `site/russian.html` | 러시아 문학 편 (머리말 + 퍼즐 데이터) |
+| `site/british.html` | 영국 문학 편 (머리말 + 퍼즐 데이터) |
+| `site/assets/crossword.css` | 두 편이 공유하는 화면 양식 |
+| `site/assets/crossword.js` | 두 편이 공유하는 게임 엔진 (한글 IME 입력, 검사/힌트/타이머) |
| `nginx/default.conf` | nginx 설정 (8080 리슨, `/healthz` 헬스체크) |
| `Dockerfile` | nginx:1.27-alpine 정적 서빙 이미지 |
| `k8s/deployment.yaml` | Deployment + NodePort Service (31380) |
| `Jenkinsfile` | Checkout → 이미지 빌드/푸시 → kubectl apply |
-Spring Boot 등 런타임은 없음. HTML/CSS/JS 만으로 동작하는 완전 정적 페이지라
+Spring Boot 등 런타임은 없음. HTML/CSS/JS 만으로 동작하는 완전 정적 사이트라
nginx 로 파일만 내려주면 끝.
+## 편을 추가할 때
+
+1. `site/<편>.html` 을 기존 편 하나를 복사해 만든다. 고칠 곳은 `
`,
+ 머리말(`.eyebrow` / `h1`), 그리고 `const PUZZLE = {...}` 뿐이다.
+2. `site/index.html` 의 `PAGES` 객체와 `.nav` 링크에 한 줄씩 추가한다.
+
+`PUZZLE` 규약:
+
+- `size` — 격자 한 변 (9)
+- `grid[r][c]` — 정답 음절, 빈 칸은 `null`
+- `nums[r][c]` — 칸에 찍히는 번호, 없으면 `0`. 길이 2 이상 구간의 시작 칸에
+ 좌→우 · 위→아래 순서로 붙인다
+- `entries[]` — `{num, dir:"A"|"D", r, c, answer, clue, kind}`
+
+**가로·세로로 길이 2 이상 이어지는 모든 구간은 반드시 `entries` 에 단서와 함께
+등록돼 있어야 한다.** 우연히 생긴 두 글자 구간이 남아 있으면 그 칸을 채워도
+완료 판정이 나지 않는 것처럼 보이므로, 격자를 새로 짜면 이 조건을 먼저 확인한다.
+
## 배포
Jenkins 잡(`crossword`)을 돌리면 아래가 순서대로 진행된다.
@@ -42,4 +74,4 @@ Jenkins credentials 는 기존 loggen/pilot1 과 동일하게
## 콘텐츠 수정
-`site/index.html` 만 고치고 커밋 → Jenkins 빌드. 그 외 변경 필요 없음.
+`site/` 아래만 고치고 커밋 → Jenkins 빌드. 그 외 변경 필요 없음.
diff --git a/site/assets/crossword.css b/site/assets/crossword.css
new file mode 100644
index 0000000..5e5a2ee
--- /dev/null
+++ b/site/assets/crossword.css
@@ -0,0 +1,166 @@
+:root{
+ --ink:#12100E;
+ --panel:#1B1815;
+ --paper:#E7E1D3;
+ --paper-lit:#F3E3C8;
+ --amber:#E8B23C;
+ --vermilion:#C6362A;
+ --vermilion-dim:#8E2A21;
+ --slate:#8A8272;
+ --bone:#D8D2C4;
+ --cellfont:"Nanum Myeongjo","Apple SD Gothic Neo","Malgun Gothic",serif;
+ --ui:"Gothic A1","Apple SD Gothic Neo","Malgun Gothic","Noto Sans KR",sans-serif;
+ --display:"Black Han Sans","Gothic A1","Apple SD Gothic Neo",sans-serif;
+}
+*{box-sizing:border-box}
+html,body{margin:0;padding:0}
+body{
+ background:var(--ink);
+ color:var(--bone);
+ font-family:var(--ui);
+ font-weight:300;
+ -webkit-text-size-adjust:100%;
+ padding:0 0 64px;
+}
+.wrap{max-width:1120px;margin:0 auto;padding:28px 20px 0}
+
+/* ---------- 머리말 ---------- */
+.head{border-bottom:2px solid var(--vermilion);padding-bottom:14px;margin-bottom:26px;position:relative}
+.eyebrow{
+ font-size:11px;letter-spacing:.32em;color:var(--slate);
+ font-weight:700;text-transform:uppercase;margin-bottom:10px;
+}
+.head h1{
+ font-family:var(--display);
+ font-weight:400;
+ font-size:clamp(30px,7vw,54px);
+ line-height:.98;
+ margin:0;
+ color:var(--paper);
+ letter-spacing:-.01em;
+}
+.head h1 em{font-style:normal;color:var(--vermilion);display:block}
+.meta{
+ display:flex;gap:18px;align-items:baseline;
+ position:absolute;right:0;bottom:14px;
+}
+.meta b{font-family:var(--ui);font-weight:700;font-size:22px;color:var(--amber);font-variant-numeric:tabular-nums}
+.meta span{font-size:11px;letter-spacing:.18em;color:var(--slate);display:block;font-weight:700}
+@media (max-width:640px){
+ .meta{position:static;margin-top:14px}
+}
+
+/* ---------- 본문 배치 ---------- */
+.stage{display:grid;grid-template-columns:minmax(0,auto) minmax(280px,1fr);gap:40px;align-items:start}
+@media (max-width:900px){.stage{grid-template-columns:1fr;gap:26px}}
+
+/* ---------- 현재 단서 띠 ---------- */
+.cluebar{
+ display:flex;align-items:center;gap:12px;
+ background:var(--panel);border-left:5px solid var(--vermilion);
+ padding:11px 14px;margin-bottom:14px;min-height:56px;
+}
+.cb-num{
+ background:var(--vermilion);color:#fff;font-weight:900;font-size:14px;
+ min-width:26px;height:26px;display:grid;place-items:center;flex:none;
+}
+.cb-dir{font-size:11px;font-weight:700;letter-spacing:.2em;color:var(--amber);flex:none}
+.cb-text{font-size:15px;line-height:1.45;color:var(--paper);font-weight:300}
+
+/* ---------- 격자 ---------- */
+.board-frame{position:relative;width:min(94vw,468px)}
+.board{
+ display:grid;grid-template-columns:repeat(9,1fr);gap:2px;
+ background:#000;border:3px solid #000;
+ box-shadow:9px 9px 0 var(--vermilion-dim);
+ width:100%;
+}
+.cell{
+ position:relative;aspect-ratio:1/1;background:var(--paper);
+ display:grid;place-items:center;cursor:pointer;user-select:none;
+ transition:background .09s linear;
+}
+.cell.block{background:#0B0A08;cursor:default}
+.cell .n{
+ position:absolute;top:2px;left:4px;font-family:var(--ui);
+ font-size:clamp(8px,1.9vw,10px);font-weight:700;color:var(--vermilion);line-height:1;pointer-events:none;
+}
+.cell .ch{
+ font-family:var(--cellfont);font-weight:700;color:#14120F;
+ font-size:clamp(17px,4.4vw,27px);line-height:1;pointer-events:none;
+ transform:translateY(1px);
+}
+.cell.lit{background:var(--paper-lit)}
+.cell.cur{background:var(--amber)}
+.cell.given .ch{color:var(--vermilion)}
+.cell.bad::after{
+ content:"";position:absolute;inset:0;pointer-events:none;
+ background:
+ linear-gradient(to bottom left, transparent 46%, var(--vermilion) 46%, var(--vermilion) 54%, transparent 54%);
+ opacity:.85;
+}
+.board.solved .cell:not(.block){background:#EFE4C6}
+#ime{
+ position:absolute;pointer-events:none;opacity:0;
+ border:0;padding:0;margin:0;background:transparent;
+ color:transparent;caret-color:transparent;font-size:16px;
+ z-index:2;
+}
+
+/* ---------- 도구 ---------- */
+.tools{display:flex;flex-wrap:wrap;gap:7px;margin-top:22px}
+.tools button{
+ font-family:var(--ui);font-size:12px;font-weight:700;letter-spacing:.06em;
+ color:var(--bone);background:transparent;border:1px solid #3A352D;
+ padding:9px 13px;cursor:pointer;transition:.14s;
+}
+.tools button:hover{border-color:var(--amber);color:var(--amber)}
+.tools button.on{background:var(--vermilion);border-color:var(--vermilion);color:#fff}
+.tools button:focus-visible{outline:2px solid var(--amber);outline-offset:2px}
+.tip{font-size:12px;line-height:1.7;color:var(--slate);margin:16px 0 0;max-width:44ch}
+.tip kbd{
+ font-family:var(--ui);font-size:11px;font-weight:700;color:var(--bone);
+ border:1px solid #3A352D;padding:1px 5px;
+}
+
+/* ---------- 단서 목록 ---------- */
+.cluegroup+.cluegroup{margin-top:30px}
+.cluegroup h2{
+ font-family:var(--ui);font-size:11px;font-weight:900;letter-spacing:.3em;
+ color:var(--ink);background:var(--amber);display:inline-block;
+ padding:4px 11px;margin:0 0 12px;
+}
+.cluegroup ol{list-style:none;margin:0;padding:0}
+.cluegroup li{
+ display:grid;grid-template-columns:30px 1fr;gap:10px;align-items:baseline;
+ padding:8px 10px 8px 6px;cursor:pointer;border-left:3px solid transparent;
+ border-bottom:1px solid #26221D;
+}
+.cluegroup li b{font-weight:900;font-size:13px;color:var(--slate);text-align:right}
+.cluegroup li p{margin:0;font-size:13.5px;line-height:1.5;color:#BFB8A8;font-weight:300}
+.cluegroup li i{font-style:normal;color:var(--slate);font-size:11px;margin-left:5px}
+.cluegroup li:hover{background:#1B1815}
+.cluegroup li.cross{border-left-color:var(--vermilion-dim)}
+.cluegroup li.cross b{color:var(--bone)}
+.cluegroup li.on{background:var(--vermilion);border-left-color:var(--amber)}
+.cluegroup li.on b,.cluegroup li.on p,.cluegroup li.on i{color:#fff}
+.cluegroup li.filled b{color:var(--amber)}
+
+/* ---------- 완료 ---------- */
+.done{
+ position:fixed;inset:auto 0 0;background:var(--vermilion);color:#fff;
+ padding:16px 20px;display:flex;gap:14px;align-items:center;justify-content:center;
+ font-weight:700;font-size:15px;letter-spacing:.02em;z-index:9;
+ animation:rise .35s ease-out;
+}
+@keyframes rise{from{transform:translateY(100%)}to{transform:translateY(0)}}
+.done[hidden]{display:none}
+@media (prefers-reduced-motion:reduce){
+ *{animation:none !important;transition:none !important}
+}
+
+/* ---------- 셸(index.html) 프레임 안에서 ---------- */
+.framed body{padding-bottom:72px}
+.framed .wrap{padding-top:18px}
+.framed .head h1{font-size:clamp(26px,5.4vw,40px)}
+.framed .head{padding-bottom:12px;margin-bottom:20px}
diff --git a/site/assets/crossword.js b/site/assets/crossword.js
new file mode 100644
index 0000000..f3cf3ac
--- /dev/null
+++ b/site/assets/crossword.js
@@ -0,0 +1,344 @@
+(function(){
+"use strict";
+
+/* 셸(index.html) 안에서 열렸는지 표시 - 여백만 살짝 줄인다 */
+if (window.self !== window.top) document.documentElement.classList.add('framed');
+
+const N = PUZZLE.size;
+const SOL = PUZZLE.grid;
+const NUMS = PUZZLE.nums;
+
+const board = document.getElementById('board');
+const ime = document.getElementById('ime');
+const listA = document.getElementById('listA');
+const listD = document.getElementById('listD');
+const cbNum = document.getElementById('cbNum');
+const cbDir = document.getElementById('cbDir');
+const cbText = document.getElementById('cbText');
+const elTimer = document.getElementById('timer');
+const elProg = document.getElementById('progress');
+const doneBar = document.getElementById('done');
+const doneMsg = document.getElementById('doneMsg');
+
+const idx = (r,c) => r*N + c;
+const isOpen = (r,c) => r>=0 && r=0 && c {
+ const cells = [];
+ for (let k=0; k a.num - b.num || (a.dir === 'A' ? -1 : 1));
+ENTRIES.forEach((e,i) => e.i = i);
+
+const owner = []; // owner[idx] = {A:entry|null, D:entry|null}
+for (let i=0;i e.cells.forEach(([r,c]) => { owner[idx(r,c)][e.dir] = e; }));
+
+const TOTAL = SOL.flat().filter(v => v !== null).length;
+
+/* ---------- 상태 ---------- */
+const user = new Array(N*N).fill('');
+const given = new Set(); // 힌트/정답으로 채워진 칸
+let dir = 'A';
+let cur = ENTRIES[0].cells[0].slice();
+let session = null; // {cells, written}
+let composing = false;
+let checking = false;
+let solved = false;
+let t0 = null, tick = null;
+let booted = false; // 첫 렌더 뒤에만 단서 목록을 따라 스크롤
+
+/* ---------- 격자 그리기 ---------- */
+const cellEls = new Array(N*N).fill(null);
+for (let r=0;r';
+ const ch = document.createElement('span');
+ ch.className = 'ch';
+ d.appendChild(ch);
+ cellEls[idx(r,c)] = d;
+ }
+ board.appendChild(d);
+ }
+}
+
+/* ---------- 단서 목록 ---------- */
+function buildList(ol, d){
+ ENTRIES.filter(e => e.dir === d).forEach(e => {
+ const li = document.createElement('li');
+ li.dataset.i = e.i;
+ li.innerHTML = '' + e.num + '