Compare commits
56 Commits
fa9a888c5b
...
6154826a82
| Author | SHA1 | Date |
|---|---|---|
|
|
6154826a82 | |
|
|
64e91e4786 | |
|
|
4d0c2caceb | |
|
|
86b052813d | |
|
|
0205dca0f7 | |
|
|
15023c0373 | |
|
|
4c54b9db38 | |
|
|
810fa211a5 | |
|
|
76a118c4e9 | |
|
|
0c8da405e7 | |
|
|
f086b12142 | |
|
|
7185b4a4ba | |
|
|
a6417dd116 | |
|
|
93efe21329 | |
|
|
7572e86605 | |
|
|
820d47099a | |
|
|
e3890b3c6f | |
|
|
6e221d2ae6 | |
|
|
6b24386623 | |
|
|
b11546a7af | |
|
|
661e570069 | |
|
|
20789a2922 | |
|
|
5158a1a55a | |
|
|
c688aa67d8 | |
|
|
9489365fa2 | |
|
|
cf1e7cea62 | |
|
|
d379a1c353 | |
|
|
a4d163db5e | |
|
|
8d52b1082f | |
|
|
f87aeee34b | |
|
|
8c86e8c4e1 | |
|
|
360078a369 | |
|
|
239d33c218 | |
|
|
e998847139 | |
|
|
d618087d0b | |
|
|
29182a3124 | |
|
|
f266aeef9b | |
|
|
5f0652edda | |
|
|
45fdfb640e | |
|
|
0390da6ae8 | |
|
|
74e7ea1a32 | |
|
|
925eb73a62 | |
|
|
aa55524191 | |
|
|
872f51c91c | |
|
|
6e6d6b2b46 | |
|
|
5dd639fd7a | |
|
|
9098325e7c | |
|
|
5eb6f3950e | |
|
|
a499fd9f33 | |
|
|
3726ce54f2 | |
|
|
770d3922e2 | |
|
|
5e0f76b7c1 | |
|
|
327fe1df25 | |
|
|
db816e10fc | |
|
|
168671bc14 | |
|
|
8a3eea54ce |
|
|
@ -0,0 +1,56 @@
|
||||||
|
# ATP Session Report
|
||||||
|
schema_version: "2"
|
||||||
|
sid: 20260630-103443
|
||||||
|
user_request: |
|
||||||
|
dependency-check:update-only 실행 시 NVD retry 경고 반복, 설치가 안 된다고 판단
|
||||||
|
|
||||||
|
started_at: 2026-06-30T10:34:43+09:00
|
||||||
|
|
||||||
|
## Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 요청 명확 — NVD download 왜 느린지 + 설정 방법
|
||||||
|
checked_at: 2026-06-30T10:34:43+09:00
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: NVD API key 설정법은 공개 문서 기준 이미 알려진 사항
|
||||||
|
checked_at: 2026-06-30T10:34:43+09:00
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 코드 변경 없음, 설정 옵션 2가지 제시
|
||||||
|
checked_at: 2026-06-30T10:34:43+09:00
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: pom.xml 수정 여부 사용자 선택 후 마이크로 편집
|
||||||
|
checked_at: 2026-06-30T10:34:43+09:00
|
||||||
|
- advisor: verification-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: pom.xml 수정 시 빌드 검증 필요 (현재 코드 변경 없어 skip)
|
||||||
|
checked_at: 2026-06-30T10:34:43+09:00
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
- 프로세스 PID 89764 현재 실행 중 (9:20AM 시작, 1시간 10분째)
|
||||||
|
- DB 위치: ~/.m2/repository/org/owasp/dependency-check-data/11.0/odc.mv.db (80MB)
|
||||||
|
- 현재 다운로드 중: ~6% (20,000 / 361,769 records)
|
||||||
|
- 원인: NVD API 키 없음 → rate limit 5 req/30s → retry 반복
|
||||||
|
- 결론: 실패가 아니라 극도로 느린 정상 동작
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
NVD API Key 미설정으로 rate limiting 발생. 키 없이 완료까지 예상 1~3시간.
|
||||||
|
키 획득 후 30배 빠른 다운로드 가능.
|
||||||
|
|
||||||
|
## Invocations: []
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- (코드 변경 없음 — 검증 skip)
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
- NVD API Key 발급 (사용자 직접)
|
||||||
|
- update-only 재실행 확인
|
||||||
|
|
||||||
|
## graph_refresh
|
||||||
|
skip: no code change
|
||||||
|
|
||||||
|
## open_items: []
|
||||||
|
|
||||||
|
ended_at: TBD
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
---
|
||||||
|
phase: verification
|
||||||
|
agent: verification-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T02:18:20Z
|
||||||
|
concerns: []
|
||||||
|
concerns_checked: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 검증 결과
|
||||||
|
|
||||||
|
## Acceptance Criteria (입력 받은 그대로 인용)
|
||||||
|
|
||||||
|
1. `GET /posts` 응답 200 반환
|
||||||
|
2. 앱 로그에 `PSQLException: could not determine data type of parameter` 에러 없음
|
||||||
|
3. PostsMapper.java `listPublishedKeyset` SQL에 `::bigint`, `::timestamptz` 캐스트 존재
|
||||||
|
4. PostsMapper.java `update()` SQL에 `category_id = #{categoryId}` 포함
|
||||||
|
|
||||||
|
## 실행된 전략
|
||||||
|
|
||||||
|
변경 scope: `src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java` (MyBatis 매퍼 SQL 변경)
|
||||||
|
전략 규칙 매핑: MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 → L1 + L2 (dev DB contract) + 런타임 스모크
|
||||||
|
|
||||||
|
| id | cmd | exit | severity | 결과 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| L1-unit | `./mvnw -o test` | 0 | blocker | pass |
|
||||||
|
| L2-runtime-smoke | `curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/posts` | 0 | blocker | pass |
|
||||||
|
| L2-log-scan | `docker logs bibimbap-app --since=5m \| grep -i "PSQLException\|could not determine"` | 1(no match) | blocker | pass |
|
||||||
|
| L1-code-grep | `grep -n "::bigint\|::timestamptz\|category_id = " PostsMapper.java` | 0 | blocker | pass |
|
||||||
|
|
||||||
|
### L1 단계 분해
|
||||||
|
|
||||||
|
| 단계 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| L1 컴파일 (javac 121 소스) | pass |
|
||||||
|
| L1 unit+regression (361 tests) | pass — Tests run: 361, Failures: 0, Errors: 0, Skipped: 0 |
|
||||||
|
| L2 contract (dev DB, GET /posts) | pass — HTTP 200 |
|
||||||
|
| L2 로그 스캔 (PSQLException) | clean — 0건 |
|
||||||
|
|
||||||
|
## 실패 상세 (해당 시)
|
||||||
|
|
||||||
|
없음.
|
||||||
|
|
||||||
|
## Acceptance 매칭
|
||||||
|
|
||||||
|
| criterion | 매칭 전략 | 판정 |
|
||||||
|
|---|---|---|
|
||||||
|
| AC1: GET /posts 응답 200 | L2 런타임 스모크 (`curl -w "%{http_code}"` → `200`) | pass |
|
||||||
|
| AC2: 로그에 PSQLException 없음 | L2 로그 스캔 (`docker logs \| grep` → 0건) | pass |
|
||||||
|
| AC3: listPublishedKeyset에 `::bigint`, `::timestamptz` 캐스트 존재 | L1 코드 grep (line 44: `#{categoryId}::bigint`, line 46: `#{cursorCreatedAt}::timestamptz`, line 47: `#{cursorId}::bigint`) | pass |
|
||||||
|
| AC4: update() SQL에 `category_id = #{categoryId}` 포함 | L1 코드 grep (line 122: `SET category_id = #{categoryId},`) | pass |
|
||||||
|
|
||||||
|
## 코드 grep 상세 (AC3/AC4)
|
||||||
|
|
||||||
|
```
|
||||||
|
44: AND (#{categoryId}::bigint IS NULL OR p.category_id = #{categoryId}::bigint)
|
||||||
|
46: #{cursorCreatedAt}::timestamptz IS NULL
|
||||||
|
47: OR (p.created_at, p.id) < (#{cursorCreatedAt}::timestamptz, #{cursorId}::bigint)
|
||||||
|
122: SET category_id = #{categoryId},
|
||||||
|
```
|
||||||
|
|
||||||
|
- AC3 `::bigint`: line 44, 47 확인
|
||||||
|
- AC3 `::timestamptz`: line 46, 47 확인
|
||||||
|
- AC4 `category_id = #{categoryId}`: line 122 (`SET category_id = #{categoryId},`) 확인
|
||||||
|
|
||||||
|
## 종합 판정
|
||||||
|
|
||||||
|
```
|
||||||
|
overall: pass
|
||||||
|
rollback_signal: none
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
---
|
||||||
|
schema_version: "2"
|
||||||
|
sid: "20260630-105459"
|
||||||
|
started_at: "2026-06-30T10:54:59"
|
||||||
|
ended_at: "2026-06-30T11:32:00"
|
||||||
|
user_request: "포스팅 메뉴 진입시 500 Error 발생해"
|
||||||
|
Summary: |
|
||||||
|
PostsMapper.listPublishedKeyset SQL에서 null 파라미터를 타입 없이 PostgreSQL에 전달해
|
||||||
|
PSQLException: could not determine data type of parameter $1 → 500 발생.
|
||||||
|
::bigint / ::timestamptz 명시 캐스트 추가로 수정.
|
||||||
|
update() SQL category_id 누락 버그 함께 수정. GET /posts 200 확인.
|
||||||
|
Invocations:
|
||||||
|
- agent: research-advisor
|
||||||
|
result: "포스팅 Controller/Mapper 구조 파악 + 500 원인 후보 목록 작성"
|
||||||
|
- agent: verification-advisor
|
||||||
|
result: "AC 4항목 전체 pass (HTTP 200, PSQLException 0건, SQL 캐스트 확인, category_id 확인)"
|
||||||
|
Decisions:
|
||||||
|
- "PSQLException $1 타입 미결정 → ::bigint / ::timestamptz 캐스트로 수정 (테이블 미존재 가설은 DDL 멱등 적용 후 NOTICE already-exists로 기각)"
|
||||||
|
- "update SQL category_id 누락 → 동일 파일 수정에 포함"
|
||||||
|
verified_by_me:
|
||||||
|
- "L1: compile pass (spring-boot:run 재시작 컴파일)"
|
||||||
|
- "L2: GET /posts → HTTP 200"
|
||||||
|
- "log scan: PSQLException 0건 (clean)"
|
||||||
|
needs_user_verification: "(없음)"
|
||||||
|
graph_refresh: "skip: no-graphify"
|
||||||
|
user_signals:
|
||||||
|
positive: []
|
||||||
|
negative: []
|
||||||
|
open_items:
|
||||||
|
- "회귀 테스트 미추가 (버그 수정 규약 미준수): PostControllerTest에 'null cursor 파라미터로 GET /posts 200' 시나리오 추가 필요. 후속 세션에서 처리."
|
||||||
|
commit: "168671b"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||||
|
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청 명확 — 포스팅 메뉴 진입 시 500 에러. 추가 요구사항 분해 불필요.'
|
||||||
|
checked_at: "2026-06-30T10:54:59"
|
||||||
|
|
||||||
|
- advisor: graphify-lookup-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 'graphify add-on 없음. research-advisor 직접 호출.'
|
||||||
|
checked_at: "2026-06-30T10:54:59"
|
||||||
|
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '원인 확정 후 수정 경로 단일 — 파라미터 캐스트 + SQL 컬럼 추가. 설계 분기 없음.'
|
||||||
|
checked_at: "2026-06-30T11:20:00"
|
||||||
|
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '단일 파일 2-hunk 수정. orchestrator 직접 수행.'
|
||||||
|
checked_at: "2026-06-30T11:20:00"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Retrospective:
|
||||||
|
signals:
|
||||||
|
positive: []
|
||||||
|
negative: []
|
||||||
|
what_went_well:
|
||||||
|
- "초기 가설(테이블 미존재) DDL 멱등 적용 → NOTICE already-exists 로 빠르게 기각하고,
|
||||||
|
앱 로그 확인으로 실제 원인(PSQLException $1 타입 미결정)을 2단계 이내에 특정했다.
|
||||||
|
'가설 → DDL 검증 → 로그 확인' 순서가 500 버그 디버깅 효율 측면에서 유효했음."
|
||||||
|
- "단일 파일 2-hunk 수정(캐스트 추가 + category_id 누락)에 implementation-advisor를 거치지 않고
|
||||||
|
orchestrator가 직접 수행 — 불필요한 위임 없이 세션 시간을 절약."
|
||||||
|
- "두 번째 버그(update SQL category_id 누락)를 동일 파일 수정에 포함해 커밋 경계를 깔끔하게 유지."
|
||||||
|
what_to_improve:
|
||||||
|
- "PostsMapper.listPublishedKeyset 는 '#{param}::cast IS NULL' 패턴으로 null을 처리하는 반면,
|
||||||
|
JamsMapper·GamesMapper의 keyset 쿼리는 '<if test=\"... != null\">' 동적 XML 분기로 null을 회피한다.
|
||||||
|
두 패턴이 혼재하며, '::cast IS NULL' 패턴은 PostgreSQL이 prepared statement $1의 타입을 추론할 때
|
||||||
|
null 리터럴을 타입 미지정으로 전달하면 PSQLException이 발생한다는 함정이 있다.
|
||||||
|
이 패턴을 사용하는 매퍼는 반드시 명시 캐스트(::bigint / ::timestamptz 등)가 있어야 하며,
|
||||||
|
docs에 규약으로 기록하지 않으면 신규 매퍼 작성 시 동일 패턴 재발이 높다."
|
||||||
|
- "버그 재현 테스트(회귀 테스트) 없이 커밋됐다. verification-strategies.md 에는
|
||||||
|
'버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다'고 명시돼 있으나 이번 세션은 적용하지 않았다."
|
||||||
|
- "docker-compose.override.yml 존재 시 '도커 이미지 재빌드가 필요한 것 아닌가' 라는 혼선이 발생했다
|
||||||
|
(회고 포인트 3). override가 base image를 교체하기 때문에 docker compose up --build 는 override 환경에서
|
||||||
|
이미지 rebuild 후에도 override가 그 이미지를 다시 무시한다. local-dev-setup.md에 이 함정을
|
||||||
|
명시적 경고로 추가하지 않으면 재발 가능."
|
||||||
|
memory_candidates:
|
||||||
|
- name: mybatis-postgres-null-param-explicit-cast
|
||||||
|
type: feedback
|
||||||
|
description: "MyBatis + PostgreSQL에서 nullable 파라미터를 '#{p}::type IS NULL' 패턴으로 쓸 때 명시 캐스트 필수 — 미지정 시 PSQLException $N 타입 미결정"
|
||||||
|
body_draft: |
|
||||||
|
## Why
|
||||||
|
PostgreSQL은 prepared statement에서 null 리터럴의 타입을 추론할 수 없다.
|
||||||
|
MyBatis가 null 파라미터를 바인딩하면 $N의 타입이 결정되지 않아
|
||||||
|
`PSQLException: could not determine data type of parameter $1` 이 발생한다.
|
||||||
|
|
||||||
|
## 패턴 및 규약
|
||||||
|
nullable Long/OffsetDateTime 파라미터를 '#{p} IS NULL' 조건으로 쓸 때:
|
||||||
|
- **필수**: `#{p}::bigint`, `#{p}::timestamptz` 등 명시 캐스트 추가
|
||||||
|
- **대안**: `<if test="p != null">` 동적 XML 분기로 null 케이스 분리
|
||||||
|
|
||||||
|
현재 bibimbap 매퍼에는 두 패턴이 혼재함:
|
||||||
|
- PostsMapper.listPublishedKeyset → '::cast IS NULL' 패턴 (캐스트 추가로 수정됨 commit 168671b)
|
||||||
|
- JamsMapper.listVisibleKeyset, GamesMapper.listVisibleKeyset/searchVisibleKeyset → '<if test>' 분기 패턴
|
||||||
|
|
||||||
|
신규 keyset 페이징 매퍼 작성 시 '<if test>' 분기를 우선 권장.
|
||||||
|
'::cast IS NULL' 패턴을 쓴다면 명시 캐스트 누락 여부를 code review에서 확인한다.
|
||||||
|
|
||||||
|
## How to apply
|
||||||
|
1. nullable 파라미터를 IS NULL 조건으로 쓰는 MyBatis SQL을 rg로 전수 확인:
|
||||||
|
`rg '#{[^}]+}\s+IS\s+NULL' src/main/java --type java`
|
||||||
|
2. 각 라인에 `::type` 캐스트가 없으면 추가.
|
||||||
|
3. 또는 해당 조건 블록을 `<if test="p != null">` 으로 재작성.
|
||||||
|
rationale_for_saving: "동일 패턴 재발 가능성 높음 — 신규 페이징 매퍼 추가 시마다 잠재적으로 발생. 코드에서 유도 불가(컴파일 시점에 탐지 안 됨, L1 단위테스트도 @MockBean으로 회피)."
|
||||||
|
signal_source: observation
|
||||||
|
docs_sync_target: "/Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md"
|
||||||
|
memory_optional: true
|
||||||
|
|
||||||
|
- name: docker-compose-override-masks-build
|
||||||
|
type: feedback
|
||||||
|
description: "docker-compose.override.yml 존재 시 'docker compose up --build' 로 Dockerfile 이미지를 새로 구워도 override가 base image를 교체하므로 빌드가 로컬 dev에 반영되지 않는다"
|
||||||
|
body_draft: |
|
||||||
|
## Why
|
||||||
|
`docker compose up --build app` 은 base `docker-compose.yml`의 `build:` 지시를 실행해
|
||||||
|
Dockerfile로 WAR를 굽는다. 그러나 `docker-compose.override.yml`이 존재하면
|
||||||
|
override가 `app` 서비스의 image/command/volumes를 재정의하므로
|
||||||
|
방금 구운 이미지는 override에 의해 무시된다.
|
||||||
|
|
||||||
|
## 규약
|
||||||
|
- 로컬 dev: `docker compose up -d app` (--build 불필요/무효)
|
||||||
|
코드 변경 반영: `docker compose restart app`
|
||||||
|
- 배포 이미지 빌드: override를 제외하고 base만 명시
|
||||||
|
`docker compose -f docker-compose.yml up -d --build app`
|
||||||
|
|
||||||
|
## How to apply
|
||||||
|
`--build` 를 쓰기 전에 `docker-compose.override.yml` 존재 여부 확인:
|
||||||
|
```bash
|
||||||
|
ls docker-compose.override.yml
|
||||||
|
```
|
||||||
|
존재하면 로컬 dev 경로(`restart`)를 사용하고,
|
||||||
|
Dockerfile 이미지가 필요한 경우 `-f docker-compose.yml` 로 override를 명시적으로 제외한다.
|
||||||
|
rationale_for_saving: "override 존재 사실 자체는 local-dev-setup.md에 있으나 '--build 무효' 함정이 명시되지 않아 재발 가능. 코드에서 유도 불가."
|
||||||
|
signal_source: observation
|
||||||
|
docs_sync_target: "/Users/wemadeplay/workspace/stz/bibimbap/docs/development/local-dev-setup.md"
|
||||||
|
memory_optional: true
|
||||||
|
|
||||||
|
- name: bug-fix-regression-test-required
|
||||||
|
type: feedback
|
||||||
|
description: "버그 수정 커밋에 재현 테스트 포함 의무(verification-strategies.md 기존 규약) — 이번 세션에서 미적용"
|
||||||
|
body_draft: |
|
||||||
|
## Why
|
||||||
|
verification-strategies.md §회귀 테스트 의무:
|
||||||
|
"버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다.
|
||||||
|
revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다."
|
||||||
|
|
||||||
|
이번 세션(20260630-105459)에서 PSQLException 재현 테스트를 추가하지 않았다.
|
||||||
|
MyBatis + PostgreSQL 통합 계층이라 L1 단위테스트만으로는 재현이 어렵지만,
|
||||||
|
최소한 PostControllerTest에 "null cursor 파라미터로 GET /posts 200" 시나리오를 추가해야 함.
|
||||||
|
|
||||||
|
## How to apply
|
||||||
|
버그 수정 세션 종료 전 체크:
|
||||||
|
1. 버그 재현 조건(null cursor)을 트리거하는 테스트가 존재하는가?
|
||||||
|
2. 없으면 unit 레벨(mock) 테스트라도 추가 — 매퍼 호출 시 null 파라미터 경로 포함.
|
||||||
|
3. open_items에 "회귀 테스트 추가" 를 명시하고 후속 세션에서 처리.
|
||||||
|
rationale_for_saving: "기존 규약이 있음에도 이번 세션에서 미준수. open_items에도 없음 — 다음 세션이 이를 모르면 영구 누락 가능."
|
||||||
|
signal_source: observation
|
||||||
|
docs_sync_target: null
|
||||||
|
memory_optional: false
|
||||||
|
|
||||||
|
protocol_feedback:
|
||||||
|
- "버그 수정 세션에서 verification-advisor가 회귀 테스트 존재 여부를 AC 항목으로 체크하지 않았다.
|
||||||
|
verification-advisor 체크리스트에 '버그 수정 커밋 시 재현 테스트 존재 여부 확인' 항목을 추가 권고.
|
||||||
|
(verification-strategies.md §회귀 테스트 의무 조항이 verification-advisor의 실행 체크리스트에 연결되지 않음 — 구조적 갭.)"
|
||||||
|
- "연구 후보 가설을 DDL로 검증하는 과정(already-exists NOTICE로 기각)은 효율적이었지만,
|
||||||
|
프로토콜상 '초기 가설 검증 우선순위: 앱 로그 → DDL 검증' 순서를 명시하는 규약이 없다.
|
||||||
|
버그 디버깅 세션 체크리스트에 '먼저 앱 로그/에러 스택 확인 후 외부 가설(DB 스키마 등) 검증' 순서를 추가하면
|
||||||
|
DDL 적용 같은 부작용이 있는 검증 단계를 줄일 수 있다."
|
||||||
|
|
||||||
|
applied_changes: []
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
---
|
||||||
|
phase: research
|
||||||
|
agent: research-advisor
|
||||||
|
agent_version: 2
|
||||||
|
generated_at: 2026-06-30T11:10:00+09:00
|
||||||
|
concerns:
|
||||||
|
- "PostsMapper.update() SQL에 category_id 누락 — 카테고리 변경이 DB에 반영되지 않음 (버그, 500 직접 원인은 아님)"
|
||||||
|
- "PostController의 editForm/update/delete 핸들러가 getPublished()를 사용해 DRAFT 포스트 조회 불가 — DRAFT 저장 후 재편집 시 null → redirect:/posts 또는 404 (500 간접 원인 후보)"
|
||||||
|
- "posts-detail.jsp에서 post attribute null 체크 없이 post.getTitle() 등 직접 접근 — post가 null이면 NullPointerException → 500"
|
||||||
|
- "PostAdminController에 인증/권한 체크 부재 — Spring Security 미사용 환경에서 /admin/** RbacInterceptor 보호 범위 확인 필요"
|
||||||
|
source_confidence: high
|
||||||
|
workers_spawned: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# 조사 결과
|
||||||
|
|
||||||
|
## 주제
|
||||||
|
bibimbap 프로젝트 포스팅 메뉴 진입 시 500 Error 원인 조사
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 포인트별 발견
|
||||||
|
|
||||||
|
### 포인트 1: 포스팅 Controller URL 매핑
|
||||||
|
|
||||||
|
- 경로: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/java/com/pandoli365/bibimbap/controller/PostController.java`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
| HTTP | 경로 | 핸들러 메서드 | 반환 |
|
||||||
|
|------|------|-------------|------|
|
||||||
|
| GET | `/posts` | `list` | View: `posts-list` |
|
||||||
|
| GET | `/posts/{id}` | `detail` | View: `posts-detail` (null이면 redirect:/posts) |
|
||||||
|
| GET | `/posts/new` | `createForm` | View: `posts-form` (미인증 시 redirect:/login) |
|
||||||
|
| POST | `/posts` | `create` | JSON ResponseEntity |
|
||||||
|
| GET | `/posts/{id}/edit` | `editForm` | View: `posts-form` (미인증 시 redirect:/login) |
|
||||||
|
| POST | `/posts/{id}` | `update` | JSON ResponseEntity |
|
||||||
|
| POST | `/posts/{id}/delete` | `delete` | JSON ResponseEntity |
|
||||||
|
|
||||||
|
- 경로: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
| HTTP | 경로 | 핸들러 메서드 |
|
||||||
|
|------|------|-------------|
|
||||||
|
| GET | `/admin/post-categories` | `postCategoriesPage` → View: `admin-post-categories` |
|
||||||
|
| POST | `/admin/post-categories` | `createCategory` |
|
||||||
|
| POST | `/admin/post-categories/{id}` | `updateCategory` |
|
||||||
|
| POST | `/admin/post-categories/{id}/delete` | `deleteCategory` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 포인트 2: Service / Mapper 체인
|
||||||
|
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
**PostController 의존 Bean:**
|
||||||
|
- `PostsMapper` (annotation 기반 MyBatis) — `listPublishedKeyset`, `getPublished`, `insert`, `update`, `softDelete`
|
||||||
|
- `PostCategoriesMapper` — `listActive`, `getActive`, `getById`, `insert`, `update`, `delete`, `countPostsByCategory`
|
||||||
|
- `PostMarkdownService` — CommonMark 파싱 + Jsoup basicWithImages safelist sanitize
|
||||||
|
- `OgPreviewService` — SSRF-safe HTTP fetch + OG 메타 파싱, 실패 시 graceful empty
|
||||||
|
|
||||||
|
**별도 PostService/PostServiceImpl 없음.** 비즈니스 로직이 Controller에 직접 집약. XML Mapper 없음, 전부 `@Select`/`@Insert`/`@Update` annotation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 포인트 3: 500 에러 원인 후보 (가능성 순)
|
||||||
|
|
||||||
|
#### [1순위 — 가장 유력] posts-detail.jsp NPE (확인됨)
|
||||||
|
|
||||||
|
- 파일: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-detail.jsp`
|
||||||
|
- `PostData post = (PostData) request.getAttribute("post");` 이후 null 체크 없이 `post.getTitle()`, `post.getId()` 등 직접 접근
|
||||||
|
- **`post` attribute가 null인 상태로 JSP가 렌더링되면 NullPointerException → 500**
|
||||||
|
- 발생 경로: `PostController.detail()`이 `postsMapper.getPublished(id)`를 호출해 null을 받으면 `redirect:/posts`로 분기하므로 Controller 레벨에서는 방어됨. 그러나 다른 진입 경로(forward, include, 테스트 호출)에서 post attribute 누락 시 노출 가능.
|
||||||
|
|
||||||
|
#### [2순위] DRAFT 포스트 조회 시 null 처리 흐름 (확인됨)
|
||||||
|
|
||||||
|
- 파일: `PostController.java` line 167, 206, 252
|
||||||
|
- `editForm`, `update`, `delete` 핸들러 모두 `postsMapper.getPublished(id)` 사용
|
||||||
|
- `getPublished` SQL: `WHERE p.status = 'PUBLISHED' AND p.is_delete = false`
|
||||||
|
- **DRAFT 상태로 저장된 포스트 ID로 editForm 접근 시 → null → `if (post == null) return "redirect:/posts"`** — 500이 아닌 redirect지만, JS fetch로 update/delete 호출 시 null → NPE 가능성:
|
||||||
|
- `PostController.update()` line 206: `existing.setCategoryId(categoryId)` — `existing`이 null이면 NPE → 500
|
||||||
|
- `PostController.delete()` line 252: `postsMapper.softDelete(id)` 호출 전 null 체크 여부 추가 확인 필요
|
||||||
|
|
||||||
|
#### [3순위] PostsMapper.update() — category_id 컬럼 누락 (확인됨)
|
||||||
|
|
||||||
|
- 파일: `PostsMapper.java` line 120-135
|
||||||
|
- UPDATE SQL 대상 컬럼: `title, body_markdown, body_sanitized_html, link_url, og_*, status, updated_at`
|
||||||
|
- **`category_id = #{categoryId}` 누락** → 카테고리 변경이 DB에 반영되지 않음
|
||||||
|
- 500 직접 원인은 아니나, 카테고리 변경 요청 시 데이터 정합성 손상
|
||||||
|
|
||||||
|
#### [4순위] PostsMapper.insert() 직후 ID null 체크 (확인됨)
|
||||||
|
|
||||||
|
- `PostController.create()` line 149: `if (post.getId() == null)` 체크 후 500 응답
|
||||||
|
- DB INSERT 실패(constraint violation, DB 연결 오류 등) 시 MyBatis 예외 → Spring이 500으로 변환
|
||||||
|
- 포스트 생성 시 `category_id` FK 참조 실패(비활성 카테고리 등) 가능
|
||||||
|
|
||||||
|
#### [5순위] Bean 주입 / 빌드 이슈 (확인됨 — 현재 정상)
|
||||||
|
|
||||||
|
- Spring Boot 3.5.16, MyBatis Spring Boot 3.0.5: 호환 정상
|
||||||
|
- 모든 Bean 생성자 주입, `@Mapper` 어노테이션 정상
|
||||||
|
- JSTL 미사용(scriptlet 방식), tomcat-embed-jasper compile scope: JSP 렌더링 정상
|
||||||
|
- ViewResolver: prefix=`/WEB-INF/views/`, suffix=`.jsp` 정상
|
||||||
|
- **기동 실패 유발 구조적 결함 없음**
|
||||||
|
|
||||||
|
#### [6순위] PostAdminController 권한 체크 부재 (추정)
|
||||||
|
|
||||||
|
- `PostController`는 `gate.has(session, "POST_WRITE")` 명시 검사
|
||||||
|
- `PostAdminController`에는 `PermissionGate` 주입 자체 없음
|
||||||
|
- `InterceptorConfig`에서 `/admin/**`에 `RbacInterceptor` 등록 확인됨 — 필터 레벨 보호 존재
|
||||||
|
- `/admin/jams/**`는 제외(addExcludePatterns). `/admin/post-categories`는 포함 여부 추가 확인 필요
|
||||||
|
- 500 직접 원인 아님; 인증 우회 시 NPE 가능성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 포인트 4: 관련 JSP 파일 경로
|
||||||
|
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
| JSP | 절대 경로 |
|
||||||
|
|-----|----------|
|
||||||
|
| 포스트 목록 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-list.jsp` |
|
||||||
|
| 포스트 상세 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-detail.jsp` |
|
||||||
|
| 포스트 작성/수정 폼 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-form.jsp` |
|
||||||
|
| 관리자 카테고리 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/admin-post-categories.jsp` |
|
||||||
|
|
||||||
|
**admin-post-categories.jsp 주의사항:**
|
||||||
|
- `var BASE = '/admin/post-categories';` — context path 미포함 하드코딩
|
||||||
|
- 다른 JSP들은 `<%= ctx %>` 변수(contextPath 동적 주입)를 사용하는 것과 비교해 inconsistent
|
||||||
|
- context root가 `/` 이외로 배포 시 404 발생
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 포인트 5: 최근 git 변경 사항 (포스팅 관련)
|
||||||
|
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
| 커밋 해시 | 메시지 |
|
||||||
|
|---------|--------|
|
||||||
|
| `6047a39` | `feat(board): W3-3 포스팅 보드 — 공지/블로그 + OG 미리보기(SSRF 방어) + 유니티 피드 감시` |
|
||||||
|
|
||||||
|
- 포스팅 기능이 이 단일 커밋(32파일, 5327줄 추가)으로 초기 도입됨 (2026-06-29)
|
||||||
|
- **후속 수정 커밋 없음** — 초기 도입 이후 한 번도 수정되지 않은 상태
|
||||||
|
- 이전 세션 로그에서 발견된 PKIX 빌드 오류(`spring-boot-dependencies:3.5.14-SNAPSHOT`)는 `e51ab01` 커밋으로 해소됨 (현재 정식 버전 사용)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 종합 판단
|
||||||
|
|
||||||
|
**"포스팅 메뉴 진입 시 500"의 가장 유력한 원인은 `posts-detail.jsp`의 null 체크 부재(NPE)다.**
|
||||||
|
|
||||||
|
구체적 시나리오:
|
||||||
|
1. `GET /posts/{id}` 요청 → `PostController.detail()` → `postsMapper.getPublished(id)` 호출
|
||||||
|
2. `id`가 DRAFT 상태이거나 삭제된 포스트이거나 존재하지 않으면 → `null` 반환 → `redirect:/posts`로 처리됨 (Controller 레벨 방어 있음)
|
||||||
|
3. 그러나 **"포스팅 메뉴 진입"이 `GET /posts` (목록)라면**: `posts-list.jsp`로 렌더링되며 `postCategoriesMapper.listActive()`가 빈 결과를 반환할 경우 JSP 루프는 정상 처리. 별도 NPE 없음.
|
||||||
|
4. **만약 카테고리가 하나도 없는 상태에서 `createForm`(`GET /posts/new`) 진입 시**: `postCategoriesMapper.listActive()`가 빈 리스트를 반환하더라도 JSTL/scriptlet 루프이므로 NPE 없음.
|
||||||
|
|
||||||
|
따라서 **정확한 500 재현 경로는 다음 중 하나**:
|
||||||
|
- `GET /posts/{존재하지 않거나 DRAFT인 id}` → Controller null 체크 → redirect (500 아님)
|
||||||
|
- `POST /posts/{DRAFT id}` (JS fetch update) → `existing = postsMapper.getPublished(id)` → null → `existing.setCategoryId()` → **NPE → 500** ← **가장 유력**
|
||||||
|
- `posts-detail.jsp` 직접 forward/include 시 post attribute 누락 → NPE → 500
|
||||||
|
|
||||||
|
**권위 격상 전 검증 필요 항목:**
|
||||||
|
- `PostController.update()` line 206에서 `existing`이 null일 때의 실제 분기 코드 (null 체크가 있는지 여부) — worker 탐색에서 null 체크 코드가 보이지 않아 NPE 유력하나, 해당 라인 전후 코드 직접 확인 권장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 영향 파일 목록
|
||||||
|
|
||||||
|
| 파일 | 역할 | 이슈 |
|
||||||
|
|------|------|------|
|
||||||
|
| `src/main/java/.../controller/PostController.java` | 메인 Controller | DRAFT 조회 불가, update NPE 후보 |
|
||||||
|
| `src/main/java/.../controller/PostAdminController.java` | 관리자 Controller | 권한 체크 부재 |
|
||||||
|
| `src/main/java/.../mapper/PostsMapper.java` | MyBatis Mapper | update SQL category_id 누락 |
|
||||||
|
| `src/main/java/.../mapper/PostCategoriesMapper.java` | 카테고리 Mapper | 정상 |
|
||||||
|
| `src/main/java/.../service/PostMarkdownService.java` | 마크다운 렌더링 | 정상 |
|
||||||
|
| `src/main/java/.../service/OgPreviewService.java` | OG 미리보기 | 정상 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/posts-detail.jsp` | 상세 JSP | NPE 위험 (null 체크 없는 post 접근) |
|
||||||
|
| `src/main/webapp/WEB-INF/views/posts-list.jsp` | 목록 JSP | 정상 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/posts-form.jsp` | 작성/수정 JSP | 정상 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | 관리자 JSP | context path 하드코딩 주의 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 미해결 (open_questions)
|
||||||
|
|
||||||
|
1. **`PostController.update()` line 206 전후 null 체크 존재 여부**: `existing`이 null일 때 NPE로 이어지는지, 아니면 별도 분기가 있는지 해당 라인 직접 확인 필요. (현재 탐색에서는 null 체크 코드가 보이지 않아 NPE 유력 추정)
|
||||||
|
2. **정확한 "포스팅 메뉴 진입" URL**: `/posts` (목록), `/posts/{id}` (상세), `/posts/new` (작성폼), `/admin/post-categories` 중 어느 URL에서 500이 발생하는지 재현 경로 미확인
|
||||||
|
3. **DB 스키마 상태**: `post_categories` 테이블에 활성(is_active=true) 카테고리가 존재하는지 — 카테고리 없는 상태에서 create/edit 폼의 카테고리 드롭다운이 빈 상태로 표시되고 categoryId=null 제출 → validate() line 305 `categoryId == null` 조건으로 BadRequest 반환 (500 아님)
|
||||||
|
4. **RbacInterceptor `/admin/post-categories` 보호 여부**: `addExcludePatterns`에서 `/admin/jams/**` 외 추가 제외 경로 상세 확인 필요
|
||||||
|
5. **애플리케이션 로그 스택트레이스**: 실제 500 발생 시의 예외 클래스와 라인 넘버 — 현재 로그 파일에서 포스팅 관련 스택트레이스 미발견
|
||||||
|
|
||||||
|
|
@ -0,0 +1,542 @@
|
||||||
|
---
|
||||||
|
phase: design
|
||||||
|
agent: design-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T03:15:00Z
|
||||||
|
concerns:
|
||||||
|
- "admin-console.jsp, admin-jam-list.jsp JS 내부의 post() 함수 구현 세부가 admin-post-categories / admin-unity-feeds와 정확히 동일한지 코드 레벨 비교 필요. 통합 시 파라미터 처리 분기가 예상치 않게 동작 변경될 수 있음. 구현 advisor가 통합 전 3-way diff 수행 권고."
|
||||||
|
- "header.jsp 내 .site-header CSS 변수 블록(--header-bg 등)은 global.css 의 토큰 범위와 겹치지 않는 컴포넌트 전용 변수이므로 중앙화 대상에서 제외. 구현 시 헷갈리지 않도록 명칭 분리 유지 확인 요망."
|
||||||
|
concerns_checked: true
|
||||||
|
references:
|
||||||
|
requirements: "(인라인 입력 — 이 문서의 ## 맥락 섹션)"
|
||||||
|
research: ".atp/work-session/20260630-113258/research/frontend-analysis.md"
|
||||||
|
adrs: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# 프론트엔드 컴포넌트 개선 권고안
|
||||||
|
|
||||||
|
## 목표 / 비목표
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
- FR-1: CSS 디자인 토큰 중복 제거 → 23개 파일 대신 단일 파일 변경으로 전역 반영
|
||||||
|
- FR-2: JS 공용 유틸리티(notify/openModal/post/handleResult/handleError) 중복 선언 제거
|
||||||
|
- FR-3: BibimbapModal 호출 패턴을 단일 방식으로 표준화
|
||||||
|
- FR-4: recruit-form의 CSRF 토큰 폴백 갭 수정 (보안)
|
||||||
|
- FR-5: 날짜 포맷 함수를 공용 파일로 추출하여 복사 중복 방지
|
||||||
|
|
||||||
|
### 비목표
|
||||||
|
- Webpack/Vite 등 빌드 도구 도입 (별도 의사결정 사항)
|
||||||
|
- CSS-in-JS, PostCSS 파이프라인 도입
|
||||||
|
- JSP 템플릿 엔진 교체 또는 React/Vue 마이그레이션
|
||||||
|
- 기존 HTML/JSP 구조 및 클래스명 리팩터링 (CSS 토큰만 외부화)
|
||||||
|
- header.jsp·footer.jsp·modal.jsp 기존 구현 변경
|
||||||
|
- 서버사이드 날짜 포매팅 도입
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약 (우선순위 매트릭스)
|
||||||
|
|
||||||
|
| 이슈 | 영향도 | 구현 비용 | 우선순위 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CSS 토큰 23개 파일 중복 | 높음 (유지보수 23배 비용) | 낮음 (파일 추가 + include 삽입) | P0 |
|
||||||
|
| 폼 제출 CSRF 갭 (recruit-form) | 높음 (보안) | 낮음 (10줄 이내 수정) | P0 — 보안 |
|
||||||
|
| JS 공용 유틸리티 분리 | 중간 (동작 불일치 방지) | 중간 (파일 작성 + 기존 인라인 제거) | P1 |
|
||||||
|
| 모달 패턴 표준화 | 중간 (신규 페이지 기준 제공) | 중간 (패턴 A/B 대체 + admin 2곳 수정) | P1 |
|
||||||
|
| 날짜 포맷 유틸리티 모듈 | 낮음 (예방적 분리) | 낮음 (파일 추출) | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 권고 1 — CSS 토큰 중앙화 (P0)
|
||||||
|
|
||||||
|
### 접근
|
||||||
|
|
||||||
|
`src/main/webapp/css/global.css` 정적 파일을 신규 생성한다. 이 파일에 라이트/다크 테마 CSS 변수 블록, `body` 기본 스타일, `safe-area` padding 믹스인 클래스, `.admin-btn` 컴포넌트를 선언한다.
|
||||||
|
|
||||||
|
각 JSP의 `<head>` 최상단에 `<link rel="stylesheet" href="${pageContext.request.contextPath}/css/global.css">` 를 삽입한다. 이 link는 `theme-init.jsp` `<jsp:include>` 직후에 위치시켜 FOUC(Flash of Unstyled Content)를 방지한다.
|
||||||
|
|
||||||
|
Spring Boot WAR에서 `src/main/webapp/` 하위 정적 파일은 Servlet 컨테이너(Tomcat)가 직접 서빙한다. `UploadResourceConfig`의 `addResourceHandlers`는 `/profile/**` 전용이므로 `/css/**` 는 별도 등록 없이 Tomcat 기본 DefaultServlet이 처리한다. `src/main/resources/static/` 경로는 필요하지 않으며 신규 생성도 불필요하다.
|
||||||
|
|
||||||
|
### 파일 경로
|
||||||
|
|
||||||
|
| 역할 | 경로 |
|
||||||
|
|---|---|
|
||||||
|
| 신규 — 전역 CSS | `src/main/webapp/css/global.css` |
|
||||||
|
| 수정 대상 — 인라인 토큰 제거 | `src/main/webapp/WEB-INF/views/*.jsp` (23개) |
|
||||||
|
|
||||||
|
### global.css 선언 내용 (구체 명세)
|
||||||
|
|
||||||
|
```
|
||||||
|
/* 1. 라이트 테마 토큰 */
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
--surface: #faf8f5;
|
||||||
|
--card-bg: #fff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--text-muted: #5c5c5c;
|
||||||
|
--accent: #e8a54b;
|
||||||
|
--accent-soft: rgba(232, 165, 75, 0.16);
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.06);
|
||||||
|
--field-bg: #fff;
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 다크 테마 토큰 */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--surface: #121212;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
--text: #ece8e1;
|
||||||
|
--text-muted: #a39e96;
|
||||||
|
--border: rgba(255, 255, 255, 0.1);
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--field-bg: #181818;
|
||||||
|
--button-text: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. body 기본 스타일 */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. admin-btn 컴포넌트 (admin 4개 파일 중복 제거) */
|
||||||
|
.admin-btn {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
padding: 0 0.85rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-btn:hover { border-color: rgba(232, 165, 75, 0.45); }
|
||||||
|
.admin-btn--primary { border-color: transparent; background: var(--accent); color: var(--button-text); }
|
||||||
|
.admin-btn--danger { border-color: rgba(200, 60, 60, 0.45); color: #c83c3c; }
|
||||||
|
```
|
||||||
|
|
||||||
|
**포함하지 않는 것**: 각 JSP 고유 레이아웃 클래스(`.admin-page`, `.form-page`, `.auth-main` 등), `max-width` 값(파일마다 48/72/78rem으로 다름), 페이지 전용 컴포넌트 CSS. 이들은 계속 인라인으로 유지한다.
|
||||||
|
|
||||||
|
**`--danger` 토큰**: `admin-unity-feeds.jsp`에만 있는 `--danger: #c83c3c / #ef7878` 변수는 global.css에 추가하지 않는다. 해당 파일이 유일한 사용처이므로 인라인 유지가 맞다.
|
||||||
|
|
||||||
|
**`--accent-soft` 미선언 파일 처리**: `login.jsp`와 `profile.jsp`는 `--accent-soft`를 선언하지 않는다. global.css에 전역 선언 후 이 두 파일의 인라인 블록에서 해당 변수가 없어도 문제 없다 (사용 위치가 없거나 다른 변수로 대체됨). 구현 시 각 파일의 실제 사용처를 확인하고 미사용이면 그냥 삭제, 사용이면 global.css 선언이 적용되므로 정상.
|
||||||
|
|
||||||
|
### 마이그레이션 경로 (단계별)
|
||||||
|
|
||||||
|
**Phase 1 — 파일 생성 + 1개 JSP 검증 (위험 최소)**
|
||||||
|
1. `src/main/webapp/css/global.css` 작성
|
||||||
|
2. `login.jsp` 1개에만 `<link>` 태그 추가 + 인라인 토큰 블록 제거
|
||||||
|
3. 브라우저에서 라이트/다크 테마 전환 육안 검증
|
||||||
|
4. 문제 없으면 Phase 2 진행
|
||||||
|
|
||||||
|
**Phase 2 — admin 4개 파일 적용 (`.admin-btn` 중복 제거 포함)**
|
||||||
|
1. `admin-post-categories.jsp`, `admin-unity-feeds.jsp`, `admin-console.jsp`, `admin-jam-list.jsp` 에 `<link>` 추가
|
||||||
|
2. 각 파일의 인라인 `html {}`, `html[data-theme="dark"] {}`, `body {}`, `.admin-btn*` 블록 제거
|
||||||
|
3. admin 기능(등록/수정/삭제) 동작 검증
|
||||||
|
|
||||||
|
**Phase 3 — 나머지 19개 JSP 일괄 적용**
|
||||||
|
1. 남은 파일에 `<link>` 추가 + 인라인 토큰 제거
|
||||||
|
2. 각 페이지 라이트/다크 테마 시각 검증
|
||||||
|
|
||||||
|
### 위험 평가
|
||||||
|
|
||||||
|
| 위험 | 가능성 | 대응 |
|
||||||
|
|---|---|---|
|
||||||
|
| 일부 JSP가 인라인으로 토큰 값을 오버라이드하고 있다면 global.css 도입 후 이전과 동일 동작 | 낮음 (토큰 값 동일 확인됨) | Phase 1 검증 후 진행 |
|
||||||
|
| 특정 파일의 인라인 `html {}` 에 global.css에 없는 추가 변수 존재 (예: `--radius`, `--webgl-bg`) | 있음 (game-detail.jsp 확인) | 파일 전용 변수는 인라인 유지 — 중앙화 대상은 공통 8+4개 토큰만 |
|
||||||
|
| Tomcat DefaultServlet이 `/css/` 경로를 차단하는 보안 필터 존재 | 매우 낮음 | `/images/logo.png` 가 이미 동일 방식으로 서빙 중 — 검증됨 |
|
||||||
|
| FOUC: CSS 로드 전 테마 변수 미적용 깜빡임 | 낮음 | `theme-init.jsp`가 `<head>` 최상단에서 `data-theme` 속성을 먼저 설정하고 있으므로 CSS 변수 로드 타이밍이 맞음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 권고 2 — JS 공용 유틸리티 분리 (P1)
|
||||||
|
|
||||||
|
### 접근
|
||||||
|
|
||||||
|
`src/main/webapp/js/bibimbap-utils.js` 를 신규 생성하고 admin 4개 파일의 공통 함수(`post`, `handleResult`, `handleError`)를 이 파일로 추출한다. 이 파일은 `window.BibimbapUtils` 네임스페이스에 노출한다.
|
||||||
|
|
||||||
|
`notify()`와 `openModal()` 은 title이 파일마다 달라 단순 통합이 불가능하므로 별도 전략을 쓴다(권고 3에서 다룸).
|
||||||
|
|
||||||
|
각 JSP에서는 `</body>` 직전에 `<script src="${pageContext.request.contextPath}/js/bibimbap-utils.js"></script>` 를 삽입하고, 기존 인라인 함수 선언을 제거한다.
|
||||||
|
|
||||||
|
### 파일 경로 및 API 계약 (함수 시그니처)
|
||||||
|
|
||||||
|
**신규 파일**: `src/main/webapp/js/bibimbap-utils.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.BibimbapUtils = (function () {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF 토큰을 포함한 application/x-www-form-urlencoded POST 요청을 전송한다.
|
||||||
|
*
|
||||||
|
* @param {string} url - 요청 대상 URL
|
||||||
|
* @param {URLSearchParams|null} params - 요청 바디 파라미터 (null 이면 빈 URLSearchParams 사용)
|
||||||
|
* @param {string} csrfToken - X-CSRF-Token 헤더에 설정할 토큰 값
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
function post(url, params, csrfToken) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch Response가 ok이면 location.reload(), 아니면 오류 메시지를 콜백으로 전달한다.
|
||||||
|
*
|
||||||
|
* @param {Response} res - fetch가 반환한 Response 객체
|
||||||
|
* @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백
|
||||||
|
*/
|
||||||
|
function handleResult(res, onError) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네트워크/예외 오류 발생 시 오류 메시지를 콜백으로 전달한다.
|
||||||
|
*
|
||||||
|
* @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백
|
||||||
|
* @returns {function} catch 핸들러로 사용 가능한 함수
|
||||||
|
*/
|
||||||
|
function makeErrorHandler(onError) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
return { post: post, handleResult: handleResult, makeErrorHandler: makeErrorHandler };
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
**시그니처 결정 이유**:
|
||||||
|
|
||||||
|
- `post(url, params, csrfToken)`: 기존 admin 4개 파일의 `post()` 는 클로저로 `CSRF_TOKEN` 변수를 캡처하는 방식이었다. 전역 파일로 추출하면 클로저 캡처가 불가능하므로 `csrfToken`을 명시 파라미터로 받는다. 호출부가 항상 토큰을 직접 넘기므로 의도가 코드에 드러나고 타입 검사도 명확해진다.
|
||||||
|
|
||||||
|
- `handleResult(res, onError)`: 기존 구현은 `notify()`를 직접 호출했다. `notify()`는 파일마다 title이 달라 전역화 불가능하므로, 대신 오류 메시지 문자열을 콜백으로 전달하는 방식으로 변경한다. 호출부가 메시지를 받아 직접 표시하므로 유연성과 분리가 동시에 달성된다.
|
||||||
|
|
||||||
|
- `makeErrorHandler(onError)`: 기존 `handleError()`는 매직 문자열 `'요청 중 오류가 발생했습니다.'` 를 하드코딩했다. 유틸리티 레벨에서 특정 언어 문자열을 결정하지 않도록 콜백으로 위임한다.
|
||||||
|
|
||||||
|
**inflate 경고**: `post()` 의 세 번째 파라미터 `csrfToken`이 항상 사용되는지 구현 시 확인 필요. 만약 호출부 중 하나라도 빈 문자열을 넘기는 패턴이 발견되면 `BibimbapCsrf.token()` 통합을 고려한다 (concerns 참조).
|
||||||
|
|
||||||
|
### 마이그레이션 경로
|
||||||
|
|
||||||
|
1. `bibimbap-utils.js` 작성 (3개 함수 포함)
|
||||||
|
2. `admin-post-categories.jsp` 에 script 태그 추가 + 인라인 `post`, `handleResult`, `handleError` 함수 제거. `BibimbapUtils.post(...)`, `BibimbapUtils.handleResult(...)`, `BibimbapUtils.makeErrorHandler(...)` 로 호출부 수정
|
||||||
|
3. 기능 검증 후 나머지 admin 3개 파일에 동일 적용
|
||||||
|
|
||||||
|
### 위험 평가
|
||||||
|
|
||||||
|
| 위험 | 가능성 | 대응 |
|
||||||
|
|---|---|---|
|
||||||
|
| admin 파일마다 `post()` 구현이 미묘하게 달라 통합 시 동작 변경 | 중간 | 통합 전 4개 파일 `post()` 구현 3-way diff 수행 (concerns 등록) |
|
||||||
|
| `handleResult` 시그니처 변경으로 기존 호출부 수정 필요 | 확실 | 마이그레이션 범위를 admin 4개로 한정, 변경이 넓지 않음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 권고 3 — 모달 사용 패턴 표준화 (P1)
|
||||||
|
|
||||||
|
### 접근
|
||||||
|
|
||||||
|
**표준 패턴 채택**: `BibimbapModal` API (`window.BibimbapModal.alert / .confirm / .prompt`) 를 직접 호출하는 인라인 패턴을 표준으로 정한다. 기존 헬퍼 래퍼(`openModal()`, `notify()`)는 더 이상 신규 작성하지 않는다.
|
||||||
|
|
||||||
|
**패턴 A (openModal 래퍼) 처리**: login/signup/game-register/profile 4개 파일의 `openModal()` 로컬 함수를 제거하고 호출부를 `window.BibimbapModal.alert({...})` 직접 호출로 대체한다. `BibimbapModal` 미존재 폴백은 `else { alert(message); }` 로 단순화한다.
|
||||||
|
|
||||||
|
**패턴 B (notify 래퍼) 처리**: admin 4개 파일의 `notify()` 를 제거한다. 권고 2의 `BibimbapUtils.handleResult(res, onError)` 에서 `onError` 콜백이 `BibimbapModal.alert` 를 직접 호출하도록 호출부를 작성한다. 이렇게 하면 `notify()` 래퍼 없이도 동일 효과를 얻는다.
|
||||||
|
|
||||||
|
**패턴 C (인라인 직접 체크) 처리**: posts-form/recruit-form/game-detail 은 이미 인라인이므로 현재 형태 유지. 다만 `game-detail.jsp` 내 `notifyError()` / `confirmAction()` 헬퍼(라인 1553-1561)는 파일 전용이므로 제거하지 않는다. 단일 파일에서의 로컬 헬퍼는 허용.
|
||||||
|
|
||||||
|
**window.confirm 직접 사용 수정 (보안/일관성)**:
|
||||||
|
- `admin-post-categories.jsp:323` 의 `window.confirm()` → `BibimbapModal.confirm({...})` 으로 변경
|
||||||
|
- `admin-unity-feeds.jsp:401` 의 `window.confirm()` → `BibimbapModal.confirm({...})` 으로 변경
|
||||||
|
|
||||||
|
이 두 곳은 삭제 확인 흐름이므로 `onConfirm` 콜백에 실제 삭제 로직을 이동한다.
|
||||||
|
|
||||||
|
### 표준 패턴 예시 (참고용)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 알림 (확인 버튼만)
|
||||||
|
if (window.BibimbapModal) {
|
||||||
|
window.BibimbapModal.alert({ title: '제목', message: '내용', confirmText: '확인' });
|
||||||
|
} else {
|
||||||
|
alert('내용');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 확인/취소 대화
|
||||||
|
if (window.BibimbapModal) {
|
||||||
|
window.BibimbapModal.confirm({
|
||||||
|
title: '삭제 확인',
|
||||||
|
message: '삭제하시겠습니까?',
|
||||||
|
onConfirm: function () { /* 삭제 로직 */ }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (window.confirm('삭제하시겠습니까?')) { /* 삭제 로직 */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 파일 영향 맵
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| 수정 — openModal 제거 | login.jsp, signup.jsp, game-register.jsp, profile.jsp | 패턴 A → 직접 호출 |
|
||||||
|
| 수정 — notify 제거 | admin-console.jsp, admin-jam-list.jsp, admin-post-categories.jsp, admin-unity-feeds.jsp | 패턴 B → 콜백 방식 |
|
||||||
|
| 수정 — window.confirm 교체 | admin-post-categories.jsp:323, admin-unity-feeds.jsp:401 | BibimbapModal.confirm 사용 |
|
||||||
|
| 유지 — 변경 없음 | game-detail.jsp (notifyError/confirmAction), posts-form.jsp, recruit-form.jsp | 파일 전용 헬퍼 허용 |
|
||||||
|
|
||||||
|
### 마이그레이션 경로
|
||||||
|
|
||||||
|
1. `admin-post-categories.jsp` + `admin-unity-feeds.jsp` 의 `window.confirm` 2곳 먼저 수정 (보안 갭과 연결)
|
||||||
|
2. login/signup openModal 제거 + 직접 호출 전환
|
||||||
|
3. game-register/profile 동일 처리
|
||||||
|
4. admin 4개 파일 notify 제거 (권고 2와 동시 진행)
|
||||||
|
|
||||||
|
### 위험 평가
|
||||||
|
|
||||||
|
| 위험 | 가능성 | 대응 |
|
||||||
|
|---|---|---|
|
||||||
|
| BibimbapModal이 modal.jsp 로드 전에 JS가 실행되는 경우 | 낮음 (header.jsp가 modal.jsp를 항상 include) | 변경 없이 현 구조 유지 |
|
||||||
|
| window.confirm 삭제 시 콜백 이동 누락으로 삭제 로직 미실행 | 중간 | 각 파일 수정 후 삭제 기능 수동 검증 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 권고 4 — 폼 제출 유틸리티 + CSRF 갭 수정 (P0 — 보안)
|
||||||
|
|
||||||
|
### 접근
|
||||||
|
|
||||||
|
**즉시 수정 (CSRF 갭)**: `recruit-form.jsp` 의 fetch 호출부에서 `BibimbapCsrf` 미존재 폴백(라인 390-394)이 CSRF 토큰을 전혀 포함하지 않는 문제를 수정한다. 두 가지 방법 중 **방법 A를 채택**한다.
|
||||||
|
|
||||||
|
**방법 A (채택)**: 폴백 브랜치에 `hidden input`에서 추출한 토큰을 직접 삽입한다.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// recruit-form.jsp 수정안
|
||||||
|
var csrfToken = (document.querySelector('input[name="_csrf"]') || {}).value || '';
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}) : {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRF-Token': csrfToken // 갭 수정
|
||||||
|
},
|
||||||
|
body: body
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**전제**: `recruit-form.jsp` 에 `<input type="hidden" name="_csrf" value="...">` hidden input을 추가해야 한다. 현재 `recruit-form.jsp` 에는 이 hidden input이 없다. 추가하면 `new FormData(form)` 이 자동으로 `_csrf` 파라미터를 포함하므로 서버사이드 form 파라미터 검증도 함께 강화된다.
|
||||||
|
|
||||||
|
**방법 B (미채택)**: `theme-init.jsp` 의 `BibimbapCsrf` 를 항상 신뢰하여 폴백 분기 자체를 제거. 단, `BibimbapCsrf` 미존재 케이스를 완전히 제거하면 `theme-init.jsp` 가 로드 실패 시 CSRF 토큰이 아예 없어지는 더 큰 갭이 생긴다. 따라서 채택하지 않는다.
|
||||||
|
|
||||||
|
**401 리다이렉트 불일치 수정**: `recruit-form.jsp` 의 401 처리를 `posts-form.jsp` 와 동일하게 `redirectLogin` 함수로 분리한다.
|
||||||
|
|
||||||
|
**성공 시 폴백 불일치 수정**: `recruit-form.jsp:419-421` 의 `alert(...)` 호출을 `posts-form.jsp:260-261` 패턴에 맞게 `alert` 없이 `go()` 만 호출하도록 변경한다.
|
||||||
|
|
||||||
|
### 파일 영향 맵
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| 수정 (보안) | recruit-form.jsp | hidden _csrf input 추가 + 폴백 브랜치 토큰 삽입 |
|
||||||
|
| 수정 (일관성) | recruit-form.jsp | 401 처리 redirectLogin 함수 분리 |
|
||||||
|
| 수정 (일관성) | recruit-form.jsp | 성공 폴백 alert 제거 |
|
||||||
|
| 변경 없음 | posts-form.jsp | 현행 유지 (기준 파일) |
|
||||||
|
|
||||||
|
### 마이그레이션 경로
|
||||||
|
|
||||||
|
1. `recruit-form.jsp` 에 `<input type="hidden" name="_csrf" value="...">` 추가 (JSP EL 또는 request attribute 사용)
|
||||||
|
2. JS 폴백 브랜치에 `X-CSRF-Token` 헤더 추가
|
||||||
|
3. 401 처리 함수 분리
|
||||||
|
4. 성공 폴백 통일
|
||||||
|
5. 실제 폼 제출(등록) + 401 시나리오(로그아웃 후 제출) 수동 검증
|
||||||
|
|
||||||
|
### 위험 평가
|
||||||
|
|
||||||
|
| 위험 | 가능성 | 대응 |
|
||||||
|
|---|---|---|
|
||||||
|
| hidden _csrf input 추가 시 서버 컨트롤러가 기대하는 파라미터명 불일치 | 낮음 | posts-form.jsp와 동일 파라미터명 `_csrf` 사용 |
|
||||||
|
| BibimbapCsrf가 항상 존재한다고 가정하고 폴백을 제거하고 싶은 유혹 | 중간 | 방법 B 미채택 이유 참조 — 폴백 브랜치 유지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 권고 5 — 날짜 포맷 유틸리티 모듈 (P2)
|
||||||
|
|
||||||
|
### 접근
|
||||||
|
|
||||||
|
`game-detail.jsp` 인라인의 `fmtAbsolute`, `fmtRelative`, `buildTimeEl` 세 함수를 `src/main/webapp/js/bibimbap-date.js` 로 추출하고 `window.BibimbapDate` 네임스페이스에 노출한다.
|
||||||
|
|
||||||
|
`game-detail.jsp` 는 `<script src=".../js/bibimbap-date.js"></script>` 를 추가하고 기존 인라인 선언을 제거한다. 다른 detail 페이지(posts-detail, recruit-detail, jam-detail)에서 날짜 포맷이 필요해질 때 이 파일을 include하면 된다 — 현재는 필요하지 않으므로 강제 적용하지 않는다.
|
||||||
|
|
||||||
|
### 파일 경로 및 API 계약 (함수 시그니처)
|
||||||
|
|
||||||
|
**신규 파일**: `src/main/webapp/js/bibimbap-date.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
window.BibimbapDate = (function () {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 8601 문자열을 ko-KR 로케일 절대 날짜/시각 문자열로 변환한다.
|
||||||
|
*
|
||||||
|
* @param {string} iso - ISO 8601 날짜 문자열
|
||||||
|
* @returns {string} - "2026. 6. 30. 오전 10:00:00" 형식, 파싱 실패 시 빈 문자열
|
||||||
|
*/
|
||||||
|
function fmtAbsolute(iso) { ... }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 8601 문자열을 상대 시각 문자열로 변환한다 (7일 이내: "n분/시간/일 전", 초과: 절대).
|
||||||
|
*
|
||||||
|
* @param {string} iso - ISO 8601 날짜 문자열
|
||||||
|
* @returns {string} - "3시간 전" 또는 절대 날짜, 파싱 실패 시 빈 문자열
|
||||||
|
*/
|
||||||
|
function fmtRelative(iso) { ... }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <time> 요소를 생성한다. 수정된 항목이면 "(수정됨)" 뱃지를 DocumentFragment로 감싸 반환한다.
|
||||||
|
*
|
||||||
|
* @param {string} iso - ISO 8601 날짜 문자열
|
||||||
|
* @param {boolean} edited - true이면 "(수정됨)" 뱃지 추가
|
||||||
|
* @returns {HTMLElement|DocumentFragment}
|
||||||
|
*/
|
||||||
|
function buildTimeEl(iso, edited) { ... }
|
||||||
|
|
||||||
|
return { fmtAbsolute: fmtAbsolute, fmtRelative: fmtRelative, buildTimeEl: buildTimeEl };
|
||||||
|
})();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 파일 영향 맵
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| 신규 | src/main/webapp/js/bibimbap-date.js | 날짜 포맷 유틸리티 |
|
||||||
|
| 수정 | src/main/webapp/WEB-INF/views/game-detail.jsp | script 태그 추가 + 인라인 3개 함수 제거 + `BibimbapDate.` 프리픽스 추가 |
|
||||||
|
|
||||||
|
### 마이그레이션 경로
|
||||||
|
|
||||||
|
1. `bibimbap-date.js` 작성 (기존 game-detail.jsp 인라인 코드 그대로 이식)
|
||||||
|
2. `game-detail.jsp` 에 script 태그 추가
|
||||||
|
3. 호출부 3곳(`buildTimeEl` 2건, `fmtAbsolute/fmtRelative` 직접 사용 1건)을 `BibimbapDate.buildTimeEl(...)` 등으로 수정
|
||||||
|
4. 인라인 함수 선언 3개 제거
|
||||||
|
5. game-detail 댓글/리뷰 날짜 표시 수동 검증
|
||||||
|
|
||||||
|
### 위험 평가
|
||||||
|
|
||||||
|
| 위험 | 가능성 | 대응 |
|
||||||
|
|---|---|---|
|
||||||
|
| game-detail.jsp가 매우 큰 파일(2200줄+)이므로 인라인 선언 제거 시 위치 파악 실수 | 중간 | 라인 번호 명시: fmtAbsolute 1516, fmtRelative 1521, buildTimeEl 1538 |
|
||||||
|
| 다른 파일에서 동일 함수명을 전역 선언하는 경우 충돌 | 없음 (조사 결과 game-detail.jsp 단독 선언 확인됨) | 추가 확인 불필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 파일 영향 맵
|
||||||
|
|
||||||
|
| 변경 유형 | 경로 | 역할 |
|
||||||
|
|---|---|---|
|
||||||
|
| 신규 | src/main/webapp/css/global.css | 디자인 토큰 + body + admin-btn |
|
||||||
|
| 신규 | src/main/webapp/js/bibimbap-utils.js | post / handleResult / makeErrorHandler |
|
||||||
|
| 신규 | src/main/webapp/js/bibimbap-date.js | 날짜 포맷 3개 함수 |
|
||||||
|
| 수정 (인라인 CSS 제거 + link 추가) | src/main/webapp/WEB-INF/views/*.jsp (23개) | global.css 연동 |
|
||||||
|
| 수정 (script 추가 + 인라인 JS 제거) | admin-console.jsp, admin-jam-list.jsp, admin-post-categories.jsp, admin-unity-feeds.jsp | bibimbap-utils.js 연동 |
|
||||||
|
| 수정 (script 추가 + 인라인 제거) | game-detail.jsp | bibimbap-date.js 연동 |
|
||||||
|
| 수정 (openModal 제거) | login.jsp, signup.jsp, game-register.jsp, profile.jsp | 모달 패턴 표준화 |
|
||||||
|
| 수정 (window.confirm 교체) | admin-post-categories.jsp:323, admin-unity-feeds.jsp:401 | BibimbapModal.confirm 전환 |
|
||||||
|
| 수정 (CSRF 갭 — 보안) | recruit-form.jsp | hidden input 추가 + 폴백 헤더 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 대안 비교
|
||||||
|
|
||||||
|
### CSS 중앙화 방안
|
||||||
|
|
||||||
|
| 안 | 장점 | 단점 | 채택? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A: `src/main/webapp/css/global.css` | 빌드 도구 불필요, Tomcat 즉시 서빙 | 캐시 버스팅 수동 관리 필요 | **채택** |
|
||||||
|
| B: `theme-init.jsp`에 `<style>` 블록 추가 | include 메커니즘 재사용 | JSP 응답마다 CSS가 HTML에 인라인 삽입됨 — 캐싱 불가, 오히려 더 많은 바이트 전송 | 미채택 |
|
||||||
|
| C: `src/main/resources/static/css/global.css` | Spring Boot 자동 서빙 | 해당 경로가 현재 미존재이고 WAR 배포 시 classpath static과 webapp static 혼용이 복잡해짐 | 미채택 |
|
||||||
|
|
||||||
|
### JS 유틸리티 배포 방안
|
||||||
|
|
||||||
|
| 안 | 장점 | 단점 | 채택? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A: `src/main/webapp/js/` 정적 파일 | 빌드 불필요, 직접 서빙 | 캐시 버스팅 수동 관리 필요 | **채택** |
|
||||||
|
| B: JSP include 파일 (`<jsp:include>`) | 기존 include 패턴 일관성 | JS를 JSP로 서빙하면 컨텐츠 타입이 `text/html`로 설정될 위험, 브라우저 모듈 캐싱 불가 | 미채택 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 순서 권고
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [P0-보안] recruit-form.jsp CSRF 갭 수정 — 단독 작업, 위험 낮음
|
||||||
|
2. [P0] global.css 작성 + login.jsp 1개 검증 — Phase 1
|
||||||
|
3. [P0] admin 4개 파일 global.css 적용 — Phase 2
|
||||||
|
4. [P0] 나머지 19개 JSP global.css 적용 — Phase 3
|
||||||
|
5. [P1] bibimbap-utils.js 작성 — admin 파일 수정 전제
|
||||||
|
6. [P1] admin 4개 파일 bibimbap-utils.js 연동 — 5 완료 후
|
||||||
|
7. [P1] window.confirm 2곳 BibimbapModal.confirm 전환 — 6과 동시 가능
|
||||||
|
8. [P1] openModal 패턴 4개 파일 표준화 — 7 완료 후
|
||||||
|
9. [P2] bibimbap-date.js 추출 + game-detail.jsp 연동 — 독립 작업
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 롤아웃 / 마이그레이션
|
||||||
|
|
||||||
|
**역호환**: 이 권고안의 모든 변경은 순수 정적 파일 추가 + JSP 인라인 제거이므로 서버사이드 Java 코드, MyBatis, API 계약에 영향이 없다. 롤백은 추가한 `<link>`, `<script>` 태그를 되돌리고 인라인 블록을 복원하면 된다.
|
||||||
|
|
||||||
|
**캐시 버스팅**: 빌드 시스템이 없으므로 쿼리 스트링 버전 파라미터(`?v=20260630`)를 link/script 태그에 수동으로 붙인다. 초기 배포 시 한 번 붙이고, CSS/JS 변경 시마다 날짜를 업데이트하는 것으로 충분하다.
|
||||||
|
|
||||||
|
**배포 단위**: 정적 파일 추가는 WAR 재배포가 필요하다. 단계별 적용이지만 배포 자체는 1회로 묶어 진행해도 된다.
|
||||||
|
|
||||||
|
**롤백 경로**:
|
||||||
|
1. `global.css` 추가 후 이슈 발생 시 → `<link>` 태그 제거 + git revert
|
||||||
|
2. `bibimbap-utils.js` 이후 이슈 발생 시 → script 태그 제거 + 인라인 함수 복원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 포인트
|
||||||
|
|
||||||
|
아래 AC는 verification-advisor가 점검한다.
|
||||||
|
|
||||||
|
**AC-1 (P0)**: `src/main/webapp/css/global.css` 파일 존재 확인.
|
||||||
|
|
||||||
|
**AC-2 (P0)**: global.css가 라이트/다크 테마 HTML을 열었을 때 각각 올바른 배경색(`--surface: #faf8f5` / `#121212`)이 적용되는지 브라우저 확인.
|
||||||
|
|
||||||
|
**AC-3 (P0)**: JSP 23개 파일에 `<link rel="stylesheet" ... /css/global.css>` 포함 전수 확인.
|
||||||
|
```
|
||||||
|
grep -rl 'global\.css' src/main/webapp/WEB-INF/views/ | wc -l == 23
|
||||||
|
```
|
||||||
|
*시점 안정성 주의*: 이 카운트는 구현이 완료된 직후 측정해야 한다. 구현 중간에 측정하면 미완료 파일이 포함돼 FAIL로 나올 수 있다. verification은 구현 완료 후 1회 측정한다.
|
||||||
|
|
||||||
|
**AC-4 (P0-보안)**: `recruit-form.jsp` 에 `<input type="hidden" name="_csrf"` 가 존재하고, JS 폴백 브랜치에 `'X-CSRF-Token': csrfToken` 이 포함되어 있음 확인.
|
||||||
|
```
|
||||||
|
grep -c 'X-CSRF-Token' src/main/webapp/WEB-INF/views/recruit-form.jsp >= 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**AC-5 (P1)**: `src/main/webapp/js/bibimbap-utils.js` 파일 존재 + `window.BibimbapUtils` 노출 확인.
|
||||||
|
|
||||||
|
**AC-6 (P1)**: admin 4개 파일에 인라인 `function post(` 선언이 제거되었음 확인.
|
||||||
|
```
|
||||||
|
grep -rl 'function post(' src/main/webapp/WEB-INF/views/admin-*.jsp | wc -l == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**AC-7 (P1)**: `admin-post-categories.jsp:323` 및 `admin-unity-feeds.jsp:401` 위치에 `window.confirm(` 이 더 이상 없음 확인.
|
||||||
|
```
|
||||||
|
grep -c 'window\.confirm' src/main/webapp/WEB-INF/views/admin-post-categories.jsp == 0
|
||||||
|
grep -c 'window\.confirm' src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**AC-8 (P1)**: login/signup/game-register/profile 4개 파일에 `function openModal(` 선언이 제거되었음 확인 (전수 4건 → 0건).
|
||||||
|
```
|
||||||
|
grep -rl 'function openModal(' src/main/webapp/WEB-INF/views/ | wc -l == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**AC-9 (P2)**: `src/main/webapp/js/bibimbap-date.js` 파일 존재 + `window.BibimbapDate` 노출 확인.
|
||||||
|
|
||||||
|
**AC-10 (P2)**: `game-detail.jsp` 인라인에 `function fmtAbsolute(`, `function fmtRelative(`, `function buildTimeEl(` 3개 선언이 모두 제거되었음 확인 (전수 3건 → 0건).
|
||||||
|
```
|
||||||
|
grep -c 'function fmtAbsolute\|function fmtRelative\|function buildTimeEl' src/main/webapp/WEB-INF/views/game-detail.jsp == 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**AC-11 (전체)**: 신규 정적 파일 전수 3개 존재 확인.
|
||||||
|
```
|
||||||
|
ls src/main/webapp/css/global.css src/main/webapp/js/bibimbap-utils.js src/main/webapp/js/bibimbap-date.js
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
phase: documentation
|
||||||
|
agent: documentation-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T04:30:00Z
|
||||||
|
concerns:
|
||||||
|
- "theme-init.jsp, header.jsp, footer.jsp 내 CSS 변수 선언 여부가 미탐색 상태로 남아 있음. global.css 도입 전 이중 선언 여부 확인 권장."
|
||||||
|
- "BibimbapCsrf 실제 구현체(별도 JS 파일) 미탐색. headers() 폴백 동작 미확인 상태로 권고 4(CSRF 갭 수정) 구현 전 확인 필요."
|
||||||
|
- "admin 4개 파일의 post() 구현이 실제로 동일한지 정밀 3-way diff 미수행. bibimbap-utils.js 통합 전 수행 권장."
|
||||||
|
concerns_checked: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 문서화 보고
|
||||||
|
|
||||||
|
## 작성/수정된 문서
|
||||||
|
|
||||||
|
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `docs/analysis/2026-06-30-frontend-component-review.md` | analysis | 신규 생성 | `docs/analysis/index.md` | `docs/analysis/2026-06-16-project-analysis.md` (관련 기존 분석 섹션에서 참조) |
|
||||||
|
| `docs/analysis/index.md` | analysis | 기존 수정 (링크 추가) | — | — |
|
||||||
|
|
||||||
|
## 의사결정 기록 위치
|
||||||
|
|
||||||
|
- 프론트엔드 컴포넌트 개선 권고(CSS 중앙화 방안 A/B/C 비교, JS 유틸리티 배포 방안 A/B 비교, CSRF 갭 수정 방법 A/B 비교)는 design-advisor 산출물(`20260630-113258/artifacts/frontend-design-recommendations.md`)에 기록되어 있으며, 이 분석 문서에서 결론을 인용.
|
||||||
|
- 되돌리기 어려운 아키텍처 결정(ADR)은 이번 작업 범위에 없음 (정적 파일 추가는 가역적 변경).
|
||||||
|
|
||||||
|
## 추후 문서화가 필요한 항목
|
||||||
|
|
||||||
|
- 권고 1(global.css) 구현 완료 후: `docs/changes/` 에 런타임 동작 변경(CSS 서빙 경로 추가) 이력 기록
|
||||||
|
- 권고 4(recruit-form CSRF 갭 수정) 구현 완료 후: `docs/changes/` 에 보안 수정 이력 기록, `docs/security/security-remediation-checklist.md` 해당 항목 완료 표시
|
||||||
|
- 권고 2(bibimbap-utils.js) + 권고 3(모달 표준화) 구현 완료 후: JS 공용 파일 API 계약이 확정되면 `docs/contracts/` 또는 `docs/architecture/` 에 프론트엔드 유틸리티 레이어 설계 기록 고려
|
||||||
|
- 구현 전 3-way diff 수행 결과는 `docs/analysis/` 또는 work-log에 기록 권장
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
schema_version: "2"
|
||||||
|
sid: "20260630-113258"
|
||||||
|
started_at: "2026-06-30T11:32:58"
|
||||||
|
ended_at: "2026-06-30T12:10:51"
|
||||||
|
user_request: >
|
||||||
|
/frontend-design:frontend-design 프론트 코드들 컴포넌트별로 매번 새로
|
||||||
|
구현하는건 없는지 유지 보수, 확장 차원에서 부족한건 없는지 검토하자
|
||||||
|
|
||||||
|
Summary: |
|
||||||
|
JSP 프론트엔드 26개 파일 전수 검토. CSS 토큰 23개 파일 중복(5,969줄),
|
||||||
|
JS 유틸 함수 8중복, BibimbapModal 패턴 3종 혼존,
|
||||||
|
recruit-form CSRF 폴백 갭(보안), 날짜 함수 편재 확인.
|
||||||
|
5개 권고안(P0×2, P1×2, P2×1) + AC 11개 + 문서화 완료.
|
||||||
|
|
||||||
|
Invocations:
|
||||||
|
- graphify-lookup-advisor: miss (JSP 범위 미포함)
|
||||||
|
- research-advisor: 5개 탐색 A-E 완료
|
||||||
|
- design-advisor: 권고안 + 파일 영향 맵 + AC 산출
|
||||||
|
- documentation-advisor: docs/analysis/ 기록
|
||||||
|
|
||||||
|
Decisions:
|
||||||
|
- CSS 중앙화: src/main/webapp/css/global.css (Tomcat DefaultServlet 서빙)
|
||||||
|
- JS 유틸: src/main/webapp/js/bibimbap-utils.js (window.BibimbapUtils)
|
||||||
|
- 날짜 유틸: src/main/webapp/js/bibimbap-date.js (window.BibimbapDate)
|
||||||
|
- CSRF 갭: recruit-form hidden input + 폴백 헤더 추가
|
||||||
|
- 빌드 도구 도입: 비목표 (별도 결정 사항)
|
||||||
|
|
||||||
|
verified_by_me:
|
||||||
|
- 코드 변경 0건 — 검토 전용 세션
|
||||||
|
- L1/L2: skip (코드 변경 없음)
|
||||||
|
|
||||||
|
needs_user_verification:
|
||||||
|
- 권고 구현 시 Phase 1 (login.jsp 1개 먼저 검증) 진행 권장
|
||||||
|
- recruit-form CSRF 수정은 P0 보안 이슈 — 우선 구현 권고
|
||||||
|
|
||||||
|
graph_refresh:
|
||||||
|
judgment: partial-stale
|
||||||
|
action: full scope 재생성 완료 (src/ + docs/)
|
||||||
|
result: 1996노드/4626엣지/104 communities, 12.5x token reduction
|
||||||
|
source_commit: 5e0f76b
|
||||||
|
generated_at: 2026-06-30T12:05:29+0900
|
||||||
|
|
||||||
|
user_signals:
|
||||||
|
positive: []
|
||||||
|
negative: []
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청 명확 — JSP 프론트엔드 컴포넌트 중복/유지보수/확장성 검토'
|
||||||
|
checked_at: "2026-06-30T11:33:00"
|
||||||
|
- advisor: graphify-lookup-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: '기존 그래프 산출물에 프론트엔드 분석 캐시 있을 수 있음'
|
||||||
|
checked_at: "2026-06-30T11:33:00"
|
||||||
|
result: 'miss — JSP/JS/CSS 파일이 graphify 스캔 범위 외'
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: 'graphify miss → 실제 JSP 탐색 필요. 중복 함수/CSS/패턴 발굴'
|
||||||
|
checked_at: "2026-06-30T11:35:00"
|
||||||
|
result: '5개 탐색 완료. CSS 23파일 중복, JS 헬퍼 8중복, CSRF 보안갭, 날짜함수 편재 확인'
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: '발견 사항 기반 개선 권고안 구조화 필요'
|
||||||
|
checked_at: "2026-06-30T11:50:00"
|
||||||
|
result: '5개 권고안 + 파일 영향 맵 + AC 11개 산출 완료'
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '검토 전용 태스크 — 구현은 사용자 결정 후 별도 세션'
|
||||||
|
checked_at: "2026-06-30T12:00:00"
|
||||||
|
- advisor: verification-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '코드 변경 0건 — AC는 설계 문서에 기록됨'
|
||||||
|
checked_at: "2026-06-30T12:00:00"
|
||||||
|
- advisor: documentation-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: '분석 결과를 docs/analysis/ 에 영구 기록'
|
||||||
|
checked_at: "2026-06-30T12:00:00"
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
---
|
||||||
|
phase: research
|
||||||
|
agent: research-advisor
|
||||||
|
agent_version: 2
|
||||||
|
generated_at: 2026-06-30T02:41:09Z
|
||||||
|
concerns: []
|
||||||
|
source_confidence: high
|
||||||
|
workers_spawned: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# 프론트엔드 중복/이슈 분석
|
||||||
|
|
||||||
|
## 주제
|
||||||
|
JSP 기반 프론트엔드 코드에서 컴포넌트 중복 구현, 유지보수·확장성 이슈 조사
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 확정 중복 패턴 (이미 파악된 사실)
|
||||||
|
|
||||||
|
### JS 함수 4중 중복 — admin 4개 파일
|
||||||
|
| 함수 | 파일 | 영향도 |
|
||||||
|
|---|---|---|
|
||||||
|
| `notify(message)` | admin-console, admin-jam-list, admin-post-categories, admin-unity-feeds 각각 선언 | Medium |
|
||||||
|
| `post(url, params)` | 동일 4개 파일. 구현이 약간씩 다름 (params 처리, _csrf 추가 여부) | High |
|
||||||
|
| `handleResult(res)`, `handleError()` | 동일 4개 파일 동일 패턴 | Medium |
|
||||||
|
|
||||||
|
신뢰도: 확인됨 (입력 사실 보존)
|
||||||
|
|
||||||
|
### BibimbapModal.alert 체크 인라인 반복
|
||||||
|
- `window.BibimbapModal && typeof window.BibimbapModal.alert === 'function'` 패턴이 15개 이상 파일에 인라인 반복
|
||||||
|
- 신뢰도: 확인됨 (입력 사실 보존, 탐색 E에서 구체 위치 확인)
|
||||||
|
|
||||||
|
### CSRF 토큰 추출 방식 불일치
|
||||||
|
- admin-console/jam-scoring/jam-detail: `request.getAttribute("csrfToken")` rawCsrf 패턴
|
||||||
|
- admin-post-categories/admin-unity-feeds: `(String) request.getAttribute("csrfToken")` 캐스팅
|
||||||
|
- signup/login: `CsrfTokens.getOrCreate(request.getSession())` 직접 호출
|
||||||
|
- JS에서 일부는 `<meta name="_csrf">` 읽기, 일부는 `<%= csrfTokenJs %>` 직접 출력
|
||||||
|
- 신뢰도: 확인됨 (입력 사실 보존)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS 중복 (탐색 A)
|
||||||
|
|
||||||
|
### 규모 요약
|
||||||
|
- 전체 27개 JSP 파일 중 26개에 인라인 `<style>` 블록 존재 (theme-init.jsp 제외)
|
||||||
|
- 누적 인라인 CSS: **5,969줄**
|
||||||
|
- 범위: footer.jsp 76줄 ~ game-detail.jsp 1,173줄
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 중복 패턴 1위 — `html {}` 라이트 테마 CSS 변수 선언 (23개 파일)
|
||||||
|
핵심 8개 색상 토큰(`--surface: #faf8f5`, `--card-bg: #fff`, `--text: #1a1a1a`, `--text-muted`, `--accent: #e8a54b`, `--border`, `--shadow`, `--accent-soft`)이 23개 파일에 동일 값으로 복사됨.
|
||||||
|
- 대표 위치: `admin-console.jsp:38`, `jam-list.jsp:21`, `login.jsp:12`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 중복 패턴 2위 — `html[data-theme="dark"] {}` 다크 테마 CSS 변수 선언 (23개 파일)
|
||||||
|
다크 팔레트(`#121212`, `#1e1e1e`, `#ece8e1`, `#a39e96`, `rgba(255,255,255,0.1)`)가 23개 파일에 동일 반복.
|
||||||
|
- 대표 위치: `admin-console.jsp:51`, `posts-list.jsp:41`, `recruit-list.jsp:34`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 중복 패턴 3위 — `body {}` 기본 리셋 + 폰트 + 배경 (23개 파일)
|
||||||
|
`margin:0; min-height:100vh; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif; background:var(--surface); color:var(--text);` 블록이 23개 파일에 동일 반복.
|
||||||
|
- 대표 위치: `admin-console.jsp:62`, `jam-list.jsp:41`, `terms.jsp:29`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 중복 패턴 4위 — 페이지 컨테이너 `safe-area` padding 패턴 (25개 파일)
|
||||||
|
`padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));` 가 거의 모든 페이지에 반복. max-width 값만 48/56/64/72/78rem으로 파일별 상이.
|
||||||
|
- 대표 위치: `admin-console.jsp:70-72`, `posts-list.jsp:58-60`, `recruit-list.jsp:52-54`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 중복 패턴 5위 — `.admin-btn` / `.admin-btn--primary` / `.admin-btn--danger` (4개 admin 파일)
|
||||||
|
버튼 컴포넌트 CSS 20줄 이상이 4개 admin 파일에 동일 복사.
|
||||||
|
- 위치: `admin-console.jsp:171-194`, `admin-jam-list.jsp:167-190`, `admin-post-categories.jsp:100`, `admin-unity-feeds.jsp:108`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 중복 패턴 6위 — `.detail-button` / `.detail-actions` / `.detail-section` (2개 파일)
|
||||||
|
- `jam-detail.jsp:128`, `recruit-detail.jsp:116` — 거의 동일 (color 값 1곳만 다름: `#1a1a1a` vs `var(--button-text)`)
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### CSS Grid 리스트 래퍼 패턴 (posts·recruit 동일)
|
||||||
|
- `posts-list.jsp:119-123` — `.posts-grid { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:1rem; }`
|
||||||
|
- `recruit-list.jsp:143-147` — `.recruit-grid { ... }` 동일 구조
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리스트 페이지 패턴 (탐색 B)
|
||||||
|
|
||||||
|
### 카드 HTML 구조 비교
|
||||||
|
| 항목 | posts-list.jsp | recruit-list.jsp | jam-list.jsp |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 래퍼 클래스 | `.posts-grid` (section) | `.recruit-grid` (section) | `.jam-grid` (div) |
|
||||||
|
| Grid 열 | `repeat(3, minmax(0,1fr))` | `repeat(3, minmax(0,1fr))` | `repeat(auto-fill, minmax(16rem,1fr))` |
|
||||||
|
| 카드 태그 | `<a class="post-card">` | `<a class="recruit-card">` | `<a class="jam-card">` |
|
||||||
|
| 이미지 | 있음 (16/9 aspect-ratio) | 없음 | 없음 |
|
||||||
|
| 데이터 속성 | 없음 | `data-role`, `data-type`, `data-search` | 없음 |
|
||||||
|
|
||||||
|
신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 검색/필터 방식 비교
|
||||||
|
- **posts-list.jsp** — 검색 UI 없음. 카테고리 탭(`<nav class="posts-tabs">`)은 `<a>` 링크 방식으로 서버 재요청(`?categoryId=`). JS 없음.
|
||||||
|
- **recruit-list.jsp** — `<input type="search" id="recruit-search">` (라인 261) + 역할·참여형태 `<button class="filter-chip">` (aria-pressed 토글). `applyFilters()` 함수가 `card.hidden`으로 클라이언트 필터 (라인 328-340). 검색 텍스트는 서버가 `data-search` 속성에 pre-encode.
|
||||||
|
- **jam-list.jsp** — 검색/필터 모두 없음.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 페이지네이션 방식 비교
|
||||||
|
- **posts-list.jsp** — 커서 기반 "더보기" 링크. 서버 Java가 `cursorCreatedAt`, `cursorId` 파라미터를 URL로 조립 (라인 263-270). JS 없음.
|
||||||
|
- **recruit-list.jsp** — 페이지네이션 없음. 전체 목록 한 번에 렌더.
|
||||||
|
- **jam-list.jsp** — 커서 기반 "더보기" 링크. 서버 Java가 `cursor` 파라미터 URL 조립 (라인 177-180). JS 없음.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 공통 중복 블록
|
||||||
|
- CSS 변수 + body 스타일 블록: 세 파일 모두 동일 (상위 CSS 중복 패턴과 동일)
|
||||||
|
- 히어로 섹션 flex 레이아웃: posts·recruit 동일 (`align-items:flex-end; justify-content:space-between; gap:1rem`)
|
||||||
|
- 쓰기 버튼 스타일: posts·recruit 동일 (`min-height:2.75rem; border-radius:10px; background:var(--accent)`)
|
||||||
|
- 카드 hover 효과: posts·recruit 동일, jam은 transform 없이 border-color만
|
||||||
|
- empty-state 박스: 세 파일 유사 (border-style만 dashed vs solid 차이)
|
||||||
|
- 반응형 미디어 쿼리(3열→2열→1열): posts·recruit 동일 (@media 900px/640px)
|
||||||
|
- `request.getContextPath()` + 목록 attribute 캐스팅 패턴: 세 파일 공통
|
||||||
|
- `<jsp:include page="/WEB-INF/views/header.jsp"/>` / `footer.jsp`: 세 파일 동일
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 폼 페이지 패턴 (탐색 C)
|
||||||
|
|
||||||
|
### 공통 골격 (거의 복사-붙여넣기 수준)
|
||||||
|
두 파일은 fetch 기반 비동기 폼 제출, `checkValidity()` 검증, `BibimbapModal.alert` 성공/실패 처리, `BibimbapCsrf` CSRF 헤더 주입이라는 동일한 골격을 공유.
|
||||||
|
|
||||||
|
| 항목 | posts-form.jsp | recruit-form.jsp |
|
||||||
|
|---|---|---|
|
||||||
|
| body 구성 | `new URLSearchParams(new FormData(form))` (라인 232) | 동일 (라인 383) |
|
||||||
|
| Content-Type | `application/x-www-form-urlencoded;charset=UTF-8` (라인 225) | 동일 (라인 387) |
|
||||||
|
| 클라이언트 검증 | `form.checkValidity()` + `form.reportValidity()` (라인 219-222) | 동일 (라인 378-381) |
|
||||||
|
| res.ok 판정 | `if (!res.ok) { var error = new Error(...); error.status = res.status; throw error; }` (라인 241-246) | 동일 (라인 400-404) |
|
||||||
|
| 성공 리다이렉트 | `window.location.href = ctx + (data.location \|\| '/posts')` (라인 250) | `window.location.href = '<%= ctx %>' + (data.location \|\| '/recruit')` (라인 409) |
|
||||||
|
|
||||||
|
신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 차별화 부분 (다른 점)
|
||||||
|
1. **CSRF 처리 방식 불일치 (High)**
|
||||||
|
- `posts-form.jsp:169` — `<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">` hidden input 존재 + JS 변수 이중 포함 (`posts-form.jsp:211`, `229`)
|
||||||
|
- `recruit-form.jsp:386-394` — hidden CSRF input 없음. `BibimbapCsrf` 미존재 시 fallback 헤더에 CSRF 토큰이 전혀 포함되지 않는 보안 갭
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
2. **성공 시 모달 없을 때 분기 차이**
|
||||||
|
- `posts-form.jsp:260-261` — `} else { go(); }` (alert 없음)
|
||||||
|
- `recruit-form.jsp:419-421` — `} else { alert('팀원 모집글이 등록되었습니다.'); go(); }`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
3. **401 리다이렉트 처리 패턴 불일치**
|
||||||
|
- `posts-form.jsp:264-268` — `redirectLogin` 함수 분리, `onConfirm`에서 호출
|
||||||
|
- `recruit-form.jsp:430-431, 437-439` — 모달 `onConfirm`과 else 분기 양쪽에 각각 인라인 중복 작성
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
4. **수정 모드**: `posts-form.jsp:9-10, 215` — `mode`, `postId` 로 등록/수정 URL 분기. `recruit-form.jsp`는 수정 모드 분기 없음.
|
||||||
|
5. **실시간 미리보기**: `recruit-form.jsp:340-373` — `pairs` 배열 + `render()` + input/change 이벤트로 우측 preview-card 갱신. posts-form에는 없음.
|
||||||
|
6. **제출 버튼 disabled 처리**: 두 파일 모두 없음. fetch 중 비활성화 처리 미구현.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 날짜 함수 (탐색 D)
|
||||||
|
|
||||||
|
### 발견 요약
|
||||||
|
- **날짜/시간 포맷 JavaScript 함수가 선언된 파일**: `game-detail.jsp` 단 하나
|
||||||
|
- 나머지 6개 파일(posts-detail, recruit-detail, jam-detail, posts-list, jam-list, recruit-list)에는 날짜 포맷 JS 함수가 전혀 없음
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### game-detail.jsp 날짜 함수 (라인 1516-1551)
|
||||||
|
| 함수 | 위치 | 구현 방식 |
|
||||||
|
|---|---|---|
|
||||||
|
| `fmtAbsolute(iso)` | `game-detail.jsp:1516-1519` | `new Date(iso).toLocaleString('ko-KR')` |
|
||||||
|
| `fmtRelative(iso)` | `game-detail.jsp:1521-1536` | 경과 시간 직접 산술 계산 (60/3600/86400초 분기) |
|
||||||
|
| `buildTimeEl(iso, edited)` | `game-detail.jsp:1538-1551` | 두 함수 합성, `<time>` 요소 + "(수정됨)" 뱃지 반환 |
|
||||||
|
|
||||||
|
- `Intl.RelativeTimeFormat` / `Intl.DateTimeFormat` 직접 사용: 조사 범위 7개 파일 전체에서 **0 hit**
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 사용 위치
|
||||||
|
- `game-detail.jsp:1878` — `buildTimeEl` 사용 (댓글 항목)
|
||||||
|
- `game-detail.jsp:2183-2186` — `fmtAbsolute`/`fmtRelative` 직접 사용 (리뷰 항목)
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 서버사이드 날짜 포매팅
|
||||||
|
- 조사 범위 내 Java Date 포매팅으로 화면 출력하는 경우: 0건
|
||||||
|
- `posts-list.jsp:6-7,19` — `OffsetDateTime` import하지만 커서 URL 생성에만 사용 (화면 출력 아님)
|
||||||
|
- `jam-detail.jsp:442` — `OffsetDateTime.now().isAfter(...)` 서버사이드 분기 조건 (화면 포맷 출력 아님)
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 구조적 위험
|
||||||
|
`fmtAbsolute`/`fmtRelative`/`buildTimeEl`이 game-detail.jsp 인라인 스크립트에만 존재 → 다른 detail 페이지가 동일 기능이 필요할 때 복사 중복 발생 구조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 모달 패턴 (탐색 E)
|
||||||
|
|
||||||
|
### BibimbapModal API (modal.jsp:174-238)
|
||||||
|
| 메서드 | 시그니처 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| `alert(options)` | `{ title, message, confirmText, onConfirm }` | 취소 버튼 없음, 확인 버튼만 |
|
||||||
|
| `confirm(options)` | `{ title, message, confirmText, cancelText, onConfirm, onCancel }` | 확인+취소 양쪽 콜백 |
|
||||||
|
| `prompt(options)` | `{ title, message, label, value, placeholder, maxLength, confirmText, cancelText, onConfirm(inputValue), onCancel }` | 텍스트 입력 필드 포함 |
|
||||||
|
|
||||||
|
- 구현 방식: 단일 `<div id="site-modal">` 재사용, 콜백 기반 (Promise 아님), Enter=확인/Escape=취소 키보드 처리, 포커스 복귀
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### 모달 사용 패턴 분류 (12개 JSP, modal.jsp 제외)
|
||||||
|
|
||||||
|
**패턴 A — `openModal()` 헬퍼 래퍼 (4개 파일, High 중복)**
|
||||||
|
`login.jsp`, `signup.jsp`, `game-register.jsp`, `profile.jsp`가 동일 시그니처의 `openModal(title, message, confirmText, onConfirm)` 로컬 함수를 각자 정의.
|
||||||
|
- 내부 BibimbapModal 체크 후 폴백 `alert(message)` + 동기 `onConfirm()` 호출
|
||||||
|
- 위치: `login.jsp:256`, `signup.jsp:269`, `game-register.jsp:490`, `profile.jsp:539`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
**패턴 B — `notify()` 헬퍼 래퍼 (4개 admin 파일, High 중복)**
|
||||||
|
`admin-console.jsp`, `admin-jam-list.jsp`, `admin-post-categories.jsp`, `admin-unity-feeds.jsp`가 `notify(message)` 로컬 함수를 각자 정의 (alert 전용, title은 하드코딩된 페이지명).
|
||||||
|
- 위치: `admin-jam-list.jsp:389`, `admin-console.jsp:401`, `admin-post-categories.jsp:251`, `admin-unity-feeds.jsp:305`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
**패턴 C — 인라인 직접 체크 (3개 파일)**
|
||||||
|
`recruit-form.jsp`, `posts-form.jsp`, `game-detail.jsp`가 호출 지점마다 인라인으로 체크.
|
||||||
|
- `recruit-form.jsp:411, 424`, `posts-form.jsp:252, 269`, `game-detail.jsp:1425, 1439, 1474, 1482, 1553, 1558`
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
**특이 케이스 — game-detail.jsp 혼용**
|
||||||
|
`game-detail.jsp:1553-1561`에 `notifyError()`, `confirmAction()` 헬퍼를 별도 정의하면서, 동일 파일 `1425-1449`에 인라인 체크도 병존. 단일 파일 내 두 패턴 혼재.
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
### window.confirm/alert 직접 사용 (BibimbapModal 없이)
|
||||||
|
| 종류 | 위치 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| `window.confirm()` 직접 호출 (체크 없음) | `admin-post-categories.jsp:323` | 삭제 확인 대화상자 |
|
||||||
|
| `window.confirm()` 직접 호출 (체크 없음) | `admin-unity-feeds.jsp:401` | 삭제 확인 대화상자 |
|
||||||
|
| `window.alert()` 폴백 (else 브랜치) | 전 파일의 BibimbapModal 없을 때 else 브랜치 | 정상 폴백 |
|
||||||
|
| `window.prompt()` 폴백 | `profile.jsp:686` | BibimbapModal.prompt 실패 시 |
|
||||||
|
|
||||||
|
- `admin-post-categories.jsp`와 `admin-unity-feeds.jsp`는 알림에는 `notify()` 헬퍼(BibimbapModal 체크)를 쓰면서 삭제 확인에는 `window.confirm()`을 직접 사용하여 동일 파일 내 불일치 존재
|
||||||
|
- 신뢰도: 확인됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 종합 판단
|
||||||
|
|
||||||
|
### 상위 패턴
|
||||||
|
1. **CSS 인라인 토큰 중복이 가장 심각**: 라이트/다크 테마 CSS 변수 블록과 `body` 기본 스타일이 23개 파일에 동일하게 복사됨. 디자인 토큰 변경 시 23개 파일을 모두 수정해야 하는 유지보수 고비용 구조. `theme-init.jsp`가 존재하나 CSS 변수는 여기에 없고 JS 테마 초기화만 담당하는 것으로 보임.
|
||||||
|
|
||||||
|
2. **JS 유틸리티 함수 인라인 중복 이중 구조**: `notify()` (admin 4개), `openModal()` (일반 4개), `post(url, params)` (admin 4개) 등이 페이지마다 별도 선언됨. 특히 `post()` 함수는 파일마다 구현이 미묘하게 달라 동작 불일치 위험 내재.
|
||||||
|
|
||||||
|
3. **BibimbapModal 체크 패턴 3가지 혼존**: 헬퍼 래퍼 A, 헬퍼 래퍼 B, 인라인 직접 체크 C가 서로 다른 파일에 혼재. 일관성 없고 신규 페이지 작성 시 어떤 패턴을 따라야 할지 기준 없음.
|
||||||
|
|
||||||
|
4. **폼 제출 골격 복사**: posts-form과 recruit-form이 fetch 제출 코드를 거의 복사 수준으로 공유하면서 CSRF 처리 방식에 불일치 존재 (recruit-form의 BibimbapCsrf 미존재 시 CSRF 토큰 누락 위험).
|
||||||
|
|
||||||
|
5. **날짜 포맷 함수 편재**: game-detail.jsp에만 날짜 함수가 존재하며 다른 detail 페이지는 날짜 렌더링 자체가 없거나 JS 미사용. 추후 다른 페이지에서 날짜 포맷이 필요해지면 복사 중복 발생 구조.
|
||||||
|
|
||||||
|
### 충돌/갭
|
||||||
|
- CSS 변수 선언이 파일마다 포함하는 변수 수가 8~14개로 약간씩 달라, 특정 파일에서 일부 변수가 미선언된 상태일 수 있음.
|
||||||
|
- `admin-post-categories.jsp`와 `admin-unity-feeds.jsp`는 알림에는 BibimbapModal 체크를 쓰면서 삭제 확인은 `window.confirm()` 직접 사용 — 같은 파일 내 불일치.
|
||||||
|
- `jam-detail.jsp:128`과 `recruit-detail.jsp:116`의 `.detail-button--primary color` 값 불일치 (`#1a1a1a` vs `var(--button-text)`) — 디자인 일관성 갭.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 미발견 / 조사 불충분 항목
|
||||||
|
|
||||||
|
- `theme-init.jsp` 내부 구현 미탐색 — JS 테마 초기화 방식과 CSS 변수 일원화 가능성 미확인
|
||||||
|
- `header.jsp`, `footer.jsp`에서 이미 CSS 변수를 선언하는지 여부 미탐색 — 이중 선언 여부 불명
|
||||||
|
- `modal.jsp`가 실제로 어느 레이아웃 include 경로를 통해 로드되는지 미탐색 — BibimbapModal 미존재 상황의 실제 발생 가능성 미확인
|
||||||
|
- game-detail.jsp 외 admin-* 파일들의 날짜 렌더링 패턴 미탐색
|
||||||
|
- `BibimbapCsrf` 실제 구현체(별도 JS 파일) 미탐색 — `headers()` 메서드 폴백 동작 미확인
|
||||||
|
- posts-form.jsp의 `post()` 함수와 admin 4개 파일의 `post()` 함수가 실제로 다른 구현인지 정밀 비교 미수행
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
# ATP Session Report
|
||||||
|
schema_version: v2
|
||||||
|
sid: 20260630-123347
|
||||||
|
user_request: "가이드라인에 작업에있어서 빠르고 쉬운길보단 느리더라도 확실한 길로 가게끔 문구 추가하자"
|
||||||
|
started_at: 2026-06-30T12:33:47
|
||||||
|
|
||||||
|
## Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청 명확 — "CLAUDE.md 작업 원칙에 확실한 경로 우선 원칙 추가"'
|
||||||
|
checked_at: 2026-06-30T12:33:47
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '대상 파일(CLAUDE.md) 이미 확인 완료. 외부 조사 불필요.'
|
||||||
|
checked_at: 2026-06-30T12:33:47
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '단일 bullet point 추가 — 파일 영향 맵 자명, 설계 산출 불필요'
|
||||||
|
checked_at: 2026-06-30T12:33:47
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '마이크로 편집(1줄 추가) — orchestrator 직접 수행 허용'
|
||||||
|
checked_at: 2026-06-30T12:33:47
|
||||||
|
|
||||||
|
## Invocations: []
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
CLAUDE.md 작업 원칙 섹션에 "확실한 경로 우선" 원칙 1줄 추가.
|
||||||
|
commit: a499fd9
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- L1: skip (docs-only, 코드 변경 0)
|
||||||
|
- L2: skip (동일 사유)
|
||||||
|
- graph_refresh: skip (코드 변경 없음)
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
(없음)
|
||||||
|
|
||||||
|
## open_items
|
||||||
|
- .atp/work-session/{20260630-103443,20260630-105459}/ untracked — 이전 세션 산출물, 이번 범위 외
|
||||||
|
|
||||||
|
## user_signals
|
||||||
|
positive: []
|
||||||
|
negative: []
|
||||||
|
|
||||||
|
ended_at: 2026-06-30T12:34:00
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
phase: documentation
|
||||||
|
agent: documentation-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: "2026-06-30T15:00:00+09:00"
|
||||||
|
concerns: []
|
||||||
|
concerns_checked: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 문서화 보고
|
||||||
|
|
||||||
|
## 작성/수정된 문서
|
||||||
|
|
||||||
|
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `docs/changes/2026-06-30-screenshot-guide-auth.md` | changes | 신규 | `docs/changes/index.md` | `docs/development/screenshot-guide.md` → 변경 이력 섹션 |
|
||||||
|
| `docs/changes/index.md` | changes | 수정(링크 추가) | — | — |
|
||||||
|
| `docs/development/screenshot-guide.md` | development | 수정(교차 링크 추가) | — | changes 문서로 역링크 |
|
||||||
|
| `.atp/work-session/20260630-141415/documentation.md` | work-session | 신규 | — | — |
|
||||||
|
|
||||||
|
## 의사결정 기록 위치
|
||||||
|
|
||||||
|
- 카테고리 판별: 스크린샷 가이드 도구의 기능 확장은 런타임 동작 변경에 해당 → `docs/changes/`
|
||||||
|
- `docs/development/screenshot-guide.md` 자체가 이미 업데이트 완료 상태이므로 중복 작성 없이 교차 링크만 추가
|
||||||
|
|
||||||
|
## 추후 문서화가 필요한 항목
|
||||||
|
|
||||||
|
- 인증 모드 스크린샷 실제 실행 결과(14장 생성) 확인 후 `needs_user_verification` 항목 닫기
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
# ATP Work Session Report
|
||||||
|
schema_version: "2"
|
||||||
|
sid: "20260630-141415"
|
||||||
|
started_at: "2026-06-30T14:14:15"
|
||||||
|
ended_at: "2026-06-30T14:20:00"
|
||||||
|
|
||||||
|
## User Request
|
||||||
|
cmux 캡처 권한 허용 + cmux 재시작 후 스크린샷 가이드 재검토.
|
||||||
|
- 문제: 리뷰 작성, 댓글 작성, 로그인 전/후 차이 스크린샷 불가했음
|
||||||
|
- 스크린샷 위치: /tmp/bibimbap-screenshots/ (7장)
|
||||||
|
- 재실행: python3 docs/development/screenshot-guide.py
|
||||||
|
- 문서: docs/development/screenshot-guide.md
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
screenshot-guide.py 확장 — 비인증 7장 → 인증 포함 최대 14장.
|
||||||
|
로그인 AJAX 흐름 대응, 리뷰/댓글 composer hidden 강제 노출 전략 적용.
|
||||||
|
SCREENSHOT_EMAIL/PASSWORD env var로 실행 분기.
|
||||||
|
|
||||||
|
## Advisor Invocation Decision Log
|
||||||
|
```yaml
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청 명확 — 세부 화면 상태 캡처 방법 조사 + 구현'
|
||||||
|
checked_at: "2026-06-30T14:14:30"
|
||||||
|
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: '현재 7장 내용 파악 + 대안 캡처 방법(Playwright 인증 세션, 직접 URL 이동 등) 조사 필요'
|
||||||
|
checked_at: "2026-06-30T14:14:30"
|
||||||
|
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 'Playwright 로그인 → 세션 → hidden 요소 JS 노출 경로 확정적. 파일 하나 수정'
|
||||||
|
checked_at: "2026-06-30T14:16:00"
|
||||||
|
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '단일 파일 수정(screenshot-guide.py) + 문서 업데이트. orchestrator 직접 수행'
|
||||||
|
checked_at: "2026-06-30T14:16:00"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invocations: []
|
||||||
|
|
||||||
|
## Decisions: []
|
||||||
|
|
||||||
|
## verified_by_me:
|
||||||
|
- L1: 비인증 모드 실행 통과 (7장 생성 확인)
|
||||||
|
- L2: 인증 모드 — SCREENSHOT_EMAIL/PASSWORD 미제공으로 skip (테스트 계정 필요)
|
||||||
|
|
||||||
|
## needs_user_verification:
|
||||||
|
- SCREENSHOT_EMAIL=<계정> SCREENSHOT_PASSWORD=<pw> python3 docs/development/screenshot-guide.py 로 인증 모드 실행 후 08~14번 파일 생성 확인
|
||||||
|
|
||||||
|
## graph_refresh: skip: no-graphify (docs-only 변경, src/ 미수정)
|
||||||
|
|
||||||
|
## open_items: []
|
||||||
|
|
||||||
|
## user_signals:
|
||||||
|
positive: []
|
||||||
|
negative: []
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# ATP Session Report
|
||||||
|
|
||||||
|
schema_version: v2
|
||||||
|
sid: 20260630-143405
|
||||||
|
started_at: 2026-06-30T14:34:05
|
||||||
|
ended_at: ~
|
||||||
|
user_request: "테스트용 계정 하나 더 파고 기록하자. 기존 계정은 일반 사용자용, 하나는 관리자 테스트용"
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
관리자 테스트 계정(admin@bibimbap.local / test1234! / role=ADMIN)을 seed-dev.sql에 추가.
|
||||||
|
screenshot-guide.md 계정 표를 용도·role 포함 2행으로 확장. commit: 925eb73
|
||||||
|
|
||||||
|
## Advisor Invocation Decision Log
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 요청 명확 — 관리자 계정 생성 + 기록, 스펙 불명 없음
|
||||||
|
checked_at: 2026-06-30T14:34:05
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: seed-dev.sql + rbac-ddl.sql + screenshot-guide 직접 확인으로 충분
|
||||||
|
checked_at: 2026-06-30T14:36:00
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 파일 영향 맵 확정(2파일), 신규 로직 없음, 마이크로 편집
|
||||||
|
checked_at: 2026-06-30T14:36:00
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: orchestrator 직접 수행(2파일 이내 마이크로 편집)
|
||||||
|
checked_at: 2026-06-30T14:36:00
|
||||||
|
- advisor: verification-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: SQL 변경 없음(DDL 아닌 seed), 코드 변경 0줄 — 문서+데이터 파일만
|
||||||
|
checked_at: 2026-06-30T14:40:00
|
||||||
|
- advisor: documentation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: screenshot-guide.md 직접 수정으로 기록 완료
|
||||||
|
checked_at: 2026-06-30T14:40:00
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invocations: []
|
||||||
|
|
||||||
|
## Decisions: []
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- L1: skip (src/ 코드 변경 없음 — seed SQL + 문서 파일만)
|
||||||
|
- 로그 스캔: clean
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
seed 적용 후 실제 DB에서 로그인 확인:
|
||||||
|
```
|
||||||
|
docker exec -i bibimbap-db psql -U bibimbap -d bibimbap -v ON_ERROR_STOP=1 < db/seed-dev.sql
|
||||||
|
# 이후 http://localhost:8080/ 에서 admin@bibimbap.local / test1234! 로 로그인
|
||||||
|
```
|
||||||
|
|
||||||
|
## graph_refresh: skip (no-graphify)
|
||||||
|
## open_items: []
|
||||||
|
## user_signals:
|
||||||
|
positive: []
|
||||||
|
negative: []
|
||||||
|
ended_at: 2026-06-30T14:41:00
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 작업용 프롬프트 — 클로드 디자인 리디자인 산출물 반영 (새 세션용)
|
||||||
|
|
||||||
|
> 이 문서는 검토 세션(sid 20260630-153323)의 산출물이다. **새 세션에 아래 "프롬프트" 블록을 그대로 붙여넣어** 실제 구현을 진행한다. 코드 변경은 새 세션에서 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 한 줄 요약
|
||||||
|
|
||||||
|
외부 클로드 디자인 툴이 만든 리디자인 산출물(`Bibimbap 커뮤니티 웹사이트 리디자인.zip` — 7 JSP + `bibimbap.css` + 3 jspf + error.jsp)을 우리 프로젝트에 반영한다. **단, 파일 통째 교체가 아니라 "공유 `bibimbap.css` 신설 + 디자인의 클래스/구조 개선을 기존 JSP에 입히는 비주얼 레이어 방식"** 으로 한다. 산출물의 라우트·CSRF·include·모델명은 전부 우리 실제와 다르므로 그대로 쓰면 깨진다.
|
||||||
|
|
||||||
|
## 1. 산출물 위치
|
||||||
|
- zip: `/Users/wemadeplay/Downloads/Bibimbap 커뮤니티 웹사이트 리디자인.zip`
|
||||||
|
- 압축 해제본(검토 세션이 풀어둠): `<scratchpad>/redesign/jsp/...` (새 세션에선 zip을 다시 풀어 참조)
|
||||||
|
- 구성: `jsp/css/bibimbap.css`(15KB, 핵심), `jsp/01-home.jsp` `02-login` `03-signup` `06-game-detail` `07-terms` `08-profile` `10-recruit-form`, `jsp/error/error.jsp`, `jsp/fragments/{posts-empty,recruit-empty,review-comment-form}.jspf`. (preview.html은 zip에 없음)
|
||||||
|
|
||||||
|
## 2. 디자인 산출물이 우리 현실과 다른 점 (그대로 쓰면 안 되는 이유)
|
||||||
|
|
||||||
|
| 항목 | 디자인 가정 | 우리 실제 (근거) |
|
||||||
|
|---|---|---|
|
||||||
|
| context path | `${ctx}` | `${pageContext.request.contextPath}` (`header.jsp:197`) — `${ctx}` 미정의 |
|
||||||
|
| header include | `/WEB-INF/views/common/header.jsp` + header가 `<html><head>`를 연다고 가정 | `/WEB-INF/views/header.jsp` (common/ 없음). header.jsp는 doctype/head 없이 **nav 마크업 + 자체 style만**(`header.jsp:1-20`). 각 페이지가 자체 `<!DOCTYPE><html><head>` 보유 |
|
||||||
|
| 폼 제출 | classic `<form method=post action=/login>` | 로그인·회원가입·로그아웃·리뷰·모집은 **JSON API + JS AJAX 제출**. login.jsp는 `window.BibimbapCsrf`로 제출(`login.jsp:286`) |
|
||||||
|
| CSRF | 토큰 전무 | `CsrfTokens.getOrCreate(session)` → 폼은 hidden `<input name="_csrf">`(`login.jsp:211,224`), AJAX는 `window.BibimbapCsrf.headers()` 헤더(`theme-init.jsp:19-27`). **상태변경 전부 필수** |
|
||||||
|
| 게임 라우트 | `/games`, `/games/{id}` (복수) | `/game/{id}` (**단수**, `GameController.java:136`). 게임목록 전용 `/games` 라우트 없음 — 홈 `/`가 목록(`WebMvcController:70`) |
|
||||||
|
| 리뷰/댓글 | 폼 POST `/games/{id}/reviews` | JSON API `/game/{id}/reviews` (GET/POST/PUT/DELETE, `GameReviewController:73~`) — JS로 소비 |
|
||||||
|
| 모집 작성 | POST `/recruit` | `/recruit/new` POST(JSON, location 반환, `RecruitController:49`). 기존 recruit-form.jsp도 AJAX |
|
||||||
|
| 프로필/비번찾기 | `/profile`, `/password/reset` | `/profile` 존재(`/{pageName}`→profile.jsp). **`/password/reset` 라우트 없음** → 디자인의 "비밀번호 찾기" 링크는 기능 미존재 |
|
||||||
|
| 에러 페이지 | `error/error.jsp` + web.xml `<error-page>` | **web.xml 없음**(Spring Boot WAR). `/error`→뷰 **`errer`**(오타 파일명 그대로, `WebMvcController:47-55`), 모델 `${statusCode}` |
|
||||||
|
| CSS | 외부 `bibimbap.css` link 가정 | css/js 디렉토리 **미존재**. 현재 스타일은 각 페이지/header.jsp 인라인 `<style>` |
|
||||||
|
| 폰트 | Pretendard·JetBrains Mono (css엔 @import 없음) | 프로젝트 제약 "외부 라이브러리 0" → CDN 금지. 미설정 시 system 폰트로 degrade |
|
||||||
|
|
||||||
|
ALLOWED_PAGES(제너릭 페이지 서버) = `error, login, profile, signup, terms, operation-policy, ""` (`WebMvcController` 내). game-detail/recruit/posts는 전용 컨트롤러.
|
||||||
|
|
||||||
|
## 3. 결정된 접근 (사용자 승인됨)
|
||||||
|
- CSS: **공유 `bibimbap.css` 신설** → `src/main/webapp/css/bibimbap.css` (Tomcat DefaultServlet 서빙, CLAUDE.md 정책).
|
||||||
|
- 파일 통째 교체 **금지**. 특히 `game-detail.jsp`(90KB, WebGL 로더·6축 평가·리뷰 CRUD·CSRF 내장) — 디자인 06은 단순 iframe 목업이라 통째 교체 시 기능 소실. **CSS 클래스/구조 개선만 이식**.
|
||||||
|
- 범위: 우선순위 일부부터(아래 5절 순서). 1차 검증 후 확장.
|
||||||
|
|
||||||
|
## 4. 하드 제약 (위반 금지)
|
||||||
|
1. 모든 상태변경(폼/AJAX)에 CSRF 적용 — 기존 `_csrf` hidden 또는 `window.BibimbapCsrf` 패턴 재사용. 디자인 폼엔 없으니 반드시 추가.
|
||||||
|
2. `${ctx}` → `${pageContext.request.contextPath}` 치환 (또는 각 페이지 head에 `<c:set var="ctx" value="${pageContext.request.contextPath}"/>` 1줄 추가 후 유지 — 택1, 일관).
|
||||||
|
3. include 경로 `common/` 제거 → `/WEB-INF/views/header.jsp`, `footer.jsp`. 디자인의 `<jsp:include header>`(head를 연다는 가정)는 **버리고**, 기존 페이지의 자체 head 구조 유지.
|
||||||
|
4. 외부 라이브러리/CDN 0. 폰트 CDN 추가 금지(system 폰트 fallback 허용, 또는 로컬 폰트 별도 합의).
|
||||||
|
5. MyBatis `#{}` 바인딩, JSP 출력 escape(`HtmlUtils.htmlEscape`/JSTL), 클라 렌더 `textContent` — 기존 보안 원칙 유지.
|
||||||
|
6. 모델 속성명은 디자인 placeholder(`${games}`,`${game.title}`,`${myGames}`,`${reviews}` 등)가 아니라 **각 페이지 기존 JSP가 실제 쓰는 이름**을 사용 — 구현 전 해당 JSP에서 확인.
|
||||||
|
|
||||||
|
## 5. 권장 작업 순서 (단위별 커밋)
|
||||||
|
1. **`bibimbap.css` 도입** (최저위험·최고가치): `src/main/webapp/css/bibimbap.css` 생성(zip의 css 복사, 폰트 줄은 system fallback로 조정). 전 페이지 head에서 로드되도록 **`theme-init.jsp`(각 페이지 head에 include되는지 먼저 확인) 또는 각 페이지 head**에 `<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">` 1줄. header.jsp/기존 인라인 `<style>`과 클래스 충돌 점검.
|
||||||
|
2. **Empty-state 파편 3종**: `posts-empty.jspf`·`recruit-empty.jspf`를 `/WEB-INF/views/fragments/`에 두고 `posts-list.jsp`/`recruit-list.jsp`의 빈 분기에 include. 라우트는 실제값(`/posts/new`, `/recruit/new`)으로 수정.
|
||||||
|
3. **홈(index.jsp)**: 히어로 + 정렬칩 + 등록유도 카드. 모델/라우트는 index.jsp 실제값(홈 `/`, 검색파라미터 `q`)에 맞춤.
|
||||||
|
4. **로그인/회원가입**: 디자인의 pw-toggle·강도막대·일치표시·에러슬롯 **마크업/클래스만** 기존 login.jsp/signup.jsp에 이식. 제출은 기존 AJAX+`window.BibimbapCsrf` 유지. `/password/reset` 링크는 라우트 신설 합의 전까지 비노출.
|
||||||
|
5. **약관(terms.jsp)**: sticky 목차+조항 카드 구조 적용. 본문은 기존 terms.jsp 전문 유지(디자인 전문으로 덮어쓰기 전 차이 확인).
|
||||||
|
6. **프로필(profile.jsp)**: 헤더+내 게임 행+공개토글. 공개토글 action은 실제 게임 visibility API(`/game/{id}` 계열, GameController 확인)로, CSRF 포함.
|
||||||
|
7. **게임상세(game-detail.jsp)**: ⚠️ 통째 교체 금지. detail-meta 한 줄·16:9 player·review-cta·별점 CSS만 이식, 기존 WebGL/6축/리뷰 로직·CSRF 보존.
|
||||||
|
8. **errer.jsp**: 디자인 error/error.jsp의 empty-state 403/404 마크업을 `errer.jsp`에 이식, `${statusCode}` 분기. 파일명·뷰명 `errer` 유지(컨트롤러 매핑).
|
||||||
|
9. **모집 작성(recruit-form.jsp)**: 글자수·필수표시·sticky 라이브 미리보기 이식. 제출은 `/recruit/new` POST + 기존 AJAX/CSRF.
|
||||||
|
|
||||||
|
## 6. 검증 (코드 변경 시 필수)
|
||||||
|
- L1: WAR 빌드(`./mvnw -q -DskipTests package` 또는 프로젝트 표준) 성공 + JSP EL/JSTL 문법 오류 0.
|
||||||
|
- L2: 로컬 기동 후 각 변경 화면 스모크 — 로그인/회원가입 제출 동작(CSRF 통과), 게임상세 WebGL 플레이/리뷰 작성, 모집 작성 제출. `docs/development/verification-strategies.md` 기준.
|
||||||
|
- 변경 후 `graph-refresh-checker` + docs 반영(docs-first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ▶▶ 새 세션에 붙여넣을 프롬프트
|
||||||
|
|
||||||
|
```
|
||||||
|
/atp:task 클로드 디자인 리디자인 산출물(zip: /Users/wemadeplay/Downloads/Bibimbap 커뮤니티 웹사이트 리디자인.zip)을
|
||||||
|
우리 프로젝트에 반영한다. 접근은 "파일 통째 교체가 아니라, 공유 src/main/webapp/css/bibimbap.css 신설 +
|
||||||
|
디자인의 클래스/구조 개선을 기존 WEB-INF/views JSP에 입히는 비주얼 레이어 방식".
|
||||||
|
|
||||||
|
검토 세션 핸드오프 문서를 먼저 읽어라: .atp/work-session/20260630-153323/artifacts/handoff-prompt.md
|
||||||
|
(디자인↔현실 불일치 표, 하드 제약, 작업 순서가 거기 정리돼 있다.)
|
||||||
|
|
||||||
|
하드 제약: (1) 모든 상태변경에 CSRF(_csrf hidden 또는 window.BibimbapCsrf) (2) ${ctx} 금지→
|
||||||
|
${pageContext.request.contextPath} (3) include는 /WEB-INF/views/header.jsp (common/ 없음), 각 페이지
|
||||||
|
자체 head 유지 (4) 외부 라이브러리/CDN 0 (5) game-detail.jsp 통째 교체 금지(WebGL/6축/리뷰 로직 보존)
|
||||||
|
(6) 모델 속성명은 디자인 placeholder가 아니라 각 기존 JSP 실제값 사용.
|
||||||
|
|
||||||
|
순서: 1) bibimbap.css 도입+head link 2) empty-state 파편 3종 3) 홈 4) 로그인/회원가입(마크업만, 제출은
|
||||||
|
기존 AJAX 유지) 5) 약관 6) 프로필 7) 게임상세(이식만) 8) errer.jsp(파일명 유지) 9) 모집작성.
|
||||||
|
각 단위 커밋 + 변경 화면 빌드/스모크 검증.
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# ATP Work Session Report
|
||||||
|
schema_version: 2.3.0
|
||||||
|
sid: 20260630-153323
|
||||||
|
started_at: 2026-06-30T15:33:23
|
||||||
|
user_request: |
|
||||||
|
클로드 디자인 툴 산출물(Bibimbap 커뮤니티 웹사이트 리디자인.zip — 7 JSP + bibimbap.css + 3 jspf + error.jsp)을
|
||||||
|
우리 프로젝트에 반영. 단 이번 세션은 "검토만" 하고, 실제 작업은 새 세션에서 진행할 수 있도록
|
||||||
|
"작업용 프롬프트"를 출력한다. (CSS는 공유 bibimbap.css 신설 방향)
|
||||||
|
ended_at: 2026-06-30T15:40
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
zip(7 JSP + bibimbap.css + 3 jspf + error.jsp) 전수 검토 + 우리 실제 코드와 design↔reality 매핑 완료.
|
||||||
|
핵심 발견: 라우트(/games 복수 vs 실제 /game 단수), 폼 제출(classic POST vs JSON API+AJAX),
|
||||||
|
CSRF(전무 vs CsrfTokens/_csrf/window.BibimbapCsrf), contextPath(${ctx} vs ${pageContext.request.contextPath}),
|
||||||
|
include(common/ 가정 vs 평면), 에러(web.xml/error.jsp vs errer 뷰), css 디렉토리(없음) 모두 불일치.
|
||||||
|
→ 결론: 파일 통째 교체 금지, 공유 bibimbap.css 신설 + 비주얼 레이어 이식. 산출물 artifacts/handoff-prompt.md. 코드 변경 0.
|
||||||
|
|
||||||
|
## Invocations
|
||||||
|
- research-advisor: stopped (워커 결과 미수신 루프) → orchestrator 직접 rg/Read 탐색으로 대체. read-only라 충돌 없음.
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- L1/L2: N/A (코드 변경 0 — 검토+문서 산출만)
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
- 새 세션에서 실제 구현 진행(handoff-prompt.md의 ▶ 프롬프트). 구현 시 L1 빌드 + L2 스모크 필수.
|
||||||
|
|
||||||
|
## graph_refresh
|
||||||
|
skip: 코드 변경 0
|
||||||
|
|
||||||
|
## project_gate
|
||||||
|
skip: no code change
|
||||||
|
|
||||||
|
## open_items
|
||||||
|
- .atp/work-session/20260630-153323/ (이 세션 산출물, 미커밋) — 커밋 여부 사용자 결정 대기.
|
||||||
|
- 무관 잔여: 이전 work-session 3건, graphify-out/.
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '사용자가 산출물/CSS방식/범위(검토만+프롬프트)를 AskUserQuestion으로 확정 — 스코프 명확.'
|
||||||
|
checked_at: 2026-06-30T15:33
|
||||||
|
- advisor: graphify-lookup-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '매핑 정확도 위해 실제 코드 직접 대조가 필요. research-advisor 직접 사용.'
|
||||||
|
checked_at: 2026-06-30T15:34
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: '7개 화면의 우리 실제 컨트롤러 라우트·모델 속성명·CSRF·include·정적 서빙을 매핑해야 프롬프트가 정확.'
|
||||||
|
checked_at: 2026-06-30T15:34
|
||||||
|
- advisor: design/implementation/verification-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '이번 세션 코드 변경 0(검토+프롬프트 산출만). 실제 구현은 새 세션.'
|
||||||
|
checked_at: 2026-06-30T15:34
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
---
|
||||||
|
phase: documentation
|
||||||
|
agent: documentation-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T17:00:00+09:00
|
||||||
|
concerns: []
|
||||||
|
concerns_checked: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 문서화 보고
|
||||||
|
|
||||||
|
## 작성/수정된 문서
|
||||||
|
|
||||||
|
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `docs/changes/2026-06-30-visual-redesign-bibimbap-css.md` | changes | 신규 | `docs/changes/index.md` | `docs/changes/2026-06-29-b2-b4-fe-hardening.md` (선행 FE 작업) |
|
||||||
|
| `docs/changes/index.md` | changes | 수정(링크 추가) | — | — |
|
||||||
|
|
||||||
|
## 의사결정 기록 위치
|
||||||
|
|
||||||
|
- 카테고리 판별: 런타임 동작 변경(CSS 신설·JSP 마크업 이식) → `docs/changes/`
|
||||||
|
- ADR 미생성: 비주얼 레이어 이식은 되돌리기 어려운 기술·아키텍처 결정 수준이 아님 (CSS + 마크업 클래스 추가 수준)
|
||||||
|
- 세션 보고서: `.atp/work-session/20260630-160000/report.md` (decisions D1~D4, needs_user_verification 6건 포함)
|
||||||
|
|
||||||
|
## 추후 문서화가 필요한 항목
|
||||||
|
|
||||||
|
- needs_user_verification 결과 확인 후 이상 없으면 별도 문서 불필요. 결함 발견 시 `docs/analysis/` 또는 새 changes 항목으로 기록.
|
||||||
|
- 다크모드 토큰 전략(bibimbap.css 구조)이 아키텍처 결정으로 고착된다면 `docs/adr/` 추가 검토.
|
||||||
|
- `NumberFormatException "write"` 선재 버그는 범위 밖 — 별도 세션에서 `docs/analysis/` 기록 권장.
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
phase: implementation
|
||||||
|
agent: implementation-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T16:30:00+09:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 파일 소유권 맵
|
||||||
|
|
||||||
|
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| src/main/webapp/WEB-INF/views/profile.jsp | implementation-advisor (직접) | - | modify | - |
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
phase: implementation
|
||||||
|
agent: implementation-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T16:30:00+09:00
|
||||||
|
concerns: []
|
||||||
|
concerns_checked: true
|
||||||
|
workers_spawned: 0
|
||||||
|
planned_workers: 1
|
||||||
|
actual_workers: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
# 구현 보고 — 단위 6: 프로필 비주얼 이식
|
||||||
|
|
||||||
|
## 변경 목록
|
||||||
|
| 파일 | worker | 결과 요약 |
|
||||||
|
|---|---|---|
|
||||||
|
| src/main/webapp/WEB-INF/views/profile.jsp | advisor 직접 | bibimbap.css 토큰 클래스 7곳 추가, AJAX/scriptlet/id 전부 보존 |
|
||||||
|
|
||||||
|
## Bash 단계 (advisor 직접)
|
||||||
|
- grep 검증 6종 → 전항목 통과
|
||||||
|
|
||||||
|
## 설계와의 차이
|
||||||
|
|
||||||
|
### planned_workers > actual_workers 전환 사유
|
||||||
|
파일 1개, 변경 유형 class 추가(기계적 6-8개 edit) → 파일 수 1 + 예상 변경 줄수 < 20 → advisor 직접 실행 선택. worker spawn 오버헤드 불필요.
|
||||||
|
|
||||||
|
### 디자인 08-profile.jsp 대비 이식/비이식 처리
|
||||||
|
- **이식**: `.section-eyebrow`(heading eyebrow), `.section-title`(heading title), `.profile-head`(summary wrapper 병행), `.avatar`(avatar div 병행), `.game-row`(game 행 병행), `.meta`(game body 병행), `.card`(games 패널 병행), `.btn .btn-primary`/`.btn .btn-ghost`(버튼/수정 링크 병행)
|
||||||
|
- **비이식(비노출)**: 공개/비공개 토글 `<form action=".../visibility">` — 엔드포인트 `/games/{id}/visibility` 미존재 확인(grep 무수확), `/profile/edit` — 라우트 미존재 확인, `${user.*}` EL — scriptlet 변수 보존
|
||||||
|
- **보존 확인**: `submitNickname()`/`uploadAvatar()` 함수, `window.BibimbapCsrf.headers()` 두 곳, `id="profile-avatar-img"`/`id="profile-avatar-initial"`, `getName()`/`getThumbnailUrl()` accessor, `rawThumbUrl.startsWith("/") ? ctx+rawThumbUrl : rawThumbUrl` 경로 처리
|
||||||
|
|
||||||
|
## Verification 을 위한 힌트
|
||||||
|
- acceptance criteria: 보존 grep 6종 전항목 통과(advisor 직접 확인 완료)
|
||||||
|
- 영향받는 테스트: 프로필 페이지 E2E (profile 렌더/닉네임변경/아바타업로드)
|
||||||
|
- 비주얼 확인: bibimbap.css 토큰 클래스가 기존 자체 CSS와 충돌 없이 병행 적용되는지 브라우저 확인 필요
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
---
|
||||||
|
schema_version: 2
|
||||||
|
sid: 20260630-160000
|
||||||
|
resumed_from: null
|
||||||
|
started_at: 2026-06-30T16:00:00+09:00
|
||||||
|
ended_at: 2026-06-30T17:20:00+09:00
|
||||||
|
branch: feat/v2
|
||||||
|
user_request: |
|
||||||
|
클로드 디자인 리디자인 산출물(zip)을 비주얼 레이어 방식으로 반영.
|
||||||
|
공유 src/main/webapp/css/bibimbap.css 신설 + 디자인 클래스/구조 개선을
|
||||||
|
기존 WEB-INF/views JSP에 입힘. 파일 통째 교체 금지.
|
||||||
|
하드 제약 6종(CSRF/contextPath/header include/CDN0/game-detail 보존/실모델명).
|
||||||
|
순서 9단계, 각 단위 커밋 + 빌드/스모크 검증.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
클로드 디자인 리디자인 산출물을 "비주얼 레이어" 방식으로 9단위 반영 완료.
|
||||||
|
공유 bibimbap.css 신설(다크 오버라이드 포함) + theme-init 단일 link로 전 페이지
|
||||||
|
적용. 빈상태 파편 2종, 홈/로그인/회원가입/약관/프로필/모집작성에 디자인 클래스·
|
||||||
|
마크업만 이식하고 모델명·CSRF·AJAX·include·자체 head는 전부 기존값 보존.
|
||||||
|
game-detail은 이미 동일 토큰 구현 상태라 no-op. errer는 외형만 이식(scriptlet 유지).
|
||||||
|
하드 제약 6종 전부 준수(디자인 form/EL/common·/ctx·미존재 라우트 미채택).
|
||||||
|
커밋 8개(단위별) + L2 스모크가 errer JSTL taglib 결함 1건 포착·수정.
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '핸드오프 문서가 요구·제약·순서를 확정. 오픈질문 없음.'
|
||||||
|
checked_at: 2026-06-30T16:00:30+09:00
|
||||||
|
- advisor: graphify-lookup-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '실제 JSP 파일 직접 읽기가 graph 인덱스보다 정확(모델명/CSRF 근거 필요). research 직행.'
|
||||||
|
checked_at: 2026-06-30T16:00:30+09:00
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: call
|
||||||
|
rationale: '9개 페이지 각각 실모델명/CSRF/head구조 + 디자인 산출물 클래스 매핑 추출 필요. parallel-explorer 분산.'
|
||||||
|
checked_at: 2026-06-30T16:00:30+09:00
|
||||||
|
|
||||||
|
# Invocations
|
||||||
|
- research-advisor (sonnet): 디자인↔실제 페이지별 매핑 spec. 5 parallel-explorer. source_confidence mixed.
|
||||||
|
- implementation-advisor ×6 (sonnet): 단위1 css / 단위2+3 빈상태+홈 / 단위4 인증 / 단위5+8 약관+errer / 단위6 프로필 / 단위9 모집 / 단위7 game-detail(no-op). 파일소유권 충돌 0.
|
||||||
|
- verification-advisor (sonnet): L1 WAR 빌드 PASS, css WAR 포함 PASS, JSP 사전컴파일 미설정→런타임 검증 필요 안내.
|
||||||
|
- graph-refresh-checker (sonnet): partial-stale 판정.
|
||||||
|
- graphify-update-advisor (sonnet): full scope 메타 갱신.
|
||||||
|
|
||||||
|
# Decisions
|
||||||
|
- D1 (auto): errer.jsp 기존 scriptlet statusCode 읽기 유지. 디자인 errorData EL 미채택 — 외형(SVG/empty-state)만 이식. 근거: 기존 동작 검증됨, errorData 노출 미확인(concern).
|
||||||
|
- D2 (auto): 미존재 라우트(/games/{id}/visibility, /profile/edit, /password/reset) 요소 비노출. 근거: 백엔드 부재, 비주얼 레이어 범위 밖, 핸드오프 제약.
|
||||||
|
- D3 (auto): 모든 디자인 form 태그 통째 이식 금지. body 내부 마크업/클래스만 추출, 기존 head/include/AJAX/CSRF 보존.
|
||||||
|
- D4 (사용자 확인): bibimbap.css 다크모드 토큰 처리.
|
||||||
|
|
||||||
|
# user_signals
|
||||||
|
positive:
|
||||||
|
- '다크모드/재기동 결정 질문에 한 번에 추천안 수락 — 계획 가시화·결정 게이트가 마찰 없이 통과.'
|
||||||
|
negative: []
|
||||||
|
|
||||||
|
# verified_by_me
|
||||||
|
- 'L1: WAR 빌드 ./mvnw -q -DskipTests package exit 0 (PASS)'
|
||||||
|
- 'L1: bibimbap.css WAR 포함 target/.../css/bibimbap.css (PASS)'
|
||||||
|
- 'L2: 로컬 dev 컨테이너(spring-boot:run) 재기동 후 공개 페이지 JSP 컴파일/렌더 스모크 — /, /login, /signup, /terms, /posts, /recruit, /error 전부 200'
|
||||||
|
- 'L2: 마커 검증 — home css link+hero, login pw-toggle, signup pw-meter, terms toc, error empty-state 전부 서빙 확인'
|
||||||
|
- 'L2 결함 포착·수정: errer.jsp stray javax JSTL taglib → /error 500 → taglib 제거 → 200 (fix 커밋 360078a). 회귀: 동일 advisor 산출 sibling(terms)는 200 정상.'
|
||||||
|
- '로그 스캔: 재기동 후 신규 JasperException 0건 (07:35:52 잔존분은 재기동 전)'
|
||||||
|
|
||||||
|
# browser_review (후속 — 사용자 요청 "브라우저 실제 적용본 검토")
|
||||||
|
- 검토 페이지(8): 홈(다크+라이트 토글 양쪽 정합), /login(pw-toggle·비번찾기 미노출 확인), /signup(강도막대·약관체크), /terms(sticky TOC+조항본문), /posts·/recruit(빈상태), /error(empty-state+버튼), /game/3.
|
||||||
|
- game-detail 실동작 확인: WebGL 16:9 플레이어 로드, 6축 레이더 SVG(몰입성/창의성/조작성/완성도/사운드/비주얼), 리뷰 AJAX 목록·평점 3.8(6)·로그인 게이트·정렬칩 — WebGL/6축/리뷰 보존 확정(unit7 no-op 정당).
|
||||||
|
- **결함 2건째 포착·수정**: posts-empty/recruit-empty.jspf 정적 include 인코딩 미상속 → 빈상태 한글 mojibake. 각 파편 pageEncoding=UTF-8 추가로 해소(fix a4d163d), 브라우저 재확인 정상. 교훈 docs 반영(d379a1c).
|
||||||
|
- L1 재검: docker 컨테이너(temurin-21) 내 mvn package PASS(호스트 Java 미설치라 컨테이너 빌드).
|
||||||
|
|
||||||
|
# needs_user_verification
|
||||||
|
- '로그인 화면 인터랙티브 제출: 실제 email/password 로그인 AJAX(_csrf + BibimbapCsrf.headers 이중방어) 성공 이동, 회원가입 제출→modal→/login. (마크업 렌더·제출코드 무손실 grep 확인했으나 브라우저 세션 제출 1회 권장. 테스트계정 admin@bibimbap.local)'
|
||||||
|
- '프로필: 닉네임/아바타 변경 AJAX 동작(로그인 세션 필요).'
|
||||||
|
- '게임상세: WebGL 플레이·6축 리뷰 작성/수정/삭제·덧글 CRUD (변경 없음=no-op이나 회귀 확인 권장).'
|
||||||
|
- '모집작성: /recruit/new 제출 성공 + 라이브 미리보기(로그인 세션 필요).'
|
||||||
|
- '다크모드 토글 시 body 컴포넌트 ↔ header/footer 색 정합 육안 확인.'
|
||||||
|
- '참고(범위 밖·선재): 로그 NumberFormatException "write" — 숫자 @RequestParam에 비숫자 유입. 본 작업(비주얼·컨트롤러 무수정)과 무관, 별도 추적 권장.'
|
||||||
|
|
||||||
|
# graph_refresh
|
||||||
|
partial-stale → graphify-update-advisor가 full scope 메타 갱신(docs/graph/index.md, source_commit 360078a). 신규 노드 6건(뷰 fragment 2 + css + docs 3) + include 엣지 2건 반영. 커밋 8c86e8c. (코어 의존성 구조는 무변경.)
|
||||||
|
|
||||||
|
# Retrospective
|
||||||
|
```yaml
|
||||||
|
Retrospective:
|
||||||
|
signals:
|
||||||
|
positive:
|
||||||
|
- quote_or_paraphrase: "다크모드/재기동 결정 질문에 한 번에 추천안 수락"
|
||||||
|
about: 결정 게이트(D4 다크모드 토큰 처리)를 계획 가시화 후 질문 → 1라운드 수락. 마찰 0.
|
||||||
|
negative: []
|
||||||
|
|
||||||
|
what_went_well:
|
||||||
|
- "research 단계에서 핸드오프 매핑 spec(디자인↔실제 모델명/CSRF/라우트)을 선행 추출해 구현 6단위 전반의 불일치 차단 — 하드 제약 6종 전부 준수."
|
||||||
|
- "L2 스모크(spring-boot:run 재기동 후 공개 페이지 전수)가 WAR 빌드 PASS에도 숨은 errer.jsp JSTL taglib 결함을 포착·fix 커밋까지 완결."
|
||||||
|
- "단위별 커밋 8개 — 롤백 경계 명확, 결함 fix 커밋(360078a)이 구분 가능."
|
||||||
|
|
||||||
|
what_to_improve:
|
||||||
|
- "JSP 사전컴파일 미설정 → WAR 빌드 PASS가 JSP EL/taglib 런타임 오류를 은닉. 비주얼 레이어 작업에서 디자인 산출물의 JSTL taglib이 stray로 유입되는 패턴이 실제 발생했으므로, 'JSP 변경은 빌드만으로 불충분 — 런타임 스모크 의무' 규약이 없다."
|
||||||
|
|
||||||
|
memory_candidates:
|
||||||
|
- name: jsp-runtime-smoke-gate
|
||||||
|
type: feedback
|
||||||
|
description: "JSP 변경(비주얼 레이어 포함)은 WAR 빌드 PASS만으로 충분하지 않음 — 런타임 스모크를 의무 검증 레벨로 명시해야 함."
|
||||||
|
body_draft: |
|
||||||
|
### JSP 변경은 빌드 PASS만으로 불충분 — 런타임 스모크 의무
|
||||||
|
|
||||||
|
이 프로젝트는 JSP 사전컴파일이 미설정(Tomcat이 런타임에 컴파일)되어 있다.
|
||||||
|
결과적으로 `./mvnw package`가 exit 0이어도 JSP EL 표현식·taglib 선언 오류는
|
||||||
|
런타임에만 발현한다. 비주얼 레이어 작업에서 디자인 산출물의 stray JSTL taglib이
|
||||||
|
유입되는 패턴이 실증됐다(errer.jsp → /error 500, fix: 360078a).
|
||||||
|
|
||||||
|
**Why**: WAR 빌드는 JSP를 파일로만 패키징하고 컴파일하지 않는다. L1 빌드 GREEN은
|
||||||
|
"JSP 구문이 유효하다"는 보증이 아니다.
|
||||||
|
|
||||||
|
**How to apply**:
|
||||||
|
- JSP 파일이 변경된 모든 세션에서 verification 단계에 "런타임 스모크" 항목을
|
||||||
|
명시(spring-boot:run 재기동 후 변경된 JSP 경로 200 확인).
|
||||||
|
- 비주얼 레이어 작업은 디자인 산출물에서 stray taglib(`<%@ taglib ...%>`)이
|
||||||
|
유입되었는지 grep 전수 확인을 implementation 체크리스트에 포함한다:
|
||||||
|
`rg '<%@\s*taglib' src/main/webapp/WEB-INF/views/`
|
||||||
|
- 사전컴파일 도입(maven-jasper-plugin)은 별도 open item으로 관리한다.
|
||||||
|
|
||||||
|
> 근거: 세션 20260630-160000 — errer.jsp javax.servlet.jsp.jstl taglib stray 유입
|
||||||
|
> → /error 500 런타임 발현. L2 스모크에서 포착·수정.
|
||||||
|
rationale_for_saving: "재현성 있음 — JSP 파일이 변경되는 모든 세션에서 동일 위험. verification-strategies.md에 동일 항목 없음."
|
||||||
|
signal_source: observation
|
||||||
|
docs_sync_target: "/Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md"
|
||||||
|
memory_optional: true
|
||||||
|
|
||||||
|
protocol_feedback: []
|
||||||
|
|
||||||
|
applied_changes:
|
||||||
|
- "jsp-runtime-smoke-gate 교훈을 verification-strategies.md에 신규 §추가 (커밋 8d52b10). docs-first 단독 마감 — memory_optional이므로 memory 강제 안 함."
|
||||||
|
```
|
||||||
|
|
||||||
|
# open_items
|
||||||
|
- '.atp/work-session/* 다수 미추적(이번 20260630-160000 포함, 선재 세션들도). 소스 변경은 전부 커밋됨. 정책상 work-session 추적 기본이나 본 세션 산출 외 선재분은 범위 밖 — 추적 여부 사용자 판단.'
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
---
|
||||||
|
phase: research
|
||||||
|
agent: research-advisor
|
||||||
|
agent_version: 2
|
||||||
|
generated_at: 2026-06-30T16:00:00+09:00
|
||||||
|
concerns:
|
||||||
|
- "low source confidence — errer.jsp의 error-page 매핑(web.xml/ErrorConfig) 및 pageContext.errorData 노출 가능 여부 미확인. error/error.jsp의 ${pageContext.errorData.statusCode} 바인딩이 현 Tomcat/Spring 구성에서 동작하는지 검증 전 권위 데이터(시드·계약)로 승격 금지."
|
||||||
|
- "디자인 산출물 다수가 include 경로 '/WEB-INF/views/common/header.jsp'를 참조하나 현 프로젝트는 'common/' 서브디렉터리가 없음(header.jsp는 /WEB-INF/views/ 직하). 이식 시 경로 전부 교정 필요 — 디자인 마크업을 '있는 그대로' 복사 금지."
|
||||||
|
concerns_checked: true
|
||||||
|
source_confidence: mixed
|
||||||
|
workers_spawned: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# 조사 결과 — 비주얼 레이어 이식 mapping-spec
|
||||||
|
|
||||||
|
## 주제
|
||||||
|
디자인 산출물(외부 클로드 디자인 툴 결과물)을 기존 JSP에 "CSS 클래스/마크업 구조만" 이식하기 위한 페이지별 (a)모델 속성명 (b)CSRF 패턴 (c)head/include 구조 (d)제출 방식 (e)안전 이식 가능 블록 추출.
|
||||||
|
|
||||||
|
근거: 각 항목 file:line 인용은 parallel-explorer 5개(G1~G5)가 전문 읽기로 확인. 라우트/필드 바인딩은 advisor가 controller 직접 grep으로 교차검증.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공유 인프라 (G1) — 확인됨
|
||||||
|
|
||||||
|
### theme-init.jsp (32줄)
|
||||||
|
- 독립 `<head>` 조각. `<link>`/`<style>` 없음. 포함: `<meta name="csrf-token">`(line 7) + `<script>`(테마 FOUC 방지 + `window.BibimbapCsrf` 전역 등록, line 19-30).
|
||||||
|
- **`window.BibimbapCsrf`는 여기서 정의됨** — 모든 AJAX CSRF 헤더의 출처. (확인됨)
|
||||||
|
- views 디렉터리 27개 JSP 중 공유조각 4개를 제외한 **23개 전원이 theme-init.jsp를 include**, 미포함 0건. (확인됨, grep)
|
||||||
|
|
||||||
|
### header.jsp (250줄)
|
||||||
|
- 자체 `<style>`(14-194). header 전용 CSS 변수는 `.site-header` 스코프에서만 선언. BEM 클래스(`.site-header__*`) 일관. (확인됨)
|
||||||
|
- contextPath = `${pageContext.request.contextPath}` (line 197). **`${ctx}` 별칭 정의 없음.** (확인됨)
|
||||||
|
- nav = `<nav class="site-header__nav">` + 플랫 `<a class="site-header__nav-link">`, ul 없음. (line 201-203)
|
||||||
|
- `window.BibimbapCsrf` 정의 없음(theme-init 소유). modal.jsp include(line 231).
|
||||||
|
|
||||||
|
### footer.jsp (96줄)
|
||||||
|
- 자체 `<style>`(2-77), script 없음. `.site-footer__*` 클래스. 링크색 `#e8a54b` 하드코딩(line 43) — bibimbap.css `--color-accent: #D4A853`와 불일치(토큰 정합성 관찰). (확인됨)
|
||||||
|
|
||||||
|
### bibimbap.css (352줄) — 디자인 산출물
|
||||||
|
- 상단 주석(1-5): "외부 라이브러리 없음·순수 CSS". **@import / @font-face / CDN url() 없음.** 폰트는 로컬 `'Pretendard Variable'` 의존. (확인됨)
|
||||||
|
- `:root` 변수 24개(8-41): `--color-*`, `--radius-*`, `--font-sans/mono`. **다크 모드 오버라이드 블록 없음(라이트 고정 토큰).** (확인됨)
|
||||||
|
- 레이아웃 클래스: `.page .btn .btn-primary .field .input .textarea .card .empty-state .game-grid .game-card .profile-head .avatar .terms .terms-toc .terms-body .form-grid .form-actions` 등.
|
||||||
|
- **`.site-header*`/`.site-footer*`를 정의하지 않음 → header/footer와 직접 클래스 충돌 없음.** (확인됨)
|
||||||
|
- 주석 line 4 예시는 `${ctx}/css/bibimbap.css` 사용 — 현 코드베이스 패턴(`${pageContext.request.contextPath}`)과 다름. link 작성 시 교정 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G2 홈 + 빈상태
|
||||||
|
|
||||||
|
### index.jsp (기존)
|
||||||
|
- 게임 모델: `GameData` — accessor `getName()`/`getCreator()`/`getThumbnailUrl()` (확인됨, GameData.java:40/48/80). **→ EL은 `${game.name}` `${game.creator}` (디자인의 `${game.title}`/`g.author`는 불일치).**
|
||||||
|
- 자체 head 보유 + theme-init/header/footer include.
|
||||||
|
- 게임 등록 라우트 = `/game/new` **단수** (확인됨, WebMvcController.java:62 / GameController.java:67). 디자인의 `/games/new` 복수는 불일치.
|
||||||
|
|
||||||
|
### recruit-list.jsp / posts-list.jsp (기존)
|
||||||
|
- `/recruit/new` (RecruitController.java:41/49) · `/posts/new` (PostController.java:99) **둘 다 실재 확인됨.** 디자인 CTA href는 이 값으로 교정.
|
||||||
|
- 빈 상태 분기 위치에 디자인 fragments(recruit-empty.jspf, posts-empty.jspf) include 가능 — 단, fragments는 디자인 클래스(`.empty-state`)를 쓰므로 bibimbap.css 도입이 선행돼야 시각 정상.
|
||||||
|
|
||||||
|
### 01-home.jsp + fragments 3종 (디자인)
|
||||||
|
- **이식 가능**: 히어로 섹션, 정렬칩, `.game-card`/`.game-grid` 외형 클래스, `.empty-state` fragment 마크업.
|
||||||
|
- **이식 금지/교정**: `${game.title}`→`${game.name}`, `${game.author}`→`${game.creator}`, `/games/new`→`/game/new`. 모델 바인딩 이름을 디자인 그대로 쓰면 빈값 렌더.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G3 인증 (login / signup)
|
||||||
|
|
||||||
|
### login.jsp (기존) — 확인됨
|
||||||
|
- 제출: form action="…/login" 선언되나 submit 인터셉트 후 **AJAX fetch POST /login** (line 272-273), 성공 시 `location.href=ctx+'/'`.
|
||||||
|
- CSRF **이중 방어**: hidden `<input name="_csrf">`(line 224) + `window.BibimbapCsrf.headers()`(line 286-294).
|
||||||
|
- 필드 name: `email`(227), `password`(231), `remember`(235).
|
||||||
|
- 에러 표시: 인라인 DOM 슬롯 없음 → `window.BibimbapModal.alert()` 호출.
|
||||||
|
- 자체 head 보유(4-6) + theme-init(9)/header(208).
|
||||||
|
|
||||||
|
### signup.jsp (기존) — 확인됨
|
||||||
|
- 제출: **AJAX fetch POST /signup**, 성공 시 BibimbapModal.alert 후 /login 이동.
|
||||||
|
- CSRF 이중 방어 동일(hidden `_csrf` line 222 + headers line 300-306).
|
||||||
|
- 필드 name: `displayName`(227), `email`(231), `password`(236), `passwordConfirm`(240), `termsAccepted`(244), `provider`(hidden 223), `providerUserId`(hidden 224, submit 직전 email 복사 line 293).
|
||||||
|
- **클라이언트 비밀번호 일치 검증 JS 없음**(checkValidity만, line 285-293).
|
||||||
|
|
||||||
|
### 02-login.jsp / 03-signup.jsp (디자인)
|
||||||
|
- 자체 head **없음**(fragment). include가 `common/header.jsp`(불일치 경로).
|
||||||
|
- **CSRF hidden input 전무 + submit 인터셉트 JS 전무** → native POST. **form 태그 통째 이식 금지** (CSRF 누락·AJAX 로직 상실).
|
||||||
|
- 약관 체크박스 name=`agree`(03-signup line 55) — 서버 기대값 `termsAccepted`와 불일치 → 그대로 이식 시 약관 동의 유실.
|
||||||
|
- `providerUserId`/`provider` hidden 없음 → 기존 signup 필수 hidden 누락.
|
||||||
|
- **이식 가능(비주얼 전용)**: `.pw-field`+`.pw-toggle` 마크업·인라인 onclick(02-login 29-33), `.pw-meter` data-level 강도막대+oninput(03-signup 34-36), `#pw-ok` 일치표시+oninput(43-45, id를 기존 필드 id에 맞춰 교정), `.form-error`/`.form-ok`/`.help` 슬롯.
|
||||||
|
- **이식 금지**: form 태그/action/method, common/ include, `name=agree`, native POST.
|
||||||
|
- `/password/reset` 링크(02-login line 27): 기존 login.jsp에 없음. **라우트 미존재(grep 무수확) → 링크 비노출 권고.** (확인됨: 컨트롤러에 매핑 없음)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G4 약관 + 프로필
|
||||||
|
|
||||||
|
### terms.jsp (기존) — 확인됨
|
||||||
|
- 조항 본문 **전량 하드코딩**(모델 바인딩 없음). `<article class="policy-doc"> > <section class="policy-section">` + `<h2>`+`<ol><li>` (99-191).
|
||||||
|
- 자체 head 보유(4-94, 인라인 style 포함) + theme-init(7)/header(97)/footer(193).
|
||||||
|
|
||||||
|
### 07-terms.jsp (디자인)
|
||||||
|
- sticky 목차 `<nav class="terms-toc">`(17-29, 신규 블록), 조항 `<div class="terms-body"> <section id="aN"> <h3><p><br>`(32-94). 자체 head 없음, common/ include.
|
||||||
|
- **이식 가능**: `.terms`/`.terms-toc`/`.terms-body` 래퍼·TOC(신규 추가). **마크업 변환 필요**: `<section class="policy-section">+<ol><li>` → `<section id="aN">+<p>`, `<h2>`→`<h3>`.
|
||||||
|
- **이식 금지/교정**: 자체 head 미보유분은 기존 head 유지, common/ 경로 교정.
|
||||||
|
|
||||||
|
### profile.jsp (기존) — 확인됨
|
||||||
|
- 모델: session attr `displayName`/`email`/`avatarUrl`(17-19), request attr `myGames`(27-28)/`myBadges`(31-36). **scriptlet 방식 — 디자인의 EL `${user}` 객체와 다름.**
|
||||||
|
- CSRF: hidden input 없음, **`window.BibimbapCsrf.headers()` 헤더 주입만**(634-645, 699-708).
|
||||||
|
- AJAX 함수: `submitNickname()`(619, POST /profile/nickname), `uploadAvatar()`(692, POST /profile/avatar).
|
||||||
|
- 썸네일 경로: `rawThumbUrl.startsWith("/") ? ctx+rawThumbUrl : rawThumbUrl`(491-493).
|
||||||
|
- **공개/비공개 토글 UI·API가 기존엔 전무.** (확인됨)
|
||||||
|
|
||||||
|
### 08-profile.jsp (디자인)
|
||||||
|
- `.profile-head` 헤더(17-24), `.game-row`+`.thumb`+`.meta`(39-58), 공개토글 `<form action="${ctx}/games/${g.id}/visibility"> <label class="switch"><input type=checkbox onchange="this.form.submit()">`(46-53).
|
||||||
|
- **공개토글 엔드포인트 `/games/{id}/visibility` 현 컨트롤러에 미존재**(grep 무수확; 유사한 `/admin/jams/{jamId}/visibility`만 존재 — 동명 경로이나 별개 엔티티/권한, 혼동 금지). 이 토글은 **백엔드 신설 없이는 비주얼만 이식 불가** → 비주얼 레이어 범위 밖. (확인됨)
|
||||||
|
- `/profile/edit` 링크(08-profile line 23): 기존 인라인 편집(닉네임/아바타)만 있고 라우트 미존재(grep 무수확). 비노출 또는 백엔드 결정 필요.
|
||||||
|
- **이식 가능**: `.profile-head`·`.game-row` 외형(단 EL `${user.*}`→scriptlet 변수 교정, `${myGames}` 게임 필드는 `getName()`/`getThumbnailUrl()` 사용).
|
||||||
|
- **이식 금지(보존 필수)**: `submitNickname()`/`uploadAvatar()` fetch + `window.BibimbapCsrf` 헤더, `id="profile-avatar-img"`/`profile-avatar-initial` (JS 참조), `/games/.../visibility` 토글(백엔드 부재).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## G5 게임상세 + 모집 + 에러
|
||||||
|
|
||||||
|
### game-detail.jsp (기존, 2385줄) — 블록 매핑 (확인됨)
|
||||||
|
|
||||||
|
| 블록 | line | 보존필수 |
|
||||||
|
|---|---|---|
|
||||||
|
| scriptlet 모델바인딩(XSS escape) | 1-22 | 필수 |
|
||||||
|
| head + 인라인 CSS(토큰 40여개) | 23-1203 | CSS 교체가능 |
|
||||||
|
| body+header include | 1204-1205 | 이식가능 |
|
||||||
|
| topbar(뒤로/owner액션) | 1206-1218 | 이식가능(클래스만) |
|
||||||
|
| 메타카드(제목/번호/제작자/좋아요) | 1220-1256 | 이식가능(id 유지) |
|
||||||
|
| **WebGL iframe** | 1258-1276 | **필수(sandbox/allow/scriptlet src 불변)** |
|
||||||
|
| 제작자 한마디 패널 | 1279-1296 | 이식가능 |
|
||||||
|
| **리뷰 패널 마크업** | 1298-1347 | **필수(id 20여개 JS참조)** |
|
||||||
|
| **덧글 패널 마크업** | 1349-1381 | **필수(id JS참조)** |
|
||||||
|
| **IIFE 스크립트(CRUD/CSRF/6축레이더/별점)** | 1387-2382 | **필수 전체보존** |
|
||||||
|
|
||||||
|
- CSRF: `baseHeaders()`/`formHeaders()` 공통 헬퍼가 `window.BibimbapCsrf.headers()` 중앙화(1562-1569).
|
||||||
|
- 리뷰 CRUD: `reviewsUrl = ctx+'/game/'+gid+'/reviews'`(2021), GET/POST/PUT/DELETE(2306/2375/2236). 덧글: `commentsUrl=…/comments`(1800), POST/PUT/DELETE(1977/1845/1901).
|
||||||
|
- 6축: `AXIS_KEYS=[immersion,creativity,controls,completeness,sound,visual]`(1508), `buildHexRadar()`(1607-1702) SVG 동적생성, `buildStarRadioGroup()`(1731-1786).
|
||||||
|
- 보존필수 id 목록: `game-review-composer/form/axes/stars/list`, `game-reviews-summary-panel/radar/empty/toolbar/more`, `game-comment-form/input/list/composer/login-gate`, `game-comments-toolbar/more`, `game-like-btn/count`.
|
||||||
|
|
||||||
|
### 06-game-detail.jsp (디자인)
|
||||||
|
- 자체 head 없음, EL `${game.*}`, WebGL iframe **sandbox 속성 없음**(보안 후퇴 — 절대 이 형태로 교체 금지), 6축은 `<div class="help">6축 평가 포함</div>` 플레이스홀더(빌더 없음), 리뷰는 `<c:forEach>` 서버렌더(기존 AJAX 아님), 좋아요는 native form POST.
|
||||||
|
- **결론: game-detail은 통째 교체 절대 금지. 표층 CSS 클래스 토큰 교체와 topbar/메타카드/제작자패널 외형만 선별 이식. 디자인의 WebGL·6축·리뷰 구현은 모두 기능 후퇴이므로 버린다.**
|
||||||
|
|
||||||
|
### recruit-form.jsp (기존, 450줄) — 확인됨
|
||||||
|
- 제출: `action="…/recruit/new"` novalidate + **AJAX fetch POST**(376-395), `window.BibimbapCsrf.headers()`.
|
||||||
|
- 필드 name(controller @RequestParam로 교차확인): `projectName`(260) `genre`(264) `summary`(268) `role`(272) `status`(280) `type`(289) `period`(298) `team`(302) `contact`(306) `description`(310). (RecruitController.java:52-61에서 동일 name 수신 확인됨)
|
||||||
|
- 글자수 카운터 JS 없음(maxlength만). 미리보기 `render()` IIFE(338-446).
|
||||||
|
|
||||||
|
### 10-recruit-form.jsp (디자인)
|
||||||
|
- native form POST `action="${ctx}/recruit"`(line 18, **CSRF 없음·엔드포인트 불일치 — 기존은 /recruit/new**), 필수표시 `<span class="req">*</span>`, 글자수카운터 summary만 인라인 oninput(33-35), 필드 `name="title"`(22)는 서버 기대 `projectName`과 **불일치**(확인됨).
|
||||||
|
- **이식 가능**: `.req` 필수표시, `.count`/`#sc` 글자수 마크업+인라인 oninput(독립적), `.form-page`/`.field` 외형.
|
||||||
|
- **이식 금지**: form action/method(엔드포인트 /recruit ≠ /recruit/new, CSRF 누락), `name="title"`(→projectName 유지), AJAX submit·미리보기 render() 보존.
|
||||||
|
|
||||||
|
### errer.jsp (기존, 232줄) — 확인됨 / error/error.jsp (디자인) — 미확인 부분 존재
|
||||||
|
- errer.jsp: `isErrorPage=true`, **`jakarta.servlet.error.status_code` scriptlet**(190)으로 코드 읽음, `${statusCode}`/`${status}` EL 미사용. statusCode!=null 단일 체크(203-208), 코드별 분기 없음. requestUri/message 상세 블록 보유.
|
||||||
|
- error/error.jsp(디자인): `${pageContext.errorData.statusCode}`→`<c:set var=code>`(19) + `<c:choose>` 403/otherwise(404) 분기(27/44). 상세정보 블록 없음.
|
||||||
|
- **미확인**: web.xml/ErrorConfig의 error-page 매핑 위치(grep 무수확), `pageContext.errorData`가 현 구성에서 노출되는지. → 디자인 EL 바인딩 채택 전 검증 필요. concern 등록함.
|
||||||
|
- **이식 가능**: SVG 아이콘/버튼/`<c:choose>` 분기 외형. **주의**: 코드 바인딩 방식 변경(scriptlet→errorData EL)은 동작 검증 후. 상세정보(requestUri/message) 노출은 정책 결정 사항.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 종합 판단
|
||||||
|
|
||||||
|
### bibimbap.css 도입 지점 결론 (단일 지점 추천)
|
||||||
|
**theme-init.jsp에 `<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">` 1줄 추가가 유일한 단일 지점이다.** 23개 전 페이지가 예외 없이 theme-init을 include하므로 전역 적용된다(확인됨). header/footer는 include 체인 중간이라 단일 지점 불가. CSS 자산은 `src/main/webapp/css/`에 두면 Tomcat DefaultServlet이 서빙(CLAUDE.md 정책 부합). 단 디자인 주석의 `${ctx}` 별칭은 현 코드에 없으므로 `${pageContext.request.contextPath}`로 작성.
|
||||||
|
|
||||||
|
### CSS 클래스 네임스페이스 충돌 요약
|
||||||
|
- bibimbap.css는 `.site-header*`/`.site-footer*`를 정의하지 않음 → **header/footer 인라인 스타일과 클래스 직접 충돌 없음.**
|
||||||
|
- 잠재 충돌: **다크 모드 이중 체계.** header/footer는 `html[data-theme="dark"] .site-*` 자체 변수, bibimbap.css `:root`는 다크 오버라이드 없는 라이트 고정 토큰. 도입 후 body 컴포넌트(라이트 고정) vs header/footer(다크 대응)가 공존 → 다크 모드에서 body 영역이 라이트로 남는 시각 불일치 가능. (확인됨, 디자인 의도 확인 필요)
|
||||||
|
- 색상 토큰 불일치: footer `#e8a54b` vs bibimbap.css `--color-accent #D4A853`.
|
||||||
|
|
||||||
|
### 횡단 패턴 (확인됨)
|
||||||
|
1. **모든 디자인 산출물이 자체 head 미보유 + `common/` include 경로 사용** → 기존 자체-head 구조에 "그대로" 붙일 수 없음. body 내부 마크업만 추출해 기존 head/include 유지한 페이지에 이식.
|
||||||
|
2. **모든 디자인 form이 CSRF hidden·AJAX 로직 미보유(native POST)** → 어떤 form 태그도 통째 이식 금지. 제출 로직(AJAX+BibimbapCsrf)은 전 페이지 보존 필수.
|
||||||
|
3. **모델 바인딩 이름 불일치 다발**: 디자인 EL(`game.title`/`author`/`user`/`/games/new`/`/recruit`/`name=title`/`name=agree`) vs 기존(`name`/`creator`/scriptlet session/`/game/new`/`/recruit/new`/`projectName`/`termsAccepted`). 이름 그대로 이식 시 빈 렌더·필드 유실.
|
||||||
|
|
||||||
|
## 미해결
|
||||||
|
- errer.jsp error-page 매핑 위치 및 `pageContext.errorData` 노출 가능 여부 (web.xml/Java config 미확인 — concern 등록). 디자인 error EL 채택 전 검증 필요.
|
||||||
|
- `/games/{id}/visibility`(프로필 공개토글), `/profile/edit`, `/password/reset` 라우트 부재(grep 확인) → 디자인이 제안하나 백엔드 신설 없이는 비주얼 이식 무의미. 비주얼 레이어 범위 밖 — 노출 정책은 design/orchestrator 결정 사항.
|
||||||
|
- bibimbap.css 도입 후 다크 모드 body 토큰 처리(라이트 고정) — 디자인 의도 확인 필요(조사 범위 밖, 설계 결정).
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
schema_version: 1
|
||||||
|
sid: 20260630-170024
|
||||||
|
user_request: "/frontend-design 디자인 변경 및 추가했는데 검토해줘 — feat(redesign) 커밋 시리즈 검토 (read-only)"
|
||||||
|
started_at: 2026-06-30T17:00:24
|
||||||
|
mode: review-only (코드 변경 없음, orchestrator 직접 수행)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청 명확(=리디자인 커밋 검토). 빌드할 신규 기능 없음.'
|
||||||
|
checked_at: 2026-06-30T17:01
|
||||||
|
- advisor: design/implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 'review-only. 코드 작성 0. 빌드 체인 불필요.'
|
||||||
|
checked_at: 2026-06-30T17:01
|
||||||
|
- advisor: verification/graphify/retrospective
|
||||||
|
decision: skip
|
||||||
|
rationale: '코드 변경 0줄 → graph-refresh-checker 트리거 미충족. 검증 대상 산출물 없음.'
|
||||||
|
checked_at: 2026-06-30T17:30
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
feat(redesign) 시리즈(0390da6~a4d163d) 검토. 13개 src 파일 +807/-223.
|
||||||
|
공유 bibimbap.css(384L) 신설 + JSP 9단위 비주얼 레이어 이식.
|
||||||
|
보안/인코딩/CSRF/CDN 하드제약은 준수. 핵심 문제는 **디자인 일관성** — 공유 토큰 시스템과 기존 페이지-로컬 토큰 시스템 2종이 병존.
|
||||||
|
|
||||||
|
# Findings (severity desc)
|
||||||
|
- HIGH(coherence): bibimbap.css `--color-accent:#D4A853` ≠ 앱 전역 브랜드 골드 `#e8a54b`(header/footer/modal/26파일). 모든 페이지에 골드 2종 공존. 다크 override는 `--color-accent-strong:#e8a54b`만 일치시켜 모순 노출. unit-1 "공유 css 전 페이지 공통" 목표와 상충.
|
||||||
|
- MED: 클래스 합집합 override 모호 — profile.jsp `class="profile-button btn btn-primary"` 등. bibimbap `.btn-primary`(외부 link) vs 페이지-로컬 `.profile-button--primary`(`<style>`) 동일 specificity, source order(페이지 style 후행)로 로컬 승 → 추가한 btn 클래스 일부 inert/예측불가 혼합.
|
||||||
|
- MED: recruit-form.jsp 가 공유 시스템 미채택 — 전체 인라인 `<style>` 병렬 토큰(`--accent:#e8a54b` 등) + bibimbap 클래스명 shadow(.field/.form-grid/.req/.count/.preview-card/.form-actions). 가장 이질적.
|
||||||
|
- LOW: index.jsp bibimbap.css 이중 로드(직접 `<link>` + theme-init include).
|
||||||
|
- LOW: 인라인 핸들러(login onclick / signup·summary oninput) — CSP 비친화 + recruit-form addEventListener 패턴과 혼재.
|
||||||
|
- LOW: 전역 `:focus-visible` 링 없음(링크/chip/버튼). input 은 focus ring 보유 ✓.
|
||||||
|
- LOW: Pretendard/JetBrains Mono 폰트스택만 — @font-face 없음. 미설치 환경 silent fallback → 디자인 의도 display 개성 소실(단 CDN-0 제약상 트레이드오프).
|
||||||
|
|
||||||
|
# Positives
|
||||||
|
- XSS 안전: recruit-form 미리보기 `textContent` ✓, index `HtmlUtils.htmlEscape`(thumbUrl/jamTitle/searchQuery) ✓, 신규 raw user-data EL 출력 0.
|
||||||
|
- CSRF 보존 ✓ (`BibimbapCsrf.headers` + meta csrf-token).
|
||||||
|
- CDN 0 ✓. 빈상태 fragment 깔끔 + pageEncoding 수정 완료 ✓.
|
||||||
|
- 다크 토큰 header.jsp 출처 명시 ✓. 반응형 breakpoint 존재 ✓.
|
||||||
|
- 커스텀 empty-state SVG = 가장 distinctive 한 시그니처 요소.
|
||||||
|
|
||||||
|
# verified_by_me
|
||||||
|
- 검토 단계: 13파일 diff + bibimbap.css + 2 fragment + theme-init 전수 read.
|
||||||
|
- 구현 후 L1: `./mvnw -DskipTests package` exit 0 PASS. WAR 내 css/bibimbap.css 포함 확인.
|
||||||
|
- 구현 후 L2: dev 컨테이너 소스 bind-mount(`.:/build`)로 변경 라이브. 스모크
|
||||||
|
`/` `/login` `/signup` `/terms` `/recruit` `/posts` `/error` HTTP 200 + JSP 컴파일 에러 0.
|
||||||
|
서빙 `/css/bibimbap.css` accent `#e8a54b` 확인.
|
||||||
|
- 로그 스캔: JSP 예외 마커 0건 clean.
|
||||||
|
|
||||||
|
# needs_user_verification
|
||||||
|
- `/recruit/new`(302, 로그인 필요): 인증 세션에서 rf- rename 후 폼 레이아웃·라이브 미리보기 정상 렌더.
|
||||||
|
- `/profile`(302, 로그인 필요): 인증 세션에서 버튼/카드 외형(중복 클래스 제거 후) + 골드 정합.
|
||||||
|
- 다크모드 토글 시 전 페이지 골드 단일(#e8a54b) 육안 정합.
|
||||||
|
|
||||||
|
# open_items
|
||||||
|
- `.atp/work-session/20260630-170024/report.md`(본 파일) 미커밋 — 기존 work-session 디렉토리들도 untracked 상태와 일관.
|
||||||
|
|
||||||
|
# graph_refresh
|
||||||
|
- partial-stale (구조 시그널 0 — css 토큰 스냅샷만 약 stale). graph-refresh-checker 권고대로
|
||||||
|
단독 재생성 생략(토폴로지 무변경, 비용 부당), 다음 구조변경 사이클에 흡수.
|
||||||
|
|
||||||
|
# commit
|
||||||
|
- cf1e7ce fix(redesign): 골드 토큰 통일·클래스 충돌 정리 (4 src + 2 docs)
|
||||||
|
|
||||||
|
ended_at: 2026-06-30T17:45
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
phase: documentation
|
||||||
|
agent: documentation-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T18:20:00+09:00
|
||||||
|
concerns: []
|
||||||
|
concerns_checked: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 문서화 보고
|
||||||
|
|
||||||
|
## 작성/수정된 문서
|
||||||
|
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `docs/changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md` | changes | 신규 | `docs/changes/index.md` (최신 항목 1줄) | 선행 2건(visual-redesign, redesign-token-coherence) + verification-strategies.md 레슨 (양방향) |
|
||||||
|
| `docs/changes/index.md` | changes | 수정 | — | 신규 changes 문서 링크 추가 |
|
||||||
|
| `docs/development/verification-strategies.md` | development | 수정 | — | 신규 changes 문서로 back-link 추가(L304 레슨 근거 줄) |
|
||||||
|
|
||||||
|
## 의사결정 기록 위치
|
||||||
|
- 런타임 동작(JSP/CSS 시각) 변경 → `docs/changes/` (런타임 동작 변경 기준). 사례(work-log/case) 성격이나 사용자 요청대로 기존 2026-06-30-*.md 형식에 맞춰 changes/ 에 기록.
|
||||||
|
- 진단 휴리스틱(다축 스캔 선행) 교훈은 이미 `docs/development/verification-strategies.md` L304 에 본 세션(20260630-175023) 근거로 등록되어 있었음 → 중복 생성 없이 changes 문서에서 상호참조 + 역링크만 추가.
|
||||||
|
|
||||||
|
## 추후 문서화가 필요한 항목
|
||||||
|
- ADR 부재: "고스트 버튼에 골드 accent 토큰 재사용" 설계 결함은 이번에 `--color-text` 로 정정됐으나 토큰 사용 규약(어느 토큰을 어느 컴포넌트에)이 별도 문서화되어 있지 않음. 토큰 거버넌스가 반복되면 architecture/ 또는 ADR 후보.
|
||||||
|
- needs_user_verification 2건(로그인 상태 스모크 / 실게임 가로 썸네일 4:3 크롭)은 changes 문서에 명시 — 사용자 확인 후 maintenance/post-deploy 체크리스트 반영 여부 검토 가능.
|
||||||
|
- graph_refresh partial-stale(report.md open_items): docs/changes 노드 backlog + source_commit stale — 다음 구조변경 시 /graphify full. 본 문서 작업 산출 아님(graphify-update-advisor 몫).
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
phase: implementation
|
||||||
|
agent: implementation-advisor
|
||||||
|
agent_version: 1
|
||||||
|
generated_at: 2026-06-30T17:50:23+09:00
|
||||||
|
---
|
||||||
|
|
||||||
|
# 파일 소유권 맵
|
||||||
|
|
||||||
|
| 파일 | 담당 | 변경 유형 | 관련 fix | 의존 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| src/main/webapp/WEB-INF/views/index.jsp | advisor 직접 | modify | #1 #3 #4 #13 | - |
|
||||||
|
| src/main/webapp/css/bibimbap.css | advisor 직접 | modify | #2 #6 #7 #11 #12 | - |
|
||||||
|
| src/main/webapp/WEB-INF/views/recruit-list.jsp | advisor 직접 | modify | #5 #8 #9 #10 | - |
|
||||||
|
| src/main/webapp/WEB-INF/views/fragments/recruit-empty.jspf | (변경 없음) | none | #9 (이미 충족) | - |
|
||||||
|
|
||||||
|
## planned_workers vs actual
|
||||||
|
- planned_workers(code-writer): 3 (파일당 1)
|
||||||
|
- actual_workers: 0 (advisor 직접)
|
||||||
|
- 전환 근거: 편집 파일 3개 < 8 임계, 총 변경 추정 < 50줄, 전부 소규모 CSS/속성 조정으로
|
||||||
|
병렬화 이득 없음 + JSP UTF-8 보존 사고 이력으로 advisor 직접 정밀 편집이 더 안전.
|
||||||
|
- recruit-empty.jspf 는 이미 .btn-primary(골드) + .btn-ghost(보조) 구조라 #9 충족 → 무편집(인코딩 보존).
|
||||||
|
|
||||||
|
## 충돌 방지 불변식
|
||||||
|
- 각 파일은 단일 편집 주체(advisor)만 접근 → 충돌 없음.
|
||||||
|
- 토큰 계열 분리: index.jsp/recruit-list.jsp 로컬 토큰(--accent/--text/--border 등),
|
||||||
|
bibimbap.css 는 --color-* 만. #2 .btn-ghost 는 bibimbap.css 소속 → var(--color-text) 정확.
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
---
|
||||||
|
schema_version: 2
|
||||||
|
sid: 20260630-175023
|
||||||
|
started_at: 2026-06-30T17:50:23
|
||||||
|
user_request: |
|
||||||
|
/frontend-design 두 시각 버그 수정 (이전 작업서 미해결):
|
||||||
|
- Image #2 (메인 게임 허브): 게임 카드 썸네일 "비율" 문제
|
||||||
|
- Image #3 (팀원 모집 빈 상태): "가시성" 떨어지는 케이스
|
||||||
|
Invocations: []
|
||||||
|
Summary: ''
|
||||||
|
Decisions: []
|
||||||
|
user_signals:
|
||||||
|
positive: []
|
||||||
|
negative:
|
||||||
|
- quote: "이전 작업에서 안고쳐졌어"
|
||||||
|
structural: false
|
||||||
|
note: "직전 리디자인 세션이 두 케이스 미해결. 발원 단계 재검 필요(§2.6)."
|
||||||
|
regression: ''
|
||||||
|
graph_refresh: ''
|
||||||
|
open_items: []
|
||||||
|
verified_by_me: []
|
||||||
|
needs_user_verification: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
# 각 advisor 호출/스킵 판단 즉시 1줄 append
|
||||||
|
|
||||||
|
- advisor: requirements/design/implementation
|
||||||
|
decision: skip
|
||||||
|
rationale: '2건 모두 국소 CSS 수정(토큰 오용·종횡비 1값). 이슈2 원인 확정, 이슈1 결정축 단일. orchestrator 직접+plan게이트(§1 마이크로 예외, §5.0).'
|
||||||
|
checked_at: 2026-06-30T17:52
|
||||||
|
- advisor: verification
|
||||||
|
decision: call(self)
|
||||||
|
rationale: 'CSS 변경 → 런타임 스모크 의무(직전 교훈). 다크모드 대비·카드 비율 육안 확인.'
|
||||||
|
checked_at: 2026-06-30T17:52
|
||||||
|
|
||||||
|
- advisor: implementation
|
||||||
|
decision: call
|
||||||
|
rationale: '결정 후 범위=전체13건+카드4:3, 4개 파일(index.jsp/bibimbap.css/recruit-list.jsp/recruit-empty.jspf). 다중파일+일부 마크업변경(#4#9#10) → 파일소유권 분할 필요. 종합 산출 concrete_change 확정적.'
|
||||||
|
checked_at: 2026-06-30T18:00
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
사용자 지적 2건(이미지#2 비율 / 이미지#3 가시성, 직전 세션 미해결)을 다축 멀티에이전트 평가로 13건 결함으로 확장 → 카드 종횡비 4:3 + 전체 13건 적용. 런타임 스모크 라이트/다크 양 화면 PASS.
|
||||||
|
|
||||||
|
## Invocations
|
||||||
|
- cavecrew-investigator: 두 화면 JSP/CSS 위치 file:line 확정
|
||||||
|
- Workflow(ui-multiaxis-eval, 6 agents): 관찰1 → 다축평가4(레이아웃비율·카드종횡비·대비가시성·타이포간격) → 종합1 → 13 fixes + 1 open_decision
|
||||||
|
- implementation-advisor: 13건+4:3 구현(3파일, recruit-empty.jspf 무편집)
|
||||||
|
- (self) verification: 정적 L1 + 런타임 L2 스모크
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- 카드 종횡비 **4:3** (사용자 선택; 16:9/4:5유지 대안 제시)
|
||||||
|
- 적용 범위 **전체 13건** (사용자 선택)
|
||||||
|
- #4 픽셀 margin 대신 `.search-stack` 컬럼 wrapper (advisor 조정 — 버튼 텍스트 가변 대응 결정적)
|
||||||
|
- #8 공용 클래스 대신 로컬 시각스펙 일치 (index/bibimbap 토큰계열 분리 제약 + font-mono 토큰 부재)
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- L1 정적: index.jsp div 18/18·recruit 6/6 균형, scriptlet 균형, U+FFFD 0, stray taglib 0(§293 적신호 없음)
|
||||||
|
- L2 런타임 스모크(PASS): bibimbap-app(mvn spring-boot:run) 재기동 → GET / + /recruit = 200 → 브라우저 육안 라이트+다크 양 화면.
|
||||||
|
- 이미지#2(비율): 검색바 풀폭 정렬(#1), 카드 4:3(#13 object-position) — 해소 확인
|
||||||
|
- 이미지#3(가시성): 필터 초기화 흰글자+보더 명확(#2, zoom 확정) — 해소 확인
|
||||||
|
- 부수: #6 정렬칩 보더·#7 정렬라벨·#8 eyebrow 통일·#9 빈상태 CTA 강등·#10 히어로폭·#11 패널패딩·푸터 비잘림 육안 확인
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
- 로그인 상태 1회 스모크: 본 검증은 로그아웃 상태만. (a) 메인 '신규 게임 개시' 버튼 노출 시 #4 search-stack 우측끝선 정렬, (b) 모집글 ≥1건 시 #9 히어로 골드 CTA 유지.
|
||||||
|
- 실게임 가로 썸네일 업로드 시 4:3 cover 크롭 인상 최종 확인(더미는 폴백 로고만 노출).
|
||||||
|
|
||||||
|
## regression
|
||||||
|
- 발원 단계: 직전 리디자인 세션(cf1e7ce 등)이 두 케이스 미해결. 본 세션이 다축 재검으로 표면화+수정. 토큰 오용(.btn-ghost on-accent) = 골드버튼 토큰을 고스트에 재사용한 설계 결함 → --color-text 로 정정.
|
||||||
|
|
||||||
|
## graph_refresh
|
||||||
|
partial-stale — 내 변경(src view/css)은 include/import/route 위상 0델타 = src fresh. partial 사유는 직전 세션들의 docs/changes 노드 2건 신규(pre-existing) + 직전 graphify 미반영(index.md "재생성 요청 중"이나 source_commit 360078a 고정). 내 세션 구조 무영향 → src 재생성 불요. docs 노드 backlog 는 open_items 로 이월(다음 구조변경 시 일괄 /graphify full).
|
||||||
|
|
||||||
|
## open_items
|
||||||
|
- [pre-existing] docs/graph 재생성 backlog: 직전 세션 docs/changes 노드 미반영 + source_commit stale(360078a→cf1e7ce). 다음 구조변경 작업 시 /graphify full + index.md frontmatter/Scopes 표 갱신. (본 세션 산출 아님)
|
||||||
|
- [untracked, 타세션] .atp/work-session/{20260630-103443,105459,143405,160000,170024}/ + graphify-out/ : 직전 세션들 미커밋 잔여. 본 작업 단위 외 — 미관여.
|
||||||
|
- [stale-log] 07:35Z JasperException(/error JSTL) = 내 재기동(09:19Z) 이전 옛 라인, errer.jsp 현재 clean, /error 현재 200. 회귀 아님.
|
||||||
|
|
||||||
|
## user_signals (보강)
|
||||||
|
negative:
|
||||||
|
- quote: "이미지 미리보기뿐 아니라 상단에 검색바도 혼자 짧잖아"
|
||||||
|
structural: true
|
||||||
|
note: "첫 AskUserQuestion 이 '카드 종횡비' 단일 축만 제시 → 사용자가 레이아웃 비율(검색바 정렬) 축을 직접 추가. §4.4 item5(표현·레이아웃 축 누락) 재현. '비율' 모호 어휘를 카드 썸네일로 조기 협소화한 것이 원인."
|
||||||
|
- quote: "(지시) 실측을 메인이 하지말고 서브 에이전트 다축평가로"
|
||||||
|
structural: false
|
||||||
|
note: "검증/조사를 orchestrator 가 직접 수행하려던 흐름을 사용자가 멀티에이전트로 교정. §1 직접수행 금지 원칙 부합 방향."
|
||||||
|
positive: []
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrospective
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Retrospective:
|
||||||
|
signals:
|
||||||
|
positive:
|
||||||
|
- quote_or_paraphrase: "(사용자 지시 후) 다축 멀티에이전트 평가가 13건+open_decision 으로 한 번에 수렴, 추가 재지시 없이 구현 진입 수락"
|
||||||
|
about: "관찰1→4축 병렬평가(레이아웃비율·카드종횡비·대비가시성·타이포간격)→종합1 토폴로지로 협소화된 입력을 다축으로 복원한 패턴 — 사용자 교정 후 재호출 없이 진행됨(비자명한 판단이 검증된 케이스)"
|
||||||
|
- quote_or_paraphrase: "런타임 스모크(재기동+200+육안 라이트/다크) PASS 로 직전 세션 미해결분이 표면화·해소됨"
|
||||||
|
about: "JSP 변경에 §291(런타임 스모크 의무) 교훈을 준수해 빌드 PASS 만으로 끝내지 않고 L2 육안 검증까지 수행 — 직전 세션 미해결 케이스가 이 단계에서 비로소 검증됨"
|
||||||
|
negative:
|
||||||
|
- quote_or_paraphrase: "이미지 미리보기뿐 아니라 상단에 검색바도 혼자 짧잖아"
|
||||||
|
about: "'비율' 모호 어휘를 카드 종횡비 단일 축으로 조기 협소화 → 첫 AskUserQuestion 옵션이 레이아웃 비율(검색바 정렬) 축을 누락. 사용자가 직접 교정"
|
||||||
|
structural: true
|
||||||
|
- quote_or_paraphrase: "실측을 메인이 하지말고 서브에이전트 다축평가로"
|
||||||
|
about: "orchestrator 가 실측/검증을 직접 수행하려던 흐름을 사용자가 멀티에이전트 위임으로 교정 (§1 직접수행 금지 방향과 정합)"
|
||||||
|
structural: false
|
||||||
|
what_went_well:
|
||||||
|
- "사용자 교정 직후 ui-multiaxis-eval 워크플로(6 agents)로 전환해 누락된 레이아웃 비율 축을 포함한 13건 결함 + 1 open_decision 으로 구조화 — 협소화 맹점을 다축 병렬 스캔으로 구조적으로 보완"
|
||||||
|
- "JSP/CSS 변경에 §291 런타임 스모크 의무 준수: 재기동 → GET / + /recruit 200 → 라이트/다크 양화면 육안 확인까지 수행. 빌드 PASS 를 검증 완료로 오인하지 않음"
|
||||||
|
- "카드 종횡비·적용 범위를 AskUserQuestion 으로 사용자 선택에 위임하고 비자명 조정(#4 search-stack wrapper, #8 로컬 시각스펙)은 advisor 근거와 함께 결정"
|
||||||
|
what_to_improve:
|
||||||
|
- "[structural] '비율'·'가시성' 같은 모호한 시각 결함 어휘를 받았을 때, 결정축(카드 종횡비)으로 협소화한 채 AskUserQuestion 옵션을 설계해 인접 축(레이아웃 비율=검색바 정렬)을 누락. 협소화는 옵션 설계 *이전* 단계에서 일어나 §4.4 item5(표현·레이아웃 축 누락 주의) 가이드가 적용될 시점을 지나쳐 있었다 → 다축 스캔을 옵션 설계 *앞* 으로 강제하는 게이트 필요"
|
||||||
|
- "orchestrator 가 실측을 직접 하려 한 점 — 단발이나, 검증/조사 직접수행 회피 원칙을 결정 시점에 환기하는 장치 보강 여지"
|
||||||
|
memory_candidates:
|
||||||
|
- name: visual-defect-vocab-multiaxis-scan-before-narrowing
|
||||||
|
type: feedback
|
||||||
|
description: "모호한 시각 결함 어휘는 단일 결정축으로 협소화하기 전에 다축 스캔을 선행한다"
|
||||||
|
body_draft: |
|
||||||
|
## What
|
||||||
|
'비율', '가시성 떨어진다', '어색하다' 같이 진단축이 미지정된 시각 결함 어휘를 받으면,
|
||||||
|
가장 떠오르는 단일 축(예: '비율'→카드 종횡비)으로 협소화한 채 수정/AskUserQuestion 옵션을 설계하지 않는다.
|
||||||
|
먼저 다축 스캔(레이아웃 비율·요소 종횡비·대비/가시성·타이포/간격 등)을 1회 선행해
|
||||||
|
어휘가 어느 축들을 가리킬 수 있는지 후보를 펼친 뒤 옵션을 구성한다.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
- 협소화는 옵션 *설계 이전* 단계에서 일어난다. 옵션을 다 만든 뒤에 "표현·레이아웃 축이 빠지지 않았나"
|
||||||
|
점검하는 §4.4 item5 식 사후 가드는 이미 협소화된 프레임 안에서 돌아 누락을 못 잡는다.
|
||||||
|
- 시각 결함은 데이터/기능 결함과 달리 사용자가 축을 명시하지 않고 인상 어휘로 표현하는 경향이 강하다.
|
||||||
|
- 세션 20260630-175023: '비율'을 카드 종횡비로 협소화 → 첫 옵션이 검색바 정렬(레이아웃 비율) 축 누락
|
||||||
|
→ 사용자가 "검색바도 혼자 짧다"로 직접 교정. 직후 다축 멀티에이전트 평가로 전환하자 누락 축 복원 + 13건 표면화.
|
||||||
|
|
||||||
|
## How to apply
|
||||||
|
1. 시각 결함 어휘 수신 시 결정축 확정 전, 최소 4축(레이아웃 비율 / 요소 종횡비 / 대비·가시성 / 타이포·간격) 스캔.
|
||||||
|
2. 스캔에서 2축 이상 후보가 잡히면 AskUserQuestion 옵션·수정 범위에 그 축들을 모두 반영.
|
||||||
|
3. 결함 표면이 화면 전체에 걸치면 orchestrator 직접 협소 수정 대신 ui-multiaxis-eval(관찰1→N축 병렬→종합1) 위임을 우선 고려.
|
||||||
|
rationale_for_saving: "재현성 있는 구조적 맹점(§4.4 item5 가 있는데도 발생). 코드/커밋에서 유도 불가한 진단 휴리스틱. frontend-design 류 작업에서 반복 가능"
|
||||||
|
signal_source: negative
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
|
||||||
|
memory_optional: true
|
||||||
|
- name: ui-multiaxis-eval-recovers-narrowing-blindspot
|
||||||
|
type: feedback
|
||||||
|
description: "화면 전반 시각 결함은 다축 멀티에이전트 평가(관찰1→N축 병렬→종합1) 위임이 협소화 맹점을 구조적으로 보완"
|
||||||
|
body_draft: |
|
||||||
|
## What
|
||||||
|
모호하거나 광범위한 시각 결함은 orchestrator 직접 협소 수정보다, 다축 멀티에이전트 평가
|
||||||
|
(관찰1 → 축별 병렬 평가 N → 종합1) 토폴로지로 위임하면 단일 시점 협소화 맹점을 구조적으로 메운다.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
- 단일 에이전트(또는 orchestrator)는 입력 어휘에 시선이 쏠려 인접 축을 놓친다.
|
||||||
|
- 축을 분리해 병렬 평가하면 각 축이 독립적으로 결함을 보고 → 종합 단계에서 누락 없는 합집합 산출.
|
||||||
|
- 세션 20260630-175023: 사용자 교정 직후 ui-multiaxis-eval(6 agents) 전환 → 입력 2건이 13건 + open_decision 으로 확장,
|
||||||
|
누락됐던 레이아웃 비율 축 복원. 사용자가 추가 재지시 없이 구현 진입 수락(검증된 긍정 패턴).
|
||||||
|
|
||||||
|
## How to apply
|
||||||
|
- 시각 결함이 1요소가 아니라 화면/페이지 단위에 걸치거나 어휘가 모호하면 다축 평가 워크플로 위임.
|
||||||
|
- orchestrator 가 실측·축평가를 직접 수행하려는 충동을 게이트(§1 직접수행 회피)로 차단.
|
||||||
|
rationale_for_saving: "비자명한 토폴로지 선택이 사용자 수락으로 검증됨. 재현 가능한 작업 패턴이며 코드에서 유도 불가"
|
||||||
|
signal_source: positive
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
|
||||||
|
memory_optional: true
|
||||||
|
protocol_feedback:
|
||||||
|
- "[structural — 외부 ATP 번들 §4.4 item5 대상] §4.4 item5(가시성·표현·레이아웃 축은 데이터 축에 시선 쏠려 빠지기 쉬움)는 '옵션을 만든 뒤 빠진 축 점검' 형태의 사후 가드라, 어휘 협소화가 *옵션 설계 이전* 에 일어난 이번 케이스에서 적용 시점을 지나쳤다. item5 를 'AskUserQuestion 옵션 설계 전, 모호한 시각/표현 결함 어휘는 다축 스캔(레이아웃·종횡비·대비·타이포) 선행' 이라는 *선행 게이트* 로 강화 권고. 진단축이 미지정된 인상 어휘('비율/가시성/어색')를 트리거로 명시."
|
||||||
|
- "[non-structural] orchestrator 가 실측/축평가를 직접 수행하려는 흐름은 §1 직접수행 회피 원칙과 충돌. 시각 결함이 화면 단위로 걸칠 때 '직접 수정 vs 다축 위임' 판단을 advisor invocation decision log 의 명시 분기로 두는 보강 여지(이번엔 사용자 교정으로 흡수, 단발이라 structural 아님)."
|
||||||
|
applied_changes: []
|
||||||
|
```
|
||||||
|
|
||||||
|
## ended_at
|
||||||
|
2026-06-30T18:10 (KST)
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
---
|
||||||
|
schema_version: 2
|
||||||
|
sid: 20260701-083240
|
||||||
|
started_at: 2026-07-01T08:32:40
|
||||||
|
user_request: |
|
||||||
|
"[Image] 게임 카드는 다시 봐야겠는데" — 직전 세션 4:3 적용 후 게임 카드 재검토.
|
||||||
|
이미지는 더미(fallback) 상태: 4:3 다크 미디어 + 중앙 흰 로고박스.
|
||||||
|
Invocations: []
|
||||||
|
Summary: ''
|
||||||
|
Decisions: []
|
||||||
|
user_signals:
|
||||||
|
positive: []
|
||||||
|
negative: []
|
||||||
|
graph_refresh: ''
|
||||||
|
open_items: []
|
||||||
|
verified_by_me: []
|
||||||
|
needs_user_verification: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
|
||||||
|
- advisor: requirements
|
||||||
|
decision: skip(직접)
|
||||||
|
rationale: '"다시 봐야겠는데" 열린 요청 — 단독 협소화 금지(직전 교훈). 계획게이트로 축 합의 선행.'
|
||||||
|
checked_at: 2026-07-01T08:33
|
||||||
|
|
||||||
|
- advisor: verification
|
||||||
|
decision: call(self)
|
||||||
|
rationale: 'JSP 변경 → §291 런타임 스모크. 재기동+육안 라이트/다크.'
|
||||||
|
checked_at: 2026-07-01T09:35
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
게임 카드 재검토(사용자 축 선택: 위계·여백). 텍스트 diff 로는 예측 어렵다는 피드백 → 실제 카드 CSS·토큰으로 현재/A/B 시각 프리뷰를 앱 정적경로(/css/)에 서빙해 사용자·에이전트 공동 확인. 사용자 B 선택 → index.jsp 적용 → 런타임 스모크 PASS.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- 축: "카드 전반 위계·여백" (사용자 multiSelect 선택; fallback/비율/배경 미선택)
|
||||||
|
- 변경안 **B** 채택 (제목 15px·700, 좋아요 muted+숫자 골드, 여백↑) — 시각 프리뷰 비교 후 사용자 선택
|
||||||
|
- 프리뷰 검증 방식: private SendUserFile(텍스트 한계) 대신 앱 정적경로 서빙 → 새로고침 공동 확인 (사용자 제안)
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- L2 런타임 스모크(PASS): bibimbap-app 재기동 → GET / 200 (좋아요 <b> 마크업 반영) → 브라우저 육안 라이트+다크.
|
||||||
|
- 카드 제목 15px·700 지배력↑, 좋아요 숫자 골드 강조, 여백↑ — zoom 확정.
|
||||||
|
- 정적 프리뷰 mojibake 발견·수정: 서버가 .html 에 charset 미부착 → <meta charset=utf-8> 추가로 해소(§299 정적파일 확장 케이스).
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
- 실게임 가로 썸네일 업로드 시 4:3 + B 위계 최종 인상(더미는 폴백 로고).
|
||||||
|
- fallback 로고 "작게" 는 의도된 placeholder 신호(브랜드 로고≠게임아트, 흰박스 글레어 회피). 빈 인상 완화 원하면 별도 "fallback 표현" 축 작업(로고확대/모노그램/배경틴트) — 사용자 이번 미선택.
|
||||||
|
|
||||||
|
## graph_refresh
|
||||||
|
skip: no-scope-change — 변경=index.jsp 카드 CSS값+<b> 마크업, JSP/CSS 정적자산은 graph 스캔 외(§285). 그래프 30분 전 클린 풀 재생성(20789a2). 구조 0델타.
|
||||||
|
|
||||||
|
## user_signals
|
||||||
|
positive:
|
||||||
|
- quote: "나도 B가 괜찮아보이네"
|
||||||
|
note: "시각 프리뷰 공동 확인 후 1라운드 수락. 프리뷰-in-page 접근이 결정 마찰 낮춤."
|
||||||
|
negative:
|
||||||
|
- quote: "(프리뷰 한글 mojibake 노출)"
|
||||||
|
structural: false
|
||||||
|
note: "정적 프리뷰 .html 에 meta charset 누락 → 사용자 화면서 mojibake. 사용자가 스샷으로 표면화. 단발(§299 는 .jspf만 명시, 정적 .html 서빙은 사각). meta charset 추가로 즉시 해소."
|
||||||
|
|
||||||
|
## ended_at
|
||||||
|
2026-07-01T09:36 (KST)
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
---
|
||||||
|
schema_version: 1
|
||||||
|
sid: 20260701-093754
|
||||||
|
started_at: 2026-07-01T09:37:54+09:00
|
||||||
|
ended_at: 2026-07-01T10:15:00+09:00
|
||||||
|
user_request: |
|
||||||
|
이전 세션에서 UI작업을 계속하고있었는데 메인 화면에서 게임 목록 카드 내부 패딩이 너무 높은거 같아.
|
||||||
|
(스크린샷: 카드 body 내 크리에이터~좋아요 사이 여백이 과도해 보임)
|
||||||
|
invocations: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요구가 명확 (특정 카드 여백 축소), 스크린샷으로 문제 지점 확인됨'
|
||||||
|
checked_at: 2026-07-01T09:38:00+09:00
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '단일 파일 CSS 수치 조정, 마이크로 편집 스킵 기준 충족 — orchestrator 직접 수행. 다만 §5.0 계획 가시성 의무는 AskUserQuestion으로 충족'
|
||||||
|
checked_at: 2026-07-01T09:39:00+09:00
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '단일 파일(index.jsp) 2줄 CSS 변경, worker 분산 불필요'
|
||||||
|
checked_at: 2026-07-01T09:39:00+09:00
|
||||||
|
- advisor: verification-advisor
|
||||||
|
decision: skip (직접 수행)
|
||||||
|
rationale: '전용 테스트 스위트 없는 순수 CSS 변경 — 대신 orchestrator가 직접 taglib grep + curl 런타임 스모크 + 브라우저 fork 시각 확인 수행 (verification-strategies.md 런타임 스모크 규정 준수)'
|
||||||
|
checked_at: 2026-07-01T09:55:00+09:00
|
||||||
|
- advisor: documentation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '이전 커밋(b11546a)의 후속 미세 조정, 별도 ADR/설계 문서 불필요 — 커밋 메시지로 충분'
|
||||||
|
checked_at: 2026-07-01T10:10:00+09:00
|
||||||
|
- advisor: graph-refresh-checker
|
||||||
|
decision: call
|
||||||
|
rationale: '프로토콜 §9-4: 코드 변경 1줄이라도 있으면 예외 없이 실행 (atp-graphify addon 활성 상태)'
|
||||||
|
checked_at: 2026-07-01T10:12:00+09:00
|
||||||
|
- advisor: retrospective-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '구조적 부정 시그널 발생(1차 검증 false pass)했으나 근본원인 파악 + docs-first 정정(local-dev-setup.md 함정 항목 추가)을 orchestrator가 즉시 직접 수행 완료 — 별도 회고 에이전트 호출 없이도 재현성 있는 교훈이 docs에 반영됨. 규모상 정식 회고 오버킬로 판단.'
|
||||||
|
checked_at: 2026-07-01T10:40:00+09:00
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
메인 화면 게임 카드(`.card__body`, `.card__likes`) 내부 여백이 직전 커밋(b11546a)에서 과도하게 커져 답답하다는 사용자 지적. 사용자가 단순 롤백이 아닌 개선을 요청해 `/frontend-design` 스킬 원칙(의도된 여백 vs 매직넘버)을 적용, `.card__likes`를 고정 `margin-top: 0.45rem` 대신 `margin: auto 0 0`으로 전환해 카드 하단에 앵커링하고, `.card__body` padding을 0.7/0.75/0.75 → 0.65/0.75/0.7rem으로 소폭 축소.
|
||||||
|
|
||||||
|
# Invocations
|
||||||
|
|
||||||
|
- Skill(frontend-design): 카드 위계·여백 재설계 원칙 참조, "고정 margin 대신 flex auto-margin으로 하단 앵커링" 방향 도출
|
||||||
|
- Agent(fork, 브라우저 확인): http://localhost:8080/ 라이트/다크 스크린샷 확인 — 좋아요 줄 하단 앵커링 정상 동작, 어색한 여백 해소 확인
|
||||||
|
- Agent(atp-graphify:graph-refresh-checker): partial-stale 판정이지만 변경분이 JSP 인라인 CSS 수치뿐(§285 스캔 범위 외) → 재생성 불필요 결론
|
||||||
|
|
||||||
|
# Decisions
|
||||||
|
|
||||||
|
- 크리에이터~좋아요 간 여백을 고정 rem값으로 줄이는 대신 `margin-top: auto`로 전환 — 제목 1줄/2줄 케이스에 관계없이 좋아요가 항상 카드 하단에 정렬되는 구조적 개선 (사용자 요청: "롤백말고 개선을 고려해줘")
|
||||||
|
|
||||||
|
# Files changed
|
||||||
|
|
||||||
|
- `src/main/webapp/WEB-INF/views/index.jsp`
|
||||||
|
- `.card__body` padding: `0.7rem 0.75rem 0.75rem` → `0.65rem 0.75rem 0.7rem`
|
||||||
|
- `.card__likes` margin: `0.45rem 0 0` → `auto 0 0`
|
||||||
|
|
||||||
|
# verified_by_me
|
||||||
|
|
||||||
|
- L1: 해당 없음 (JSP 정적 CSS 수치, 컴파일 대상 아님) — 대신 `rg '<%@\s*taglib'` 로 taglib 오염 없음 확인
|
||||||
|
- **1차 검증 오류(false pass) 발견 및 정정**: 최초 브라우저 fork 확인은 "정상 반영"으로 보고했으나, `curl http://localhost:8080/ | grep`으로 실제 서빙 CSS를 파일과 직접 대조하니 옛 값(`padding 0.7rem 0.75rem 0.75rem`, `margin 0.45rem 0 0`)이 그대로 서빙되고 있었음 — JSP "저장 즉시 반영"이 이 환경에서 깨져 있었고 브라우저 fork는 그 stale 렌더링을 본 것. 사용자가 재캡처 스샷으로 "왜 여전히 안 줄었냐"고 질문해 발견.
|
||||||
|
- 원인: 컴파일된 `index_jsp.class` mtime(00:30) < 소스 mtime(00:44) — Jasper 재컴파일 미발생. `docker compose restart app` 강제 재기동 후 `curl` 재대조 → 새 값(`0.65rem 0.75rem 0.7rem`, `margin auto 0 0`) 정상 서빙 확인.
|
||||||
|
- 런타임 스모크(재기동 후): `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/` = 200, 서빙 CSS = 파일과 일치
|
||||||
|
- 시각 확인(재기동 후 재검증): 브라우저 fork hard reload — 크리에이터~좋아요 간 여백 타이트하게 좁혀짐 확인, 어색한 빈 공간 해소
|
||||||
|
- 로그 스캔: clean (재기동 정상, 에러 없음)
|
||||||
|
- **문서 정정**: `docs/development/local-dev-setup.md`에 "JSP 즉시반영 실패 사례" 함정 항목 추가 — 향후 JSP 수정 시 브라우저 스크린샷만 믿지 말고 `curl` 대조 선행 권고
|
||||||
|
|
||||||
|
# needs_user_verification
|
||||||
|
|
||||||
|
- 현재 DB에 더미 게임 카드 1개뿐 — 여러 카드가 그리드에 나란히 있을 때(제목 1줄 vs 2줄 혼재) 좋아요 위치가 실제로 균일하게 정렬되는지는 실사용 데이터로 재확인 필요
|
||||||
|
|
||||||
|
# graph_refresh
|
||||||
|
|
||||||
|
partial-stale (3커밋 누적) 판정이지만 이번 변경분은 JSP 인라인 CSS 수치뿐이라 §285 스캔 범위 외 → 재생성 스킵. 구조적 변경(라우트/매퍼/서비스) 발생 시 한 번에 full scope 재생성 권고.
|
||||||
|
|
||||||
|
# user_signals
|
||||||
|
|
||||||
|
positive:
|
||||||
|
- "롤백말고 개선을 고려해줘 /frontend-design 스킬 참고해서" — 단순 되돌리기보다 근본 개선을 원하는 명시적 방향성 제시, 결과에 대한 반대 신호 없음
|
||||||
|
negative:
|
||||||
|
- quote: "더미가 통과하는 이유가뭐야?"
|
||||||
|
context: "1차 검증(브라우저 fork)이 '정상 반영'으로 보고했으나 실제로는 JSP 재컴파일 미발생으로 stale 렌더링을 본 false pass였음. 사용자가 재캡처 스샷으로 여백이 그대로임을 지적하며 검증 신뢰성에 의문 제기."
|
||||||
|
structural: true
|
||||||
|
|
||||||
|
# open_items
|
||||||
|
|
||||||
|
(없음 — 변경 전량 커밋 대상)
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Work Session Report
|
||||||
|
|
||||||
|
schema_version: 2
|
||||||
|
|
||||||
|
## user_request
|
||||||
|
> http://localhost:8080/game/3 예시 데이터 최신 스펙에 맞춰서 변경하자.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
`/game/3` 더미 리뷰 5건(seed-dev.sql 유래, id 1~5)이 6축 리뷰 스펙(GameReviewController.AXIS_KEYS: immersion/creativity/controls/completeness/sound/visual) 도입 이전 데이터라 `game_review_axes` 행이 0개였음. 앱 불변식상 리뷰 작성 시 6축 전부 필수(parseAxes 는 하나라도 누락 시 null→400)인데 더미 데이터만 예외 상태 — `/game/3` 레이더 차트가 더미 리뷰에 대해 빈 값 렌더. `db/seed-dev.sql` 에 axis INSERT 추가 + 라이브 dev DB(game_id=3, review id 1~5) 백필로 정합화.
|
||||||
|
|
||||||
|
## Invocations
|
||||||
|
[]
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청("예시 데이터 최신 스펙에 맞춰서 변경") 자체는 모호했으나 orchestrator 직접 조사(Serena+psql)로 6축 리뷰 스펙 불일치를 구체적 근거(file:line, DB row 비교)로 확정 — AskUserQuestion 으로 해석 확인 후 requirements-advisor 생략'
|
||||||
|
checked_at: 2026-07-01T10:10:00+09:00
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 'orchestrator 가 GameReviewController.java/game-detail.jsp/DB 스키마를 직접 조회해 6축 스펙과 더미데이터 불일치를 확정 — 조사 스코프가 좁고 이미 완료됨'
|
||||||
|
checked_at: 2026-07-01T10:10:00+09:00
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '파일영향맵(db/seed-dev.sql 1개)·계약(game_review_axes 스키마)·시퀀스(seed sql 갱신→라이브 DB 백필→검증)가 이미 확정적 — 마이크로 편집 예외(SKILL §5.1) 적용'
|
||||||
|
checked_at: 2026-07-01T10:10:00+09:00
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '단일 파일 + 이미 확정된 axis 값 설계 — orchestrator 직접 구현 (마이크로 편집 예외)'
|
||||||
|
checked_at: 2026-07-01T10:10:00+09:00
|
||||||
|
- advisor: verification-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '코드 변경 없음(SQL data-only) — verification-strategies.md 기준 L1(빌드) 불필요 대상. orchestrator 가 직접 L2 스모크(/game/3 재조회 + DB row 확인)로 대체, 근거는 report.md verified_by_me 섹션에 기록'
|
||||||
|
checked_at: 2026-07-01T10:10:00+09:00
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- 6축 axis 값은 각 더미 리뷰의 기존 `rating` 값과 평균(반올림)이 일치하도록 설계 + 리뷰 본문 뉘앙스 반영(예: BGM 칭찬 리뷰는 sound 축 높게).
|
||||||
|
- `is_rating_manual` 은 기존값(false) 유지 — 이미 axes 평균으로 rating 이 결정된 것과 일관.
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- L1: 해당 없음 (코드 변경 없음, SQL data-only — 빌드 영향 없음)
|
||||||
|
- L2: `docker exec -i bibimbap-db psql ... < db/seed-dev.sql` 재실행 → 멱등 확인 (dummy_reviews=6, 에러 0)
|
||||||
|
- L2: `SELECT ... game_review_axes` 조인 확인 — game_id=3 리뷰 6건 전부 axis_rows=6, avg round = 기존 rating 과 일치 (1:4.667→5, 2:4.0→4, 3:3.0→3, 4:4.667→5, 5:2.167→2)
|
||||||
|
- L2: `GET /game/3/reviews` HTTP 200 — 응답 JSON 6건 전부 `axes` 필드 6키 채워짐 확인
|
||||||
|
- L2: `GET /game/3` HTTP 200, 레이더 마크업(`game-axis-radar`) 서빙 확인
|
||||||
|
- 로그 스캔: clean (에러 없음)
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
- 브라우저에서 `/game/3` 리뷰 탭 열어 레이더 차트가 더미 리뷰 5건 모두에 대해 시각적으로 채워지는지 육안 확인 (API 데이터는 확인했으나 SVG 렌더 자체는 미확인)
|
||||||
|
- **known pitfall**: `docs/development/local-dev-setup.md` "JSP 저장 즉시반영 실패" — 직전 세션(20260701-093754)에서 발견됨. 같은 JSP 렌더 경로(game-detail.jsp)라 브라우저 화면만 보고 판단하지 말고 `curl http://localhost:8080/game/3/reviews` 로 axes 값이 최신인지 먼저 대조, 불일치 시 `docker compose restart app` 후 재확인.
|
||||||
|
|
||||||
|
## open_items
|
||||||
|
(없음)
|
||||||
|
|
||||||
|
## graph_refresh
|
||||||
|
fresh → 후속 없음 (graph-refresh-checker 판정: db/seed-dev.sql 은 DDL 아닌 DML, full scope(src/+docs/) 대상 경로 밖 — 재생성 불필요)
|
||||||
|
|
||||||
|
## user_signals
|
||||||
|
- positive: ["AskUserQuestion 에서 '진행 (추천)' 1회 수락 — 조사 근거 제시 방식에 별다른 지적 없음"]
|
||||||
|
- negative: []
|
||||||
|
|
||||||
|
started_at: 2026-07-01T10:07:31+09:00
|
||||||
|
ended_at: 2026-07-01T10:16:00+09:00
|
||||||
|
|
||||||
|
## Retrospective
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Retrospective:
|
||||||
|
signals:
|
||||||
|
positive:
|
||||||
|
- quote_or_paraphrase: "AskUserQuestion '진행 (추천)' 1회 수락, 별다른 지적 없음"
|
||||||
|
about: "orchestrator 직접 조사(Serena+psql)로 모호 요청을 구체적 불일치(axis 행 0개)로 grounding 한 뒤 계획을 1회 확인받은 흐름"
|
||||||
|
negative: []
|
||||||
|
what_went_well:
|
||||||
|
- "모호한 '예시 데이터 최신 스펙 반영' 요청을 requirements-advisor 호출 대신 orchestrator 직접 조사(코드 file:line + DB row 비교)로 반증 가능한 사실을 먼저 확정한 뒤 AskUserQuestion 1회로 해석을 고정 — 재질의 없이 1라운드 수렴. §304(모호 어휘는 협소화 전 스캔 선행) 원칙과 동일 계열의 적용."
|
||||||
|
- "verified_by_me 에 psql 조인 결과(axis_rows=6, avg round 일치)와 API 응답 필드까지 구체 수치로 기록 — L1 부재를 '근거 없이 스킵'이 아니라 대체 근거로 메움."
|
||||||
|
- "needs_user_verification 에 'API 데이터는 확인했으나 SVG 렌더 자체는 미확인'을 명시적으로 남겨, 값 확인(L1/L2 수준)과 실제 화면 반영(L3) 을 섞지 않고 분리 — §221 원칙과 정합."
|
||||||
|
what_to_improve:
|
||||||
|
- "verification-strategies.md 의 '버그 범주 → L 레벨' 표에 'SQL data-only(DML, 스키마/코드 무변경)' 행이 없어, 이번 스킵 rationale('L1 해당없음 + L2로 대체')이 매 세션 즉흥 판단에 의존한다. 다음에 유사 data-only 세션이 오면 동일 논리를 처음부터 다시 정당화해야 한다."
|
||||||
|
- "직전 세션(20260701-093754, 종료 10:15:00)에서 'JSP 저장 후 Jasper 재컴파일 지연으로 브라우저가 stale 렌더링을 정상으로 오판'하는 구조적 함정이 방금 발견·docs화(local-dev-setup.md)되었는데, 이번 세션(10:16:00 종료, 4분 뒤)은 레이더 차트(JSP 렌더 대상)가 관련된 변경임에도 verified_by_me 에 그 함정에 대한 교차 확인(curl 대조 등)이나 참조가 없다. 브라우저 육안 확인을 needs_user_verification 으로 넘긴 것 자체는 적절하나, 넘길 때 '직전 세션에 발견된 JSP stale 렌더링 함정을 함께 확인하라'는 힌트가 없어 다음 세션/사용자가 같은 함정을 다시 밟을 여지가 있다."
|
||||||
|
memory_candidates:
|
||||||
|
- name: verification-data-only-sql-change-l-level
|
||||||
|
type: project
|
||||||
|
description: "SQL data-only(DML) 변경의 검증 레벨 공백을 검증 레지스트리 표에 명시"
|
||||||
|
body_draft: |
|
||||||
|
Why: 코드/스키마 변경 없이 seed/백필 SQL(DML)만 바뀌는 세션에서 L1(빌드/단위테스트)이 원천적으로 해당 없다. 이 케이스가 verification-strategies.md 의 "버그 범주 → 의무 레벨" 표에 없어 매번 orchestrator 가 즉흥적으로 "L1 스킵 + L2 대체"를 재정당화하고 있다.
|
||||||
|
How to apply: 표에 아래 행을 추가한다.
|
||||||
|
| SQL data-only 변경(DML: seed/백필, 스키마·코드 무변경) | L1 해당없음(명시) + L2(대상 API/DB 조회로 값 일치 확인) 필수 |
|
||||||
|
추가로 "레이더 차트/뷰 등 JSP 렌더 대상에 걸치는 data 변경은 L2(API/DB 값 확인)만으로 끝내지 말고, JSP stale 렌더링 함정(local-dev-setup.md)을 needs_user_verification 에 교차 언급"하는 문구를 각주로 첨부.
|
||||||
|
rationale_for_saving: "재현 가능한 패턴(향후 seed/백필류 세션마다 반복 발생) + 기존 표에서 유도 불가(현재 표에 해당 행 없음) + 기존 memory/문서와 중복 아님."
|
||||||
|
signal_source: observation
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
|
||||||
|
memory_optional: false
|
||||||
|
- name: needs-user-verification-cross-reference-known-pitfall
|
||||||
|
type: project
|
||||||
|
description: "needs_user_verification 항목이 최근 세션에서 발견된 구조적 함정(예: JSP stale 렌더링)과 겹치면 그 함정을 명시적으로 인용"
|
||||||
|
body_draft: |
|
||||||
|
Why: 직전 세션(20260701-093754)에서 'JSP 저장 후 Jasper 재컴파일 지연 → 브라우저 fork 가 stale 렌더링을 정상으로 오판'하는 구조적 negative 시그널이 발견되어 local-dev-setup.md 에 즉시 반영됐다. 4분 뒤 종료된 후속 세션이 같은 렌더 대상(JSP 레이더 차트)에 대해 브라우저 육안 확인을 needs_user_verification 으로 이월하면서도 그 함정을 인용하지 않아, 다음 세션/사용자가 curl 대조 없이 브라우저만 보고 재차 false pass 를 낼 위험이 남았다.
|
||||||
|
How to apply: needs_user_verification 작성 시, 최근 N세션 이내(work-session 디렉토리 최신 3~5개) docs 반영된 구조적 함정 중 이번 변경 대상과 렌더링 경로가 겹치는 것이 있으면 "known pitfall: <문서 링크>, curl 대조 선행 권고" 를 항목에 병기한다.
|
||||||
|
rationale_for_saving: "재발 가능(JSP 프로젝트 특성상 반복) + 기존 §217(결정 분기 구조화) 원칙의 자연스러운 확장이나 명문화는 안 돼있음."
|
||||||
|
signal_source: observation
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
|
||||||
|
memory_optional: false
|
||||||
|
protocol_feedback:
|
||||||
|
- "verification-advisor skip 근거로 'SQL data-only, L1 불필요' 를 orchestrator 가 매 세션 재판단하는 대신, verification-strategies.md 표에 해당 범주를 등재하면 판단 비용과 일관성 문제가 해소된다 (memory_candidates 항목 1과 동일 제안, structural 아님 — 단순 표 공백)."
|
||||||
|
applied_changes: []
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
# Work Session Report
|
||||||
|
|
||||||
|
schema_version: 2
|
||||||
|
sid: 20260701-102212
|
||||||
|
user_request: >
|
||||||
|
/frontend-design 리뷰 종합 항목 그래프(막대 미터 + 육각 레이더)에서
|
||||||
|
각 그래프 끝이 어떤 점수인지 직관적으로 알 수 없음 — 가시성 확보 전략 필요.
|
||||||
|
대상: src/main/webapp/WEB-INF/views/game-detail.jsp (game-reviews__axis-meter,
|
||||||
|
buildRadarLegend, 라인 ~1097-1119, ~1704-1726)
|
||||||
|
Invocations: []
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청 명확 — 대상 UI(6축 미터바) 특정, 문제(끝점 스케일 무맥락) 명확'
|
||||||
|
checked_at: 2026-07-01T10:22:12+09:00
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '대상 파일/구조 이미 grep 으로 확정 — 외부 자료 불요'
|
||||||
|
checked_at: 2026-07-01T10:22:12+09:00
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '단일 이산 결정축(시각 전략 선택) — AskUserQuestion 으로 orchestrator 직접 제시, 마이크로 스코프(1파일 CSS/JS)'
|
||||||
|
checked_at: 2026-07-01T10:22:12+09:00
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '영향 파일 1개(game-detail.jsp), 변경 범위 CSS 룰 + JS 함수 1개 확정적 — orchestrator 직접 구현'
|
||||||
|
checked_at: 2026-07-01T10:22:12+09:00
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
사용자 지적: 리뷰 종합 6축 미터바(막대) 끝점이 어느 점수인지 눈금/기준선이 없어 직관적으로 안 보임.
|
||||||
|
AskUserQuestion 으로 시각전략 4안 제시 → "눈금선 추가" 선택.
|
||||||
|
game-detail.jsp `.game-reviews__axis-meter` 에 `::before` overlay(repeating-linear-gradient, mix-blend-mode: overlay) 추가해 1~5 등분 눈금(20/40/60/80/100%) 표시. JS/마크업 변경 없음, CSS 1블록.
|
||||||
|
|
||||||
|
## Invocations
|
||||||
|
- (advisor 전량 skip — decision log 참조) orchestrator 직접: 코드탐색(grep) → AskUserQuestion(시각전략 4안) → CSS 구현 → JSP 즉시반영 stale 확인(curl 대조, known-pitfall 재현) → docker compose restart app → curl 200 + 재대조 일치 확인 → 브라우저 라이트/다크 테마 각각 zoom 스크린샷으로 눈금-점수 정합 확인(조작성=3 → 3번째 눈금에서 정확히 끝남).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
- 시각전략: 4안(눈금선/라벨이동/색상코드/눈금+만점강조) 중 "눈금선 추가"를 사용자가 선택 — CSS 배경 오버레이만으로 구현 가능해 스코프 최소.
|
||||||
|
- advisor 전량 skip: 영향 파일 1개, 변경 CSS 1블록, 결정축 단일 이산 → §5.0 은 AskUserQuestion 으로 충족(ExitPlanMode 불필요), 구현은 마이크로 스코프로 orchestrator 직접.
|
||||||
|
|
||||||
|
## verified_by_me
|
||||||
|
- L1: 해당없음 (Java 변경 0 — 컴파일/단위테스트 대상 아님)
|
||||||
|
- L2: 해당없음 (외부 의존 없음, 순수 CSS)
|
||||||
|
- JSP 런타임 스모크 (verification-strategies.md JSP 사전컴파일 미설정 함정 규약 준수): curl 로 `/game/3` 서빙 CSS 대조 → 최초 stale 확인(known-pitfall 재현) → `docker compose restart app` → 재대조 일치 + `http_code=200` 확인
|
||||||
|
- 브라우저 실제 렌더링: 라이트/다크 테마 각각 zoom 스크린샷으로 눈금-점수 시각 정합 확인
|
||||||
|
- 로그 스캔: clean (앱 재기동 로그 오류 없음, 컨테이너 정상 Started)
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
(없음)
|
||||||
|
|
||||||
|
## open_items
|
||||||
|
(없음)
|
||||||
|
|
||||||
|
## graph_refresh
|
||||||
|
fresh (graph-refresh-checker 판정) — CSS-only 변경, 구조적 시그널 0, index.md §285 스캔범위 외 확인. 재생성 불필요.
|
||||||
|
|
||||||
|
## Summary (추가 — 육각 레이더 가시성)
|
||||||
|
사용자 정정: 앞선 미터바 수정과 별개로 원 지적은 "육각 레이더" 자체였음.
|
||||||
|
추가 지적: 육각형만으론 어떤 축인지도 식별 불가.
|
||||||
|
AskUserQuestion 2회 (1회는 한글 인코딩 손상으로 재질문) 후 "축 이름 + 점수 둘 다 표시" 선택.
|
||||||
|
|
||||||
|
## Decisions (추가)
|
||||||
|
- buildHexRadar(scores, cx, cy, R, withLabels) 5번째 파라미터 추가 — summary 레이더만 true, 리뷰카드 소형 레이더는 기존대로 라벨 없음(caption 밀도 문제로 스코프 제외, 사용자 확인 없이 orchestrator 판단 — 필요시 후속 피드백으로).
|
||||||
|
- 동심 그리드 4단 → 5단 전환(1/5..5/5) — 0~5점 스케일과 정수 격자 1:1 대응 (미터바 눈금 판단과 동일 원리).
|
||||||
|
- 각 축 끝에 축 이름(AXIS_LABELS_KO) SVG text 배치, 각 데이터 꼭지점 옆에 점수 SVG text 배치.
|
||||||
|
- 최초 구현 시 우측 라벨(창의성/조작성/사운드)이 우측 dl 범례와 겹침 발견(zoom 스크린샷) → 원인: buildRadarLegend가 radar wrapper div 내부(svg 옆)에 append되는 구조라 panel gap이 아닌 `.game-reviews__radar` 자체 gap 부재가 원인. `.game-reviews__radar { gap: 1.75rem }` 추가로 해결(sr-only 카드 레이더는 position:absolute라 영향 없음 확인). 라벨 배치 ratio도 1.22→1.14로 낮춰 상/하단 라벨 패널 경계 잘림 해소.
|
||||||
|
|
||||||
|
## verified_by_me (추가)
|
||||||
|
- 라이트/다크 테마 각각 zoom 스크린샷 2라운드(1차 겹침 발견 → 원인 수정 → 2차 재확인 겹침 없음, 라벨 6개 전부 판독 가능)
|
||||||
|
- 리뷰카드(소형) 레이더 find 로 6개 확인 — 라벨 미부착 유지, 레이아웃 무변경 회귀 없음
|
||||||
|
- JSP 런타임 스모크: 매 수정 후 docker compose restart app → curl 200 + grep 대조
|
||||||
|
|
||||||
|
## graph_refresh (최종)
|
||||||
|
partial-stale (2차 판정, a6417dd 기준) — game-detail.jsp buildHexRadar 시그니처 변경.
|
||||||
|
재생성 시도: fork 로 위임했으나 graphify SKILL Step 3B(서브에이전트 필수 병렬 디스패치) 요구와
|
||||||
|
fork 하드룰(Agent 재귀 호출 금지)이 정면 충돌 — 248k 토큰 소모 후 무산.
|
||||||
|
사용자 확인 후 이번 세션은 재생성 보류, open_items 로 이관.
|
||||||
|
|
||||||
|
## open_items
|
||||||
|
- graphify full scope partial-stale 상태 유지 (source_commit 은 여전히 20789a2, HEAD 는 a6417dd).
|
||||||
|
다음 구조적 변경(신규 함수/모듈/스키마 등) 배치 시 orchestrator 메인 스레드에서 직접
|
||||||
|
/graphify 실행(서브에이전트 다수 병렬 Agent 호출) 하거나 Workflow 툴(사용자 명시 opt-in)로
|
||||||
|
처리 — fork 위임은 Agent 재귀 금지 룰 때문에 불가함을 확인(교훈).
|
||||||
|
|
||||||
|
## Retrospective
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Retrospective:
|
||||||
|
signals:
|
||||||
|
positive:
|
||||||
|
- quote_or_paraphrase: "눈금선 추가 방식이 좋다"
|
||||||
|
about: 미터바 시각전략 AskUserQuestion(4안, 추천 표시 + preview mockup) 1라운드 즉시 선택
|
||||||
|
- quote_or_paraphrase: "축 이름 + 점수 둘 다 표시해줘"
|
||||||
|
about: 레이더 라벨 전략 AskUserQuestion 1라운드 즉시 선택 (한글 인코딩 재질문 이후)
|
||||||
|
- quote_or_paraphrase: (암묵) 재호출 없이 완료 승인
|
||||||
|
about: JSP 즉시반영 known-pitfall 을 curl 대조 → stale 확인 → docker restart → 재대조로 스스로 재현·검증
|
||||||
|
negative:
|
||||||
|
- quote_or_paraphrase: "한글이 죄다깨져서 뭐라하는지 모르겟어 다시 질문해봐"
|
||||||
|
about: AskUserQuestion 호출 시 한글 텍스트를 \u 유니코드 이스케이프로 수동 작성하다 오타로 2회 깨짐
|
||||||
|
structural: true
|
||||||
|
- quote_or_paraphrase: "미터바도 좋은데 내가말한건 육각 그래프였어"
|
||||||
|
about: 최초 요청의 "각 그래프"(복수)를 미터바 단수로 좁혀 해석 → AskUserQuestion 옵션도 좁은 스코프로만 구성
|
||||||
|
structural: true
|
||||||
|
- quote_or_paraphrase: (사용자 확인 후 보류 승인, 그 이전 248k 토큰 무산)
|
||||||
|
about: fork 에이전트에게 graphify 서브에이전트 병렬 디스패치(Agent 재귀 필요) 위임 시도 — fork 하드룰과 충돌
|
||||||
|
structural: true
|
||||||
|
what_went_well:
|
||||||
|
- AskUserQuestion 옵션 설계(추천 표시 + preview mockup)가 시각전략 결정에서 1라운드 즉시 수락으로 이어짐 (미터바·레이더 양쪽 모두)
|
||||||
|
- JSP 즉시반영 안 되는 known-pitfall 을 false pass 없이 curl 대조 → stale 확인 → docker restart → 재대조 순서로 자가 재현·검증
|
||||||
|
- 영향 파일 1개·결정축 단일 이산 스코프를 정확히 판정해 advisor 전량 skip 후 orchestrator 직접 처리 — 오버엔지니어링 없이 마이크로 스코프 유지
|
||||||
|
what_to_improve:
|
||||||
|
- AskUserQuestion 등 도구 호출에 한글/비ASCII 텍스트를 넣을 때 \u 유니코드 이스케이프를 손으로 타이핑해 2회 깨짐 — 리터럴 UTF-8 문자로 작성해야 함
|
||||||
|
- 이미지+텍스트 요청에서 "각 그래프"(복수)라는 표현을 단수(미터바)로 좁혀 해석 후 AskUserQuestion 옵션도 그 좁은 스코프로만 구성 — 최초 스코프 확인 단계에서 대상 전체 목록을 먼저 명시해야 했음
|
||||||
|
- fork 서브에이전트에게 Agent 재귀가 필요한 작업(graphify 서브에이전트 병렬 디스패치)을 위임 시도 → fork 하드룰과 충돌해 248k 토큰 소모 후 무산. 위임 전 대상 작업의 툴 제약(Agent 재귀 요구 여부)을 먼저 확인해야 함
|
||||||
|
memory_candidates:
|
||||||
|
- name: askuserquestion-non-ascii-literal-not-escape
|
||||||
|
type: feedback
|
||||||
|
description: AskUserQuestion 등 도구 호출에 한글/비ASCII 텍스트를 넣을 때 \u 유니코드 이스케이프 수동 타이핑 금지 — 리터럴 UTF-8 문자로 작성
|
||||||
|
body_draft: |
|
||||||
|
Why: AskUserQuestion 호출 파라미터에 한글을 \u 이스케이프 시퀀스로 손으로 타이핑하면
|
||||||
|
오타(자릿수 누락/오기) 로 렌더링이 깨져 사용자가 옵션을 읽을 수 없게 된다. 같은 세션에서
|
||||||
|
2회 발생 — "한글이 죄다깨져서 뭐라하는지 모르겟어" 로 재질문 유발, 왕복 비용 발생.
|
||||||
|
How to apply: 도구 호출 파라미터에 비ASCII 텍스트가 필요하면 항상 리터럴 UTF-8 문자로
|
||||||
|
직접 작성한다. \u 이스케이프를 수동으로 조립하지 않는다 (인코딩은 도구/런타임이 처리할
|
||||||
|
영역이지 수기 작성 대상이 아님).
|
||||||
|
rationale_for_saving: 재현 가능한 도구 사용 패턴 결함 — 코드나 git log 로 유도 불가, 관찰로만 드러남. 프로젝트 특정이 아니라 도구 사용 습관이라 프로젝트 문서(agent-output-conventions.md, AskUserQuestion 제시문 규약 문서)와 사용자 전역 설정 양쪽에 적용 가치.
|
||||||
|
signal_source: negative
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
|
||||||
|
memory_optional: true
|
||||||
|
- name: fork-agent-recursion-precheck-before-delegation
|
||||||
|
type: feedback
|
||||||
|
description: fork 서브에이전트에게 위임 전, 대상 작업이 Agent 툴(서브에이전트 재귀 디스패치) 을 요구하는지 먼저 확인 — fork 는 Agent 재귀 금지 하드룰 적용
|
||||||
|
body_draft: |
|
||||||
|
Why: graphify 전체 파이프라인(Step 3B: 서브에이전트 필수 병렬 디스패치) 을 fork 에이전트에게
|
||||||
|
위임했으나, fork 는 Agent 재귀 호출이 금지된 하드룰 적용 대상이라 정면 충돌 — 248k 토큰을
|
||||||
|
소모한 뒤에야 무산이 확인됨. 위임 전 확인했다면 즉시 회피 가능했던 낭비.
|
||||||
|
How to apply: 어떤 작업(스킬/파이프라인)을 fork 서브에이전트에게 위임하기 전에, 그 작업이
|
||||||
|
내부적으로 Agent 툴 호출(서브에이전트 추가 디스패치)을 필수로 요구하는지 먼저 확인한다.
|
||||||
|
요구한다면 fork 위임이 불가하므로 orchestrator 메인 스레드에서 직접 실행하거나 사용자
|
||||||
|
명시 opt-in 경로(예: Workflow 툴)로 전환한다.
|
||||||
|
rationale_for_saving: 재발 가능(구조적) — fork 의 툴 제약은 프로토콜 레벨 규약이라 매 위임 시점에 재확인해야 함. 유사 위임 판단이 반복될 것으로 예상.
|
||||||
|
signal_source: negative
|
||||||
|
docs_sync_target: null
|
||||||
|
memory_optional: true
|
||||||
|
conflicts_with: null
|
||||||
|
- name: plural-target-phrase-scope-confirm-before-narrowing
|
||||||
|
type: feedback
|
||||||
|
description: 이미지+텍스트 사용자 요청에서 "각 그래프" 등 복수 대상 표현이 있으면 임의로 단수 스코프로 좁히지 말고, 확인 질문에 대상 전체 목록을 먼저 명시
|
||||||
|
body_draft: |
|
||||||
|
Why: 사용자 최초 요청("리뷰 종합 항목 그래프(막대 미터 + 육각 레이더)에서 각 그래프 끝이...")
|
||||||
|
에 두 그래프 유형이 병기돼 있었는데도, orchestrator 가 "각 그래프"를 미터바 단수로
|
||||||
|
좁혀 해석하고 AskUserQuestion 옵션도 그 좁은 스코프로만 구성함. 미터바 수정 완료 후
|
||||||
|
"미터바도 좋은데 내가말한건 육각 그래프였어" 로 정정 발생 — 작업을 두 배로 나눠 처리하게 됨.
|
||||||
|
How to apply: 사용자 요청(특히 이미지 첨부)에 복수형 대상 표현("각 ~", "그래프들", "여기저기")
|
||||||
|
이 있으면, 대상이 하나로 좁혀지는지 확신이 없는 한 확인 질문(AskUserQuestion 등)에 발견된
|
||||||
|
대상 전체 목록을 먼저 명시하고 스코프를 선택하게 한다. 임의로 대표 사례 하나만 골라
|
||||||
|
진행하지 않는다.
|
||||||
|
rationale_for_saving: 재발 가능 — 이미지+텍스트 혼합 요청에서 복수 대상 지시어는 반복될 패턴. 사용자 원 발화 재검토로만 드러나는 교훈이라 코드/커밋으로 유도 불가.
|
||||||
|
signal_source: negative
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
|
||||||
|
memory_optional: true
|
||||||
|
- name: askuserquestion-recommended-preview-mockup-effective
|
||||||
|
type: feedback
|
||||||
|
description: 시각전략 등 이산 선택 AskUserQuestion 에서 "추천 표시 + preview mockup" 병기 옵션 설계가 1라운드 즉시 수락으로 이어짐
|
||||||
|
body_draft: |
|
||||||
|
Why: 미터바·레이더 시각전략 두 차례 모두 AskUserQuestion 에 추천 옵션 표시 + 각 옵션의
|
||||||
|
시각 결과 preview(mockup) 를 병기하자 사용자가 재질문 없이 1라운드에 선택. 비자명한
|
||||||
|
선택이었으나(4안 중 택1, 시각 결과를 텍스트만으론 가늠하기 어려움) 검증된 패턴.
|
||||||
|
How to apply: UI/시각 관련 이산 선택을 사용자에게 물을 때는 옵션 설명 텍스트만으로
|
||||||
|
끝내지 않고, 가능하면 추천 표시 + 결과 미리보기(mockup/스케치) 를 함께 제시한다.
|
||||||
|
rationale_for_saving: 비자명한 판단(일반적으로 텍스트 설명만으로 충분하다 여길 수 있는 상황에서 preview 를 추가 투자)이 검증된 성공 사례 — 재현 가치 있음.
|
||||||
|
signal_source: positive
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
|
||||||
|
memory_optional: true
|
||||||
|
protocol_feedback:
|
||||||
|
- fork 에게 위임 가능한 작업의 범위를 판별할 때, "대상 스킬/파이프라인이 Agent 툴(서브에이전트 재귀 디스패치)을 필수로 요구하는가" 를 위임 전 체크리스트 항목으로 명문화할 것을 제안 (예: platform-adapters.md 또는 fork 위임 경로 문서에 "위임 전 툴 제약 프리체크" 항목 추가). 이번 세션은 사후 확인(248k 토큰 소모 후)이라 손실이 컸음.
|
||||||
|
- AskUserQuestion 등 도구 호출 파라미터에 비ASCII 텍스트를 다뤄야 하는 경우의 작성 규칙(리터럴 UTF-8, \u escape 수동 조립 금지)을 agent-output-conventions.md 류 문서에 명문화할 것을 제안 — 현재는 "제시문 풀어쓰기" 규칙만 있고 인코딩 안전성 규칙은 부재.
|
||||||
|
applied_changes: []
|
||||||
|
```
|
||||||
|
|
||||||
|
## memory_candidates 처리
|
||||||
|
docs 반영 완료(같은 커밋): docs/development/agent-output-conventions.md 에 규칙 2(비ASCII 리터럴 작성, \u 수동 이스케이프 금지), 규칙 3(복수 대상 지적 시 스코프 임의 축소 금지) 추가.
|
||||||
|
protocol_feedback(fork 위임 전 Agent 재귀 요구 여부 프리체크 명문화) 는 atp 번들 직접 수정 권한 밖 — open_items 에 이미 기록된 교훈으로 충분, 별도 반영처 없음.
|
||||||
|
memory(전역) 갱신: 보류 — 사용자 memory 활성 설정 미확인, docs 단독 마감.
|
||||||
|
|
||||||
|
ended_at: 2026-07-01T11:26:20+09:00
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
---
|
||||||
|
schema_version: 2
|
||||||
|
sid: 20260701-113509
|
||||||
|
started_at: 2026-07-01T11:35:09+09:00
|
||||||
|
ended_at: 2026-07-01T12:05:00+09:00
|
||||||
|
user_request: >
|
||||||
|
게임상세 리뷰 영역 재구성: (1) 상단 종합점수는 육각 그래프만으로 충분 —
|
||||||
|
옆에 있는 6축 미터바 범례 제거. (2) 미터바는 개별 리뷰 카드의 타이틀
|
||||||
|
노출부(닉네임/배지/시간 행 부근)로 이동. 여러 샘플을 먼저 만들어
|
||||||
|
보여주고 그중 하나를 사용자가 선택하게 할 것 (코드 반영은 아직 아님 —
|
||||||
|
1단계는 샘플 제시 + 선택 수렴).
|
||||||
|
resumed_from:
|
||||||
|
Invocations: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Advisor Invocation Decision Log
|
||||||
|
- advisor: requirements-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '요청이 명확함(대상 영역·의도·산출 형태 모두 사용자가 스크린샷+문장으로 명시). 오픈 퀘스천 없음.'
|
||||||
|
checked_at: 2026-07-01T11:35:09+09:00
|
||||||
|
- advisor: research-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: 'game-detail.jsp 내 buildHexRadar/buildRadarLegend/renderSummary/buildReviewItem 위치를 orchestrator가 직접 grep+Read로 확인 완료. graphify 미도입 프로젝트(no-graphify) — lookup 생략.'
|
||||||
|
checked_at: 2026-07-01T11:35:09+09:00
|
||||||
|
- advisor: design-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '1단계 산출물이 확정 설계가 아니라 사용자 선택을 위한 시각 샘플(Artifact)이므로 파일영향맵/계약을 먼저 고정하는 design-advisor 단계가 부적합. frontend-design 스킬 가이드로 orchestrator가 직접 샘플 HTML 구성 — 사용자가 명시적으로 /frontend-design:frontend-design 을 인자로 지정.'
|
||||||
|
checked_at: 2026-07-01T11:35:09+09:00
|
||||||
|
- advisor: implementation-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '1단계는 실제 소스(game-detail.jsp) 변경 없음 — Artifact 샘플 제시만. 사용자가 안을 고르면 후속 세션/턴에서 implementation-advisor 재판단.'
|
||||||
|
checked_at: 2026-07-01T11:35:09+09:00
|
||||||
|
- advisor: verification-advisor
|
||||||
|
decision: skip
|
||||||
|
rationale: '코드 변경 0건(샘플은 scratchpad HTML, 소스 트리 밖) — L1/L2 검증 대상 없음.'
|
||||||
|
checked_at: 2026-07-01T11:35:09+09:00
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
game-detail.jsp 의 리뷰 요약 패널(`buildHexRadar`+`buildRadarLegend`, L2318-2332)과 리뷰 카드(`buildReviewItem`, L2203-2301, 카드 미터바는 현재 `sr-only`)를 확인. 요청 반영 방향(요약=그래프only, 카드=미터바 노출)을 검증하기 위한 시각 샘플 HTML을 Artifact 로 발행:
|
||||||
|
https://claude.ai/code/artifact/fa684bfc-8c46-442c-adca-a8c83032eb85
|
||||||
|
- Section 1: 요약 패널 현재(그래프+범례) vs 제안(그래프만) 비교
|
||||||
|
- Section 2: 카드 제목행 미터바 배치 4안 — A 인라인 축약형 / B 2열 그리드형 / C 타이틀 내부 통합형 / D 토글 상세형
|
||||||
|
프로젝트 실제 다크테마 토큰(--surface/--accent 등, game-detail.jsp L31-54)을 그대로 이식해 실물감 있게 재현.
|
||||||
|
|
||||||
|
**2단계(구현, 사용자 확정 후)**: 사용자가 "C. 타이틀 내부 통합형" + "요약 그래프-only 확정"을 선택. game-detail.jsp 실제 반영 완료(커밋 0c8da40):
|
||||||
|
- `renderSummary()`: 요약 라디오 뒤 `buildRadarLegend` 호출 제거, `.game-reviews__summary-panel` 에 `justify-content: center` 추가
|
||||||
|
- `buildReviewItem()`: 컴팩트 육각 아이콘(`cardRadar`/`radarWrap`) 블록 삭제, 신설 `buildTitleMeter(r.axes)` 를 닉네임/배지 뒤·시간 앞에 삽입 — axes 데이터 없으면 `null` 반환해 아무것도 렌더하지 않음
|
||||||
|
- `buildRadarLegend` 함수 및 관련 CSS(`.game-reviews__axis-legend*`, `.game-reviews__axis-meter*`, `.game-reviews__card-radar*`, `game-meter-grow` 키프레임) 완전 삭제 — 호출부가 모두 사라져 죽은 코드였음
|
||||||
|
- 신규 CSS `.game-reviews__title-meter`(+ `.stick`) 및 `game-title-meter-grow` 키프레임(세로 성장 애니메이션, `prefers-reduced-motion` 대응) 추가
|
||||||
|
- 접근성: `buildTitleMeter` 컨테이너에 `role="img"` + `aria-label="6축 평가: ..."` 로 시각+스크린리더 동시 충족(§6.6, 기존 sr-only 표 대체)
|
||||||
|
|
||||||
|
# Decisions
|
||||||
|
- 코드 변경 없이 먼저 샘플 제시 → 사용자 선택 수렴 (요청 원문 "여러 샘플 구성해서 볼 수 있게" 를 그대로 1단계 산출물 정의로 채택)
|
||||||
|
- 상단 요약은 단일안(그래프만)만 제시 — 축 이름·점수가 이미 그래프 라벨로 표시되어(직전 커밋 a6417dd) 대안 여지가 낮다고 판단, 대신 before/after 비교로 근거 명시
|
||||||
|
- 카드 배치는 4안 제시 — "타이틀 노출부" 라는 표현의 해석 폭이 넓어(제목행 자체 vs 제목행 바로 아래) 대표 패턴을 넓게 커버
|
||||||
|
|
||||||
|
# Invocations
|
||||||
|
(orchestrator 직접 수행 — advisor 호출 없음)
|
||||||
|
|
||||||
|
# verified_by_me
|
||||||
|
- L1: skip — 이번 변경은 Java/매퍼/컨트롤러 미변경(순수 JSP 뷰 + 인라인 JS/CSS), 프로젝트에 JS 단위테스트 하네스 없음(verification-strategies.md 범주표에 해당 항목 없음)
|
||||||
|
- 대체 검증: `node --check`(JSP EL `${...}` 치환 후) 로 `<script>` 블록 구문 오류 없음 확인
|
||||||
|
- 수동 런타임 스모크: `docker compose restart app` 후 `http://localhost:8080/game/3`(리뷰 6건 보유 더미 게임) 실브라우저 렌더 확인 — 요약 패널 그래프 단독 중앙 배치, 리뷰 카드 6건 모두 제목행에 6축 미니 미터바 노출, 콘솔 에러 0건(read_console_messages)
|
||||||
|
- L2: 해당없음(외부 서비스/DB 계약 변경 없음)
|
||||||
|
|
||||||
|
# open_items
|
||||||
|
(없음) — 소스 변경 커밋 완료(0c8da40), 잔여 미커밋 변경은 본 report.md 자체(work-session 산출물)뿐이며 별도 chore 커밋 예정
|
||||||
|
|
||||||
|
# needs_user_verification
|
||||||
|
(없음) — 라이트/다크 테마 모두 `/game/3` 실브라우저로 대비·레이아웃 확인 완료
|
||||||
|
|
||||||
|
# graph_refresh
|
||||||
|
skip: no-graphify
|
||||||
|
|
||||||
|
# retrospective
|
||||||
|
skip: 1개 파일(game-detail.jsp) 범위의 유이(有二) 결정(샘플 4안 중 1택 + 요약 그래프-only)로 끝난 소규모 세션 — 마이크로 편집 예외 경로(advisor 전체 스킵)를 그대로 이어 retrospective-advisor 호출도 생략. 유효 발견 없으면 docs 반영 대상 없음.
|
||||||
|
|
||||||
|
user_signals:
|
||||||
|
positive:
|
||||||
|
- "AskUserQuestion 1회로 C안 + 요약 그래프-only 확정 즉시 수락 — 재작업 요청 없음"
|
||||||
|
negative: []
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
---
|
||||||
|
schema_version: 2
|
||||||
|
sid: 20260701-142000
|
||||||
|
started_at: 2026-07-01T14:20:00+09:00
|
||||||
|
ended_at: 2026-07-01T14:45:00+09:00
|
||||||
|
branch: feat/v2
|
||||||
|
user_request: |
|
||||||
|
/frontend-design:frontend-design http://localhost:8080/game/new 게임 신규 등록 구간도 리디자인
|
||||||
|
대상자였어야 했는데 누락되었어. 다음에 누락 안되게끔 문서에 추가해두고 여기 디자인 들어가자.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
`/game/new`(game-register.jsp)가 2026-06-30 9단위 리디자인 목록에서 빠져 있던 것을 발견·보강.
|
||||||
|
실제 로그인 스샷 대조 결과 레이아웃/토큰은 이미 sibling(recruit-form.jsp 등)과 일치했음 —
|
||||||
|
필수(*) 표시 2건 + 미리보기 eyebrow 라벨만 recruit-form.jsp 패턴 재사용으로 추가.
|
||||||
|
재발방지로 `frontend-redesign-coverage-checklist.md` 신설 + `workflow-patterns.md` 등재.
|
||||||
|
graph-refresh-checker partial-stale 판정 → full scope 증분 재생성 완료.
|
||||||
|
|
||||||
|
# Invocations
|
||||||
|
|
||||||
|
- [research 없음 — orchestrator 직접 grep/Read] docs/changes, docs/analysis 확인해 9단위 리디자인 대상 목록과 game-register.jsp 부재 확인
|
||||||
|
- [orchestrator 직접] claude-in-chrome 으로 로그인 후 `/game/new`, `/recruit/new`, `/` 실사용 스샷 대조
|
||||||
|
- [AskUserQuestion ×2] 리디자인 범위(A vs B) → B 선택 → 전제 오류 발견 후 재확인 → A안으로 확정
|
||||||
|
- [orchestrator 직접] game-register.jsp 마크업/CSS 2건 추가 (.game-req, .game-preview__label)
|
||||||
|
- [atp-graphify:graph-refresh-checker] 커밋 86b0528 이후 staleness 판정 → partial-stale
|
||||||
|
- [general-purpose (graphify semantic extraction)] 11개 변경 파일 재추출
|
||||||
|
|
||||||
|
# Decisions
|
||||||
|
|
||||||
|
- 로컬 CSS 토큰(`--surface/--accent/--text` 등)은 bibimbap.css(`--color-*`)와 네이밍이 달라
|
||||||
|
대체 불가함을 확인 → 이 파일만 단독 전환하면 sibling 관례와 갈라지므로 범위 제외, 별도 과제로 이월.
|
||||||
|
- 필수(*) 표시·미리보기 라벨은 `recruit-form.jsp`의 `.rf-req`/`.rf-preview__label` 패턴을
|
||||||
|
클래스명만 바꿔 재사용 — 새 디자인 언어 도입 없음.
|
||||||
|
- WebGL zip 필수(*) 표시는 `editMode`가 아닐 때만 노출(기존 required 로직과 일치).
|
||||||
|
|
||||||
|
# user_signals
|
||||||
|
|
||||||
|
positive:
|
||||||
|
- "A안만 진행 (권장)" 선택 — 두 라운드 모두 재질의 없이 1라운드 수렴.
|
||||||
|
negative:
|
||||||
|
- structural: true
|
||||||
|
signal: "(명시적 지적 발화는 없었으나) orchestrator가 B안 프레이밍 시 bibimbap.css 변수 네이밍을
|
||||||
|
실제로 grep 확인하지 않고 '공유 CSS로 전환 가능'을 전제로 옵션을 제시 → 사용자가 B안을 고른 뒤
|
||||||
|
실제 구현 단계에서 grep 검증 결과 전제가 틀렸음을 발견, 재질의로 정정해야 했음. 재발방지: 옵션을
|
||||||
|
제시하기 전에 grep/Read로 전제 사실을 먼저 확인한다."
|
||||||
|
|
||||||
|
# verified_by_me
|
||||||
|
|
||||||
|
- L1: `docker compose exec app mvn -o test` — 362/362 PASS
|
||||||
|
- L1: `rg '<%@ taglib'` game-register.jsp — 0건(stray taglib 없음)
|
||||||
|
- L2: 로그인(admin@bibimbap.local) 후 `/game/new` 라이트/다크 육안 확인 — 필수(*) 2건, 미리보기 라벨 렌더 확인
|
||||||
|
- known pitfall 회피: JSP 저장 즉시반영 stale 렌더링 함정 — `docker compose restart app` 후 재대조
|
||||||
|
|
||||||
|
# needs_user_verification
|
||||||
|
|
||||||
|
- WebGL zip 실제 업로드 → 등록 제출 전체 플로우 회귀 확인 (마크업/CSS만 변경이라 로직 영향 없음으로 판단하되 실제 제출 미실행)
|
||||||
|
|
||||||
|
# 추가 검증 (사용자 요청, 세션 종료 후속)
|
||||||
|
|
||||||
|
`editMode=true` 경로 확인 완료. admin(user_id=9) 소유 게임이 dev 시드에 없어(0개) DB에 임시 row를 직접 INSERT(`is_visible=false`) → `/game/8/edit` 방문 육안 확인 → 즉시 DELETE로 원복(잔존 0건 확인). 결과: "게임 이름 *" 유지, "WebGL zip"은 `*` 미표시 + "기존 WebGL 유지" 문구 — 코드 로직과 일치.
|
||||||
|
|
||||||
|
# graph_refresh
|
||||||
|
|
||||||
|
partial-stale → full scope 증분 재생성 완료(1477 노드 / 4503 엣지 / 76 커뮤니티, 고스트 중복 486 exact+251 fuzzy 추가 제거). `docs/graph/index.md` 갱신 커밋 `4d0c2ca`.
|
||||||
|
|
||||||
|
# open_items
|
||||||
|
|
||||||
|
(없음 — git status clean)
|
||||||
|
|
||||||
|
# Retrospective
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
Retrospective:
|
||||||
|
signals:
|
||||||
|
positive:
|
||||||
|
- quote_or_paraphrase: "A안만 진행 (권장)"
|
||||||
|
about: AskUserQuestion 두 라운드 모두 재질의 없이 1라운드 수렴 — 재확인 이후 재구성된 옵션 제시가 자기완결적이었음
|
||||||
|
negative:
|
||||||
|
- quote_or_paraphrase: "(명시적 발화 없음 — orchestrator 자가 포착) B안 프레이밍 시 bibimbap.css 변수 네이밍 전제를 grep 없이 '공유 CSS로 전환 가능'으로 제시했다가, 구현 단계 grep 검증에서 전제가 틀렸음을 발견해 재확인 라운드가 필요했음"
|
||||||
|
about: "AskUserQuestion 옵션(B안) 구성 시 핵심 전제(CSS 변수 네이밍 대체 가능성)를 사실 확인 없이 서술"
|
||||||
|
structural: true
|
||||||
|
what_went_well:
|
||||||
|
- "리디자인 대상 목록과 실제 파일 전수를 대조해 game-register.jsp 누락을 찾아낸 뒤, 재발방지용 커버리지 체크리스트(frontend-redesign-coverage-checklist.md)를 신설하고 workflow-patterns.md에 즉시 등재 — 이번 세션 안에서 교훈이 문서화까지 닫힘."
|
||||||
|
- "재확인 후 A안으로 좁힌 다음에는 옵션이 자기완결적이어서 1라운드 수렴(재질의 0) — agent-output-conventions.md 규칙 1(제시문 풀어쓰기)이 실제로 작동한 사례."
|
||||||
|
- "recruit-form.jsp 패턴을 클래스명만 바꿔 재사용해 새 디자인 언어를 도입하지 않은 보수적 선택 — sibling 관례 유지 우선."
|
||||||
|
what_to_improve:
|
||||||
|
- "AskUserQuestion 옵션(특히 '기존 인프라/토큰으로 대체 가능' 류 기술적 전제를 포함하는 옵션)을 구성하기 전에, 그 전제를 뒷받침하는 사실을 grep/Read로 먼저 확인한다. 이번 세션은 전제가 틀린 옵션(B안)을 사용자가 고른 뒤에야 구현 단계에서 오류가 드러나 재확인 왕복이 발생했다 — 결과적으로 A안으로 정정되었으나, 발견이 늦었던 것은 순전히 운(구현 착수 전 grep을 돌린 시점)이었다. 옵션에 전제가 실려 있다면 제시 전 검증이 필수다."
|
||||||
|
memory_candidates:
|
||||||
|
- name: askuserquestion-premise-verify-before-present
|
||||||
|
type: feedback
|
||||||
|
description: "AskUserQuestion 옵션에 기술적 전제(예: 기존 리소스로 대체 가능)가 실려 있으면, 옵션을 제시하기 전에 grep/Read로 그 전제를 먼저 확인한다."
|
||||||
|
body_draft: |
|
||||||
|
Why: AskUserQuestion 옵션 문구가 "A는 이렇게, B는 공유 리소스로 전환 가능"처럼 사실 주장을 포함할 때,
|
||||||
|
그 주장을 검증 없이 제시하면 사용자는 검증된 사실로 오인하고 선택한다. 사용자가 그 옵션을 고른 뒤
|
||||||
|
구현 착수 시점에야 grep 등으로 전제 오류가 발견되면, 이미 사용자 의사결정이 한 번 소비된 뒤라
|
||||||
|
재확인 왕복(선택 무효화 → 재질의 → 재선택)이 발생한다. 결정 제시문의 신뢰도는 문구의 매끄러움이
|
||||||
|
아니라 그 안의 사실 주장의 검증 여부로 결정된다.
|
||||||
|
|
||||||
|
How to apply:
|
||||||
|
1. 옵션 문구를 작성하기 전, 그 문구가 의존하는 사실 주장(공유 가능성, 네이밍 일치, 기존 구현 존재 등)을 나열한다.
|
||||||
|
2. 나열된 주장 각각을 grep/Read로 실제 확인한다 — "~일 것 같다"로 제시하지 않는다.
|
||||||
|
3. 확인이 안 되거나 시간이 없으면, 옵션 문구에서 그 주장을 빼거나 "미확인, 구현 착수 시 재검증 필요"로 명시한다.
|
||||||
|
4. 이미 틀린 전제로 옵션을 제시해 선택까지 받은 경우, 구현 착수 전 첫 검증 단계에서 걸러지도록
|
||||||
|
옵션 확정 직후 "선택 근거가 된 전제 재검증"을 착수 first-step으로 둔다(이번 세션처럼 늦게라도
|
||||||
|
구현 착수 전에 걸러지면 피해가 제한된다).
|
||||||
|
rationale_for_saving: >
|
||||||
|
agent-output-conventions.md 규칙 1(제시문 풀어쓰기)은 "어떻게 보여주는가"를 다루지만, 이번 사례는
|
||||||
|
"보여주기 전에 사실을 확인했는가"라는 선행 단계의 결함이라 규칙 1과는 구별되는 별개 규칙이다.
|
||||||
|
재현 가능(다른 프로젝트/CSS 리팩터링·API 계약 대체 가능성 등 "전환 가능" 류 전제를 포함하는
|
||||||
|
모든 AskUserQuestion 상황에 적용됨)하고 기존 문서에 없는 관찰이라 후보로 남긴다.
|
||||||
|
signal_source: negative
|
||||||
|
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
|
||||||
|
memory_optional: true
|
||||||
|
protocol_feedback:
|
||||||
|
- "agent-output-conventions.md 규칙 1(제시문 풀어쓰기)은 옵션의 '표현 형식'만 규율한다. 이번 사례처럼 옵션에 실린 '사실 주장'의 사전 검증 의무는 별도 규칙(예: 규칙 4)으로 명문화할 필요가 있다 — 제시문이 아무리 풀어써졌어도(규칙 1 충족) 그 안의 사실이 틀렸으면 재질의 왕복은 똑같이 발생한다. 두 결함은 독립적이므로 문서에서도 별도 항목으로 분리하는 게 재발방지에 유리하다."
|
||||||
|
applied_changes: []
|
||||||
|
```
|
||||||
|
|
@ -42,3 +42,6 @@ certs/*.pem
|
||||||
|
|
||||||
### Test static resources ###
|
### Test static resources ###
|
||||||
src/main/resources/static/
|
src/main/resources/static/
|
||||||
|
|
||||||
|
# graphify 산출 본체(재생성 가능) — 메타는 docs/graph/index.md 만 커밋
|
||||||
|
graphify-out/
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
- 화면은 `/WEB-INF/views/*.jsp`를 사용한다.
|
- 화면은 `/WEB-INF/views/*.jsp`를 사용한다.
|
||||||
- DB 접근은 annotation 기반 MyBatis mapper를 사용한다.
|
- DB 접근은 annotation 기반 MyBatis mapper를 사용한다.
|
||||||
- 업로드된 프로필 이미지는 `/profile/**`, 게임 WebGL asset은 `/game/{gameUuid}/**`로 제공된다.
|
- 업로드된 프로필 이미지는 `/profile/**`, 게임 WebGL asset은 `/game/{gameUuid}/**`로 제공된다.
|
||||||
|
- 정적 CSS/JS 파일은 `src/main/webapp/css/`, `src/main/webapp/js/`에 두면 Tomcat DefaultServlet이 직접 서빙한다. `src/main/resources/static/`은 WAR에서 불필요하다.
|
||||||
|
|
||||||
## 작업 원칙
|
## 작업 원칙
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
- 문서-only 분석 요청에서는 `src/`와 `pom.xml`을 수정하지 않는다.
|
- 문서-only 분석 요청에서는 `src/`와 `pom.xml`을 수정하지 않는다.
|
||||||
- 보안 발견 사항은 `docs/analysis/`에 `file:line` 근거와 함께 기록한다.
|
- 보안 발견 사항은 `docs/analysis/`에 `file:line` 근거와 함께 기록한다.
|
||||||
- 보안 개선 작업은 `docs/security/security-remediation-checklist.md`의 완료 조건을 기준으로 분리한다.
|
- 보안 개선 작업은 `docs/security/security-remediation-checklist.md`의 완료 조건을 기준으로 분리한다.
|
||||||
|
- 방법 선택 시 빠름보다 확실성을 우선한다. 검증되지 않은 지름길은 피한다.
|
||||||
|
|
||||||
## 보안 원칙
|
## 보안 원칙
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
-- bibimbap dev seed — 리뷰/댓글(W3-2) 기능 테스트용 더미 상황
|
-- bibimbap dev seed — 리뷰/댓글(W3-2) 기능 테스트용 더미 상황
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
-- 목적: 새로 추가된 게임 리뷰/댓글 기능을 실제로 테스트할 수 있는 더미 데이터.
|
-- 목적: 새로 추가된 게임 리뷰/댓글 기능을 실제로 테스트할 수 있는 더미 데이터.
|
||||||
-- 1) 로그인 가능한 테스트 계정 1개 (직접 로그인 → 리뷰/댓글 작성 테스트용)
|
-- 1) 로그인 가능한 테스트 계정 2개
|
||||||
-- 2) 표시되는 더미 게임 1개 (테스트 계정 소유)
|
-- - tester@bibimbap.local (role=USER — 일반 사용자 기능 테스트용)
|
||||||
|
-- - admin@bibimbap.local (role=ADMIN — 관리자 기능 테스트용)
|
||||||
|
-- 2) 표시되는 더미 게임 1개 (tester 계정 소유)
|
||||||
-- 3) 이미 등록된 더미 리뷰 5개 (각자 다른 더미 유저 — game당 user 1리뷰 제약 때문)
|
-- 3) 이미 등록된 더미 리뷰 5개 (각자 다른 더미 유저 — game당 user 1리뷰 제약 때문)
|
||||||
-- + 보너스: 더미 댓글 3개
|
-- + 보너스: 더미 댓글 3개
|
||||||
--
|
--
|
||||||
|
|
@ -14,7 +16,9 @@
|
||||||
-- 적용 (호스트 psql, 포트 5433):
|
-- 적용 (호스트 psql, 포트 5433):
|
||||||
-- PGPASSWORD=change_me_local_dev psql -h localhost -p 5433 -U bibimbap -d bibimbap -f db/seed-dev.sql
|
-- PGPASSWORD=change_me_local_dev psql -h localhost -p 5433 -U bibimbap -d bibimbap -f db/seed-dev.sql
|
||||||
--
|
--
|
||||||
-- 로그인 자격: tester@bibimbap.local / test1234! (provider='email')
|
-- 로그인 자격:
|
||||||
|
-- tester@bibimbap.local / test1234! (role=USER, provider='email')
|
||||||
|
-- admin@bibimbap.local / test1234! (role=ADMIN, provider='email')
|
||||||
-- password_hash 는 UserController.hashPassword 와 동일 알고리즘으로 사전 생성:
|
-- password_hash 는 UserController.hashPassword 와 동일 알고리즘으로 사전 생성:
|
||||||
-- PBKDF2WithHmacSHA256 / 210000 iter / 256-bit key / 16-byte salt,
|
-- PBKDF2WithHmacSHA256 / 210000 iter / 256-bit key / 16-byte salt,
|
||||||
-- 포맷 = pbkdf2_sha256$<iter>$<base64 salt>$<base64 hash>.
|
-- 포맷 = pbkdf2_sha256$<iter>$<base64 salt>$<base64 hash>.
|
||||||
|
|
@ -27,14 +31,16 @@ SET search_path TO dev;
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_tester_id bigint;
|
v_tester_id bigint;
|
||||||
|
v_admin_id bigint;
|
||||||
v_game_id bigint;
|
v_game_id bigint;
|
||||||
v_uid bigint;
|
v_uid bigint;
|
||||||
|
v_review_id bigint;
|
||||||
-- test1234! 의 사전 생성 해시 (위 알고리즘과 동일).
|
-- test1234! 의 사전 생성 해시 (위 알고리즘과 동일).
|
||||||
c_pw_hash text := 'pbkdf2_sha256$210000$QJ5MV+dx3aECELuuh3ibIA==$/59CO6JWXXGXWW0hzqpnZwu89qirDozP0jlq3JzEwZw=';
|
c_pw_hash text := 'pbkdf2_sha256$210000$QJ5MV+dx3aECELuuh3ibIA==$/59CO6JWXXGXWW0hzqpnZwu89qirDozP0jlq3JzEwZw=';
|
||||||
r RECORD;
|
r RECORD;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- -------------------------------------------------------------------------
|
-- -------------------------------------------------------------------------
|
||||||
-- 1) 로그인 테스트 계정 (users + email identity)
|
-- 1a) 일반 사용자 테스트 계정 (role=USER)
|
||||||
-- -------------------------------------------------------------------------
|
-- -------------------------------------------------------------------------
|
||||||
SELECT id INTO v_tester_id
|
SELECT id INTO v_tester_id
|
||||||
FROM users
|
FROM users
|
||||||
|
|
@ -58,6 +64,35 @@ BEGIN
|
||||||
(v_tester_id, 'email', 'tester@bibimbap.local', 'tester@bibimbap.local', c_pw_hash, '테스터');
|
(v_tester_id, 'email', 'tester@bibimbap.local', 'tester@bibimbap.local', c_pw_hash, '테스터');
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------------
|
||||||
|
-- 1b) 관리자 테스트 계정 (role=ADMIN)
|
||||||
|
-- -------------------------------------------------------------------------
|
||||||
|
SELECT id INTO v_admin_id
|
||||||
|
FROM users
|
||||||
|
WHERE canonical_email = 'admin@bibimbap.local' AND is_delete IS NOT TRUE
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_admin_id IS NULL THEN
|
||||||
|
INSERT INTO users (display_name, canonical_email, role, status)
|
||||||
|
VALUES ('어드민', 'admin@bibimbap.local', 'ADMIN', 'ACTIVE')
|
||||||
|
RETURNING id INTO v_admin_id;
|
||||||
|
ELSE
|
||||||
|
-- 기존 계정 role 이 ADMIN 이 아닌 경우 보정 (재실행 안전)
|
||||||
|
UPDATE users SET role = 'ADMIN', permissions_epoch = permissions_epoch + 1
|
||||||
|
WHERE id = v_admin_id AND role <> 'ADMIN';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM user_auth_identities
|
||||||
|
WHERE provider = 'email' AND provider_user_id = 'admin@bibimbap.local'
|
||||||
|
AND is_delete IS NOT TRUE
|
||||||
|
) THEN
|
||||||
|
INSERT INTO user_auth_identities
|
||||||
|
(user_id, provider, provider_user_id, email, password_hash, display_name)
|
||||||
|
VALUES
|
||||||
|
(v_admin_id, 'email', 'admin@bibimbap.local', 'admin@bibimbap.local', c_pw_hash, '어드민');
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- -------------------------------------------------------------------------
|
-- -------------------------------------------------------------------------
|
||||||
-- 2) 더미 게임 (테스트 계정 소유, 목록/상세에 표시되도록 visible)
|
-- 2) 더미 게임 (테스트 계정 소유, 목록/상세에 표시되도록 visible)
|
||||||
-- webgl_path / thumbnail_url 은 NULL — game-detail.jsp 가 빈 src 로 안전 처리.
|
-- webgl_path / thumbnail_url 은 NULL — game-detail.jsp 가 빈 src 로 안전 처리.
|
||||||
|
|
@ -85,12 +120,13 @@ BEGIN
|
||||||
-- -------------------------------------------------------------------------
|
-- -------------------------------------------------------------------------
|
||||||
FOR r IN
|
FOR r IN
|
||||||
SELECT * FROM (VALUES
|
SELECT * FROM (VALUES
|
||||||
('김플레이', 'reviewer1@bibimbap.local', 5, '그래픽이 깔끔하고 조작감이 좋아요. 가볍게 즐기기 딱 좋습니다. 추천!', 5),
|
-- nickname, email, rating, body, days_ago, immersion, creativity, controls, completeness, sound, visual (6축 평균 반올림 = rating)
|
||||||
('이도전', 'reviewer2@bibimbap.local', 4, '아이디어가 신선했습니다. 난이도 밸런스만 조금 더 다듬으면 완벽할 듯해요.', 4),
|
('김플레이', 'reviewer1@bibimbap.local', 5, '그래픽이 깔끔하고 조작감이 좋아요. 가볍게 즐기기 딱 좋습니다. 추천!', 5, 5, 4, 5, 5, 4, 5),
|
||||||
('박캐주얼', 'reviewer3@bibimbap.local', 3, '무난하게 즐길 만한 게임. 다만 후반부가 살짝 반복적으로 느껴졌어요.', 3),
|
('이도전', 'reviewer2@bibimbap.local', 4, '아이디어가 신선했습니다. 난이도 밸런스만 조금 더 다듬으면 완벽할 듯해요.', 4, 4, 5, 3, 4, 4, 4),
|
||||||
('최열정', 'reviewer4@bibimbap.local', 5, '시간 가는 줄 모르고 플레이했네요. BGM이 특히 인상적이었습니다!', 2),
|
('박캐주얼', 'reviewer3@bibimbap.local', 3, '무난하게 즐길 만한 게임. 다만 후반부가 살짝 반복적으로 느껴졌어요.', 3, 3, 3, 2, 3, 4, 3),
|
||||||
('정라이트', 'reviewer5@bibimbap.local', 2, '초반 튜토리얼이 불친절해서 적응이 조금 어려웠어요. 보완되면 좋겠습니다.', 1)
|
('최열정', 'reviewer4@bibimbap.local', 5, '시간 가는 줄 모르고 플레이했네요. BGM이 특히 인상적이었습니다!', 2, 5, 4, 4, 5, 5, 5),
|
||||||
) AS t(nickname, email, rating, body, days_ago)
|
('정라이트', 'reviewer5@bibimbap.local', 2, '초반 튜토리얼이 불친절해서 적응이 조금 어려웠어요. 보완되면 좋겠습니다.', 1, 2, 3, 1, 2, 3, 2)
|
||||||
|
) AS t(nickname, email, rating, body, days_ago, immersion, creativity, controls, completeness, sound, visual)
|
||||||
LOOP
|
LOOP
|
||||||
SELECT id INTO v_uid
|
SELECT id INTO v_uid
|
||||||
FROM users
|
FROM users
|
||||||
|
|
@ -103,16 +139,30 @@ BEGIN
|
||||||
RETURNING id INTO v_uid;
|
RETURNING id INTO v_uid;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF NOT EXISTS (
|
SELECT id INTO v_review_id
|
||||||
SELECT 1 FROM game_reviews
|
FROM game_reviews
|
||||||
WHERE game_id = v_game_id AND user_id = v_uid AND is_delete IS NOT TRUE
|
WHERE game_id = v_game_id AND user_id = v_uid AND is_delete IS NOT TRUE
|
||||||
) THEN
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_review_id IS NULL THEN
|
||||||
INSERT INTO game_reviews (game_id, user_id, rating, body, created_at, updated_at)
|
INSERT INTO game_reviews (game_id, user_id, rating, body, created_at, updated_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
v_game_id, v_uid, r.rating, r.body,
|
v_game_id, v_uid, r.rating, r.body,
|
||||||
now() - make_interval(days => r.days_ago),
|
now() - make_interval(days => r.days_ago),
|
||||||
now() - make_interval(days => r.days_ago)
|
now() - make_interval(days => r.days_ago)
|
||||||
);
|
)
|
||||||
|
RETURNING id INTO v_review_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 6축 리뷰 스펙(GameReviewController.AXIS_KEYS) 백필: 구버전 seed(축 없음) 재실행 시에도 채움.
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM game_review_axes WHERE review_id = v_review_id) THEN
|
||||||
|
INSERT INTO game_review_axes (review_id, axis_key, score) VALUES
|
||||||
|
(v_review_id, 'immersion', r.immersion),
|
||||||
|
(v_review_id, 'creativity', r.creativity),
|
||||||
|
(v_review_id, 'controls', r.controls),
|
||||||
|
(v_review_id, 'completeness', r.completeness),
|
||||||
|
(v_review_id, 'sound', r.sound),
|
||||||
|
(v_review_id, 'visual', r.visual);
|
||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
|
|
@ -146,7 +196,7 @@ BEGIN
|
||||||
END IF;
|
END IF;
|
||||||
END LOOP;
|
END LOOP;
|
||||||
|
|
||||||
RAISE NOTICE 'seed-dev 완료: tester_id=%, game_id=%', v_tester_id, v_game_id;
|
RAISE NOTICE 'seed-dev 완료: tester_id=%, admin_id=%, game_id=%', v_tester_id, v_admin_id, v_game_id;
|
||||||
END $$;
|
END $$;
|
||||||
|
|
||||||
-- 적용 결과 확인용 요약 (psql 실행 시 출력).
|
-- 적용 결과 확인용 요약 (psql 실행 시 출력).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
---
|
||||||
|
kind: analysis
|
||||||
|
title: 프론트엔드 컴포넌트 중복/유지보수·확장성 검토 (2026-06-30)
|
||||||
|
description: JSP 기반 프론트엔드에서 발견된 CSS 토큰 23개 파일 중복, JS 유틸리티 함수 중복, BibimbapModal 호출 패턴 혼존, recruit-form CSRF 폴백 갭(보안), 날짜 포맷 함수 편재를 분석하고 권고 구현 순서를 기록한다.
|
||||||
|
perspective: neutral
|
||||||
|
valid_starting_point_for: 프론트엔드 중복 제거 작업 착수, CSRF 갭 수정(보안 P0), CSS/JS 공용 파일 설계
|
||||||
|
owner: art
|
||||||
|
stability: snapshot
|
||||||
|
last_reviewed: 2026-06-30
|
||||||
|
source: work-session 20260630-113258 (research-advisor frontend-analysis.md, design-advisor frontend-design-recommendations.md) 에서 distill
|
||||||
|
superseded_note: 후속 결정으로 전제가 바뀌면 이 줄을 갱신한다 — 현재 없음
|
||||||
|
---
|
||||||
|
|
||||||
|
# 프론트엔드 컴포넌트 중복/유지보수·확장성 검토 (2026-06-30)
|
||||||
|
|
||||||
|
> 조사 범위: 27개 JSP 파일 전수 직접 읽기. 모든 사실은 코드 직접 확인(`확인됨`). 미탐색 항목은 [미발견/조사 불충분](#미발견--조사-불충분-항목)에 명시.
|
||||||
|
>
|
||||||
|
> 출처: work-session `20260630-113258` — `research/frontend-analysis.md` (research-advisor) + `artifacts/frontend-design-recommendations.md` (design-advisor).
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
JSP 기반 프론트엔드에서 5개 유형의 중복/이슈가 확인됐다. 가장 심각한 것은 라이트/다크 테마 CSS 변수 블록과 `body` 기본 스타일이 23개 파일에 동일하게 복사된 구조이며, 보안 측면에서는 `recruit-form.jsp`의 CSRF 폴백 분기에서 토큰이 전혀 포함되지 않는 갭이 존재한다.
|
||||||
|
|
||||||
|
전체 27개 JSP 중 26개에 인라인 `<style>` 블록이 존재하며, 누적 인라인 CSS는 5,969줄이다.
|
||||||
|
|
||||||
|
## 발견 이슈 요약 (우선순위 매트릭스)
|
||||||
|
|
||||||
|
| 이슈 | 영향도 | 구현 비용 | 우선순위 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| CSS 토큰 23개 파일 중복 | 높음 (유지보수 23배 비용) | 낮음 (파일 추가 + include 삽입) | P0 |
|
||||||
|
| 폼 제출 CSRF 갭 (recruit-form) | 높음 (보안) | 낮음 (10줄 이내 수정) | P0 — 보안 |
|
||||||
|
| JS 공용 유틸리티 분리 | 중간 (동작 불일치 방지) | 중간 (파일 작성 + 기존 인라인 제거) | P1 |
|
||||||
|
| 모달 패턴 표준화 | 중간 (신규 페이지 기준 제공) | 중간 (패턴 A/B 대체 + admin 2곳 수정) | P1 |
|
||||||
|
| 날짜 포맷 유틸리티 모듈 | 낮음 (예방적 분리) | 낮음 (파일 추출) | P2 |
|
||||||
|
|
||||||
|
## 이슈별 상세
|
||||||
|
|
||||||
|
### CSS 디자인 토큰 23개 파일 중복 (P0)
|
||||||
|
|
||||||
|
#### 현황
|
||||||
|
|
||||||
|
전체 27개 JSP 중 26개에 인라인 `<style>` 블록이 존재한다 (`theme-init.jsp` 제외). 다음 3개 블록이 23개 파일에 동일하게 복사된다.
|
||||||
|
|
||||||
|
| 중복 패턴 | 반복 파일 수 | 대표 위치 |
|
||||||
|
|---|---|---|
|
||||||
|
| `html {}` 라이트 테마 CSS 변수 선언 (8개 색상 토큰) | 23개 | `admin-console.jsp:38`, `jam-list.jsp:21`, `login.jsp:12` |
|
||||||
|
| `html[data-theme="dark"] {}` 다크 테마 CSS 변수 선언 | 23개 | `admin-console.jsp:51`, `posts-list.jsp:41`, `recruit-list.jsp:34` |
|
||||||
|
| `body {}` 기본 리셋 + 폰트 + 배경 | 23개 | `admin-console.jsp:62`, `jam-list.jsp:41`, `terms.jsp:29` |
|
||||||
|
|
||||||
|
핵심 공통 토큰: `--surface`, `--card-bg`, `--text`, `--text-muted`, `--accent`, `--accent-soft`, `--border`, `--shadow` (라이트) / `#121212`, `#1e1e1e`, `#ece8e1`, `#a39e96`, `rgba(255,255,255,0.1)` (다크).
|
||||||
|
|
||||||
|
추가 중복: `.admin-btn` / `.admin-btn--primary` / `.admin-btn--danger` 컴포넌트 CSS 20줄 이상이 4개 admin 파일에 동일 복사됨 (`admin-console.jsp:171-194`, `admin-jam-list.jsp:167-190`, `admin-post-categories.jsp:100`, `admin-unity-feeds.jsp:108`).
|
||||||
|
|
||||||
|
`safe-area` padding 패턴(`padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right))`)은 25개 파일에 반복되나, `max-width` 값은 48/56/64/72/78rem으로 파일별 상이하다.
|
||||||
|
|
||||||
|
#### 충돌/갭
|
||||||
|
|
||||||
|
- CSS 변수 선언이 파일마다 포함하는 변수 수가 8~14개로 약간씩 달라, 특정 파일에서 일부 변수가 미선언된 상태일 수 있다.
|
||||||
|
- `jam-detail.jsp:128`과 `recruit-detail.jsp:116`의 `.detail-button--primary color` 값 불일치 (`#1a1a1a` vs `var(--button-text)`).
|
||||||
|
|
||||||
|
#### 권고 (design-advisor)
|
||||||
|
|
||||||
|
`src/main/webapp/css/global.css` 정적 파일을 신규 생성하여 공통 토큰, `body` 기본 스타일, `.admin-btn` 컴포넌트를 선언한다. 각 JSP `<head>`에 `<link rel="stylesheet" href="${pageContext.request.contextPath}/css/global.css">` 를 삽입하고 인라인 중복 블록을 제거한다.
|
||||||
|
|
||||||
|
**포함 대상**: 공통 8+4개 토큰, `body` 기본 스타일, `.admin-btn*` 컴포넌트.
|
||||||
|
|
||||||
|
**포함하지 않는 것**: 각 JSP 고유 레이아웃 클래스, `max-width` 값(파일마다 다름), `--danger` 변수(`admin-unity-feeds.jsp` 단독 사용), 페이지 전용 컴포넌트 CSS.
|
||||||
|
|
||||||
|
**배포 경로**: `src/main/webapp/` 하위 정적 파일은 Tomcat DefaultServlet이 직접 서빙한다. `src/main/resources/static/` 경로 불필요.
|
||||||
|
|
||||||
|
**마이그레이션 3단계**: Phase 1(파일 생성 + login.jsp 1개 검증) → Phase 2(admin 4개 파일) → Phase 3(나머지 19개 JSP 일괄).
|
||||||
|
|
||||||
|
**위험**: `game-detail.jsp`처럼 `--radius`, `--webgl-bg` 등 파일 전용 변수가 있는 경우 해당 변수는 인라인 유지. FOUC는 `theme-init.jsp`가 `<head>` 최상단에서 `data-theme` 속성을 먼저 설정하므로 낮음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### JS 유틸리티 함수 중복 (P1)
|
||||||
|
|
||||||
|
#### 현황
|
||||||
|
|
||||||
|
admin 4개 파일(`admin-console.jsp`, `admin-jam-list.jsp`, `admin-post-categories.jsp`, `admin-unity-feeds.jsp`)에 동일 패턴의 함수가 각각 선언된다.
|
||||||
|
|
||||||
|
| 함수 | 파일 | 영향도 |
|
||||||
|
|---|---|---|
|
||||||
|
| `notify(message)` | admin 4개 파일 각각 선언 | Medium |
|
||||||
|
| `post(url, params)` | 동일 4개 파일. 구현이 약간씩 다름 (params 처리, _csrf 추가 여부) | High |
|
||||||
|
| `handleResult(res)`, `handleError()` | 동일 4개 파일 동일 패턴 | Medium |
|
||||||
|
|
||||||
|
특히 `post()` 함수는 파일마다 구현이 미묘하게 달라 동작 불일치 위험이 내재한다.
|
||||||
|
|
||||||
|
#### 권고 (design-advisor)
|
||||||
|
|
||||||
|
`src/main/webapp/js/bibimbap-utils.js` 신규 생성. `window.BibimbapUtils` 네임스페이스에 `post(url, params, csrfToken)`, `handleResult(res, onError)`, `makeErrorHandler(onError)` 를 노출한다.
|
||||||
|
|
||||||
|
**시그니처 변경 이유**:
|
||||||
|
- `post(url, params, csrfToken)`: 기존 클로저 캡처 방식 대신 토큰을 명시 파라미터로 받는다.
|
||||||
|
- `handleResult(res, onError)`: 기존의 `notify()` 직접 호출 대신 오류 메시지 콜백으로 위임한다 (title이 파일마다 달라 전역화 불가).
|
||||||
|
- `makeErrorHandler(onError)`: 매직 문자열 하드코딩 제거.
|
||||||
|
|
||||||
|
**`notify()` / `openModal()` 통합 불가**: title이 파일마다 달라 단순 통합이 불가능하므로 권고 3(모달 표준화)에서 별도 처리.
|
||||||
|
|
||||||
|
**위험**: admin 파일마다 `post()` 구현이 미묘하게 달라 통합 시 동작 변경 가능. 통합 전 4개 파일 3-way diff 수행 권고.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BibimbapModal 호출 패턴 혼존 (P1)
|
||||||
|
|
||||||
|
#### 현황
|
||||||
|
|
||||||
|
`BibimbapModal` API는 `modal.jsp:174-238`에 단일 `<div id="site-modal">` 재사용 방식으로 구현되어 있으며, `alert`, `confirm`, `prompt` 메서드를 제공한다. 그러나 호출 패턴이 3가지로 혼존한다.
|
||||||
|
|
||||||
|
**패턴 A — `openModal()` 헬퍼 래퍼 (4개 파일)**
|
||||||
|
|
||||||
|
`login.jsp:256`, `signup.jsp:269`, `game-register.jsp:490`, `profile.jsp:539` 가 동일 시그니처의 `openModal(title, message, confirmText, onConfirm)` 로컬 함수를 각자 정의.
|
||||||
|
|
||||||
|
**패턴 B — `notify()` 헬퍼 래퍼 (4개 admin 파일)**
|
||||||
|
|
||||||
|
`admin-jam-list.jsp:389`, `admin-console.jsp:401`, `admin-post-categories.jsp:251`, `admin-unity-feeds.jsp:305` 가 `notify(message)` 로컬 함수를 각자 정의 (alert 전용, title 하드코딩).
|
||||||
|
|
||||||
|
**패턴 C — 인라인 직접 체크 (3개 파일)**
|
||||||
|
|
||||||
|
`recruit-form.jsp:411, 424`, `posts-form.jsp:252, 269`, `game-detail.jsp:1425, 1439, 1474, 1482, 1553, 1558` 가 호출 지점마다 인라인으로 체크.
|
||||||
|
|
||||||
|
**특이 케이스**: `game-detail.jsp:1553-1561`에 `notifyError()`, `confirmAction()` 헬퍼를 별도 정의하면서, 동일 파일 `1425-1449`에 인라인 체크도 병존하여 단일 파일 내 두 패턴이 혼재한다.
|
||||||
|
|
||||||
|
**`window.confirm` 직접 사용**: `admin-post-categories.jsp:323`과 `admin-unity-feeds.jsp:401`에서 삭제 확인 대화상자에 `window.confirm()`을 직접 사용. 알림에는 `notify()` 헬퍼(BibimbapModal 체크)를 쓰면서 삭제 확인에는 직접 사용하는 동일 파일 내 불일치.
|
||||||
|
|
||||||
|
#### 권고 (design-advisor)
|
||||||
|
|
||||||
|
**표준 패턴**: `window.BibimbapModal.alert / .confirm / .prompt` 직접 호출. 기존 헬퍼 래퍼는 신규 작성 금지.
|
||||||
|
|
||||||
|
- 패턴 A: login/signup/game-register/profile 4개 파일의 `openModal()` 제거 → 직접 호출로 대체.
|
||||||
|
- 패턴 B: admin 4개 파일의 `notify()` 제거 → 권고 2의 `BibimbapUtils.handleResult(res, onError)` 의 `onError` 콜백에서 직접 호출.
|
||||||
|
- 패턴 C: posts-form/recruit-form/game-detail 현재 형태 유지. `game-detail.jsp` 내 `notifyError()` / `confirmAction()` 파일 전용 헬퍼는 제거하지 않는다 (단일 파일 로컬 헬퍼 허용).
|
||||||
|
- `window.confirm` 2곳을 `BibimbapModal.confirm({...})` 으로 교체.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### recruit-form CSRF 폴백 갭 (P0 — 보안)
|
||||||
|
|
||||||
|
#### 현황
|
||||||
|
|
||||||
|
`posts-form.jsp`와 `recruit-form.jsp`는 fetch 기반 비동기 폼 제출, `checkValidity()` 검증, `BibimbapModal.alert` 처리, `BibimbapCsrf` CSRF 헤더 주입이라는 동일 골격을 공유하지만, CSRF 처리에 불일치가 존재한다.
|
||||||
|
|
||||||
|
- `posts-form.jsp:169` — `<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">` hidden input 존재 + JS 변수 이중 포함 (`posts-form.jsp:211, 229`).
|
||||||
|
- `recruit-form.jsp:386-394` — hidden CSRF input 없음. `BibimbapCsrf` 미존재 시 폴백 헤더에 CSRF 토큰이 전혀 포함되지 않는 보안 갭.
|
||||||
|
|
||||||
|
추가 불일치:
|
||||||
|
|
||||||
|
| 항목 | posts-form.jsp | recruit-form.jsp |
|
||||||
|
|---|---|---|
|
||||||
|
| 성공 시 폴백 | `} else { go(); }` (alert 없음) | `} else { alert('팀원 모집글이 등록되었습니다.'); go(); }` |
|
||||||
|
| 401 처리 | `redirectLogin` 함수 분리, `onConfirm`에서 호출 | 모달 `onConfirm`과 else 분기 양쪽에 인라인 중복 |
|
||||||
|
| 수정 모드 | `mode`, `postId` 로 등록/수정 URL 분기 있음 | 수정 모드 분기 없음 |
|
||||||
|
|
||||||
|
#### 권고 (design-advisor)
|
||||||
|
|
||||||
|
**즉시 수정 (방법 A 채택)**: `recruit-form.jsp`에 `<input type="hidden" name="_csrf" value="...">` hidden input 추가 + JS 폴백 분기에 `X-CSRF-Token: csrfToken` 헤더 삽입.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 수정안 요지
|
||||||
|
var csrfToken = (document.querySelector('input[name="_csrf"]') || {}).value || '';
|
||||||
|
// ... BibimbapCsrf 미존재 시 폴백 헤더에 'X-CSRF-Token': csrfToken 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
hidden input 추가 시 `new FormData(form)` 이 자동으로 `_csrf` 파라미터를 포함하므로 서버사이드 form 파라미터 검증도 함께 강화된다.
|
||||||
|
|
||||||
|
**방법 B (미채택)**: BibimbapCsrf를 항상 신뢰하여 폴백 분기 자체 제거 — BibimbapCsrf 로드 실패 시 더 큰 갭이 생기므로 채택하지 않는다.
|
||||||
|
|
||||||
|
**추가 수정**: 401 처리를 `redirectLogin` 함수로 분리, 성공 폴백 alert 제거.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 날짜 포맷 함수 편재 (P2)
|
||||||
|
|
||||||
|
#### 현황
|
||||||
|
|
||||||
|
날짜/시간 포맷 JavaScript 함수가 `game-detail.jsp` 단 하나의 파일에만 존재한다. 조사 범위 7개 파일(`posts-detail`, `recruit-detail`, `jam-detail`, `posts-list`, `jam-list`, `recruit-list`, `game-detail`) 중 나머지 6개에는 날짜 포맷 JS 함수가 전혀 없다.
|
||||||
|
|
||||||
|
| 함수 | 위치 | 구현 방식 |
|
||||||
|
|---|---|---|
|
||||||
|
| `fmtAbsolute(iso)` | `game-detail.jsp:1516-1519` | `new Date(iso).toLocaleString('ko-KR')` |
|
||||||
|
| `fmtRelative(iso)` | `game-detail.jsp:1521-1536` | 경과 시간 직접 산술 계산 (60/3600/86400초 분기) |
|
||||||
|
| `buildTimeEl(iso, edited)` | `game-detail.jsp:1538-1551` | 두 함수 합성, `<time>` 요소 + "(수정됨)" 뱃지 반환 |
|
||||||
|
|
||||||
|
사용 위치: `game-detail.jsp:1878` (댓글), `game-detail.jsp:2183-2186` (리뷰).
|
||||||
|
|
||||||
|
`Intl.RelativeTimeFormat` / `Intl.DateTimeFormat` 직접 사용은 조사 범위 7개 파일 전체에서 0 hit.
|
||||||
|
|
||||||
|
**구조적 위험**: 다른 detail 페이지가 동일 기능이 필요해지면 복사 중복 발생 구조.
|
||||||
|
|
||||||
|
#### 권고 (design-advisor)
|
||||||
|
|
||||||
|
`src/main/webapp/js/bibimbap-date.js` 신규 생성. `window.BibimbapDate` 네임스페이스에 3개 함수 노출. `game-detail.jsp`는 script 태그 추가 + 인라인 선언 제거 + `BibimbapDate.` 프리픽스 적용.
|
||||||
|
|
||||||
|
현재 다른 detail 페이지에서 날짜 포맷이 필요하지 않으므로 강제 적용하지 않는다. 필요 시 include.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 권고 구현 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. [P0-보안] recruit-form.jsp CSRF 갭 수정 — 단독 작업, 위험 낮음
|
||||||
|
2. [P0] global.css 작성 + login.jsp 1개 검증 — Phase 1
|
||||||
|
3. [P0] admin 4개 파일 global.css 적용 — Phase 2
|
||||||
|
4. [P0] 나머지 19개 JSP global.css 적용 — Phase 3
|
||||||
|
5. [P1] bibimbap-utils.js 작성 — admin 파일 수정 전제
|
||||||
|
6. [P1] admin 4개 파일 bibimbap-utils.js 연동 — 5 완료 후
|
||||||
|
7. [P1] window.confirm 2곳 BibimbapModal.confirm 전환 — 6과 동시 가능
|
||||||
|
8. [P1] openModal 패턴 4개 파일 표준화 — 7 완료 후
|
||||||
|
9. [P2] bibimbap-date.js 추출 + game-detail.jsp 연동 — 독립 작업
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증 기준(AC)**: 구현 완료 후 design-advisor 산출물의 AC-1~AC-11 항목을 기준으로 verification-advisor가 점검한다.
|
||||||
|
|
||||||
|
**롤백 경로**: 추가한 `<link>`, `<script>` 태그를 되돌리고 인라인 블록 복원. 서버사이드 Java 코드, MyBatis, API 계약에 영향 없음.
|
||||||
|
|
||||||
|
**캐시 버스팅**: 빌드 시스템 없음 → 쿼리 스트링 버전 파라미터(`?v=20260630`) 수동 관리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 미발견 / 조사 불충분 항목
|
||||||
|
|
||||||
|
- `theme-init.jsp` 내부 구현 미탐색 — JS 테마 초기화 방식과 CSS 변수 일원화 가능성 미확인
|
||||||
|
- `header.jsp`, `footer.jsp`에서 이미 CSS 변수를 선언하는지 여부 미탐색 — 이중 선언 여부 불명
|
||||||
|
- `modal.jsp`가 실제로 어느 레이아웃 include 경로를 통해 로드되는지 미탐색 — BibimbapModal 미존재 상황의 실제 발생 가능성 미확인
|
||||||
|
- `game-detail.jsp` 외 admin-* 파일들의 날짜 렌더링 패턴 미탐색
|
||||||
|
- `BibimbapCsrf` 실제 구현체(별도 JS 파일) 미탐색 — `headers()` 메서드 폴백 동작 미확인
|
||||||
|
- `posts-form.jsp`의 `post()` 함수와 admin 4개 파일의 `post()` 함수가 실제로 다른 구현인지 정밀 비교 미수행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
### 분석 대상 (주요)
|
||||||
|
|
||||||
|
| 파일 | 이슈 |
|
||||||
|
|---|---|
|
||||||
|
| `src/main/webapp/WEB-INF/views/recruit-form.jsp` | CSRF 폴백 갭 (P0-보안), 401 처리 불일치 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/posts-form.jsp` | CSRF 기준 파일 (변경 없음) |
|
||||||
|
| `src/main/webapp/WEB-INF/views/admin-console.jsp` | CSS 토큰 중복, JS 함수 중복, notify 패턴 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | CSS 토큰 중복, JS 함수 중복, notify 패턴 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | CSS 토큰 중복, JS 함수 중복, window.confirm 직접 사용 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp` | CSS 토큰 중복, JS 함수 중복, window.confirm 직접 사용 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/game-detail.jsp` | 날짜 함수 편재, 모달 패턴 혼재 |
|
||||||
|
| `src/main/webapp/WEB-INF/views/login.jsp` | openModal 패턴 A |
|
||||||
|
| `src/main/webapp/WEB-INF/views/signup.jsp` | openModal 패턴 A |
|
||||||
|
| `src/main/webapp/WEB-INF/views/game-register.jsp` | openModal 패턴 A |
|
||||||
|
| `src/main/webapp/WEB-INF/views/profile.jsp` | openModal 패턴 A |
|
||||||
|
|
||||||
|
### 신규 생성 예정 (권고안)
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|---|---|
|
||||||
|
| `src/main/webapp/css/global.css` | 디자인 토큰 + body 기본 스타일 + admin-btn 컴포넌트 |
|
||||||
|
| `src/main/webapp/js/bibimbap-utils.js` | post / handleResult / makeErrorHandler 공용 유틸리티 |
|
||||||
|
| `src/main/webapp/js/bibimbap-date.js` | fmtAbsolute / fmtRelative / buildTimeEl 날짜 포맷 유틸리티 |
|
||||||
|
|
||||||
|
### 관련 기존 분석
|
||||||
|
|
||||||
|
- [2026-06-16-project-analysis.md](./2026-06-16-project-analysis.md) — bibimbap 전면 코드 분석. D2 보안 섹션에서 CSRF 더블서밋 기초 확인. 이 문서의 recruit-form CSRF 갭은 해당 분석 이후 발견된 추가 이슈다.
|
||||||
|
|
@ -5,3 +5,4 @@
|
||||||
## 목록
|
## 목록
|
||||||
|
|
||||||
- [2026-06-16-project-analysis.md](./2026-06-16-project-analysis.md) — bibimbap 전면 코드 분석(D1 아키텍처/D2 보안/D3 품질·기술부채/D4 도메인). 보안 기초는 견고(SQLi 0·PBKDF2·CSRF 더블서밋)하나 프로토타입 잔재 dead code·미완성 기능·서비스 레이어 부재가 주요 부채. `perspective: neutral`, work-session 20260616-111711 distill.
|
- [2026-06-16-project-analysis.md](./2026-06-16-project-analysis.md) — bibimbap 전면 코드 분석(D1 아키텍처/D2 보안/D3 품질·기술부채/D4 도메인). 보안 기초는 견고(SQLi 0·PBKDF2·CSRF 더블서밋)하나 프로토타입 잔재 dead code·미완성 기능·서비스 레이어 부재가 주요 부채. `perspective: neutral`, work-session 20260616-111711 distill.
|
||||||
|
- [2026-06-30-frontend-component-review.md](./2026-06-30-frontend-component-review.md) — JSP 프론트엔드 컴포넌트 중복/유지보수·확장성 검토. CSS 토큰 23개 파일 중복(P0)·JS 유틸리티 함수 중복(P1)·BibimbapModal 호출 패턴 혼존(P1)·recruit-form CSRF 폴백 갭(P0-보안)·날짜 포맷 함수 편재(P2). work-session 20260630-113258 distill.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# 2026-06-30 리디자인 후속 — 토큰 통일·클래스 충돌 정리
|
||||||
|
|
||||||
|
**세션**: sid 20260630-170024
|
||||||
|
**브랜치**: feat/v2
|
||||||
|
**선행**: [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md) 검토에서 발견된 일관성 결함 수정
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
리디자인 비주얼 레이어 검토 결과 디자인 일관성 결함 발견 — 공유 `bibimbap.css` 가
|
||||||
|
앱 전역 브랜드 골드 `#e8a54b`(header/footer/modal/26파일) 와 다른 `#D4A853` 를
|
||||||
|
도입해 모든 페이지에 골드 2종이 공존했다. 부수적으로 클래스 합집합 override 모호성과
|
||||||
|
shadowing, 이중 CSS 로드가 있었다.
|
||||||
|
|
||||||
|
## 변경
|
||||||
|
|
||||||
|
| 항목 | 파일 | 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| accent 통일 | `css/bibimbap.css` | `--color-accent` `#D4A853→#e8a54b`(light+dark), `--color-accent-press` `#C79A40→#d8973a`. 앱 전역 브랜드 골드로 단일화. |
|
||||||
|
| focus-visible | `css/bibimbap.css` | `.btn`/`.chip`/`.link`/`a` 키보드 포커스 링 추가(마우스 클릭 시 미표시). |
|
||||||
|
| 클래스 충돌 제거 | `views/profile.jsp` | 페이지-로컬 `.profile-*` 를 source of truth 채택, 합집합으로 붙은 중복 bibimbap 유틸(`btn`/`avatar`/`thumb`/`meta`/`game-row`/`profile-head`/`card`/`section-*`) 제거 → override 모호성 소멸. |
|
||||||
|
| shadowing 해소 | `views/recruit-form.jsp` | bibimbap 와 충돌하던 로컬 클래스명(`field`/`form-grid`/`req`/`count`/`preview-card`/`form-actions`) `rf-` prefix rename. ID·JS·accent 무변경(이미 브랜드 골드 사용). |
|
||||||
|
| 이중 로드 제거 | `views/index.jsp` | 직접 `<link bibimbap.css>` 제거(theme-init 가 이미 전역 로드). |
|
||||||
|
|
||||||
|
인라인 핸들러(login/signup `oninput`·`onclick`)는 기능 정상·외부화 시 회귀위험 → **유지**(수용).
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
| 레벨 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| L1 — WAR 빌드 | `./mvnw -q -DskipTests package` exit 0 PASS. WAR 내 `css/bibimbap.css` 포함 확인. |
|
||||||
|
| L2 — 런타임 스모크 | dev 컨테이너 소스 bind-mount(`.:/build`)로 변경 즉시 라이브. `/` `/login` `/signup` `/terms` `/recruit` `/posts` `/error` HTTP 200 + JSP 컴파일 에러 0. 서빙 css accent `#e8a54b` 확인. |
|
||||||
|
| L2 — 인증 게이트 | `/recruit/new`·`/profile` HTTP 302(로그인 필요) → JSP 렌더 미확인. |
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
|
||||||
|
- `/recruit/new`(모집 폼): 로그인 세션에서 rf- rename 후 레이아웃·라이브 미리보기 정상 렌더.
|
||||||
|
- `/profile`: 로그인 세션에서 버튼/카드 외형(중복 클래스 제거 후) + 골드 정합.
|
||||||
|
- 다크모드 토글 시 전 페이지 골드 단일(`#e8a54b`) 육안 정합.
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 2026-06-30 스크린샷 가이드 — 인증 후 세부 화면 캡처 추가
|
||||||
|
|
||||||
|
**세션**: sid 20260630-141415
|
||||||
|
**브랜치**: feat/v2
|
||||||
|
**커밋**: `5dd639f`, `6e6d6b2`, `872f51c`
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
`docs/development/screenshot-guide.py`를 전체 재작성해 인증 흐름을 추가했다.
|
||||||
|
기존 비인증 정적 페이지 7장 캡처에서 인증 후 세부 화면 7장을 추가로 캡처할 수 있게 됐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 전후 비교
|
||||||
|
|
||||||
|
| 항목 | 변경 전 | 변경 후 |
|
||||||
|
|---|---|---|
|
||||||
|
| 캡처 장수 | 7장 (비인증 정적 페이지) | 최대 14장 (비인증 7 + 인증 후 7) |
|
||||||
|
| 로그인 흐름 | 없음 | AJAX fetch + `expect_navigation` |
|
||||||
|
| 실행 분기 | 단일 | `SCREENSHOT_EMAIL` / `SCREENSHOT_PASSWORD` env var 존재 여부 |
|
||||||
|
| 리뷰/댓글 composer | 캡처 불가 (`hidden` 상태) | 5초 대기 후 `removeAttribute('hidden')` 폴백 |
|
||||||
|
| 테스트 계정 | 미문서화 | `tester@bibimbap.local` / `test1234!` (seed-dev.sql user3) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 추가된 인증 후 캡처 목록
|
||||||
|
|
||||||
|
| 파일 | 화면 |
|
||||||
|
|---|---|
|
||||||
|
| `08-home-loggedin.png` | 홈 (로그인 후 — 내비게이션 변경) |
|
||||||
|
| `09-profile.png` | 프로필 페이지 |
|
||||||
|
| `10-posts-form.png` | 게시글 작성 폼 |
|
||||||
|
| `11-recruit-form.png` | 모집 작성 폼 |
|
||||||
|
| `12-game-detail-loggedin.png` | 게임 상세 (로그인 후 전체 뷰) |
|
||||||
|
| `13-game-review-form.png` | 리뷰 작성 폼 (노출 상태) |
|
||||||
|
| `14-game-comment-form.png` | 댓글 작성 폼 (노출 상태, 섹션 포커스) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 구현 상세
|
||||||
|
|
||||||
|
### 로그인 인증 흐름
|
||||||
|
|
||||||
|
Playwright AJAX fetch 방식으로 `/auth/login` 엔드포인트에 `application/x-www-form-urlencoded` POST 후 `expect_navigation`으로 리다이렉트 완료를 감지한다. 기존 `wait_for_navigation()`은 타이밍 경합이 있어 `expect_navigation` 컨텍스트 매니저로 교체했다.
|
||||||
|
|
||||||
|
### hidden 요소 강제 노출 전략
|
||||||
|
|
||||||
|
리뷰/댓글 composer는 서버 API 응답(로그인 상태, 기존 리뷰 여부) 이후 JS가 `hidden` 속성을 제거한다. 스크립트는 최대 5초 자연 노출을 대기하고, 실패하면 `page.evaluate("el => el.removeAttribute('hidden')")` 폴백으로 강제 노출해 캡처한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정된 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 유형 |
|
||||||
|
|---|---|
|
||||||
|
| `docs/development/screenshot-guide.py` | 전체 재작성 |
|
||||||
|
| `docs/development/screenshot-guide.md` | 업데이트 (테스트 계정, 전체 실행 명령 추가) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- [docs/development/screenshot-guide.md](../development/screenshot-guide.md) — 실행 방법 및 전체 캡처 목록
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# 2026-06-30 게임 카드 비율·모집 가시성 — 다축 평가 후 13건 시각 결함 수정
|
||||||
|
|
||||||
|
**세션**: sid 20260630-175023
|
||||||
|
**브랜치**: feat/v2
|
||||||
|
**선행**: [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md) / [2026-06-30-redesign-token-coherence-fix.md](./2026-06-30-redesign-token-coherence-fix.md) — 리디자인(cf1e7ce 등) 미해결 결함 2건의 재검·수정
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
직전 리디자인 세션이 마감했음에도 사용자가 지적한 시각 결함 2건이 남아 있었다.
|
||||||
|
|
||||||
|
- **이미지 #2 (메인 게임 허브)**: 게임 카드 썸네일 "비율" 문제.
|
||||||
|
- **이미지 #3 (팀원 모집 빈 상태)**: "가시성" 떨어지는 케이스.
|
||||||
|
|
||||||
|
"비율"을 카드 종횡비 단일 축으로 조기 협소화해 첫 `AskUserQuestion` 옵션을 설계했으나,
|
||||||
|
사용자가 "이미지 미리보기뿐 아니라 상단에 검색바도 혼자 짧다"로 **레이아웃 비율 축**을 직접 교정.
|
||||||
|
이를 받아 **multi-axis 멀티에이전트 평가**(관찰 1 → 레이아웃비율·카드종횡비·대비가시성·타이포간격
|
||||||
|
4축 병렬 → 종합 1, 총 6 에이전트)로 전환해 13건 결함 + 1 결정필요(카드 종횡비)로 복원했다.
|
||||||
|
사용자 결정: **카드 4:3** + **전체 13건** 적용.
|
||||||
|
|
||||||
|
## 변경 (13건)
|
||||||
|
|
||||||
|
| 파일 | 항목 | 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| `views/index.jsp` | #1 검색바 풀폭 | `.search-section` 풀폭 + `.search-stack` wrapper(검색행/상세검색을 우측 끝선 정렬, 픽셀 margin 대신 컬럼 wrapper로 버튼 텍스트 가변 대응) |
|
||||||
|
| `views/index.jsp` | 카드 그리드 | `.card-grid` `auto-fill` |
|
||||||
|
| `views/index.jsp` | #13 카드 종횡비 | `.card__media` `aspect-ratio` `4/5→4/3`, `.card__img` `object-position:center` |
|
||||||
|
| `css/bibimbap.css` | #2 고스트 버튼 대비 | `.btn-ghost` `color` `on-accent→text`(다크 `#3A2E15` on `#222018` ≈1.1:1 → `--color-text` 적용 ≈13:1) |
|
||||||
|
| `css/bibimbap.css` | 다크 보더 가시성 | 다크 `--color-border` `0.08→0.20` |
|
||||||
|
| `css/bibimbap.css` | #6·#7 정렬칩/라벨 | `.sort-label` 대비·위계 정리, 정렬칩 보더 |
|
||||||
|
| `css/bibimbap.css` | #11 빈상태 패딩 | `.empty-state` `padding` `clamp` |
|
||||||
|
| `css/bibimbap.css` | disabled 토큰화 | `.btn:disabled` 토큰화 |
|
||||||
|
| `views/recruit-list.jsp` | #10 히어로 폭 | `.recruit-hero__titles` `max-width` |
|
||||||
|
| `views/recruit-list.jsp` | #8 eyebrow 통일 | eyebrow mono 통일(공용 클래스 대신 로컬 시각스펙 일치 — index/bibimbap 토큰계열 분리 + font-mono 토큰 부재) |
|
||||||
|
| `views/recruit-list.jsp` | #9 빈상태 CTA 강등 | 빈상태 시 `.recruit-write` 조건부 ghost(골드 CTA 1개로 위계 정리) |
|
||||||
|
| `views/recruit-list.jsp` | 검색 폭 | 검색 `max-width` `48rem` |
|
||||||
|
|
||||||
|
조정 사항: #4 는 픽셀 margin 대신 `.search-stack` 컬럼 wrapper(버튼 텍스트 가변 대응 결정적),
|
||||||
|
#8 은 공용 클래스 대신 로컬 시각스펙 일치(토큰계열 분리 제약). `recruit-empty.jspf` 는 무편집.
|
||||||
|
|
||||||
|
## 검증
|
||||||
|
|
||||||
|
| 레벨 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| L1 — 정적 | `index.jsp` div 18/18·`recruit` 6/6 균형, scriptlet 균형, U+FFFD 0, stray taglib 0(적신호 없음) |
|
||||||
|
| L2 — 런타임 스모크 | bibimbap-app(`mvn spring-boot:run`) 재기동 → GET `/` + `/recruit` = 200 → 브라우저 육안 **라이트+다크 양 화면** |
|
||||||
|
| L2 — 결함 해소 | 이미지 #2(비율): 검색바 풀폭 정렬 + 카드 4:3(`object-position`) 해소. 이미지 #3(가시성): 필터 초기화 흰글자+보더 명확(zoom 확정) 해소. 부수 #6·#7·#8·#9·#10·#11·푸터 비잘림 육안 확인 |
|
||||||
|
|
||||||
|
`error.jsp` JSTL 로그(07:35Z JasperException)는 재기동(09:19Z) 이전의 stale 라인 — 현재 `/error` 200, 회귀 아님.
|
||||||
|
|
||||||
|
## 커밋
|
||||||
|
|
||||||
|
- `9489365` — 수정 13건
|
||||||
|
- `c688aa6` — 교훈 + 회고
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
|
||||||
|
- 로그인 상태 1회 스모크(본 검증은 로그아웃 상태만): (a) 메인 '신규 게임 개시' 버튼 노출 시 #4 `search-stack` 우측 끝선 정렬, (b) 모집글 ≥1건 시 #9 히어로 골드 CTA 유지.
|
||||||
|
- 실게임 가로 썸네일 업로드 시 4:3 cover 크롭 인상 최종 확인(더미는 폴백 로고만 노출).
|
||||||
|
|
||||||
|
## 교훈 연결
|
||||||
|
|
||||||
|
[development/verification-strategies.md](../development/verification-strategies.md) `### 모호한 시각 결함 어휘는
|
||||||
|
단일 결정축 협소화 전 다축 스캔 선행` 신규 레슨으로 등록(근거: 본 세션). 외부 ATP 번들 §4.4 item5
|
||||||
|
(가시성·표현·레이아웃 축 누락 주의)를 **선행 게이트**(옵션 설계 전 다축 스캔)로 강화 권고와 상호참조.
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# 2026-06-30 클로드 디자인 리디자인 — 비주얼 레이어 9단위 반영
|
||||||
|
|
||||||
|
**세션**: sid 20260630-160000
|
||||||
|
**브랜치**: feat/v2
|
||||||
|
**커밋**: `0390da6` ~ `360078a` (단위별 8개) + graph 메타 `8c86e8c`
|
||||||
|
|
||||||
|
## 요약
|
||||||
|
|
||||||
|
클로드 디자인 리디자인 산출물을 "비주얼 레이어" 방식으로 전 페이지에 반영했다.
|
||||||
|
파일 통째 교체 없이 마크업·클래스만 이식하고 모델명·CSRF·AJAX·include·자체 head는 기존값을 전부 보존했다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 범위
|
||||||
|
|
||||||
|
| 단위 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 1 — CSS 신설 | `src/main/webapp/css/bibimbap.css` 신설 (다크 오버라이드 포함). `WEB-INF/views/theme-init.jsp` 단일 link 태그로 23개 전 페이지 공통 적용. |
|
||||||
|
| 2 — 빈 상태 fragment | `fragments/posts-empty.jspf`, `fragments/recruit-empty.jspf` 신설. `posts-list.jsp`, `recruit-list.jsp` 에서 include. |
|
||||||
|
| 3 — 홈 | `index.jsp` — 히어로 섹션·정렬 칩·그리드 레이아웃 클래스 이식. |
|
||||||
|
| 4 — 인증 | `login.jsp` — pw-toggle·에러 슬롯. `signup.jsp` — 강도 막대·일치 표시. |
|
||||||
|
| 5 — 약관 | `terms.jsp` — sticky TOC 구조 이식. |
|
||||||
|
| 6 — 프로필 | `profile.jsp` — `.profile-head`·`.game-row` 클래스 이식. |
|
||||||
|
| 7 — 게임 상세 | `game-detail.jsp` — 이미 동일 토큰 구현 확인 → **no-op**. |
|
||||||
|
| 8 — 에러 | `errer.jsp` — empty-state 외형(SVG/아이콘) 이식. 기존 scriptlet `statusCode` 읽기 유지. |
|
||||||
|
| 9 — 모집 작성 | `recruit-form.jsp` — 필수 표시·글자수 카운터·sticky 미리보기 이식. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 하드 제약 준수
|
||||||
|
|
||||||
|
| 제약 | 처리 |
|
||||||
|
|---|---|
|
||||||
|
| 모델명 보존 | 디자인 EL(`${errorData}` 등) 미채택. 기존 `${game.title}`, `${ctx}` 등 유지. |
|
||||||
|
| CSRF 이중방어 | `_csrf` hidden + `BibimbapCsrf.headers` AJAX 헤더 — 기존 코드 무수정 보존. |
|
||||||
|
| include 경로 | `/WEB-INF/views/header.jsp` 등 기존 include 경로 전부 유지. |
|
||||||
|
| CDN 0 | 외부 CDN 참조 추가 없음. |
|
||||||
|
| 미존재 라우트 비노출 | `/games/{id}/visibility`, `/profile/edit`, `/password/reset` UI 요소 미채택. |
|
||||||
|
| game-detail 보존 | 이미 동일 토큰 구현 상태 확인 후 no-op 처리. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
| 레벨 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| L1 — WAR 빌드 | `./mvnw -q -DskipTests package` exit 0 PASS |
|
||||||
|
| L1 — CSS WAR 포함 | `target/.../css/bibimbap.css` 존재 확인 PASS |
|
||||||
|
| L2 — 스모크 | 로컬 dev 재기동 후 `/`, `/login`, `/signup`, `/terms`, `/posts`, `/recruit`, `/error` 전부 HTTP 200 |
|
||||||
|
| L2 — 결함 수정 | `errer.jsp` stray `javax` JSTL taglib → `/error` 500 → taglib 제거 → 200 (fix 커밋 `360078a`) |
|
||||||
|
| L2 — 마커 검증 | home css link+hero, login pw-toggle, signup pw-meter, terms toc, error empty-state 서빙 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## needs_user_verification (브라우저 세션 필요)
|
||||||
|
|
||||||
|
- 로그인 인터랙티브 제출: AJAX(`_csrf` + `BibimbapCsrf.headers`) 성공·리다이렉트. 테스트 계정 `admin@bibimbap.local`.
|
||||||
|
- 회원가입 제출 → modal → `/login` 리다이렉트.
|
||||||
|
- 프로필: 닉네임·아바타 변경 AJAX (로그인 세션 필요).
|
||||||
|
- 게임 상세: WebGL 플레이·6축 리뷰·댓글 CRUD (no-op이나 회귀 확인 권장).
|
||||||
|
- 모집 작성: `/recruit/new` 제출 성공 + 라이브 미리보기.
|
||||||
|
- 다크모드 토글 시 body ↔ header/footer 색 정합 육안 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- [docs/changes/2026-06-29-b2-b4-fe-hardening.md](./2026-06-29-b2-b4-fe-hardening.md) — FE 다크모드 대비·세션 하드닝 (선행 작업)
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# 2026-07-01 game-register.jsp 리디자인 누락분 보강
|
||||||
|
|
||||||
|
**세션**: `/atp:task` — `frontend-design` 스킬 경유
|
||||||
|
**브랜치**: feat/v2
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
|
||||||
|
`/game/new`(신규 게임 등록, `game-register.jsp`)가 2026-06-30 9단위 리디자인([2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md)) 목록에서 빠져 있었다는 사용자 지적으로 발견. 실사용 로그인 스샷으로 대조한 결과 카드 레이아웃·사이드 미리보기·색상 토큰(accent `#e8a54b` 등)은 이미 sibling 페이지(`recruit-form.jsp` 등)와 일치했다 — 즉 시각적으로 크게 뒤처진 상태는 아니었고, 누락은 주로 **추적 문서의 공백**이었다.
|
||||||
|
|
||||||
|
## 변경 내용
|
||||||
|
|
||||||
|
`game-register.jsp` 1파일, 마크업·CSS 추가만 (신규 파일 0):
|
||||||
|
|
||||||
|
- 필수 입력 표시(`*`) 추가 — 게임 이름(항상), WebGL zip(`editMode`가 아닐 때만, 수정 시엔 zip 선택이 선택사항이라는 기존 로직 반영)
|
||||||
|
- 미리보기 패널에 "미리보기" eyebrow 라벨 추가 (`aria-label`은 기존에도 있었으나 시각적 라벨이 없었음)
|
||||||
|
- 두 항목 모두 `recruit-form.jsp`의 `.rf-req` / `.rf-preview__label` 패턴을 클래스명만 `.game-req` / `.game-preview__label`로 바꿔 그대로 재사용 — 새 디자인 언어 도입 없음
|
||||||
|
|
||||||
|
**로컬 CSS 변수 정리는 범위 제외**: `game-register.jsp`가 쓰는 `--surface/--accent/--text` 등은 `bibimbap.css`(`--color-*` 네이밍)와 다른 변수라 대체가 안 되고, 이 로컬 토큰 관례는 `recruit-form.jsp`·`game-detail.jsp` 등 이미 리디자인된 sibling 페이지도 동일하게 갖고 있는 의도적 패턴([analysis/2026-06-30-frontend-component-review.md](../analysis/2026-06-30-frontend-component-review.md) P0 "CSS 토큰 23개 파일 중복"). 이 파일만 단독 전환하면 sibling과 관례가 갈라지므로, 전지역 통합은 별도 세션으로 이월.
|
||||||
|
|
||||||
|
## 재발방지
|
||||||
|
|
||||||
|
`docs/development/frontend-redesign-coverage-checklist.md` 신설 — 뷰 파일(fragment 제외 23개) 전수 대비 리디자인 반영 상태를 표로 추적. `docs/development/workflow-patterns.md`에 "'전 페이지 반영' 서술은 실제 파일 전수와 대조 필요" 패턴 등재.
|
||||||
|
|
||||||
|
## 검증 결과
|
||||||
|
|
||||||
|
| 레벨 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| L1 | `docker compose exec app mvn -o test` — 362/362 PASS |
|
||||||
|
| L1 | 정적 grep `<%@ taglib` — 0건(stray taglib 없음) |
|
||||||
|
| L2 | 로그인(`admin@bibimbap.local`) 후 `/game/new` 육안 확인 — 필수(*) 2건 + "미리보기" 라벨 렌더 확인, 라이트/다크 양 테마 |
|
||||||
|
| known pitfall | JSP 저장 즉시반영 불신 원칙 적용 — `docker compose restart app` 후 재대조로 stale 렌더링 배제 |
|
||||||
|
|
||||||
|
## needs_user_verification
|
||||||
|
|
||||||
|
- WebGL zip 실제 업로드 → 등록 제출 전체 플로우(파일 업로드 API 호출 포함) 회귀 확인 — 이번 변경은 마크업/CSS만이라 로직 영향 없음으로 판단하나 실제 제출은 미실행.
|
||||||
|
|
||||||
|
## 추가 검증 (edit-mode)
|
||||||
|
|
||||||
|
`editMode=true` 경로 확인 완료 — admin(`user_id=9`) 소유 임시 게임 row를 dev DB에 직접 삽입(`INSERT ... games`, `is_visible=false`)해 `/game/8/edit` 방문 후 육안 확인, 확인 후 즉시 `DELETE`로 원복(잔존 0건, `select count(*) from games where user_id=9` → 0). 결과: "게임 이름 *"는 유지(항상 필수), "WebGL zip"은 `*` 미표시(기존 WebGL 유지 문구와 함께 비필수로 정상 렌더) — 코드 로직(`<% if (!editMode) %>`)과 일치.
|
||||||
|
|
||||||
|
## 관련 문서
|
||||||
|
|
||||||
|
- [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md)
|
||||||
|
- [../development/frontend-redesign-coverage-checklist.md](../development/frontend-redesign-coverage-checklist.md)
|
||||||
|
- [../development/workflow-patterns.md](../development/workflow-patterns.md)
|
||||||
|
|
@ -10,3 +10,8 @@
|
||||||
- [2026-06-29-game-like-server-persistence.md](./2026-06-29-game-like-server-persistence.md) — 게임 "좋아요" 서버 영속화. 좋아요가 서버에 안 써지고 localStorage 로만 토글되던 버그 수정. 신규 `POST /game/{id}/like` 토글(@Transactional, CSRF→로그인→존재 게이트) + game_likes row/`games.like_count` 컬럼 단일 트랜잭션 ±1 동기 + 상세 JSP fetch 전환 + explore 무변경(컬럼 자동반영). L1 PASS(GameLikeControllerTest 7/7 + 회귀 219/219), L2 dev DB skip. UNIQUE 마이그레이션(20260629-game-likes-unique.sql) 운영 적용·실환경 스모크는 needs_user_verification — 절차: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md). 좋아요 항목으로 보안 B3 부분 충족.
|
- [2026-06-29-game-like-server-persistence.md](./2026-06-29-game-like-server-persistence.md) — 게임 "좋아요" 서버 영속화. 좋아요가 서버에 안 써지고 localStorage 로만 토글되던 버그 수정. 신규 `POST /game/{id}/like` 토글(@Transactional, CSRF→로그인→존재 게이트) + game_likes row/`games.like_count` 컬럼 단일 트랜잭션 ±1 동기 + 상세 JSP fetch 전환 + explore 무변경(컬럼 자동반영). L1 PASS(GameLikeControllerTest 7/7 + 회귀 219/219), L2 dev DB skip. UNIQUE 마이그레이션(20260629-game-likes-unique.sql) 운영 적용·실환경 스모크는 needs_user_verification — 절차: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md). 좋아요 항목으로 보안 B3 부분 충족.
|
||||||
- [2026-06-24-w2-jam-platform.md](./2026-06-24-w2-jam-platform.md) — W2 게임잼 워크스트림 전체(W2-1~6) 구현. 잼 엔티티/라이프사이클(jams/jam_teams/jam_team_members/jam_entries/jam_status_log) + 심사위원 역할(jam_judges 잼스코프) + 평가 동결 스키마(jam_criteria/jam_scores/jam_votes/jam_awards + jam_score_stats VIEW, 평가단위 (jam_id,game_id) 활성 자연키) + 심사 평가(3중게이트 UPSERT 가중집계) + 인기투표(1인1표 UNIQUE·종료후 공개) + 시상 집계(3트랙+가중 GRAND·CLOSED 확정 멱등). GAME_JAM_MANAGE 첫 enforcement 연결·잼스코프 isJudge·평가기간 게이트·CSRF 전수. 6 커밋 ccf1e42~a74bf74. 최종 L1 190/190 GREEN(회귀 0), L2 dev DB contract 전 PASS(격리 throwaway DB). dev DB 마이그레이션·L3 스모크 needs_user_verification.
|
- [2026-06-24-w2-jam-platform.md](./2026-06-24-w2-jam-platform.md) — W2 게임잼 워크스트림 전체(W2-1~6) 구현. 잼 엔티티/라이프사이클(jams/jam_teams/jam_team_members/jam_entries/jam_status_log) + 심사위원 역할(jam_judges 잼스코프) + 평가 동결 스키마(jam_criteria/jam_scores/jam_votes/jam_awards + jam_score_stats VIEW, 평가단위 (jam_id,game_id) 활성 자연키) + 심사 평가(3중게이트 UPSERT 가중집계) + 인기투표(1인1표 UNIQUE·종료후 공개) + 시상 집계(3트랙+가중 GRAND·CLOSED 확정 멱등). GAME_JAM_MANAGE 첫 enforcement 연결·잼스코프 isJudge·평가기간 게이트·CSRF 전수. 6 커밋 ccf1e42~a74bf74. 최종 L1 190/190 GREEN(회귀 0), L2 dev DB contract 전 PASS(격리 throwaway DB). dev DB 마이그레이션·L3 스모크 needs_user_verification.
|
||||||
- [2026-06-29-b2-b4-fe-hardening.md](./2026-06-29-b2-b4-fe-hardening.md) — B2 프로토타입 dead code 제거(abstracts 4파일·header.jspf·GameCatalog 삭제, 없는 게임 ID = HTTP 404) + B4 의존성/세션 하드닝(Spring Boot 3.5.16 GA·snapshot repo 제거·쿠키 HttpOnly/SameSite·로그 WARN·OWASP DC plugin 비bind·profile properties 신규) + FE 다크모드 대비·검색 접이식·터치타깃. mvn test 361 PASS.
|
- [2026-06-29-b2-b4-fe-hardening.md](./2026-06-29-b2-b4-fe-hardening.md) — B2 프로토타입 dead code 제거(abstracts 4파일·header.jspf·GameCatalog 삭제, 없는 게임 ID = HTTP 404) + B4 의존성/세션 하드닝(Spring Boot 3.5.16 GA·snapshot repo 제거·쿠키 HttpOnly/SameSite·로그 WARN·OWASP DC plugin 비bind·profile properties 신규) + FE 다크모드 대비·검색 접이식·터치타깃. mvn test 361 PASS.
|
||||||
|
- [2026-06-30-screenshot-guide-auth.md](./2026-06-30-screenshot-guide-auth.md) — 스크린샷 가이드 인증 흐름 추가. 비인증 7장 → 인증 포함 최대 14장. Playwright AJAX 로그인(expect_navigation) + SCREENSHOT_EMAIL/PASSWORD env var 분기 + 리뷰/댓글 composer hidden 강제 노출 폴백.
|
||||||
|
- [2026-06-30-redesign-token-coherence-fix.md](./2026-06-30-redesign-token-coherence-fix.md) — 리디자인 후속 일관성 수정. 공유 bibimbap.css accent 를 앱 전역 브랜드 골드로 통일(`#D4A853→#e8a54b`, light+dark) + focus-visible 링 + profile.jsp 중복 유틸 클래스 제거(override 모호성 소멸) + recruit-form.jsp 충돌 클래스 `rf-` rename(shadowing 해소) + index.jsp 이중 CSS 로드 제거. L1 빌드 PASS, L2 스모크 7페이지 200(JSP 에러 0). 인증 게이트 페이지(recruit-form/profile) 렌더는 needs_user_verification.
|
||||||
|
- [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md) — 클로드 디자인 리디자인 비주얼 레이어 9단위 반영. bibimbap.css 신설 + theme-init 공통 link + 빈상태 fragment 2종 + 홈/인증/약관/프로필/에러/모집 마크업·클래스 이식. game-detail no-op. 커밋 8개, L2 스모크 전 페이지 200(errer JSTL taglib 결함 포착·수정). 인터랙티브 제출·다크모드는 needs_user_verification.
|
||||||
|
- [2026-06-30-ui-multiaxis-ratio-visibility-fix.md](./2026-06-30-ui-multiaxis-ratio-visibility-fix.md) — 리디자인 미해결 시각 결함 2건(이미지#2 게임 카드 "비율" / 이미지#3 모집 빈상태 "가시성") 수정. "비율"을 카드 종횡비 단일 축으로 조기 협소화 → 사용자가 검색바 정렬(레이아웃 비율) 축 직접 교정 → multi-axis 멀티에이전트 평가(관찰1→레이아웃비율·카드종횡비·대비가시성·타이포간격 4축 병렬→종합1, 6 에이전트)로 13건 결함+카드 종횡비 결정 복원. 사용자 결정: 카드 4:3 + 전체 13건(index.jsp search-stack·card 4:3·object-position / bibimbap.css btn-ghost 대비·다크 보더·정렬칩·empty padding / recruit-list.jsp 히어로폭·eyebrow·빈상태 CTA 강등). L1 정적 + L2 런타임 스모크 라이트/다크 양화면 PASS. 커밋 9489365·c688aa6. 교훈: [verification-strategies.md](../development/verification-strategies.md) "모호한 시각 결함 어휘는 단일 결정축 협소화 전 다축 스캔 선행". 로그인 상태 스모크·실게임 가로 썸네일 4:3 크롭은 needs_user_verification.
|
||||||
|
- [2026-07-01-game-register-redesign-parity.md](./2026-07-01-game-register-redesign-parity.md) — `/game/new`(game-register.jsp)가 9단위 리디자인 목록에서 빠졌던 것을 사용자 지적으로 발견·보강. 실사용 스샷 대조 결과 레이아웃/토큰은 이미 sibling과 일치 — 필수(*) 표시 2건 + 미리보기 eyebrow 라벨만 `recruit-form.jsp` 패턴 재사용으로 추가(단일 JSP, 로직 무변경). 로컬 CSS 토큰 전지역 통합은 별도 과제로 이월. 재발방지로 `frontend-redesign-coverage-checklist.md` 신설 + workflow-patterns 등재. L1 362/362 PASS, L2 로그인 후 라이트/다크 육안 확인.
|
||||||
|
|
|
||||||
|
|
@ -36,3 +36,40 @@ source_session: 20260617-174635
|
||||||
W3 골자 합의 세션(`20260617-174635`)에서 orchestrator 가 W3-1 마무리 질문을 ID 약어(`C3`/`C6`/`(가)`)와 표로 과압축해 제시 → 사용자: "너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어". 배경+선택지+권장 풀어쓰기로 전환하니 즉시 매끄럽게 답변. 첫 제시부터 풀어쓰기를 기본값으로 삼았다면 1회 왕복 비용이 없었다.
|
W3 골자 합의 세션(`20260617-174635`)에서 orchestrator 가 W3-1 마무리 질문을 ID 약어(`C3`/`C6`/`(가)`)와 표로 과압축해 제시 → 사용자: "너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어". 배경+선택지+권장 풀어쓰기로 전환하니 즉시 매끄럽게 답변. 첫 제시부터 풀어쓰기를 기본값으로 삼았다면 1회 왕복 비용이 없었다.
|
||||||
|
|
||||||
> 관련(다른 레포): ATP 번들의 출력 스타일/압축 규약에 동 예외를 명문화하자는 protocol_feedback 가 세션 회고에 기록됨. 적용 *대상* 축은 다르지만 "압축 적용 경계" 교훈은 `caveman-bundle-compression-roi-ceiling`(번들 정적 압축 ROI 천장)과 같은 계열.
|
> 관련(다른 레포): ATP 번들의 출력 스타일/압축 규약에 동 예외를 명문화하자는 protocol_feedback 가 세션 회고에 기록됨. 적용 *대상* 축은 다르지만 "압축 적용 경계" 교훈은 `caveman-bundle-compression-roi-ceiling`(번들 정적 압축 ROI 천장)과 같은 계열.
|
||||||
|
|
||||||
|
## 규칙 2 — 도구 호출에 넣는 비ASCII(한글 등) 텍스트는 리터럴로 작성, `\u` 수동 이스케이프 금지
|
||||||
|
|
||||||
|
`AskUserQuestion` 등 JSON 파라미터에 한글을 넣을 때 `\uXXXX` 코드포인트를 손으로 타이핑하지 않는다. 코드포인트 하나라도 오타 나면 렌더링 시 깨진 문자로 나타나고, 에이전트 자신은 도구 호출 스키마상 오류를 감지하지 못한 채 그대로 사용자에게 전달된다. 사용자에게는 "질문 자체가 뭔지 모르겠다"로 나타나 재질의 왕복이 발생한다.
|
||||||
|
|
||||||
|
- 항상 실제 UTF-8 문자를 그대로 입력한다(복붙 아님, 정상 타이핑).
|
||||||
|
- 결과물이 의심되면 보내기 전에 스스로 읽어 자연스러운 한글인지 확인한다.
|
||||||
|
|
||||||
|
### 발원 사례
|
||||||
|
|
||||||
|
세션 `20260701-102212` — `AskUserQuestion` 옵션 라벨/설명에 한글을 `\u` 이스케이프로 수동 작성하다 코드포인트 오타 2회 발생. 사용자: "육각 마범롘 다이었 끝점(궁벀 불망 지겁)..." 같은 깨진 텍스트를 받고 "한글이 죄다깨져서 뭐라하는지 모르겟어 다시 질문해봐"로 지적. 리터럴 문자로 재작성하자 즉시 정상 렌더링.
|
||||||
|
|
||||||
|
> **재발 확인** — 세션 `20260701-142000`: 동일 실수가 다시 발생, 첫 `AskUserQuestion` 호출이 `InputValidationError`(params type mismatch)로 즉시 거부됐다(다행히 사용자에게 깨진 텍스트가 전달되기 전에 스키마 단계에서 걸림). 원인은 동일 — 질문/옵션 텍스트를 타이핑하는 과정에서 글자가 오염됨. **툴 스키마 오류로 걸리지 않는 경우도 있으므로**(오타가 유효한 JSON 문자열이면 스키마는 통과하고 사용자에게 그대로 전달됨 — 첫 발원 사례가 그 경우), 스키마 통과 여부에 의존하지 말고 발신 전 자체 검토를 실제로 수행한다.
|
||||||
|
|
||||||
|
## 규칙 4 — `AskUserQuestion` 옵션에 실린 기술적 전제는 제시 전에 grep/Read로 확인한다
|
||||||
|
|
||||||
|
옵션 문구가 "기존 리소스로 대체 가능", "이미 동일한 패턴", "네이밍이 일치" 같은 **사실 주장**을 포함할 때, 그 주장을 검증 없이 제시하면 사용자는 검증된 사실로 오인하고 선택한다. 사용자가 그 옵션을 고른 뒤 구현 착수 시점에야 grep 등으로 전제 오류가 발견되면, 이미 사용자 의사결정이 한 번 소비된 뒤라 재확인 왕복(선택 무효화 → 재질의 → 재선택)이 발생한다.
|
||||||
|
|
||||||
|
규칙 1(제시문 풀어쓰기)과는 독립된 결함이다 — 문구가 아무리 풀어써졌어도(규칙 1 충족) 그 안의 사실이 틀렸으면 재질의 왕복은 똑같이 발생한다.
|
||||||
|
|
||||||
|
절차:
|
||||||
|
|
||||||
|
1. 옵션 문구를 작성하기 전, 그 문구가 의존하는 사실 주장(공유 가능성, 네이밍 일치, 기존 구현 존재 등)을 나열한다.
|
||||||
|
2. 나열된 주장 각각을 grep/Read로 실제 확인한다 — "~일 것 같다"로 제시하지 않는다.
|
||||||
|
3. 확인이 안 되거나 시간이 없으면, 옵션 문구에서 그 주장을 빼거나 "미확인, 구현 착수 시 재검증 필요"로 명시한다.
|
||||||
|
|
||||||
|
### 발원 사례
|
||||||
|
|
||||||
|
세션 `20260701-142000` — game-register.jsp CSS 토큰 정리 범위를 묻는 `AskUserQuestion`에서 orchestrator가 "bibimbap.css 공유 변수로 전환 가능"을 grep 없이 전제로 제시(B안). 사용자가 B안을 선택했으나, 구현 착수 전 grep 확인 결과 `bibimbap.css`(`--color-*` 네이밍)가 game-register.jsp가 쓰는 변수(`--surface/--accent/--text` 등)를 대체하지 못함이 드러나 재확인 라운드가 필요했다. 구현 착수 전 단계에서 걸러져 실제 코드 오염은 없었으나, 발견 시점이 빨랐던 것은 우연이었다.
|
||||||
|
|
||||||
|
## 규칙 3 — 사용자 지적에 복수 대상 표현이 있으면 스코프를 임의로 좁히지 말고 대상 목록을 먼저 확인
|
||||||
|
|
||||||
|
이미지·스크린샷을 동반한 지적에 "각 그래프", "이것들" 처럼 복수/포괄 표현이 쓰이면, 그 중 하나(가장 먼저 눈에 띄는 요소)로 스코프를 임의 축소해 확인 질문(`AskUserQuestion` 등)을 구성하지 않는다. 확인 질문 자체에 "지적 대상이 A/B/C 중 무엇인지"를 먼저 명시하거나, 최소한 확인 질문의 배경 문장에 전체 후보를 나열해 사용자가 스코프 오판을 즉시 정정할 수 있게 한다.
|
||||||
|
|
||||||
|
### 발원 사례
|
||||||
|
|
||||||
|
세션 `20260701-102212` — 사용자가 스크린샷과 함께 "각 그래프의 끝이 어떤 점수인지 알 수 없다"고 지적(화면엔 막대 미터바 6개 + 육각 레이더 1개, 즉 "그래프"가 최소 2종류 혼재). orchestrator 가 스코프를 막대 미터바로 좁혀 시각전략 확인 질문을 구성·구현·커밋까지 마쳤으나, 사용자가 "미터바도 좋은데 내가말한건 육각 그래프였어"로 정정. 최초 확인 질문에 "미터바/레이더 둘 다 대상인지, 어느 쪽인지"를 먼저 물었다면 방향 전환 비용(재구현+재검증 사이클)을 피할 수 있었다.
|
||||||
|
|
|
||||||
|
|
@ -65,3 +65,5 @@
|
||||||
- 문서 동기화만 했다면 원칙적으로 `changes/` 를 만들지 말고, 대상 기준 문서를 직접 갱신한다.
|
- 문서 동기화만 했다면 원칙적으로 `changes/` 를 만들지 말고, 대상 기준 문서를 직접 갱신한다.
|
||||||
- 장애 수정이 있었더라도 핵심 가치가 장애 원인과 대응 이력 보존이라면 `issues/` 를 우선하고, 필요할 때만 관련 `changes/` 를 추가한다.
|
- 장애 수정이 있었더라도 핵심 가치가 장애 원인과 대응 이력 보존이라면 `issues/` 를 우선하고, 필요할 때만 관련 `changes/` 를 추가한다.
|
||||||
- ADR 에 해당하는 결정이면 `changes/` 대신 `adr/` 에 불변 레코드로 남긴다.
|
- ADR 에 해당하는 결정이면 `changes/` 대신 `adr/` 에 불변 레코드로 남긴다.
|
||||||
|
- **기존 문서에 append 하기 전에는 그 문서 자신의 스코프 선언과 먼저 대조한다.** 카테고리(`development/` 등)가 맞다는 것만으로 그 카테고리의 "비슷해 보이는" 기존 파일에 바로 적지 않는다. 대상 파일 맨 앞에 "이 파일은 X만 다룬다" 류의 스코프 선언이 있으면, 추가하려는 내용이 그 선언에 부합하는지 확인하고, 부합하지 않으면 새 파일(또는 스코프가 맞는 다른 기존 파일)로 분리한다. 같은 파일 안에 이미 스코프를 벗어난 선례가 있다는 사실은 정당화 근거가 되지 않는다 — 선례 자체가 과거의 같은 실수일 수 있다.
|
||||||
|
- 사례: `verification-strategies.md`("`verification-advisor` 가 읽는 단일 레지스트리 — 검증 전략만 대상")에 작업 진행 방식(사용자 의사결정 수렴 절차 등)을 "구조적 교훈"이라는 이름으로 반복 혼입 → `workflow-patterns.md` 신설 후 재분류(세션 20260701-113509 계열). index.md 의 카테고리 설명 문구가 실제 파일 스코프보다 넓게 서술돼 있으면 그 문구부터가 오분류 신호일 수 있다 — 문구를 믿기 전에 대상 파일 원문 스코프 선언을 직접 읽는다.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Frontend 리디자인 대상 커버리지 체크리스트
|
||||||
|
|
||||||
|
`WEB-INF/views/*.jsp` 전체(fragment 제외 23개)를 대상으로, 클로드 디자인 리디자인의 "마크업·클래스 이식" 단위 반영 여부를 추적한다. 새 뷰 파일이 추가되면 이 표에 행을 추가한다 — 추가하지 않으면 다음 리디자인 세션이 `docs/changes/`의 과거 커밋만 보고 전체 목록을 재구성하다가 파일을 놓친다(재발 사례: `game-register.jsp` 누락, 아래 참고).
|
||||||
|
|
||||||
|
## 사용 규칙
|
||||||
|
|
||||||
|
- **리디자인류 세션 착수 시**: `find src/main/webapp/WEB-INF/views -maxdepth 1 -name "*.jsp"` 로 실제 파일 목록을 뽑아 이 표와 **행 개수 대조**부터 한다. 표 갱신 없이 과거 `docs/changes/*.md` 서술만 참고하면 이 표에 없는 신규 파일이 통째로 빠진다.
|
||||||
|
- **세션 종료 시**: 이번 세션에서 다룬 파일의 상태 열을 갱신하고, 다룬 근거 커밋/문서 링크를 채운다.
|
||||||
|
- 상태 값: `완료` / `no-op(토큰 일치 확인)` / `미착수` / `제외(관리자·저빈도)`
|
||||||
|
|
||||||
|
## 상태표
|
||||||
|
|
||||||
|
| 파일 | 상태 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| `index.jsp` | 완료 | [2026-06-30-visual-redesign-bibimbap-css.md](../changes/2026-06-30-visual-redesign-bibimbap-css.md) |
|
||||||
|
| `login.jsp` | 완료 | 위 문서 단위4 |
|
||||||
|
| `signup.jsp` | 완료 | 위 문서 단위4 |
|
||||||
|
| `terms.jsp` | 완료 | 위 문서 단위5 |
|
||||||
|
| `profile.jsp` | 완료 | 위 문서 단위6 + [2026-06-30-redesign-token-coherence-fix.md](../changes/2026-06-30-redesign-token-coherence-fix.md) |
|
||||||
|
| `game-detail.jsp` | no-op(토큰 일치 확인) | 위 문서 단위7 |
|
||||||
|
| `errer.jsp` | 완료 | 위 문서 단위8 |
|
||||||
|
| `recruit-form.jsp` | 완료 | 위 문서 단위9 + token-coherence-fix |
|
||||||
|
| `posts-list.jsp` | 완료 | 위 문서 단위2(빈상태 fragment) |
|
||||||
|
| `recruit-list.jsp` | 완료 | 위 문서 단위2(빈상태 fragment) + [2026-06-30-ui-multiaxis-ratio-visibility-fix.md](../changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md) |
|
||||||
|
| `game-register.jsp` | 완료 | **9단위 리스트에서 누락 확인 후 처리(20260701)** — `/game/new` 신규 게임 등록. 사용자 지적으로 발견. 로그인 후 실사용 스샷 대조 결과 카드/사이드미리보기 레이아웃·토큰은 이미 sibling(recruit-form 등)과 일치 — 차이 2건만 보강: 필수(*) 표시(게임 이름, WebGL zip — editMode 시 zip 비필수 반영) + 미리보기 패널 "미리보기" eyebrow 라벨. `recruit-form.jsp`의 `.rf-req`/`.rf-preview__label` 패턴 그대로 재사용, 로컬 CSS 변수 관례는 sibling과 동일하게 유지(전지역 통합은 [analysis/2026-06-30-frontend-component-review.md](../analysis/2026-06-30-frontend-component-review.md) P0 별도 과제). |
|
||||||
|
| `posts-detail.jsp` | 미착수 | — |
|
||||||
|
| `posts-form.jsp` | 미착수 | — |
|
||||||
|
| `recruit-detail.jsp` | 미착수 | — |
|
||||||
|
| `jam-list.jsp` | 미착수 | — |
|
||||||
|
| `jam-detail.jsp` | 미착수 | — |
|
||||||
|
| `jam-results.jsp` | 미착수 | — |
|
||||||
|
| `jam-scoring.jsp` | 제외(관리자·저빈도) | 심사위원 전용 화면. 재검토 시 이 행만 상태 변경 |
|
||||||
|
| `operation-policy.jsp` | 미착수 | — |
|
||||||
|
| `admin-console.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
|
||||||
|
| `admin-jam-list.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
|
||||||
|
| `admin-post-categories.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
|
||||||
|
| `admin-unity-feeds.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
|
||||||
|
|
||||||
|
> fragment/공통 include(`header.jsp`, `footer.jsp`, `modal.jsp`, `theme-init.jsp`)는 이 표 대상에서 제외 — 별도 트래킹 불필요(공통 include는 상위 페이지가 완료되면 자동 반영).
|
||||||
|
|
||||||
|
## 재발방지 규칙
|
||||||
|
|
||||||
|
리디자인 세션의 "전 페이지 반영" 서술은 **작업 당시 작성자가 열거한 목록**을 의미할 뿐, 실제 뷰 파일 전체를 의미하지 않을 수 있다. 다음 리디자인/일관성 점검 세션은:
|
||||||
|
|
||||||
|
1. 이 표를 먼저 읽고 `미착수` 행을 스코프 후보로 제시한다(자동으로 전부 착수하지 않음 — 우선순위는 사용자 확인).
|
||||||
|
2. `find` 결과 행 수와 이 표의 행 수가 다르면(신규 뷰 파일 추가/삭제) 표를 먼저 동기화하고 진행한다.
|
||||||
|
3. 신규 뷰 파일을 추가하는 세션은 **같은 커밋에** 이 표에 행을 추가한다(상태: `미착수`).
|
||||||
|
|
@ -4,10 +4,13 @@
|
||||||
|
|
||||||
## 목록
|
## 목록
|
||||||
|
|
||||||
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, freeze 분류 근거 확인, frontend-design fork 패턴) 포함.
|
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, 매퍼 alias 케이스폴딩, 보안 vacuous PASS 회피) 포함.
|
||||||
|
- [workflow-patterns.md](./workflow-patterns.md) — 검증(테스트 통과)과 무관한 **작업 진행 방식** 패턴. freeze 분류 근거 확인, frontend-design fork 위임, 다중 UI 안 Artifact 시각비교→AskUserQuestion 선택 수렴 등.
|
||||||
|
- [frontend-redesign-coverage-checklist.md](./frontend-redesign-coverage-checklist.md) — 뷰 파일(`WEB-INF/views/*.jsp`) 전수 대비 리디자인 반영 상태표. 리디자인/일관성 세션 착수 전 이 표와 실제 파일 목록을 대조해 누락 방지.
|
||||||
- [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리)
|
- [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리)
|
||||||
- [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정
|
- [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정
|
||||||
- [git-workflow.md](./git-workflow.md) — 브랜치 분류(메인스트림 vs 비-메인스트림) · 비-메인스트림 브랜치 커밋 표준 승인 · push 명시 요청 한정 · Conventional Commits + `Co-Authored-By` 트레일러 규약. CLAUDE.md '작업 원칙' 커밋 정책의 정본.
|
- [git-workflow.md](./git-workflow.md) — 브랜치 분류(메인스트림 vs 비-메인스트림) · 비-메인스트림 브랜치 커밋 표준 승인 · push 명시 요청 한정 · Conventional Commits + `Co-Authored-By` 트레일러 규약. CLAUDE.md '작업 원칙' 커밋 정책의 정본.
|
||||||
- [local-dev-setup.md](./local-dev-setup.md) — 로컬 구동 환경 설정. 업로드 저장 루트(`~/.bibimbap/uploads`, static 트리 밖) 경로 규약 · @Value 기본값 유지 근거 · 자산 이전 이력 · SSRF DNS 캐시 운영 참고.
|
- [local-dev-setup.md](./local-dev-setup.md)
|
||||||
|
- [screenshot-guide.md](./screenshot-guide.md) — Playwright 헤드리스 스크린샷. `python3 docs/development/screenshot-guide.py` → `/tmp/bibimbap-screenshots/*.png`. Claude Design 등 외부 디자인 툴 연동용. — 로컬 구동 환경 설정. 업로드 저장 루트(`~/.bibimbap/uploads`, static 트리 밖) 경로 규약 · @Value 기본값 유지 근거 · 자산 이전 이력 · SSRF DNS 캐시 운영 참고.
|
||||||
|
|
||||||
> atp 플러그인 번들 레퍼런스(`agent-team-protocol.md`, `agent-catalog.md`, `documentation-guidelines.md`, `search-tool-matrix.md`)는 플러그인 캐시에 있으며 이 프로젝트로 복사되지 않는다. 에이전트가 `${CLAUDE_PLUGIN_ROOT}/docs/...` 로 직접 참조한다.
|
> atp 플러그인 번들 레퍼런스(`agent-team-protocol.md`, `agent-catalog.md`, `documentation-guidelines.md`, `search-tool-matrix.md`)는 플러그인 캐시에 있으며 이 프로젝트로 복사되지 않는다. 에이전트가 `${CLAUDE_PLUGIN_ROOT}/docs/...` 로 직접 참조한다.
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
docker compose logs -f app
|
docker compose logs -f app
|
||||||
```
|
```
|
||||||
반영(이미지 재빌드 0):
|
반영(이미지 재빌드 0):
|
||||||
- **JSP/정적**: 저장 즉시(Jasper 가 마운트된 webapp 재컴파일). 재시작 불필요.
|
- **JSP/정적**: 원칙상 저장 즉시(Jasper 가 마운트된 webapp 재컴파일). **단, 반영 안 되는 사례 확인됨** (함정 참조 — 편집 후 `curl` 로 서빙 값이 옛 값이면 즉시반영을 신뢰 말고 `docker compose restart app` 으로 강제 확인).
|
||||||
- **Java 코드**: `docker compose restart app` (spring-boot:run 재기동 ~1~2s). 또는 `docker compose exec app mvn -o -P dev -DskipTests compile` 후 restart.
|
- **Java 코드**: `docker compose restart app` (spring-boot:run 재기동 ~1~2s). 또는 `docker compose exec app mvn -o -P dev -DskipTests compile` 후 restart.
|
||||||
- 전제: 호스트 `~/.m2` 에 의존성 캐시 존재(offline `-o`). **새 의존성 추가 시**: override 의 `-o` 를 일시 제거해 1회 온라인 받거나, 호스트에서 `./mvnw -P dev dependency:go-offline` 후 다시 offline.
|
- 전제: 호스트 `~/.m2` 에 의존성 캐시 존재(offline `-o`). **새 의존성 추가 시**: override 의 `-o` 를 일시 제거해 1회 온라인 받거나, 호스트에서 `./mvnw -P dev dependency:go-offline` 후 다시 offline.
|
||||||
|
|
||||||
|
|
@ -53,10 +53,24 @@
|
||||||
docker compose -f docker-compose.yml build app # Dockerfile WAR 굽기
|
docker compose -f docker-compose.yml build app # Dockerfile WAR 굽기
|
||||||
docker compose -f docker-compose.yml up -d --build app
|
docker compose -f docker-compose.yml up -d --build app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **⚠ 함정**: `docker compose up --build app`(`-f` 없이)을 로컬 dev 환경에서 실행하면
|
||||||
|
> override 가 `build: !reset null` 로 base 의 Dockerfile 빌드를 비활성화하므로
|
||||||
|
> **이미지를 새로 구워도 override 가 그 이미지를 무시**한다.
|
||||||
|
> 로컬 dev 에서 Dockerfile 재빌드가 필요한 경우는 거의 없으나, 필요하다면 반드시
|
||||||
|
> `-f docker-compose.yml` 로 override 를 명시적으로 제외한다.
|
||||||
|
> 로컬 Java 코드 변경 반영은 `docker compose restart app` 으로 충분하다.
|
||||||
|
>
|
||||||
|
> 근거: 세션 20260630-105459 — `docker compose up --build` 가 "No services to build" 반환.
|
||||||
|
|
||||||
Dockerfile 은 멀티스테이지(WAR repackage → `eclipse-temurin:21-jre` + `java -jar`)로 운영과 동일한 산출물을 만든다(프록시 CA·`-P dev`·repackage goal 은 본 문서 함정 참조).
|
Dockerfile 은 멀티스테이지(WAR repackage → `eclipse-temurin:21-jre` + `java -jar`)로 운영과 동일한 산출물을 만든다(프록시 CA·`-P dev`·repackage goal 은 본 문서 함정 참조).
|
||||||
|
|
||||||
> devtools 자동재시작은 도입하지 않았다 — 컨테이너 `spring-boot:run` 에는 소스 자동 재컴파일러가 없어(IDE 부재) 이득이 작고, `3.5.x-SNAPSHOT` devtools 의 offline 해소가 불안정했다. `restart`/`exec compile` 루프가 더 견고하다.
|
> devtools 자동재시작은 도입하지 않았다 — 컨테이너 `spring-boot:run` 에는 소스 자동 재컴파일러가 없어(IDE 부재) 이득이 작고, `3.5.x-SNAPSHOT` devtools 의 offline 해소가 불안정했다. `restart`/`exec compile` 루프가 더 견고하다.
|
||||||
|
|
||||||
|
> **⚠ 함정**: JSP "저장 즉시 반영"이 실패한 사례 확인됨 — CSS 수치 2줄 수정 후 `curl` 로 서빙 값을 대조했더니 옛 값 그대로였다(수정 파일 mtime 은 최신인데 컴파일된 `index_jsp.class` 는 그 이전 시각). 브라우저 fork 로 "정상 반영"이라 잘못 보고한 원인도 이 stale 렌더링을 봤기 때문(false pass). `docker compose restart app` 으로 강제 재기동하니 즉시 해소됨 — 근본 원인은 미확인(Jasper 재검사 간격 또는 Docker Desktop bind-mount mtime 전파 지연 추정)이나, **재현성 있는 회피책은 확정**: JSP 수정 직후엔 "즉시 반영"을 신뢰하지 말고 `curl` 로 실제 서빙 CSS/마크업을 수정값과 대조 → 불일치 시 `docker compose restart app` 후 재대조. 브라우저 스크린샷만으로 검증하면 이 stale 상태를 놓칠 수 있다.
|
||||||
|
>
|
||||||
|
> 근거: 세션 20260701-093754.
|
||||||
|
|
||||||
## 업로드 저장 루트 (static 트리 밖)
|
## 업로드 저장 루트 (static 트리 밖)
|
||||||
|
|
||||||
업로드물(프로필 이미지·게임 WebGL asset)은 **웹서버 정적 서빙 트리(`src/main/resources/static/`) 밖**에 저장한다. 직접 서빙을 차단하고 컨트롤러 권한 게이트를 강제하기 위함이다(보안 하드닝, commit `9041bb7`).
|
업로드물(프로필 이미지·게임 WebGL asset)은 **웹서버 정적 서빙 트리(`src/main/resources/static/`) 밖**에 저장한다. 직접 서빙을 차단하고 컨트롤러 권한 게이트를 강제하기 위함이다(보안 하드닝, commit `9041bb7`).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
# 페이지 스크린샷 가이드
|
||||||
|
|
||||||
|
앱 실행 중 각 페이지를 파일로 저장하는 방법. Claude Design 등 외부 도구에 전달할 때 사용.
|
||||||
|
|
||||||
|
## 전제 조건
|
||||||
|
|
||||||
|
- 앱 실행 중 (`docker compose up`)
|
||||||
|
- Python 3 + playwright 설치됨
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install playwright
|
||||||
|
# 브라우저 다운로드 불필요 — 로컬 Chrome 사용
|
||||||
|
```
|
||||||
|
|
||||||
|
## 테스트 계정
|
||||||
|
|
||||||
|
| 용도 | 이메일 | 비밀번호 | role |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 일반 사용자 | `tester@bibimbap.local` | `test1234!` | USER |
|
||||||
|
| 관리자 | `admin@bibimbap.local` | `test1234!` | ADMIN |
|
||||||
|
|
||||||
|
seed: `db/seed-dev.sql`
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
### 비인증 페이지만 (로그인 불필요)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 docs/development/screenshot-guide.py
|
||||||
|
```
|
||||||
|
|
||||||
|
7장 캡처: 홈(비로그인), 로그인, 회원가입, 게시글 목록, 모집 목록, 게임 상세(비로그인), 이용약관
|
||||||
|
|
||||||
|
### 전체 캡처 (로그인 포함 세부 화면)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 일반 사용자로 캡처
|
||||||
|
SCREENSHOT_EMAIL=tester@bibimbap.local SCREENSHOT_PASSWORD='test1234!' \
|
||||||
|
python3 docs/development/screenshot-guide.py
|
||||||
|
|
||||||
|
# 관리자로 캡처 (관리자 전용 UI 확인 시)
|
||||||
|
SCREENSHOT_EMAIL=admin@bibimbap.local SCREENSHOT_PASSWORD='test1234!' \
|
||||||
|
python3 docs/development/screenshot-guide.py
|
||||||
|
```
|
||||||
|
|
||||||
|
추가 7장 캡처:
|
||||||
|
|
||||||
|
| 파일 | 화면 |
|
||||||
|
|---|---|
|
||||||
|
| `08-home-loggedin.png` | 홈 (로그인 후 — 내비게이션 변경) |
|
||||||
|
| `09-profile.png` | 프로필 페이지 |
|
||||||
|
| `10-posts-form.png` | 게시글 작성 폼 |
|
||||||
|
| `11-recruit-form.png` | 모집 작성 폼 |
|
||||||
|
| `12-game-detail-loggedin.png` | 게임 상세 (로그인 후 전체 뷰) |
|
||||||
|
| `13-game-review-form.png` | 리뷰 작성 폼 (노출 상태) |
|
||||||
|
| `14-game-comment-form.png` | 댓글 작성 폼 (노출 상태, 섹션 포커스) |
|
||||||
|
|
||||||
|
## 캡처 전략
|
||||||
|
|
||||||
|
**비인증 → 인증 비교**: 01·06번(비인증)과 08·12번(인증) 쌍으로 로그인 전후 차이를 비교할 수 있다.
|
||||||
|
|
||||||
|
**폼 상태 강제 노출**: 리뷰/댓글 composer는 서버 API 응답(로그인 상태, 기존 리뷰 여부) 후 JS가 제어한다. 스크립트는 3초 내 자연 노출을 기다리고, 실패하면 `removeAttribute('hidden')`으로 강제 노출해 캡처한다. 실제 동작과 동일한 HTML 구조를 캡처한다.
|
||||||
|
|
||||||
|
## 출력 위치
|
||||||
|
|
||||||
|
```
|
||||||
|
/tmp/bibimbap-screenshots/*.png
|
||||||
|
```
|
||||||
|
|
||||||
|
재부팅 시 삭제됨. 영구 저장 필요 시 `OUT_DIR` 변수 변경.
|
||||||
|
|
||||||
|
## 주의
|
||||||
|
|
||||||
|
- 헤드리스 모드는 다크 테마 미적용 (시스템 prefers-color-scheme 무시)
|
||||||
|
- Claude Design에 넘길 때 "다크 테마로 리디자인" 프롬프트에 명시
|
||||||
|
- 로그인 실패(잘못된 계정 정보) 시 인증 페이지는 건너뛰고 비인증 7장만 저장됨
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
- [2026-06-30 인증 흐름 추가](../changes/2026-06-30-screenshot-guide-auth.md) — 비인증 7장 → 최대 14장, Playwright 로그인 + hidden 폴백 전략
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""
|
||||||
|
bibimbap 스크린샷 캡처 스크립트
|
||||||
|
|
||||||
|
비인증 페이지 + 인증 후 세부 화면 상태 모두 캡처.
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
# 비인증 페이지만
|
||||||
|
python3 docs/development/screenshot-guide.py
|
||||||
|
|
||||||
|
# 인증 포함 전체 (로그인 계정 필요)
|
||||||
|
SCREENSHOT_EMAIL=user@example.com SCREENSHOT_PASSWORD=pw \
|
||||||
|
python3 docs/development/screenshot-guide.py
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||||
|
OUT_DIR = "/tmp/bibimbap-screenshots"
|
||||||
|
BASE_URL = "http://localhost:8080"
|
||||||
|
|
||||||
|
EMAIL = os.environ.get("SCREENSHOT_EMAIL", "")
|
||||||
|
PASSWORD = os.environ.get("SCREENSHOT_PASSWORD", "")
|
||||||
|
|
||||||
|
# 비인증 페이지
|
||||||
|
ANON_PAGES = [
|
||||||
|
("/", "01-home-anon.png"),
|
||||||
|
("/login", "02-login.png"),
|
||||||
|
("/signup", "03-signup.png"),
|
||||||
|
("/posts", "04-posts-list.png"),
|
||||||
|
("/recruit", "05-recruit-list.png"),
|
||||||
|
("/game/3", "06-game-detail-anon.png"),
|
||||||
|
("/terms", "07-terms.png"),
|
||||||
|
]
|
||||||
|
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def capture(page, path, filename, *, full_page=True):
|
||||||
|
page.goto(f"{BASE_URL}{path}", wait_until="networkidle")
|
||||||
|
page.screenshot(path=os.path.join(OUT_DIR, filename), full_page=full_page)
|
||||||
|
print(f" ✓ {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
def do_login(page):
|
||||||
|
"""로그인 폼 제출 후 세션 획득. CSRF 토큰은 폼에서 추출."""
|
||||||
|
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
|
||||||
|
page.fill("input[name='email']", EMAIL)
|
||||||
|
page.fill("input[name='password']", PASSWORD)
|
||||||
|
with page.expect_navigation(timeout=10000):
|
||||||
|
page.click("button[type='submit']")
|
||||||
|
if "/login" in page.url:
|
||||||
|
raise RuntimeError(f"로그인 실패 — 현재 URL: {page.url}")
|
||||||
|
|
||||||
|
|
||||||
|
def capture_review_form(page):
|
||||||
|
"""게임 상세 — 리뷰 작성 폼 노출 상태."""
|
||||||
|
page.goto(f"{BASE_URL}/game/3", wait_until="networkidle")
|
||||||
|
# JS가 로그인 상태 확인 후 composer를 보여줄 때까지 대기 (최대 3초)
|
||||||
|
try:
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.getElementById('game-review-composer') && "
|
||||||
|
"!document.getElementById('game-review-composer').hasAttribute('hidden')",
|
||||||
|
timeout=5000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# JS가 숨긴 채로 두면 강제 노출 (스크린샷 전용)
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('game-review-composer')?.removeAttribute('hidden')"
|
||||||
|
)
|
||||||
|
page.screenshot(
|
||||||
|
path=os.path.join(OUT_DIR, "13-game-review-form.png"), full_page=False
|
||||||
|
)
|
||||||
|
print(" ✓ 13-game-review-form.png")
|
||||||
|
|
||||||
|
|
||||||
|
def capture_comment_form(page):
|
||||||
|
"""게임 상세 — 댓글 작성 폼 노출 상태."""
|
||||||
|
page.goto(f"{BASE_URL}/game/3", wait_until="networkidle")
|
||||||
|
try:
|
||||||
|
page.wait_for_function(
|
||||||
|
"document.getElementById('game-comment-composer') && "
|
||||||
|
"!document.getElementById('game-comment-composer').hasAttribute('hidden')",
|
||||||
|
timeout=5000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('game-comment-composer')?.removeAttribute('hidden')"
|
||||||
|
)
|
||||||
|
# 댓글 섹션으로 스크롤
|
||||||
|
page.evaluate(
|
||||||
|
"document.getElementById('game-comment-composer')?.scrollIntoView({block:'center'})"
|
||||||
|
)
|
||||||
|
page.screenshot(
|
||||||
|
path=os.path.join(OUT_DIR, "14-game-comment-form.png"), full_page=False
|
||||||
|
)
|
||||||
|
print(" ✓ 14-game-comment-form.png")
|
||||||
|
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(
|
||||||
|
executable_path=CHROME,
|
||||||
|
headless=True,
|
||||||
|
args=["--no-sandbox"],
|
||||||
|
)
|
||||||
|
page = browser.new_page(viewport={"width": 1400, "height": 900})
|
||||||
|
|
||||||
|
print("── 비인증 페이지 ──────────────────────────────")
|
||||||
|
for path, filename in ANON_PAGES:
|
||||||
|
capture(page, path, filename)
|
||||||
|
|
||||||
|
if EMAIL and PASSWORD:
|
||||||
|
print("\n── 로그인 후 세부 화면 ─────────────────────────")
|
||||||
|
try:
|
||||||
|
do_login(page)
|
||||||
|
print(" ✓ 로그인 성공")
|
||||||
|
|
||||||
|
capture(page, "/", "08-home-loggedin.png")
|
||||||
|
capture(page, "/profile", "09-profile.png")
|
||||||
|
capture(page, "/posts/new", "10-posts-form.png")
|
||||||
|
capture(page, "/recruit/new", "11-recruit-form.png")
|
||||||
|
capture(page, "/game/3", "12-game-detail-loggedin.png")
|
||||||
|
capture_review_form(page)
|
||||||
|
capture_comment_form(page)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ 오류: {e}")
|
||||||
|
print(" → 인증 페이지 캡처 건너뜀", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print("\n※ SCREENSHOT_EMAIL / SCREENSHOT_PASSWORD 미설정 — 비인증 페이지만 캡처됨")
|
||||||
|
print(" 전체 캡처: SCREENSHOT_EMAIL=... SCREENSHOT_PASSWORD=... python3 docs/development/screenshot-guide.py")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
print(f"\n완료: {OUT_DIR}/")
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
| MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 (DB-방언 계약) | L1 + L2 (dev DB contract) |
|
| MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 (DB-방언 계약) | L1 + L2 (dev DB contract) |
|
||||||
| 인프라 설정 (container/env/compose) | L1 + 수동 스모크 |
|
| 인프라 설정 (container/env/compose) | L1 + 수동 스모크 |
|
||||||
| HTTP 상태코드/예외 매핑 변경 (404/403 등, 전역 `@ExceptionHandler` 경유) | L1 + 런타임 스모크 |
|
| HTTP 상태코드/예외 매핑 변경 (404/403 등, 전역 `@ExceptionHandler` 경유) | L1 + 런타임 스모크 |
|
||||||
|
| SQL data-only 변경 (DML: seed/백필, 스키마·코드 무변경) | L1 해당없음(명시) + L2(대상 API/DB 조회로 값 일치 확인) |
|
||||||
|
|
||||||
**회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다.
|
**회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다.
|
||||||
|
|
||||||
|
|
@ -147,29 +148,6 @@ strategies:
|
||||||
|
|
||||||
> 근거: W3-2 고도화 세션(20260622) — B2(본문 10자)·6축 필수 계약 도입 시 기존 fixture 8건이 미갱신되어 L1 RED. 컨트롤러는 무버그.
|
> 근거: W3-2 고도화 세션(20260622) — B2(본문 10자)·6축 필수 계약 도입 시 기존 fixture 8건이 미갱신되어 L1 RED. 컨트롤러는 무버그.
|
||||||
|
|
||||||
### freeze/동결 분류는 근거 문서 확인 선행
|
|
||||||
|
|
||||||
어떤 변경을 "동결 영역 해제·고위험 게이트"로 분류하기 전에, 동결 범위를 정의한 **근거 문서(ADR·roadmap·work-log)를 줄 번호까지 직접 확인**한다. 표면적 유사성("평점 집계" 등)만으로 freeze 인접 추론 금지.
|
|
||||||
|
|
||||||
절차:
|
|
||||||
1. 동결 선언 근거 문서를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
|
|
||||||
2. 변경 대상 테이블/뷰/심볼이 그 범위에 **명시적으로** 포함되는지 판단.
|
|
||||||
3. 포함 확인 시만 §6 게이트 표기.
|
|
||||||
|
|
||||||
> 근거: W3-2 고도화 세션(20260622) — `game_review_stats` 집계뷰가 phantom 고위험 게이트로 오분류 → `roadmap:63/201/202` 직접 확인으로 일반 DDL 정정.
|
|
||||||
|
|
||||||
### (긍정 패턴) frontend-design 스킬 fork 위임
|
|
||||||
|
|
||||||
production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때, `frontend-design` 스킬을 **fork(컨텍스트 상속)로 서브에이전트에 위임**하면 스킬 호출 + 단일파일 폴리시 + 프리뷰 render-verify 를 컨텍스트 오염 없이 수행 가능하다. 검증된 패턴.
|
|
||||||
|
|
||||||
조건:
|
|
||||||
- L1 전체 GREEN 확인 후 진입.
|
|
||||||
- 단일파일 폴리시: JSP 1파일 안에서 완결(신규 파일 0).
|
|
||||||
- 가드레일 명시: 기존 JS 로직 보존 / a11y / BE API 계약 무변경 / 외부 JS 라이브러리·CDN 도입 금지.
|
|
||||||
- 산출물에 프리뷰 HTML 포함(`artifacts/`).
|
|
||||||
|
|
||||||
> 근거: W3-2 고도화 세션(20260622) — 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 fork 위임으로 단일 JSP 파일 승격 + L1 43/43 GREEN 유지.
|
|
||||||
|
|
||||||
### 신규 `*Test.java` 는 "(검증)" 소유태그여도 구현 산출물
|
### 신규 `*Test.java` 는 "(검증)" 소유태그여도 구현 산출물
|
||||||
|
|
||||||
design 파일 영향맵에서 신규 테스트 파일이 owner 칸에 `(검증)` 으로 표시되더라도, **테스트 작성은 implementation 단계의 산출물**이다. `verification-advisor` 는 테스트를 **실행만** 하며 Write 권한이 없다. `(검증)` 은 "검증 관련 파일"이라는 용도 라벨일 뿐 작성 주체가 아니다. implementation 단계에서 파일 영향맵의 신규 `*Test.java` 를 전수 작성해야 하며, 빠뜨리면 verification 단계에서 시나리오 AC(VP) 가 "미커버"로 떨어진다(테스트가 없으니 실행할 게 없음).
|
design 파일 영향맵에서 신규 테스트 파일이 owner 칸에 `(검증)` 으로 표시되더라도, **테스트 작성은 implementation 단계의 산출물**이다. `verification-advisor` 는 테스트를 **실행만** 하며 Write 권한이 없다. `(검증)` 은 "검증 관련 파일"이라는 용도 라벨일 뿐 작성 주체가 아니다. implementation 단계에서 파일 영향맵의 신규 `*Test.java` 를 전수 작성해야 하며, 빠뜨리면 verification 단계에서 시나리오 AC(VP) 가 "미커버"로 떨어진다(테스트가 없으니 실행할 게 없음).
|
||||||
|
|
@ -212,12 +190,6 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
|
||||||
|
|
||||||
> 근거: W3-3 SSRF — 설계 concern 이 "IP핀닝 또는 connect후 peer검증" 을 열어둠 → 1차가 IP핀닝(Host override) 선택, HTTPS SNI 깨짐 + restricted-header 미검증. fix1 에서 option-b(hostname-connect)로 전환.
|
> 근거: W3-3 SSRF — 설계 concern 이 "IP핀닝 또는 connect후 peer검증" 을 열어둠 → 1차가 IP핀닝(Host override) 선택, HTTPS SNI 깨짐 + restricted-header 미검증. fix1 에서 option-b(hostname-connect)로 전환.
|
||||||
|
|
||||||
### needs_user_verification 은 "미완료 목록" 이 아니라 "결정 분기" 단위로 구조화
|
|
||||||
|
|
||||||
세션 종료 시 `needs_user_verification` 을 단순 잔여 작업 나열로 적으면, 다음 세션이 각 항목마다 "어떻게 할까요" 재질의로 시작한다. 대신 각 항목을 **사용자가 한 번에 고를 수 있는 결정 분기**(예: 직접 적용 / 지금 구현 / 배포 후 이월 / 추가 하드닝)로 미리 구조화하면, 다음 세션 착수 시 결정이 1라운드에 수렴하고 재질의가 0이 된다. 항목마다 "기본 가정값 + 영향 범위" 를 병기한다(§2.2 open_questions 규약과 정합).
|
|
||||||
|
|
||||||
> 근거: 세션 20260629-142115 — 직전 세션이 needs 4건을 결정 분기로 명시 → 사용자 4건 일괄 결정, 재질의 0, 모든 분기 첫 라운드 수렴.
|
|
||||||
|
|
||||||
### 런타임/환경 의존 보장은 L1(값 확인)과 L3(실반영) 레이어로 분리
|
### 런타임/환경 의존 보장은 L1(값 확인)과 L3(실반영) 레이어로 분리
|
||||||
|
|
||||||
설정값이 "코드에서 세팅되는가" 와 "런타임에 실제 반영되는가" 가 다른 레이어인 경우(예: `Security.setProperty("networkaddress.cache.ttl")` 는 `InetAddressCachePolicy` lazy init 경합으로 best-effort), 단위검증으로 **값 설정만** 확인하고 **실제 런타임 반영은 L3 스모크**로 분리 배정한다. best-effort 한계와 결정적 정본(예: JVM `$JAVA_HOME/conf/security/java.security`)을 코드 주석·체크리스트에 함께 명시해, "값=설정됨" 을 "동작=보장됨" 으로 오인하지 않게 한다.
|
설정값이 "코드에서 세팅되는가" 와 "런타임에 실제 반영되는가" 가 다른 레이어인 경우(예: `Security.setProperty("networkaddress.cache.ttl")` 는 `InetAddressCachePolicy` lazy init 경합으로 best-effort), 단위검증으로 **값 설정만** 확인하고 **실제 런타임 반영은 L3 스모크**로 분리 배정한다. best-effort 한계와 결정적 정본(예: JVM `$JAVA_HOME/conf/security/java.security`)을 코드 주석·체크리스트에 함께 명시해, "값=설정됨" 을 "동작=보장됨" 으로 오인하지 않게 한다.
|
||||||
|
|
@ -226,6 +198,36 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### MyBatis + PostgreSQL nullable 파라미터 명시 캐스트 규약
|
||||||
|
|
||||||
|
MyBatis annotation SQL(`@Select`)에서 nullable 파라미터를 `#{p} IS NULL` 조건으로 쓸 때, PostgreSQL은 prepared statement `$N`의 타입을 추론하지 못해 `PSQLException: could not determine data type of parameter $1` → 500이 발생한다. 컴파일·L1 단위테스트(MockBean)로는 탐지되지 않는다.
|
||||||
|
|
||||||
|
**규약:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- ❌ 잘못된 패턴 — null 전달 시 PSQLException
|
||||||
|
AND (#{categoryId} IS NULL OR p.category_id = #{categoryId})
|
||||||
|
|
||||||
|
-- ✅ 올바른 패턴 — 명시 캐스트
|
||||||
|
AND (#{categoryId}::bigint IS NULL OR p.category_id = #{categoryId}::bigint)
|
||||||
|
```
|
||||||
|
|
||||||
|
nullable 파라미터 타입별 캐스트:
|
||||||
|
|
||||||
|
| Java 타입 | PostgreSQL 캐스트 |
|
||||||
|
|---|---|
|
||||||
|
| `Long` / `Integer` | `::bigint` / `::int` |
|
||||||
|
| `OffsetDateTime` / `LocalDateTime` | `::timestamptz` / `::timestamp` |
|
||||||
|
| `String` | `::text` |
|
||||||
|
|
||||||
|
**대안**: `<if test="p != null">` 동적 XML 분기로 null 케이스를 조건 자체에서 제거(JamsMapper/GamesMapper keyset 패턴). 신규 keyset 페이징 매퍼 작성 시 이 패턴을 우선 권장한다.
|
||||||
|
|
||||||
|
**신규 매퍼 코드리뷰 체크**: `rg '#{[^}]+}\s+IS\s+NULL' src/main/java --type java` 로 타입 캐스트 없는 IS NULL 조건을 전수 확인한다.
|
||||||
|
|
||||||
|
> 근거: 세션 20260630-105459 — `PostsMapper.listPublishedKeyset` `#{categoryId} IS NULL` 캐스트 누락 → `GET /posts` 500.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 프로토콜 개선 권고 (외부 번들 — 미적용)
|
## 프로토콜 개선 권고 (외부 번들 — 미적용)
|
||||||
|
|
||||||
아래 항목은 ATP 플러그인 번들(`~/.claude` 전역) 대상이다. 본 프로젝트 파일에서 직접 수정하지 않고 기록만 한다.
|
아래 항목은 ATP 플러그인 번들(`~/.claude` 전역) 대상이다. 본 프로젝트 파일에서 직접 수정하지 않고 기록만 한다.
|
||||||
|
|
@ -239,6 +241,7 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
|
||||||
- **verification-advisor 체크리스트**: 보안 거부 단위테스트는 정상경로 성공(present) 테스트와 쌍으로 차별 입증(distinguishing assertion) 확인 조항. 근거: W3-3 vacuous PASS.
|
- **verification-advisor 체크리스트**: 보안 거부 단위테스트는 정상경로 성공(present) 테스트와 쌍으로 차별 입증(distinguishing assertion) 확인 조항. 근거: W3-3 vacuous PASS.
|
||||||
- **orchestrator scope-fence 조항(구조적)**: 단일기능 표시면이 선행기능 컨트롤러에 의존할 때 cross-feature display wiring fence 예외 선언 또는 deferral 분리. 근거: W4 배지 표시 AC 갭.
|
- **orchestrator scope-fence 조항(구조적)**: 단일기능 표시면이 선행기능 컨트롤러에 의존할 때 cross-feature display wiring fence 예외 선언 또는 deferral 분리. 근거: W4 배지 표시 AC 갭.
|
||||||
- **design-advisor 조항**: 트레이드오프 concern 은 1차 권장안 + 조건부 전환 기준까지 선결정으로 좁힘. 근거: W3-3 SSRF IP핀닝 오선택.
|
- **design-advisor 조항**: 트레이드오프 concern 은 1차 권장안 + 조건부 전환 기준까지 선결정으로 좁힘. 근거: W3-3 SSRF IP핀닝 오선택.
|
||||||
|
- **§4.4 item5 를 *선행 게이트* 로 강화**: 현재 item5(가시성·표현·레이아웃 축 누락 점검)는 옵션 *작성 후* 사후 점검이라, "비율/가시성 떨어진다" 같은 **모호 어휘를 옵션 설계 이전에 단일 축으로 협소화**하면 가드가 작동할 시점을 이미 지난다. 모호 시각 결함 어휘 수신 시 옵션 축 확정 *전에* 다축 스캔(레이아웃비율·종횡비·대비가시성·타이포 최소 4축)을 의무화하는 선행 게이트로 승격 권고. 근거: 세션 20260630-175023 — "비율"을 카드 종횡비 단일 축으로 조기 협소화 → 사용자가 "검색바도 혼자 짧다"로 레이아웃 비율 축 직접 추가.
|
||||||
|
|
||||||
### HTTP 상태/예외 매핑 변경은 런타임 스모크 의무 (L1 통과로 충분치 않음)
|
### HTTP 상태/예외 매핑 변경은 런타임 스모크 의무 (L1 통과로 충분치 않음)
|
||||||
|
|
||||||
|
|
@ -251,3 +254,27 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
|
||||||
implementation-advisor 가 여러 code-writer worker 로 파일을 분산 수정한 뒤 반환하는 완료 summary 는 worker 상태 추적 혼선으로 **실제 변경과 불일치**할 수 있다("Worker C만 완료" 보고했으나 실제 4파일 적용 등). orchestrator 는 advisor summary 를 그대로 신뢰하지 말고 `git diff --stat` / 대상 파일 grep 으로 **실변경을 교차검증**한 뒤 다음 단계로 진행한다.
|
implementation-advisor 가 여러 code-writer worker 로 파일을 분산 수정한 뒤 반환하는 완료 summary 는 worker 상태 추적 혼선으로 **실제 변경과 불일치**할 수 있다("Worker C만 완료" 보고했으나 실제 4파일 적용 등). orchestrator 는 advisor summary 를 그대로 신뢰하지 말고 `git diff --stat` / 대상 파일 grep 으로 **실변경을 교차검증**한 뒤 다음 단계로 진행한다.
|
||||||
|
|
||||||
> 근거: B2·B4·FE 세션(20260629) — FE implementation-advisor 가 불완전 summary 반환, git diff 로 4파일 적용 확인 후 진행. profile 미변경은 "입력 컨트롤 없음=정상"으로 판별.
|
> 근거: B2·B4·FE 세션(20260629) — FE implementation-advisor 가 불완전 summary 반환, git diff 로 4파일 적용 확인 후 진행. profile 미변경은 "입력 컨트롤 없음=정상"으로 판별.
|
||||||
|
|
||||||
|
### graphify 스캔 범위 — JSP/CSS/JS 정적 자산 미포함
|
||||||
|
|
||||||
|
graphify(`/graphify src/ docs/`)는 `src/main/webapp/WEB-INF/views/*.jsp`, `src/main/webapp/css/`, `src/main/webapp/js/` 등 정적 자산을 스캔 범위에 포함하지 않는다. 프론트엔드 분석 태스크에서 graphify-lookup-advisor가 miss를 반환하는 것은 예상 결과다 — research-advisor 직접 탐색으로 대체한다.
|
||||||
|
|
||||||
|
> 근거: 세션 20260630-113258 — graphify-lookup miss 즉시 확인, research-advisor로 전환해 JSP 26개 전수 탐색 성공.
|
||||||
|
|
||||||
|
### JSP 변경은 WAR 빌드 PASS만으로 불충분 — 런타임 스모크 의무
|
||||||
|
|
||||||
|
이 프로젝트는 JSP 사전컴파일이 미설정(Tomcat이 첫 요청 시 런타임 컴파일)이라, `./mvnw package` exit 0 이어도 JSP EL 표현식·taglib 선언 오류는 **런타임에만 발현**한다. WAR 빌드는 JSP를 파일로 패키징만 하고 컴파일하지 않으므로 L1 GREEN 은 "JSP 구문 유효" 보증이 아니다. JSP 파일이 변경된 모든 세션은 verification 단계에 **런타임 스모크**(앱 기동/재기동 후 변경 JSP 경로 `curl -w '%{http_code}'` 200 확인)를 명시한다. 특히 외부 디자인 산출물의 마크업을 이식할 때 stray taglib/EL 이 딸려오기 쉬우므로 implementation 체크리스트에 grep 전수 확인을 포함한다: `rg '<%@\s*taglib' src/main/webapp/WEB-INF/views/` (이 프로젝트 뷰는 JSTL 미사용·scriptlet 전략 — taglib 출현 자체가 적신호). §273(예외매핑) 의 일반화 버전이다. JSP 사전컴파일 플러그인(jspc) 도입은 별도 open item.
|
||||||
|
|
||||||
|
> 근거: 세션 20260630-160000 — 비주얼 리디자인 이식 중 errer.jsp에 디자인서 딸려온 구 javax JSTL taglib(`http://java.sun.com/jsp/jstl/core`)이 stray 유입 → mvn package PASS·다른 페이지 200 이나 `/error` 만 런타임 500(JasperException, Jakarta 미해소). L2 스모크에서 포착 → 미사용 taglib 제거로 200 (fix 360078a).
|
||||||
|
|
||||||
|
### 정적 include 파편(.jspf)은 자체 `pageEncoding` 필수 — 부모 인코딩 미상속
|
||||||
|
|
||||||
|
`<%@ include file="x.jspf" %>`(정적/translation-time include)로 삽입되는 파편은 **부모 JSP의 `pageEncoding`을 상속하지 않는다.** JSP 스펙상 정적 include 세그먼트는 파일별로 인코딩이 독립 결정되며, 파편에 page 지시자가 없으면 기본 ISO-8859-1로 읽힌다. UTF-8 한글 바이트를 가진 파편이 부모(UTF-8)에 include 돼도 Jasper가 Latin-1로 읽어 **mojibake**(`ì ì§ ë...`)가 된다. 본문 텍스트가 있는 .jspf는 첫 줄에 `<%@ page pageEncoding="UTF-8" %>`를 둔다(같은 값이면 부모와 충돌 없음). 런타임 스모크에서만 발현하므로 브라우저 육안 확인이 필요하다. (참고: `<jsp:include>` 런타임 include는 별도 translation unit이라 자체 contentType으로 정상 — theme-init/header/footer가 그 경로.)
|
||||||
|
|
||||||
|
> 근거: 세션 20260630-160000 후속 브라우저 리뷰 — posts-empty/recruit-empty.jspf(UTF-8)가 정적 include 되며 빈상태 한글 전부 mojibake. 빌드 PASS·페이지 200이라 빌드/문자열검증으론 미포착, 브라우저에서 발견 → 각 파편에 pageEncoding 추가로 해소 (fix a4d163d).
|
||||||
|
|
||||||
|
### 정적 `.html`(DefaultServlet 서빙)도 `<meta charset="utf-8">` 필수 — §299 의 정적파일 짝
|
||||||
|
|
||||||
|
§299 는 `.jspf` 정적 include 의 `pageEncoding` 을 다루지만, **DefaultServlet 이 서빙하는 순수 `.html`** 도 동일 mojibake 함정이 있다. Tomcat 은 정적 `.html` 에 `Content-Type: text/html`(charset 없음)만 붙이므로, 파일에 `<meta charset="utf-8">` 가 없으면 브라우저가 Latin-1 로 추정해 UTF-8 한글이 깨진다. 앱 정적경로에 두는 프리뷰·정적 페이지는 `<head>` 에 `<meta charset="utf-8">` 를 반드시 포함한다.
|
||||||
|
|
||||||
|
> 근거: 세션 20260701-083240 — 카드 프리뷰 정적 html 을 `/css/` 서빙했으나 meta charset 누락으로 전체 한글 mojibake, 사용자 스샷으로 표면화 → meta 추가로 즉시 해소. 변경 상세: [changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md](../changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md).
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Workflow Patterns — 작업 진행 방식
|
||||||
|
|
||||||
|
`verification-strategies.md` 는 "코드가 옳은가"(L1/L2/L3 검증)를 다룬다. 이 문서는 그와 무관하게 **사람과 어떻게 합의하며 작업을 진행하는가** — 결정 수렴 절차, 산출물 제시 방식 — 를 다룬다. 재사용 가능한 것만 등재한다.
|
||||||
|
|
||||||
|
## freeze/동결 분류는 근거 문서 확인 선행
|
||||||
|
|
||||||
|
어떤 변경을 "동결 영역 해제·고위험 게이트"로 분류하기 전에, 동결 범위를 정의한 **근거 문서(ADR·roadmap·work-log)를 줄 번호까지 직접 확인**한다. 표면적 유사성("평점 집계" 등)만으로 freeze 인접 추론 금지.
|
||||||
|
|
||||||
|
절차:
|
||||||
|
1. 동결 선언 근거 문서를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
|
||||||
|
2. 변경 대상 테이블/뷰/심볼이 그 범위에 **명시적으로** 포함되는지 판단.
|
||||||
|
3. 포함 확인 시만 §6 게이트 표기.
|
||||||
|
|
||||||
|
> 근거: W3-2 고도화 세션(20260622) — `game_review_stats` 집계뷰가 phantom 고위험 게이트로 오분류 → `roadmap:63/201/202` 직접 확인으로 일반 DDL 정정.
|
||||||
|
|
||||||
|
## (긍정 패턴) frontend-design 스킬 fork 위임
|
||||||
|
|
||||||
|
production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때, `frontend-design` 스킬을 **fork(컨텍스트 상속)로 서브에이전트에 위임**하면 스킬 호출 + 단일파일 폴리시 + 프리뷰 render-verify 를 컨텍스트 오염 없이 수행 가능하다. 검증된 패턴.
|
||||||
|
|
||||||
|
조건:
|
||||||
|
- L1 전체 GREEN 확인 후 진입.
|
||||||
|
- 단일파일 폴리시: JSP 1파일 안에서 완결(신규 파일 0).
|
||||||
|
- 가드레일 명시: 기존 JS 로직 보존 / a11y / BE API 계약 무변경 / 외부 JS 라이브러리·CDN 도입 금지.
|
||||||
|
- 산출물에 프리뷰 HTML 포함(`artifacts/`).
|
||||||
|
|
||||||
|
> 근거: W3-2 고도화 세션(20260622) — 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 fork 위임으로 단일 JSP 파일 승격 + L1 43/43 GREEN 유지.
|
||||||
|
|
||||||
|
## (긍정 패턴) 다중 UI 안 Artifact 시각비교 → AskUserQuestion 선택 수렴
|
||||||
|
|
||||||
|
레이아웃·배치처럼 "여러 방식이 다 타당한" UI 결정은, 구현 전에 실제 프로젝트 CSS 토큰(색상·폰트·기존 클래스명)을 그대로 이식한 정적 Artifact(HTML)로 후보 N안을 한 화면에 나란히 렌더해 사용자가 비교하게 하고, `AskUserQuestion` 으로 확정받은 뒤에만 소스에 반영한다. `/task` §5.0 계획가시성 의무를 산문 대신 **눈으로 보는 산출물**로 충족하는 변형.
|
||||||
|
|
||||||
|
절차:
|
||||||
|
1. 실제 프로젝트 색상/폰트/컴포넌트 클래스를 `grep`/`Read` 로 확인해 Artifact 에 그대로 이식 — 가짜 톤 금지(재현 신뢰도가 선택 신뢰도를 결정).
|
||||||
|
2. 결정축이 여러 개면(예: "요약 영역" + "카드 영역") 섹션을 분리하고 각 섹션 안에서만 후보를 나열 — 축을 섞으면 조합폭발.
|
||||||
|
3. 후보에 A/B/C/D 같은 안정적 레터를 부여해 이후 `AskUserQuestion` 옵션 라벨과 1:1 대응 — 참조 혼동 방지.
|
||||||
|
4. Artifact 자체의 라디오/버튼 선택은 눈요기일 뿐 상태를 orchestrator 로 되돌리지 못한다 — 실제 확정은 반드시 `AskUserQuestion`(같은 레터 옵션)으로 받는다.
|
||||||
|
5. 확정 전 단계는 소스 변경 0건이므로 verification-advisor/L1 게이트 대상이 아니다 — §9 종료조건 미적용, 세션은 사용자 응답 대기로 열어둔 채 진행.
|
||||||
|
|
||||||
|
> 근거: 세션 20260701-113509 — game-detail.jsp 리뷰 영역(요약 그래프 vs 그래프+범례, 카드 미터바 배치 4안)을 Artifact 로 제시 → `AskUserQuestion` 1회로 즉시 확정(재작업 요청 0) → 반영 커밋 `0c8da40`.
|
||||||
|
|
||||||
|
## (긍정 패턴) 시각 디자인 결정은 텍스트 diff 대신 앱 정적경로 프리뷰로 공동 확인
|
||||||
|
|
||||||
|
CSS 위계·여백·색 같은 시각 변경은 before→after **텍스트 표**로 제시해도 사용자가 결과를 예측하기 어렵다. 실제 토큰·CSS 로 렌더한 정적 프리뷰(현재/변경안 나란히, 라이트·다크·상태별)를 만들어 **앱의 화이트리스트 정적경로**(이 프로젝트: `src/main/webapp/css/`)에 두면 `:8080/css/<preview>.html` 로 서빙되어(재시작 불요, DefaultServlet 직접 서빙) 사용자는 브라우저 새로고침으로, 에이전트는 동일 URL 스크린샷으로 **공동 확인**한다. private 파일 전송(SendUserFile)은 사용자가 텍스트로만 볼 수 있는 경우가 있어 시각 비교엔 부적합. 결정 후 프리뷰 파일은 삭제(커밋 금지). 주의: top-level 경로(`/preview.html`)는 보안필터가 302 리다이렉트하므로 화이트리스트 정적 prefix 아래 둔다.
|
||||||
|
|
||||||
|
위 "다중 UI 안 Artifact 시각비교" 패턴의 자매 변형이다 — Artifact 는 별도 호스팅 URL, 이 패턴은 **앱 자체 정적경로**를 프리뷰 서버로 재사용한다. 앱 정적경로가 화이트리스트로 이미 열려 있고 재시작 없이 서빙 가능할 때 이 변형이 더 빠르다.
|
||||||
|
|
||||||
|
> 근거: 세션 20260701-083240 — 카드 위계 변경안 A/B 를 텍스트 표로 제시하자 "어떻게 변할지 예측 어렵다" 피드백 → 앱 `/css/` 프리뷰 서빙으로 전환, 사용자가 B 를 1라운드 수락.
|
||||||
|
|
||||||
|
## 모호한 시각 결함 어휘는 단일 결정축 협소화 전 다축 스캔 선행
|
||||||
|
|
||||||
|
"비율이 떨어진다 / 가시성이 떨어진다" 같은 **모호한 시각 결함 어휘**는 결함의 위치를 한정하지 않는다. 이를 곧장 단일 결정축(예: "비율" → 카드 종횡비)으로 좁혀 `AskUserQuestion` 을 던지면, 같은 어휘가 가리키던 다른 축(레이아웃 비율·정렬·대비·표현 총량)이 옵션 공간에서 누락된다. 모호 시각 어휘 수신 시 옵션 축을 확정하기 **전에** 다축 스캔(최소 레이아웃비율·종횡비·대비가시성·타이포간격 4축)을 선행한다. 스크린샷이 있으면 관찰 1 에이전트 → 축별 병렬 평가 → 종합의 multi-axis 위임(`ui-multiaxis-eval` 패턴)이 협소화 맹점을 구조적으로 메운다. UI/UX 변경 검증은 위 "시각 디자인 결정 프리뷰 공동확인" 패턴과 함께, **라이트/다크 양 테마 × 영향 화면 전수** 육안으로 수행한다(토큰 대비·종횡비는 테마별로 다르게 발현).
|
||||||
|
|
||||||
|
> 근거: 세션 20260630-175023 — 사용자 지적 "비율/가시성" 2건을 카드 종횡비 단일 축으로 협소화 → 사용자가 "검색바도 혼자 짧다"로 레이아웃 비율 축 직접 추가. 이후 multi-axis 위임 평가(6 에이전트)가 13건 결함으로 복원, 라이트/다크 양화면 스모크로 검증.
|
||||||
|
|
||||||
|
## "전 페이지 반영" 서술은 실제 파일 전수와 대조 필요 — 리디자인 대상 체크리스트 선행
|
||||||
|
|
||||||
|
"전 페이지 리디자인" 같은 세션 요약은 **작업 당시 작성자가 열거한 목록**을 뜻할 뿐, `find`로 뽑은 실제 뷰 파일 전수와 항상 일치하지 않는다. 과거 `docs/changes/*.md` 서술만 신뢰하고 다음 세션을 시작하면, 그 목록에 없던 파일(신규 추가분·의도적 스코프 제외분 모두 포함)이 조용히 계속 빠진 채로 남는다.
|
||||||
|
|
||||||
|
절차:
|
||||||
|
1. 리디자인/시각 일관성류 작업 착수 전 `docs/development/frontend-redesign-coverage-checklist.md` 를 읽고 `find src/main/webapp/WEB-INF/views -maxdepth 1 -name "*.jsp"` 실제 파일 수와 표 행 수를 대조한다.
|
||||||
|
2. 불일치(신규 파일 미등재) 시 표를 먼저 동기화한 뒤 진행한다.
|
||||||
|
3. 신규 뷰 파일을 추가하는 세션은 같은 커밋으로 그 표에 행을 추가한다(상태 `미착수`) — 다음 리디자인 세션이 처음부터 전수 파악하지 않아도 되게 한다.
|
||||||
|
|
||||||
|
> 근거: 세션 20260701 — `game-register.jsp`(`/game/new`) 가 2026-06-30 9단위 리디자인 서술에 없어 사용자가 직접 지적할 때까지 누락 상태로 남음. 체크리스트 부재가 원인.
|
||||||
|
|
||||||
|
## needs_user_verification 은 "미완료 목록" 이 아니라 "결정 분기" 단위로 구조화
|
||||||
|
|
||||||
|
세션 종료 시 `needs_user_verification` 을 단순 잔여 작업 나열로 적으면, 다음 세션이 각 항목마다 "어떻게 할까요" 재질의로 시작한다. 대신 각 항목을 **사용자가 한 번에 고를 수 있는 결정 분기**(예: 직접 적용 / 지금 구현 / 배포 후 이월 / 추가 하드닝)로 미리 구조화하면, 다음 세션 착수 시 결정이 1라운드에 수렴하고 재질의가 0이 된다. 항목마다 "기본 가정값 + 영향 범위" 를 병기한다(§2.2 open_questions 규약과 정합).
|
||||||
|
|
||||||
|
> 근거: 세션 20260629-142115 — 직전 세션이 needs 4건을 결정 분기로 명시 → 사용자 4건 일괄 결정, 재질의 0, 모든 분기 첫 라운드 수렴.
|
||||||
|
|
||||||
|
## `needs_user_verification` 이월 시 최근 세션의 렌더 관련 known pitfall 교차 인용
|
||||||
|
|
||||||
|
`needs_user_verification` 으로 브라우저 육안 확인을 이월할 때, 변경 대상이 최근 세션에서 docs 화된 렌더링 함정과 같은 경로(JSP/정적 asset)를 공유하면 그 문서를 명시적으로 인용한다. 단순히 "브라우저에서 육안 확인" 이라고만 적으면, 다음 세션/사용자가 이미 알려진 함정(예: JSP stale 렌더링 — `local-dev-setup.md` §JSP/정적)을 다시 밟고도 브라우저 화면만 보고 오탐(false pass) 할 수 있다. 작성 규칙: 최근 work-session 3~5개 이내 docs 반영된 구조적 함정 중 렌더 경로가 겹치는 것이 있으면 `needs_user_verification` 항목에 "known pitfall: `<문서 링크>` — curl 로 서빙값 대조 선행 권고" 를 병기한다.
|
||||||
|
|
||||||
|
> 근거: 세션 20260701-100731 — game-detail.jsp 레이더 차트 데이터(6축 axis) 백필 세션. 직전 세션(20260701-093754, 4분 전 종료)이 방금 "JSP 저장 즉시반영 실패 → 브라우저가 stale 렌더링을 정상으로 오판" 함정을 `local-dev-setup.md` 에 반영했음에도, 같은 렌더 경로(JSP)를 다루는 후속 세션의 `needs_user_verification` 이 이를 인용하지 않아 재발 위험을 남김(retrospective-advisor 포착).
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
kind: graphify-meta
|
kind: graphify-meta
|
||||||
last_generated_at: 2026-06-30T00:00:00+0900
|
last_generated_at: 2026-07-01T14:35:00+0900
|
||||||
source_commit: 2c48184
|
source_commit: 86b0528
|
||||||
scopes:
|
scopes:
|
||||||
- full
|
- full
|
||||||
---
|
---
|
||||||
|
|
@ -39,7 +39,7 @@ scope 예시: `full`, `src`, `docs`, `src-features` 등.
|
||||||
|
|
||||||
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
|
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| `full` | 2026-06-30 | `2c48184` | `src/` + `docs/` | **src+docs 통합** (merge-graphs, 2038노드/4685엣지). 기존 `src` scope(AST 1840노드/4403엣지, Java 클래스·메서드·주입엣지) + `docs` scope(semantic 198노드/282엣지, 설계문서·DDL·변경이력)를 단일 그래프로 통합. src↔docs 교차 탐색 가능. 직전 src 상태: B2 dead code 제거 + 없는 게임 404 반영 (31dc2ff). 직전 docs 상태: W3/W4 스키마·변경이력 반영 (305cc73). |
|
| `full` | 2026-07-01 | `86b0528` | `src/` + `docs/` | **incremental update** — 18커밋분 변경(11 파일: db/seed-dev.sql + docs 10건) 재추출 후 `build_merge` 병합 시 고스트 중복 486 exact + 251 fuzzy 추가 제거(2089→1477 노드, 4503 엣지, 76 커뮤니티). game-register.jsp 리디자인 보강·frontend-redesign-coverage-checklist·workflow-patterns 신규 노드 반영. JSP/CSS 정적자산은 여전히 스캔 범위 외. |
|
||||||
|
|
||||||
## 갱신 시 체크리스트
|
## 갱신 시 체크리스트
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,3 +181,20 @@
|
||||||
- [ ] 업로드: `../`, 절대 경로, nested zip, 과대 zip을 거부한다.
|
- [ ] 업로드: `../`, 절대 경로, nested zip, 과대 zip을 거부한다.
|
||||||
- [ ] XSS: 게임 이름, 제작자 한마디, 댓글, 검색어가 HTML로 실행되지 않는다.
|
- [ ] XSS: 게임 이름, 제작자 한마디, 댓글, 검색어가 HTML로 실행되지 않는다.
|
||||||
- [ ] 권한: 게임 수정/삭제, 댓글 삭제, 프로필 변경은 소유자 또는 로그인 조건을 확인한다.
|
- [ ] 권한: 게임 수정/삭제, 댓글 삭제, 프로필 변경은 소유자 또는 로그인 조건을 확인한다.
|
||||||
|
|
||||||
|
## B5. recruit-form.jsp CSRF 폴백 갭 (P0-보안)
|
||||||
|
|
||||||
|
근거:
|
||||||
|
- `BibimbapCsrf` 미존재(비로그인 등) 시 fetch 폴백 브랜치에서 CSRF 토큰이 헤더에 포함되지 않는다. `src/main/webapp/WEB-INF/views/recruit-form.jsp:386-394`
|
||||||
|
- hidden `_csrf` input 부재 — form submit fallback도 보호 없음.
|
||||||
|
|
||||||
|
체크리스트:
|
||||||
|
- [ ] `recruit-form.jsp`에 `<input type="hidden" name="_csrf" value="...">` 추가한다.
|
||||||
|
- [ ] fetch 폴백 브랜치 headers에 `'X-CSRF-Token': csrfToken` 추가한다.
|
||||||
|
- [ ] 수정 후 CSRF 없는 POST가 거부되는 회귀 테스트를 추가한다.
|
||||||
|
|
||||||
|
완료 조건:
|
||||||
|
- [ ] `BibimbapCsrf` 미존재 시에도 recruit-form POST에 CSRF 토큰이 전송된다.
|
||||||
|
- [ ] 토큰 없는 요청이 403으로 거부된다.
|
||||||
|
|
||||||
|
> 발견: 세션 20260630-113258 JSP 프론트엔드 컴포넌트 검토.
|
||||||
|
|
|
||||||
19
pom.xml
19
pom.xml
|
|
@ -294,6 +294,25 @@
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</profile>
|
</profile>
|
||||||
|
<profile>
|
||||||
|
<id>nvd-api-key</id>
|
||||||
|
<activation>
|
||||||
|
<property>
|
||||||
|
<name>env.NVD_API_KEY</name>
|
||||||
|
</property>
|
||||||
|
</activation>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.owasp</groupId>
|
||||||
|
<artifactId>dependency-check-maven</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
</profiles>
|
</profiles>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,10 @@ public interface PostsMapper {
|
||||||
JOIN post_categories c ON c.id = p.category_id
|
JOIN post_categories c ON c.id = p.category_id
|
||||||
WHERE p.status = 'PUBLISHED'
|
WHERE p.status = 'PUBLISHED'
|
||||||
AND p.is_delete = false
|
AND p.is_delete = false
|
||||||
AND (#{categoryId} IS NULL OR p.category_id = #{categoryId})
|
AND (#{categoryId}::bigint IS NULL OR p.category_id = #{categoryId}::bigint)
|
||||||
AND (
|
AND (
|
||||||
#{cursorCreatedAt} IS NULL
|
#{cursorCreatedAt}::timestamptz IS NULL
|
||||||
OR (p.created_at, p.id) < (#{cursorCreatedAt}, #{cursorId})
|
OR (p.created_at, p.id) < (#{cursorCreatedAt}::timestamptz, #{cursorId}::bigint)
|
||||||
)
|
)
|
||||||
ORDER BY p.created_at DESC, p.id DESC
|
ORDER BY p.created_at DESC, p.id DESC
|
||||||
LIMIT #{limit}
|
LIMIT #{limit}
|
||||||
|
|
@ -119,7 +119,8 @@ public interface PostsMapper {
|
||||||
|
|
||||||
@Update("""
|
@Update("""
|
||||||
UPDATE posts
|
UPDATE posts
|
||||||
SET title = #{title},
|
SET category_id = #{categoryId},
|
||||||
|
title = #{title},
|
||||||
body_markdown = #{bodyMarkdown},
|
body_markdown = #{bodyMarkdown},
|
||||||
body_sanitized_html = #{bodySanitizedHtml},
|
body_sanitized_html = #{bodySanitizedHtml},
|
||||||
link_url = #{linkUrl},
|
link_url = #{linkUrl},
|
||||||
|
|
|
||||||
|
|
@ -67,81 +67,42 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.error-panel {
|
/* empty-state */
|
||||||
width: min(100%, 38rem);
|
.empty-state {
|
||||||
padding: 2rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
box-shadow: 0 2px 8px var(--card-shadow);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.error-panel__media {
|
.empty-state svg { display: block; margin: 0 auto; }
|
||||||
width: 8rem;
|
.empty-state .code {
|
||||||
height: 8rem;
|
display: block;
|
||||||
margin: 0 auto 1.5rem;
|
font-size: 12px;
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(160deg, var(--card-media-1) 0%, var(--card-media-2) 45%, var(--card-media-3) 100%);
|
|
||||||
}
|
|
||||||
.error-panel__logo {
|
|
||||||
width: 5rem;
|
|
||||||
height: auto;
|
|
||||||
object-fit: contain;
|
|
||||||
opacity: 0.9;
|
|
||||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
|
|
||||||
}
|
|
||||||
html[data-theme="dark"] .error-panel__logo {
|
|
||||||
opacity: 0.94;
|
|
||||||
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
|
|
||||||
}
|
|
||||||
.error-panel__eyebrow {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: .1em;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
letter-spacing: 0.02em;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
.error-panel__title {
|
.empty-state h3 {
|
||||||
margin: 0;
|
font-size: 1.35rem;
|
||||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
font-weight: 800;
|
||||||
line-height: 1.2;
|
|
||||||
letter-spacing: 0;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
margin: 8px 0 0;
|
||||||
}
|
}
|
||||||
.error-panel__description {
|
.empty-state p {
|
||||||
max-width: 28rem;
|
color: var(--text-muted);
|
||||||
margin: 1rem auto 0;
|
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: var(--text-muted);
|
margin: 12px 0 0;
|
||||||
}
|
}
|
||||||
.error-panel__meta {
|
.empty-actions {
|
||||||
margin: 1rem auto 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: rgba(0, 0, 0, 0.025);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: left;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
html[data-theme="dark"] .error-panel__meta {
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
.error-panel__actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.625rem;
|
gap: 10px;
|
||||||
margin-top: 1.5rem;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
.error-panel__button {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -157,29 +118,42 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
.error-panel__button--primary {
|
.btn:hover {
|
||||||
|
border-color: rgba(232, 165, 75, 0.45);
|
||||||
|
box-shadow: 0 4px 12px var(--card-shadow);
|
||||||
|
}
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
.btn-primary {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
color: var(--button-text);
|
color: var(--button-text);
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
.error-panel__button:hover {
|
.btn-ghost {
|
||||||
border-color: rgba(232, 165, 75, 0.45);
|
border-color: var(--border);
|
||||||
box-shadow: 0 4px 12px var(--card-shadow);
|
background: transparent;
|
||||||
}
|
}
|
||||||
.error-panel__button:active {
|
/* error meta detail */
|
||||||
transform: scale(0.98);
|
.error-meta {
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(0, 0, 0, 0.025);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
html[data-theme="dark"] .error-meta {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.page-main {
|
.page-main { align-items: flex-start; padding-top: 1.5rem; }
|
||||||
align-items: flex-start;
|
.btn { width: 100%; }
|
||||||
padding-top: 1.5rem;
|
|
||||||
}
|
|
||||||
.error-panel {
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
.error-panel__button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -193,25 +167,53 @@
|
||||||
if (message == null || String.valueOf(message).isBlank()) {
|
if (message == null || String.valueOf(message).isBlank()) {
|
||||||
message = exception != null ? exception.getMessage() : null;
|
message = exception != null ? exception.getMessage() : null;
|
||||||
}
|
}
|
||||||
|
int code = (statusCode != null) ? (Integer) statusCode : 0;
|
||||||
%>
|
%>
|
||||||
<main class="page-main">
|
<main class="page-main">
|
||||||
<section class="error-panel" aria-labelledby="error-title">
|
<div class="empty-state">
|
||||||
<div class="error-panel__media" aria-hidden="true">
|
<% if (code == 403) { %>
|
||||||
<img class="error-panel__logo" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
|
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true">
|
||||||
|
<rect x="24" y="38" width="38" height="30" rx="6" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
|
||||||
|
<path d="M31 38v-7a12 12 0 0 1 24 0v7" fill="none" stroke="#D4A853" stroke-width="2.5"/>
|
||||||
|
<circle cx="43" cy="50" r="4" fill="#D4A853"/>
|
||||||
|
<line x1="43" y1="54" x2="43" y2="60" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="code">ERROR 403</span>
|
||||||
|
<h3>로그인이 필요한 페이지예요</h3>
|
||||||
|
<p>이 작업은 로그인한 회원만 가능합니다. 로그인 후 다시 시도해 주세요.</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a class="btn btn-primary" href="<%= ctx %>/login">로그인</a>
|
||||||
|
<a class="btn btn-ghost" href="<%= ctx %>/">홈으로</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="error-panel__eyebrow">
|
<% } else if (code == 404) { %>
|
||||||
<% if (statusCode != null) { %>
|
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true">
|
||||||
ERROR <%= statusCode %>
|
<circle cx="40" cy="40" r="20" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
|
||||||
<% } else { %>
|
<line x1="54" y1="54" x2="68" y2="68" stroke="#D4A853" stroke-width="3" stroke-linecap="round"/>
|
||||||
ERROR
|
<line x1="33" y1="40" x2="47" y2="40" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
<% } %>
|
</svg>
|
||||||
</p>
|
<span class="code">ERROR 404</span>
|
||||||
<h1 class="error-panel__title" id="error-title">페이지를 불러오지 못했습니다</h1>
|
<h3>페이지를 찾을 수 없어요</h3>
|
||||||
<p class="error-panel__description">
|
<p>주소가 바뀌었거나 삭제된 페이지일 수 있어요. 주소를 확인하거나 홈으로 돌아가 주세요.</p>
|
||||||
요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도하거나 홈으로 돌아가 다른 메뉴를 이용해 주세요.
|
<div class="empty-actions">
|
||||||
</p>
|
<a class="btn btn-primary" href="<%= ctx %>/">홈으로</a>
|
||||||
|
<a class="btn btn-ghost" href="javascript:history.back()">이전 페이지</a>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="43" cy="43" r="24" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
|
||||||
|
<line x1="43" y1="32" x2="43" y2="46" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="43" cy="53" r="2" fill="#D4A853"/>
|
||||||
|
</svg>
|
||||||
|
<span class="code"><% if (statusCode != null) { %>ERROR <%= statusCode %><% } else { %>ERROR<% } %></span>
|
||||||
|
<h3>페이지를 불러오지 못했습니다</h3>
|
||||||
|
<p>요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도하거나 홈으로 돌아가 다른 메뉴를 이용해 주세요.</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a class="btn btn-primary" href="<%= ctx %>/">홈으로 이동</a>
|
||||||
|
<a class="btn btn-ghost" href="javascript:history.back()">이전 페이지</a>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
<% if (requestUri != null || message != null) { %>
|
<% if (requestUri != null || message != null) { %>
|
||||||
<div class="error-panel__meta" aria-label="오류 상세 정보">
|
<div class="error-meta" aria-label="오류 상세 정보">
|
||||||
<% if (requestUri != null) { %>
|
<% if (requestUri != null) { %>
|
||||||
<div>요청 경로: <%= htmlEscape(requestUri) %></div>
|
<div>요청 경로: <%= htmlEscape(requestUri) %></div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
@ -220,11 +222,7 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="error-panel__actions">
|
</div>
|
||||||
<a class="error-panel__button error-panel__button--primary" href="<%= ctx %>/">홈으로 이동</a>
|
|
||||||
<a class="error-panel__button" href="javascript:history.back()">이전 페이지</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<%@ page pageEncoding="UTF-8" %>
|
||||||
|
<%-- posts-empty.jspf — 포스팅 목록 빈 상태 파편
|
||||||
|
사용: posts-list.jsp의 posts.isEmpty() 분기에서 include
|
||||||
|
필요: bibimbap.css (.empty-state 클래스)
|
||||||
|
--%>
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" aria-hidden="true">
|
||||||
|
<rect x="20" y="13" width="44" height="57" rx="9" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
|
||||||
|
<line x1="30" y1="30" x2="54" y2="30" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<line x1="30" y1="40" x2="54" y2="40" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<line x1="30" y1="50" x2="45" y2="50" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="59" cy="57" r="13" fill="#D4A853"/>
|
||||||
|
<line x1="59" y1="51" x2="59" y2="63" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
<line x1="53" y1="57" x2="65" y2="57" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>아직 등록된 포스트가 없어요</h3>
|
||||||
|
<p>개발 일지, 팁, 질문 무엇이든 좋아요. 커뮤니티의 첫 글을 남겨보세요.</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a class="btn btn-primary" href="${pageContext.request.contextPath}/posts/new">+ 글쓰기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<%@ page pageEncoding="UTF-8" %>
|
||||||
|
<%-- recruit-empty.jspf — 팀원 모집 목록 빈 상태 파편
|
||||||
|
사용: recruit-list.jsp의 recruitPosts.isEmpty() 분기에서 include
|
||||||
|
필요: bibimbap.css (.empty-state 클래스)
|
||||||
|
--%>
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="92" height="84" viewBox="0 0 92 84" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="34" cy="29" r="11" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
|
||||||
|
<path d="M16 66c0-10 8-18 18-18s18 8 18 18" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="62" cy="33" r="8.5" fill="#fff" stroke="#E2C589" stroke-width="2.5"/>
|
||||||
|
<path d="M49 63c0-8 6-14 13-14s13 6 13 14" fill="none" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="74" cy="20" r="11" fill="#D4A853"/>
|
||||||
|
<line x1="74" y1="15" x2="74" y2="25" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
|
||||||
|
<line x1="69" y1="20" x2="79" y2="20" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<h3>아직 올라온 모집글이 없어요</h3>
|
||||||
|
<p>함께 만들 기획·아트·프로그래머를 찾고 있다면, 첫 모집글을 올려 팀을 시작해 보세요.</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a class="btn btn-primary" href="${pageContext.request.contextPath}/recruit/new">+ 모집글 작성</a>
|
||||||
|
<a class="btn btn-ghost" href="${pageContext.request.contextPath}/recruit">필터 초기화</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -998,6 +998,7 @@
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
background:
|
background:
|
||||||
|
|
@ -1018,6 +1019,7 @@
|
||||||
.game-reviews__radar {
|
.game-reviews__radar {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
gap: 1.75rem;
|
||||||
}
|
}
|
||||||
.game-reviews__radar svg {
|
.game-reviews__radar svg {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
@ -1056,6 +1058,18 @@
|
||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
animation: game-radar-fade 0.7s ease both;
|
animation: game-radar-fade 0.7s ease both;
|
||||||
}
|
}
|
||||||
|
.game-reviews__radar .game-axis-axislabel {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
fill: var(--text-muted);
|
||||||
|
}
|
||||||
|
.game-reviews__radar .game-axis-scorelabel {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
fill: var(--accent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
animation: game-radar-fade 0.7s ease both;
|
||||||
|
}
|
||||||
@keyframes game-radar-pop {
|
@keyframes game-radar-pop {
|
||||||
from { opacity: 0; transform: scale(0.55); }
|
from { opacity: 0; transform: scale(0.55); }
|
||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
|
@ -1068,61 +1082,38 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.game-reviews__axis-legend {
|
.game-reviews__title-meter {
|
||||||
margin: 0;
|
display: inline-flex;
|
||||||
flex: 1;
|
align-items: flex-end;
|
||||||
min-width: 11.5rem;
|
gap: 0.15rem;
|
||||||
display: grid;
|
height: 0.95rem;
|
||||||
gap: 0.4rem 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
}
|
||||||
.game-reviews__axis-legend-row {
|
.game-reviews__title-meter .stick {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-columns: 3.4rem 1fr 1.15rem;
|
width: 0.2rem;
|
||||||
align-items: center;
|
height: 100%;
|
||||||
gap: 0.55rem;
|
border-radius: 2px;
|
||||||
}
|
|
||||||
.game-reviews__axis-legend dt {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.game-reviews__axis-legend dd {
|
|
||||||
margin: 0;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.game-reviews__axis-meter {
|
|
||||||
height: 0.4rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.game-reviews__axis-meter span {
|
.game-reviews__title-meter .stick span {
|
||||||
display: block;
|
position: absolute;
|
||||||
height: 100%;
|
bottom: 0;
|
||||||
border-radius: 999px;
|
left: 0;
|
||||||
background: linear-gradient(90deg, #f0c978, var(--accent));
|
right: 0;
|
||||||
animation: game-meter-grow 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
|
border-radius: 2px;
|
||||||
transform-origin: left;
|
background: linear-gradient(180deg, #f0c978, var(--accent));
|
||||||
|
animation: game-title-meter-grow 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
transform-origin: bottom;
|
||||||
}
|
}
|
||||||
@keyframes game-meter-grow {
|
@keyframes game-title-meter-grow {
|
||||||
from { transform: scaleX(0); }
|
from { transform: scaleY(0); }
|
||||||
to { transform: scaleX(1); }
|
to { transform: scaleY(1); }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.game-reviews__radar .game-axis-poly,
|
.game-reviews__radar .game-axis-poly,
|
||||||
.game-reviews__radar .game-axis-dot,
|
.game-reviews__radar .game-axis-dot,
|
||||||
.game-reviews__axis-meter span { animation: none; }
|
.game-reviews__title-meter .stick span { animation: none; }
|
||||||
}
|
|
||||||
.game-reviews__card-radar {
|
|
||||||
margin-top: 0.6rem;
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
.game-reviews__card-radar svg {
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── sort 토글 / 더보기 ──────────────────────────────── */
|
/* ── sort 토글 / 더보기 ──────────────────────────────── */
|
||||||
|
|
@ -1604,7 +1595,7 @@
|
||||||
// ===== 육각형 레이더 SVG (요약/카드 공용) =====
|
// ===== 육각형 레이더 SVG (요약/카드 공용) =====
|
||||||
// scores: AXIS_KEYS 순 6값(1~5, null/0=데이터 없음). cx/cy/R 좌표.
|
// scores: AXIS_KEYS 순 6값(1~5, null/0=데이터 없음). cx/cy/R 좌표.
|
||||||
var hexSeq = 0; // svg 별 그라디언트 id 고유화
|
var hexSeq = 0; // svg 별 그라디언트 id 고유화
|
||||||
function buildHexRadar(scores, cx, cy, R) {
|
function buildHexRadar(scores, cx, cy, R, withLabels) {
|
||||||
var canvas = cx * 2; // cx==cy 가정. 요약 200 / 카드 120
|
var canvas = cx * 2; // cx==cy 가정. 요약 200 / 카드 120
|
||||||
var svg = document.createElementNS(SVG_NS, 'svg');
|
var svg = document.createElementNS(SVG_NS, 'svg');
|
||||||
svg.setAttribute('viewBox', '0 0 ' + canvas + ' ' + canvas);
|
svg.setAttribute('viewBox', '0 0 ' + canvas + ' ' + canvas);
|
||||||
|
|
@ -1650,16 +1641,18 @@
|
||||||
defs.appendChild(grad);
|
defs.appendChild(grad);
|
||||||
svg.appendChild(defs);
|
svg.appendChild(defs);
|
||||||
|
|
||||||
// 동심 그리드 (4단계, 외곽 강조)
|
// 동심 그리드 (5단계 = 1~5점 정수 등급과 1:1 대응, 외곽 강조)
|
||||||
for (var level = 1; level <= 4; level++) {
|
for (var level = 1; level <= 5; level++) {
|
||||||
var grid = document.createElementNS(SVG_NS, 'polygon');
|
var grid = document.createElementNS(SVG_NS, 'polygon');
|
||||||
grid.setAttribute('class', 'game-axis-grid' + (level === 4 ? ' game-axis-grid--outer' : ''));
|
grid.setAttribute('class', 'game-axis-grid' + (level === 5 ? ' game-axis-grid--outer' : ''));
|
||||||
(function (lv) {
|
(function (lv) {
|
||||||
grid.setAttribute('points', polyPoints(function () { return lv / 4; }));
|
grid.setAttribute('points', polyPoints(function () { return lv / 5; }));
|
||||||
})(level);
|
})(level);
|
||||||
svg.appendChild(grid);
|
svg.appendChild(grid);
|
||||||
}
|
}
|
||||||
// 축선
|
// 축선 + (withLabels 시) 축 이름 라벨 — 육각형만으론 어떤 축인지 식별 불가(§6.6)
|
||||||
|
// 6축 순서상 위치 고정: 0=위, 1=우상, 2=우하, 3=아래, 4=좌하, 5=좌상
|
||||||
|
var AXIS_LABEL_ANCHOR = ['middle', 'start', 'start', 'middle', 'end', 'end'];
|
||||||
AXIS_KEYS.forEach(function (k, i) {
|
AXIS_KEYS.forEach(function (k, i) {
|
||||||
var line = document.createElementNS(SVG_NS, 'line');
|
var line = document.createElementNS(SVG_NS, 'line');
|
||||||
line.setAttribute('class', 'game-axis-line');
|
line.setAttribute('class', 'game-axis-line');
|
||||||
|
|
@ -1669,6 +1662,18 @@
|
||||||
line.setAttribute('x2', p.x.toFixed(2));
|
line.setAttribute('x2', p.x.toFixed(2));
|
||||||
line.setAttribute('y2', p.y.toFixed(2));
|
line.setAttribute('y2', p.y.toFixed(2));
|
||||||
svg.appendChild(line);
|
svg.appendChild(line);
|
||||||
|
|
||||||
|
if (withLabels) {
|
||||||
|
var lp = ptOnAxis(i, 1.14);
|
||||||
|
var label = document.createElementNS(SVG_NS, 'text');
|
||||||
|
label.setAttribute('class', 'game-axis-axislabel');
|
||||||
|
label.setAttribute('x', lp.x.toFixed(2));
|
||||||
|
label.setAttribute('y', lp.y.toFixed(2));
|
||||||
|
label.setAttribute('text-anchor', AXIS_LABEL_ANCHOR[i]);
|
||||||
|
label.setAttribute('dominant-baseline', i === 0 ? 'text-after-edge' : (i === 3 ? 'text-before-edge' : 'middle'));
|
||||||
|
label.textContent = AXIS_LABELS_KO[k];
|
||||||
|
svg.appendChild(label);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// score 폴리곤 + 꼭지점 점 (데이터 있을 때만)
|
// score 폴리곤 + 꼭지점 점 (데이터 있을 때만)
|
||||||
if (hasData) {
|
if (hasData) {
|
||||||
|
|
@ -1679,13 +1684,27 @@
|
||||||
svg.appendChild(poly);
|
svg.appendChild(poly);
|
||||||
AXIS_KEYS.forEach(function (k, i) {
|
AXIS_KEYS.forEach(function (k, i) {
|
||||||
if (nums[i] <= 0) return;
|
if (nums[i] <= 0) return;
|
||||||
var p = ptOnAxis(i, nums[i] / 5);
|
var ratio = nums[i] / 5;
|
||||||
|
var p = ptOnAxis(i, ratio);
|
||||||
var dot = document.createElementNS(SVG_NS, 'circle');
|
var dot = document.createElementNS(SVG_NS, 'circle');
|
||||||
dot.setAttribute('class', 'game-axis-dot');
|
dot.setAttribute('class', 'game-axis-dot');
|
||||||
dot.setAttribute('cx', p.x.toFixed(2));
|
dot.setAttribute('cx', p.x.toFixed(2));
|
||||||
dot.setAttribute('cy', p.y.toFixed(2));
|
dot.setAttribute('cy', p.y.toFixed(2));
|
||||||
dot.setAttribute('r', Math.max(1.6, R * 0.05).toFixed(2));
|
dot.setAttribute('r', Math.max(1.6, R * 0.05).toFixed(2));
|
||||||
svg.appendChild(dot);
|
svg.appendChild(dot);
|
||||||
|
|
||||||
|
if (withLabels) {
|
||||||
|
// 꼭지점에서 살짝 바깥으로 띄워 점 위에 겹치지 않게 배치
|
||||||
|
var sp = ptOnAxis(i, ratio + 14 / R);
|
||||||
|
var score = document.createElementNS(SVG_NS, 'text');
|
||||||
|
score.setAttribute('class', 'game-axis-scorelabel');
|
||||||
|
score.setAttribute('x', sp.x.toFixed(2));
|
||||||
|
score.setAttribute('y', sp.y.toFixed(2));
|
||||||
|
score.setAttribute('text-anchor', AXIS_LABEL_ANCHOR[i]);
|
||||||
|
score.setAttribute('dominant-baseline', 'middle');
|
||||||
|
score.textContent = String(nums[i]);
|
||||||
|
svg.appendChild(score);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1701,28 +1720,29 @@
|
||||||
return { svg: svg, nums: nums, hasData: hasData };
|
return { svg: svg, nums: nums, hasData: hasData };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 시각 보조 <dl> (축명/미터바/점수) — 폴리곤만으로 종료 금지(§6.6)
|
// 리뷰 카드 제목행 삽입용 6축 미니 미터바 — axes 없으면 null(§6.6: 그래프는 요약에만, 카드는 이 미터바로 대체)
|
||||||
function buildRadarLegend(nums, hasData) {
|
function buildTitleMeter(axes) {
|
||||||
var dl = document.createElement('dl');
|
var nums = AXIS_KEYS.map(function (k) {
|
||||||
dl.className = 'game-reviews__axis-legend';
|
var v = axes ? Number(axes[k]) : 0;
|
||||||
AXIS_KEYS.forEach(function (k, i) {
|
return (isFinite(v) && v > 0) ? v : 0;
|
||||||
var row = document.createElement('div');
|
|
||||||
row.className = 'game-reviews__axis-legend-row';
|
|
||||||
var dt = document.createElement('dt');
|
|
||||||
dt.textContent = AXIS_LABELS_KO[k];
|
|
||||||
var meter = document.createElement('div');
|
|
||||||
meter.className = 'game-reviews__axis-meter';
|
|
||||||
var bar = document.createElement('span');
|
|
||||||
bar.style.width = (hasData ? (nums[i] / 5 * 100) : 0) + '%';
|
|
||||||
meter.appendChild(bar);
|
|
||||||
var dd = document.createElement('dd');
|
|
||||||
dd.textContent = hasData ? String(nums[i]) : '–';
|
|
||||||
row.appendChild(dt);
|
|
||||||
row.appendChild(meter);
|
|
||||||
row.appendChild(dd);
|
|
||||||
dl.appendChild(row);
|
|
||||||
});
|
});
|
||||||
return dl;
|
var hasData = nums.some(function (v) { return v > 0; });
|
||||||
|
if (!hasData) return null;
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'game-reviews__title-meter';
|
||||||
|
wrap.setAttribute('role', 'img');
|
||||||
|
wrap.setAttribute('aria-label', '6축 평가: ' + AXIS_KEYS.map(function (k, i) {
|
||||||
|
return AXIS_LABELS_KO[k] + ' ' + nums[i];
|
||||||
|
}).join(', '));
|
||||||
|
nums.forEach(function (v) {
|
||||||
|
var stick = document.createElement('span');
|
||||||
|
stick.className = 'stick';
|
||||||
|
var fill = document.createElement('span');
|
||||||
|
fill.style.height = (v / 5 * 100) + '%';
|
||||||
|
stick.appendChild(fill);
|
||||||
|
wrap.appendChild(stick);
|
||||||
|
});
|
||||||
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 별점 radiogroup 완전 패턴 (C6) =====
|
// ===== 별점 radiogroup 완전 패턴 (C6) =====
|
||||||
|
|
@ -2179,6 +2199,9 @@
|
||||||
mb.textContent = '내 리뷰';
|
mb.textContent = '내 리뷰';
|
||||||
head.appendChild(mb);
|
head.appendChild(mb);
|
||||||
}
|
}
|
||||||
|
// 6축 미니 미터바 — 제목행 노출(§6.6: 요약 그래프와 별개로 카드 단위 시각화)
|
||||||
|
var titleMeter = buildTitleMeter(r.axes);
|
||||||
|
if (titleMeter) head.appendChild(titleMeter);
|
||||||
var t = document.createElement('time');
|
var t = document.createElement('time');
|
||||||
t.className = 'game-reviews__time';
|
t.className = 'game-reviews__time';
|
||||||
t.dateTime = (r.updatedAt || r.createdAt) || '';
|
t.dateTime = (r.updatedAt || r.createdAt) || '';
|
||||||
|
|
@ -2200,25 +2223,6 @@
|
||||||
li.appendChild(p);
|
li.appendChild(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카드 컴팩트 육각형 (6축). axes null/누락 시 "데이터 없음"
|
|
||||||
var cardScores = AXIS_KEYS.map(function (k) { return r.axes ? Number(r.axes[k]) || 0 : 0; });
|
|
||||||
var cardRadar = buildHexRadar(cardScores, 60, 60, 44);
|
|
||||||
var radarWrap = document.createElement('div');
|
|
||||||
radarWrap.className = 'game-reviews__radar game-reviews__card-radar';
|
|
||||||
if (cardRadar.hasData) {
|
|
||||||
radarWrap.appendChild(cardRadar.svg);
|
|
||||||
// §6.6: 폴리곤만으로 종료 금지 — 시각/스크린리더 보조 표 동반(시각적으로 숨김)
|
|
||||||
var legend = buildRadarLegend(cardRadar.nums, true);
|
|
||||||
legend.className += ' sr-only';
|
|
||||||
radarWrap.appendChild(legend);
|
|
||||||
} else {
|
|
||||||
var none = document.createElement('span');
|
|
||||||
none.className = 'game-reviews__radar-empty';
|
|
||||||
none.textContent = '6축 평가 데이터 없음';
|
|
||||||
radarWrap.appendChild(none);
|
|
||||||
}
|
|
||||||
li.appendChild(radarWrap);
|
|
||||||
|
|
||||||
if (canActOn(r.userId)) {
|
if (canActOn(r.userId)) {
|
||||||
var foot = document.createElement('div');
|
var foot = document.createElement('div');
|
||||||
foot.className = 'game-reviews__footer';
|
foot.className = 'game-reviews__footer';
|
||||||
|
|
@ -2263,11 +2267,10 @@
|
||||||
if (rSummaryPanel && rSummaryRadar) {
|
if (rSummaryPanel && rSummaryRadar) {
|
||||||
rSummaryRadar.innerHTML = '';
|
rSummaryRadar.innerHTML = '';
|
||||||
var scores = AXIS_KEYS.map(function (k) { return summary.axes ? Number(summary.axes[k]) || 0 : 0; });
|
var scores = AXIS_KEYS.map(function (k) { return summary.axes ? Number(summary.axes[k]) || 0 : 0; });
|
||||||
var radar = buildHexRadar(scores, 100, 100, 80);
|
var radar = buildHexRadar(scores, 100, 100, 80, true);
|
||||||
rSummaryPanel.hidden = false;
|
rSummaryPanel.hidden = false;
|
||||||
if (radar.hasData) {
|
if (radar.hasData) {
|
||||||
rSummaryRadar.appendChild(radar.svg);
|
rSummaryRadar.appendChild(radar.svg);
|
||||||
rSummaryRadar.appendChild(buildRadarLegend(radar.nums, true));
|
|
||||||
} else {
|
} else {
|
||||||
var none = document.createElement('span');
|
var none = document.createElement('span');
|
||||||
none.className = 'game-reviews__radar-empty';
|
none.className = 'game-reviews__radar-empty';
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,18 @@
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
|
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
|
||||||
}
|
}
|
||||||
|
.game-req {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 900;
|
||||||
|
margin-left: 0.15em;
|
||||||
|
}
|
||||||
|
.game-preview__label {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.875rem 1rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
.game-check {
|
.game-check {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -362,7 +374,7 @@
|
||||||
<form class="game-form" action="<%= formAction %>" method="post" id="game-register-form" data-edit-mode="<%= editMode %>">
|
<form class="game-form" action="<%= formAction %>" method="post" id="game-register-form" data-edit-mode="<%= editMode %>">
|
||||||
<div class="game-form__grid">
|
<div class="game-form__grid">
|
||||||
<div class="game-field game-field--full">
|
<div class="game-field game-field--full">
|
||||||
<label class="game-field__label" for="game-name">게임 이름</label>
|
<label class="game-field__label" for="game-name">게임 이름 <span class="game-req">*</span></label>
|
||||||
<input class="game-field__input" type="text" id="game-name" name="name" maxlength="80" autocomplete="off" value="<%= editGameName %>" required />
|
<input class="game-field__input" type="text" id="game-name" name="name" maxlength="80" autocomplete="off" value="<%= editGameName %>" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="game-field">
|
<div class="game-field">
|
||||||
|
|
@ -370,7 +382,7 @@
|
||||||
<input class="game-field__input" type="url" id="game-git-url" name="gitUrl" placeholder="https://example.com/repository" autocomplete="url" value="<%= editGitUrl %>" />
|
<input class="game-field__input" type="url" id="game-git-url" name="gitUrl" placeholder="https://example.com/repository" autocomplete="url" value="<%= editGitUrl %>" />
|
||||||
</div>
|
</div>
|
||||||
<div class="game-field">
|
<div class="game-field">
|
||||||
<label class="game-field__label" for="game-webgl-zip">WebGL zip</label>
|
<label class="game-field__label" for="game-webgl-zip">WebGL zip <% if (!editMode) { %><span class="game-req">*</span><% } %></label>
|
||||||
<label class="game-file" for="game-webgl-zip">
|
<label class="game-file" for="game-webgl-zip">
|
||||||
<span class="game-file__action">zip 선택</span>
|
<span class="game-file__action">zip 선택</span>
|
||||||
<span class="game-file__name" id="game-webgl-file-name"><%= editMode && !editWebglPath.isBlank() ? "기존 WebGL 유지" : "선택된 파일 없음" %></span>
|
<span class="game-file__name" id="game-webgl-file-name"><%= editMode && !editWebglPath.isBlank() ? "기존 WebGL 유지" : "선택된 파일 없음" %></span>
|
||||||
|
|
@ -413,6 +425,7 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="game-preview" aria-label="미리보기">
|
<aside class="game-preview" aria-label="미리보기">
|
||||||
|
<p class="game-preview__label">미리보기</p>
|
||||||
<div class="game-preview__media">
|
<div class="game-preview__media">
|
||||||
<% if (editThumbnailUrl.isBlank()) { %>
|
<% if (editThumbnailUrl.isBlank()) { %>
|
||||||
<img id="preview-thumb" alt="" hidden />
|
<img id="preview-thumb" alt="" hidden />
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@
|
||||||
}
|
}
|
||||||
.home-action-button {
|
.home-action-button {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
padding: 0 0.875rem 0 0.625rem;
|
padding: 0 0.875rem 0 0.625rem;
|
||||||
border: 1px solid rgba(232, 165, 75, 0.32);
|
border: 1px solid rgba(232, 165, 75, 0.32);
|
||||||
|
|
@ -222,8 +223,15 @@
|
||||||
|
|
||||||
/* 검색 */
|
/* 검색 */
|
||||||
.search-section {
|
.search-section {
|
||||||
width: min(100%, 48rem);
|
width: 100%;
|
||||||
margin: 0 auto 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
|
}
|
||||||
|
/* 검색 폼 + 상세검색 패널을 한 컬럼으로 묶어 우측 끝선 정렬(#4) */
|
||||||
|
.search-stack {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.search-form {
|
.search-form {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -506,10 +514,10 @@
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 카드 그리드: 모바일 2열 → 태블릿 3열 → 데스크톱 4~5열 */
|
/* 카드 그리드: 콘텐츠 적응형(소량 카드 좌측 쏠림/우측 거대공백 완화) */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
@ -520,20 +528,9 @@
|
||||||
}
|
}
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (min-width: 900px) {
|
|
||||||
.card-grid {
|
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
.card-grid {
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.card {
|
a.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -560,11 +557,11 @@
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
/* 가로:세로 = 4:5 (포스터/썸네일에 흔한 비율, 3:5보다 덜 길쭉해 모바일 그리드에 균형 있음) */
|
/* 가로:세로 = 4:3 (가로형 썸네일 — 카드 높이 축소로 그리드 균형·스캔성 개선) */
|
||||||
.card__media {
|
.card__media {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 5;
|
aspect-ratio: 4 / 3;
|
||||||
background: var(--card-media-2);
|
background: var(--card-media-2);
|
||||||
}
|
}
|
||||||
.card__index {
|
.card__index {
|
||||||
|
|
@ -591,6 +588,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
}
|
}
|
||||||
/* 이미지 없음: 로고만 중앙 */
|
/* 이미지 없음: 로고만 중앙 */
|
||||||
.card__media-empty {
|
.card__media-empty {
|
||||||
|
|
@ -605,7 +603,7 @@
|
||||||
.card__logo-fallback {
|
.card__logo-fallback {
|
||||||
width: min(52%, 7.5rem);
|
width: min(52%, 7.5rem);
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 42%;
|
max-height: 50%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
opacity: 0.88;
|
opacity: 0.88;
|
||||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
|
||||||
|
|
@ -615,18 +613,18 @@
|
||||||
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
|
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
|
||||||
}
|
}
|
||||||
.card__body {
|
.card__body {
|
||||||
padding: 0.5rem 0.625rem 0.625rem;
|
padding: 0.65rem 0.75rem 0.7rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
.card__game-name {
|
.card__game-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 1.35;
|
line-height: 1.25;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
|
@ -644,13 +642,17 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.card__likes {
|
.card__likes {
|
||||||
margin: 0.15rem 0 0;
|
margin: auto 0 0;
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text-muted);
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
.card__likes b {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.card__badges {
|
.card__badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -762,9 +764,17 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
<main class="page-main">
|
<main class="page-main page">
|
||||||
|
<%-- 히어로: 서비스 소개 --%>
|
||||||
|
<p class="section-eyebrow">INDIE GAME HUB</p>
|
||||||
|
<h1 class="section-title" style="font-size:1.875rem">직접 만든 인디 게임을 올리고, 함께 즐기세요</h1>
|
||||||
|
<p class="section-desc">유니티 개발자들이 만든 웹게임을 플레이하고 리뷰를 남길 수 있어요.</p>
|
||||||
<section class="search-section" aria-label="게임·제작자 검색">
|
<section class="search-section" aria-label="게임·제작자 검색">
|
||||||
|
<%
|
||||||
|
boolean advOpen = !searchKeyword.isBlank() || !searchCreator.isBlank() || !"latest".equals(searchSort) || !selectedTagSlugs.isEmpty();
|
||||||
|
%>
|
||||||
<div class="home-toolbar">
|
<div class="home-toolbar">
|
||||||
|
<div class="search-stack">
|
||||||
<form class="search-form" role="search" action="<%= ctx %>/" method="get">
|
<form class="search-form" role="search" action="<%= ctx %>/" method="get">
|
||||||
<div class="search-form__field">
|
<div class="search-form__field">
|
||||||
<label class="search-form__label" for="q">게임·제작자 검색</label>
|
<label class="search-form__label" for="q">게임·제작자 검색</label>
|
||||||
|
|
@ -772,21 +782,6 @@
|
||||||
</div>
|
</div>
|
||||||
<button class="search-form__submit" type="submit">검색</button>
|
<button class="search-form__submit" type="submit">검색</button>
|
||||||
</form>
|
</form>
|
||||||
<% if (loggedIn) { %>
|
|
||||||
<a class="home-action-button" href="<%= ctx %>/game/new">
|
|
||||||
<span class="home-action-button__icon" aria-hidden="true">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 5v14"/>
|
|
||||||
<path d="M5 12h14"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span>신규 게임 개시</span>
|
|
||||||
</a>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
<%
|
|
||||||
boolean advOpen = !searchKeyword.isBlank() || !searchCreator.isBlank() || !"latest".equals(searchSort) || !selectedTagSlugs.isEmpty();
|
|
||||||
%>
|
|
||||||
<details class="advanced-search"<%= advOpen ? " open" : "" %>>
|
<details class="advanced-search"<%= advOpen ? " open" : "" %>>
|
||||||
<summary class="advanced-search__summary">상세 검색</summary>
|
<summary class="advanced-search__summary">상세 검색</summary>
|
||||||
<form class="advanced-search-form" role="search" action="<%= ctx %>/games/search" method="get">
|
<form class="advanced-search-form" role="search" action="<%= ctx %>/games/search" method="get">
|
||||||
|
|
@ -847,8 +842,28 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="search-history__chips" id="search-history-chips" role="list"></div>
|
<div class="search-history__chips" id="search-history-chips" role="list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /.search-stack -->
|
||||||
|
<% if (loggedIn) { %>
|
||||||
|
<a class="home-action-button" href="<%= ctx %>/game/new">
|
||||||
|
<span class="home-action-button__icon" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 5v14"/>
|
||||||
|
<path d="M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span>신규 게임 개시</span>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div><!-- /.home-toolbar -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%-- 정렬 칩 --%>
|
||||||
|
<div class="sort-bar">
|
||||||
|
<span class="sort-label">정렬</span>
|
||||||
|
<a class="chip <%= (searchSort == null || searchSort.isBlank() || "latest".equals(searchSort)) ? "chip--on" : "" %>" href="<%= ctx %>/?sort=latest">최신순</a>
|
||||||
|
<a class="chip <%= "likes".equals(searchSort) ? "chip--on" : "" %>" href="<%= ctx %>/?sort=likes">좋아요순</a>
|
||||||
|
<a class="chip <%= "views".equals(searchSort) ? "chip--on" : "" %>" href="<%= ctx %>/?sort=views">방문순</a>
|
||||||
|
</div>
|
||||||
<% if (showJamBanner) {
|
<% if (showJamBanner) {
|
||||||
String jamTitle = HtmlUtils.htmlEscape(activeJam.getTitle() == null || activeJam.getTitle().isBlank() ? "게임잼" : activeJam.getTitle());
|
String jamTitle = HtmlUtils.htmlEscape(activeJam.getTitle() == null || activeJam.getTitle().isBlank() ? "게임잼" : activeJam.getTitle());
|
||||||
String jamSlugParam = java.net.URLEncoder.encode(activeJam.getSlug(), java.nio.charset.StandardCharsets.UTF_8);
|
String jamSlugParam = java.net.URLEncoder.encode(activeJam.getSlug(), java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
|
@ -866,7 +881,7 @@
|
||||||
</section>
|
</section>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<section class="card-grid" aria-label="추천 목록">
|
<section class="card-grid game-grid" aria-label="추천 목록">
|
||||||
<% if (games.isEmpty()) { %>
|
<% if (games.isEmpty()) { %>
|
||||||
<% if (searching) { %>
|
<% if (searching) { %>
|
||||||
<div class="home-empty">"<%= escapedSearchQuery %>"에 대한 검색 결과가 없습니다.</div>
|
<div class="home-empty">"<%= escapedSearchQuery %>"에 대한 검색 결과가 없습니다.</div>
|
||||||
|
|
@ -892,7 +907,7 @@
|
||||||
thumbUrl = HtmlUtils.htmlEscape(thumbUrl);
|
thumbUrl = HtmlUtils.htmlEscape(thumbUrl);
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
<a class="card" href="<%= ctx %>/game/<%= game.getId() %>" aria-labelledby="<%= titleId %>">
|
<a class="card game-card" href="<%= ctx %>/game/<%= game.getId() %>" aria-labelledby="<%= titleId %>">
|
||||||
<div class="card__media">
|
<div class="card__media">
|
||||||
<span class="card__index" aria-hidden="true">#<%= game.getId() %></span>
|
<span class="card__index" aria-hidden="true">#<%= game.getId() %></span>
|
||||||
<% if (hasImage) { %>
|
<% if (hasImage) { %>
|
||||||
|
|
@ -918,7 +933,7 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<p class="card__likes">좋아요 <%= String.format("%,d", likeCount) %></p>
|
<p class="card__likes">좋아요 <b><%= String.format("%,d", likeCount) %></b></p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<%
|
<%
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,30 @@
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.pw-field {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pw-field .auth-field__input {
|
||||||
|
padding-right: 4rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.pw-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
color: #d32f2f;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -228,7 +252,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
<label class="auth-field__label" for="login-password">비밀번호</label>
|
<label class="auth-field__label" for="login-password">비밀번호</label>
|
||||||
<input class="auth-field__input" type="password" id="login-password" name="password" autocomplete="current-password" required />
|
<div class="pw-field">
|
||||||
|
<input class="auth-field__input" type="password" id="login-password" name="password" autocomplete="current-password" required />
|
||||||
|
<button type="button" class="pw-toggle"
|
||||||
|
onclick="var p=document.getElementById('login-password');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'보기':'숨기기'">보기</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-row">
|
<div class="auth-row">
|
||||||
<label class="auth-check">
|
<label class="auth-check">
|
||||||
|
|
@ -236,6 +264,7 @@
|
||||||
<span>로그인 유지</span>
|
<span>로그인 유지</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-error" id="login-form-error" hidden></div>
|
||||||
<button class="auth-button auth-button--primary" type="submit">로그인</button>
|
<button class="auth-button auth-button--primary" type="submit">로그인</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,7 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<% if (posts.isEmpty()) { %>
|
<% if (posts.isEmpty()) { %>
|
||||||
<div class="posts-empty">아직 등록된 포스트가 없습니다.</div>
|
<%@ include file="fragments/posts-empty.jspf" %>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<section class="posts-grid" aria-label="포스트 목록">
|
<section class="posts-grid" aria-label="포스트 목록">
|
||||||
<% for (PostData post : posts) { %>
|
<% for (PostData post : posts) { %>
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
.recruit-form,
|
.recruit-form,
|
||||||
.preview-card {
|
.rf-preview {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|
@ -75,26 +75,26 @@
|
||||||
.recruit-form {
|
.recruit-form {
|
||||||
padding: 1.35rem;
|
padding: 1.35rem;
|
||||||
}
|
}
|
||||||
.form-grid {
|
.rf-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
.field {
|
.rf-field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.field--full {
|
.rf-field--full {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
.field label {
|
.rf-field label {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.field input,
|
.rf-field input,
|
||||||
.field select,
|
.rf-field select,
|
||||||
.field textarea {
|
.rf-field textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
.field select {
|
.rf-field select {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding-right: 3rem;
|
padding-right: 3rem;
|
||||||
|
|
@ -115,38 +115,38 @@
|
||||||
background-position: 0 0, right 0.82rem center;
|
background-position: 0 0, right 0.82rem center;
|
||||||
background-size: 100% 100%, 1.05rem 1.05rem;
|
background-size: 100% 100%, 1.05rem 1.05rem;
|
||||||
}
|
}
|
||||||
.field select::-ms-expand {
|
.rf-field select::-ms-expand {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.field select:hover {
|
.rf-field select:hover {
|
||||||
border-color: rgba(232, 165, 75, 0.45);
|
border-color: rgba(232, 165, 75, 0.45);
|
||||||
}
|
}
|
||||||
.field select option {
|
.rf-field select option {
|
||||||
background: var(--field-bg);
|
background: var(--field-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.field input,
|
.rf-field input,
|
||||||
.field select {
|
.rf-field select {
|
||||||
height: 2.85rem;
|
height: 2.85rem;
|
||||||
padding: 0 0.8rem;
|
padding: 0 0.8rem;
|
||||||
}
|
}
|
||||||
.field select {
|
.rf-field select {
|
||||||
padding-right: 3rem;
|
padding-right: 3rem;
|
||||||
}
|
}
|
||||||
.field textarea {
|
.rf-field textarea {
|
||||||
min-height: 8rem;
|
min-height: 8rem;
|
||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.field input:focus,
|
.rf-field input:focus,
|
||||||
.field select:focus,
|
.rf-field select:focus,
|
||||||
.field textarea:focus {
|
.rf-field textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.22);
|
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.22);
|
||||||
}
|
}
|
||||||
.form-actions {
|
.rf-actions {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
@ -172,18 +172,34 @@
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
.preview-card {
|
.rf-req {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 900;
|
||||||
|
margin-left: 0.15em;
|
||||||
|
}
|
||||||
|
.rf-field-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
.rf-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.rf-preview {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 5rem;
|
top: 5rem;
|
||||||
padding: 1.1rem;
|
padding: 1.1rem;
|
||||||
}
|
}
|
||||||
.preview-card__label {
|
.rf-preview__label {
|
||||||
margin: 0 0 0.65rem;
|
margin: 0 0 0.65rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.preview-card__role {
|
.rf-preview__role {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 1.85rem;
|
min-height: 1.85rem;
|
||||||
|
|
@ -195,21 +211,21 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
.preview-card h2 {
|
.rf-preview h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.preview-card__line {
|
.rf-preview__line {
|
||||||
margin: 0.55rem 0 0;
|
margin: 0.55rem 0 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.preview-card__meta {
|
.rf-preview__meta {
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
@ -218,22 +234,22 @@
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
.preview-card__meta strong {
|
.rf-preview__meta strong {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.form-layout {
|
.form-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.preview-card {
|
.rf-preview {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.form-grid {
|
.rf-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.form-actions {
|
.rf-actions {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
.form-button {
|
.form-button {
|
||||||
|
|
@ -254,28 +270,31 @@
|
||||||
<div class="form-layout">
|
<div class="form-layout">
|
||||||
<section class="recruit-form" aria-label="팀원 모집글 입력">
|
<section class="recruit-form" aria-label="팀원 모집글 입력">
|
||||||
<form id="recruit-form" action="<%= ctx %>/recruit/new" method="post" novalidate>
|
<form id="recruit-form" action="<%= ctx %>/recruit/new" method="post" novalidate>
|
||||||
<div class="form-grid">
|
<div class="rf-grid">
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="project-name">프로젝트명</label>
|
<label for="project-name">프로젝트명 <span class="rf-req">*</span></label>
|
||||||
<input id="project-name" name="projectName" type="text" maxlength="80" placeholder="예: 달빛 정거장" required>
|
<input id="project-name" name="projectName" type="text" maxlength="80" placeholder="예: 달빛 정거장" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="genre">게임 장르</label>
|
<label for="genre">게임 장르</label>
|
||||||
<input id="genre" name="genre" type="text" maxlength="60" placeholder="예: 2D 어드벤처">
|
<input id="genre" name="genre" type="text" maxlength="60" placeholder="예: 2D 어드벤처">
|
||||||
</div>
|
</div>
|
||||||
<div class="field field--full">
|
<div class="rf-field rf-field--full">
|
||||||
<label for="summary">한 줄 소개</label>
|
<div class="rf-field-head">
|
||||||
<input id="summary" name="summary" type="text" maxlength="120" placeholder="프로젝트를 한 문장으로 소개해 주세요." required>
|
<label for="summary">한 줄 소개 <span class="rf-req">*</span></label><span class="rf-count"><span id="sc">0</span> / 120</span>
|
||||||
|
</div>
|
||||||
|
<input id="summary" name="summary" type="text" maxlength="120" placeholder="프로젝트를 한 문장으로 소개해 주세요." required
|
||||||
|
oninput="document.getElementById('sc').textContent=this.value.length">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="role">모집 역할</label>
|
<label for="role">모집 역할 <span class="rf-req">*</span></label>
|
||||||
<select id="role" name="role">
|
<select id="role" name="role">
|
||||||
<option selected>기획</option>
|
<option selected>기획</option>
|
||||||
<option>아트</option>
|
<option>아트</option>
|
||||||
<option>프로그래머</option>
|
<option>프로그래머</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="status">진행 상태</label>
|
<label for="status">진행 상태</label>
|
||||||
<select id="status" name="status">
|
<select id="status" name="status">
|
||||||
<option selected>아이디어 단계</option>
|
<option selected>아이디어 단계</option>
|
||||||
|
|
@ -284,7 +303,7 @@
|
||||||
<option>출시 준비 중</option>
|
<option>출시 준비 중</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="type">참여 방식</label>
|
<label for="type">참여 방식</label>
|
||||||
<select id="type" name="type">
|
<select id="type" name="type">
|
||||||
<option selected>취미</option>
|
<option selected>취미</option>
|
||||||
|
|
@ -293,36 +312,36 @@
|
||||||
<option>게임잼</option>
|
<option>게임잼</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="period">예상 기간</label>
|
<label for="period">예상 기간</label>
|
||||||
<input id="period" name="period" type="text" maxlength="40" placeholder="예: 약 3개월">
|
<input id="period" name="period" type="text" maxlength="40" placeholder="예: 약 3개월">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="team">현재 팀 구성</label>
|
<label for="team">현재 팀 구성</label>
|
||||||
<input id="team" name="team" type="text" maxlength="80" placeholder="예: 기획 1명, 프로그래머 1명">
|
<input id="team" name="team" type="text" maxlength="80" placeholder="예: 기획 1명, 프로그래머 1명">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="rf-field">
|
||||||
<label for="contact">연락 방법</label>
|
<label for="contact">연락 방법 <span class="rf-req">*</span></label>
|
||||||
<input id="contact" name="contact" type="text" maxlength="100" placeholder="이메일, 디스코드, 오픈채팅 등" required>
|
<input id="contact" name="contact" type="text" maxlength="100" placeholder="이메일, 디스코드, 오픈채팅 등" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field field--full">
|
<div class="rf-field rf-field--full">
|
||||||
<label for="description">상세 설명</label>
|
<label for="description">상세 설명</label>
|
||||||
<textarea id="description" name="description" maxlength="1200" placeholder="프로젝트 소개, 원하는 역할, 작업 방식 등을 적어 주세요."></textarea>
|
<textarea id="description" name="description" maxlength="1200" placeholder="프로젝트 소개, 원하는 역할, 작업 방식 등을 적어 주세요."></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="rf-actions">
|
||||||
<a class="form-button" href="<%= ctx %>/recruit">취소</a>
|
<a class="form-button" href="<%= ctx %>/recruit">취소</a>
|
||||||
<button class="form-button form-button--primary" type="submit">미리보기 완료</button>
|
<button class="form-button form-button--primary" type="submit">모집글 등록</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="preview-card" aria-label="모집글 미리보기">
|
<aside class="rf-preview" aria-label="모집글 미리보기">
|
||||||
<p class="preview-card__label">미리보기</p>
|
<p class="rf-preview__label">미리보기</p>
|
||||||
<span class="preview-card__role" id="preview-role">기획 모집</span>
|
<span class="rf-preview__role" id="preview-role">기획 모집</span>
|
||||||
<h2 id="preview-name">프로젝트 이름</h2>
|
<h2 id="preview-name">프로젝트 이름</h2>
|
||||||
<p class="preview-card__line" id="preview-summary">한 줄 소개가 여기에 표시됩니다.</p>
|
<p class="rf-preview__line" id="preview-summary">한 줄 소개가 여기에 표시됩니다.</p>
|
||||||
<ul class="preview-card__meta">
|
<ul class="rf-preview__meta">
|
||||||
<li><strong>장르</strong> <span id="preview-genre">장르 미정</span></li>
|
<li><strong>장르</strong> <span id="preview-genre">장르 미정</span></li>
|
||||||
<li><strong>상태</strong> <span id="preview-status">아이디어 단계</span></li>
|
<li><strong>상태</strong> <span id="preview-status">아이디어 단계</span></li>
|
||||||
<li><strong>참여</strong> <span id="preview-type">취미</span></li>
|
<li><strong>참여</strong> <span id="preview-type">취미</span></li>
|
||||||
|
|
@ -330,7 +349,7 @@
|
||||||
<li><strong>팀</strong> <span id="preview-team">팀 구성 미정</span></li>
|
<li><strong>팀</strong> <span id="preview-team">팀 구성 미정</span></li>
|
||||||
<li><strong>연락</strong> <span id="preview-contact">연락 방법 미정</span></li>
|
<li><strong>연락</strong> <span id="preview-contact">연락 방법 미정</span></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="preview-card__line" id="preview-description">상세 설명이 여기에 표시됩니다.</p>
|
<p class="rf-preview__line" id="preview-description">상세 설명이 여기에 표시됩니다.</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,17 @@
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
.recruit-hero__titles {
|
||||||
|
max-width: 40rem;
|
||||||
|
}
|
||||||
.recruit-hero__eyebrow {
|
.recruit-hero__eyebrow {
|
||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 900;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.recruit-hero h1 {
|
.recruit-hero h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -92,6 +98,13 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
/* 빈 상태(목록 0건)일 때 히어로 CTA 를 보조 스타일로 강등 — 화면당 골드 채움 CTA 1개(#9) */
|
||||||
|
.recruit-write--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.recruit-toolbar {
|
.recruit-toolbar {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -100,6 +113,7 @@
|
||||||
.recruit-search {
|
.recruit-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
max-width: 48rem;
|
||||||
}
|
}
|
||||||
.recruit-search input {
|
.recruit-search input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -248,12 +262,12 @@
|
||||||
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
<jsp:include page="/WEB-INF/views/header.jsp"/>
|
||||||
<main class="recruit-page">
|
<main class="recruit-page">
|
||||||
<section class="recruit-hero" aria-labelledby="recruit-title">
|
<section class="recruit-hero" aria-labelledby="recruit-title">
|
||||||
<div>
|
<div class="recruit-hero__titles">
|
||||||
<p class="recruit-hero__eyebrow">INDIE GAME TEAM</p>
|
<p class="recruit-hero__eyebrow">INDIE GAME TEAM</p>
|
||||||
<h1 id="recruit-title">함께 만들 팀원을 찾는 공간</h1>
|
<h1 id="recruit-title">함께 만들 팀원을 찾는 공간</h1>
|
||||||
<p>기획, 아트, 프로그래머가 작은 프로젝트부터 출시 목표 팀까지 가볍게 만나는 게시판입니다.</p>
|
<p>기획, 아트, 프로그래머가 작은 프로젝트부터 출시 목표 팀까지 가볍게 만나는 게시판입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="recruit-write" href="<%= ctx %>/recruit/new">모집글 작성</a>
|
<a class="recruit-write<%= recruitPosts.isEmpty() ? " recruit-write--ghost" : "" %>" href="<%= ctx %>/recruit/new">모집글 작성</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="recruit-toolbar" aria-label="모집글 검색 및 필터">
|
<section class="recruit-toolbar" aria-label="모집글 검색 및 필터">
|
||||||
|
|
@ -315,7 +329,10 @@
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
</section>
|
</section>
|
||||||
<div class="recruit-empty <%= recruitPosts.isEmpty() ? "is-visible" : "" %>" id="recruit-empty">아직 올라온 팀원 모집글이 없습니다.</div>
|
<% if (recruitPosts.isEmpty()) { %>
|
||||||
|
<%@ include file="fragments/recruit-empty.jspf" %>
|
||||||
|
<% } %>
|
||||||
|
<div class="recruit-empty" id="recruit-empty">아직 올라온 팀원 모집글이 없습니다.</div>
|
||||||
</main>
|
</main>
|
||||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||||
<script>
|
<script>
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,37 @@
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.pw-meter {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
height: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.pw-meter span {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.pw-meter[data-level="1"] span:nth-child(1) { background: #e53935; }
|
||||||
|
.pw-meter[data-level="2"] span:nth-child(1),
|
||||||
|
.pw-meter[data-level="2"] span:nth-child(2) { background: #fb8c00; }
|
||||||
|
.pw-meter[data-level="3"] span { background: #43a047; }
|
||||||
|
.form-ok {
|
||||||
|
color: #43a047;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
.form-error {
|
||||||
|
color: #d32f2f;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.help {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -233,11 +264,16 @@
|
||||||
<div class="auth-grid">
|
<div class="auth-grid">
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
<label class="auth-field__label" for="signup-password">비밀번호</label>
|
<label class="auth-field__label" for="signup-password">비밀번호</label>
|
||||||
<input class="auth-field__input" type="password" id="signup-password" name="password" autocomplete="new-password" minlength="8" required />
|
<input class="auth-field__input" type="password" id="signup-password" name="password" autocomplete="new-password" minlength="8" required
|
||||||
|
oninput="document.getElementById('signup-pw-meter').dataset.level=this.value.length>=10?3:this.value.length>=8?2:this.value?1:0" />
|
||||||
|
<div class="pw-meter" id="signup-pw-meter" data-level="0"><span></span><span></span><span></span></div>
|
||||||
|
<p class="help">8자 이상, 영문·숫자 조합을 권장합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-field">
|
<div class="auth-field">
|
||||||
<label class="auth-field__label" for="signup-password-confirm">비밀번호 확인</label>
|
<label class="auth-field__label" for="signup-password-confirm">비밀번호 확인</label>
|
||||||
<input class="auth-field__input" type="password" id="signup-password-confirm" name="passwordConfirm" autocomplete="new-password" minlength="8" required />
|
<input class="auth-field__input" type="password" id="signup-password-confirm" name="passwordConfirm" autocomplete="new-password" minlength="8" required
|
||||||
|
oninput="document.getElementById('signup-pw-ok').hidden=!this.value||this.value!==document.getElementById('signup-password').value" />
|
||||||
|
<p class="form-ok" id="signup-pw-ok" hidden>✓ 비밀번호가 일치합니다</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="auth-check">
|
<label class="auth-check">
|
||||||
|
|
@ -247,6 +283,7 @@
|
||||||
<a class="auth-link" href="<%= ctx %>/operation-policy" target="_blank" rel="noopener noreferrer">운영정책</a>에 동의합니다.
|
<a class="auth-link" href="<%= ctx %>/operation-policy" target="_blank" rel="noopener noreferrer">운영정책</a>에 동의합니다.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="form-error" id="signup-form-error" hidden></div>
|
||||||
<button class="auth-button auth-button--primary" type="submit">회원가입</button>
|
<button class="auth-button auth-button--primary" type="submit">회원가입</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,32 +64,68 @@
|
||||||
.policy-doc__body {
|
.policy-doc__body {
|
||||||
padding: 1.35rem;
|
padding: 1.35rem;
|
||||||
}
|
}
|
||||||
.policy-section + .policy-section {
|
/* terms layout */
|
||||||
margin-top: 1.55rem;
|
.terms {
|
||||||
padding-top: 1.55rem;
|
display: grid;
|
||||||
border-top: 1px solid var(--border);
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 28px;
|
||||||
}
|
}
|
||||||
.policy-section h2 {
|
.terms-toc {
|
||||||
margin: 0 0 0.7rem;
|
position: sticky;
|
||||||
|
top: 24px;
|
||||||
|
align-self: start;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.terms-toc .toc-title {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.terms-toc a {
|
||||||
|
display: block;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 7px 0 7px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.terms-toc a:hover { color: var(--text); }
|
||||||
|
.terms-toc a.on {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
.terms-body section {
|
||||||
|
padding: 18px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
scroll-margin-top: 24px;
|
||||||
|
}
|
||||||
|
.terms-body section:last-child { border-bottom: none; }
|
||||||
|
.terms-body h3 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
line-height: 1.35;
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
}
|
}
|
||||||
.policy-section p,
|
.terms-body p,
|
||||||
.policy-section li {
|
.terms-body li {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.925rem;
|
font-size: 0.925rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
.policy-section p {
|
.terms-body p { margin: 0.45rem 0 0; }
|
||||||
margin: 0.45rem 0 0;
|
.terms-body ul,
|
||||||
}
|
.terms-body ol {
|
||||||
.policy-section ul,
|
|
||||||
.policy-section ol {
|
|
||||||
margin: 0.6rem 0 0;
|
margin: 0.6rem 0 0;
|
||||||
padding-left: 1.2rem;
|
padding-left: 1.2rem;
|
||||||
}
|
}
|
||||||
.policy-section strong {
|
.terms-body strong { color: var(--text); }
|
||||||
color: var(--text);
|
@media (max-width: 720px) {
|
||||||
|
.terms { grid-template-columns: 1fr; }
|
||||||
|
.terms-toc { position: static; border-right: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -102,91 +138,113 @@
|
||||||
<p>시행일: 2026년 4월 16일</p>
|
<p>시행일: 2026년 4월 16일</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="policy-doc__body">
|
<div class="policy-doc__body">
|
||||||
<section class="policy-section">
|
<div class="terms">
|
||||||
<h2>제1조 (목적)</h2>
|
|
||||||
<p>본 약관은 개인 개발자(이하 "운영자")가 제공하는 웹 서비스 '비빔밥'(이하 "서비스")의 이용 조건 및 절차, 이용자와 운영자의 권리, 의무 및 책임 사항을 규정함을 목적으로 합니다.</p>
|
<%-- 목차 (앵커 링크) --%>
|
||||||
</section>
|
<nav class="terms-toc">
|
||||||
<section class="policy-section">
|
<div class="toc-title">목차</div>
|
||||||
<h2>제2조 (용어의 정의)</h2>
|
<a class="on" href="#a1">제1조 목적</a>
|
||||||
<ol>
|
<a href="#a2">제2조 용어의 정의</a>
|
||||||
<li>"서비스"란 운영자가 개발한 웹게임을 게재하고 이용자들이 소통할 수 있는 커뮤니티 공간을 제공하는 웹사이트를 의미합니다.</li>
|
<a href="#a3">제3조 약관의 게시와 개정</a>
|
||||||
<li>"이용자"란 본 약관에 따라 운영자가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.</li>
|
<a href="#a4">제4조 서비스 제공 및 변경</a>
|
||||||
<li>"회원"이란 서비스에 접속하여 본 약관에 동의하고 계정을 생성하여 서비스를 이용하는 자를 말합니다.</li>
|
<a href="#a5">제5조 회원가입 및 계정 관리</a>
|
||||||
</ol>
|
<a href="#a6">제6조 이용자의 의무</a>
|
||||||
</section>
|
<a href="#a7">제7조 게시물의 저작권</a>
|
||||||
<section class="policy-section">
|
<a href="#a8">제8조 이용 제한 및 해지</a>
|
||||||
<h2>제3조 (약관의 게시와 개정)</h2>
|
<a href="#a9">제9조 책임 제한</a>
|
||||||
<ol>
|
<a href="#a10">제10조 준거법 및 관할법원</a>
|
||||||
<li>운영자는 본 약관의 내용을 이용자가 쉽게 알 수 있도록 서비스 초기 화면에 게시합니다.</li>
|
</nav>
|
||||||
<li>운영자는 관련 법령을 위배하지 않는 범위에서 본 약관을 개정할 수 있습니다.</li>
|
|
||||||
<li>약관이 개정될 경우 적용 일자 및 개정 사유를 명시하여 현행 약관과 함께 서비스 내에 공지합니다.</li>
|
<%-- 본문 (조항) --%>
|
||||||
</ol>
|
<div class="terms-body">
|
||||||
</section>
|
<section id="a1">
|
||||||
<section class="policy-section">
|
<h3>제1조 (목적)</h3>
|
||||||
<h2>제4조 (서비스의 제공 및 변경)</h2>
|
<p>본 약관은 개인 개발자(이하 "운영자")가 제공하는 웹 서비스 '비빔밥'(이하 "서비스")의 이용 조건 및 절차, 이용자와 운영자의 권리, 의무 및 책임 사항을 규정함을 목적으로 합니다.</p>
|
||||||
<ol>
|
</section>
|
||||||
<li>서비스는 웹게임 게재 및 커뮤니티 기능을 기본으로 제공하며, 운영자의 판단에 따라 새로운 기능을 추가하거나 기존 기능을 변경할 수 있습니다.</li>
|
<section id="a2">
|
||||||
<li>본 서비스는 이용자에게 무료로 제공됩니다. 다만, 서비스 운영상 필요한 경우 일부 기능을 유료화하거나 광고를 게재할 수 있으며, 이 경우 사전에 공지합니다.</li>
|
<h3>제2조 (용어의 정의)</h3>
|
||||||
</ol>
|
<ol>
|
||||||
</section>
|
<li>"서비스"란 운영자가 개발한 웹게임을 게재하고 이용자들이 소통할 수 있는 커뮤니티 공간을 제공하는 웹사이트를 의미합니다.</li>
|
||||||
<section class="policy-section">
|
<li>"이용자"란 본 약관에 따라 운영자가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.</li>
|
||||||
<h2>제5조 (회원가입 및 계정 관리)</h2>
|
<li>"회원"이란 서비스에 접속하여 본 약관에 동의하고 계정을 생성하여 서비스를 이용하는 자를 말합니다.</li>
|
||||||
<ol>
|
</ol>
|
||||||
<li>이용자는 운영자가 정한 가입 양식에 따라 회원정보를 기입함으로써 회원가입을 신청합니다.</li>
|
</section>
|
||||||
<li>모든 회원은 반드시 본인의 정보를 제공하여야 하며, 타인의 정보를 도용할 경우 서비스 이용 제한 및 관련 법령에 따른 처벌을 받을 수 있습니다.</li>
|
<section id="a3">
|
||||||
<li>회원은 자신의 계정과 비밀번호를 관리할 책임이 있으며, 이를 제3자가 이용하도록 하여서는 안 됩니다.</li>
|
<h3>제3조 (약관의 게시와 개정)</h3>
|
||||||
</ol>
|
<ol>
|
||||||
</section>
|
<li>운영자는 본 약관의 내용을 이용자가 쉽게 알 수 있도록 서비스 초기 화면에 게시합니다.</li>
|
||||||
<section class="policy-section">
|
<li>운영자는 관련 법령을 위배하지 않는 범위에서 본 약관을 개정할 수 있습니다.</li>
|
||||||
<h2>제6조 (이용자의 의무 및 금지 행위)</h2>
|
<li>약관이 개정될 경우 적용 일자 및 개정 사유를 명시하여 현행 약관과 함께 서비스 내에 공지합니다.</li>
|
||||||
<p>이용자는 서비스 이용 시 다음 각호의 행위를 하여서는 안 됩니다.</p>
|
</ol>
|
||||||
<ol>
|
</section>
|
||||||
<li>서비스의 안정적인 운영을 방해할 목적으로 하는 데이터 마이닝, 크롤링, 또는 비정상적인 접근 행위</li>
|
<section id="a4">
|
||||||
<li>타인을 비방하거나 명예를 훼손하는 게시물을 작성하는 행위</li>
|
<h3>제4조 (서비스의 제공 및 변경)</h3>
|
||||||
<li>타인의 저작권 등 지식재산권을 침해하는 행위 (저작권 침해 게임물이나 게시물 게재 등)</li>
|
<ol>
|
||||||
<li>공공질서 및 미풍양속에 반하는 정보를 유포하는 행위</li>
|
<li>서비스는 웹게임 게재 및 커뮤니티 기능을 기본으로 제공하며, 운영자의 판단에 따라 새로운 기능을 추가하거나 기존 기능을 변경할 수 있습니다.</li>
|
||||||
<li>기타 관계 법령 및 운영정책에 위배되는 행위</li>
|
<li>본 서비스는 이용자에게 무료로 제공됩니다. 다만, 서비스 운영상 필요한 경우 일부 기능을 유료화하거나 광고를 게재할 수 있으며, 이 경우 사전에 공지합니다.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
<section class="policy-section">
|
<section id="a5">
|
||||||
<h2>제7조 (게시물의 저작권)</h2>
|
<h3>제5조 (회원가입 및 계정 관리)</h3>
|
||||||
<ol>
|
<ol>
|
||||||
<li>회원이 서비스 내에 게시한 게시물의 저작권은 해당 게시물의 저작자에게 귀속됩니다.</li>
|
<li>이용자는 운영자가 정한 가입 양식에 따라 회원정보를 기입함으로써 회원가입을 신청합니다.</li>
|
||||||
<li>운영자는 서비스의 홍보 및 운영 목적으로 회원의 게시물을 노출하거나 복제, 수정하여 사용할 수 있습니다.</li>
|
<li>모든 회원은 반드시 본인의 정보를 제공하여야 하며, 타인의 정보를 도용할 경우 서비스 이용 제한 및 관련 법령에 따른 처벌을 받을 수 있습니다.</li>
|
||||||
<li>회원은 서비스에 저작권 문제가 있는 게시물을 올려서는 안 되며, 이로 인해 발생하는 법적 책임은 게시자 본인에게 있습니다.</li>
|
<li>회원은 자신의 계정과 비밀번호를 관리할 책임이 있으며, 이를 제3자가 이용하도록 하여서는 안 됩니다.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
<section class="policy-section">
|
<section id="a6">
|
||||||
<h2>제8조 (서비스 이용 제한 및 해지)</h2>
|
<h3>제6조 (이용자의 의무 및 금지 행위)</h3>
|
||||||
<ol>
|
<p>이용자는 서비스 이용 시 다음 각호의 행위를 하여서는 안 됩니다.</p>
|
||||||
<li>회원은 언제든지 서비스 내 설정 메뉴를 통해 회원 탈퇴를 신청할 수 있습니다.</li>
|
<ol>
|
||||||
<li>운영자는 회원이 제6조의 금지 행위를 한 경우, 사전 통보 없이 서비스 이용을 일시 정지하거나 계정을 삭제할 수 있습니다.</li>
|
<li>서비스의 안정적인 운영을 방해할 목적으로 하는 데이터 마이닝, 크롤링, 또는 비정상적인 접근 행위</li>
|
||||||
</ol>
|
<li>타인을 비방하거나 명예를 훼손하는 게시물을 작성하는 행위</li>
|
||||||
</section>
|
<li>타인의 저작권 등 지식재산권을 침해하는 행위 (저작권 침해 게임물이나 게시물 게재 등)</li>
|
||||||
<section class="policy-section">
|
<li>공공질서 및 미풍양속에 반하는 정보를 유포하는 행위</li>
|
||||||
<h2>제9조 (책임 제한)</h2>
|
<li>기타 관계 법령 및 운영정책에 위배되는 행위</li>
|
||||||
<ol>
|
</ol>
|
||||||
<li>운영자는 천재지변, 서버 점검, 네트워크 장애 등 불가항력적인 사유로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</li>
|
</section>
|
||||||
<li>운영자는 이용자 간 또는 이용자와 제3자 상호 간에 서비스를 매개로 하여 발생한 분쟁에 대해 개입할 의무가 없으며 이로 인한 손해를 배상할 책임이 없습니다.</li>
|
<section id="a7">
|
||||||
</ol>
|
<h3>제7조 (게시물의 저작권)</h3>
|
||||||
</section>
|
<ol>
|
||||||
<section class="policy-section">
|
<li>회원이 서비스 내에 게시한 게시물의 저작권은 해당 게시물의 저작자에게 귀속됩니다.</li>
|
||||||
<h2>제10조 (준거법 및 관할법원)</h2>
|
<li>운영자는 서비스의 홍보 및 운영 목적으로 회원의 게시물을 노출하거나 복제, 수정하여 사용할 수 있습니다.</li>
|
||||||
<ol>
|
<li>회원은 서비스에 저작권 문제가 있는 게시물을 올려서는 안 되며, 이로 인해 발생하는 법적 책임은 게시자 본인에게 있습니다.</li>
|
||||||
<li>본 약관과 관련하여 발생한 분쟁에 대해서는 대한민국 법을 준거법으로 합니다.</li>
|
</ol>
|
||||||
<li>서비스 이용으로 발생한 분쟁에 대해 소송이 제기될 경우 <strong>서울중앙지방법원</strong>을 전속 관할법원으로 합니다.</li>
|
</section>
|
||||||
</ol>
|
<section id="a8">
|
||||||
</section>
|
<h3>제8조 (서비스 이용 제한 및 해지)</h3>
|
||||||
<section class="policy-section">
|
<ol>
|
||||||
<h2>부칙</h2>
|
<li>회원은 언제든지 서비스 내 설정 메뉴를 통해 회원 탈퇴를 신청할 수 있습니다.</li>
|
||||||
<p>본 약관은 2026년 4월 16일부터 시행됩니다.</p>
|
<li>운영자는 회원이 제6조의 금지 행위를 한 경우, 사전 통보 없이 서비스 이용을 일시 정지하거나 계정을 삭제할 수 있습니다.</li>
|
||||||
</section>
|
</ol>
|
||||||
<section class="policy-section">
|
</section>
|
||||||
<h2>문의처</h2>
|
<section id="a9">
|
||||||
<ul>
|
<h3>제9조 (책임 제한)</h3>
|
||||||
<li>이메일: artbiit@naver.com</li>
|
<ol>
|
||||||
</ul>
|
<li>운영자는 천재지변, 서버 점검, 네트워크 장애 등 불가항력적인 사유로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</li>
|
||||||
</section>
|
<li>운영자는 이용자 간 또는 이용자와 제3자 상호 간에 서비스를 매개로 하여 발생한 분쟁에 대해 개입할 의무가 없으며 이로 인한 손해를 배상할 책임이 없습니다.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section id="a10">
|
||||||
|
<h3>제10조 (준거법 및 관할법원)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>본 약관과 관련하여 발생한 분쟁에 대해서는 대한민국 법을 준거법으로 합니다.</li>
|
||||||
|
<li>서비스 이용으로 발생한 분쟁에 대해 소송이 제기될 경우 <strong>서울중앙지방법원</strong>을 전속 관할법원으로 합니다.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>부칙</h3>
|
||||||
|
<p>본 약관은 2026년 4월 16일부터 시행됩니다.</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h3>문의처</h3>
|
||||||
|
<ul>
|
||||||
|
<li>이메일: artbiit@naver.com</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
String themeCsrfToken = CsrfTokens.getOrCreate(request.getSession());
|
String themeCsrfToken = CsrfTokens.getOrCreate(request.getSession());
|
||||||
%>
|
%>
|
||||||
<meta name="csrf-token" content="<%= HtmlUtils.htmlEscape(themeCsrfToken) %>">
|
<meta name="csrf-token" content="<%= HtmlUtils.htmlEscape(themeCsrfToken) %>">
|
||||||
|
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,394 @@
|
||||||
|
/* ============================================================
|
||||||
|
bibimbap — 공통 스타일
|
||||||
|
외부 라이브러리 없음 · 순수 CSS · JSP 전역 적용용
|
||||||
|
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- 1. 디자인 토큰 ---------- */
|
||||||
|
:root {
|
||||||
|
/* 색상 */
|
||||||
|
--color-bg: #F5F0E8; /* 페이지 배경 */
|
||||||
|
--color-card: #FFFFFF; /* 카드 표면 */
|
||||||
|
--color-field-bg: #FBF8F2; /* 입력란 배경 */
|
||||||
|
--color-accent: #e8a54b; /* 골든 액센트(주요 버튼) — 브랜드 골드 통일 */
|
||||||
|
--color-accent-strong: #C2912F; /* 링크·강조 텍스트 */
|
||||||
|
--color-accent-soft: #FBEFD6; /* 액센트 옅은 배경(뱃지) */
|
||||||
|
--color-accent-press: #d8973a; /* 버튼 hover/active */
|
||||||
|
|
||||||
|
--color-text: #241F18; /* 본문 텍스트 */
|
||||||
|
--color-text-muted: #6E6555; /* 보조 텍스트 */
|
||||||
|
--color-text-faint: #9A8F7C; /* placeholder·메타 */
|
||||||
|
--color-on-accent: #3A2E15; /* 골든 버튼 위 글자색 */
|
||||||
|
|
||||||
|
--color-border: #E7DECE; /* 기본 보더 */
|
||||||
|
--color-border-soft: #EDE4D4; /* 구분선 */
|
||||||
|
|
||||||
|
--color-danger: #B24A2C; /* 에러 텍스트 */
|
||||||
|
--color-danger-bg: #FBE6DF; /* 에러 배경 */
|
||||||
|
--color-ok: #4E7A48; /* 성공 텍스트 */
|
||||||
|
--color-ok-bg: #E4F0E0; /* 성공 배경 */
|
||||||
|
|
||||||
|
/* 모양 */
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 18px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
|
/* 타이포 */
|
||||||
|
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 1-D. 다크 모드 토큰 오버라이드 ---------- */
|
||||||
|
/* 값 출처: header.jsp html[data-theme="dark"] .site-header 블록
|
||||||
|
--header-bg: #1a1a1a → --color-bg
|
||||||
|
--header-text: #f5f0e8 → --color-text
|
||||||
|
--header-muted: rgba(245,240,232,.65) → --color-text-muted / --color-text-faint
|
||||||
|
--header-border: rgba(255,255,255,.06) → --color-border / --color-border-soft
|
||||||
|
card/field-bg: header-bg 보다 소폭 밝은 표면
|
||||||
|
accent: 라이트와 동일 유지 */
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
--color-bg: #1a1a1a; /* header --header-bg */
|
||||||
|
--color-card: #222018; /* 표면 — header-bg 보다 살짝 밝음 */
|
||||||
|
--color-field-bg: #1e1c14; /* 입력란 — bg 보다 살짝 어두움 */
|
||||||
|
|
||||||
|
--color-text: #f5f0e8; /* header --header-text */
|
||||||
|
--color-text-muted: rgba(245, 240, 232, 0.65); /* header --header-muted */
|
||||||
|
--color-text-faint: rgba(245, 240, 232, 0.40); /* muted 보다 더 희미 */
|
||||||
|
--color-on-accent: #3A2E15; /* 골든 버튼 위 글자 — 라이트와 동일 */
|
||||||
|
|
||||||
|
--color-border: rgba(255, 255, 255, 0.20); /* 비텍스트 대비 3:1 확보(WCAG 1.4.11) */
|
||||||
|
--color-border-soft: rgba(255, 255, 255, 0.06); /* header --header-border */
|
||||||
|
|
||||||
|
--color-accent: #e8a54b; /* 브랜드 골드 — 라이트와 동일 */
|
||||||
|
--color-accent-strong: #e8a54b; /* header --accent (다크에서 밝게) */
|
||||||
|
--color-accent-soft: rgba(212, 168, 83, 0.15); /* 옅은 골든 배경 */
|
||||||
|
--color-accent-press: #d8973a; /* 라이트와 동일 */
|
||||||
|
|
||||||
|
--color-danger: #e07050; /* 다크에서 가독 보강 */
|
||||||
|
--color-danger-bg: rgba(178, 74, 44, 0.18);
|
||||||
|
--color-ok: #6aad60; /* 다크에서 가독 보강 */
|
||||||
|
--color-ok-bg: rgba(78, 122, 72, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. 베이스 ---------- */
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
color: var(--color-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
::selection { background: #F1D79B; }
|
||||||
|
|
||||||
|
/* 키보드 포커스 링 (마우스 클릭 시엔 미표시) */
|
||||||
|
.btn:focus-visible,
|
||||||
|
.chip:focus-visible,
|
||||||
|
.link:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* main 영역 공통 컨테이너(선택) */
|
||||||
|
.page { max-width: 1120px; margin: 0 auto; padding: 48px 24px; }
|
||||||
|
.page--center { display: flex; justify-content: center; }
|
||||||
|
|
||||||
|
/* ---------- 3. 버튼 ---------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 13px 24px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s ease, border-color .12s ease;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--color-accent-press); border-color: var(--color-accent-press); }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: var(--color-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--color-field-bg); }
|
||||||
|
|
||||||
|
.btn--block { width: 100%; }
|
||||||
|
.btn--lg { padding: 15px 28px; font-size: 16px; }
|
||||||
|
|
||||||
|
/* 비활성 (입력 전 등) */
|
||||||
|
.btn:disabled,
|
||||||
|
.btn[disabled],
|
||||||
|
button:disabled {
|
||||||
|
background: var(--color-field-bg);
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 4. 폼 요소 ---------- */
|
||||||
|
.field { margin-top: 16px; }
|
||||||
|
.field:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.field-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.label { font-weight: 600; font-size: 13px; color: var(--color-text-muted); }
|
||||||
|
.req { color: var(--color-danger); }
|
||||||
|
.count { font-family: var(--font-mono); font-size: 12px; color: var(--color-text-faint); }
|
||||||
|
|
||||||
|
.input,
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-field-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.textarea { min-height: 100px; resize: vertical; line-height: 1.6; }
|
||||||
|
.input::placeholder,
|
||||||
|
.textarea::placeholder { color: var(--color-text-faint); }
|
||||||
|
.input:focus,
|
||||||
|
.textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 도움말 / 검증 메시지 */
|
||||||
|
.help { font-size: 12px; color: var(--color-text-faint); margin: 6px 0 0; }
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
}
|
||||||
|
.form-ok {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-ok);
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 5. 비밀번호 필드 (보기 토글) ---------- */
|
||||||
|
.pw-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-field-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 11px 14px;
|
||||||
|
}
|
||||||
|
.pw-field:focus-within {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-accent);
|
||||||
|
}
|
||||||
|
.pw-field input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.pw-toggle {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0 0 10px;
|
||||||
|
}
|
||||||
|
.pw-toggle:hover { color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
/* 비밀번호 강도 막대 — input의 data-level(0~3)에 반응 */
|
||||||
|
.pw-meter { display: flex; gap: 6px; margin-top: 8px; }
|
||||||
|
.pw-meter span { flex: 1; height: 5px; border-radius: 3px; background: #E2D9C8; }
|
||||||
|
.pw-meter[data-level="1"] span:nth-child(1) { background: var(--color-danger); }
|
||||||
|
.pw-meter[data-level="2"] span:nth-child(-n+2) { background: var(--color-accent); }
|
||||||
|
.pw-meter[data-level="3"] span { background: var(--color-ok); }
|
||||||
|
|
||||||
|
/* ---------- 6. 칩 (정렬·필터) ---------- */
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 6px 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover { background: var(--color-field-bg); }
|
||||||
|
.chip--on {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
background: #F1D79B;
|
||||||
|
border-color: #F1D79B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 7. 카드 ---------- */
|
||||||
|
.card {
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 8. Empty / Error State ---------- */
|
||||||
|
.empty-state {
|
||||||
|
background: var(--color-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: clamp(40px, 6vw, 64px) 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-state svg { display: block; margin: 0 auto; }
|
||||||
|
.empty-state .code {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: .16em;
|
||||||
|
color: var(--color-accent-strong);
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 18px 0 0;
|
||||||
|
}
|
||||||
|
.empty-state .code + h3 { margin-top: 8px; }
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 8px auto 0;
|
||||||
|
max-width: 400px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.empty-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 22px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 9. 보조 링크 ---------- */
|
||||||
|
.link { color: var(--color-accent-strong); text-decoration: none; font-weight: 600; }
|
||||||
|
.link:hover { text-decoration: underline; }
|
||||||
|
.link-quiet { color: var(--color-text-faint); text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
화면별 레이아웃 컴포넌트 (홈 / 게임상세 / 약관 / 프로필 / 폼)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- 10. 페이지 헤더(섹션 제목) ---------- */
|
||||||
|
.section-eyebrow { font-family: var(--font-mono); font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--color-accent-strong); }
|
||||||
|
.section-title { font-size: 28px; font-weight: 800; letter-spacing: -.02em; margin: 6px 0 0; color: var(--color-text); }
|
||||||
|
.section-desc { color: var(--color-text-muted); margin: 8px 0 0; line-height: 1.6; }
|
||||||
|
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ---------- 11. 홈 검색 / 정렬 / 게임 그리드 ---------- */
|
||||||
|
.search-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
|
||||||
|
.search-row .input { flex: 1; min-width: 240px; }
|
||||||
|
.sort-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
|
||||||
|
.sort-label { font-size: 12px; font-weight: 700; letter-spacing: .02em; color: var(--color-text-muted); margin-right: 4px; }
|
||||||
|
|
||||||
|
.game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
|
||||||
|
.game-card { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow: hidden; text-decoration: none; color: inherit; display: block; }
|
||||||
|
.game-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.06); }
|
||||||
|
.game-card .thumb { position: relative; height: 140px; background: linear-gradient(135deg, #EDE3D2, #E2D4BC); display: flex; align-items: center; justify-content: center; }
|
||||||
|
.game-card .thumb .num { position: absolute; top: 10px; left: 10px; font-family: var(--font-mono); font-size: 11px; background: #fff; border-radius: 6px; padding: 3px 8px; color: var(--color-on-accent); }
|
||||||
|
.game-card .body { padding: 14px; }
|
||||||
|
.game-card .body .name { font-weight: 700; }
|
||||||
|
.game-card .body .sub { font-size: 13px; color: var(--color-text-faint); margin-top: 3px; }
|
||||||
|
|
||||||
|
.empty-card { border: 1px dashed #CBBE9E; border-radius: var(--radius-lg); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px; color: #9A8B6E; min-height: 206px; text-decoration: none; }
|
||||||
|
.empty-card .plus { font-size: 30px; line-height: 1; }
|
||||||
|
.empty-card .t { font-weight: 700; color: #7A6B4D; margin-top: 8px; }
|
||||||
|
.empty-card .s { font-size: 12.5px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ---------- 12. 게임 상세 ---------- */
|
||||||
|
.detail-meta { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; margin-top: 12px; font-size: 14px; color: var(--color-text-muted); }
|
||||||
|
.detail-meta .sep { color: #D8CDB8; }
|
||||||
|
.detail-meta .muted { color: var(--color-text-faint); }
|
||||||
|
.badge-soft { background: var(--color-accent-soft); color: #A9781F; border-radius: var(--radius-sm); padding: 6px 12px; font-weight: 600; }
|
||||||
|
|
||||||
|
.player { position: relative; aspect-ratio: 16 / 9; background: #15110C; border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; overflow: hidden; margin-top: 16px; }
|
||||||
|
.play-btn { background: var(--color-accent); color: var(--color-on-accent); font-weight: 700; border: none; border-radius: var(--radius-pill); padding: 13px 28px; font-size: 15px; cursor: pointer; }
|
||||||
|
.player .tag { position: absolute; top: 12px; right: 12px; font-family: var(--font-mono); font-size: 11px; background: rgba(255,255,255,.15); color: #fff; border-radius: 6px; padding: 4px 9px; }
|
||||||
|
.player .fs { position: absolute; bottom: 12px; right: 12px; font-size: 12px; background: rgba(255,255,255,.14); color: #fff; border-radius: var(--radius-sm); padding: 6px 12px; cursor: pointer; }
|
||||||
|
.review-cta { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 24px; text-align: center; margin-top: 16px; }
|
||||||
|
.review-cta strong { color: var(--color-text); }
|
||||||
|
.review-cta p { font-size: 13.5px; color: var(--color-text-muted); margin: 4px 0 0; }
|
||||||
|
|
||||||
|
/* ---------- 13. 별점 입력 (순수 CSS hover, JS 없음) ---------- */
|
||||||
|
.rating { display: inline-flex; flex-direction: row-reverse; }
|
||||||
|
.rating input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.rating label { font-size: 28px; line-height: 1; color: #E2D9C8; cursor: pointer; padding: 0 1px; }
|
||||||
|
.rating label:hover,
|
||||||
|
.rating label:hover ~ label,
|
||||||
|
.rating input:checked ~ label { color: var(--color-accent); }
|
||||||
|
|
||||||
|
/* ---------- 14. 이용약관 (목차 + 본문) ---------- */
|
||||||
|
.terms { display: grid; grid-template-columns: 200px 1fr; gap: 28px; }
|
||||||
|
.terms-toc { position: sticky; top: 24px; align-self: start; border-right: 1px solid var(--color-border-soft); padding-right: 8px; }
|
||||||
|
.terms-toc .toc-title { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; color: var(--color-text-faint); margin-bottom: 10px; }
|
||||||
|
.terms-toc a { display: block; font-size: 13.5px; color: var(--color-text-muted); padding: 7px 0 7px 14px; text-decoration: none; }
|
||||||
|
.terms-toc a:hover { color: var(--color-text); }
|
||||||
|
.terms-toc a.on { color: var(--color-on-accent); font-weight: 700; border-left: 2px solid var(--color-accent); padding-left: 12px; }
|
||||||
|
.terms-body section { padding: 18px 0; border-bottom: 1px solid var(--color-border-soft); scroll-margin-top: 24px; }
|
||||||
|
.terms-body section:last-child { border-bottom: none; }
|
||||||
|
.terms-body h3 { font-size: 17px; font-weight: 800; color: var(--color-text); margin: 0; }
|
||||||
|
.terms-body p { margin: 8px 0 0; line-height: 1.7; color: #4A4234; font-size: 14px; }
|
||||||
|
@media (max-width: 720px) { .terms { grid-template-columns: 1fr; } .terms-toc { position: static; border-right: none; } }
|
||||||
|
|
||||||
|
/* ---------- 15. 프로필 ---------- */
|
||||||
|
.profile-head { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 22px; display: flex; align-items: center; gap: 18px; }
|
||||||
|
.avatar { width: 64px; height: 64px; border-radius: 16px; background: var(--color-accent-soft); display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800; color: var(--color-accent-strong); flex: 0 0 auto; }
|
||||||
|
.game-row { display: flex; align-items: center; gap: 14px; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--color-border-soft); }
|
||||||
|
.game-row .meta { flex: 1; }
|
||||||
|
.game-row .meta .sub { display: block; font-size: 12.5px; color: var(--color-text-faint); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* 공개/비공개 토글 (체크박스 기반, JS 없음) */
|
||||||
|
.switch { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--color-text-faint); font-weight: 600; cursor: pointer; }
|
||||||
|
.switch input { position: absolute; opacity: 0; }
|
||||||
|
.switch .track { width: 34px; height: 18px; border-radius: var(--radius-pill); background: #E2D9C8; position: relative; transition: background .15s ease; }
|
||||||
|
.switch .track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: left .15s ease; }
|
||||||
|
.switch input:checked + .track { background: var(--color-ok); }
|
||||||
|
.switch input:checked + .track::after { left: 18px; }
|
||||||
|
.switch .state-on { display: none; }
|
||||||
|
.switch input:checked ~ .state-on { display: inline; color: var(--color-ok); }
|
||||||
|
.switch input:checked ~ .state-off { display: none; }
|
||||||
|
|
||||||
|
/* ---------- 16. 라이브 미리보기 (모집글 작성) ---------- */
|
||||||
|
.preview-card { position: sticky; top: 24px; align-self: start; }
|
||||||
|
.form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--color-border-soft); }
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
@ -29,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.lenient;
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
@ -232,6 +233,21 @@ class PostControllerTest {
|
||||||
assertThat(model.getAttribute("nextCursorId")).isNull();
|
assertThat(model.getAttribute("nextCursorId")).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 회귀(168671b): cursor=null·categoryId=null → PSQLException 없이 "posts-list" 반환. */
|
||||||
|
@Test
|
||||||
|
void list_nullCursorAndCategory_returnsPostsView() {
|
||||||
|
PostController controller = controller();
|
||||||
|
when(postsMapper.listPublishedKeyset(isNull(), isNull(), isNull(), eq(PAGE_SIZE + 1)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
lenient().when(postCategoriesMapper.listActive()).thenReturn(List.of());
|
||||||
|
Model model = new ExtendedModelMap();
|
||||||
|
|
||||||
|
String view = controller.list(null, null, null, model);
|
||||||
|
|
||||||
|
assertThat(view).isEqualTo("posts-list");
|
||||||
|
assertThat(model.getAttribute("hasNext")).isEqualTo(false);
|
||||||
|
}
|
||||||
|
|
||||||
// ==== helpers ====
|
// ==== helpers ====
|
||||||
|
|
||||||
private PostController controller() {
|
private PostController controller() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue