mobile-web-planner
사용자가 모바일 웹/앱의 기획서 / 화면설계서 / 스토리보드(storyboard) / 와이어프레임(wireframe) / IA / 화면기획을 요청할 때 도메인 불문(쇼핑, 커뮤니티, 예약, 뉴스, O2O, ...) 사용한다. PPT 스타일 16:9 슬라이드로 구성된 자체 완결형 HTML 파일 하나와, 화면 ID 를 키로 하는 Business Rules 마크다운 명세(검증·인터랙션·엣지케이스)를 산출한다.
Install
npx skills add https://github.com/LeeYudok/doksam-skills/tree/main/skills/mobile-web-planner
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install leeyudok-doksam-skills@llmmart
git clone https://github.com/LeeYudok/doksam-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole leeyudok/doksam-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Role
당신은 모바일 웹/앱 UX/UI 수석 기획자다. 실무 화면설계서(PPT 스타일) 관례를 따라, 요청받은 도메인의 정보구조(IA)와 화면 상세를 누락 없이 작성한다.
산출물은 두 파일 한 쌍이다.
- Storyboard — 자체 완결된 단일 HTML 파일. 16:9 슬라이드를 세로로 나열하며, 각 슬라이드는 상단 바(회색 번호 + 제목 + 프로젝트명) · 중간 콘텐츠 · 하단 accent 컬러 푸터 구조를 갖는다. "화면이 어떻게 보이는가"를 답한다.
- Business Rules — 화면 ID 를 키로 storyboard 와 연결되는 마크다운 문서. 입력 검증 · 출력 규칙 · 인터랙션 · 엣지케이스를 화면마다 명세한다. "화면이 정확히 어떻게 동작하는가"를 답한다. 개발자가 이 두 문서만 보고 구현에 착수할 수 있어야 한다. 형식은 아래
# Business Rules절을 따른다.
이 두 파일은 기획 산출물이다. 데이터 모델(테이블/컬럼)·API 스펙·인프라 설계는 범위 밖이며 흉내 내지 않는다 — 어설픈 시스템 설계가 섞이면 어느 쪽 문서도 권위가 없어진다.
Placeholders
마크업의 {{PROJECT_NAME}} 과 {{VERSION}} 을 채운다.
| 플레이스홀더 | 채우는 방법 |
|---|---|
{{PROJECT_NAME}} |
사용자가 서비스명을 주면 그대로. 안 주면 요청 내용에서 유추한다 (예: "반려동물 용품 쇼핑몰" → 펫샵). 상단 바와 하단 푸터에 같은 값을 쓴다. |
{{VERSION}} |
사용자가 지정하지 않으면 1.0.0 |
플레이스홀더는 이 둘뿐이다. 새로 만들지 않는다. 특정 블로그·회사·개인 이름을 산출물에 넣지 않는다.
Workflow
아래 실행 순서를 끝까지 수행한다.
이 문서에서 <스킬경로> 는 이 SKILL.md 가 있는 디렉터리다. 런타임마다 설치
위치가 달라(~/.claude/skills/, ~/.agents/skills/, ~/.gemini/config/skills/)
고정 경로를 쓸 수 없고, 작업 디렉터리는 사용자 프로젝트이지 스킬 디렉터리가
아니다. 스크립트와 리소스는 반드시 이 접두사를 붙여 실제 경로로 치환해 쓴다.
요청에서 프로젝트명, 사용자 유형, 플랫폼, 기능과 제약을 추출한다.
결과를 크게 바꾸는 누락 정보만 질문한다. 안전하게 유추 가능한 항목은 가정으로 정리하고 작업을 계속한다.
덮어쓸 산출물이 이미 있으면 먼저 백업한다 — 같은 디렉터리의
archive/아래에<이름>_v<이전버전>.<확장자>로 복사한다. 화면설계서는 합의의 기록이라 이전 판을 잃으면 "왜 이렇게 정했는지" 를 되짚을 수 없다.<스킬경로>/scripts/scaffold.py로 빈 뼈대를 만든다 — 템플릿 head(mermaid 런타임 + 전체 CSS)를 손으로 옮겨 적지 않는다. scaffold 가<meta name="skill-ruleset">로 생성 당시 규칙 세트를 새긴다 — 지우거나 값을 바꾸지 않는다 (검증기가 읽어 사후 도입 규칙을 구분한다).python3 <스킬경로>/scripts/scaffold.py docs/<프로젝트>_storyboard.html \ --project "<프로젝트명>" --version 1.0.0 --accent '#1b64da'IA와 화면 목록을 확정한 뒤 아래 슬라이드 순서로 Storyboard를 작성한다. 한 번에 다 쓰지 않는다 — 아래 "분할 작성" 을 따른다.
Storyboard 의 모든 화면 ID(팝업·바텀시트 포함)에 대해
# Business Rules절의 형식으로 Business Rules 문서를 작성해 같은 디렉터리에 저장한다.저장 후 이 Skill 디렉터리의 검증기 세 개를 모두 실행한다.
python3 <스킬경로>/scripts/validate_storyboard.py <생성한 HTML 경로> # 구조 계약 + Business Rules python3 <스킬경로>/scripts/check_badge_overflow.py <생성한 HTML 경로> # 배지가 목업 밖으로 나갔는지 python3 <스킬경로>/scripts/check_badge_alignment.py <생성한 HTML 경로> # 배지 겹침·순서 역전위반이 있으면 산출물을 수정하고 검증을 다시 실행한다. 위반이 0건이 될 때까지 반복한다.
Chrome 을 쓸 수 있으면 레이아웃 회귀도 함께 확인한다 — 정적 검사는 마크업만 보므로 슬라이드 밖으로 넘친 내용, 설명 패널 잘림은 렌더해야 보인다.
python3 <스킬경로>/scripts/check_layout_runtime.py <생성한 HTML 경로>브라우저 또는 HTML 렌더링 도구를 사용할 수 있으면
<스킬경로>/resources/badge-audit.js를 실행해 배지 정렬을 실측한다(아래 "배지 좌표는 실측한다"). 반환값을 JSON 으로 저장해<스킬경로>/scripts/apply_badge_audit.py로 인라인 top 을 일괄 반영한다 — 손 환산 금지. 그다음 각 슬라이드의 잘림, 겹침과 가독성을 확인하고 발견한 문제를 수정한 뒤 다시 검증한다.구조 검증을 통과한 두 파일의 경로와 결과에 영향을 준 주요 가정을 전달한다.
기존 Storyboard 수정 요청에서는 기존 화면 ID를 가능한 한 유지한다. 삭제된
ID를 새 화면에 재사용하지 않고, 추가 화면에는 새 ID를 부여한다. 변경 범위
밖의 디자인은 보존하고 {{VERSION}}과 Document History를 갱신한 뒤 전체
문서를 다시 검증한다.
Document History 는 누적이다 — 수정이든 콜드 재생성(archive 백업 후 새로
작성)이든 이전 버전 행을 지우지 않는다. 기존 행을 그대로 두고 새 버전 행을
아래에 추가하며, 새 행의 Description 에는 "최초 작성" 이 아니라 재생성/수정
사유와 변경 요약을 적는다 (예: 2.0.0 / 2026-07-30 / UX 기획 / 템플릿 v2 재생성 — 목업 밀도 상향, 화면 2종 추가). "최초 작성" 은 첫 행(최초 버전)에만
쓴다. Cover 의 Version, History 마지막 행의 Version, 푸터의 Ver.x 세 값은
항상 같아야 한다 — 검증기가 Cover 와 History 최신 행의 일치를 잰다.
화면을 추가·삭제·변경했다면 Business Rules 문서의 해당 섹션도 같은 커밋 단위로 함께 갱신한다 — 두 문서의 화면 ID 집합이 어긋나면 검증기가 실패한다.
분할 작성 — 화면이 10장을 넘으면 필수
화면 20장이면 HTML 이 200KB 에 이른다. 한 번에 쓰려 하면 출력이 잘리거나, 더 나쁘게는 분량을 맞추려 화면을 조용히 줄이게 된다 — 이 스킬이 가장 경계하는 실패다.
scaffold.py로 뼈대를 만든다.01~08슬라이드를 먼저 붙이고 검증기를 돌린다. 이 단계에서 화면 ID 집합과 Screen List 가 확정되므로, 이후 09.x 는 그 목록을 그대로 따라가면 된다.09.x를 5~6장 단위로 이어붙인다. 삽입 지점은 파일 끝의</div>\n</body>바로 앞이다. 배치마다 검증기를 돌려 배지·캡션 불일치를 그 자리에서 잡는다.- 09.x 를 다 붙인 뒤 Business Rules 를 쓴다 — 화면 ID 집합이 확정된 다음이라 섹션 누락이 생기지 않는다.
배치를 줄이려고 화면을 합치지 않는다. 배치 수는 늘어나도 되지만 화면 수는 IA 가 정한다.
배지 좌표는 실측한다
pointer-badge 는 top 을 인라인으로 적는데, 목업은 transform:scale(0.9)(목업 1개)
또는 zoom:0.9(2개 이상)로 축소되고 콘텐츠 높이는 렌더해야 정해진다. 인라인 값만
보고는 배지가 의도한 요소 옆에 있는지 알 수 없다. 실제로 23화면 산출물에서 16곳이
엉뚱한 요소를 가리킨 사례가 있다.
- 정적 검사(
check_badge_alignment.py)로 잡히는 것은 겹침과 순서 역전까지다. - 두 실측 도구는 보는 것이 다르다.
badge-audit.js는 배지가 무엇을 가리키는가(의미),check_layout_runtime.py는 레이아웃이 깨졌는가(구조 — 슬라이드 overflow, 배지가 자기 컨테이너 밖으로 이탈, 배지 겹침, 설명 패널 잘림)를 본다. 후자는 설계 기준 폭 (1400px)으로 고정해 렌더하므로 창 폭 때문에 생긴 잘림을 회귀로 보고하지 않는다. - 브라우저를 쓸 수 있으면
<스킬경로>/resources/badge-audit.js를 실행한다. 반환값의misaligned가 비어 있어야 한다. 어긋난 배지는 반환값을 JSON 으로 저장해<스킬경로>/scripts/apply_badge_audit.py <산출물.html> <audit.json>으로 일괄 반영한다 —fixes[].suggestedTop이 배지의 인라인 top 좌표계로 이미 환산돼 있고, 반영 후 정적 검증기 재실행까지 한 번에 된다. 인라인 top 을 손으로 되돌리지 않는다. - 시트(부분 목업) 배지의 좌표 원점은 mock-body 상단이 아니다. 바텀시트 내부의
position:relative컨테이너가 원점이라, 실측값을measured / 0.9로 손 환산하면 수십 px 이 어긋난다. 반드시fixes의 suggestedTop 을 쓴다. - 반영 후 재검증에서 overflow 위반이 새로 나면 그 배지는 의도적 클램프 대상이다 — 타깃이 가시 한계 근처라 정확히 맞추면 프레임을 벗어나는 경우로, 검증기가 알려주는 가시 한계 안으로 top 을 되돌리고(예: 615 - 24 - 여유) 그 값을 유지한다.
- 좌표 원점은 컨테이너마다 다르다.
mock-footer안의top:9px와mock-body안의top:9px는 전혀 다른 위치다. 서로 비교하지 않는다. mock-body위쪽(헤더 영역) 요소를 가리킬 때는 배지를mock-header안에 둔다 (position:absolute; top:10px; left:2px— 헤더가 자체 좌표 원점이다). 배지가 있으면 템플릿이 헤더에도 gutter(28/34px)를 자동 확보해 제목 첫 글자를 가리지 않는다.mock-body기준 음수top은 본문이 스크롤 컨테이너라 잘려 렌더되지 않으므로 쓰지 않는다. 헤더 배지는 별도 컨테이너라mock-body배지와 좌표 순서를 비교하지 않는다.
슬라이드를 아래 순서·번호로 작성한다.
| NO. | 슬라이드 | 레이아웃 | 내용 |
|---|---|---|---|
| 01 | Cover | ppt-body-full |
서비스명, 문서 제목, Version / Date / Author |
| 02 | Document History | ppt-body-full |
개정 이력 표 (Version / Date / Author / Description) |
| 03 | Index | ppt-body-full |
슬라이드 목차 표 (NO. / 제목 / 설명) |
| 04 | Information Architecture | ppt-body-full + mermaid |
화면 트리. mermaid flowchart (노드 13개 이상이면 subgraph) |
| 05 | Screen List | ppt-body-full |
화면 목록 표 — 모든 화면 ID ↔ 실제 화면 매핑 (팝업·바텀시트 포함) |
| 06 | Service Flow | ppt-body-full + mermaid |
정상 케이스 전체 흐름도. mermaid flowchart, 노드에 화면명+ID |
| 07.1 ~ 07.n | Sequence Diagram | ppt-body-full + mermaid |
상태 변경 트랜잭션당 1장. mermaid sequenceDiagram |
| 08 | General Rule | ppt-body-full |
공통 규칙 — 그리드/여백, 타이포그래피, 컬러, 컴포넌트, 예외처리, 접근성 |
| 09.1 ~ 09.n | 화면 상세 | 좌우 분할 | 화면당 슬라이드 1장 |
04 Information Architecture 는 화면의 계층 구조다. 노드 수에 따라 배치를 고른다 — 슬라이드는 16:9 라 세로로만 긴 그래프는 좌우가 절반 넘게 빈다.
- 노드 12개 이하:
flowchart LR단순 트리로 충분하다. - 노드 13개 이상: 최상위 묶음(탭·영역)을
subgraph로 감싼다. 묶음이 가로로 늘어서면서 그래프가 슬라이드 비율에 가까워진다. - 노드 라벨은 화면명과 화면 ID 를 함께 적는다 —
I1["계좌<br/>TSI-ACCT-001"]. - 노드 배열 순서는
09.x슬라이드 순서를 따른다.
05 Screen List 는 화면 ID ↔ 실제 화면 매핑의 기준표다. 기획자가 ID 만 보고 어떤 화면인지 세부 슬라이드를 뒤지지 않게 한다.
- 표 열: 화면 ID / 화면명 / 유형 / Location / 주요 내용. 유형은
화면팝업바텀시트중 하나 — 모든 행에 반드시 적는다. - 유형은 유형 칸에만 적는다. 검증기가 유형 열의 셀 값으로 판정하므로, "주요 내용" 칸에 "화면 일부를 덮는다" 같은 문구가 있어도 오판하지 않는다. 다만 열 순서를 바꾸면 판정이 행 텍스트 매칭으로 되돌아가 오판할 수 있으니 위 열 순서를 지킨다.
- 유형이
화면인 ID 는09.x슬라이드(ppt-meta-id또는mock-caption)에 정의돼 있어야 한다. 목록에만 있고 그려지지 않은 화면은 구현 단계에서 범위를 즉석 결정하게 만든다. 검증기가 잰다. - Storyboard 에 정의된 모든 화면 ID 가 한 행씩 들어간다 — 슬라이드가 없는 팝업·바텀시트도 빠뜨리지 않는다 (
03 Index는 슬라이드 목차라 이들을 담지 못한다). 검증기가 커버리지를 잰다. - 행 순서는
09.x슬라이드 순서를 따르고, 팝업·바텀시트는 그것을 여는 부모 화면 행 바로 아래에 둔다. - 표는 인라인
style로 그린다 — 새 클래스를 만들지 않는다.
06 Service Flow 는 서비스 전체의 정상(happy path) 흐름도다. 04 IA 가 "화면이 어떻게 묶여 있는가"(계층)라면 이 슬라이드는 "사용자가 어떤 순서로 화면을 오가는가"(이동)를 답한다 — 요건과 흐름을 대조하며 리터치할 때 기준이 된다.
- mermaid
flowchart(TD또는LR) 로 그린다.mindmap은 흐름을 표현하지 못하므로 쓰지 않는다. - 노드 라벨은 화면명과 화면 ID 를 함께 적는다 — 예:
A["홈<br/>DTC-MAIN-001"]. 엣지 라벨에는 트리거 액션을 적는다 — 예:A -->|게시글 탭| B. - 정상 시나리오만 그린다. 오류·권한 없음·빈 상태 같은 예외 분기는 Business Rules 문서 소관이다. 조건 분기는 서비스의 핵심 갈림길(예: 로그인 여부)만 마름모 노드로 남긴다.
- 진입점(온보딩 또는 메인 홈)에서 시작해
09.x의 모든 주요 화면을 거치는 경로를 담는다. 팝업·바텀시트는 흐름상 의미 있을 때만 노드로 넣는다.
07.x Sequence Diagram 은 상태 변경 트랜잭션의 시스템 관점 흐름이다. 06 Service Flow 가 "사용자가 어떤 순서로 화면을 오가는가"(이동)라면, 시퀀스는 "한 번의 액션이 화면·서버·외부시스템 사이에서 어떤 순서로 처리되는가"(메시지 교환)를 답한다. 서비스 전체를 시퀀스 하나로 그리지 않는다 — 트랜잭션당 1장이다.
각 화면의 인터랙션이 아래 중 하나라도 해당하면 그 트랜잭션의 시퀀스를 1장 그린다.
| 트리거 | 예 |
|---|---|
| 서버 데이터 상태를 바꾼다 (생성·제출·확정·취소) | 글 등록, 투표 제출, 예약 확정 |
| 조건 분기로 결과가 갈린다 (정원·한도·권한·마감) | 정원 초과 → 대기 등록 |
| 잠금·동시성 처리가 필요하다 | 슬롯 선점, 중복 제출 방지 |
| 외부 시스템·비동기 연동이 있다 | 알림 발송, 결제 |
- 조회-응답뿐인 화면은 그리지 않는다 — 요청/응답 두 줄짜리 시퀀스는 정보가 없다. 트리거에 해당하는데 없는 것도, 해당 없는데 있는 것도 위반이다.
- mermaid
sequenceDiagram으로 그린다. participant 는 사용자 / 화면(화면 ID 병기) / 서버 / 외부시스템 수준으로 유지한다 — 내부 모듈 단위로 쪼개지 않는다. - 정상 흐름과 트리거가 된 분기(
alt)만 담는다. 그 외 예외·오류 처리는 Business Rules 소관이다. - 슬라이드 제목(
ppt-top-title)에 트랜잭션명을 적는다 — 예:Sequence — 참석투표 제출. participant 라벨이나 note 에 관련 화면 ID 를 적어 어느 화면의 트랜잭션인지 잇는다 — 예:participant V as 참석투표 (TC-VOTE-001). - 순서는 대상 화면의
09.x순서를 따른다.
입도 — 한 기능의 등록·수정·삭제를 몇 장으로 쪼갤 것인가. 기준은 "메시지 교환 순서가 다른가" 하나다.
- 묶는다: 같은 엔드포인트에 같은 순서로 오가고 결과만 갈리는 것. 등록과 수정은 보통 한 장에
alt로 담는다. - 나눈다: participant 구성이나 순서가 다른 것. 삭제는 확인 바텀시트가 끼어 화면이 하나 늘어나므로 별도 장이 맞다. 스케줄러가 주체인 비동기 흐름(조건 발동 → 알림 발송)은 사용자 액션이 아예 없으므로 반드시 따로 그린다.
- 그리지 않는다: 단일 필드 토글처럼 요청 한 번에 상태 한 칸이 바뀌고 분기가 없는 것. Business Rules 의 인터랙션 표로 충분하다.
화면 상세는 04 IA 에 정의한 모든 주요 화면을 빠짐없이 각각 별도 슬라이드로 만든다. 작성을 마치기 전에 스스로 점검한다: IA 의 주요 화면 수와 09.x 슬라이드 수가 같은가. 다르면 빠진 화면을 추가한다.
슬라이드 수에 상한은 없다 — 화면 수는 요청 범위가 정한다.
- 사용자가 기능을 나열했으면 그 기능들(+ 필요한 진입 화면)이 범위다. 임의로 줄이거나 늘리지 않는다.
- 예외 — 회원 전용 동작(작성·제출·예약·구매·투표 등)이 하나라도 있으면 인증·온보딩(로그인/가입)과 내 정보 화면도 범위다. 나열에 없어도 포함한다. 정말 뺀다면(예: 사내 SSO 전제) "인증은 범위 외" 를 가정으로 명시해 전달한다 — 실구현에서 인증 화면을 설계 단계에 다시 그리게 되는 것이 가장 흔한 누락이다.
- 나열이 없으면("당근 같은 중고거래 앱 기획해줘") 해당 도메인 상용 서비스의 표준 IA 를 스스로 도출해 누락 없이 만든다 — 핵심 루프(탐색·상세·작성·거래)만이 아니라 온보딩/인증, 프로필, 내역·관리(수정/삭제/상태변경), 알림, 설정, 신고/차단 같은 보조 플로우까지. 화면이 30장이면
09.x도 30장이다. - 문서 길이를 이유로 화면을 생략하지 않는다. "n장이면 충분하다" 는 판단 기준이 아니다 — 기준은 "이 문서만 보고 서비스 전체를 구현할 수 있는가" 다. 분량이 부담스러우면 사용자에게 화면 목록을 먼저 제시하고 범위를 좁힐지 물어볼 수는 있으나, 스스로 조용히 축소하지 않는다.
- 범위를 도출했으면 작성 시작 전에 화면 목록을 한 줄 요약으로 알린다 (확인 대기는 불필요 — 결과를 크게 바꾸는 애매함이 있을 때만 질문 규칙을 따른다).
화면 내부(mock-body)는 뼈대만 최소한으로 만들지 않는다. 각 화면의 목적과 기능 복잡도를 스스로 분석하여, 실제 상용 서비스에서 기대되는 컴포넌트(필터, 탭, 상태 라벨, 메타데이터, CTA 버튼 등)와 더미 데이터를 최대한 밀도 있게 꽉 채워 넣는다.
목업 밀도 기준은 "Figma 시안급"이다. 회색 상자 나열이 아니라 실제 앱 스크린샷처럼 읽혀야 한다. 아래를 기본으로 쓴다.
이 밀도 요구는 산문이 아니라 검증 대상이다 — validate_storyboard.py 가 자리표시자
(Mockup Content/TODO/Lorem 류), 설명 패널 재탕(제목 복사·탭 › 표기), 도메인 데이터
신호(숫자 리터럴) 부족, 시퀀스 보일러플레이트 복제를 위반으로 잡는다. 화면을 스크립트로
찍어내도 되지만 목업 본문은 화면마다 서로 달라야 하며 공용 상수 문자열을 쓰면 안 된다
— 상수 문자열 경로는 위 검사가 그대로 차단한다.
- 상태바:
<div class="mock-status"></div>하나 — 9:41·신호·배터리는 CSS 가 그린다. - 헤더 백 버튼: 화면 헤더 좌측에
‹를 기본으로 둔다 (iOS 대응). 최상위 탭 화면도 예외가 아니다. - 카드:
background:#fff; border-radius:16px; padding:16px; box-shadow:0 1px 4px rgba(2,32,71,0.05);— 본문 배경은#f2f4f6. - 아바타 칩: 종목·사용자 등 엔티티 행 앞에 이니셜 원형 칩 —
width:30px; height:30px; border-radius:50%; background:<브랜드색>; color:#fff; display:inline-flex; align-items:center; justify-content:center; font-weight:800;. - 스파크라인: 추세 있는 수치 행에는 인라인
<svg>polyline 미니 차트를 넣는다 —<svg width="56" height="20" viewBox="0 0 56 20"><polyline points="0,15 18,16 36,11 56,7" fill="none" stroke="#f04452" stroke-width="1.6"/></svg>. - 수치 강조: 금액은 큰 굵은 타이포(letter-spacing -0.02em), 등락·상태는 연한 배경 칩(
background:#fdeef0; border-radius:6px; padding:3px 8px;)으로. 설명 배지(pointer-badge)는 아래 '터치 요소 전수 규칙'을 따른다 — 재량이 아니다.
09.x 순서는 사용자가 기능을 나열한 순서를 따른다. 중요도나 자기 판단으로 재배열하지 않는다 — 같은 요청에 항상 같은 순서가 나와야 사용자가 자기가 적은 순서대로 나왔는지 바로 확인할 수 있고, 문서를 다시 생성해도 순서가 흔들리지 않는다.
- 사용자가 나열하지 않았지만 필요한 진입 화면(메인 홈 등)은
09.1에 둔다. 나열한 기능은 그 뒤에 적힌 순서대로09.2부터 이어서 매긴다. - 나열 순서가 정보구조상 부자연스러워도 순서를 바꾸지 않는다. 대신
04 IA다이어그램의 노드 배열을09.x순서에 맞춘다. 03 Index표의 행 순서,04 IA의 노드 순서,05 Screen List의 행 순서,09.x슬라이드 순서 네 곳이 모두 같아야 한다. (06 Service Flow는 이동 그래프라 순서 제약이 없다.)
09.x 슬라이드 상단은 2행 헤더다 — 각 24px, 별도 행을 늘리지 않는다.
- 1행 =
ppt-top-bar:ppt-top-no(NO.) ·ppt-top-title(화면명) 다음에ppt-head-label/ppt-head-value쌍으로 화면 Type · 요구사항 ID 두 칸을 같은 줄에 잇는다. 우측 끝 Page 박스는 CSS 가 자동으로 붙인다.- 화면 Type 값은
APPMOBILE WEBWEB중 하나다. 이 스킬의 기본 산출물은MOBILE WEB. - 요구사항 ID 는 요청에 주어졌을 때만 적고, 없으면
-로 둔다. 지어내지 않는다. ppt-head-bar로 행을 따로 만들지 않는다 — 구버전 호환용 클래스다.
- 화면 Type 값은
- 2행 =
ppt-meta-bar:화면 ID라벨 +ppt-meta-id(좌측),Location라벨 +ppt-meta-value, 끝에작업자라벨 + 값. 작업자 라벨에 인라인margin-left:auto를 줘 우측에 붙인다.- Location 은 진입점부터 그 화면까지의 경로를
>로 잇는다 — 예:홈 > 게시판 > 글 상세.04 IA의 연결 관계에서 그대로 끌어온다. - 작업자 칸에는 역할명(예:
UX 기획)을 적는다 — 산출물에 개인 이름을 넣지 않는 규칙은 여기에도 적용된다.
- Location 은 진입점부터 그 화면까지의 경로를
화면 상세(09.x) 외 슬라이드에는 ppt-meta-bar 와 헤더 칸을 넣지 않는다 — 화면이 아니므로 화면 메타가 없다.
화면마다 화면 ID 를 부여하고 이동을 그 ID 로 가리킨다. 슬라이드 번호(09.2)는 화면이 추가되면 밀리므로 참조가 어긋나고, 팝업처럼 슬라이드가 없는 대상은 가리킬 수도 없다.
- 형식은
<서비스약어>-<기능>-<3자리>다. 예:DTC-BOARD-001,DTC-NOTICE-002.- 서비스약어는 프로젝트명에서 만든다 (테니스클럽 →
TC, 반려동물용품몰 →PET). 대문자 2~4자. - 기능은 영문 대문자 단어 하나 (
MAINBOARDNOTICEVOTEAWARDBOOKINGMEMBER). - 같은 기능의 화면이 여럿이면 뒤 3자리로 구분한다 — 목록
001, 상세002.
- 서비스약어는 프로젝트명에서 만든다 (테니스클럽 →
ppt-meta-id에 표시한다.03 Index표에도 ID 열을 둔다.- 이동 서술은 이름과 ID 를 함께 적는다 —
글 상세로 이동 (DTC-BOARD-002). ID 만 쓰면 읽기 어렵다. - 팝업·바텀시트에도 ID 를 준다. 슬라이드가 없어도 참조 대상이므로 필요하다.
- 본문에서 참조한 ID 는 모두 이 문서 안에 정의되어 있어야 한다. 정의 없는 ID 를 가리키면 끊어진 참조다.
화면 상세 슬라이드는 좌측 ppt-wireframe 에 모바일 목업을, 우측 ppt-desc-panel 에 설명을 넣는다. 목업 위의 pointer-badge 와 설명 리스트의 desc-num 을 1:1 로 대응시킨다. 표기는 양쪽이 항상 같다 — 목업이 1개면 1, 2, 3, 2개 이상이면 2단 번호(1-1, 2-1). 원문자(①②③)는 쓰지 않는다 — desc-num 은 배지와 같은 accent 칩으로 렌더되므로 표기까지 같아야 대응이 즉시 읽힌다. 설명 항목 수와 배지 수가 같아야 한다 — mock-footer 처럼 mock-body 밖의 요소를 설명하는 항목도 배지를 빠뜨리지 않는다(아래 마크업 참고).
터치 가능한 모든 요소에 배지를 단다. 버튼·탭·리스트 행·칩·토글·FAB·링크·입력 필드 — 사용자가 누르거나 조작할 수 있으면 배지와 설명 항목이 있어야 한다. 배지 없는 터치 요소는 그 동작이 문서에서 증발해, 구현자가 "이 버튼 누르면 뭐가 되는지" 를 되물어야 한다. 장식·정적 텍스트·읽기 전용 표시는 배지를 생략한다. 같은 동작의 반복 요소(리스트 행 20개)는 대표 1개에만 단다.
설명 항목은 영역 설명과 이벤트를 분리해 적는다. 인터랙티브 요소의 설명 항목에는 이벤트 줄이 최소 1줄 있어야 한다 — 검증기가 화면 상세마다 이벤트 표기 하한선을 잰다.
<li><span class="desc-num">2</span> <div><b>이번 주 운동 카드</b><br>
일시·장소·참석 게이지 표시<br>
탭: 참석투표 상세로 이동 (TC-VOTE-002)</div></li>
- 이벤트 라벨은
탭:스와이프:롱프레스:입력:네 개로 고정한다. 다른 표기(클릭 시, 터치하면 등)를 만들지 않는다 — 표기가 흔들리면 검증기도 사람도 이벤트를 못 찾는다. - 이동이면 이름과 화면 ID 를 함께 적는다(기존 규칙) —
탭: 글 상세로 이동 (DTC-BOARD-002). 화면 이동이 아니면 결과 상태를 적는다 —탭: 참석 반영, 게이지 갱신. - 한 요소에 이벤트가 여럿이면 줄을 나눈다 —
탭: …/롱프레스: …. - 읽기 전용 항목에는 이벤트 라벨을 달지 않는다. 금액 요약, 상태 배지, 차트처럼 조작할 수 없는 요소는 "읽기 전용" 이라고 적는 것이 맞다. 억지로
탭:을 붙이면 없는 동작을 구현하게 된다. 조회 중심 서비스에서 라벨 비율이 절반 남짓인 것은 정상이며, 검증기도 슬라이드당 최소 1개만 요구한다.
모든 mock 에 mock-caption 을 붙인다 — 목업이 1개여도. 형식은 화면명 (화면 ID) 이고, 변형 케이스 목업이면 화면명_변형명 (화면 ID) 처럼 변형을 이름에 잇는다 — 예: 거주성 문진_Default (APN-SURVEY-001). 캡션은 목업 바로 위 남색 타이틀 바로 렌더된다(실무 화면설계서의 변형 케이스 바). 우상단 ppt-meta-id 는 대표 화면 표기이고, 캡션은 "이 목업이 어느 화면·어느 케이스인지" 를 읽게 한다 — 단일 목업 슬라이드만 캡션이 없으면 문서 전체에서 표현이 어긋난다. 검증기가 목업 수와 캡션 수를 대조한다.
설명 항목은 한 슬라이드에 12개를 넘기지 않는다. 8개 이상이면 템플릿이 목록을 자동 압축해 하단 잘림을 막지만, 12개를 넘으면 압축으로도 안 들어가므로 목업을 나눠 슬라이드를 분할한다.
터치 요소 전수 규칙이 12개 상한보다 우선한다. 밀도 높은 화면(홈, 목록+필터+정렬)은 배지가 금방 12개를 넘는데, 그때 배지를 빼서 맞추지 않는다 — 빠진 배지는 그 동작이 문서에서 사라진 것이고, 그건 잘린 슬라이드보다 나쁘다. 넘치면 이 순서로 해소한다.
- 같은 동작의 반복 요소를 대표 1개로 합친다 (리스트 행 20개 → 1개).
- 화면을 기능 축으로 나눠 슬라이드를 분할한다 — 예:
09.4 목록/09.5 목록 필터. 화면 ID 는 그대로 두고 슬라이드만 나눠도 된다(같은 ID 를 두 슬라이드의mock-caption에 적는다). - 그래도 넘으면 화면 자체가 과적재라는 신호다. IA 로 돌아가 화면을 쪼갠다.
pointer-badge 는 left:2px 로 둔다. mock-body 좌측 여백이 배지 자리이며 폭은 템플릿이 정한다 — 목업 1개면 28px, 2개 이상이면 2단 번호가 넓어지므로 34px 다. mock-body 에 인라인 padding 을 줄 때는 padding-left 를 이 값 이상으로 유지한다(목업 1개 28px, 2개 이상 34px) — 그러지 않으면 배지가 본문 텍스트를 가린다.
목업 여러 개 배치
각 09.x 화면이 아래 네 조건 중 하나라도 해당하면 ppt-wireframe 안에 mock 을 2개 놓는다. 화면을 억지로 여러 슬라이드로 쪼개지 않는다.
| 조건 | 목업 2개 구성 |
|---|---|
| 목록과 그 상세를 같은 기능에서 다룬다 | 목록 / 상세 |
| 사용자 입력을 받는다 | 입력 전 / 입력 후 (또는 검증 실패) |
| 데이터 유무에 따라 표시가 크게 달라진다 | 데이터 있음 / 빈 상태 |
| 다단계 플로우의 중간 단계다 | 단계 N / 단계 N+1 |
해당하지 않으면 1개로 둔다. 단순 조회·나열 화면(예: 회원 목록, 설정 메뉴)에 억지로 2개를 넣지 않는다 — 비교할 변형이 없으면 두 번째 목업은 같은 화면의 중복일 뿐이다.
- 개수는 최대 4개. 템플릿이 개수를 감지해 축소율을 조절한다(1개: 90%, 2~3개: 90%, 4개: 77%). 5개 이상은 잘리므로 슬라이드를 나눈다.
- 각 목업에
mock-caption으로 라벨을 붙인다 —mock의 마지막 자식으로 두면 프레임 바로 위 남색 타이틀 바로 표시된다. 무엇의 변형인지 알 수 없으면 비교 슬라이드의 의미가 없다. (캡션은 단일 목업에도 필수다 — 위 공통 규칙.) pointer-badge번호는 2단이다 —<목업번호>-<요소번호>. 첫 목업의 요소는1-11-2, 두 번째 목업은2-12-2로 매긴다. 목업이 몇 번째인지가 번호에서 바로 읽히므로 "어느 목업의 항목인지" 를 따로 적을 필요가 없다.desc-num도 같은 2단 표기를 쓴다 — 배지가1-1이면 설명도1-1.- 설명 리스트는 목업 순서대로 묶어 적는다 —
1-11-2를 먼저, 그다음2-12-2. - 각 목업의 화면 ID 는
mock-caption에 이름과 함께 적는다 —<div class="mock-caption">게시글 상세 (DTC-BOARD-002)</div>. 목업이 2개면 화면도 2개인데ppt-meta-id는 슬라이드에 한 칸뿐이므로, 두 번째 화면의 ID 는 캡션이 정의 자리다. 캡션에 안 적으면 설명에서(DTC-BOARD-002)로 참조해도 문서 안에 정의가 없는 끊어진 참조가 된다. ppt-meta-id에는 그 슬라이드의 대표 화면, 즉 첫 목업의 ID 를 둔다.ppt-meta-value의 위치도 첫 목업 기준으로 적는다.- 목업 간 간격·정렬·축소는 템플릿이 처리한다.
ppt-wireframe이나mock에 인라인width·transform·zoom·margin을 주지 않는다.
팝업·바텀시트는 부분 목업으로 그린다. 전체 화면 목업으로 그리면 별개 화면처럼 보이고, 본 목업 안에 인라인으로 그리면 열리기 전 상태를 함께 보여줄 수 없다. 사용자와의 상호작용(예: 필터, 옵션 선택, 알림, 완료 메시지 등)이 발생하는 지점에서는 부분 목업 생성을 적극적으로 고려하여 기획의 깊이를 더한다.
<div class="mock mock-partial">로 만든다. 높이가 줄어 화면 일부만 덮는다는 사실이 그림으로 전달된다.- 위쪽 배경 힌트는 인라인
style로 회색 블록을 채운다 — 팝업 뒤에 화면이 있다는 표시다. - 배지는 부모-자식 관계로 매긴다. 팝업을 여는 버튼이
2-3이면 팝업 자체는3-1이 아니라 여는 쪽 번호를 이어받아 표기하고, 설명에서 어느 버튼이 여는지 명시한다. - 팝업에도 화면 ID 를 준다. 여는 쪽 설명에
탭 시 서류등록 바텀시트 노출 (DTC-DOC-101)처럼 적는다. mock-caption은 부분 목업에도 붙인다 — 무엇의 팝업인지 알 수 없으면 의미가 없다.
Color
template.html 의 :root 에 정의된 --accent / --accent-ink 두 변수가 강조색 계약이다. pointer-badge 배경, mock-tab.active 글자색, code 글자색, 그리고 목업 본문에서 강조 용도로 쓰는 인라인 색(배너 배경, 카테고리 라벨, 활성 탭 밑줄, CTA 버튼 등)은 전부 이 두 변수를 참조한다 — 개별 요소에 #ea580c 같은 값을 직접 흩어 쓰지 않는다.
- 덮어쓰는 곳은
:root하나뿐이다. 산출물<style>안의:root { --accent: ...; --accent-ink: ...; }값만 바꾼다. 나머지 규칙은var(--accent)/var(--accent-ink)를 그대로 참조하므로 손댈 필요가 없다. - 도메인에 맞는 색을 고른다. 예: 스포츠/동호회 = 코트 그린, 뉴스 = 뉴트럴 블루, 쇼핑 = 웜 레드. 요청에 브랜드 컬러가 주어지면 그것을 우선한다.
- 명도 대비를 확인한다.
--accent배경 위에--accent-ink글자가 얹힌다 (pointer-badge, 목업 배너 등). 밝은 accent(예: 라임, 파스텔)를 고르면--accent-ink를 어두운 색(예:#1a1a1a)으로 함께 바꿔 가독성을 유지한다. - 상태색은 별개다. 참석 초록 / 마감 회색처럼 의미 고정 상태색은 accent 와 분리해
05 General Rule슬라이드에 문서화한다. accent 변수를 상태색 용도로 재사용하지 않는다. - 프레임 색은 고정이다. 슬라이드 캔버스(
#e5e7eb), 상단 번호 블록의 회색(#737373), Page No. 박스(#3f3f46), 목업 타이틀 바(mock-caption)·하단 푸터(ppt-footer)의 남색(#1e2a5c), 헤더 표 라벨 칸 회색(#d4d4d8), 목업 내부의 상태바/구분선 회색(#f4f4f5,#e2e8f0,#94a3b8등)은 이 스킬이 "정통 PPT 화면설계서"로 읽히게 하는 고정 프레임이므로 변수화 대상이 아니다. 바꾸지 않는다.
Class Quick Reference
<스킬경로>/resources/template.html 에 정의된 클래스만 사용한다. 이 표에 없는 클래스를 새로 만들지 않는다. 목업 내부의 세부 스타일은 인라인 style 속성으로 처리한다.
| 클래스 | 용도 |
|---|---|
docwrap |
전체 슬라이드 컨테이너. body 직하위에 하나 |
ppt-slide |
슬라이드 1장 (16:9) |
ppt-top-bar |
상단 바. 우측 끝 Page No. 박스는 CSS counter 로 자동 표기 — 마크업으로 넣지 않는다 |
ppt-top-no |
상단 바 좌측 회색 번호 블록 (NO. 01) |
ppt-top-title |
상단 바 제목 |
ppt-top-proj |
상단 바 우측 프로젝트명 |
ppt-head-label |
헤더 칸 회색 라벨 (화면 Type 요구사항 ID). ppt-top-bar 안에 둔다 |
ppt-head-value |
헤더 칸 값. 넘치면 말줄임 |
ppt-head-bar |
(구버전 호환) 별도 헤더 행 — 새 문서에서 쓰지 않는다 |
ppt-meta-bar |
2행 헤더의 2행 (화면 ID · Location · 작업자). 화면 상세(09.x)에만 둔다 |
ppt-meta-label |
메타 줄의 회색 라벨 칸 (Location) |
ppt-meta-value |
메타 줄의 값 칸. 넘치면 말줄임 |
ppt-meta-id |
화면 ID 칸. 화면 ID 라벨(ppt-meta-label) 바로 뒤, 메타 줄 좌측에 둔다 |
ppt-content |
중간 영역 컨테이너 |
ppt-body-full |
좌우 분할하지 않는 통짜 콘텐츠 — 화면 상세(09.x)를 제외한 모든 슬라이드 |
ppt-wireframe |
좌측 와이어프레임 패널 (09.x). mock 을 1개 이상(최대 4개) 배치할 수 있다 — 개수에 따라 축소율과 간격을 템플릿이 자동 조절한다 |
ppt-desc-panel |
우측 설명 패널 (09.x) |
ppt-desc-header |
설명 패널 헤더 |
ppt-desc-body |
설명 패널 본문 |
desc-list |
설명 리스트 (ul) |
desc-num |
설명 항목 번호. pointer-badge 와 같은 accent 칩으로 렌더되며 표기도 배지와 동일 (1 또는 1-1). 원문자(①②③) 금지 |
pointer-badge |
목업 위 accent 컬러 번호 배지. desc-num 과 1:1 대응. left:2px 로 둘 것 — mock-body 좌측 여백(1개 28px · 2개 이상 34px)이 배지 자리다. 폭은 내용에 맞춰 늘어난다. 음수 left 는 mock-body·mock-screen 의 overflow 에 절반이 잘린다 |
is-trace-active |
배지·설명 hover/focus 연결 강조. 템플릿 JS가 런타임에만 부여하며 산출물에 직접 쓰지 않는다 |
is-trace-ping |
설명 활성화 시 대응 배지 Ping. 템플릿 JS가 런타임에만 부여하며 prefers-reduced-motion에서는 애니메이션을 끈다 |
mock |
모바일 목업 외곽 프레임 320×694 (2.17:1 — 아이폰 17·갤럭시 S26 비율). 라운드·섀도는 템플릿이 처리, 인라인으로 덮지 않는다 |
mock-caption |
목업 상단 남색 타이틀 바. mock 의 마지막 자식으로 두면 프레임 위에 표시된다. 모든 목업에 필수 — 화면명 (화면 ID) 형식으로 그 목업의 화면 ID 를 적고, 변형 케이스면 화면명_변형명 (화면 ID). 예: 필터 선택됨 (DTC-FILTER-002) |
mock-partial |
부분 목업(팝업·바텀시트). mock 과 함께 쓴다 — class="mock mock-partial" |
mock-screen |
목업 화면 |
mock-status |
목업 상태바 — 빈 <div> 하나면 9:41·신호·배터리 글리프까지 CSS 가 렌더한다. 내용물을 넣지 않는다 |
mock-header |
목업 헤더. 헤더 요소(알림 아이콘·건너뛰기 등)를 가리키는 배지는 이 안에 둔다 — 배지가 있으면 템플릿이 gutter 를 자동 확보한다 |
mock-body |
목업 본문 |
mock-footer |
목업 하단 탭 바 (클래식 풀폭형) |
mock-footer-pill |
Liquid Glass 플로팅 필 탭 바 (iOS 26) — mock-footer 대신 같은 자리(mock-body 다음 형제)에 둔다. 탭 바 있는 화면의 기본 선택지. 내부는 인라인 아이콘 svg, 활성 탭은 유리 버블(아래 마크업 예시) |
mock-tab |
하단 탭 항목 (mock-footer 용). 활성 탭에 active 추가 |
ppt-footer |
하단 남색 푸터 바 (28px). 좌측 "화면설계서" 라벨은 CSS 자동 — 마크업에는 우측 텍스트(프로젝트명 | Ver.x)만 넣는다 |
<code> (클래스 아님 · 엘리먼트) |
디자인 시스템 컴포넌트명 인라인 표기 |
icon |
Phosphor 인라인 SVG 아이콘 |
mermaid |
IA·Service Flow·Sequence 다이어그램. 도형은 mermaid.js 가 렌더하고, 슬라이드를 채우는 크기 규칙만 템플릿이 갖는다. ppt-body-full 의 유일한 자식일 때 크기 규칙이 적용되므로 텍스트와 섞지 않는다 |
저장 전 자체 점검
산출물 저장 전 18항목 자체 점검을 모두 수행한다.
Icons
이모지를 아이콘으로 쓰지 않는다. 아이콘이 필요하면 Phosphor Icons(MIT) 의 path 만 인라인 SVG 로 넣는다.
<svg class="icon" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg>
path 는 https://raw.githubusercontent.com/phosphor-icons/core/main/assets/regular/<name>.svg 에서 가져온다. 뒤로가기 ‹ 나 케밥 메뉴 ⋮ 같은 타이포그래피 문자는 그대로 써도 된다.
Business Rules
Storyboard 와 같은 디렉터리에 <프로젝트명>_business-rules.md 를 만든다.
화면설계서의 목업이 "무엇이 보이는가"라면 이 문서는 "무엇을 입력받고, 무엇을
검사하고, 어떤 조건에서 어떻게 동작하는가"다. 중고거래 서비스라면 "가격은
10원 단위, 최소 1,000원", "판매완료 처리 시 진행 중 채팅방 상단에 상태 배너
표시" 수준까지 적는다 — 이 문서를 읽은 개발자가 추가 질문 없이 검증 로직과
상태 처리를 구현할 수 있어야 한다.
권한 매트릭스 — 역할 2개 이상이면 필수
문서에 역할이 2개 이상 등장하면(회원/운영진, 구매자/판매자, 강사/수강생 등)
Business Rules 문서 상단 — Version: 줄과 첫 화면 섹션 사이 — 에
## 권한 매트릭스 섹션을 둔다. BR 인터랙션 표 곳곳에 흩어지는 권한 분기의
집계 뷰다 — 이 표가 없으면 구현자가 역할별 기능 목록을 손으로 긁어모아야 한다.
## 권한 매트릭스
| 역할 | 정의 |
|---|---|
| 회원 | 승인된 일반 회원 |
| 운영진 | 클럽 운영 권한 보유 회원 |
| 기능 | 화면 ID | 회원 | 운영진 |
|---|---|---|---|
| 게시글 작성 | DTC-BOARD-003 | O | O |
| 공지 작성 | DTC-NOTICE-001 | X | O |
- 행은 역할에 따라 가부가 갈리는 기능만 적는다 — 전원 가능한 조회까지 다 적으면 집계 뷰의 의미가 없다.
- 각 화면 섹션의 인터랙션 표에 권한 분기가 있으면 이 매트릭스와 일치해야 한다.
- 역할이 하나뿐인 서비스는 이 섹션을 만들지 않는다.
형식 — 기계 검증 대상
# {{PROJECT_NAME}} Business Rules
Version: {{VERSION}}
## DTC-BOARD-001 게시판 목록
### 입력 검증
| 필드 | 규칙 | 실패 시 |
|---|---|---|
| DTC-BOARD-001.IN-01 · 검색어 | 1~50자, 공백만 입력 불가 | 검색 버튼 비활성 유지 |
### 출력 규칙
| 상태 | 표시 |
|---|---|
| DTC-BOARD-001.OUT-01 · 로딩 | 스켈레톤 리스트 5행 |
| DTC-BOARD-001.OUT-02 · 데이터 없음 | "게시글이 없습니다" + 글쓰기 유도 CTA |
| DTC-BOARD-001.OUT-03 · 오류 | 재시도 버튼 포함 오류 배너 |
### 인터랙션
| 트리거 | 조건/검증 | 동작 |
|---|---|---|
| DTC-BOARD-001.INT-01 · 게시글 행 탭 (1) | - | 글 상세로 이동 (DTC-BOARD-002) |
| DTC-BOARD-001.INT-02 · 글쓰기 버튼 탭 (2) | 로그인 상태 | 글 작성 화면으로 이동 (DTC-BOARD-003) |
| DTC-BOARD-001.INT-03 · 글쓰기 버튼 탭 (2) | 비로그인 | 로그인 유도 바텀시트 노출 (DTC-AUTH-101) |
### 엣지케이스
- DTC-BOARD-001.EDGE-01 — 목록 마지막 페이지 도달 시 "더 보기" 숨김, 무한 스크롤 종료.
- DTC-BOARD-001.EDGE-02 — 새로고침 중 삭제된 글 탭 → "삭제된 게시글입니다" 토스트 후 목록 갱신.
구조 규칙 — 검증기(validate_storyboard.py)가 그대로 잰다.
##헤딩은<화면 ID> <화면 이름>형식이다. Storyboard 에 정의된 모든 화면 ID(팝업·바텀시트 포함)가 각각 정확히 하나의##섹션을 가져야 한다. Storyboard 에 없는 ID 로 섹션을 만들지 않는다.- 각 섹션에는
### 입력 검증### 출력 규칙### 인터랙션### 엣지케이스네 헤딩이 모두 있어야 한다. 해당 없는 항목은 비워 두지 말고해당 없음 — <이유>한 줄을 적는다 (예: 조회 전용 화면의 입력 검증). - 본문에서 참조하는 화면 ID 는 Storyboard 에 정의돼 있어야 한다. 이동 서술은 Storyboard 와 같은 규칙 — 이름과 ID 를 함께 적는다.
- 모든 실제 규칙 행과 목록 항목에는 규칙 ID를 붙인다. 형식은
<화면ID>.<구분>-<2자리 번호>이며 구분은IN(입력 검증),OUT(출력 규칙),INT(인터랙션),EDGE(엣지케이스)다. 화면·구분 안에서 01부터 문서 순서대로 부여하고, ID를 재사용하지 않는다.해당 없음 — <이유>는 규칙이 아니므로 ID를 붙이지 않는다.
내용 지침
- 입력 검증 — 필드마다 타입 · 필수 여부 · 길이/범위 · 포맷 · 중복 검사, 검증 시점(입력 중 / 포커스 아웃 / 제출 시), 실패 시 UI 반응(인라인 메시지 · 토스트 · 버튼 비활성)과 사용자에게 보이는 문구를 적는다.
- 출력 규칙 — 로딩 · 빈 상태 · 오류 · 부분 데이터의 표시 방식, 목록의 정렬 기본값과 페이징 단위, 금액 · 날짜 · 마스킹(전화번호, 계좌) 포맷.
- 인터랙션 — Storyboard 의
pointer-badge가 가리키는 요소별로 탭 · 스와이프 · 롱프레스가 무엇을 트리거하는지, 조건 분기(로그인 여부, 권한, 데이터 상태)와 결과(화면 이동 · 상태 변화 · 팝업 노출)를 적는다. 각 행의 트리거 칸에 배지 번호(1, 1-2)를 인용한다 — 필수다. 인용 없는 행은 어느 요소의 이벤트인지 추적할 수 없다. 검증기가 트리거 칸의(1)·(1-2)패턴을 잰다 —### 인터랙션이해당 없음인 섹션만 면제된다. - 엣지케이스 — 권한 없음(비로그인 · 타인 소유), 동시성(이미 마감된 투표, 판매완료된 상품), 네트워크 오류와 중복 제출 방지(더블탭), 한도 도달(업로드 개수 초과) 시의 동작을 적는다.
- 수치는 구체적으로 적는다. "적당히 제한"이 아니라 "최소 1,000원 / 최대
99,999,000원, 10원 단위". 요청에 없어 정할 수 없는 값은 합리적으로 정하되
끝에
(가정)을 붙인다 — Storyboard 의 가정 전달 규칙과 같다.
Output
<스킬경로>/resources/template.html 의 <head> 전체 — preconnect 링크, mermaid <script> 태그, mermaid.initialize({...}) 설정, <style> 블록 — 를 그대로 인라인한 단일 HTML 파일을 만든다. 손으로 옮겨 적지 말고 <스킬경로>/scripts/scaffold.py 로 뼈대를 만든다 — 430줄 CSS 를 재작성하면 토큰을 크게 쓰고, 오타 하나에 검증기가 미정의 클래스로 막는다. <style> 만 가져오면 04 IA · 06 Service Flow · 07.x Sequence Diagram 슬라이드의 mermaid 다이어그램이 렌더러 없이 원문 텍스트로 남는다. 채팅에 코드 블록으로 출력하지 않는다 — 사용 중인 런타임의 파일 쓰기 수단으로 <프로젝트명>_storyboard.html 로 저장하고, 같은 디렉터리에 <프로젝트명>_business-rules.md 를 저장한 뒤, 두 저장 경로를 사용자에게 알린다. 파일명 접미사(_storyboard.html / _business-rules.md)를 지켜야 검증기가 두 파일을 짝으로 인식한다.
<스킬경로>/scripts/validate_storyboard.py의 종료 코드가 0이 아닌 산출물은 완료로 간주하지
않는다. Business Rules 문서의 위반도 같은 종료 코드에 합산된다. check_badge_overflow.py
와 check_badge_alignment.py 도 같은 기준이다. 세 검증을 통과하기 전에는 최종
산출물로 전달하지 않는다. Chrome 이 있으면 check_layout_runtime.py 도 exit 0 이어야
한다 — 없으면 그 사실을 결과에 적는다.
PDF · PPTX 내보내기
사용자가 PDF/PPTX 를 요청하면 내보내기 절차를 읽고 실행한다.
Markup
화면 상세 마크업을 작성할 때 마크업 예제를 참조한다.
Files (doksam-skills)
-
agents
-
antigravity.md 1.1 KB
--- name: mobile-web-planner description: 모바일 웹·앱 IA와 PPT 스타일 화면설계서를 작성하거나 수정할 때 사용하는 UX/UI 수석 기획자 --- # Mobile Web Planner Agent 당신은 모바일 웹·앱 UX/UI 수석 기획자다. 모바일 화면기획, IA, Wireframe 또는 Storyboard 요청에는 `mobile-web-planner` Skill을 작업 계약의 단일 원본으로 사용한다. 요구사항과 가정을 정리하고 IA와 화면 목록을 확정한 다음 Storyboard를 생성하거나 수정한다. Skill에 포함된 검증기를 실행해 모든 위반을 수정한다. 브라우저 도구를 사용할 수 있으면 슬라이드의 잘림, 겹침과 가독성을 확인한다. 구조 검증을 통과한 자체 완결형 HTML 파일만 최종 산출물로 전달한다. 검증기 통과는 **하한선이지 완료 조건이 아니다.** 목업 본문이 자리표시자· 설명 재탕·빈 뼈대가 아니라 실제 앱 화면으로 읽히는지 자체 점검을 통과해야 완료다. 화면을 스크립트로 생성하는 경우에도 목업 본문은 화면마다 달라야 하며 공용 상수 문자열을 쓰지 않는다. -
claude.md 1 KB
--- name: mobile-web-planner description: 모바일 웹·앱 IA와 PPT 스타일 화면설계서를 작성하거나 수정할 때 사용하는 UX/UI 수석 기획자 skills: - mobile-web-planner --- `mobile-web-planner` Skill을 작업 계약의 단일 원본으로 사용한다. 사용자 요청을 요구사항과 가정으로 정리하고, IA와 화면 목록을 확정한 뒤 Storyboard를 생성하거나 수정한다. Skill에 포함된 검증기를 실행하고 위반을 수정한다. 브라우저 도구를 사용할 수 있으면 렌더링 결과의 잘림, 겹침, 가독성도 확인한다. 구조 검증을 통과한 자체 완결형 HTML 파일만 최종 산출물로 전달한다. 검증기 통과는 **하한선이지 완료 조건이 아니다.** 목업 본문이 자리표시자· 설명 재탕·빈 뼈대가 아니라 실제 앱 화면으로 읽히는지 자체 점검을 통과해야 완료다. 화면을 스크립트로 생성하는 경우에도 목업 본문은 화면마다 달라야 하며 공용 상수 문자열을 쓰지 않는다. -
codex.toml 1 KB
name = "mobile_web_planner" description = "모바일 웹·앱 IA와 PPT 스타일 화면설계서를 작성하거나 수정하는 UX/UI 수석 기획자" developer_instructions = """ mobile-web-planner 스킬을 작업 계약의 단일 원본으로 사용한다. 요구사항과 가정을 정리하고, IA 와 화면 목록을 확정한 뒤 Storyboard 를 생성하거나 수정한다. 스킬에 포함된 검증기를 실행해 모든 위반을 수정하고, 통과할 때까지 검증을 반복한다. 브라우저 도구를 쓸 수 있으면 렌더링된 슬라이드의 잘림·겹침·가독성을 확인한다. 구조 검증을 통과한 자체 완결형 HTML 산출물만, 파일 경로와 주요 가정과 함께 전달한다. 검증기 통과는 하한선이지 완료 조건이 아니다 — 목업 본문이 자리표시자·설명 재탕·빈 뼈대가 아니라 실제 앱 화면으로 읽혀야 완료다. 화면을 스크립트로 생성해도 목업 본문은 화면마다 달라야 하며 공용 상수 문자열을 쓰지 않는다. """ -
openai.yaml 257 B
interface: display_name: "Mobile Web Planner" short_description: "모바일 IA와 화면설계서 생성·수정·검증" default_prompt: "$mobile-web-planner 로 내 요구사항에서 모바일 앱 화면설계서를 만들고 검증까지 해줘."
-
-
references
-
export.md 3.5 KB
# PDF · PPTX 내보내기 사용자가 PDF 나 PPT 를 요청하면 `export_deck.py` 를 쓴다. 손으로 Chrome 명령을 조립하지 않는다. ```sh python3 <스킬경로>/scripts/export_deck.py <프로젝트명>_storyboard.html # -> <프로젝트명>_storyboard.pdf, <프로젝트명>_storyboard.pptx ``` `--pdf-only` / `--pptx-only` 로 하나만 만들 수 있고, `--scale` 로 PPTX 캡처 배율을 조절한다(기본 2.0 = 2800×1576px, A4 기준 약 240dpi). 캡처는 병렬로 돌며 `--jobs` 로 동시 실행 수를 조절한다(기본은 코어 수 - 2, 최대 8). 46슬라이드 기준 30초 안팎이다. mermaid 슬라이드는 렌더 시점의 텍스트 측정 차이로 **실행마다 레이아웃이 미세하게 흔들린다.** 내용은 동일하고 잘리지 않는다 — 46장 중 1장꼴로 픽셀이 달라지는 정도이니 산출물을 바이트로 비교하지 않는다. **두 형식은 렌더 경로가 다르다 — 의도된 것이다.** | | 경로 | 텍스트 | |---|---|---| | PDF | 인쇄 CSS + `--print-to-pdf` | 벡터 · 선택/검색 가능 | | PPTX | 슬라이드별 PNG + OOXML 조립 | 이미지 | 같은 HTML 을 같은 렌더 엔진으로 그리므로 내용은 동일하다. **PDF 까지 이미지로 만들지 않는다** — 텍스트 선택·검색과 인쇄 선명도를 잃는다. 사용자가 "PPT 처럼 똑같이" 를 요구해도 이 구분은 유지하고 이유를 설명한다. ### 설명 패널을 PPT 에서 고치게 하려면 — `--editable-desc` 기본 PPTX 는 슬라이드마다 전면 이미지 한 장이라 **PPT 에서 한 글자도 고칠 수 없다.** 사용자가 "PPT 에서 문구를 다듬겠다" 고 하면 이 옵션을 쓴다. ```sh python3 <스킬경로>/scripts/export_deck.py <산출물>.html --editable-desc ``` 화면 상세(`09.x`)의 **우측 설명 패널만** PPT 도형(배지 칩 + 텍스트 상자)으로 얹는다. 목업은 이미지로 둔다 — 기획자가 고치고 싶은 것은 설명 문구지 목업이 아니고, 목업까지 도형으로 옮기는 것은 레이아웃을 PPT 오브젝트 모델로 다시 짜는 별개의 작업이다. - 이미지 쪽 패널 본문은 비운 채 캡처하므로 글자가 겹쳐 보이지 않는다. - 슬라이드당 Chrome 이 **2회** 돈다(실측 1 + 캡처 1). 25슬라이드 기준 35초 안팎. - PPT 가 쓰는 글꼴이 브라우저와 달라 **줄바꿈 위치가 미세하게 달라질 수 있다.** 그래서 기본값이 아니다 — 사용자가 편집을 원할 때만 켠다. - 설명 패널이 없는 슬라이드(표지·IA·시퀀스 등)는 그대로 이미지 한 장이다. ### 인쇄 CSS 만 쓸 때 템플릿에 인쇄 CSS 가 들어 있어 브라우저에서 인쇄(⌘P)해도 **한 장에 한 슬라이드씩 A4 가로**로 떨어진다. 용지·여백·배율을 따로 만질 필요가 없다 — 문서가 `@page` 로 A4 가로를 지정하고 배율도 스스로 잡는다. 인쇄 대화상자의 **"배경 그래픽"** 옵션만 켜져 있으면 된다. headless 로 직접 뽑을 때는 `--virtual-time-budget` 이 mermaid 가 렌더될 시간을 준다. 없으면 IA·흐름도·시퀀스 슬라이드가 **빈 칸으로 인쇄된다.** 16:9 슬라이드를 A4(1.414)에 넣으면 위아래로 21mm 씩 여백이 남는다. 이건 결함이 아니라 PPT 덱을 A4 로 뽑을 때의 정상 결과다. 사용자가 "A4 에 꽉 채워 달라" 고 하면 슬라이드 비율을 바꾸는 게 아니라 이 사실을 알린다 — 비율을 바꾸면 목업 크기와 배지 좌표가 전부 어긋난다. -
markup-examples.md 6.4 KB
# Markup ## 화면 상세 (09.x) — 좌우 분할 ```html <div class="ppt-slide"> <div class="ppt-top-bar"> <div class="ppt-top-no">NO. 09.1</div> <div class="ppt-top-title">Main Home</div> <div class="ppt-head-label">화면 Type</div> <div class="ppt-head-value">MOBILE WEB</div> <div class="ppt-head-label">요구사항 ID</div> <div class="ppt-head-value">-</div> <!-- Page 박스는 CSS 가 자동으로 붙는다. ppt-top-proj 는 두지 않는다(푸터와 중복) --> </div> <div class="ppt-meta-bar"> <div class="ppt-meta-label">화면 ID</div> <div class="ppt-meta-id">DTC-MAIN-001</div> <div class="ppt-meta-label">Location</div> <div class="ppt-meta-value">홈</div> <div class="ppt-meta-label" style="margin-left:auto;">작업자</div> <div class="ppt-meta-value">UX 기획</div> </div> <div class="ppt-content"> <div class="ppt-wireframe"> <div class="mock"> <div class="mock-screen"> <div class="mock-status"></div> <!-- 빈 div 하나 — 9:41·배터리는 CSS 가 그린다 --> <div class="mock-header"><span><span style="font-size:20px; font-weight:400; margin-right:8px;">‹</span>메인 홈</span></div> <div class="mock-body" style="position:relative; background:#f2f4f6;"> <span class="pointer-badge" style="position:absolute; top:20px; left:2px; z-index:10;">1</span> <!-- 목업 내용. 세부 스타일은 인라인 style 로. 카드는 아래 "목업 밀도" 스니펫 참조 --> </div> <div class="mock-footer-pill"> <!-- 탭 바 있는 화면의 기본형. 배지는 여기(position:relative)에 얹는다 --> <span class="pointer-badge" style="position:absolute; top:10px; left:2px; z-index:10;">4</span> <div style="display:flex; align-items:center; gap:6px; background:rgba(255,255,255,0.22); border:1px solid rgba(255,255,255,0.3); border-radius:17px; padding:6px 11px; box-shadow:inset 0 1px 2px rgba(255,255,255,0.35), 0 2px 8px rgba(0,0,0,0.25);"> <svg class="icon" viewBox="0 0 256 256" style="fill:#fff; width:14px; height:14px;"><path d="M218.83,103.77l-80-75.48a1.14,1.14,0,0,1-.11-.11,16,16,0,0,0-21.53,0l-.11.11L37.17,103.77A16,16,0,0,0,32,115.55V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V160h32v48a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V115.55A16,16,0,0,0,218.83,103.77ZM208,208H160V160a16,16,0,0,0-16-16H112a16,16,0,0,0-16,16v48H48V115.55l.11-.1L128,40l79.9,75.43.11.1Z"/></svg> <span style="font-size:11px; font-weight:800; color:#fff;">홈</span> </div> <svg class="icon" viewBox="0 0 256 256" style="fill:rgba(255,255,255,0.75); width:14px; height:14px;"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg> </div> </div> <div class="mock-caption">메인 홈 (DTC-MAIN-001)</div> </div> </div> <div class="ppt-desc-panel"> <div class="ppt-desc-header">Description (화면설명)</div> <div class="ppt-desc-body"> <ul class="desc-list"> <li><span class="desc-num">1</span> <div><b>배너 영역</b><br>주요 속보 롤링 (Max. 5개)<br>탭: 공지 상세로 이동 (DTC-NOTICE-002)<br><code>Banner</code></div></li> <li><span class="desc-num">2</span> <div><b>네비게이션</b><br>탭: 해당 카테고리 목록 전환<br>스와이프: 인접 탭 이동<br><code>Tabs</code></div></li> </ul> </div> </div> </div> <div class="ppt-footer"> {{PROJECT_NAME}} | Ver.{{VERSION}} </div> </div> ``` ## 화면 상세 — 목업 2개 (상태 비교) 좌측 패널에 `mock` 을 나란히 두고 각각 `mock-caption` 으로 라벨을 붙인다. 배지 번호는 2단이다 — 첫 목업이 `1-1`·`1-2`, 두 번째 목업이 `2-1`. 축소율과 간격은 템플릿이 처리하므로 인라인으로 크기를 주지 않는다. 캡션에는 라벨과 함께 그 목업의 화면 ID 를 적어 두 번째 화면의 ID 도 문서 안에 정의된다. ```html <div class="ppt-wireframe"> <div class="mock"> <div class="mock-screen"> <div class="mock-status"></div> <div class="mock-header">필터</div> <div class="mock-body" style="position:relative;"> <span class="pointer-badge" style="position:absolute; top:20px; left:2px; z-index:10;">1-1</span> <!-- 선택 전 목록 --> <span class="pointer-badge" style="position:absolute; top:200px; left:2px; z-index:10;">1-2</span> <!-- 적용 버튼 (비활성) --> </div> </div> <div class="mock-caption">필터 기본 (DTC-FILTER-001)</div> </div> <div class="mock"> <div class="mock-screen"> <div class="mock-status"></div> <div class="mock-header">필터</div> <div class="mock-body" style="position:relative;"> <span class="pointer-badge" style="position:absolute; top:20px; left:2px; z-index:10;">2-1</span> <!-- 선택된 칩이 강조된 목록 --> </div> </div> <div class="mock-caption">필터 선택됨 (DTC-FILTER-002)</div> </div> </div> <div class="ppt-desc-panel"> <div class="ppt-desc-header">Description (화면설명)</div> <div class="ppt-desc-body"> <ul class="desc-list"> <li><span class="desc-num">1-1</span> <div><b>필터 목록 (기본 상태)</b><br>미선택 시 전체 조건 노출 <code>ChipGroup</code></div></li> <li><span class="desc-num">1-2</span> <div><b>적용 버튼 (기본 상태)</b><br>선택 0건이면 비활성 <code>Button (disabled)</code></div></li> <li><span class="desc-num">2-1</span> <div><b>필터 목록 (선택됨)</b><br>선택 항목 Primary 강조, 상단 고정 <code>ChipGroup (selected)</code></div></li> </ul> </div> </div> ``` ## 표지·이력·목차·IA·화면목록·흐름도·시퀀스·공통규칙 — 통짜 (화면 상세 제외 전부) ```html <div class="ppt-slide"> <div class="ppt-top-bar"> <div class="ppt-top-no">NO. 04</div> <div class="ppt-top-title">Information Architecture</div> <div class="ppt-top-proj">{{PROJECT_NAME}}</div> </div> <div class="ppt-content"> <div class="ppt-body-full"> <!-- 텍스트, 표, 또는 <div class="mermaid"> 다이어그램 --> </div> </div> <div class="ppt-footer"> {{PROJECT_NAME}} | Ver.{{VERSION}} </div> </div> ``` -
self-check.md 5.9 KB
# 저장 전 자체 점검 파일을 저장하기 전에 완성된 마크업을 훑으며 아래 열여덟 가지를 센다. 어긋나는 항목이 있으면 저장 전에 고친다. 템플릿의 CSS 를 그대로 옮기지 않고 다시 썼더라도 이 점검은 그대로 수행한다. 1. **클래스** — 산출물에 등장하는 `class` 값을 전부 모아 Class Quick Reference 표와 대조한다. 표에 없는 이름이 하나라도 있으면 그 `class` 를 지우고 같은 효과를 인라인 `style` 로 옮긴다. 표에 없는 클래스는 CSS 정의가 없어 아무 스타일도 적용되지 않는다. 2. **이모지** — 이모지 개수가 0 인가. 하나라도 있으면 Phosphor 인라인 SVG 아이콘으로 바꾸거나 지운다. `‹` `⋮` 같은 타이포그래피 문자는 이모지가 아니므로 그대로 둔다. 3. **배지 좌표** — 모든 `pointer-badge` 의 `left` 값이 `2px` 인가. 다른 값이 하나라도 있으면 `2px` 로 바꾼다. 배지가 겹쳐 보이면 `left` 대신 `top` 을 조정한다. 4. **배지 개수·표기** — 슬라이드마다 `pointer-badge` 개수와 `desc-num` 개수가 같은가. 다르면 모자란 쪽을 채워 1:1 로 맞춘다. `desc-num` 에 원문자(①②③)가 있으면 배지와 같은 평문 표기(`1`, `1-1`)로 바꾼다. 5. **화면 순서** — `03 Index` 표의 행 순서, `04 IA` 의 노드 순서, `05 Screen List` 의 행 순서, `09.x` 슬라이드 순서 네 곳이 같은가. 그리고 그 순서가 사용자가 기능을 나열한 순서와 같은가(진입 화면은 `09.1`). 다르면 사용자 나열 순서를 기준으로 네 곳을 함께 맞춘다. 6. **목업 개수** — `09.x` 마다 목업 트리거 표(위 `## 목업 여러 개 배치`)를 대조한다. 트리거에 해당하는데 `mock` 이 1개면 2개로 늘린다. 해당하지 않는데 2개면 1개로 줄인다. 그리고 목업 수와 `mock-caption` 수가 슬라이드마다 같은가 — 단일 목업에도 캡션이 있어야 한다. 7. **화면 위치·헤더** — `09.x` 마다 1행(`ppt-top-bar` 안 화면 Type·요구사항 ID 칸)과 2행(`ppt-meta-bar`: 화면 ID·Location·작업자)이 있고, Location 값이 `04 IA` 의 경로와 맞는가. 작업자 칸이 역할명인가(개인 이름 금지). `ppt-head-bar` 를 새로 만들지 않았는가. 화면 상세 외 슬라이드에는 화면 메타가 없어야 한다. 8. **화면 ID** — `09.x` 마다 `ppt-meta-id` 가 있는가. 각 `mock-caption` 에 그 목업의 ID 가 적혀 있는가. 본문에서 참조한 ID 를 모아 `ppt-meta-id` 와 `mock-caption` 에 정의된 ID 집합과 대조한다 — 어느 쪽에도 없는 ID 가 하나라도 있으면 그 화면을 추가하거나 참조를 고친다. 9. **Business Rules** — Storyboard 에 정의한 화면 ID 집합과 Business Rules 문서의 `##` 섹션 ID 집합이 정확히 같은가. 각 섹션에 `### 입력 검증` `### 출력 규칙` `### 인터랙션` `### 엣지케이스` 네 헤딩이 모두 있고 내용이 비어 있지 않은가(해당 없으면 `해당 없음 — <이유>`). Rules 본문에서 참조한 화면 ID 가 전부 Storyboard 에 정의돼 있는가. 10. **커버리지** — 기능 나열 없는 요청이라면: 같은 도메인의 상용 서비스에 있는 필수 플로우(온보딩/인증 · 프로필 · 내역 관리 · 알림 · 설정 · 신고/차단) 중 이 문서에 없는 것이 있는가. 있으면 화면을 추가하거나, 뺀 이유를 가정으로 명시했는지 확인한다. 나열 요청이어도 회원 전용 동작이 있으면 인증·온보딩·내 정보 화면이 있는가(없으면 제외 가정을 명시했는가). 11. **Screen List 커버리지** — `05 Screen List` 표에 등장하는 화면 ID 집합이 문서에 정의된 전체 화면 ID 집합(팝업·바텀시트 포함)과 같은가. 빠진 ID 가 있으면 행을 추가하고, 표에만 있는 ID 가 있으면 그 화면을 정의하거나 행을 지운다. 12. **Service Flow** — `06 Service Flow` 슬라이드에 mermaid `flowchart` 가 있고, 노드 라벨에 화면명과 화면 ID 가 함께 적혀 있으며, 흐름도가 참조한 ID 가 전부 문서에 정의돼 있는가. 13. **시퀀스 커버리지** — Business Rules 각 화면의 `### 인터랙션` 표에서 상태 변경 동작(생성·제출·확정·취소류)을 모은다. 그 트랜잭션 집합과 `07.x` 슬라이드 집합이 1:1 인가. 빠진 트랜잭션이 있으면 시퀀스를 추가하고, 조회뿐인 화면에 시퀀스가 있으면 뺀다. 각 `07.x` 에 `sequenceDiagram` 과 관련 화면 ID 가 있는가. 14. **Screen List 유형 정합** — `05 Screen List` 모든 행에 유형(`화면`/`팝업`/`바텀시트`)이 적혀 있는가. 유형이 `화면` 인 ID 가 전부 `09.x` 에 정의돼 있는가. 15. **권한 매트릭스** — 문서에 역할이 2개 이상 등장하면(회원/운영진, 구매자/판매자 등) Business Rules 상단에 `## 권한 매트릭스` 가 있고, 역할 정의 표와 기능×화면 ID×역할 표가 BR 본문의 권한 분기와 일치하는가. 역할이 하나뿐이면 없어야 정상이다. 16. **이벤트 커버리지** — 목업의 터치 가능 요소(버튼·탭·행·칩·토글·FAB·입력)마다 배지가 있는가. 인터랙티브 설명 항목마다 이벤트 라벨(`탭:` `스와이프:` `롱프레스:` `입력:`) 줄이 있는가. storyboard 의 모든 이벤트가 Business Rules `### 인터랙션` 표에 대응 행을 갖는가. 읽기 전용 항목에 라벨이 없는 것은 정상이다. 17. **배지 좌표** — `check_badge_alignment.py` 가 겹침·순서 역전을 보고하지 않는가. 브라우저를 쓸 수 있다면 `<스킬경로>/resources/badge-audit.js` 의 `misaligned` 가 비어 있는가 — 인라인 `top` 은 추정값이라 실측 없이는 맞는지 알 수 없다. 18. **배지 인용** — Business Rules 각 `### 인터랙션` 표의 트리거 칸이 배지 번호를 인용하는가. 검증기가 잰다.
-
-
resources
-
badge-audit.js 4.6 KB
/* * badge-audit.js — 렌더된 화면설계서에서 pointer-badge 가 실제로 무엇을 가리키는지 잰다. * * 배지는 `position:absolute; top:<추정>px` 로 놓이는데, 목업은 `transform:scale(0.9)` * (목업 1개) 또는 `zoom:0.9`(2개 이상)로 축소되고 콘텐츠 높이는 렌더해야 정해진다. * 그래서 인라인 top 값만 보고는 배지가 의도한 요소 옆에 있는지 알 수 없다 — * 정적 검사(check_badge_alignment.py)로는 겹침·순서 역전까지가 한계다. * * 사용법: 산출물 HTML 을 브라우저로 열고(파일 경로 또는 로컬 서버) 개발자도구 * 콘솔이나 에이전트의 브라우저 실행 도구에 이 파일 내용을 그대로 붙여 실행한다. * 반환값의 `misaligned` 가 비어 있어야 한다. * * - misaligned[].gap: 배지 상단과 가장 가까운 콘텐츠 블록 상단의 거리(px, 목업 * 좌표계). 임계값 22px 는 배지 높이(24px)에서 온다 — 그보다 멀면 어떤 블록과도 * 같은 줄에 있지 않다는 뜻이다. * - fixes[]: 인라인 top 교정 제안. 배지는 타깃 블록 "바로 앞" 에 두는 관례이므로 * next sibling 을 타깃으로 보고, **배지 자신의 인라인 top 좌표계**(positioned * ancestor 기준 · scale/zoom 보정)로 환산한 suggestedTop 을 준다. 부분 목업 * (바텀시트)처럼 배지의 좌표 원점이 mock-body 상단이 아닌 경우에도 그대로 * 쓸 수 있는 값이다 — `measured / 0.9` 수동 환산은 이 경우 틀린다 (이슈 #72). * * 고칠 때는 반환값을 JSON 파일로 저장해 `scripts/apply_badge_audit.py` 에 넘긴다. * 인라인 top 을 손으로 되돌리지 않는다. */ (() => { const GAP_LIMIT = 22; const FIX_TOLERANCE = 3; // 이보다 작은 차이는 렌더 오차로 보고 제안하지 않는다 const misaligned = []; const fixes = []; const summary = { slides: 0, badges: 0, mermaidRendered: 0, mermaidTotal: 0 }; document.querySelectorAll(".mermaid").forEach((m) => { summary.mermaidTotal += 1; if (m.querySelector("svg")) summary.mermaidRendered += 1; }); document.querySelectorAll(".ppt-slide").forEach((slide) => { const no = (slide.querySelector(".ppt-top-no")?.textContent || "").trim(); if (!/^NO\.\s*09\./.test(no)) return; summary.slides += 1; const slideNo = no.replace(/^NO\.\s*/, ""); const containers = slide.querySelectorAll( ".mock-body, .mock-header, .mock-footer, .mock-footer-pill"); containers.forEach((body, containerIndex) => { const rect = body.getBoundingClientRect(); // scale(0.9)·zoom(0.9) 모두 rect(시각) / offsetWidth(레이아웃) 비율로 잡힌다. const scale = body.offsetWidth ? rect.width / body.offsetWidth : 1; // 스크롤된 목업에서도 좌표가 흔들리지 않게 scrollTop 을 더해 문서 좌표로 환산한다. const toLocal = (el) => Math.round(el.getBoundingClientRect().top - rect.top + body.scrollTop * scale); const blocks = [...body.querySelectorAll("*")] .filter((el) => !el.classList.contains("pointer-badge") && el.offsetHeight > 10) .map(toLocal); body.querySelectorAll(".pointer-badge").forEach((badge) => { summary.badges += 1; const top = toLocal(badge); const gap = blocks.length ? Math.min(...blocks.map((b) => Math.abs(b - top))) : Infinity; if (body.classList.contains("mock-body") && gap > GAP_LIMIT) { misaligned.push({ slide: no, mock: containerIndex, badge: badge.textContent.trim(), measuredTop: top, gap, }); } // ---- 교정 제안 ---- const styleTop = /top:\s*(-?\d+(?:\.\d+)?)px/.exec(badge.getAttribute("style") || ""); const target = badge.nextElementSibling; if (!styleTop || !target) return; const inlineTop = parseFloat(styleTop[1]); // 배지→타깃의 시각 거리(rect 차)를 scale 로 되돌리면 인라인 좌표계의 보정량이 // 된다. 배지와 타깃이 같은 positioned ancestor 아래에 있으므로 원점이 어디든 // (mock-body 상단이든 바텀시트 내부든) 상대 보정은 항상 옳다. const delta = (target.getBoundingClientRect().top - badge.getBoundingClientRect().top) / scale; const suggestedTop = Math.round(inlineTop + delta); if (Math.abs(suggestedTop - inlineTop) > FIX_TOLERANCE) { fixes.push({ slide: slideNo, label: badge.textContent.trim(), inlineTop, suggestedTop }); } }); }); }); return { scale: 0.9, summary, misaligned, fixes }; })(); -
desc-measure.js 2.4 KB
/* * desc-measure.js — 렌더된 화면 상세 슬라이드에서 Description 패널을 실측한다. * * export_deck.py 의 --editable-desc 가 쓴다. 설명 패널을 PPT 텍스트 상자로 * 얹으려면 각 항목의 배지 칩과 본문이 슬라이드 안 어디에 있는지 알아야 하는데, * 그 위치는 렌더해야 정해진다 (본문 길이에 따라 항목 높이가 달라지고, 항목이 * 8개를 넘으면 템플릿이 목록을 자동 압축한다). * * 좌표는 슬라이드 좌상단 기준 px 다. export_deck.py 가 EMU 로 환산한다. * 반환값은 슬라이드 하나에 대한 것이며, 설명 패널이 없으면 items 가 빈 배열이다. */ (() => { const slide = document.querySelector(".ppt-slide"); if (!slide) return { error: "ppt-slide 없음" }; const base = slide.getBoundingClientRect(); const rel = (el) => { const r = el.getBoundingClientRect(); return { x: r.left - base.left, y: r.top - base.top, w: r.width, h: r.height, }; }; // 설명 항목의 본문은 <b>제목</b><br>줄<br>줄 구조다. <br> 로 끊어 줄 배열을 // 만들고, 첫 줄이 제목인지(=<b> 로 시작하는지)를 함께 돌려준다. <code> 는 // 컴포넌트명 표기라 본문 줄로 합친다. const linesOf = (node) => { const out = []; let buf = ""; let boldFirst = false; let seenText = false; node.childNodes.forEach((child) => { if (child.nodeName === "BR") { out.push(buf.trim()); buf = ""; return; } const text = (child.textContent || "").replace(/\s+/g, " "); if (!seenText && text.trim()) { seenText = true; boldFirst = child.nodeName === "B" || child.nodeName === "STRONG"; } buf += text; }); if (buf.trim()) out.push(buf.trim()); return { lines: out.filter(Boolean), boldFirst }; }; const items = []; slide.querySelectorAll(".ppt-desc-panel .desc-list > li").forEach((li) => { const chip = li.querySelector(".desc-num"); const body = li.querySelector("div"); if (!chip || !body) return; const { lines, boldFirst } = linesOf(body); items.push({ label: chip.textContent.trim(), badge: rel(chip), text: rel(body), lines, boldFirst, }); }); const panel = slide.querySelector(".ppt-desc-panel"); return { slide: { w: base.width, h: base.height }, hasPanel: Boolean(panel), items, }; })(); -
layout-probe.js 3.5 KB
/* * layout-probe.js — 렌더된 화면설계서의 geometry 를 그대로 뽑아 온다. * * badge-audit.js 가 "배지가 무엇을 가리키는가"(의미)를 재는 반면, 이쪽은 * "레이아웃이 깨졌는가"(구조)만 잰다 — 슬라이드 overflow, 배지 이탈, 배지 * 겹침, 설명 패널 잘림·겹침. 템플릿 CSS 변경의 회귀를 잡는 것이 목적이라 * 판정 자체는 하지 않고 **원시 좌표만** 돌려준다. 임계값 판정은 * scripts/check_layout_runtime.py 가 한다 — 그래야 브라우저 없이도 판정 * 로직을 단위 테스트할 수 있고, 임계값이 파이썬 한 곳에만 존재한다. * * 사용법: check_layout_runtime.py 가 산출물에 주입해 실행한다. 수동으로는 * 브라우저 콘솔에 그대로 붙여 넣어도 같은 JSON 을 얻는다. */ (() => { const R = (n) => Math.round(n * 10) / 10; const box = (el) => { const r = el.getBoundingClientRect(); return { top: R(r.top), left: R(r.left), right: R(r.right), bottom: R(r.bottom) }; }; // 배지의 좌표 원점이 되는 컨테이너들. mock-body 밖(헤더·푸터·필 탭)에 놓인 // 배지는 원점이 달라 서로 비교하면 안 된다 — 컨테이너 단위로 묶는다. const HOSTS = ".mock-body, .mock-header, .mock-footer, .mock-footer-pill"; const slides = [...document.querySelectorAll(".ppt-slide")].map((slide, index) => { const no = (slide.querySelector(".ppt-top-no")?.textContent || "").trim(); const containers = [...slide.querySelectorAll(HOSTS)].map((host) => ({ kind: host.className.split(/\s+/).find((c) => c.startsWith("mock-")) || "unknown", rect: box(host), // 스크롤 컨테이너(mock-body)는 잘려 보이는 부분이 실제 가시 영역이다. clipped: host.scrollHeight > host.clientHeight + 1, badges: [...host.querySelectorAll(":scope > .pointer-badge")].map((b) => ({ label: b.textContent.trim(), ...box(b), })), })).filter((c) => c.badges.length); const panels = [...slide.querySelectorAll(".ppt-desc-body")].map((panel) => ({ clientH: panel.clientHeight, scrollH: panel.scrollHeight, })); const items = [...slide.querySelectorAll(".desc-list > li")].map((li) => ({ label: (li.querySelector(".desc-num")?.textContent || "").trim(), ...box(li), })); // scrollWidth/Height 는 정수로 반올림된다. 슬라이드 높이가 787.5px 처럼 // 소수라 그 값만 보면 회귀가 없어도 ±3px 이 흔들린다. 실제로 잘리는지는 // 자손 rect 가 슬라이드 rect 를 넘는지로 잰다. const sr = slide.getBoundingClientRect(); let overRight = 0; let overBottom = 0; slide.querySelectorAll("*").forEach((el) => { const r = el.getBoundingClientRect(); if (!r.width && !r.height) return; overRight = Math.max(overRight, r.right - sr.right); overBottom = Math.max(overBottom, r.bottom - sr.bottom); }); return { index, no, mermaid: slide.querySelectorAll(".mermaid").length, slide: { w: R(sr.width), h: R(sr.height), overRight: R(overRight), overBottom: R(overBottom), }, containers, panels, items, }; }); return { viewport: { w: window.innerWidth, h: window.innerHeight }, mermaid: { total: document.querySelectorAll(".mermaid").length, rendered: [...document.querySelectorAll(".mermaid")].filter((m) => m.querySelector("svg")).length, }, slides, }; })(); -
template.html 24.2 KB · in bundle
-
-
scripts
-
apply_badge_audit.py 5.1 KB
#!/usr/bin/env python3 """badge-audit 실측 결과(JSON)를 산출물 HTML 의 인라인 top 에 일괄 반영한다. `resources/badge-audit.js` 를 브라우저에서 실행해 반환값을 JSON 파일로 저장한 뒤 이 스크립트에 넘긴다. 반환값의 `fixes[]` 는 배지 자신의 인라인 top 좌표계(positioned ancestor 기준 · scale/zoom 보정 완료)로 환산된 값이므로, 컨테이너가 mock-body 든 바텀시트 내부든 그대로 치환하면 된다 — `measured/0.9` 수동 환산은 부분 목업에서 틀린다 (이슈 #72). 반영 후 같은 스킬의 정적 검증기 두 개(check_badge_overflow / check_badge_alignment) 를 재실행해 결과를 함께 보고한다. stdlib 만 사용한다. 사용법: python3 scripts/apply_badge_audit.py <산출물.html> <audit.json> [--dry-run] [--tolerance N] 종료 코드: 반영(또는 dry-run 예고)이 전부 성공하고 재검증도 통과하면 0, 아니면 1. """ import argparse import json import re import subprocess import sys from pathlib import Path SCRIPTS = Path(__file__).resolve().parent #: 이보다 작은 차이는 렌더 오차로 보고 건너뛴다 (badge-audit.js 와 동일 기본값). DEFAULT_TOLERANCE = 3 def slide_sections(html): """상단 바 앵커로 자른 {번호: (시작, 끝)} — 첫 등장 기준.""" heads = list(re.finditer(r'class="ppt-top-no">NO\.\s*([\d.]+)<', html)) out = {} for i, m in enumerate(heads): end = heads[i + 1].start() if i + 1 < len(heads) else len(html) out.setdefault(m.group(1), (m.end(), end)) return out def badge_pattern(inline_top, label): """슬라이드 구간 안에서 배지 하나를 특정하는 정규식. 라벨은 desc-num 과 1:1 이라 슬라이드 안에서 유일하고, 현재 inline top 을 함께 맞춰 이미 고쳐진 배지를 이중 치환하지 않는다. """ top = re.escape(f"{inline_top:g}") return re.compile( r'(class="pointer-badge"[^>]*style="[^"]*top:\s*)' + top + r'(px[^"]*"[^>]*>' + re.escape(label) + r'(?:</span>|<))') def apply_fixes(html, fixes, tolerance=DEFAULT_TOLERANCE): """(수정된 html, 적용 목록, 실패 목록) 을 반환한다. html 은 실패가 있어도 적용 가능한 것은 반영된 상태다.""" sections = slide_sections(html) applied, failed = [], [] for fx in fixes: no, label = fx["slide"], fx["label"] cur, want = fx["inlineTop"], fx["suggestedTop"] if abs(want - cur) <= tolerance: continue if no not in sections: failed.append((no, label, "슬라이드 없음")) continue s, e = sections[no] seg = html[s:e] pat = badge_pattern(cur, label) matches = list(pat.finditer(seg)) if len(matches) != 1: failed.append((no, label, f"배지 매칭 {len(matches)}건 (top:{cur:g})")) continue m = matches[0] seg = seg[:m.start()] + m.group(1) + f"{want:g}" + m.group(2) + seg[m.end():] html = html[:s] + seg + html[e:] sections = slide_sections(html) # 길이가 변했으므로 구간 재계산 applied.append((no, label, cur, want)) return html, applied, failed def revalidate(path): """정적 검증기 두 개를 재실행해 (성공 여부, 출력) 을 반환한다.""" ok, out = True, [] for script in ("check_badge_overflow.py", "check_badge_alignment.py"): proc = subprocess.run( [sys.executable, str(SCRIPTS / script), str(path)], capture_output=True, text=True) ok = ok and proc.returncode == 0 out.append(f"--- {script} (exit {proc.returncode})\n{proc.stdout.strip()}") return ok, "\n".join(out) def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("html", type=Path) ap.add_argument("audit_json", type=Path) ap.add_argument("--dry-run", action="store_true", help="바꿀 내용만 출력하고 저장하지 않는다") ap.add_argument("--tolerance", type=int, default=DEFAULT_TOLERANCE, help=f"이 값(px) 이하 차이는 건너뛴다 (기본 {DEFAULT_TOLERANCE})") args = ap.parse_args() audit = json.loads(args.audit_json.read_text(encoding="utf-8")) fixes = audit.get("fixes", audit if isinstance(audit, list) else []) if not fixes: print("fixes 가 비어 있다 — 반영할 것 없음") return 0 html = args.html.read_text(encoding="utf-8") new_html, applied, failed = apply_fixes(html, fixes, args.tolerance) for no, label, cur, want in applied: print(f"{'예고' if args.dry_run else '반영'} {no} 배지 {label}: top {cur:g} -> {want:g}") for no, label, why in failed: print(f"실패 {no} 배지 {label}: {why}") if not args.dry_run and applied: args.html.write_text(new_html, encoding="utf-8") ok, report = revalidate(args.html) print(report) if not ok: return 1 print(f"적용 {len(applied)}건 / 실패 {len(failed)}건" + (" (dry-run — 저장 안 함)" if args.dry_run else "")) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main()) -
check_badge_alignment.py 5.3 KB
#!/usr/bin/env python3 """pointer-badge 배치를 정적으로 점검한다 — 겹침과 순서 역전. 배지가 "의도한 요소를 가리키는가" 는 렌더해야만 알 수 있다(목업이 0.9배로 축소되고 콘텐츠 높이가 런타임에 정해진다). 그건 `resources/badge-audit.js` 를 브라우저에서 돌려 잡는다. 이 스크립트는 렌더 없이 잡히는 두 가지만 본다. 1. **겹침** — 같은 목업 안에서 배지 top 이 24px(배지 높이) 미만으로 붙으면 서로 가린다. 2. **순서 역전** — 배지 라벨은 위에서 아래로 매기는 것이 규약이므로 (1, 2, 3 / 1-1, 1-2), 라벨 순서와 top 순서가 어긋나면 좌표를 잘못 적은 것이다. 설명 리스트는 라벨 순으로 읽히는데 목업은 그 반대로 읽히게 된다. `check_badge_overflow.py` 와 역할이 다르다 — 그쪽은 가시 영역 이탈만 본다. stdlib 만 사용한다. 사용법: python3 scripts/check_badge_alignment.py <산출물.html> [...] 종료 코드: 문제가 없으면 0, 있으면 1. """ import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from validate_storyboard import detail_slides, markup_only # noqa: E402 #: 배지 높이(px). 이보다 가까우면 시각적으로 겹친다. BADGE_HEIGHT = 24 BADGE_RE = re.compile( r'<span class="pointer-badge"[^>]*style="([^"]*)"[^>]*>([^<]*)<') #: 배지를 담을 수 있는 컨테이너. 각각이 별도의 좌표 원점(position:relative)이므로 #: 서로 다른 컨테이너의 top 값은 비교 대상이 아니다 — mock-footer 의 `top:9px` 는 #: mock-body 의 `top:9px` 와 전혀 다른 위치다. mock-footer-pill 도 자체 #: position:relative 원점이므로 별도 컨테이너다 (이슈 #69). CONTAINER_RE = re.compile(r'class="mock-(?:body|header|footer|footer-pill)"') def badge_groups(slide_body): """슬라이드 본문을 (목업 번호, 컨테이너 번호, 블록) 단위로 자른다. 목업 경계는 `class="mock"` / `class="mock mock-partial"` 이고, 그 안에서 다시 mock-body·mock-header·mock-footer 로 나눈다. 좌표 비교는 같은 컨테이너 안에서만 뜻이 있다. """ mock_starts = [m.start() for m in re.finditer(r'class="mock[\s"]', slide_body)] groups = [] for mi, s in enumerate(mock_starts): end = mock_starts[mi + 1] if mi + 1 < len(mock_starts) else len(slide_body) mock = slide_body[s:end] cont = [m.start() for m in CONTAINER_RE.finditer(mock)] for ci, cs in enumerate(cont): ce = cont[ci + 1] if ci + 1 < len(cont) else len(mock) groups.append((mi, ci, mock[cs:ce])) return groups def badges_in(block): """(라벨, top) 목록을 문서 등장 순서대로 반환한다. top 이 없으면 건너뛴다.""" out = [] for style, label in BADGE_RE.findall(block): m = re.search(r"top:\s*(-?\d+)px", style) if m: out.append((label.strip(), int(m.group(1)))) return out def label_key(label): """'1-2' -> (1, 2), '3' -> (3,). 규약 밖 라벨은 정렬에서 제외한다.""" parts = label.split("-") if not all(p.isdigit() for p in parts) or not parts: return None return tuple(int(p) for p in parts) def check(path): """한 산출물을 점검해 문제 목록을 반환한다.""" markup = markup_only(Path(path).read_text(encoding="utf-8")) problems = [] for no, body in detail_slides(markup): for mi, _ci, block in badge_groups(body): badges = badges_in(block) for (la, ta), (lb, tb) in zip(badges, badges[1:]): if abs(tb - ta) < BADGE_HEIGHT: problems.append( f"{no} 목업{mi}: 배지 {la}({ta}px) 와 {lb}({tb}px) 가 " f"{abs(tb - ta)}px 간격으로 겹친다 (배지 높이 {BADGE_HEIGHT}px)") # 음수 top 은 mock-body 위쪽(헤더 영역) 요소를 가리키는 관용 패턴이라 # 라벨 순서와 어긋나는 것이 정상이다. 순서 검사에서 제외한다. keyed = [(label_key(l), l, t) for l, t in badges if t >= 0] keyed = [k for k in keyed if k[0] is not None] ordered = sorted(keyed) for (_ka, la, ta), (_kb, lb, tb) in zip(ordered, ordered[1:]): if tb < ta: problems.append( f"{no} 목업{mi}: 배지 {la}({ta}px) 보다 {lb}({tb}px) 가 위에 있다 " "— 라벨은 위에서 아래로 매긴다") return problems def main(): if len(sys.argv) < 2: print(__doc__, file=sys.stderr) return 2 total = 0 for path in sys.argv[1:]: problems = check(path) total += len(problems) print(f"===== {path} =====") for p in problems: print(f" X {p}") print(" => 배치 문제 없음" if not problems else f" => 문제 {len(problems)}건") print() if total: print("정적 점검은 겹침·순서까지다. 배지가 의도한 요소를 가리키는지는") print("resources/badge-audit.js 를 브라우저에서 실행해 확인한다.") return 1 if total else 0 if __name__ == "__main__": sys.exit(main()) -
check_badge_overflow.py 3.1 KB
#!/usr/bin/env python3 """pointer-badge 가 목업의 가시 영역 밖으로 밀려났는지 판정한다. `mock-body` 는 `overflow-y:auto` 라 내용이 넘쳐도 CSS 는 조용히 스크롤로 감춘다. 화면설계서는 종이/슬라이드로 읽히므로 스크롤로 감춰진 배지는 사실상 없는 것과 같다. 목업 프레임 높이에서 상태바·헤더·탭바를 뺀 가시 높이를 계산해, 그 아래에 놓인 배지를 보고한다. .mock 높이 694 + border 1px x 2 (box-sizing: border-box 이므로 692 내부) .mock-partial 높이 320 (팝업 위 회색 배경 힌트 블록만큼 body 가 줄어든다) .mock-status 26 / .mock-header 약 51 / .mock-footer 약 37 / .mock-footer-pill 54(44+하단 margin 10) .pointer-badge 높이 24 사용법: python3 scripts/check_badge_overflow.py <산출물.html> 종료 코드: 이탈 배지가 없으면 0, 있으면 1. """ import re import sys from pathlib import Path BADGE_H = 24 STATUS_H = 26 HEADER_H = 51 FOOTER_H = 37 PILL_H = 54 def mock_blocks(slide_html): """슬라이드 안의 목업 블록을 (부분목업 여부, 마크업) 으로 잘라 반환한다.""" for part in re.split(r'(?=<div class="mock(?: mock-partial)?">)', slide_html): if 'class="mock' not in part: continue head = part.split('>', 1)[0] yield 'mock-partial' in head, part def visible_height(is_partial, markup): """목업 본문(mock-body)의 가시 높이를 px 로 계산한다.""" if is_partial: hint = re.search(r'<div style="height:(\d+)px;background:#e2e8f0', markup) height = 320 - (int(hint.group(1)) if hint else 0) else: height = 692 if 'class="mock-status"' in markup: height -= STATUS_H if 'class="mock-header"' in markup: height -= HEADER_H if 'class="mock-footer"' in markup: height -= FOOTER_H if 'class="mock-footer-pill"' in markup: height -= PILL_H return height def check(path): html = re.sub(r"<style\b.*?</style>", "", Path(path).read_text(encoding="utf-8"), flags=re.S) bad = [] for m in re.finditer(r"NO\.\s*(09\.\d+)(.*?)(?=NO\.\s*09\.|\Z)", html, re.S): for is_partial, markup in mock_blocks(m.group(2)): avail = visible_height(is_partial, markup) for top in re.findall(r'class="pointer-badge"[^>]*top:\s*(\d+)px', markup): if int(top) + BADGE_H > avail: bad.append((m.group(1), "partial" if is_partial else "full", int(top), avail)) return bad def main(): if len(sys.argv) < 2: print(__doc__, file=sys.stderr) return 2 total = 0 for path in sys.argv[1:]: bad = check(path) total += len(bad) print(f"===== {path} =====") for no, kind, top, avail in bad: print(f" X {no} {kind} 목업: 배지 top {top}px + 24 > 가시 {avail}px") print(f" => 이탈 배지 {len(bad)}건" if bad else " => 이탈 배지 없음") return 1 if total else 0 if __name__ == "__main__": sys.exit(main()) -
check_layout_runtime.py 9.8 KB
#!/usr/bin/env python3 """렌더된 산출물의 레이아웃 회귀를 Chrome headless 로 잡는다 (이슈 #137). 정적 검사기(validate_storyboard·check_badge_overflow·check_badge_alignment)는 마크업의 인라인 좌표만 본다. 템플릿 CSS 가 바뀌어 목업 높이·gutter·설명 패널 밀도가 달라지면 마크업은 그대로인데 결과만 깨지고, 그건 렌더해야 보인다. 전체 픽셀 비교는 브라우저·폰트 버전이 바뀔 때마다 false positive 를 내므로 1차 계약은 **구조·좌표**다. 여기서 판정하는 것은 넷뿐이다. slide-overflow 슬라이드가 자기 16:9 박스를 넘겼다 (내용이 잘린다) badge-outside 배지가 자기 좌표 원점 컨테이너 밖으로 나갔다 badge-overlap 같은 컨테이너의 배지 둘이 겹쳤다 (번호를 못 읽는다) desc-clipped 설명 패널 내용이 패널 높이를 넘겼다 (인쇄 시 잘린다) desc-overlap 설명 항목끼리 겹쳤다 좌표 수집은 resources/layout-probe.js 가 하고 **임계값 판정은 이 파일이 한다** — 브라우저 없이도 판정 로직을 단위 테스트할 수 있게 하기 위해서다 (tests/test_layout_runtime.py). 기본은 오프라인 렌더다. 외부 폰트(@import)와 mermaid CDN 스크립트를 임시 사본에서 떼고 그린다 — CI 러너의 네트워크 상태가 판정을 흔들면 회귀 검사가 아니라 점집이 된다. mermaid 를 못 그리는 슬라이드는 slide-overflow 판정에서 제외하고 그 사실을 보고한다. 실제 mermaid 높이까지 보려면 --online 을 쓴다. 원본 파일은 절대 수정하지 않는다. 사용법: python3 scripts/check_layout_runtime.py <산출물.html> [...] [--chrome <경로>] [--online] [--json <저장경로>] 종료 코드: 위반 없으면 0, 있으면 1, 인자·Chrome 문제면 2. """ import argparse import html as _html import json import os import re import subprocess import sys import tempfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from export_deck import ( # noqa: E402 Chrome 탐색·대기시간은 내보내기와 같은 계약을 쓴다 DESIGN_W, RENDER_WAIT_MS, WINDOW_H, find_chrome, ) SKILL_ROOT = Path(__file__).resolve().parent.parent PROBE = SKILL_ROOT / "resources" / "layout-probe.js" #: 서브픽셀 반올림과 border 1px 은 회귀가 아니다. 이보다 큰 이탈만 본다. OVERFLOW_TOL = 2.0 BADGE_TOL = 1.0 #: 배지끼리 이만큼 이하로 겹치는 것은 그림자 여백 수준이라 읽기에 지장이 없다. OVERLAP_TOL = 1.0 #: 설계 기준 폭으로 고정하는 CSS. 목업 높이(694px)와 배지 인라인 top 은 전부 #: DESIGN_W=1400 에서 나온 절대값이라, 브라우저 창 폭에 따라 슬라이드가 #: 좁아지면 목업이 슬라이드를 넘긴다 — 그건 회귀가 아니라 창이 좁은 것이다. #: PDF·PPTX 내보내기가 쓰는 것과 같은 기하로 맞춰 그 구분을 없앤다. DESIGN_CSS = ("body{margin:0 !important;padding:0 !important;background:#fff !important;}" ".docwrap{max-width:none !important;gap:0 !important;}") def strip_network(html): """외부 요청을 유발하는 태그를 떼어 낸 사본 문자열을 만든다.""" html = re.sub(r'<script\b[^>]*\bsrc="https?://[^"]*"[^>]*>\s*</script>', "", html) html = re.sub(r'@import\s+url\([^)]*\);?', "", html) # mermaid 블록은 스크립트가 없으면 원문 텍스트로 흘러 슬라이드를 넘긴다. # 숨겨서 판정 대상에서 빼되, 슬라이드에 mermaid 가 있었다는 사실은 # probe 가 세어 보고한다. return html.replace("</style>", ".mermaid{display:none !important;}</style>", 1) def measure(chrome, path, offline=True): """Chrome 으로 한 번 렌더해 probe 의 JSON 을 받아 온다.""" snippet = PROBE.read_text(encoding="utf-8").strip().rstrip(";") inject = ('<script>window.addEventListener("load",()=>{setTimeout(()=>{' f"const r={snippet};" 'const p=document.createElement("pre");p.id="__layout__";' 'p.textContent=JSON.stringify(r);document.body.appendChild(p);' '},1200)});</script>') source = Path(path).read_text(encoding="utf-8") if offline: source = strip_network(source) source = source.replace("</style>", DESIGN_CSS + "</style>", 1) if "</body>" not in source: sys.exit(f"오류: </body> 가 없는 문서다 — {path}") source = source.replace("</body>", inject + "</body>", 1) with tempfile.TemporaryDirectory() as work: page = Path(work) / "probe.html" page.write_text(source, encoding="utf-8") result = subprocess.run( [chrome, "--headless", "--disable-gpu", "--hide-scrollbars", "--no-sandbox", f"--virtual-time-budget={RENDER_WAIT_MS}", f"--window-size={DESIGN_W},{WINDOW_H}", "--dump-dom", f"file://{page.resolve()}"], capture_output=True, text=True) if result.returncode != 0: sys.exit(f"오류: Chrome 실행 실패 (exit {result.returncode})\n{result.stderr[-600:]}") found = re.search(r'<pre id="__layout__">(.*?)</pre>', result.stdout, re.DOTALL) if not found: sys.exit("오류: probe 결과를 받지 못했다 — 문서가 렌더되지 않았을 수 있다") return json.loads(_html.unescape(found.group(1))) def _overlap(a, b): """두 사각형이 겹치는 폭·높이 중 작은 쪽. 안 겹치면 0 이하.""" return min( min(a["right"], b["right"]) - max(a["left"], b["left"]), min(a["bottom"], b["bottom"]) - max(a["top"], b["top"]), ) def judge(report, offline=True): """probe 측정값을 위반 목록으로 바꾼다. 브라우저 없이 테스트되는 지점.""" violations = [] def add(slide, kind, detail): violations.append({"slide": slide.get("no") or f"#{slide['index'] + 1}", "kind": kind, "detail": detail}) for slide in report.get("slides", []): geo = slide["slide"] skip_overflow = offline and slide.get("mermaid") if not skip_overflow: over_w = geo["overRight"] over_h = geo["overBottom"] if over_w > OVERFLOW_TOL or over_h > OVERFLOW_TOL: add(slide, "slide-overflow", f"내용이 슬라이드 밖으로 {max(over_w, over_h):.0f}px 넘쳤다 " f"(가로 {over_w:.0f}px · 세로 {over_h:.0f}px)") for container in slide.get("containers", []): rect = container["rect"] for badge in container["badges"]: out = max(rect["top"] - badge["top"], rect["left"] - badge["left"], badge["right"] - rect["right"], badge["bottom"] - rect["bottom"]) if out > BADGE_TOL: add(slide, "badge-outside", f"배지 {badge['label']} 가 {container['kind']} 밖으로 " f"{out:.0f}px 나갔다") badges = container["badges"] for i, first in enumerate(badges): for second in badges[i + 1:]: if _overlap(first, second) > OVERLAP_TOL: add(slide, "badge-overlap", f"배지 {first['label']} 와 {second['label']} 가 " f"{container['kind']} 안에서 겹쳤다") for panel in slide.get("panels", []): clipped = panel["scrollH"] - panel["clientH"] if clipped > OVERFLOW_TOL: add(slide, "desc-clipped", f"설명 패널 내용이 {clipped:.0f}px 잘렸다 " f"(항목을 줄이거나 밀도 규칙을 확인)") items = slide.get("items", []) for i, first in enumerate(items): for second in items[i + 1:]: if _overlap(first, second) > OVERLAP_TOL: add(slide, "desc-overlap", f"설명 항목 {first['label']} 와 {second['label']} 가 겹쳤다") return violations def main(argv=None): parser = argparse.ArgumentParser( description="Chrome headless 로 산출물 레이아웃 회귀를 검사한다") parser.add_argument("paths", nargs="+", help="검사할 산출물 HTML") parser.add_argument("--chrome", help="Chrome 실행 파일 경로") parser.add_argument("--online", action="store_true", help="외부 폰트·mermaid CDN 을 그대로 두고 렌더한다") parser.add_argument("--json", help="측정 원본을 저장할 경로") args = parser.parse_args(argv) chrome = find_chrome(args.chrome) offline = not args.online total = 0 dumps = {} for path in args.paths: if not Path(path).is_file(): print(f"오류: 파일이 없다 — {path}", file=sys.stderr) return 2 report = measure(chrome, path, offline=offline) dumps[path] = report violations = judge(report, offline=offline) total += len(violations) print(f"===== {path} =====") skipped = [s["no"] or f"#{s['index'] + 1}" for s in report["slides"] if offline and s.get("mermaid")] if skipped: print(f" - mermaid 슬라이드는 오프라인 렌더라 overflow 판정 제외: " f"{', '.join(skipped)}") for item in violations: print(f" X {item['slide']} [{item['kind']}] {item['detail']}") print(f" => 레이아웃 위반 {len(violations)}건" if violations else f" => 레이아웃 위반 없음 (슬라이드 {len(report['slides'])}장)") if args.json: Path(args.json).write_text(json.dumps(dumps, ensure_ascii=False, indent=2), encoding="utf-8") return 1 if total else 0 if __name__ == "__main__": sys.exit(main()) -
export_deck.py 26.4 KB
#!/usr/bin/env python3 """화면설계서 HTML 하나에서 PDF 와 PPTX 를 함께 만든다. python3 export_deck.py <프로젝트명>_storyboard.html # -> <프로젝트명>_storyboard.pdf, <프로젝트명>_storyboard.pptx 두 형식은 렌더 경로가 다르다. 의도된 것이다. PDF 인쇄 CSS + Chrome --print-to-pdf 텍스트가 벡터라 선택·검색된다 PPTX 슬라이드별 PNG + OOXML 조립 텍스트가 이미지다 같은 HTML 을 같은 렌더 엔진으로 그리므로 내용은 동일하다. PDF 까지 이미지로 만들면 텍스트 선택·검색과 인쇄 선명도를 잃으므로 그렇게 하지 않는다. PPT 를 편집 가능한 도형·텍스트로 만드는 것은 HTML/CSS 레이아웃을 PPT 오브젝트 모델로 다시 짜는 별개의 작업이며 이 스크립트의 범위가 아니다. stdlib 만 쓴다 — 이 저장소의 검증·생성 스크립트 공통 규약이다. """ import argparse import html import math import json import os import re import shutil import subprocess import sys import tempfile import zipfile from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path # 슬라이드 설계 기준. 목업 크기와 배지 인라인 top 이 전부 이 폭에서 나온 # 절대 픽셀값이라, 다른 폭으로 렌더하면 목업이 넘치거나 배지가 어긋난다. # 높이는 16:9 로 787.5px 이고, 창 크기는 정수라야 하므로 올려서 쓴다. DESIGN_W = 1400 DESIGN_H = DESIGN_W * 9 / 16 # 787.5 — EMU 환산의 기준 WINDOW_H = math.ceil(DESIGN_H) # 788 — --window-size 용 # PPTX 슬라이드 크기 (EMU). 16:9 와이드스크린 = 13.333 x 7.5 inch. EMU_W, EMU_H = 12192000, 6858000 # mermaid 는 JS 렌더다. 이 시간을 주지 않으면 IA·흐름도·시퀀스 슬라이드가 # 빈 칸으로 캡처·인쇄된다. RENDER_WAIT_MS = 15000 CHROME_CANDIDATES = ( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", ) XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main" NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main" NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types" # 관계 타입의 네임스페이스는 패키지 네임스페이스(REL_NS)와 다르다. 둘을 문자열 # 조작으로 파생시키면 package/2006/officeDocument/2006/... 같은 무효 URL 이 나오는데, # XML 은 여전히 well-formed 라 파싱 검사로는 잡히지 않는다 — 열 때야 실패한다. REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" def find_chrome(explicit=None): """Chrome 실행 파일을 찾는다. 경로를 하드코딩하지 않는다.""" for cand in filter(None, (explicit, os.environ.get("CHROME"), *CHROME_CANDIDATES)): found = cand if os.path.isfile(cand) else shutil.which(cand) if found: return found sys.exit( "오류: Chrome 을 찾을 수 없다. --chrome 으로 경로를 주거나 CHROME 환경변수를 " "설정할 것 (macOS 는 'Google Chrome.app', 리눅스는 google-chrome/chromium)" ) def run_chrome(chrome, *args): result = subprocess.run( [chrome, "--headless", "--disable-gpu", "--hide-scrollbars", f"--virtual-time-budget={RENDER_WAIT_MS}", *args], capture_output=True, text=True, ) if result.returncode != 0: sys.exit(f"오류: Chrome 실행 실패 (exit {result.returncode})\n{result.stderr[-600:]}") def split_slides(html): """`<div class="ppt-slide">` 블록을 문서 순서대로 잘라낸다. 반환값은 (슬라이드 번호, 블록 HTML) 목록. 파서를 쓰지 않고 div 깊이를 세는 이유는 stdlib 의 HTMLParser 로 원문을 그대로 되돌리기 어려워서다 — 인라인 style 과 SVG 가 많은 문서라 재직렬화하면 렌더가 달라진다. """ slides = [] tag = re.compile(r"<div\b|</div>") for match in re.finditer(r'<div class="ppt-slide">', html): depth, cursor = 0, match.start() while cursor < len(html): found = tag.search(html, cursor) if not found: break depth += 1 if found.group() != "</div>" else -1 cursor = found.end() if depth == 0: break block = html[match.start():cursor] no = re.search(r'class="ppt-top-no">NO\.\s*([\d.]+)<', block) slides.append((no.group(1) if no else str(len(slides) + 1), block)) return slides def isolated_page(head, block, ordinal): """슬라이드 하나만 담은 문서. 캡처 크기를 슬라이드에 정확히 맞춘다. Page No. 는 CSS counter 라 슬라이드를 떼어내면 1부터 다시 센다. counter-reset 으로 원본 순번을 유지한다. """ return ( f'{head}<body style="margin:0;padding:0;background:#fff;">' f'<div class="docwrap" style="max-width:none;gap:0;' f'counter-reset:slide {ordinal};">{block}</div></body></html>' ) # 인쇄 CSS 블록의 시작 표식. 템플릿 안에서 이 주석부터 </style> 까지가 # @page 와 @media print 규칙이다. PRINT_CSS_MARKER = "인쇄 · PDF 저장 (A4 가로" def template_print_css(): """템플릿에서 인쇄 CSS 블록을 떼어 온다. 인쇄 CSS 의 원본은 resources/template.html 한 곳이다. 여기에 복제해 두면 템플릿이 바뀔 때 조용히 어긋난다. """ template = Path(__file__).resolve().parent.parent / "resources" / "template.html" if not template.is_file(): return None text = template.read_text(encoding="utf-8") start = text.find(PRINT_CSS_MARKER) end = text.find("</style>", start) if start == -1 or end == -1: return None # 주석 여는 기호까지 포함되도록 앞으로 되짚는다 start = text.rfind("/*", 0, start) return text[start:end] def export_pdf(chrome, src, dest, workdir): """인쇄 CSS 를 태워 A4 가로 PDF 를 만든다. 인쇄 CSS 가 없는 예전 산출물이면 템플릿의 것을 임시 사본에 주입해 쓴다. 그러지 않으면 기본 용지(US Letter 세로)로 떨어지고 슬라이드가 페이지 경계에서 잘린 PDF 가 조용히 나온다 — 46슬라이드 문서가 20페이지로 나온 실측 사례가 있다. 사용자의 원본 파일은 건드리지 않는다. """ source = src if "@media print" not in src.read_text(encoding="utf-8"): css = template_print_css() if css is None: print("경고: 인쇄 CSS 가 없고 템플릿에서도 찾지 못했다 — " "PDF 가 기본 용지로 떨어진다", file=sys.stderr) else: patched = workdir / f"{src.stem}_print.html" patched.write_text( src.read_text(encoding="utf-8").replace("</style>", css + "</style>", 1), encoding="utf-8") source = patched print("알림: 인쇄 CSS 가 없는 산출물이라 템플릿의 것을 주입해 PDF 를 만든다 " "(원본 파일은 바꾸지 않는다)") run_chrome(chrome, "--no-pdf-header-footer", f"--print-to-pdf={dest}", f"file://{source.resolve()}") if not dest.is_file(): sys.exit(f"오류: PDF 가 생성되지 않았다 — {dest}") return dest def default_jobs(): """동시 캡처 수. 기계를 다 먹지 않도록 코어 두 개는 남긴다.""" return max(1, min(8, (os.cpu_count() or 4) - 2)) def _shoot_one(chrome, head, workdir, scale, index, block, blank_desc=False): """슬라이드 하나를 캡처한다. 병렬로 호출되므로 파일을 공유하지 않는다. 임시 HTML 을 한 파일에 덮어쓰며 재사용하면 병렬 실행에서 서로의 내용을 덮어써 엉뚱한 슬라이드가 찍힌다 — 슬라이드마다 별도 파일을 쓴다. """ page = workdir / f"_slide{index + 1}.html" markup = isolated_page(head, block, index) if blank_desc: # 본문만 감춘다 — visibility 라 레이아웃은 그대로여서 헤더 위치가 안 밀린다 markup = markup.replace("</style>", DESC_BLANK_CSS + "</style>", 1) page.write_text(markup, encoding="utf-8") shot = workdir / f"image{index + 1}.png" run_chrome(chrome, f"--window-size={DESIGN_W},{WINDOW_H}", f"--force-device-scale-factor={scale}", f"--screenshot={shot}", f"file://{page.resolve()}") page.unlink(missing_ok=True) if not shot.is_file(): sys.exit(f"오류: 슬라이드 {index + 1} 캡처 실패") return index, shot def shoot_slides(chrome, head, slides, workdir, scale, jobs=None, blank=None): """슬라이드마다 PNG 를 뜬다. 캡처는 서로 독립이므로 병렬로 돌린다. 비용은 대기가 아니라 Chrome 기동이다 — virtual time 은 타이머를 빨리 감을 뿐 벽시계 시간을 쓰지 않아, 대기를 줄여도 1회 3.4초에서 3.1초가 될 뿐이다. 그래서 동시 실행이 유일하게 의미 있는 개선이다. subprocess 대기 중에는 GIL 이 풀리므로 스레드로 충분하다. """ jobs = jobs or default_jobs() shots = [None] * len(slides) with ThreadPoolExecutor(max_workers=jobs) as pool: futures = [ pool.submit(_shoot_one, chrome, head, workdir, scale, index, block, bool(blank and blank[index])) for index, (_, block) in enumerate(slides) ] for future in as_completed(futures): index, shot = future.result() shots[index] = shot return shots # ---- Description 패널을 PPT 객체로 (--editable-desc) ---- # # 기본 pptx 는 슬라이드마다 전면 이미지 한 장이라 PPT 에서 글자를 고칠 수 없다. # 화면 상세(09.x)의 우측 설명 패널만 PPT 도형으로 얹으면 기획자가 실제로 고치고 # 싶어하는 문구가 편집 가능해진다. 목업은 이미지로 둔다. # # 핵심은 이미지 쪽 패널 본문을 비운 채 캡처하는 것이다. 안 그러면 래스터 글자 # 위에 텍스트 상자가 겹쳐 두 번 보인다. DESC_BLANK_CSS = ".ppt-desc-body{visibility:hidden !important;}" # 기준 산출물에서 확인한 서식. 좌표는 실측하고 서식만 여기서 고정한다. BADGE_PT, DESC_PT = 700, 840 # 100 = 1pt DESC_TITLE_RGB, DESC_BODY_RGB = "111827", "374151" DESC_LINE_SPACING = 145000 # 145% # 설계 px -> EMU. 고정 dpi(914400/96 = 9525)를 쓰면 안 된다 — 슬라이드는 # 13.333in(=96dpi 기준 1280px)인데 설계 폭은 1400px 이라 9.4% 어긋나 도형이 # 오른쪽으로 밀려 슬라이드 밖으로 나간다. 실측 사례가 있다. EMU_PER_PX = EMU_W / DESIGN_W # 8708.57 — 세로(EMU_H/787.5)와 같다 def _emu(px): return int(round(px * EMU_PER_PX)) def _xml_escape(text): return (text.replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) def measure_desc(chrome, head, slides, workdir, jobs, accent): """슬라이드마다 설명 패널을 실측한다. 패널이 없으면 None.""" script = (Path(__file__).resolve().parent.parent / "resources" / "desc-measure.js") if not script.is_file(): print("경고: desc-measure.js 가 없어 설명 패널을 객체로 만들지 못한다", file=sys.stderr) return [None] * len(slides) snippet = script.read_text(encoding="utf-8").strip().rstrip(";") inject = ('<script>window.addEventListener("load",()=>{setTimeout(()=>{' f"const r={snippet};" 'const p=document.createElement("pre");p.id="__desc__";' 'p.textContent=JSON.stringify(r);document.body.appendChild(p);' '},1200)});</script>') def one(index, block): page = workdir / f"_measure{index + 1}.html" page.write_text( isolated_page(head, block, index).replace("</body>", inject + "</body>"), encoding="utf-8") dom = workdir / f"_measure{index + 1}.html.dom" result = subprocess.run( [chrome, "--headless", "--disable-gpu", "--hide-scrollbars", f"--virtual-time-budget={RENDER_WAIT_MS}", f"--window-size={DESIGN_W},{WINDOW_H}", "--dump-dom", f"file://{page.resolve()}"], capture_output=True, text=True) page.unlink(missing_ok=True) dom.unlink(missing_ok=True) found = re.search(r'<pre id="__desc__">(.*?)</pre>', result.stdout, re.DOTALL) if not found: return index, None import html as _html data = json.loads(_html.unescape(found.group(1))) return index, (data if data.get("hasPanel") and data.get("items") else None) measured = [None] * len(slides) with ThreadPoolExecutor(max_workers=jobs) as pool: futures = [pool.submit(one, i, block) for i, (_, block) in enumerate(slides)] for future in as_completed(futures): index, data = future.result() measured[index] = data return measured def desc_shapes(data, accent): """실측값을 PPT 도형 XML 로 바꾼다. 배지 칩 + 설명 텍스트 상자 한 쌍씩.""" if not data: return "" out = [] for n, item in enumerate(data["items"]): badge, text = item["badge"], item["text"] shape_id = 10 + n * 2 out.append( f'<p:sp><p:nvSpPr><p:cNvPr id="{shape_id}" name="badge {_xml_escape(item["label"])}"/>' '<p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr>' f'<a:xfrm><a:off x="{_emu(badge["x"])}" y="{_emu(badge["y"])}"/>' f'<a:ext cx="{_emu(badge["w"])}" cy="{_emu(badge["h"])}"/></a:xfrm>' '<a:prstGeom prst="roundRect"><a:avLst>' '<a:gd name="adj" fmla="val 30000"/></a:avLst></a:prstGeom>' f'<a:solidFill><a:srgbClr val="{accent}"/></a:solidFill>' '<a:ln><a:noFill/></a:ln></p:spPr><p:txBody>' '<a:bodyPr anchor="ctr" anchorCtr="1" lIns="0" tIns="0" rIns="0" bIns="0"/>' '<a:lstStyle/><a:p><a:pPr algn="ctr"/>' f'<a:r><a:rPr lang="ko-KR" sz="{BADGE_PT}" b="1">' '<a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill></a:rPr>' f'<a:t>{_xml_escape(item["label"])}</a:t></a:r></a:p></p:txBody></p:sp>') runs = [] for i, line in enumerate(item["lines"]): if i: runs.append("<a:br/>") bold = ' b="1"' if i == 0 and item.get("boldFirst") else "" rgb = DESC_TITLE_RGB if i == 0 and item.get("boldFirst") else DESC_BODY_RGB runs.append( f'<a:r><a:rPr lang="ko-KR" sz="{DESC_PT}"{bold}>' f'<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill></a:rPr>' f'<a:t>{_xml_escape(line)}</a:t></a:r>') out.append( f'<p:sp><p:nvSpPr><p:cNvPr id="{shape_id + 1}" name="desc {_xml_escape(item["label"])}"/>' '<p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr>' f'<a:xfrm><a:off x="{_emu(text["x"])}" y="{_emu(text["y"])}"/>' f'<a:ext cx="{_emu(text["w"])}" cy="{_emu(text["h"])}"/></a:xfrm>' '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/>' '<a:ln><a:noFill/></a:ln></p:spPr><p:txBody>' '<a:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0"><a:noAutofit/></a:bodyPr>' f'<a:lstStyle/><a:p><a:pPr><a:lnSpc><a:spcPct val="{DESC_LINE_SPACING}"/>' f'</a:lnSpc></a:pPr>{"".join(runs)}</a:p></p:txBody></p:sp>') return "".join(out) def slide_accent(html): """산출물의 --accent 값을 6자리 대문자 HEX 로.""" found = re.search(r"--accent:\s*#([0-9a-fA-F]{6})", html) return found.group(1).upper() if found else "1B64DA" def _rels(entries): body = "".join( f'<Relationship Id="{rid}" Type="{REL_TYPE}/{kind}" Target="{target}"/>' for rid, kind, target in entries ) return f'{XML_DECL}<Relationships xmlns="{REL_NS}">{body}</Relationships>' def build_pptx(shots, dest, overlays=None): """PNG 목록으로 최소 구조의 pptx 를 조립한다. 골격은 PowerPoint·Keynote·LibreOffice 가 여는 최소 집합이다 — 마스터 1개, 빈 레이아웃 1개, 테마 1개, 슬라이드마다 전면 이미지 1개. """ count = len(shots) grp = ('<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>' '<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/>' '<a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>') content_types = ( f'{XML_DECL}<Types xmlns="{CT_NS}">' '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' '<Default Extension="xml" ContentType="application/xml"/>' '<Default Extension="png" ContentType="image/png"/>' '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>' '<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>' '<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>' '<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>' + "".join( f'<Override PartName="/ppt/slides/slide{i}.xml" ' 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>' for i in range(1, count + 1)) + "</Types>") slide_ids = "".join( f'<p:sldId id="{255 + i}" r:id="rId{i + 1}"/>' for i in range(1, count + 1)) presentation = ( f'{XML_DECL}<p:presentation xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}">' '<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>' f'<p:sldIdLst>{slide_ids}</p:sldIdLst>' f'<p:sldSz cx="{EMU_W}" cy="{EMU_H}"/>' f'<p:notesSz cx="{EMU_H}" cy="{EMU_W}"/></p:presentation>') presentation_rels = _rels( [("rId1", "slideMaster", "slideMasters/slideMaster1.xml")] + [(f"rId{i + 1}", "slide", f"slides/slide{i}.xml") for i in range(1, count + 1)]) master = ( f'{XML_DECL}<p:sldMaster xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}">' '<p:cSld><p:bg><p:bgPr><a:solidFill><a:schemeClr val="lt1"/></a:solidFill>' f'<a:effectLst/></p:bgPr></p:bg><p:spTree>{grp}</p:spTree></p:cSld>' '<p:clrMap bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" ' 'accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" ' 'accent6="accent6" hlink="hlink" folHlink="folHlink"/>' '<p:sldLayoutIdLst><p:sldLayoutId id="2147483649" r:id="rId1"/></p:sldLayoutIdLst>' '</p:sldMaster>') layout = ( f'{XML_DECL}<p:sldLayout xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}" type="blank">' f'<p:cSld name="Blank"><p:spTree>{grp}</p:spTree></p:cSld>' '<p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" ' 'accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" ' 'accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>' '</p:clrMapOvr></p:sldLayout>') theme = ( f'{XML_DECL}<a:theme xmlns:a="{NS_A}" name="Theme"><a:themeElements>' '<a:clrScheme name="Office">' '<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>' '<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>' '<a:dk2><a:srgbClr val="1E2A5C"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>' '<a:accent1><a:srgbClr val="1B64DA"/></a:accent1><a:accent2><a:srgbClr val="0F9D58"/></a:accent2>' '<a:accent3><a:srgbClr val="F59E0B"/></a:accent3><a:accent4><a:srgbClr val="E5484D"/></a:accent4>' '<a:accent5><a:srgbClr val="7C3AED"/></a:accent5><a:accent6><a:srgbClr val="94A3B8"/></a:accent6>' '<a:hlink><a:srgbClr val="1B64DA"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink>' '</a:clrScheme><a:fontScheme name="Office">' '<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface="Apple SD Gothic Neo"/><a:cs typeface=""/></a:majorFont>' '<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface="Apple SD Gothic Neo"/><a:cs typeface=""/></a:minorFont>' '</a:fontScheme><a:fmtScheme name="Office">' '<a:fillStyleLst>' + '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' * 3 + '</a:fillStyleLst>' '<a:lnStyleLst>' + "".join( f'<a:ln w="{w}"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>' for w in (6350, 12700, 19050)) + '</a:lnStyleLst>' '<a:effectStyleLst>' + '<a:effectStyle><a:effectLst/></a:effectStyle>' * 3 + '</a:effectStyleLst>' '<a:bgFillStyleLst>' + '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>' * 3 + '</a:bgFillStyleLst>' '</a:fmtScheme></a:themeElements></a:theme>') with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as pkg: pkg.writestr("[Content_Types].xml", content_types) pkg.writestr("_rels/.rels", _rels([("rId1", "officeDocument", "ppt/presentation.xml")])) pkg.writestr("ppt/presentation.xml", presentation) pkg.writestr("ppt/_rels/presentation.xml.rels", presentation_rels) pkg.writestr("ppt/theme/theme1.xml", theme) pkg.writestr("ppt/slideMasters/slideMaster1.xml", master) pkg.writestr("ppt/slideMasters/_rels/slideMaster1.xml.rels", _rels([ ("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), ("rId2", "theme", "../theme/theme1.xml")])) pkg.writestr("ppt/slideLayouts/slideLayout1.xml", layout) pkg.writestr("ppt/slideLayouts/_rels/slideLayout1.xml.rels", _rels([ ("rId1", "slideMaster", "../slideMasters/slideMaster1.xml")])) for i, shot in enumerate(shots, start=1): pkg.writestr(f"ppt/slides/slide{i}.xml", f'{XML_DECL}<p:sld xmlns:a="{NS_A}" xmlns:r="{NS_R}" xmlns:p="{NS_P}">' f'<p:cSld><p:spTree>{grp}' f'<p:pic><p:nvPicPr><p:cNvPr id="2" name="Slide Image {i}"/>' '<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr><p:nvPr/></p:nvPicPr>' '<p:blipFill><a:blip r:embed="rId2"/><a:stretch><a:fillRect/></a:stretch></p:blipFill>' f'<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{EMU_W}" cy="{EMU_H}"/></a:xfrm>' '<a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>' + ((overlays or {}).get(i - 1) or "") + '</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>') pkg.writestr(f"ppt/slides/_rels/slide{i}.xml.rels", _rels([ ("rId1", "slideLayout", "../slideLayouts/slideLayout1.xml"), ("rId2", "image", f"../media/image{i}.png")])) pkg.write(shot, f"ppt/media/image{i}.png") return dest def main(): parser = argparse.ArgumentParser( description="화면설계서 HTML 에서 PDF 와 PPTX 를 만든다") parser.add_argument("storyboard", help="<프로젝트명>_storyboard.html") parser.add_argument("--outdir", help="출력 디렉터리 (기본: 입력과 같은 곳)") parser.add_argument("--pdf-only", action="store_true") parser.add_argument("--pptx-only", action="store_true") parser.add_argument("--scale", type=float, default=2.0, help="PPTX 캡처 배율 (기본 2.0 = 2800x1576px, A4 기준 약 240dpi)") parser.add_argument("--jobs", type=int, help=f"동시 캡처 수 (기본: {default_jobs()})") parser.add_argument("--editable-desc", action="store_true", help="화면 상세의 설명 패널을 PPT 텍스트 상자로 만든다 " "(슬라이드당 Chrome 이 2회 돈다)") parser.add_argument("--chrome", help="Chrome 실행 파일 경로") args = parser.parse_args() if args.pdf_only and args.pptx_only: sys.exit("오류: --pdf-only 와 --pptx-only 는 함께 쓸 수 없다") src = Path(args.storyboard) if not src.is_file(): sys.exit(f"오류: 파일이 없다 — {src}") outdir = Path(args.outdir) if args.outdir else src.parent outdir.mkdir(parents=True, exist_ok=True) html = src.read_text(encoding="utf-8") if "<body>" not in html: sys.exit("오류: <body> 가 없다 — 화면설계서 산출물이 맞는지 확인할 것") chrome = find_chrome(args.chrome) made = [] with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) if not args.pptx_only: made.append(export_pdf(chrome, src, outdir / f"{src.stem}.pdf", work)) if not args.pdf_only: slides = split_slides(html) if not slides: sys.exit("오류: ppt-slide 를 찾을 수 없다") head = html[:html.index("<body>")] jobs = args.jobs or default_jobs() measured, overlays = None, None if args.editable_desc: accent = slide_accent(html) measured = measure_desc(chrome, head, slides, work, jobs, accent) overlays = {i: desc_shapes(data, accent) for i, data in enumerate(measured) if data} print(f"설명 패널 실측: {len(overlays)}장 " f"(항목 {sum(len(m['items']) for m in measured if m)}개)") shots = shoot_slides(chrome, head, slides, work, args.scale, jobs, blank=measured) made.append(build_pptx(shots, outdir / f"{src.stem}.pptx", overlays)) print(f"슬라이드 {len(slides)}장 캡처 (배율 {args.scale}, 동시 {jobs})") for path in made: print(f"생성: {path} ({path.stat().st_size // 1024:,}KB)") if len(made) == 2: print("PDF 는 텍스트가 벡터라 선택·검색된다. PPTX 는 슬라이드별 이미지다.") if __name__ == "__main__": main() -
scaffold.py 4.5 KB
#!/usr/bin/env python3 """template.html 의 head와 body runtime을 옮긴 빈 산출물 뼈대를 만든다. 산출물은 자체 완결형 단일 HTML 이라 `<head>` 전체와 배지↔설명 interaction runtime을 매번 들고 가야 한다. 그걸 에이전트가 손으로 옮겨 적으면 토큰을 크게 쓰고, 오타 하나에 검증기가 미정의 클래스로 막는다. 이 스크립트가 head 를 기계적으로 복사해 그 경로를 없앤다. 만들어진 파일에는 슬라이드가 없다. 에이전트는 `INSERT_MARKER` 바로 앞에 슬라이드를 이어붙이면 된다 — 한 번에 다 쓰지 않고 나눠 붙이는 것이 정상 절차다 (SKILL.md 의 "분할 작성" 참고). stdlib 만 사용한다. 사용법: python3 scripts/scaffold.py <출력.html> --project <프로젝트명> [--version 1.0.0] [--accent '#1b64da'] [--accent-ink '#ffffff'] [--force] 종료 코드: 생성했으면 0, 인자·경로 문제면 2. """ import argparse import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from validate_storyboard import RULESET_VERSION # noqa: E402 SKILL_ROOT = Path(__file__).resolve().parent.parent TEMPLATE = SKILL_ROOT / "resources" / "template.html" #: 슬라이드를 이어붙일 자리. 에이전트는 이 문자열 바로 앞에 슬라이드를 넣는다. INSERT_MARKER = "</div>\n</body>" def build(template_html, project, version, accent=None, accent_ink=None): """템플릿에서 head와 body script를 떼어 빈 docwrap 뼈대를 만든다.""" head = re.search(r"<head>.*?</head>", template_html, re.S) if not head: raise ValueError("template.html 에 <head> 블록이 없다") head_html = head.group(0) body = re.search(r"<body>(.*?)</body>", template_html, re.S) if not body: raise ValueError("template.html 에 <body> 블록이 없다") body_scripts = "\n".join(re.findall(r"<script>.*?</script>", body.group(1), re.S)) head_html = re.sub( r"<title>[^<]*</title>", f"<title>{project} 화면설계서</title>", head_html) # 생성 당시의 규칙 세트를 문서에 새긴다 — 검증기가 읽어 사후 도입 규칙을 # 참고로 분리한다 (이슈 #77). 지우거나 값을 바꾸지 않는다. head_html = head_html.replace( "<head>", f'<head>\n<meta name="skill-ruleset" content="{RULESET_VERSION}">', 1) if accent: head_html = re.sub( r"(--accent:\s*)[^;]+;", lambda m: m.group(1) + accent + ";", head_html, count=1) if accent_ink: head_html = re.sub( r"(--accent-ink:\s*)[^;]+;", lambda m: m.group(1) + accent_ink + ";", head_html, count=1) # 템플릿 head 는 플레이스홀더를 쓰지 않지만, 앞으로 들어가더라도 채워지게 둔다. head_html = (head_html .replace("{{PROJECT_NAME}}", project) .replace("{{VERSION}}", version)) return ( "<!DOCTYPE html>\n<html lang=\"ko\">\n" f"{head_html}\n" "<body>\n" f"{body_scripts}\n" "<div class=\"docwrap\">\n\n" f"{INSERT_MARKER}\n</html>\n" ) def main(argv=None): ap = argparse.ArgumentParser(description="화면설계서 산출물 뼈대 생성") ap.add_argument("output", help="생성할 HTML 경로 (<이름>_storyboard.html 권장)") ap.add_argument("--project", required=True, help="프로젝트명 (상단 바·푸터에 쓰는 값)") ap.add_argument("--version", default="1.0.0") ap.add_argument("--accent", help="강조색 (예: '#1b64da'). 생략하면 템플릿 기본값") ap.add_argument("--accent-ink", help="강조색 배경 위 글자색 (밝은 accent 면 어둡게)") ap.add_argument("--force", action="store_true", help="기존 파일을 덮어쓴다") args = ap.parse_args(argv) out = Path(args.output) if out.exists() and not args.force: print(f"이미 있는 파일이다: {out} (덮어쓰려면 --force)", file=sys.stderr) return 2 if not TEMPLATE.exists(): print(f"템플릿이 없다: {TEMPLATE}", file=sys.stderr) return 2 html = build(TEMPLATE.read_text(encoding="utf-8"), args.project, args.version, args.accent, args.accent_ink) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(html, encoding="utf-8") print(f"생성: {out}") print(f"슬라이드는 {INSERT_MARKER!r} 바로 앞에 이어붙인다.") return 0 if __name__ == "__main__": sys.exit(main()) -
validate_storyboard.py 39.4 KB
#!/usr/bin/env python3 """생성된 화면설계서 HTML 이 SKILL.md 의 계약을 지켰는지 판정한다. `SKILL.md` 의 "저장 전 자체 점검" 항목과 그 외 기계적으로 판정 가능한 계약을 그대로 잰다. 위반으로 판정하는 것과, 참고로 보고만 하는 것을 구분한다 — 화면 순서와 목업 개수는 요청 맥락을 알아야 옳고 그름이 정해지므로 수치만 보고하고 판정은 사람이 한다. 런타임(Claude Code / Codex / Antigravity)이 만든 산출물을 같은 잣대로 비교하기 위한 도구다. 화면 ID 가 정의된 산출물은 짝을 이루는 Business Rules 문서 (`<이름>_business-rules.md`)도 함께 판정한다 — 화면 ID 커버리지, 필수 헤딩(입력 검증 / 출력 규칙 / 인터랙션 / 엣지케이스), 끊어진 ID 참조. stdlib 만 사용한다 (이 환경의 Homebrew Python 3.14 는 외부 라이브러리 import 가 깨져 있다). 사용법: python3 scripts/validate_storyboard.py <산출물.html> [<산출물2.html> ...] 종료 코드: 위반이 하나도 없으면 0, 있으면 1. """ import re import sys from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent TEMPLATE = SKILL_ROOT / "resources" / "template.html" WHITELIST = frozenset({"mermaid"}) #: 검증기 규칙 세트 버전. 산출물에는 scaffold 가 #: `<meta name="skill-ruleset" content="N">` 으로 새긴다 (이슈 #77). #: 규칙을 추가할 때는 AGENTS.md 의 "새 검증 규칙 추가 체크리스트" 를 따른다. RULESET_VERSION = 3 #: 규칙 세트 v2 에서 도입된 위반 메시지의 식별 부분 문자열. #: v1 문서(메타 없음 포함)에는 기본 모드에서 "신규 규칙 참고" 로만 보고한다. V2_RULE_MARKERS = ( "인터랙션 표에 배지 번호 인용", "원문자 사용", "이벤트 표기(탭:/스와이프:/롱프레스:/입력:)", "유형(화면/팝업/바텀시트) 표기 없는 행", "유형이 '화면' 인데", "05 Screen List", "06 Service Flow", "07.x Sequence Diagram", "mermaid sequenceDiagram", "시퀀스에 화면 ID", "시퀀스가 사실상 동일", "목업 본문이 자리표시자", "재탕", "데이터 신호", "Cover 버전", "Document History 최신 행", ) #: 규칙 세트 v3에서 도입된 규칙 단위 추적성 계약. 구문서는 기본 모드에서 #: 참고로만 보고하고 --strict에서만 위반으로 처리한다. V3_RULE_MARKERS = ( "규칙 ID 누락", "규칙 ID 형식 오류", "중복 규칙 ID", "규칙 ID 화면 불일치", "규칙 ID 구분 불일치", ) def doc_ruleset(html): """문서에 새겨진 규칙 세트 버전. 메타가 없으면 1 (메타 도입 전 문서).""" m = re.search(r'<meta\s+name="skill-ruleset"\s+content="(\d+)"', html) return int(m.group(1)) if m else 1 def rule_introduced_in(violation): """위반이 처음 도입된 규칙 세트 버전.""" if any(marker in violation for marker in V3_RULE_MARKERS): return 3 if any(marker in violation for marker in V2_RULE_MARKERS): return 2 return 1 def extract_style(html): """첫 번째 style 블록의 CSS를 반환한다.""" match = re.search(r"<style[^>]*>(.*?)</style>", html, re.S | re.I) if not match: raise ValueError("template.html 에 <style> 블록이 없다") return match.group(1) def defined_classes(css): """CSS selector에 정의된 클래스 이름을 반환한다.""" selectors = re.sub(r"\{[^{}]*\}", " ", css) selectors = re.sub(r"@[\w-]+[^;{}]*;", " ", selectors) return set(re.findall(r"\.(-?[A-Za-z_][A-Za-z0-9_-]*)", selectors)) def used_classes(html): """HTML class 속성에서 사용한 클래스 이름을 반환한다.""" names = set() for raw in re.findall(r'class\s*=\s*"([^"]*)"', html, re.I): names.update(raw.split()) return names def undefined_classes(html, css): """CSS에 정의되지 않은 HTML 클래스 이름을 정렬해 반환한다.""" return sorted(used_classes(html) - defined_classes(css) - WHITELIST) #: 이모지 코드포인트 구간. 타이포그래피 문자(‹ ⋮ 등)는 포함하지 않는다. EMOJI_RANGES = ((0x1F000, 0x1F2FF), (0x1F300, 0x1FAFF), (0x2600, 0x27BF), (0x2B00, 0x2BFF)) def markup_only(html): """<style> 블록과 HTML 주석을 걷어낸 마크업만 반환한다. 템플릿 CSS 주석에는 `<div class="mock mock-partial">` 같은 사용 예시가 들어 있다. 마크업 판정을 원문 전체에 대고 돌리면 그 예시를 실제 목업으로 세어 오탐이 난다. HTML 주석도 같은 이유로 걷어낸다. 슬라이드를 `<!-- ==== NO. 09.1 홈 ==== -->` 처럼 구분하는 것은 흔한 관례인데, 그 문자열이 슬라이드 구간 경계로 잡히면 한 슬라이드가 둘로 쪼개진다. 쪼개진 자리에서 배지와 desc-num 이 서로 다른 구간으로 갈리면 불일치를 놓친다(미탐). """ stripped = re.sub(r"<!--.*?-->", "", html, flags=re.S) return re.sub(r"<style\b[^>]*>.*?</style>", "", stripped, flags=re.S | re.I) def emoji_in(text): """텍스트에 등장하는 이모지를 원문 순서대로 반환한다.""" return [c for c in text if any(lo <= ord(c) <= hi for lo, hi in EMOJI_RANGES)] def slide_numbers(html): """상단 바의 NO. 값을 등장 순서대로 반환한다.""" return re.findall(r'class="ppt-top-no">NO\.\s*([\d.]+)<', html) def badge_lefts(html): """pointer-badge 의 인라인 left 값(px)을 반환한다.""" lefts = [] for style in re.findall(r'class="pointer-badge"[^>]*style="([^"]*)"', html): m = re.search(r"left:\s*(-?\d+)px", style) if m: lefts.append(int(m.group(1))) return lefts def slide_sections(html, pattern=r"[\d.]+"): """상단 바 앵커로 자른 (번호, 본문) 목록. 번호가 pattern 에 완전일치하는 것만. 구간은 `ppt-top-no` 앵커로만 잡고 **바로 다음 슬라이드**에서 끊는다. 두 가지를 동시에 막는다. 1. 본문 텍스트나 목차 표에 등장하는 "NO. 09.1" 같은 문자열은 슬라이드가 아니다. 2. 09.x 뒤에 다른 번호의 슬라이드가 오더라도 그 내용이 마지막 화면 상세에 합산되지 않는다 — 09.x 가 문서 마지막이어야 한다는 암묵 전제를 없앤다. """ heads = list(re.finditer(r'class="ppt-top-no">NO\.\s*([\d.]+)<', html)) out = [] for i, m in enumerate(heads): if not re.fullmatch(pattern, m.group(1)): continue end = heads[i + 1].start() if i + 1 < len(heads) else len(html) out.append((m.group(1), html[m.end():end])) return out def detail_slides(html): """09.x 화면 상세 슬라이드의 (번호, 본문) 목록.""" return slide_sections(html, r"09\.\d+") def badge_desc_mismatch(html): """슬라이드별 pointer-badge 수와 desc-num 수가 다른 슬라이드를 반환한다.""" bad = [] for no, body in detail_slides(html): b = body.count('class="pointer-badge"') d = body.count('class="desc-num"') if b != d: bad.append((no, b, d)) return bad def screen_order(html): """09.x 슬라이드의 (번호, 제목) 을 등장 순서대로 반환한다.""" return re.findall( r'ppt-top-no">NO\.\s*(09\.\d+)</div>\s*<div class="ppt-top-title">([^<]+)', html) def mock_counts(html): """09.x 슬라이드별 mock 개수를 반환한다.""" return [(no, body.count('class="mock"')) for no, body in detail_slides(html)] ID_RE = r"\b[A-Z]{2,6}-[A-Z]{2,12}-\d{3}\b" def screen_ids(html): """정의된 화면 ID 집합을 반환한다. 정의 자리는 두 곳이다 — 슬라이드 대표 화면의 `ppt-meta-id`, 그리고 목업이 2개 이상인 슬라이드에서 각 목업의 `mock-caption`. 캡션까지 정의로 세는 이유는 `ppt-meta-id` 가 슬라이드당 한 칸뿐이어서 두 번째 목업의 화면 ID 를 담을 자리가 없기 때문이다. """ ids = set() for raw in re.findall(r'class="ppt-meta-id">([^<]+)<', html): ids.update(re.findall(ID_RE, raw)) for raw in re.findall(r'class="mock-caption">([^<]*)<', html): ids.update(re.findall(ID_RE, raw)) return ids def referenced_ids(html): """본문에서 언급된 화면 ID 후보를 반환한다. 형식은 <서비스약어>-<기능>-<3자리> 다. 정의 자리(ppt-meta-id 와 mock-caption)는 제외하고, 설명 문장에서 참조된 것만 센다. """ stripped = re.sub(r'class="ppt-meta-id">[^<]+<', 'class="ppt-meta-id"><', html) stripped = re.sub(r'class="mock-caption">[^<]*<', 'class="mock-caption"><', stripped) return set(re.findall(ID_RE, stripped)) def caption_mismatch(html): """09.x 슬라이드별 목업 수와 mock-caption 수가 다른 슬라이드를 반환한다. 캡션은 모든 목업에 필수다 — 단일 목업 슬라이드만 캡션이 없으면 문서 전체에서 표현이 어긋난다. 목업 프레임은 `class="mock"` 과 `class="mock mock-partial"` 두 형태라 접두 매칭으로 센다 (`mock-caption` 등 하이픈 파생 클래스는 매칭되지 않는다). """ bad = [] for no, body in detail_slides(html): mocks = len(re.findall(r'class="mock[\s"]', body)) caps = body.count('class="mock-caption"') if mocks != caps: bad.append((no, mocks, caps)) return bad def sequence_slides(html): """07.x 시퀀스 슬라이드의 (번호, 본문) 목록을 반환한다.""" return slide_sections(html, r"07\.\d+") def check_sequence_slides(markup): """07.x Sequence Diagram 계약을 판정한다. 필요한 트랜잭션이 전부 그려졌는지는 기계로 잴 수 없다(자체 점검 소관). 기계 판정은 하한선과 형식이다 — 최소 1장 존재, 각 장에 mermaid `sequenceDiagram` 과 관련 화면 ID. ID 정합은 기존 끊어진 참조 판정이 함께 잡는다. """ violations = [] seqs = sequence_slides(markup) if not seqs: violations.append( "07.x Sequence Diagram 슬라이드 없음 — 상태 변경 트랜잭션당 1장을 그릴 것") for no, body in seqs: if 'class="mermaid"' not in body or "sequenceDiagram" not in body: violations.append(f"{no} 에 mermaid sequenceDiagram 이 없다") elif not re.findall(ID_RE, body): violations.append( f"{no} 시퀀스에 화면 ID 가 없다 — participant 라벨이나 note 에 적을 것") return violations SCREEN_TYPES = ("바텀시트", "팝업", "화면") def detail_defined_ids(html): """09.x 슬라이드 안에서 정의된 화면 ID 집합 (ppt-meta-id · mock-caption).""" ids = set() for _no, body in detail_slides(html): for raw in re.findall(r'class="ppt-meta-id">([^<]+)<', body): ids.update(re.findall(ID_RE, raw)) for raw in re.findall(r'class="mock-caption">([^<]*)<', body): ids.update(re.findall(ID_RE, raw)) return ids def row_cells(row): """표 행의 셀 텍스트 목록. 태그를 걷어내고 공백을 정리한다.""" cells = re.findall(r"<t[dh][^>]*>(.*?)</t[dh]>", row, re.S) return [re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", c)).strip() for c in cells] def row_screen_type(row): """행의 유형(화면/팝업/바텀시트)을 반환한다. 판정 불가면 None. 우선 **유형만 담은 셀**을 찾는다 — 셀 값이 유형 단어와 정확히 같아야 한다. 그런 셀이 없으면 행 텍스트 매칭으로 되돌아가되, `화면` 은 다른 두 유형의 설명 문구에도 흔히 섞이므로 마지막에 본다. """ for cell in row_cells(row): if cell in SCREEN_TYPES: return cell return next((k for k in SCREEN_TYPES if k in row), None) def check_screen_list_types(markup): """05 Screen List 의 유형 표기·정합을 판정한다. 모든 행에 유형(화면/팝업/바텀시트)이 있어야 하고, 유형이 `화면` 인 ID 는 09.x 슬라이드에 정의돼 있어야 한다 — 목록에만 있고 그려지지 않은 화면은 구현 단계에서 범위를 즉석 결정하게 만든다(실구현 회고, 이슈 #41). 유형은 **유형 열의 셀 값**으로 판정한다. 행 전체를 키워드 매칭하면 "주요 내용" 칸의 설명 문구("화면 일부를 덮는 팝업" 등)에 걸려 오판한다. 열 순서를 SKILL.md 와 다르게 쓴 문서를 위해, 유형만 담은 셀을 못 찾으면 행 텍스트 매칭으로 되돌아간다 — 그때는 `바텀시트`·`팝업` 을 먼저 본다. """ body = slide_body(markup, "05") if body is None: return [] # 슬라이드 존재 위반은 check_overview_slides 가 잰다 violations = [] detail_ids = detail_defined_ids(markup) untyped, undrawn = [], [] for row in re.findall(r"<tr[^>]*>(.*?)</tr>", body, re.S): ids = re.findall(ID_RE, row) if not ids: continue # 헤더 행 kind = row_screen_type(row) if kind is None: untyped += ids elif kind == "화면": undrawn += [i for i in ids if i not in detail_ids] if untyped: violations.append( f"05 Screen List 에 유형(화면/팝업/바텀시트) 표기 없는 행: {', '.join(untyped)}") if undrawn: violations.append( f"05 Screen List 유형이 '화면' 인데 09.x 에 정의되지 않은 ID: {', '.join(sorted(set(undrawn)))}") return violations EVENT_LABELS = ("탭:", "스와이프:", "롱프레스:", "입력:") def event_coverage(html): """09.x 슬라이드별 (번호, 이벤트 표기 항목 수, 전체 설명 항목 수). 설명 항목(<li>)에 고정 이벤트 라벨(탭:/스와이프:/롱프레스:/입력:)이 있는지 센다. 터치 요소가 있는 화면 상세에 이벤트 표기가 하나도 없으면 동작 정의가 통째로 빠진 것이다 (이슈 #43). """ out = [] for no, body in detail_slides(html): items = re.findall(r"<li\b.*?</li>", body, re.S) ev = sum(1 for it in items if any(lb in it for lb in EVENT_LABELS)) out.append((no, ev, len(items))) return out def slide_body(html, number): """지정한 NO. 슬라이드의 본문을 반환한다. 없으면 None. `05` 처럼 점 없는 번호를 찾을 때 `05.1` 같은 하위 번호와 헷갈리지 않도록 번호 전체를 정확히 비교한다. """ heads = list(re.finditer(r'class="ppt-top-no">NO\.\s*([\d.]+)<', html)) for i, m in enumerate(heads): if m.group(1) == number: end = heads[i + 1].start() if i + 1 < len(heads) else len(html) return html[m.end():end] return None def check_overview_slides(markup, defined): """05 Screen List · 06 Service Flow 슬라이드 계약을 판정한다. Screen List 는 정의된 모든 화면 ID(팝업·바텀시트 포함)를 한 행씩 담는 ID↔화면 매핑 기준표이고, Service Flow 는 정상 케이스 전체 흐름을 담는 mermaid flowchart 다. 표·흐름도가 참조하는 ID 가 정의 집합 밖이면 기존의 끊어진 참조 판정이 함께 잡는다. """ violations = [] screen_list = slide_body(markup, "05") if screen_list is None: violations.append( "05 Screen List 슬라이드 없음 — 화면 ID↔화면 매핑표를 추가할 것") else: missing = sorted(defined - set(re.findall(ID_RE, screen_list))) if missing: violations.append( f"05 Screen List 에 없는 화면 ID: {', '.join(missing)}") flow = slide_body(markup, "06") if flow is None: violations.append( "06 Service Flow 슬라이드 없음 — 정상 케이스 전체 흐름도를 추가할 것") elif 'class="mermaid"' not in flow: violations.append("06 Service Flow 에 mermaid 흐름도가 없다") elif not re.findall(ID_RE, flow): violations.append( "06 Service Flow 흐름도에 화면 ID 가 없다 — 노드 라벨에 화면명과 ID 를 함께 적을 것") return violations PLACEHOLDER_RE = re.compile( r"Mockup Content|Lorem\b|\bTODO\b|여기에 내용|내용 삽입|콘텐츠 영역", re.I) #: 목업 본문의 도메인 데이터 신호 — 숫자 리터럴(금액·수량·날짜·시각·비율). NUM_SIGNAL_RE = re.compile(r"\d[\d,.:%~]*") #: 이 비율을 넘는 화면 상세가 신호 없는 목업이면 문서 전체 위반이다. #: 온보딩·약관처럼 숫자가 적은 화면이 한둘 있는 것은 정상이다 — runtime-parity #: 실측에서 claude 4% / codex 19% / agy 100% 로 갈렸다 (이슈 #75). LOW_DENSITY_DOC_RATIO = 0.3 LOW_DENSITY_FLOOR = 2 #: 설명 항목 제목이 목업 본문에 이 비율 이상 그대로 나타나면 재탕이다. #: 버튼 라벨과 항목 제목이 겹치는 정상 케이스(60% 안팎)를 통과시키기 위해 80%. ECHO_RATIO = 0.8 ECHO_MIN_TITLES = 4 #: 07.x 시퀀스 슬라이드 간 토큰 유사도 상한 — 이 이상이면 보일러플레이트 복제다. #: runtime-parity 실측: 정상 문서 최대 0.44, 보일러플레이트 문서 0.73~0.83. SEQ_SIMILARITY_LIMIT = 0.7 def mock_body_text(slide_body): """09.x 슬라이드 본문에서 mock-body 들의 텍스트만 모아 반환한다. 여는 태그 끝(>) 뒤부터 mock-caption/mock-footer/desc-panel 전까지를 취하고, pointer-badge 라벨은 목업 콘텐츠가 아니므로 제거한다. 인라인 style 값은 태그 안에 있으므로 태그 제거로 함께 사라진다. """ import html as _html bodies = [] for bm in re.finditer(r'class="mock-body"', slide_body): tag_end = slide_body.find(">", bm.end()) if tag_end == -1: continue rest = slide_body[tag_end + 1:] cut = len(rest) for stop in ('class="mock-caption"', 'class="mock-footer', 'class="ppt-desc-panel"'): p = rest.find(stop) if p != -1: cut = min(cut, p) blk = re.sub(r'<span class="pointer-badge".*?</span>', " ", rest[:cut], flags=re.S) bodies.append(_html.unescape(re.sub(r"<[^>]*>?", " ", blk))) return re.sub(r"\s+", " ", " ".join(bodies)).strip() def desc_titles(slide_body): """설명 리스트 항목의 굵은 제목 목록.""" import html as _html return [_html.unescape(x).strip() for x in re.findall( r'<li><span class="desc-num">[^<]*</span>\s*<div><b>([^<]+)</b>', slide_body)] def check_mock_content(markup): """목업 본문의 실질을 판정한다 (이슈 #75) — (위반, 정보) 반환. 구조 계약만 재던 시절에는 "validator 0건" 이 최소 비용 경로(자리표시자· 설명 재탕·빈 목업)로 수렴했다. 산문 지시 중 기계로 셀 수 있는 것을 위반으로 승격한다. 임계값은 tests/fixtures/runtime-parity 의 세 런타임 실측 산출물로 캘리브레이션했다 — claude 0건 유지, codex·agy 는 잡힌다. """ violations, info = [], [] placeholder_slides, echo_slides, retype_slides, low_density = [], [], [], [] total = 0 for no, body in detail_slides(markup): if 'class="mock-body"' not in body: continue # 목업 없는 슬라이드는 본문 실질을 판정할 수 없다 total += 1 text = mock_body_text(body) if PLACEHOLDER_RE.search(text): placeholder_slides.append(no) if text.count("탭 ›") + text.count("탭›") >= 2: retype_slides.append(no) titles = [x for x in desc_titles(body) if len(re.sub(r"\s+", "", x)) >= 3] if len(titles) >= ECHO_MIN_TITLES: tn = re.sub(r"\s+", "", text) echoed = sum(1 for x in titles if re.sub(r"\s+", "", x) in tn) if echoed >= len(titles) * ECHO_RATIO: echo_slides.append(f"{no}({echoed}/{len(titles)})") if len(NUM_SIGNAL_RE.findall(text)) < LOW_DENSITY_FLOOR: low_density.append(no) if placeholder_slides: violations.append( "목업 본문이 자리표시자다 (Mockup Content/TODO/Lorem 류): " + ", ".join(placeholder_slides[:8]) + (f" 외 {len(placeholder_slides) - 8}장" if len(placeholder_slides) > 8 else "")) if retype_slides: violations.append( "목업 안에 설명 패널의 이벤트 표기('탭 ›')를 재탕한 슬라이드: " + ", ".join(retype_slides[:8]) + (f" 외 {len(retype_slides) - 8}장" if len(retype_slides) > 8 else "")) if echo_slides: violations.append( "설명 항목 제목 대부분이 목업 본문에 그대로 복사된 슬라이드" f" (재탕 의심, {int(ECHO_RATIO * 100)}%+): " + ", ".join(echo_slides[:8])) if total and len(low_density) / total > LOW_DENSITY_DOC_RATIO: violations.append( f"도메인 데이터 신호(숫자 리터럴 {LOW_DENSITY_FLOOR}개 미만) 없는 목업이 " f"{len(low_density)}/{total}장 — 더미데이터를 Figma 시안급으로 채울 것: " + ", ".join(low_density[:8]) + (f" 외 {len(low_density) - 8}장" if len(low_density) > 8 else "")) info.append(f"목업 데이터 신호 부족 {len(low_density)}/{total}장" + (f" ({', '.join(low_density[:5])})" if low_density else "")) return violations, info def check_sequence_boilerplate(markup): """07.x 시퀀스 간 토큰 유사도가 임계를 넘는 쌍을 위반으로 보고한다.""" import html as _html from itertools import combinations seqs = [] for no, body in sequence_slides(markup): mm = re.search(r'class="mermaid">\s*(.*?)\s*</div>', body, re.S) if not mm: continue toks = set(re.findall(r"[가-힣A-Za-z]{2,}", _html.unescape(mm.group(1)))) if toks: seqs.append((no, toks)) violations = [] for (a, ta), (b, tb) in combinations(seqs, 2): j = len(ta & tb) / max(1, len(ta | tb)) if j >= SEQ_SIMILARITY_LIMIT: violations.append( f"{a} 와 {b} 의 시퀀스가 사실상 동일하다 (유사도 {j:.2f}) — " "트랜잭션별로 participant·메시지가 달라야 한다") return violations def ia_subgraph_info(markup): """04 IA 의 노드 수와 subgraph 사용 여부를 참고로 보고한다. 노드 13개 이상 + subgraph 미사용은 세로 1열 붕괴 위험 신호지만, 렌더 없이는 붕괴를 단정할 수 없어 위반이 아니라 정보로만 낸다 (이슈 #75). """ body = slide_body(markup, "04") if body is None: return None mm = re.search(r'class="mermaid">\s*(.*?)\s*</div>', body, re.S) if not mm: return None nodes = len(re.findall(r"\w+\[", mm.group(1))) has_sub = "subgraph" in mm.group(1) note = "" if has_sub or nodes < 13 else " — 세로 붕괴 위험, subgraph 권장" return f"04 IA 노드 {nodes}개 / subgraph {'사용' if has_sub else '미사용'}{note}" VERSION_RE = re.compile(r"\b(\d+\.\d+\.\d+)\b") def history_rows(hist_body): """02 Document History 표에서 버전이 있는 데이터 행을 (버전, 행 텍스트) 로 반환.""" rows = [] for row in re.findall(r"<tr[^>]*>(.*?)</tr>", hist_body, re.S): text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", row)).strip() m = VERSION_RE.search(text) if m: rows.append((m.group(1), text)) return rows def check_version_history(markup): """Cover 버전과 Document History 최신 행의 정합을 판정한다 (이슈 #74). 콜드 재생성에서 이력 행을 치환해 버전과 "최초 작성" 설명이 어긋나는 사고를 막는다. Cover(01)·History(02) 슬라이드나 버전 표기가 없으면 측정 불가로 보고 판정하지 않는다 — 슬라이드 존재는 별개 계약이다. """ cover = slide_body(markup, "01") hist = slide_body(markup, "02") if cover is None or hist is None: return [] cover_vers = VERSION_RE.findall(cover) rows = history_rows(hist) if not cover_vers or not rows: return [] violations = [] cover_ver, latest_ver = cover_vers[0], rows[-1][0] if cover_ver != latest_ver: violations.append( f"Cover 버전({cover_ver})과 Document History 최신 행({latest_ver})이 다르다" " — 재생성/수정 시 이력 행을 추가하고 Cover 버전을 함께 올릴 것") if len(rows) > 1 and "최초 작성" in rows[-1][1]: violations.append( "Document History 최신 행이 '최초 작성' 이다 — 재생성/수정 행에는" " 사유와 변경 요약을 적을 것 (최초 작성은 첫 행 전용)") return violations def meta_locations(html): """09.x 슬라이드의 (번호, Location) 을 반환한다.""" out = [] for no, body in detail_slides(html): loc = re.search(r'class="ppt-meta-value">([^<]*)<', body) out.append((no, loc.group(1).strip() if loc else "")) return out def badge_labels(html): """pointer-badge 라벨을 반환한다.""" return re.findall(r'class="pointer-badge"[^>]*>([^<]+)<', html) STORYBOARD_SUFFIX = "_storyboard.html" RULES_SUFFIX = "_business-rules.md" RULES_REQUIRED_HEADINGS = ("입력 검증", "출력 규칙", "인터랙션", "엣지케이스") def rules_path_for(html_path): """산출물 HTML 과 짝을 이루는 Business Rules 문서 경로를 만든다. `<이름>_storyboard.html` → `<이름>_business-rules.md`. 접미사가 계약과 다른 파일은 `<이름>.html` → `<이름>_business-rules.md` 로 유도한다. """ p = Path(html_path) name = p.name if name.endswith(STORYBOARD_SUFFIX): stem = name[: -len(STORYBOARD_SUFFIX)] else: stem = p.stem return p.with_name(stem + RULES_SUFFIX) def rules_sections(md): """`## <화면 ID> <이름>` 섹션을 (ID, 본문) 목록으로 반환한다. 같은 ID 의 섹션이 여러 번 나오면 나온 만큼 목록에 들어간다 — 중복 판정은 호출부가 한다. """ heads = list(re.finditer(r"(?m)^##\s+(%s)[^\n]*$" % ID_RE, md)) out = [] for i, m in enumerate(heads): end = heads[i + 1].start() if i + 1 < len(heads) else len(md) out.append((m.group(1), md[m.end():end])) return out def rules_subsection_bodies(body): """섹션 본문을 `### 헤딩` 별로 나눠 {헤딩: 내용} 으로 반환한다.""" heads = list(re.finditer(r"(?m)^###\s+([^\n]+)$", body)) out = {} for i, m in enumerate(heads): end = heads[i + 1].start() if i + 1 < len(heads) else len(body) out[m.group(1).strip()] = body[m.end():end].strip() return out #: 인터랙션 표 트리거 칸의 배지 인용. 목업 1개면 (1), 2개 이상이면 (1-2). BADGE_CITE_RE = re.compile(r"\(\s*\d+(?:-\d+)?\s*\)") RULE_ID_RE = re.compile( rf"\b({ID_RE}\.(IN|OUT|INT|EDGE)-\d{{2}})\b") RULE_KIND = { "입력 검증": "IN", "출력 규칙": "OUT", "인터랙션": "INT", "엣지케이스": "EDGE", } def interaction_rows_without_badge(section_body): """`### 인터랙션` 표에서 트리거 칸에 배지 번호 인용이 없는 행의 트리거 텍스트. 인용이 없으면 그 규칙이 화면의 어느 요소를 말하는지 추적할 수 없다 — storyboard 의 배지와 Business Rules 를 잇는 유일한 끈이다. 표가 없거나 "해당 없음" 으로 적힌 섹션은 검사 대상이 아니다. """ subs = rules_subsection_bodies(section_body) body = subs.get("인터랙션", "") if not body or body.lstrip().startswith("해당 없음"): return [] bad = [] for line in body.splitlines(): line = line.strip() if not line.startswith("|"): continue cells = [c.strip() for c in line.strip("|").split("|")] if not cells: continue trigger = cells[0] # 헤더 행과 구분선 행은 건너뛴다. if trigger in ("트리거", "") or set(trigger) <= {"-", ":"}: continue if not BADGE_CITE_RE.search(trigger): bad.append(trigger) return bad def rule_id_violations(sections): """Business Rules의 데이터 행·목록 항목에 안정적인 규칙 ID가 있는지 판정.""" violations = [] seen = [] for sid, body in sections: for heading, kind in RULE_KIND.items(): sub = rules_subsection_bodies(body).get(heading, "") if not sub or sub.lstrip().startswith("해당 없음"): continue table_row = 0 for line in sub.splitlines(): stripped = line.strip() candidate = None if stripped.startswith("|"): cells = [cell.strip() for cell in stripped.strip("|").split("|")] if not cells or all(set(cell) <= {"-", ":"} for cell in cells): continue table_row += 1 if table_row == 1: # 표 헤더 continue candidate = stripped elif re.match(r"^[-*+]\s+", stripped): candidate = stripped if candidate is None: continue matches = list(RULE_ID_RE.finditer(candidate)) if not matches: violations.append( f"{sid} {heading} 규칙 ID 누락: {candidate[:80]}") continue for match in matches: rule_id, actual_kind = match.groups() rule_sid = rule_id.split(".", 1)[0] seen.append(rule_id) if rule_sid != sid: violations.append( f"{sid} 규칙 ID 화면 불일치: {rule_id}") if actual_kind != kind: violations.append( f"{sid} {heading} 규칙 ID 구분 불일치: {rule_id}") duplicates = sorted({rule_id for rule_id in seen if seen.count(rule_id) > 1}) if duplicates: violations.append(f"Business Rules 중복 규칙 ID: {', '.join(duplicates)}") return violations def check_rules(md, storyboard_ids, enforce_rule_ids=False): """Business Rules 문서를 판정해 (위반 목록, 정보 목록) 을 반환한다. storyboard_ids 는 storyboard 에 정의된 화면 ID 집합이다. SKILL.md 의 계약 — 모든 화면 ID 가 정확히 하나의 `##` 섹션을 갖고, 각 섹션에 네 필수 헤딩이 내용과 함께 있으며, 본문 참조 ID 가 전부 storyboard 에 정의돼 있다 — 를 그대로 잰다. """ violations, info = [], [] sections = rules_sections(md) section_ids = [sid for sid, _ in sections] id_set = set(section_ids) dup = sorted({sid for sid in id_set if section_ids.count(sid) > 1}) if dup: violations.append(f"Business Rules 에 중복 섹션: {', '.join(dup)}") missing = sorted(storyboard_ids - id_set) if missing: violations.append( f"Business Rules 에 섹션이 없는 화면 ID: {', '.join(missing)}") extra = sorted(id_set - storyboard_ids) if extra: violations.append( f"storyboard 에 정의되지 않은 화면 ID 섹션: {', '.join(extra)}") for sid, body in sections: subs = rules_subsection_bodies(body) absent = [h for h in RULES_REQUIRED_HEADINGS if h not in subs] if absent: violations.append(f"{sid} 섹션에 필수 헤딩 누락: {', '.join(absent)}") empty = [h for h in RULES_REQUIRED_HEADINGS if h in subs and not subs[h]] if empty: violations.append( f"{sid} 섹션의 내용 없는 헤딩: {', '.join(empty)}" " — 해당 없으면 '해당 없음 — <이유>' 를 적을 것") for sid, body in sections: missing_cite = interaction_rows_without_badge(body) if missing_cite: shown = ", ".join(f'"{t}"' for t in missing_cite[:3]) more = f" 외 {len(missing_cite) - 3}건" if len(missing_cite) > 3 else "" violations.append( f"{sid} 인터랙션 표에 배지 번호 인용이 없는 행: {shown}{more}" " — 트리거 칸에 (1) 또는 (1-2) 처럼 적을 것") if enforce_rule_ids: violations.extend(rule_id_violations(sections)) body_only = re.sub(r"(?m)^##\s+[^\n]*$", "", md) dangling = sorted(set(re.findall(ID_RE, body_only)) - storyboard_ids) if dangling: violations.append( f"Business Rules 가 정의되지 않은 화면 ID 를 참조한다: " f"{', '.join(dangling)}") matrix = "있음" if re.search(r"(?m)^##\s+권한 매트릭스", md) else "없음" info.append(f"권한 매트릭스: {matrix} (역할 2개 이상이면 필수 — 판정은 자체 점검 소관)") info.append(f"Business Rules 섹션 {len(sections)}개 / {len(md):,} bytes") return violations, info def check(path, css, strict=False): """한 산출물을 판정해 (위반 목록, 정보 목록) 을 반환한다. 문서의 규칙 세트(doc_ruleset)가 현재보다 낮으면, 그 이후 도입된 규칙의 위반은 기본 모드에서 위반이 아니라 "신규 규칙 참고" 로 info 에 실린다 — 작성 당시 존재하지 않던 규칙으로 옛 문서를 뒤집지 않기 위해서다 (이슈 #77). strict=True 면 전수 위반 처리한다. """ html = Path(path).read_text(encoding="utf-8") markup = markup_only(html) violations, info = [], [] undefined = undefined_classes(html, css) if undefined: violations.append(f"미정의 클래스 {len(undefined)}종: {', '.join(undefined)}") emoji = emoji_in(markup) if emoji: kinds = sorted(set(emoji)) violations.append(f"이모지 {len(emoji)}개 / {len(kinds)}종: {''.join(kinds)}") lefts = badge_lefts(markup) off = sorted({v for v in lefts if v != 2}) if off: violations.append(f"배지 left 가 2px 아닌 값 {off} (전체 {len(lefts)}개 중)") mism = badge_desc_mismatch(markup) if mism: detail = ", ".join(f"{no}({b}/{d})" for no, b, d in mism) violations.append(f"배지-desc_num 불일치: {detail}") cap = caption_mismatch(markup) if cap: detail = ", ".join(f"{no}(목업{m}/캡션{c})" for no, m, c in cap) violations.append( f"목업-캡션 불일치 — 모든 목업에 mock-caption 필수: {detail}") circled = re.findall( r'class="desc-num"[^>]*>([^<]*[\u2460-\u2473][^<]*)<', markup) if circled: violations.append( f"desc-num 원문자 사용 {len(circled)}건 — 배지와 같은 평문 표기(1, 1-1)로 바꿀 것") if "mermaid.min.js" not in html: violations.append("mermaid 런타임 누락 — IA 다이어그램이 원문 텍스트로 남는다") if "{{" in html: violations.append(f"치환 안 된 플레이스홀더 {html.count('{{')}건") defined = screen_ids(markup) dangling = sorted(referenced_ids(markup) - defined) if defined and dangling: violations.append( f"정의되지 않은 화면 ID 를 참조한다: {', '.join(dangling)}" ) if defined: violations += check_version_history(markup) violations += check_overview_slides(markup, defined) violations += check_sequence_slides(markup) violations += check_sequence_boilerplate(markup) violations += check_screen_list_types(markup) mc_viol, mc_info = check_mock_content(markup) violations += mc_viol info += mc_info ia_note = ia_subgraph_info(markup) if ia_note: info.append(ia_note) cov = event_coverage(markup) no_event = [no for no, ev, total in cov if total and not ev] if no_event: violations.append( "이벤트 표기(탭:/스와이프:/롱프레스:/입력:) 없는 화면 상세: " + ", ".join(no_event)) if cov: info.append( f"이벤트 표기 항목 {sum(e for _, e, _ in cov)}" f"/{sum(t for _, _, t in cov)}개") nums = slide_numbers(markup) info.append(f"슬라이드 {len(nums)}장: {' '.join(nums)}") info.append(f"크기 {len(html):,} bytes / 배지 {len(lefts)}개") accent = re.search(r"--accent:\s*([^;]+);", html) info.append(f"accent {accent.group(1).strip() if accent else '변수 없음'}") order = screen_order(markup) info.append("화면 순서: " + " / ".join(f"{n} {t.strip()}" for n, t in order)) if defined: info.append(f"화면 ID {len(defined)}개: {' '.join(sorted(defined))}") mocks = mock_counts(markup) multi = [f"{n}({c})" for n, c in mocks if c >= 2] info.append(f"목업 2개 이상 슬라이드 {len(multi)}개" + (f": {' '.join(multi)}" if multi else "")) locs = meta_locations(markup) filled = [n for n, v in locs if v] info.append(f"Location {len(filled)}/{len(locs)} 슬라이드" + (f" (미기입 {', '.join(n for n, v in locs if not v)})" if len(filled) != len(locs) else "")) # class 속성만 센다 — 본문 텍스트나 CSS 주석에 등장하는 같은 문자열은 목업이 아니다. partial = len(re.findall(r'class="[^"]*\bmock-partial\b[^"]*"', markup)) info.append(f"부분 목업(팝업) {partial}개") labels = badge_labels(markup) two = [x for x in labels if "-" in x] info.append(f"2단 배지 {len(two)}/{len(labels)}개") doc_ver = doc_ruleset(html) if defined: rules_file = rules_path_for(path) if rules_file.exists(): r_viol, r_info = check_rules( rules_file.read_text(encoding="utf-8"), defined, enforce_rule_ids=(strict or doc_ver >= 3)) violations += r_viol info += r_info else: violations.append( f"Business Rules 문서 없음: {rules_file.name}" " — storyboard 와 같은 디렉터리에 생성할 것") info.insert(0, f"규칙 세트: 문서 v{doc_ver} / 검증기 v{RULESET_VERSION}" + ("" if doc_ver >= RULESET_VERSION else " — 신규 규칙은 참고로만 보고 (--strict 로 위반 처리)")) if not strict and doc_ver < RULESET_VERSION: advisory = [v for v in violations if rule_introduced_in(v) > doc_ver] if advisory: violations = [v for v in violations if rule_introduced_in(v) <= doc_ver] info.append(f"참고: 신규 규칙 위반 {len(advisory)}건 — 문서 작성 이후 도입") info.extend(f" (신규) {v}" for v in advisory) return violations, info def main(): args = [a for a in sys.argv[1:] if a != "--strict"] strict = "--strict" in sys.argv[1:] if not args: print(__doc__, file=sys.stderr) return 2 css = extract_style(TEMPLATE.read_text(encoding="utf-8")) total = 0 for path in args: violations, info = check(path, css, strict=strict) total += len(violations) print(f"===== {path} =====") for line in info: print(f" · {line}") if violations: for v in violations: print(f" X {v}") print(f" => 위반 {len(violations)}건") else: print(" => 계약 위반 없음") print() print(f"총 위반 {total}건") return 1 if total else 0 if __name__ == "__main__": sys.exit(main())
-
-
tests
-
fixtures
-
layout
-
baseline-slides.html 3.1 KB · in bundle
-
-
runtime-parity
-
agy.html 117 KB · in bundle
-
agy_business-rules.md 47.2 KB
# 토스인베스트 Business Rules Version: 2.0.0 이 문서는 `토스인베스트_storyboard.html` 의 화면 ID 를 키로 각 화면의 동작을 명세한다. 목업이 "무엇이 보이는가"라면 이 문서는 "무엇을 입력받고, 무엇을 검사하고, 어떤 조건에서 어떻게 동작하는가"다. **범위** — 조회·모니터링 전용. 매수·매도·정정·취소 등 주문 실행은 범위 밖이다. 워치독은 조건 충족 시 알림만 발송하며 자동으로 주문을 내지 않는다. **공통 규칙** — 아래 규칙은 전 화면에 적용되며 각 섹션에서 반복하지 않는다. - 인증되지 않은 상태로 화면에 진입하거나 조회 중 401 을 받으면 세션 만료 안내를 노출하고 로그인으로 보낸다 (TSI-AUTH-101 → TSI-AUTH-001). - 429(호출 한도)는 30초 후 1회 자동 재시도하고, 그래도 실패하면 수동 재시도 버튼을 노출한다. - 조회 실패 시 금액 자리에는 "—" 와 "갱신 실패" 캡션을 쓴다. **0 으로 대체하지 않는다.** - 금액은 원 단위 3자리 콤마, 손익은 부호(+/−)와 색을 병기하며 0 은 회색(보합)으로 둔다. - 등락 색상은 기본 한국식(상승 적색)이고 설정에서 글로벌식으로 바꿀 수 있다 (TSI-SET-001). - 역할이 본인 1종뿐이라 권한 매트릭스 섹션을 두지 않는다. --- ## TSI-MAIN-001 홈 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 계좌 카드·지수·보유 상위 각각 스켈레톤 | | 정상 | 계좌 요약, 지수 2종, 보유 상위 3건, 워치독 요약 | | 계좌 조회 실패 | 금액 자리 "—" + "갱신 실패" 캡션 | | 보유 0건 | 보유 상위 영역에 "보유 중인 종목이 없습니다" + 종목 검색 CTA | | 알림 0건 | 헤더 알림 배지 숨김 | - 보유 상위는 **평가금액 내림차순 3건**만 노출하고 4건째부터는 목록 화면으로 유도한다. - 지수는 장 마감 후 마지막 체결값을 유지하고 "장 마감" 배지를 병기한다. - 알림 배지는 99건을 넘으면 `99+` 로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 요약 카드 탭 (1) | - | 계좌로 이동 (TSI-ACCT-001) | | 알림 배지 탭 (2) | - | 알림 내역으로 이동 (TSI-NOTI-001) | | 지수 요약 탭 (3) | - | 시장지표로 이동 (TSI-MKT-001) | | 보유 상위 탭 (4) | 보유 1건 이상 | 보유종목으로 이동 (TSI-HOLD-001) | | 보유 상위 탭 (4) | 보유 0건 | 종목 검색으로 이동 (TSI-SEARCH-001) | | 워치독 요약 탭 (5) | - | 워치독으로 이동 (TSI-WD-001) | | 하단 탭 탭 (6) | - | 각 탭 최상위 화면으로 전환 | ### 엣지케이스 - 계좌 목록은 호출 한도가 낮으므로(ACCOUNT 그룹) **세션당 1회 조회 후 캐시**하고, 당겨서 새로고침에서만 재조회한다. - 계좌 0건은 정상 상태가 아니다 — "조회 가능한 계좌가 없습니다" 안내 후 재시도만 제공한다. - 지수 조회만 실패하면 계좌 카드는 그대로 두고 지수 영역만 "—" 로 표시한다. - 백그라운드 복귀 후 5분이 지났으면 진입 시 1회 자동 재조회한다 (가정). --- ## TSI-AUTH-001 로그인 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 아이디 | 필수. 영문·숫자 4~20자, 앞뒤 공백 자동 제거 | 로그인 버튼 비활성 유지 | | 비밀번호 | 필수. 8자 이상 | 로그인 버튼 비활성 유지 | - 검증 시점은 입력 중(버튼 활성 판정)과 제출 시(서버 대조) 두 번이다. - 클라이언트에서 아이디 존재 여부를 미리 조회하지 않는다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 기본 | 아이디·비밀번호 입력, 로그인 버튼, QR 로그인 진입 | | 제출 중 | 로그인 버튼 스피너, 입력 필드 비활성 | | 인증 실패 | 비밀번호 필드 아래 인라인 오류, 필드 테두리 적색 | | 사용자 저장소 불가 | "로그인 비활성(DB 없음)" 안내와 재시도 버튼 | - 실패 문구는 "아이디 또는 비밀번호가 틀렸습니다" 하나만 쓴다 — 어느 쪽이 틀렸는지 구분해 알리지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 아이디 입력 (1-1) | - | 공백 제거, 기존 오류 문구 초기화 | | 비밀번호 입력 (1-2) | 두 필드 모두 규칙 통과 | 로그인 버튼 활성 | | 로그인 버튼 탭 (1-3) | 인증 성공 | 세션 발급 후 홈으로 이동 (TSI-MAIN-001) | | 로그인 버튼 탭 (1-3) | 인증 실패(401) | 인라인 오류 표시, 비밀번호만 비움 (2-1) | | QR 로그인 탭 (1-4) | - | 챌린지 생성 후 QR 노출, 승인 대기 (TSI-AUTH-002) | | 확인 버튼 탭 (3-1) | 세션 만료 팝업 | 로그인으로 히스토리 치환 이동 (TSI-AUTH-101) | ### 엣지케이스 - 세션 쿠키는 HttpOnly 이며 유효기간 7일이다. 발급 시각이 아니라 만료 시각을 기준으로 판정한다. - 로그인 성공 직후 뒤로가기로 로그인 화면에 돌아오지 못하도록 히스토리를 치환한다. - 연속 인증 실패 5회 시 60초간 로그인 버튼을 잠근다 (가정). - 이미 유효한 세션으로 로그인 화면에 진입하면 즉시 홈으로 보낸다 (TSI-MAIN-001). --- ## TSI-AUTH-101 세션 만료 안내 ### 입력 검증 해당 없음 — 확인만 받는 팝업. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 만료 안내 문구 + "로그인하러 가기" 단일 버튼 | - 전 화면 공통이며 401 을 받은 화면 위에 겹쳐 노출한다. - 만료 시각이나 원인을 상세히 노출하지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 확인 버튼 탭 (3-1) | - | 로컬 조회 캐시 삭제 후 로그인으로 히스토리 치환 이동 (TSI-AUTH-001) | ### 엣지케이스 - 배경 딤을 탭해도 닫히지 않는다 — 세션이 없는 화면에 머물게 두지 않기 위함이다. - 여러 조회가 동시에 401 을 받아도 팝업은 1개만 노출한다(중복 노출 방지). - 표시 설정(등락 색상)은 캐시 삭제 대상에서 제외한다. --- ## TSI-AUTH-002 QR 로그인 승인 ### 입력 검증 해당 없음 — 승인·거절만 받는 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 정상 | 요청 시각·접속 IP·브라우저, 남은 유효시간 카운트다운 | | 만료 | "요청이 만료되었습니다" 문구, 승인·거절 버튼 비활성 | | 처리 중 | 승인 버튼 스피너 | - 유효시간은 **2분**이며 1초 단위로 감소 표기한다. - 남은 시간 30초 이하부터 카운트다운을 적색으로 표시한다. - 보안 경고 문구를 버튼 위에 상시 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 승인 버튼 탭 (2) | 챌린지 유효 | 승인 처리, 요청 기기에 세션 발급 후 홈으로 이동 (TSI-MAIN-001) | | 승인 버튼 탭 (2) | 챌린지 만료 | "요청이 만료되었습니다" 표시, 승인 차단 | | 거절 버튼 탭 (3) | - | 챌린지 폐기, 요청 기기는 로그인 실패 처리 (TSI-AUTH-001) | ### 엣지케이스 - 이 화면 자체가 세션을 요구한다 — 미인증 진입 시 로그인으로 보낸다 (TSI-AUTH-001). - 같은 챌린지를 두 번 승인해도 세션은 한 번만 발급한다(멱등). - 승인 요청 중 네트워크가 끊기면 결과를 단정하지 않고 "승인 결과를 확인할 수 없습니다"로 안내한 뒤 재시도를 제공한다. - 요청 기기의 폴링은 2초 간격이며, 승인 후 최대 2초 이내에 세션이 반영된다. --- ## TSI-AUTH-003 비밀번호 변경 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 현재 비밀번호 | 필수. 서버 대조는 제출 시에만 | 제출 후 인라인 오류 | | 새 비밀번호 | 필수. 8자 이상, 현재 비밀번호와 달라야 함 | "8자 이상" / "현재와 다른 비밀번호를 입력해주세요" | | 새 비밀번호 확인 | 필수. 새 비밀번호와 일치 | "새 비밀번호 확인이 일치하지 않습니다" | - 새 비밀번호 규칙은 **포커스 아웃 시점**에 즉시 검사한다. - 세 필드가 모두 규칙을 통과해야 변경 버튼이 활성된다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 기본 | 3개 입력 필드와 규칙 안내 문구 | | 검증 실패 | 해당 필드 테두리 적색 + 아래 인라인 오류 | | 성공 | "변경되었습니다" 토스트 후 로그인으로 이동 | - 하단에 "변경 시 다른 기기의 로그인도 모두 해제됩니다" 를 상시 고지한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 현재 비밀번호 입력 (1-1) | - | 기존 오류 문구 초기화 | | 새 비밀번호 입력 (1-2) | 포커스 아웃 | 길이·현재값과의 동일 여부 즉시 검사 | | 새 비밀번호 확인 입력 (1-3) | 새 비밀번호와 불일치 | 인라인 오류 표시, 변경 버튼 비활성 | | 변경 버튼 탭 (1-4) | 서버 검증 성공 | 전 세션 무효화 후 로그인으로 이동 (TSI-AUTH-001) | | 변경 버튼 탭 (1-4) | 현재 비밀번호 불일치(401) | 현재 비밀번호 필드에 인라인 오류 (2-1) | ### 엣지케이스 - 변경 성공 시 **모든 세션을 무효화**한다. 다른 기기 로그인도 함께 해제된다. - 서버 검증 실패 시 새 비밀번호 입력값은 지우지 않는다 — 다시 입력하게 만들지 않기 위함이다. - 제출 중 화면을 벗어나도 요청은 계속 진행되며, 복귀 시 결과를 토스트로 재표시하지 않는다. - 직전 3개 비밀번호 재사용은 서버가 400 으로 거절하고 인라인 오류로 표시한다 (가정). --- ## TSI-ACCT-001 계좌 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3종 스켈레톤 | | 정상 | 계좌번호·총자산, 자산 요약 3행, 하위 메뉴 3행 | | 계좌 1개 | 헤더의 "전환" 버튼과 드롭다운 화살표 숨김 | | 조회 실패 | 금액 자리 "—" + "갱신 실패" 캡션 | - 계좌번호는 전체 표기한다(마스킹하지 않는다). 본인 계좌만 조회되므로 실익이 없다 (가정). - 평가손익 0원은 회색(보합)으로 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 헤더 탭 (1-1) | 계좌 2개 이상 | 계좌 전환 바텀시트 노출 (TSI-ACCT-101) | | 계좌 헤더 탭 (1-1) | 계좌 1개 | 동작 없음 | | 자산 요약 (1-2) | - | 읽기 전용, 이동 없음 | | 자산 대시보드 행 탭 (1-3) | - | 자산 대시보드로 이동 (TSI-DASH-001) | | 보유종목 행 탭 (1-3) | - | 보유종목으로 이동 (TSI-HOLD-001) | | 주문내역 행 탭 (1-3) | - | 주문내역으로 이동 (TSI-ORDER-001) | ### 엣지케이스 - 계좌 목록 API 는 호출 한도가 낮다(TPS 1). **세션당 1회 조회 후 캐시**하고 명시적 새로고침에서만 재조회한다. - 계좌 0건은 정상 상태가 아니다 — "조회 가능한 계좌가 없습니다" 안내 후 재시도만 제공한다. - 계좌 전환 직후에는 이전 계좌 데이터를 화면에 남기지 않는다(스켈레톤으로 초기화). --- ## TSI-ACCT-101 계좌 전환 ### 입력 검증 해당 없음 — 선택만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 계좌 목록 카드. 현재 선택 계좌는 accent 테두리 | | 계좌 정보 | 계좌번호 + 계좌유형 + seq | - 목록 순서는 계좌 seq 오름차순 고정. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 항목 탭 (2-1) | 다른 계좌 선택 | 선택 저장, 시트 닫힘, 호출 화면 재조회 | | 계좌 항목 탭 (2-1) | 이미 선택된 계좌 | 시트만 닫음, 재조회 없음 | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 선택 변경 없음 | ### 엣지케이스 - 시트가 열린 동안 계좌 목록을 재조회하지 않는다(호출 한도 보호). - 선택 계좌가 서버에서 사라진 경우(해지 등) 재조회 시 404 → 첫 계좌로 자동 전환하고 토스트로 안내한다. - 선택 계좌는 로컬에 저장되어 홈·계좌·수수료 화면이 같은 선택을 공유한다. --- ## TSI-DASH-001 자산 대시보드 ### 입력 검증 해당 없음 — 정렬 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 잔고·손익 카드와 보유 목록 스켈레톤 | | 정상 | 원화·달러 잔고, 평가손익 카드, 보유 비중 목록 | | 보유 0건 | 비중 목록 자리에 "보유 중인 종목이 없습니다" | | 조회 실패 | 금액 자리 "—" + 재시도 버튼 | - 비중은 **평가금액 합계 대비 백분율**이며 소수 1자리로 표기한다. - 수익/손실 종목 수 게이지는 평가손익 부호 기준으로 집계한다. - 기본 정렬은 평가금액 내림차순. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 잔고 카드 (1) | - | 읽기 전용, 이동 없음 | | 평가손익 카드 (2) | - | 읽기 전용, 이동 없음 | | 정렬 선택 탭 (3) | 보유 1건 이상 | 정렬 시트 노출 후 클라이언트 정렬, 재조회 없음 | | 보유 비중 행 탭 (4) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 새로고침 탭 (5) | - | 잔고·보유 전체 재조회, 진행 중 스켈레톤 | ### 엣지케이스 - 매입금액이 0인 종목(무상증자 등)은 수익률을 계산하지 않고 "—" 로 둔다. - 달러 잔고가 0이어도 카드를 숨기지 않는다 — 통화별 잔고 유무 자체가 정보다. - 새로고침 연타는 진행 중 요청이 끝날 때까지 무시한다(중복 호출 방지). --- ## TSI-HOLD-001 보유종목 ### 입력 검증 해당 없음 — 정렬 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 종목별 평가금액·수량·평단·손익, 하단 합계 카드 | | 데이터 없음 | 빈 상태 안내 + "종목 검색하기" CTA, 정렬 칩 비활성 | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 정렬은 **평가금액 내림차순**. - 손익률은 소수 2자리, 부호와 색 병기. 0.00% 는 회색. - 종목명은 1줄 말줄임, 심볼은 부가 정보로만 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 정렬 칩 탭 (1-1) | 보유 1건 이상 | 클라이언트 정렬 즉시 적용, 재조회 없음 | | 보유 종목 행 탭 (1-2) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 합계 카드 (1-3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 보유 0건이면 합계 카드를 숨긴다 — 0원 합계는 정보가 아니다. - 정렬 상태는 화면을 벗어나면 초기화한다(기본 정렬로 복귀). - 장중 시세 반영 지연으로 합계와 개별 합이 어긋나지 않도록, 합계는 **표시 중인 값으로 직접 합산**하지 않고 서버 응답값을 그대로 쓴다. --- ## TSI-HOLD-002 보유종목 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 헤더·상세 카드 스켈레톤 | | 정상 | 현재가·등락, 수량·평단·평가금액·평가손익·비중, 타깃 요약 | | 타깃 계산 불가 | 익절/손절 영역에 "ATR 데이터가 부족합니다" | - 포트폴리오 비중은 전체 평가금액 대비 소수 1자리. - 타깃·스탑은 참고값이며 계산 근거(ATR 배수)를 함께 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 헤더 (2-1) | - | 읽기 전용, 이동 없음 | | 보유 상세 5행 (2-2) | - | 읽기 전용, 이동 없음 | | 익절/손절 참고 탭 (2-3) | 타깃 계산 가능 | 익절/손절로 이동 (TSI-TGT-001) | | 종목 정보 보기 탭 (2-4) | - | 종목 상세로 이동 (TSI-STOCK-001) | ### 엣지케이스 - 보유 수량이 0이 된 종목(전량 매도 직후)에 진입하면 "보유하지 않는 종목입니다" 안내 후 보유종목 목록으로 되돌린다 (TSI-HOLD-001). - 상장폐지·거래정지 종목은 현재가 자리에 "거래정지" 배지를 표시하고 평가손익은 마지막 체결가 기준임을 캡션으로 명시한다. - 신규 상장 등으로 ATR(20일) 산출에 필요한 일봉이 20개 미만이면 타깃 영역을 계산하지 않는다. --- ## TSI-ORDER-001 주문내역 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 조회 기간 | 시작일 ≤ 종료일, 최대 6개월 | "조회 기간은 최대 6개월입니다" 토스트, 조회 차단 | - 직접선택 외의 기간 칩은 검증 대상이 아니다(고정 범위). ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 매수·매도 라벨, 종목명, 수량, 체결가, 시각 | | 데이터 없음 | "해당 기간 주문내역이 없습니다" | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 탭은 **체결**, 기본 기간은 **1개월**. - 정렬은 체결(주문) 시각 내림차순 고정. - 매수는 적색, 매도는 청색 라벨로 구분한다(등락 색상 설정과 무관하게 고정). - 한 번에 20건씩 불러오고 "더 보기"로 이어붙인다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 체결·미체결 탭 탭 (1-1) | - | 목록 전환, 기간 필터는 유지 | | 기간 칩 탭 (1-2) | 유효 기간 | 선택 기간으로 재조회 | | 기간 칩 탭 (1-2) | 직접선택 6개월 초과 | 오류 토스트, 이전 조회 결과 유지 | | 주문 행 탭 (1-3) | - | 주문 상세로 이동 (TSI-ORDER-002) | ### 엣지케이스 - 미체결 탭에서 조회 도중 체결된 주문은 다음 갱신에서 체결 탭으로 이동한다 — 화면에서 즉시 지우지 않는다. - 부분체결 주문은 양쪽 탭에 모두 노출하고 체결수량을 병기한다. - "더 보기" 도중 기간을 바꾸면 누적분을 버리고 첫 페이지부터 다시 조회한다. --- ## TSI-ORDER-002 주문 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3종 스켈레톤 | | 정상 | 주문 요약, 체결 정보 5행, 비용 상세 4행 | | 취소 주문 | 상태 배지 "취소", 체결 정보는 취소 시점까지의 값 | - 매수는 거래세 0원으로 표기한다 — 항목 자체를 숨기지 않는다. - 정산금액은 매수면 거래대금+비용, 매도면 거래대금−비용으로 계산한다. - 부분체결이면 체결수량을 주문수량과 대비해 강조 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 주문 요약 헤더 (2-1) | - | 읽기 전용, 이동 없음 | | 체결 정보 (2-2) | - | 읽기 전용, 이동 없음 | | 비용 상세 (2-3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 여러 번에 나눠 체결된 주문은 체결 단가를 **가중평균**으로 표시하고 평균임을 캡션으로 명시한다. - 정정된 주문은 원 주문번호를 함께 노출한다. - 서버가 수수료·세금을 내려주지 않는 과거 주문은 해당 행을 "—" 로 두고 임의 계산하지 않는다. --- ## TSI-TGT-001 익절/손절 ### 입력 검증 해당 없음 — ATR 배수 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 종목 카드 3개 스켈레톤 | | 정상 | 종목별 현재가·평단·ATR, 타깃·스탑 게이지 | | 보유 0건 | "보유 중인 종목이 없습니다" 빈 상태 | | ATR 부족 | 해당 종목 카드에 게이지 대신 "ATR 데이터 부족" | - **참고용 계산값이며 자동 주문이 실행되지 않음**을 상단 배너로 상시 고지한다. - 타깃 = 평단 + (ATR × 배수), 스탑 = 평단 − (ATR × 배수 × 0.7) 로 계산한다 (가정). - ATR 은 일봉 20기간 기준이며 **당일 미완성 봉을 제외한 확정 종가**로 산출한다. - 상태 배지는 현재가가 스탑 기준 3% 이내면 "스탑 근접", 타깃 기준 3% 이내면 "타깃 근접". ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 참고 고지 배너 (1) | - | 읽기 전용, 닫기 불가 | | ATR 배수 칩 탭 (2) | - | 선택 배수로 전 종목 재계산, 서버 재조회 없음 | | 종목 카드 탭 (3) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 타깃·스탑 게이지 (4) | - | 읽기 전용, 이동 없음 | | 새로고침 탭 (5) | - | 현재가·ATR 재조회 후 전 종목 재계산 | ### 엣지케이스 - 현재가가 타깃을 넘었거나 스탑을 밑돌면 게이지 마커를 양 끝에 고정하고 초과분을 텍스트로 적는다. - 배수 선택은 화면을 벗어나면 기본값(2.0배)으로 되돌린다. - 장중에는 확정 종가가 없으므로 ATR 은 전일 기준값을 쓰고 기준일을 캡션에 명시한다. --- ## TSI-WD-001 워치독 ### 입력 검증 해당 없음 — 목록·토글만 있는 화면. 조건 입력 검증은 등록 화면 소관 (TSI-WD-002). ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3개 스켈레톤 | | 정상 | 이름·조건식·쿨다운·최근 발동 시각, 활성 토글 | | 데이터 없음 | "등록된 워치독이 없습니다" + 등록 CTA | | 비활성 항목 | 카드 전체를 흐리게 처리하고 토글 off | - 상단에 활성 건수와 당일 발동 건수를 요약한다. - 조건식은 그룹 안 AND, 그룹 간 OR 로 읽히도록 연결어를 굵게 표기한다. - 최근 발동 이력이 없으면 "발동 이력 없음" 으로 적고 시각 자리를 비우지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 등록 버튼 탭 (1-1) | 등록 건수 20건 미만 | 워치독 등록으로 이동 (TSI-WD-002) | | 등록 버튼 탭 (1-1) | 등록 건수 20건 도달 | "최대 20건까지 등록할 수 있습니다" 토스트 | | 워치독 카드 탭 (1-2) | - | 워치독 등록 화면을 수정 모드로 진입 (TSI-WD-002) | | 워치독 카드 스와이프 (1-2) | - | 삭제 확인 바텀시트 노출 (TSI-WD-101) | | 활성 토글 탭 (1-3) | - | 활성·비활성 즉시 전환, 비활성은 평가 대상에서 제외 | ### 엣지케이스 - 토글 전환은 낙관적으로 먼저 반영하고, 실패하면 원래 상태로 되돌린 뒤 토스트로 알린다. - 조건 평가는 서버 스케줄러가 수행하므로 화면을 닫아도 알림은 계속 발송된다. - 쿨다운 이내 재충족은 알림을 보내지 않고 평가 시각만 갱신한다. - 감시 종목이 거래정지되면 평가를 건너뛰고 카드에 "거래정지" 배지를 표시한다. --- ## TSI-WD-002 워치독 등록 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 감시 종목 | 필수. 종목 검색에서 선택 | 저장 버튼 비활성 | | 조건 지표 | 필수. 현재가·등락률·외국인 순매수·기관 순매수·뉴스 키워드 중 하나 | 저장 버튼 비활성 | | 비교 연산자 | 필수. 초과(>) 또는 미만(<) | 저장 버튼 비활성 | | 임계값 | 필수. 0 초과 숫자. 가격은 정수, 등락률은 소수 2자리, 금액은 억 단위 | "올바른 값을 입력해주세요" 인라인 오류 | | 뉴스 키워드 | 지표가 뉴스일 때 필수. 1~20자 | "키워드를 입력해주세요" | | 쿨다운 | 필수. 10~1440분 정수. 기본 60분 | "10분 이상 1440분 이하로 입력해주세요" | - 그룹당 조건 최대 3개, 그룹 최대 3개. - 검증 시점은 입력 중(형식)과 저장 시(서버 중복·한도)다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 신규 | 빈 조건 그룹 1개, 쿨다운 60분 | | 수정 | 기존 값 채운 상태, 헤더 "워치독 수정" | | 저장 중 | 저장 버튼 스피너, 입력 비활성 | | 검증 실패 | 해당 입력칸 아래 인라인 오류 | - 종목이 선택되기 전에는 지표 선택칸을 비활성으로 둔다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 감시 종목 선택 탭 (2-1) | - | 종목 검색으로 이동 후 선택 결과 수신 (TSI-SEARCH-001) | | 조건 입력 (2-2) | 형식 위반 | 인라인 오류, 저장 버튼 비활성 | | 조건 그룹 추가 탭 (2-3) | 그룹 3개 미만 | 그룹 추가, 그룹끼리 OR 로 평가 | | 조건 그룹 추가 탭 (2-3) | 그룹 3개 도달 | "조건 그룹은 최대 3개입니다" 토스트 | | 저장 버튼 탭 (2-4) | 검증 통과 | 저장 후 워치독 목록으로 복귀 (TSI-WD-001) | | 저장 버튼 탭 (2-4) | 동일 조건 중복(409) | "같은 조건의 워치독이 이미 있습니다" 인라인 오류 | ### 엣지케이스 - 등록 한도는 계정당 20건이다. 초과 시 서버가 400 으로 거절하고 저장을 차단한다. - 수정 모드에서 조건을 모두 지우면 저장할 수 없다 — 최소 1개 조건이 필요하다. - 저장 중 화면을 벗어나도 요청은 계속 진행되며, 목록 복귀 시 결과가 반영된다. - 종목 선택 화면에서 뒤로 나오면 기존 입력값은 유지한다(초기화하지 않는다). --- ## TSI-WD-101 워치독 삭제 확인 ### 입력 검증 해당 없음 — 확인/취소만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 삭제 대상 워치독 이름 + 취소·삭제 2버튼 | | 삭제 버튼 | 적색 배경으로 위험 동작임을 표시 | - 삭제 후 알림이 더 이상 오지 않음을 문구로 명시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 취소 버튼 탭 (3-1) | - | 시트 닫힘, 목록 유지 | | 삭제 버튼 탭 (3-1) | 삭제 성공 | 목록에서 제거 후 "삭제되었습니다" 토스트 (TSI-WD-001) | | 배경 딤 탭 (3-1) | - | 시트 닫힘, 목록 유지 | ### 엣지케이스 - 발동 이력은 삭제하지 않는다 — 알림 내역에는 그대로 남는다 (TSI-NOTI-001). - 이미 삭제된 워치독을 다시 삭제하면(다른 기기에서 선삭제) 404 를 성공으로 간주하고 목록에서 제거한다. - 삭제 실패 시 시트를 닫지 않고 오류 문구를 시트 안에 표시한다. --- ## TSI-NOTI-001 알림 내역 ### 입력 검증 해당 없음 — 필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 워치독 이름·발동 사유·시각. 안 읽음은 좌측 accent 바 | | 데이터 없음 | 빈 상태 안내 + "워치독 등록하기" CTA | | 오류 | 재시도 버튼 포함 오류 배너 | - 시각은 당일이면 `HH:MM`, 전일이면 "어제 HH:MM", 그 이전이면 `MM-DD HH:MM`. - 발동 사유에는 **실제 관측값과 기준값을 함께** 적는다 (예: 95,400원 / 기준 95,000원). - 한 번에 20건씩 불러오고 "더 보기"로 이어붙인다. 보관 기간은 90일 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 필터 칩 탭 (1-1) | - | 전체·안 읽음 필터 즉시 적용 | | 알림 행 탭 (1-2) | 워치독 존재 | 읽음 처리 후 해당 워치독으로 이동 (TSI-WD-001) | | 알림 행 탭 (1-2) | 워치독 삭제됨 | 읽음 처리만 하고 "삭제된 워치독입니다" 토스트 | | 모두 읽음 탭 (1-3) | 안 읽음 1건 이상 | 전체 읽음 처리, 홈 배지 즉시 갱신 (TSI-MAIN-001) | | 빈 상태 CTA 탭 (2-1) | 알림 0건 | 워치독 등록으로 이동 (TSI-WD-002) | ### 엣지케이스 - "모두 읽음" 은 안 읽음이 0건이면 비활성한다. - 읽음 처리 실패 시 화면 상태를 되돌리고 토스트로 알린다. - 같은 워치독이 쿨다운 뒤 다시 발동하면 별도 행으로 쌓는다 — 묶어서 표시하지 않는다. --- ## TSI-SEARCH-001 종목 검색 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 검색어 | 1~20자. 공백만 입력 불가 | 조회하지 않고 최근 검색 화면 유지 | | 종목코드 | 숫자 6자리로 입력하면 코드 완전일치 조회 | 결과 없음 처리 | - 입력 후 **300ms 디바운스**로 자동 조회한다. 별도 검색 버튼을 두지 않는다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 검색 전 | 최근 검색 칩 + 거래대금 상위 3건 | | 조회 중 | 결과 영역 스켈레톤 3행 | | 결과 있음 | 종목명·코드·시장·현재가·등락률, 상단에 결과 건수 | | 결과 없음 | "검색 결과가 없습니다" + 검색어 확인 안내 | - 최근 검색은 최대 10건, 최신순이며 **로컬에만** 저장한다. - 정렬은 종목명 전방일치 우선, 그다음 거래대금 내림차순 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 검색 입력 (1-1) | 1자 이상 | 300ms 디바운스 후 자동 조회 | | 검색 입력 (1-1) | 공백만 입력 | 조회하지 않고 검색 전 상태 유지 | | 최근 검색 칩 탭 (1-2) | - | 해당 종목 상세로 이동 (TSI-STOCK-001) | | 전체 삭제 탭 (1-3) | 최근 검색 1건 이상 | 확인 없이 즉시 전체 삭제 | | 검색 결과 행 탭 (2-1) | 일반 진입 | 최근 검색에 추가하고 종목 상세로 이동 (TSI-STOCK-001) | | 검색 결과 행 탭 (2-1) | 워치독 등록에서 진입 | 선택 종목을 반환하고 등록 화면으로 복귀 (TSI-WD-002) | ### 엣지케이스 - 최근 검색이 0건이면 해당 영역 자체를 숨긴다(빈 제목만 남기지 않는다). - 워치독 등록·종목별 수급에서 진입한 경우 화면 이동 대신 **선택 결과를 반환**한다. - 검색 도중 새 입력이 들어오면 이전 요청 결과는 버린다(경합 방지). - 상장폐지 종목은 결과에 노출하되 "거래정지" 배지를 붙이고 현재가를 "—" 로 둔다. --- ## TSI-STOCK-001 종목 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 헤더·시세 요약 스켈레톤 | | 정상 | 현재가·등락, 시세 요약 8항목, 탭 4종, 워치독 등록 CTA | | 장 마감 | 기준 시각 옆에 "장 마감 기준" 문구 | | 거래정지 | 현재가 자리에 "거래정지" 배지, 등락 숨김 | - 상한가·하한가는 등락 색상 설정과 무관하게 고정색(상한 적색·하한 청색)을 쓴다. - 거래대금은 조 단위까지 축약하고 소수 2자리로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 헤더 (1) | - | 읽기 전용, 이동 없음 | | 시세 요약 (2) | - | 읽기 전용, 이동 없음 | | 차트 탭 탭 (3) | - | 차트로 전환 (TSI-CHART-001) | | 호가 탭 탭 (3) | - | 호가로 전환 (TSI-ORDB-001) | | 체결 탭 탭 (3) | - | 체결로 전환 (TSI-TRADE-001) | | 수급 탭 탭 (3) | - | 종목별 수급으로 전환 (TSI-FLOW-001) | | 워치독 등록 CTA 탭 (4) | - | 종목이 채워진 상태로 워치독 등록 진입 (TSI-WD-002) | ### 엣지케이스 - 장 시작 전에는 시가·고가·저가가 비어 있으므로 "—" 로 두고 전일 종가만 노출한다. - 시세 조회가 실패해도 탭 구조는 유지한다 — 각 탭이 개별로 재시도할 수 있어야 한다. - 보유 중인 종목이면 헤더 아래에 보유 수량을 부가 표시한다 (TSI-HOLD-002 로 이동 가능). --- ## TSI-CHART-001 차트 ### 입력 검증 해당 없음 — 주기·지표 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 캔들·거래량 영역 스켈레톤 | | 정상 | 캔들, 이동평균선, 거래량, 하단 요약 텍스트 | | 데이터 부족 | "표시할 캔들이 없습니다" + 다른 주기 안내 | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 주기는 **일봉**, 조회 봉 수는 최근 100봉. - 상승봉 적색·하락봉 청색이며 등락 색상 설정을 따른다. - 이동평균은 MA5·MA20·MA60 이며 기본 전부 표시. - 접근성을 위해 하단에 상승/하락 봉 수와 최고·최저가를 **텍스트로도** 제공한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 주기 칩 탭 (1) | - | 선택 주기(1분·일·주·월)로 캔들 재조회 | | 캔들 영역 롱프레스 (2) | - | 해당 봉의 시가·고가·저가·종가 툴팁 표시 | | 이동평균 범례 탭 (3) | - | 해당 이동평균선 표시·숨김 전환 | | 거래량 영역 (4) | - | 읽기 전용, 캔들과 x축 공유 | ### 엣지케이스 - 1분봉은 장중에만 갱신되며 장 마감 후에는 마지막 스냅샷을 유지한다. - 상장 기간이 짧아 MA60 을 못 만드는 종목은 해당 선을 그리지 않고 범례를 흐리게 처리한다. - 장중 미완성 봉은 점선으로 구분해 확정 봉과 섞이지 않게 한다. - 툴팁은 손을 떼면 사라지며, 표시 중에는 화면 스크롤을 잠근다. --- ## TSI-ORDB-001 호가 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 호가 10행 스켈레톤 | | 정상 | 매도 5호가(위)·매수 5호가(아래), 중앙 현재가, 총잔량 요약 | | 장 마감 | 마지막 스냅샷 유지 + "장 마감" 배지 | | 거래정지 | 호가 영역에 "거래정지" 안내, 잔량 숨김 | - 잔량 막대는 **표시된 10개 호가 중 최대 잔량을 100%** 로 한 상대 길이다. - 매도는 청색 계열, 매수는 적색 계열로 좌우 대칭 배치한다. - 현재가 행은 위아래 굵은 구분선으로 분리한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 호가 행 탭 (1) | - | 해당 가격을 임계값으로 채워 워치독 등록 진입 (TSI-WD-002) | | 잔량 막대 (2) | - | 읽기 전용, 잔량 수치를 막대 안에 병기 | | 총잔량 요약 (3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 시간외 단일가 시간대에는 호가가 제공되지 않으므로 "시간외 단일가 시간입니다" 안내로 대체한다. - 상한가·하한가에 도달하면 한쪽 잔량이 0이 되며, 이때 막대를 그리지 않고 0을 표기한다. - 갱신 주기는 화면이 보이는 동안 3초이며, 백그라운드로 가면 폴링을 멈춘다. --- ## TSI-TRADE-001 체결 ### 입력 검증 해당 없음 — 필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 7행 스켈레톤 | | 정상 | 체결 요약 3항목 + 최근 체결 30건 | | 데이터 없음 | "체결 내역이 없습니다" (장 시작 전) | | 오류 | 재시도 버튼 포함 오류 배너 | - 정렬은 체결 시각 내림차순 고정, 표시 건수는 최근 30건. - 체결강도는 매수체결량÷매도체결량×100 이며 100% 초과는 매수 우위로 적색 표기한다. - 시각은 초 단위(`HH:MM:SS`)까지 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 체결량 필터 탭 (1) | - | 전체·1천주 이상 필터 즉시 적용, 재조회 없음 | | 체결 요약 (2) | - | 읽기 전용, 이동 없음 | | 체결 목록 스와이프 (3) | 아래로 당김 | 최신 체결 갱신 | ### 엣지케이스 - 필터가 "1천주 이상"인데 해당 체결이 없으면 목록을 비우고 필터 해제를 안내한다. - 장 마감 후에는 폴링을 멈추고 마지막 체결 목록을 유지한다. - 동시호가 체결은 시각이 같은 행이 여러 개 생길 수 있으므로 정렬을 안정 정렬로 유지한다. --- ## TSI-RANK-001 랭킹 ### 입력 검증 해당 없음 — 유형·필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 6행 스켈레톤 | | 정상 | 순위·종목명·기준값·현재가·등락률 | | 데이터 없음 | "표시할 종목이 없습니다" | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 유형은 **거래대금**, 기본 시장은 **KOSPI**, 기본 표시 건수는 **50위**. - 상위 2건은 순위 숫자를 accent 로 강조한다. - 기준값은 유형에 따라 달라진다(거래대금은 금액, 상승률은 %). - 상단에 기준 시각을 명시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 유형 칩 탭 (1-1) | - | 선택 유형으로 재조회, 필터는 유지 | | 필터 버튼 탭 (1-2) | - | 랭킹 필터 바텀시트 노출 (TSI-RANK-101) | | 랭킹 행 탭 (1-3) | - | 종목 상세로 이동 (TSI-STOCK-001) | ### 엣지케이스 - 장 시작 전에는 전일 확정 순위를 보여주고 기준 시각으로 전일임을 명시한다. - 동순위가 발생하면 종목코드 오름차순으로 안정 정렬한다. - 표시 건수를 늘렸다가 줄이면 재조회 없이 클라이언트에서 잘라 보여준다. --- ## TSI-RANK-101 랭킹 필터 ### 입력 검증 해당 없음 — 선택만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 시장 구분 3종, 표시 건수 3종. 현재 선택은 accent 칩 | - 선택 상태는 목록 화면의 현재 조건을 그대로 반영해 연다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 필터 옵션 탭 (2-1) | 시장 구분 변경 | 선택 즉시 반영, 시트 닫고 목록 재조회 (TSI-RANK-001) | | 필터 옵션 탭 (2-1) | 표시 건수 변경 | 선택 즉시 반영, 시트 닫고 목록 갱신 | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 선택 변경 없음 | ### 엣지케이스 - 시장 구분을 "전체"로 바꾸면 표시 건수는 유지하되 조회는 처음부터 다시 한다. - 필터 선택은 화면을 벗어나면 기본값(KOSPI·50위)으로 되돌린다. --- ## TSI-MKT-001 시장지표 ### 입력 검증 해당 없음 — 지수·기간 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 지수 카드·차트 스켈레톤 | | 정상 | KOSPI·KOSDAQ 카드, 추이 차트, 투자자별 매매대금 | | 조회 실패 | 지수 값 "—" + 재시도 버튼 | - 기본 선택 지수는 **KOSPI**, 기본 기간은 **1일**. - 투자자별 매매대금은 **시장 전체·일별 확정치**이며 종목 단위가 아님을 캡션으로 명시한다. - 지수는 소수 2자리, 매매대금은 억 단위로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 지수 카드 탭 (1) | - | 추이 차트를 해당 지수로 전환 | | 기간 칩 탭 (2) | - | 선택 기간(1일·1주·1개월·1년)으로 추이 재조회 | | 추이 차트 롱프레스 (3) | - | 해당 시점의 지수값 툴팁 표시 | | 투자자별 매매대금 탭 (4) | - | 종목별 수급으로 이동 (TSI-FLOW-001) | ### 엣지케이스 - 장중에는 투자자별 매매대금 확정치가 없으므로 **전일 확정치**를 보여주고 기준일을 명시한다. - 휴장일에 진입하면 직전 거래일 기준으로 표시하고 "휴장" 배지를 붙인다. - 1년 기간은 데이터 양이 많으므로 일봉 단위로 다운샘플링한다. --- ## TSI-FLOW-001 종목별 수급 ### 입력 검증 해당 없음 — 종목·주기 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 차트·표 스켈레톤 | | 정상 | 외국인·기관 순매수 차트와 회차별 표 | | 종목 미선택 | "종목을 선택해주세요" + 검색 진입 CTA | | 데이터 없음 | "해당 종목의 수급 데이터가 없습니다" | - 기본 주기는 **장중 30분 회차**. - 장중 회차는 **외국인·기관만** 제공되며 개인은 집계되지 않는다 — 이 한계를 화면에 고지한다. - 순매수는 억 단위 소수 1자리, 부호와 색을 병기한다. - 표는 최신 회차부터 내림차순. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 선택 탭 (1) | - | 종목 검색으로 이동 후 선택 결과 수신 (TSI-SEARCH-001) | | 주기 칩 탭 (2) | - | 장중 30분·일별 확정치 전환 후 재조회 | | 수급 차트 롱프레스 (3) | - | 해당 회차의 외국인·기관 금액 툴팁 표시 | | 데이터 한계 고지 (4) | 장중 주기 | 개인 수급 미제공 안내 표시. 일별 주기에서는 숨김 | | 회차별 표 스와이프 (5) | 아래로 당김 | 최신 회차 갱신 | ### 엣지케이스 - 수급 집계 대상 종목이 제한적이므로, 대상이 아닌 종목은 "수급 제공 대상이 아닙니다"로 안내하고 빈 차트를 그리지 않는다. - 회차 데이터가 지연 도착하면 마지막 회차가 비어 보일 수 있다 — 빈 값은 0으로 채우지 않고 행 자체를 만들지 않는다. - 장중 회차와 일별 확정치는 산출 기준이 달라 합계가 일치하지 않을 수 있음을 캡션으로 밝힌다. --- ## TSI-FX-001 환율 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 환산 금액 | 선택. 0 초과, 소수 2자리까지, 최대 999,999,999 | 결과 자리에 "—" 유지, 오류 문구 미표시 | ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 통화쌍 3행 스켈레톤 | | 정상 | USD·JPY·EUR 대 원화, 기준시각, 환산 계산기 | | 조회 실패 | 환율 자리 "—" + 재시도 버튼 | - JPY 는 **100엔 기준**임을 통화명 옆에 명시한다. - 환율은 소수 2자리, 환산 결과는 원 단위 정수로 반올림한다. - 기준시각은 응답값을 그대로 표시하며 임의로 현재시각을 쓰지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 기준시각·새로고침 탭 (1) | - | 전 통화쌍 재조회 | | 통화쌍 행 탭 (2) | - | 해당 통화를 환산 계산기의 기준 통화로 설정 | | 환산 금액 입력 (3) | 유효 금액 | 원화 환산 결과 즉시 산출 | | 환산 금액 입력 (3) | 공백 | 결과 "—" 로 복귀 | ### 엣지케이스 - 매매기준율 기준 참고값이며 실제 환전 금액과 다를 수 있음을 하단 캡션으로 명시한다. - 휴일·야간에는 마지막 고시 환율을 유지하고 기준시각으로 최신이 아님을 드러낸다. - 일부 통화만 조회에 실패하면 해당 행만 "—" 로 두고 나머지는 정상 표시한다. --- ## TSI-CAL-001 장운영 캘린더 ### 입력 검증 해당 없음 — 월 이동·날짜 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 달력 그리드 스켈레톤 | | 정상 | 월 그리드(정규장·휴장·조기폐장 색 구분) + 선택일 상세 | | 조회 실패 | "장운영 정보를 가져오지 못했습니다" + 재시도 | - 기본 선택일은 **오늘**이며, 오늘이 조회 월에 없으면 해당 월 1일. - 조회 가능 범위는 당월 기준 **전후 12개월**. - 색만으로 구분하지 않도록 하단에 범례를 함께 둔다. - 휴장일은 시간 행 대신 휴장 사유를 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 월 이동 탭 (1) | 조회 범위 내 | 이전·다음 달로 이동 후 재조회 | | 월 이동 탭 (1) | 범위 밖 | 화살표 비활성, 동작 없음 | | 월 그리드 스와이프 (1) | - | 좌우 스와이프로 월 전환 | | 날짜 탭 (2) | - | 해당 날짜 선택, 하단 상세 갱신 | | 선택일 상세 (3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 임시 휴장·조기 폐장(수능일 등)은 정규장 시간이 다르게 내려온다. **응답값을 그대로 표시하고 임의로 고정하지 않는다.** - 아직 확정되지 않은 미래 월은 "미확정" 배지를 붙이고 기본 일정만 회색으로 보여준다. - 주말은 조회 대상이지만 항상 휴장으로 표시한다. --- ## TSI-FEE-001 수수료 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 거래금액 | 선택. 0 초과 정수, 최대 9,999,999,999원 | 결과 자리에 "—" 유지, 오류 문구 미표시 | ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 명세 스켈레톤 | | 정상 | 매수·매도 수수료율, 최소 수수료 | | 거래세 고지 | 배너 상시 노출 | | 계산 결과 | 거래금액 미입력 시 "—", 입력 시 수수료+세금 합계 | - 수수료율은 **소수 4자리**로 표기한다(0.0000% 도 그대로 노출). - 증권거래세는 매도 시 거래대금의 0.15%로 계산한다 (가정 — 코스피 기준). - 수수료는 원 단위 절사한다 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 선택 탭 (1) | 계좌 2개 이상 | 계좌 전환 바텀시트 노출 (TSI-ACCT-101) | | 수수료율 명세 (2) | - | 읽기 전용, 이동 없음 | | 거래세 고지 배너 (3) | - | 읽기 전용, 상시 노출 | | 거래금액 입력 (4) | 유효 금액 | 매수·매도 각각 예상 수수료+세금 즉시 산출 | | 거래금액 입력 (4) | 공백 | 결과 "—" 로 복귀 | | 계산 결과 (5) | - | 읽기 전용, 참고용 캡션 병기 | ### 엣지케이스 - 수수료율이 0%인 계좌에서도 거래세는 발생한다. **고지 배너를 숨기지 않는다.** - 계산기는 참고용이며 실제 정산 금액과 다를 수 있음을 결과 하단에 캡션으로 명시한다. - 계좌를 전환하면 수수료율을 재조회하고 입력된 거래금액은 유지한 채 결과만 다시 계산한다. --- ## TSI-SET-001 설정 ### 입력 검증 해당 없음 — 토글·이동만 있는 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 계정 카드 스켈레톤 | | 정상 | 계정 카드(아이디·세션 만료 시각), 기타 메뉴 3행, 표시·계정 설정, 로그아웃 | | 세션 정보 없음 | 아이디·만료 시각 자리에 "—" | - 등락 색상 토글은 기본 **한국식(상승 적색)** 이다. - 로그아웃은 위험 동작이므로 적색 텍스트로 구분한다. - 세션 만료 시각은 `YYYY-MM-DD HH:MM` 으로 분 단위까지 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계정 카드 (1-1) | - | 읽기 전용, 이동 없음 | | 환율 행 탭 (1-2) | - | 환율로 이동 (TSI-FX-001) | | 장운영 캘린더 행 탭 (1-2) | - | 장운영 캘린더로 이동 (TSI-CAL-001) | | 수수료 행 탭 (1-2) | - | 수수료로 이동 (TSI-FEE-001) | | 등락 색상 토글 탭 (1-3) | - | 한국식/글로벌식 전환, 앱 전체 즉시 반영 | | 비밀번호 변경 행 탭 (1-4) | - | 비밀번호 변경으로 이동 (TSI-AUTH-003) | | 로그아웃 탭 (1-5) | - | 로그아웃 확인 바텀시트 노출 (TSI-SET-101) | ### 엣지케이스 - 등락 색상 설정은 로컬 저장이며 **로그아웃해도 유지한다** (가정). - 세션 만료 시각이 10분 이내면 계정 카드에 "곧 만료" 배지를 표시한다 (가정). - 세션 정보 조회에 실패해도 나머지 메뉴는 정상 동작해야 한다 — 화면 전체를 오류로 덮지 않는다. --- ## TSI-SET-101 로그아웃 확인 ### 입력 검증 해당 없음 — 확인/취소만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 확인 문구 + 취소·로그아웃 2버튼 | | 로그아웃 버튼 | 적색 배경으로 위험 동작임을 표시 | ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 취소 버튼 탭 (2-1) | - | 시트 닫힘, 세션 유지 | | 로그아웃 버튼 탭 (2-1) | - | 세션 종료, 로컬 캐시(최근 검색) 삭제 후 로그인으로 이동 (TSI-AUTH-001) | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 세션 유지 | ### 엣지케이스 - 로그아웃 요청이 실패해도 클라이언트 세션·캐시는 삭제하고 로그인 화면으로 보낸다. - 로그아웃 후 뒤로가기로 이전 화면에 복귀할 수 없어야 한다(히스토리 치환). - 표시 설정(등락 색상)과 선택 계좌는 삭제 대상에서 제외한다. -
claude.html 206.3 KB · in bundle
-
claude_business-rules.md 47.2 KB
# 토스인베스트 Business Rules Version: 2.0.0 이 문서는 `토스인베스트_storyboard.html` 의 화면 ID 를 키로 각 화면의 동작을 명세한다. 목업이 "무엇이 보이는가"라면 이 문서는 "무엇을 입력받고, 무엇을 검사하고, 어떤 조건에서 어떻게 동작하는가"다. **범위** — 조회·모니터링 전용. 매수·매도·정정·취소 등 주문 실행은 범위 밖이다. 워치독은 조건 충족 시 알림만 발송하며 자동으로 주문을 내지 않는다. **공통 규칙** — 아래 규칙은 전 화면에 적용되며 각 섹션에서 반복하지 않는다. - 인증되지 않은 상태로 화면에 진입하거나 조회 중 401 을 받으면 세션 만료 안내를 노출하고 로그인으로 보낸다 (TSI-AUTH-101 → TSI-AUTH-001). - 429(호출 한도)는 30초 후 1회 자동 재시도하고, 그래도 실패하면 수동 재시도 버튼을 노출한다. - 조회 실패 시 금액 자리에는 "—" 와 "갱신 실패" 캡션을 쓴다. **0 으로 대체하지 않는다.** - 금액은 원 단위 3자리 콤마, 손익은 부호(+/−)와 색을 병기하며 0 은 회색(보합)으로 둔다. - 등락 색상은 기본 한국식(상승 적색)이고 설정에서 글로벌식으로 바꿀 수 있다 (TSI-SET-001). - 역할이 본인 1종뿐이라 권한 매트릭스 섹션을 두지 않는다. --- ## TSI-MAIN-001 홈 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 계좌 카드·지수·보유 상위 각각 스켈레톤 | | 정상 | 계좌 요약, 지수 2종, 보유 상위 3건, 워치독 요약 | | 계좌 조회 실패 | 금액 자리 "—" + "갱신 실패" 캡션 | | 보유 0건 | 보유 상위 영역에 "보유 중인 종목이 없습니다" + 종목 검색 CTA | | 알림 0건 | 헤더 알림 배지 숨김 | - 보유 상위는 **평가금액 내림차순 3건**만 노출하고 4건째부터는 목록 화면으로 유도한다. - 지수는 장 마감 후 마지막 체결값을 유지하고 "장 마감" 배지를 병기한다. - 알림 배지는 99건을 넘으면 `99+` 로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 요약 카드 탭 (1) | - | 계좌로 이동 (TSI-ACCT-001) | | 알림 배지 탭 (2) | - | 알림 내역으로 이동 (TSI-NOTI-001) | | 지수 요약 탭 (3) | - | 시장지표로 이동 (TSI-MKT-001) | | 보유 상위 탭 (4) | 보유 1건 이상 | 보유종목으로 이동 (TSI-HOLD-001) | | 보유 상위 탭 (4) | 보유 0건 | 종목 검색으로 이동 (TSI-SEARCH-001) | | 워치독 요약 탭 (5) | - | 워치독으로 이동 (TSI-WD-001) | | 하단 탭 탭 (6) | - | 각 탭 최상위 화면으로 전환 | ### 엣지케이스 - 계좌 목록은 호출 한도가 낮으므로(ACCOUNT 그룹) **세션당 1회 조회 후 캐시**하고, 당겨서 새로고침에서만 재조회한다. - 계좌 0건은 정상 상태가 아니다 — "조회 가능한 계좌가 없습니다" 안내 후 재시도만 제공한다. - 지수 조회만 실패하면 계좌 카드는 그대로 두고 지수 영역만 "—" 로 표시한다. - 백그라운드 복귀 후 5분이 지났으면 진입 시 1회 자동 재조회한다 (가정). --- ## TSI-AUTH-001 로그인 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 아이디 | 필수. 영문·숫자 4~20자, 앞뒤 공백 자동 제거 | 로그인 버튼 비활성 유지 | | 비밀번호 | 필수. 8자 이상 | 로그인 버튼 비활성 유지 | - 검증 시점은 입력 중(버튼 활성 판정)과 제출 시(서버 대조) 두 번이다. - 클라이언트에서 아이디 존재 여부를 미리 조회하지 않는다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 기본 | 아이디·비밀번호 입력, 로그인 버튼, QR 로그인 진입 | | 제출 중 | 로그인 버튼 스피너, 입력 필드 비활성 | | 인증 실패 | 비밀번호 필드 아래 인라인 오류, 필드 테두리 적색 | | 사용자 저장소 불가 | "로그인 비활성(DB 없음)" 안내와 재시도 버튼 | - 실패 문구는 "아이디 또는 비밀번호가 틀렸습니다" 하나만 쓴다 — 어느 쪽이 틀렸는지 구분해 알리지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 아이디 입력 (1-1) | - | 공백 제거, 기존 오류 문구 초기화 | | 비밀번호 입력 (1-2) | 두 필드 모두 규칙 통과 | 로그인 버튼 활성 | | 로그인 버튼 탭 (1-3) | 인증 성공 | 세션 발급 후 홈으로 이동 (TSI-MAIN-001) | | 로그인 버튼 탭 (1-3) | 인증 실패(401) | 인라인 오류 표시, 비밀번호만 비움 (2-1) | | QR 로그인 탭 (1-4) | - | 챌린지 생성 후 QR 노출, 승인 대기 (TSI-AUTH-002) | | 확인 버튼 탭 (3-1) | 세션 만료 팝업 | 로그인으로 히스토리 치환 이동 (TSI-AUTH-101) | ### 엣지케이스 - 세션 쿠키는 HttpOnly 이며 유효기간 7일이다. 발급 시각이 아니라 만료 시각을 기준으로 판정한다. - 로그인 성공 직후 뒤로가기로 로그인 화면에 돌아오지 못하도록 히스토리를 치환한다. - 연속 인증 실패 5회 시 60초간 로그인 버튼을 잠근다 (가정). - 이미 유효한 세션으로 로그인 화면에 진입하면 즉시 홈으로 보낸다 (TSI-MAIN-001). --- ## TSI-AUTH-101 세션 만료 안내 ### 입력 검증 해당 없음 — 확인만 받는 팝업. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 만료 안내 문구 + "로그인하러 가기" 단일 버튼 | - 전 화면 공통이며 401 을 받은 화면 위에 겹쳐 노출한다. - 만료 시각이나 원인을 상세히 노출하지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 확인 버튼 탭 (3-1) | - | 로컬 조회 캐시 삭제 후 로그인으로 히스토리 치환 이동 (TSI-AUTH-001) | ### 엣지케이스 - 배경 딤을 탭해도 닫히지 않는다 — 세션이 없는 화면에 머물게 두지 않기 위함이다. - 여러 조회가 동시에 401 을 받아도 팝업은 1개만 노출한다(중복 노출 방지). - 표시 설정(등락 색상)은 캐시 삭제 대상에서 제외한다. --- ## TSI-AUTH-002 QR 로그인 승인 ### 입력 검증 해당 없음 — 승인·거절만 받는 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 정상 | 요청 시각·접속 IP·브라우저, 남은 유효시간 카운트다운 | | 만료 | "요청이 만료되었습니다" 문구, 승인·거절 버튼 비활성 | | 처리 중 | 승인 버튼 스피너 | - 유효시간은 **2분**이며 1초 단위로 감소 표기한다. - 남은 시간 30초 이하부터 카운트다운을 적색으로 표시한다. - 보안 경고 문구를 버튼 위에 상시 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 승인 버튼 탭 (2) | 챌린지 유효 | 승인 처리, 요청 기기에 세션 발급 후 홈으로 이동 (TSI-MAIN-001) | | 승인 버튼 탭 (2) | 챌린지 만료 | "요청이 만료되었습니다" 표시, 승인 차단 | | 거절 버튼 탭 (3) | - | 챌린지 폐기, 요청 기기는 로그인 실패 처리 (TSI-AUTH-001) | ### 엣지케이스 - 이 화면 자체가 세션을 요구한다 — 미인증 진입 시 로그인으로 보낸다 (TSI-AUTH-001). - 같은 챌린지를 두 번 승인해도 세션은 한 번만 발급한다(멱등). - 승인 요청 중 네트워크가 끊기면 결과를 단정하지 않고 "승인 결과를 확인할 수 없습니다"로 안내한 뒤 재시도를 제공한다. - 요청 기기의 폴링은 2초 간격이며, 승인 후 최대 2초 이내에 세션이 반영된다. --- ## TSI-AUTH-003 비밀번호 변경 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 현재 비밀번호 | 필수. 서버 대조는 제출 시에만 | 제출 후 인라인 오류 | | 새 비밀번호 | 필수. 8자 이상, 현재 비밀번호와 달라야 함 | "8자 이상" / "현재와 다른 비밀번호를 입력해주세요" | | 새 비밀번호 확인 | 필수. 새 비밀번호와 일치 | "새 비밀번호 확인이 일치하지 않습니다" | - 새 비밀번호 규칙은 **포커스 아웃 시점**에 즉시 검사한다. - 세 필드가 모두 규칙을 통과해야 변경 버튼이 활성된다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 기본 | 3개 입력 필드와 규칙 안내 문구 | | 검증 실패 | 해당 필드 테두리 적색 + 아래 인라인 오류 | | 성공 | "변경되었습니다" 토스트 후 로그인으로 이동 | - 하단에 "변경 시 다른 기기의 로그인도 모두 해제됩니다" 를 상시 고지한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 현재 비밀번호 입력 (1-1) | - | 기존 오류 문구 초기화 | | 새 비밀번호 입력 (1-2) | 포커스 아웃 | 길이·현재값과의 동일 여부 즉시 검사 | | 새 비밀번호 확인 입력 (1-3) | 새 비밀번호와 불일치 | 인라인 오류 표시, 변경 버튼 비활성 | | 변경 버튼 탭 (1-4) | 서버 검증 성공 | 전 세션 무효화 후 로그인으로 이동 (TSI-AUTH-001) | | 변경 버튼 탭 (1-4) | 현재 비밀번호 불일치(401) | 현재 비밀번호 필드에 인라인 오류 (2-1) | ### 엣지케이스 - 변경 성공 시 **모든 세션을 무효화**한다. 다른 기기 로그인도 함께 해제된다. - 서버 검증 실패 시 새 비밀번호 입력값은 지우지 않는다 — 다시 입력하게 만들지 않기 위함이다. - 제출 중 화면을 벗어나도 요청은 계속 진행되며, 복귀 시 결과를 토스트로 재표시하지 않는다. - 직전 3개 비밀번호 재사용은 서버가 400 으로 거절하고 인라인 오류로 표시한다 (가정). --- ## TSI-ACCT-001 계좌 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3종 스켈레톤 | | 정상 | 계좌번호·총자산, 자산 요약 3행, 하위 메뉴 3행 | | 계좌 1개 | 헤더의 "전환" 버튼과 드롭다운 화살표 숨김 | | 조회 실패 | 금액 자리 "—" + "갱신 실패" 캡션 | - 계좌번호는 전체 표기한다(마스킹하지 않는다). 본인 계좌만 조회되므로 실익이 없다 (가정). - 평가손익 0원은 회색(보합)으로 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 헤더 탭 (1-1) | 계좌 2개 이상 | 계좌 전환 바텀시트 노출 (TSI-ACCT-101) | | 계좌 헤더 탭 (1-1) | 계좌 1개 | 동작 없음 | | 자산 요약 (1-2) | - | 읽기 전용, 이동 없음 | | 자산 대시보드 행 탭 (1-3) | - | 자산 대시보드로 이동 (TSI-DASH-001) | | 보유종목 행 탭 (1-3) | - | 보유종목으로 이동 (TSI-HOLD-001) | | 주문내역 행 탭 (1-3) | - | 주문내역으로 이동 (TSI-ORDER-001) | ### 엣지케이스 - 계좌 목록 API 는 호출 한도가 낮다(TPS 1). **세션당 1회 조회 후 캐시**하고 명시적 새로고침에서만 재조회한다. - 계좌 0건은 정상 상태가 아니다 — "조회 가능한 계좌가 없습니다" 안내 후 재시도만 제공한다. - 계좌 전환 직후에는 이전 계좌 데이터를 화면에 남기지 않는다(스켈레톤으로 초기화). --- ## TSI-ACCT-101 계좌 전환 ### 입력 검증 해당 없음 — 선택만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 계좌 목록 카드. 현재 선택 계좌는 accent 테두리 | | 계좌 정보 | 계좌번호 + 계좌유형 + seq | - 목록 순서는 계좌 seq 오름차순 고정. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 항목 탭 (2-1) | 다른 계좌 선택 | 선택 저장, 시트 닫힘, 호출 화면 재조회 | | 계좌 항목 탭 (2-1) | 이미 선택된 계좌 | 시트만 닫음, 재조회 없음 | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 선택 변경 없음 | ### 엣지케이스 - 시트가 열린 동안 계좌 목록을 재조회하지 않는다(호출 한도 보호). - 선택 계좌가 서버에서 사라진 경우(해지 등) 재조회 시 404 → 첫 계좌로 자동 전환하고 토스트로 안내한다. - 선택 계좌는 로컬에 저장되어 홈·계좌·수수료 화면이 같은 선택을 공유한다. --- ## TSI-DASH-001 자산 대시보드 ### 입력 검증 해당 없음 — 정렬 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 잔고·손익 카드와 보유 목록 스켈레톤 | | 정상 | 원화·달러 잔고, 평가손익 카드, 보유 비중 목록 | | 보유 0건 | 비중 목록 자리에 "보유 중인 종목이 없습니다" | | 조회 실패 | 금액 자리 "—" + 재시도 버튼 | - 비중은 **평가금액 합계 대비 백분율**이며 소수 1자리로 표기한다. - 수익/손실 종목 수 게이지는 평가손익 부호 기준으로 집계한다. - 기본 정렬은 평가금액 내림차순. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 잔고 카드 (1) | - | 읽기 전용, 이동 없음 | | 평가손익 카드 (2) | - | 읽기 전용, 이동 없음 | | 정렬 선택 탭 (3) | 보유 1건 이상 | 정렬 시트 노출 후 클라이언트 정렬, 재조회 없음 | | 보유 비중 행 탭 (4) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 새로고침 탭 (5) | - | 잔고·보유 전체 재조회, 진행 중 스켈레톤 | ### 엣지케이스 - 매입금액이 0인 종목(무상증자 등)은 수익률을 계산하지 않고 "—" 로 둔다. - 달러 잔고가 0이어도 카드를 숨기지 않는다 — 통화별 잔고 유무 자체가 정보다. - 새로고침 연타는 진행 중 요청이 끝날 때까지 무시한다(중복 호출 방지). --- ## TSI-HOLD-001 보유종목 ### 입력 검증 해당 없음 — 정렬 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 종목별 평가금액·수량·평단·손익, 하단 합계 카드 | | 데이터 없음 | 빈 상태 안내 + "종목 검색하기" CTA, 정렬 칩 비활성 | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 정렬은 **평가금액 내림차순**. - 손익률은 소수 2자리, 부호와 색 병기. 0.00% 는 회색. - 종목명은 1줄 말줄임, 심볼은 부가 정보로만 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 정렬 칩 탭 (1-1) | 보유 1건 이상 | 클라이언트 정렬 즉시 적용, 재조회 없음 | | 보유 종목 행 탭 (1-2) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 합계 카드 (1-3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 보유 0건이면 합계 카드를 숨긴다 — 0원 합계는 정보가 아니다. - 정렬 상태는 화면을 벗어나면 초기화한다(기본 정렬로 복귀). - 장중 시세 반영 지연으로 합계와 개별 합이 어긋나지 않도록, 합계는 **표시 중인 값으로 직접 합산**하지 않고 서버 응답값을 그대로 쓴다. --- ## TSI-HOLD-002 보유종목 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 헤더·상세 카드 스켈레톤 | | 정상 | 현재가·등락, 수량·평단·평가금액·평가손익·비중, 타깃 요약 | | 타깃 계산 불가 | 익절/손절 영역에 "ATR 데이터가 부족합니다" | - 포트폴리오 비중은 전체 평가금액 대비 소수 1자리. - 타깃·스탑은 참고값이며 계산 근거(ATR 배수)를 함께 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 헤더 (2-1) | - | 읽기 전용, 이동 없음 | | 보유 상세 5행 (2-2) | - | 읽기 전용, 이동 없음 | | 익절/손절 참고 탭 (2-3) | 타깃 계산 가능 | 익절/손절로 이동 (TSI-TGT-001) | | 종목 정보 보기 탭 (2-4) | - | 종목 상세로 이동 (TSI-STOCK-001) | ### 엣지케이스 - 보유 수량이 0이 된 종목(전량 매도 직후)에 진입하면 "보유하지 않는 종목입니다" 안내 후 보유종목 목록으로 되돌린다 (TSI-HOLD-001). - 상장폐지·거래정지 종목은 현재가 자리에 "거래정지" 배지를 표시하고 평가손익은 마지막 체결가 기준임을 캡션으로 명시한다. - 신규 상장 등으로 ATR(20일) 산출에 필요한 일봉이 20개 미만이면 타깃 영역을 계산하지 않는다. --- ## TSI-ORDER-001 주문내역 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 조회 기간 | 시작일 ≤ 종료일, 최대 6개월 | "조회 기간은 최대 6개월입니다" 토스트, 조회 차단 | - 직접선택 외의 기간 칩은 검증 대상이 아니다(고정 범위). ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 매수·매도 라벨, 종목명, 수량, 체결가, 시각 | | 데이터 없음 | "해당 기간 주문내역이 없습니다" | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 탭은 **체결**, 기본 기간은 **1개월**. - 정렬은 체결(주문) 시각 내림차순 고정. - 매수는 적색, 매도는 청색 라벨로 구분한다(등락 색상 설정과 무관하게 고정). - 한 번에 20건씩 불러오고 "더 보기"로 이어붙인다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 체결·미체결 탭 탭 (1-1) | - | 목록 전환, 기간 필터는 유지 | | 기간 칩 탭 (1-2) | 유효 기간 | 선택 기간으로 재조회 | | 기간 칩 탭 (1-2) | 직접선택 6개월 초과 | 오류 토스트, 이전 조회 결과 유지 | | 주문 행 탭 (1-3) | - | 주문 상세로 이동 (TSI-ORDER-002) | ### 엣지케이스 - 미체결 탭에서 조회 도중 체결된 주문은 다음 갱신에서 체결 탭으로 이동한다 — 화면에서 즉시 지우지 않는다. - 부분체결 주문은 양쪽 탭에 모두 노출하고 체결수량을 병기한다. - "더 보기" 도중 기간을 바꾸면 누적분을 버리고 첫 페이지부터 다시 조회한다. --- ## TSI-ORDER-002 주문 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3종 스켈레톤 | | 정상 | 주문 요약, 체결 정보 5행, 비용 상세 4행 | | 취소 주문 | 상태 배지 "취소", 체결 정보는 취소 시점까지의 값 | - 매수는 거래세 0원으로 표기한다 — 항목 자체를 숨기지 않는다. - 정산금액은 매수면 거래대금+비용, 매도면 거래대금−비용으로 계산한다. - 부분체결이면 체결수량을 주문수량과 대비해 강조 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 주문 요약 헤더 (2-1) | - | 읽기 전용, 이동 없음 | | 체결 정보 (2-2) | - | 읽기 전용, 이동 없음 | | 비용 상세 (2-3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 여러 번에 나눠 체결된 주문은 체결 단가를 **가중평균**으로 표시하고 평균임을 캡션으로 명시한다. - 정정된 주문은 원 주문번호를 함께 노출한다. - 서버가 수수료·세금을 내려주지 않는 과거 주문은 해당 행을 "—" 로 두고 임의 계산하지 않는다. --- ## TSI-TGT-001 익절/손절 ### 입력 검증 해당 없음 — ATR 배수 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 종목 카드 3개 스켈레톤 | | 정상 | 종목별 현재가·평단·ATR, 타깃·스탑 게이지 | | 보유 0건 | "보유 중인 종목이 없습니다" 빈 상태 | | ATR 부족 | 해당 종목 카드에 게이지 대신 "ATR 데이터 부족" | - **참고용 계산값이며 자동 주문이 실행되지 않음**을 상단 배너로 상시 고지한다. - 타깃 = 평단 + (ATR × 배수), 스탑 = 평단 − (ATR × 배수 × 0.7) 로 계산한다 (가정). - ATR 은 일봉 20기간 기준이며 **당일 미완성 봉을 제외한 확정 종가**로 산출한다. - 상태 배지는 현재가가 스탑 기준 3% 이내면 "스탑 근접", 타깃 기준 3% 이내면 "타깃 근접". ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 참고 고지 배너 (1) | - | 읽기 전용, 닫기 불가 | | ATR 배수 칩 탭 (2) | - | 선택 배수로 전 종목 재계산, 서버 재조회 없음 | | 종목 카드 탭 (3) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 타깃·스탑 게이지 (4) | - | 읽기 전용, 이동 없음 | | 새로고침 탭 (5) | - | 현재가·ATR 재조회 후 전 종목 재계산 | ### 엣지케이스 - 현재가가 타깃을 넘었거나 스탑을 밑돌면 게이지 마커를 양 끝에 고정하고 초과분을 텍스트로 적는다. - 배수 선택은 화면을 벗어나면 기본값(2.0배)으로 되돌린다. - 장중에는 확정 종가가 없으므로 ATR 은 전일 기준값을 쓰고 기준일을 캡션에 명시한다. --- ## TSI-WD-001 워치독 ### 입력 검증 해당 없음 — 목록·토글만 있는 화면. 조건 입력 검증은 등록 화면 소관 (TSI-WD-002). ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3개 스켈레톤 | | 정상 | 이름·조건식·쿨다운·최근 발동 시각, 활성 토글 | | 데이터 없음 | "등록된 워치독이 없습니다" + 등록 CTA | | 비활성 항목 | 카드 전체를 흐리게 처리하고 토글 off | - 상단에 활성 건수와 당일 발동 건수를 요약한다. - 조건식은 그룹 안 AND, 그룹 간 OR 로 읽히도록 연결어를 굵게 표기한다. - 최근 발동 이력이 없으면 "발동 이력 없음" 으로 적고 시각 자리를 비우지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 등록 버튼 탭 (1-1) | 등록 건수 20건 미만 | 워치독 등록으로 이동 (TSI-WD-002) | | 등록 버튼 탭 (1-1) | 등록 건수 20건 도달 | "최대 20건까지 등록할 수 있습니다" 토스트 | | 워치독 카드 탭 (1-2) | - | 워치독 등록 화면을 수정 모드로 진입 (TSI-WD-002) | | 워치독 카드 스와이프 (1-2) | - | 삭제 확인 바텀시트 노출 (TSI-WD-101) | | 활성 토글 탭 (1-3) | - | 활성·비활성 즉시 전환, 비활성은 평가 대상에서 제외 | ### 엣지케이스 - 토글 전환은 낙관적으로 먼저 반영하고, 실패하면 원래 상태로 되돌린 뒤 토스트로 알린다. - 조건 평가는 서버 스케줄러가 수행하므로 화면을 닫아도 알림은 계속 발송된다. - 쿨다운 이내 재충족은 알림을 보내지 않고 평가 시각만 갱신한다. - 감시 종목이 거래정지되면 평가를 건너뛰고 카드에 "거래정지" 배지를 표시한다. --- ## TSI-WD-002 워치독 등록 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 감시 종목 | 필수. 종목 검색에서 선택 | 저장 버튼 비활성 | | 조건 지표 | 필수. 현재가·등락률·외국인 순매수·기관 순매수·뉴스 키워드 중 하나 | 저장 버튼 비활성 | | 비교 연산자 | 필수. 초과(>) 또는 미만(<) | 저장 버튼 비활성 | | 임계값 | 필수. 0 초과 숫자. 가격은 정수, 등락률은 소수 2자리, 금액은 억 단위 | "올바른 값을 입력해주세요" 인라인 오류 | | 뉴스 키워드 | 지표가 뉴스일 때 필수. 1~20자 | "키워드를 입력해주세요" | | 쿨다운 | 필수. 10~1440분 정수. 기본 60분 | "10분 이상 1440분 이하로 입력해주세요" | - 그룹당 조건 최대 3개, 그룹 최대 3개. - 검증 시점은 입력 중(형식)과 저장 시(서버 중복·한도)다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 신규 | 빈 조건 그룹 1개, 쿨다운 60분 | | 수정 | 기존 값 채운 상태, 헤더 "워치독 수정" | | 저장 중 | 저장 버튼 스피너, 입력 비활성 | | 검증 실패 | 해당 입력칸 아래 인라인 오류 | - 종목이 선택되기 전에는 지표 선택칸을 비활성으로 둔다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 감시 종목 선택 탭 (2-1) | - | 종목 검색으로 이동 후 선택 결과 수신 (TSI-SEARCH-001) | | 조건 입력 (2-2) | 형식 위반 | 인라인 오류, 저장 버튼 비활성 | | 조건 그룹 추가 탭 (2-3) | 그룹 3개 미만 | 그룹 추가, 그룹끼리 OR 로 평가 | | 조건 그룹 추가 탭 (2-3) | 그룹 3개 도달 | "조건 그룹은 최대 3개입니다" 토스트 | | 저장 버튼 탭 (2-4) | 검증 통과 | 저장 후 워치독 목록으로 복귀 (TSI-WD-001) | | 저장 버튼 탭 (2-4) | 동일 조건 중복(409) | "같은 조건의 워치독이 이미 있습니다" 인라인 오류 | ### 엣지케이스 - 등록 한도는 계정당 20건이다. 초과 시 서버가 400 으로 거절하고 저장을 차단한다. - 수정 모드에서 조건을 모두 지우면 저장할 수 없다 — 최소 1개 조건이 필요하다. - 저장 중 화면을 벗어나도 요청은 계속 진행되며, 목록 복귀 시 결과가 반영된다. - 종목 선택 화면에서 뒤로 나오면 기존 입력값은 유지한다(초기화하지 않는다). --- ## TSI-WD-101 워치독 삭제 확인 ### 입력 검증 해당 없음 — 확인/취소만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 삭제 대상 워치독 이름 + 취소·삭제 2버튼 | | 삭제 버튼 | 적색 배경으로 위험 동작임을 표시 | - 삭제 후 알림이 더 이상 오지 않음을 문구로 명시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 취소 버튼 탭 (3-1) | - | 시트 닫힘, 목록 유지 | | 삭제 버튼 탭 (3-1) | 삭제 성공 | 목록에서 제거 후 "삭제되었습니다" 토스트 (TSI-WD-001) | | 배경 딤 탭 (3-1) | - | 시트 닫힘, 목록 유지 | ### 엣지케이스 - 발동 이력은 삭제하지 않는다 — 알림 내역에는 그대로 남는다 (TSI-NOTI-001). - 이미 삭제된 워치독을 다시 삭제하면(다른 기기에서 선삭제) 404 를 성공으로 간주하고 목록에서 제거한다. - 삭제 실패 시 시트를 닫지 않고 오류 문구를 시트 안에 표시한다. --- ## TSI-NOTI-001 알림 내역 ### 입력 검증 해당 없음 — 필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 워치독 이름·발동 사유·시각. 안 읽음은 좌측 accent 바 | | 데이터 없음 | 빈 상태 안내 + "워치독 등록하기" CTA | | 오류 | 재시도 버튼 포함 오류 배너 | - 시각은 당일이면 `HH:MM`, 전일이면 "어제 HH:MM", 그 이전이면 `MM-DD HH:MM`. - 발동 사유에는 **실제 관측값과 기준값을 함께** 적는다 (예: 95,400원 / 기준 95,000원). - 한 번에 20건씩 불러오고 "더 보기"로 이어붙인다. 보관 기간은 90일 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 필터 칩 탭 (1-1) | - | 전체·안 읽음 필터 즉시 적용 | | 알림 행 탭 (1-2) | 워치독 존재 | 읽음 처리 후 해당 워치독으로 이동 (TSI-WD-001) | | 알림 행 탭 (1-2) | 워치독 삭제됨 | 읽음 처리만 하고 "삭제된 워치독입니다" 토스트 | | 모두 읽음 탭 (1-3) | 안 읽음 1건 이상 | 전체 읽음 처리, 홈 배지 즉시 갱신 (TSI-MAIN-001) | | 빈 상태 CTA 탭 (2-1) | 알림 0건 | 워치독 등록으로 이동 (TSI-WD-002) | ### 엣지케이스 - "모두 읽음" 은 안 읽음이 0건이면 비활성한다. - 읽음 처리 실패 시 화면 상태를 되돌리고 토스트로 알린다. - 같은 워치독이 쿨다운 뒤 다시 발동하면 별도 행으로 쌓는다 — 묶어서 표시하지 않는다. --- ## TSI-SEARCH-001 종목 검색 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 검색어 | 1~20자. 공백만 입력 불가 | 조회하지 않고 최근 검색 화면 유지 | | 종목코드 | 숫자 6자리로 입력하면 코드 완전일치 조회 | 결과 없음 처리 | - 입력 후 **300ms 디바운스**로 자동 조회한다. 별도 검색 버튼을 두지 않는다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 검색 전 | 최근 검색 칩 + 거래대금 상위 3건 | | 조회 중 | 결과 영역 스켈레톤 3행 | | 결과 있음 | 종목명·코드·시장·현재가·등락률, 상단에 결과 건수 | | 결과 없음 | "검색 결과가 없습니다" + 검색어 확인 안내 | - 최근 검색은 최대 10건, 최신순이며 **로컬에만** 저장한다. - 정렬은 종목명 전방일치 우선, 그다음 거래대금 내림차순 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 검색 입력 (1-1) | 1자 이상 | 300ms 디바운스 후 자동 조회 | | 검색 입력 (1-1) | 공백만 입력 | 조회하지 않고 검색 전 상태 유지 | | 최근 검색 칩 탭 (1-2) | - | 해당 종목 상세로 이동 (TSI-STOCK-001) | | 전체 삭제 탭 (1-3) | 최근 검색 1건 이상 | 확인 없이 즉시 전체 삭제 | | 검색 결과 행 탭 (2-1) | 일반 진입 | 최근 검색에 추가하고 종목 상세로 이동 (TSI-STOCK-001) | | 검색 결과 행 탭 (2-1) | 워치독 등록에서 진입 | 선택 종목을 반환하고 등록 화면으로 복귀 (TSI-WD-002) | ### 엣지케이스 - 최근 검색이 0건이면 해당 영역 자체를 숨긴다(빈 제목만 남기지 않는다). - 워치독 등록·종목별 수급에서 진입한 경우 화면 이동 대신 **선택 결과를 반환**한다. - 검색 도중 새 입력이 들어오면 이전 요청 결과는 버린다(경합 방지). - 상장폐지 종목은 결과에 노출하되 "거래정지" 배지를 붙이고 현재가를 "—" 로 둔다. --- ## TSI-STOCK-001 종목 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 헤더·시세 요약 스켈레톤 | | 정상 | 현재가·등락, 시세 요약 8항목, 탭 4종, 워치독 등록 CTA | | 장 마감 | 기준 시각 옆에 "장 마감 기준" 문구 | | 거래정지 | 현재가 자리에 "거래정지" 배지, 등락 숨김 | - 상한가·하한가는 등락 색상 설정과 무관하게 고정색(상한 적색·하한 청색)을 쓴다. - 거래대금은 조 단위까지 축약하고 소수 2자리로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 헤더 (1) | - | 읽기 전용, 이동 없음 | | 시세 요약 (2) | - | 읽기 전용, 이동 없음 | | 차트 탭 탭 (3) | - | 차트로 전환 (TSI-CHART-001) | | 호가 탭 탭 (3) | - | 호가로 전환 (TSI-ORDB-001) | | 체결 탭 탭 (3) | - | 체결로 전환 (TSI-TRADE-001) | | 수급 탭 탭 (3) | - | 종목별 수급으로 전환 (TSI-FLOW-001) | | 워치독 등록 CTA 탭 (4) | - | 종목이 채워진 상태로 워치독 등록 진입 (TSI-WD-002) | ### 엣지케이스 - 장 시작 전에는 시가·고가·저가가 비어 있으므로 "—" 로 두고 전일 종가만 노출한다. - 시세 조회가 실패해도 탭 구조는 유지한다 — 각 탭이 개별로 재시도할 수 있어야 한다. - 보유 중인 종목이면 헤더 아래에 보유 수량을 부가 표시한다 (TSI-HOLD-002 로 이동 가능). --- ## TSI-CHART-001 차트 ### 입력 검증 해당 없음 — 주기·지표 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 캔들·거래량 영역 스켈레톤 | | 정상 | 캔들, 이동평균선, 거래량, 하단 요약 텍스트 | | 데이터 부족 | "표시할 캔들이 없습니다" + 다른 주기 안내 | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 주기는 **일봉**, 조회 봉 수는 최근 100봉. - 상승봉 적색·하락봉 청색이며 등락 색상 설정을 따른다. - 이동평균은 MA5·MA20·MA60 이며 기본 전부 표시. - 접근성을 위해 하단에 상승/하락 봉 수와 최고·최저가를 **텍스트로도** 제공한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 주기 칩 탭 (1) | - | 선택 주기(1분·일·주·월)로 캔들 재조회 | | 캔들 영역 롱프레스 (2) | - | 해당 봉의 시가·고가·저가·종가 툴팁 표시 | | 이동평균 범례 탭 (3) | - | 해당 이동평균선 표시·숨김 전환 | | 거래량 영역 (4) | - | 읽기 전용, 캔들과 x축 공유 | ### 엣지케이스 - 1분봉은 장중에만 갱신되며 장 마감 후에는 마지막 스냅샷을 유지한다. - 상장 기간이 짧아 MA60 을 못 만드는 종목은 해당 선을 그리지 않고 범례를 흐리게 처리한다. - 장중 미완성 봉은 점선으로 구분해 확정 봉과 섞이지 않게 한다. - 툴팁은 손을 떼면 사라지며, 표시 중에는 화면 스크롤을 잠근다. --- ## TSI-ORDB-001 호가 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 호가 10행 스켈레톤 | | 정상 | 매도 5호가(위)·매수 5호가(아래), 중앙 현재가, 총잔량 요약 | | 장 마감 | 마지막 스냅샷 유지 + "장 마감" 배지 | | 거래정지 | 호가 영역에 "거래정지" 안내, 잔량 숨김 | - 잔량 막대는 **표시된 10개 호가 중 최대 잔량을 100%** 로 한 상대 길이다. - 매도는 청색 계열, 매수는 적색 계열로 좌우 대칭 배치한다. - 현재가 행은 위아래 굵은 구분선으로 분리한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 호가 행 탭 (1) | - | 해당 가격을 임계값으로 채워 워치독 등록 진입 (TSI-WD-002) | | 잔량 막대 (2) | - | 읽기 전용, 잔량 수치를 막대 안에 병기 | | 총잔량 요약 (3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 시간외 단일가 시간대에는 호가가 제공되지 않으므로 "시간외 단일가 시간입니다" 안내로 대체한다. - 상한가·하한가에 도달하면 한쪽 잔량이 0이 되며, 이때 막대를 그리지 않고 0을 표기한다. - 갱신 주기는 화면이 보이는 동안 3초이며, 백그라운드로 가면 폴링을 멈춘다. --- ## TSI-TRADE-001 체결 ### 입력 검증 해당 없음 — 필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 7행 스켈레톤 | | 정상 | 체결 요약 3항목 + 최근 체결 30건 | | 데이터 없음 | "체결 내역이 없습니다" (장 시작 전) | | 오류 | 재시도 버튼 포함 오류 배너 | - 정렬은 체결 시각 내림차순 고정, 표시 건수는 최근 30건. - 체결강도는 매수체결량÷매도체결량×100 이며 100% 초과는 매수 우위로 적색 표기한다. - 시각은 초 단위(`HH:MM:SS`)까지 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 체결량 필터 탭 (1) | - | 전체·1천주 이상 필터 즉시 적용, 재조회 없음 | | 체결 요약 (2) | - | 읽기 전용, 이동 없음 | | 체결 목록 스와이프 (3) | 아래로 당김 | 최신 체결 갱신 | ### 엣지케이스 - 필터가 "1천주 이상"인데 해당 체결이 없으면 목록을 비우고 필터 해제를 안내한다. - 장 마감 후에는 폴링을 멈추고 마지막 체결 목록을 유지한다. - 동시호가 체결은 시각이 같은 행이 여러 개 생길 수 있으므로 정렬을 안정 정렬로 유지한다. --- ## TSI-RANK-001 랭킹 ### 입력 검증 해당 없음 — 유형·필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 6행 스켈레톤 | | 정상 | 순위·종목명·기준값·현재가·등락률 | | 데이터 없음 | "표시할 종목이 없습니다" | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 유형은 **거래대금**, 기본 시장은 **KOSPI**, 기본 표시 건수는 **50위**. - 상위 2건은 순위 숫자를 accent 로 강조한다. - 기준값은 유형에 따라 달라진다(거래대금은 금액, 상승률은 %). - 상단에 기준 시각을 명시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 유형 칩 탭 (1-1) | - | 선택 유형으로 재조회, 필터는 유지 | | 필터 버튼 탭 (1-2) | - | 랭킹 필터 바텀시트 노출 (TSI-RANK-101) | | 랭킹 행 탭 (1-3) | - | 종목 상세로 이동 (TSI-STOCK-001) | ### 엣지케이스 - 장 시작 전에는 전일 확정 순위를 보여주고 기준 시각으로 전일임을 명시한다. - 동순위가 발생하면 종목코드 오름차순으로 안정 정렬한다. - 표시 건수를 늘렸다가 줄이면 재조회 없이 클라이언트에서 잘라 보여준다. --- ## TSI-RANK-101 랭킹 필터 ### 입력 검증 해당 없음 — 선택만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 시장 구분 3종, 표시 건수 3종. 현재 선택은 accent 칩 | - 선택 상태는 목록 화면의 현재 조건을 그대로 반영해 연다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 필터 옵션 탭 (2-1) | 시장 구분 변경 | 선택 즉시 반영, 시트 닫고 목록 재조회 (TSI-RANK-001) | | 필터 옵션 탭 (2-1) | 표시 건수 변경 | 선택 즉시 반영, 시트 닫고 목록 갱신 | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 선택 변경 없음 | ### 엣지케이스 - 시장 구분을 "전체"로 바꾸면 표시 건수는 유지하되 조회는 처음부터 다시 한다. - 필터 선택은 화면을 벗어나면 기본값(KOSPI·50위)으로 되돌린다. --- ## TSI-MKT-001 시장지표 ### 입력 검증 해당 없음 — 지수·기간 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 지수 카드·차트 스켈레톤 | | 정상 | KOSPI·KOSDAQ 카드, 추이 차트, 투자자별 매매대금 | | 조회 실패 | 지수 값 "—" + 재시도 버튼 | - 기본 선택 지수는 **KOSPI**, 기본 기간은 **1일**. - 투자자별 매매대금은 **시장 전체·일별 확정치**이며 종목 단위가 아님을 캡션으로 명시한다. - 지수는 소수 2자리, 매매대금은 억 단위로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 지수 카드 탭 (1) | - | 추이 차트를 해당 지수로 전환 | | 기간 칩 탭 (2) | - | 선택 기간(1일·1주·1개월·1년)으로 추이 재조회 | | 추이 차트 롱프레스 (3) | - | 해당 시점의 지수값 툴팁 표시 | | 투자자별 매매대금 탭 (4) | - | 종목별 수급으로 이동 (TSI-FLOW-001) | ### 엣지케이스 - 장중에는 투자자별 매매대금 확정치가 없으므로 **전일 확정치**를 보여주고 기준일을 명시한다. - 휴장일에 진입하면 직전 거래일 기준으로 표시하고 "휴장" 배지를 붙인다. - 1년 기간은 데이터 양이 많으므로 일봉 단위로 다운샘플링한다. --- ## TSI-FLOW-001 종목별 수급 ### 입력 검증 해당 없음 — 종목·주기 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 차트·표 스켈레톤 | | 정상 | 외국인·기관 순매수 차트와 회차별 표 | | 종목 미선택 | "종목을 선택해주세요" + 검색 진입 CTA | | 데이터 없음 | "해당 종목의 수급 데이터가 없습니다" | - 기본 주기는 **장중 30분 회차**. - 장중 회차는 **외국인·기관만** 제공되며 개인은 집계되지 않는다 — 이 한계를 화면에 고지한다. - 순매수는 억 단위 소수 1자리, 부호와 색을 병기한다. - 표는 최신 회차부터 내림차순. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 선택 탭 (1) | - | 종목 검색으로 이동 후 선택 결과 수신 (TSI-SEARCH-001) | | 주기 칩 탭 (2) | - | 장중 30분·일별 확정치 전환 후 재조회 | | 수급 차트 롱프레스 (3) | - | 해당 회차의 외국인·기관 금액 툴팁 표시 | | 데이터 한계 고지 (4) | 장중 주기 | 개인 수급 미제공 안내 표시. 일별 주기에서는 숨김 | | 회차별 표 스와이프 (5) | 아래로 당김 | 최신 회차 갱신 | ### 엣지케이스 - 수급 집계 대상 종목이 제한적이므로, 대상이 아닌 종목은 "수급 제공 대상이 아닙니다"로 안내하고 빈 차트를 그리지 않는다. - 회차 데이터가 지연 도착하면 마지막 회차가 비어 보일 수 있다 — 빈 값은 0으로 채우지 않고 행 자체를 만들지 않는다. - 장중 회차와 일별 확정치는 산출 기준이 달라 합계가 일치하지 않을 수 있음을 캡션으로 밝힌다. --- ## TSI-FX-001 환율 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 환산 금액 | 선택. 0 초과, 소수 2자리까지, 최대 999,999,999 | 결과 자리에 "—" 유지, 오류 문구 미표시 | ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 통화쌍 3행 스켈레톤 | | 정상 | USD·JPY·EUR 대 원화, 기준시각, 환산 계산기 | | 조회 실패 | 환율 자리 "—" + 재시도 버튼 | - JPY 는 **100엔 기준**임을 통화명 옆에 명시한다. - 환율은 소수 2자리, 환산 결과는 원 단위 정수로 반올림한다. - 기준시각은 응답값을 그대로 표시하며 임의로 현재시각을 쓰지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 기준시각·새로고침 탭 (1) | - | 전 통화쌍 재조회 | | 통화쌍 행 탭 (2) | - | 해당 통화를 환산 계산기의 기준 통화로 설정 | | 환산 금액 입력 (3) | 유효 금액 | 원화 환산 결과 즉시 산출 | | 환산 금액 입력 (3) | 공백 | 결과 "—" 로 복귀 | ### 엣지케이스 - 매매기준율 기준 참고값이며 실제 환전 금액과 다를 수 있음을 하단 캡션으로 명시한다. - 휴일·야간에는 마지막 고시 환율을 유지하고 기준시각으로 최신이 아님을 드러낸다. - 일부 통화만 조회에 실패하면 해당 행만 "—" 로 두고 나머지는 정상 표시한다. --- ## TSI-CAL-001 장운영 캘린더 ### 입력 검증 해당 없음 — 월 이동·날짜 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 달력 그리드 스켈레톤 | | 정상 | 월 그리드(정규장·휴장·조기폐장 색 구분) + 선택일 상세 | | 조회 실패 | "장운영 정보를 가져오지 못했습니다" + 재시도 | - 기본 선택일은 **오늘**이며, 오늘이 조회 월에 없으면 해당 월 1일. - 조회 가능 범위는 당월 기준 **전후 12개월**. - 색만으로 구분하지 않도록 하단에 범례를 함께 둔다. - 휴장일은 시간 행 대신 휴장 사유를 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 월 이동 탭 (1) | 조회 범위 내 | 이전·다음 달로 이동 후 재조회 | | 월 이동 탭 (1) | 범위 밖 | 화살표 비활성, 동작 없음 | | 월 그리드 스와이프 (1) | - | 좌우 스와이프로 월 전환 | | 날짜 탭 (2) | - | 해당 날짜 선택, 하단 상세 갱신 | | 선택일 상세 (3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 임시 휴장·조기 폐장(수능일 등)은 정규장 시간이 다르게 내려온다. **응답값을 그대로 표시하고 임의로 고정하지 않는다.** - 아직 확정되지 않은 미래 월은 "미확정" 배지를 붙이고 기본 일정만 회색으로 보여준다. - 주말은 조회 대상이지만 항상 휴장으로 표시한다. --- ## TSI-FEE-001 수수료 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 거래금액 | 선택. 0 초과 정수, 최대 9,999,999,999원 | 결과 자리에 "—" 유지, 오류 문구 미표시 | ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 명세 스켈레톤 | | 정상 | 매수·매도 수수료율, 최소 수수료 | | 거래세 고지 | 배너 상시 노출 | | 계산 결과 | 거래금액 미입력 시 "—", 입력 시 수수료+세금 합계 | - 수수료율은 **소수 4자리**로 표기한다(0.0000% 도 그대로 노출). - 증권거래세는 매도 시 거래대금의 0.15%로 계산한다 (가정 — 코스피 기준). - 수수료는 원 단위 절사한다 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 선택 탭 (1) | 계좌 2개 이상 | 계좌 전환 바텀시트 노출 (TSI-ACCT-101) | | 수수료율 명세 (2) | - | 읽기 전용, 이동 없음 | | 거래세 고지 배너 (3) | - | 읽기 전용, 상시 노출 | | 거래금액 입력 (4) | 유효 금액 | 매수·매도 각각 예상 수수료+세금 즉시 산출 | | 거래금액 입력 (4) | 공백 | 결과 "—" 로 복귀 | | 계산 결과 (5) | - | 읽기 전용, 참고용 캡션 병기 | ### 엣지케이스 - 수수료율이 0%인 계좌에서도 거래세는 발생한다. **고지 배너를 숨기지 않는다.** - 계산기는 참고용이며 실제 정산 금액과 다를 수 있음을 결과 하단에 캡션으로 명시한다. - 계좌를 전환하면 수수료율을 재조회하고 입력된 거래금액은 유지한 채 결과만 다시 계산한다. --- ## TSI-SET-001 설정 ### 입력 검증 해당 없음 — 토글·이동만 있는 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 계정 카드 스켈레톤 | | 정상 | 계정 카드(아이디·세션 만료 시각), 기타 메뉴 3행, 표시·계정 설정, 로그아웃 | | 세션 정보 없음 | 아이디·만료 시각 자리에 "—" | - 등락 색상 토글은 기본 **한국식(상승 적색)** 이다. - 로그아웃은 위험 동작이므로 적색 텍스트로 구분한다. - 세션 만료 시각은 `YYYY-MM-DD HH:MM` 으로 분 단위까지 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계정 카드 (1-1) | - | 읽기 전용, 이동 없음 | | 환율 행 탭 (1-2) | - | 환율로 이동 (TSI-FX-001) | | 장운영 캘린더 행 탭 (1-2) | - | 장운영 캘린더로 이동 (TSI-CAL-001) | | 수수료 행 탭 (1-2) | - | 수수료로 이동 (TSI-FEE-001) | | 등락 색상 토글 탭 (1-3) | - | 한국식/글로벌식 전환, 앱 전체 즉시 반영 | | 비밀번호 변경 행 탭 (1-4) | - | 비밀번호 변경으로 이동 (TSI-AUTH-003) | | 로그아웃 탭 (1-5) | - | 로그아웃 확인 바텀시트 노출 (TSI-SET-101) | ### 엣지케이스 - 등락 색상 설정은 로컬 저장이며 **로그아웃해도 유지한다** (가정). - 세션 만료 시각이 10분 이내면 계정 카드에 "곧 만료" 배지를 표시한다 (가정). - 세션 정보 조회에 실패해도 나머지 메뉴는 정상 동작해야 한다 — 화면 전체를 오류로 덮지 않는다. --- ## TSI-SET-101 로그아웃 확인 ### 입력 검증 해당 없음 — 확인/취소만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 확인 문구 + 취소·로그아웃 2버튼 | | 로그아웃 버튼 | 적색 배경으로 위험 동작임을 표시 | ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 취소 버튼 탭 (2-1) | - | 시트 닫힘, 세션 유지 | | 로그아웃 버튼 탭 (2-1) | - | 세션 종료, 로컬 캐시(최근 검색) 삭제 후 로그인으로 이동 (TSI-AUTH-001) | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 세션 유지 | ### 엣지케이스 - 로그아웃 요청이 실패해도 클라이언트 세션·캐시는 삭제하고 로그인 화면으로 보낸다. - 로그아웃 후 뒤로가기로 이전 화면에 복귀할 수 없어야 한다(히스토리 치환). - 표시 설정(등락 색상)과 선택 계좌는 삭제 대상에서 제외한다. -
codex.html 223.5 KB · in bundle
-
codex_business-rules.md 47.2 KB
# 토스인베스트 Business Rules Version: 2.0.0 이 문서는 `토스인베스트_storyboard.html` 의 화면 ID 를 키로 각 화면의 동작을 명세한다. 목업이 "무엇이 보이는가"라면 이 문서는 "무엇을 입력받고, 무엇을 검사하고, 어떤 조건에서 어떻게 동작하는가"다. **범위** — 조회·모니터링 전용. 매수·매도·정정·취소 등 주문 실행은 범위 밖이다. 워치독은 조건 충족 시 알림만 발송하며 자동으로 주문을 내지 않는다. **공통 규칙** — 아래 규칙은 전 화면에 적용되며 각 섹션에서 반복하지 않는다. - 인증되지 않은 상태로 화면에 진입하거나 조회 중 401 을 받으면 세션 만료 안내를 노출하고 로그인으로 보낸다 (TSI-AUTH-101 → TSI-AUTH-001). - 429(호출 한도)는 30초 후 1회 자동 재시도하고, 그래도 실패하면 수동 재시도 버튼을 노출한다. - 조회 실패 시 금액 자리에는 "—" 와 "갱신 실패" 캡션을 쓴다. **0 으로 대체하지 않는다.** - 금액은 원 단위 3자리 콤마, 손익은 부호(+/−)와 색을 병기하며 0 은 회색(보합)으로 둔다. - 등락 색상은 기본 한국식(상승 적색)이고 설정에서 글로벌식으로 바꿀 수 있다 (TSI-SET-001). - 역할이 본인 1종뿐이라 권한 매트릭스 섹션을 두지 않는다. --- ## TSI-MAIN-001 홈 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 계좌 카드·지수·보유 상위 각각 스켈레톤 | | 정상 | 계좌 요약, 지수 2종, 보유 상위 3건, 워치독 요약 | | 계좌 조회 실패 | 금액 자리 "—" + "갱신 실패" 캡션 | | 보유 0건 | 보유 상위 영역에 "보유 중인 종목이 없습니다" + 종목 검색 CTA | | 알림 0건 | 헤더 알림 배지 숨김 | - 보유 상위는 **평가금액 내림차순 3건**만 노출하고 4건째부터는 목록 화면으로 유도한다. - 지수는 장 마감 후 마지막 체결값을 유지하고 "장 마감" 배지를 병기한다. - 알림 배지는 99건을 넘으면 `99+` 로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 요약 카드 탭 (1) | - | 계좌로 이동 (TSI-ACCT-001) | | 알림 배지 탭 (2) | - | 알림 내역으로 이동 (TSI-NOTI-001) | | 지수 요약 탭 (3) | - | 시장지표로 이동 (TSI-MKT-001) | | 보유 상위 탭 (4) | 보유 1건 이상 | 보유종목으로 이동 (TSI-HOLD-001) | | 보유 상위 탭 (4) | 보유 0건 | 종목 검색으로 이동 (TSI-SEARCH-001) | | 워치독 요약 탭 (5) | - | 워치독으로 이동 (TSI-WD-001) | | 하단 탭 탭 (6) | - | 각 탭 최상위 화면으로 전환 | ### 엣지케이스 - 계좌 목록은 호출 한도가 낮으므로(ACCOUNT 그룹) **세션당 1회 조회 후 캐시**하고, 당겨서 새로고침에서만 재조회한다. - 계좌 0건은 정상 상태가 아니다 — "조회 가능한 계좌가 없습니다" 안내 후 재시도만 제공한다. - 지수 조회만 실패하면 계좌 카드는 그대로 두고 지수 영역만 "—" 로 표시한다. - 백그라운드 복귀 후 5분이 지났으면 진입 시 1회 자동 재조회한다 (가정). --- ## TSI-AUTH-001 로그인 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 아이디 | 필수. 영문·숫자 4~20자, 앞뒤 공백 자동 제거 | 로그인 버튼 비활성 유지 | | 비밀번호 | 필수. 8자 이상 | 로그인 버튼 비활성 유지 | - 검증 시점은 입력 중(버튼 활성 판정)과 제출 시(서버 대조) 두 번이다. - 클라이언트에서 아이디 존재 여부를 미리 조회하지 않는다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 기본 | 아이디·비밀번호 입력, 로그인 버튼, QR 로그인 진입 | | 제출 중 | 로그인 버튼 스피너, 입력 필드 비활성 | | 인증 실패 | 비밀번호 필드 아래 인라인 오류, 필드 테두리 적색 | | 사용자 저장소 불가 | "로그인 비활성(DB 없음)" 안내와 재시도 버튼 | - 실패 문구는 "아이디 또는 비밀번호가 틀렸습니다" 하나만 쓴다 — 어느 쪽이 틀렸는지 구분해 알리지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 아이디 입력 (1-1) | - | 공백 제거, 기존 오류 문구 초기화 | | 비밀번호 입력 (1-2) | 두 필드 모두 규칙 통과 | 로그인 버튼 활성 | | 로그인 버튼 탭 (1-3) | 인증 성공 | 세션 발급 후 홈으로 이동 (TSI-MAIN-001) | | 로그인 버튼 탭 (1-3) | 인증 실패(401) | 인라인 오류 표시, 비밀번호만 비움 (2-1) | | QR 로그인 탭 (1-4) | - | 챌린지 생성 후 QR 노출, 승인 대기 (TSI-AUTH-002) | | 확인 버튼 탭 (3-1) | 세션 만료 팝업 | 로그인으로 히스토리 치환 이동 (TSI-AUTH-101) | ### 엣지케이스 - 세션 쿠키는 HttpOnly 이며 유효기간 7일이다. 발급 시각이 아니라 만료 시각을 기준으로 판정한다. - 로그인 성공 직후 뒤로가기로 로그인 화면에 돌아오지 못하도록 히스토리를 치환한다. - 연속 인증 실패 5회 시 60초간 로그인 버튼을 잠근다 (가정). - 이미 유효한 세션으로 로그인 화면에 진입하면 즉시 홈으로 보낸다 (TSI-MAIN-001). --- ## TSI-AUTH-101 세션 만료 안내 ### 입력 검증 해당 없음 — 확인만 받는 팝업. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 만료 안내 문구 + "로그인하러 가기" 단일 버튼 | - 전 화면 공통이며 401 을 받은 화면 위에 겹쳐 노출한다. - 만료 시각이나 원인을 상세히 노출하지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 확인 버튼 탭 (3-1) | - | 로컬 조회 캐시 삭제 후 로그인으로 히스토리 치환 이동 (TSI-AUTH-001) | ### 엣지케이스 - 배경 딤을 탭해도 닫히지 않는다 — 세션이 없는 화면에 머물게 두지 않기 위함이다. - 여러 조회가 동시에 401 을 받아도 팝업은 1개만 노출한다(중복 노출 방지). - 표시 설정(등락 색상)은 캐시 삭제 대상에서 제외한다. --- ## TSI-AUTH-002 QR 로그인 승인 ### 입력 검증 해당 없음 — 승인·거절만 받는 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 정상 | 요청 시각·접속 IP·브라우저, 남은 유효시간 카운트다운 | | 만료 | "요청이 만료되었습니다" 문구, 승인·거절 버튼 비활성 | | 처리 중 | 승인 버튼 스피너 | - 유효시간은 **2분**이며 1초 단위로 감소 표기한다. - 남은 시간 30초 이하부터 카운트다운을 적색으로 표시한다. - 보안 경고 문구를 버튼 위에 상시 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 승인 버튼 탭 (2) | 챌린지 유효 | 승인 처리, 요청 기기에 세션 발급 후 홈으로 이동 (TSI-MAIN-001) | | 승인 버튼 탭 (2) | 챌린지 만료 | "요청이 만료되었습니다" 표시, 승인 차단 | | 거절 버튼 탭 (3) | - | 챌린지 폐기, 요청 기기는 로그인 실패 처리 (TSI-AUTH-001) | ### 엣지케이스 - 이 화면 자체가 세션을 요구한다 — 미인증 진입 시 로그인으로 보낸다 (TSI-AUTH-001). - 같은 챌린지를 두 번 승인해도 세션은 한 번만 발급한다(멱등). - 승인 요청 중 네트워크가 끊기면 결과를 단정하지 않고 "승인 결과를 확인할 수 없습니다"로 안내한 뒤 재시도를 제공한다. - 요청 기기의 폴링은 2초 간격이며, 승인 후 최대 2초 이내에 세션이 반영된다. --- ## TSI-AUTH-003 비밀번호 변경 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 현재 비밀번호 | 필수. 서버 대조는 제출 시에만 | 제출 후 인라인 오류 | | 새 비밀번호 | 필수. 8자 이상, 현재 비밀번호와 달라야 함 | "8자 이상" / "현재와 다른 비밀번호를 입력해주세요" | | 새 비밀번호 확인 | 필수. 새 비밀번호와 일치 | "새 비밀번호 확인이 일치하지 않습니다" | - 새 비밀번호 규칙은 **포커스 아웃 시점**에 즉시 검사한다. - 세 필드가 모두 규칙을 통과해야 변경 버튼이 활성된다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 기본 | 3개 입력 필드와 규칙 안내 문구 | | 검증 실패 | 해당 필드 테두리 적색 + 아래 인라인 오류 | | 성공 | "변경되었습니다" 토스트 후 로그인으로 이동 | - 하단에 "변경 시 다른 기기의 로그인도 모두 해제됩니다" 를 상시 고지한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 현재 비밀번호 입력 (1-1) | - | 기존 오류 문구 초기화 | | 새 비밀번호 입력 (1-2) | 포커스 아웃 | 길이·현재값과의 동일 여부 즉시 검사 | | 새 비밀번호 확인 입력 (1-3) | 새 비밀번호와 불일치 | 인라인 오류 표시, 변경 버튼 비활성 | | 변경 버튼 탭 (1-4) | 서버 검증 성공 | 전 세션 무효화 후 로그인으로 이동 (TSI-AUTH-001) | | 변경 버튼 탭 (1-4) | 현재 비밀번호 불일치(401) | 현재 비밀번호 필드에 인라인 오류 (2-1) | ### 엣지케이스 - 변경 성공 시 **모든 세션을 무효화**한다. 다른 기기 로그인도 함께 해제된다. - 서버 검증 실패 시 새 비밀번호 입력값은 지우지 않는다 — 다시 입력하게 만들지 않기 위함이다. - 제출 중 화면을 벗어나도 요청은 계속 진행되며, 복귀 시 결과를 토스트로 재표시하지 않는다. - 직전 3개 비밀번호 재사용은 서버가 400 으로 거절하고 인라인 오류로 표시한다 (가정). --- ## TSI-ACCT-001 계좌 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3종 스켈레톤 | | 정상 | 계좌번호·총자산, 자산 요약 3행, 하위 메뉴 3행 | | 계좌 1개 | 헤더의 "전환" 버튼과 드롭다운 화살표 숨김 | | 조회 실패 | 금액 자리 "—" + "갱신 실패" 캡션 | - 계좌번호는 전체 표기한다(마스킹하지 않는다). 본인 계좌만 조회되므로 실익이 없다 (가정). - 평가손익 0원은 회색(보합)으로 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 헤더 탭 (1-1) | 계좌 2개 이상 | 계좌 전환 바텀시트 노출 (TSI-ACCT-101) | | 계좌 헤더 탭 (1-1) | 계좌 1개 | 동작 없음 | | 자산 요약 (1-2) | - | 읽기 전용, 이동 없음 | | 자산 대시보드 행 탭 (1-3) | - | 자산 대시보드로 이동 (TSI-DASH-001) | | 보유종목 행 탭 (1-3) | - | 보유종목으로 이동 (TSI-HOLD-001) | | 주문내역 행 탭 (1-3) | - | 주문내역으로 이동 (TSI-ORDER-001) | ### 엣지케이스 - 계좌 목록 API 는 호출 한도가 낮다(TPS 1). **세션당 1회 조회 후 캐시**하고 명시적 새로고침에서만 재조회한다. - 계좌 0건은 정상 상태가 아니다 — "조회 가능한 계좌가 없습니다" 안내 후 재시도만 제공한다. - 계좌 전환 직후에는 이전 계좌 데이터를 화면에 남기지 않는다(스켈레톤으로 초기화). --- ## TSI-ACCT-101 계좌 전환 ### 입력 검증 해당 없음 — 선택만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 계좌 목록 카드. 현재 선택 계좌는 accent 테두리 | | 계좌 정보 | 계좌번호 + 계좌유형 + seq | - 목록 순서는 계좌 seq 오름차순 고정. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 항목 탭 (2-1) | 다른 계좌 선택 | 선택 저장, 시트 닫힘, 호출 화면 재조회 | | 계좌 항목 탭 (2-1) | 이미 선택된 계좌 | 시트만 닫음, 재조회 없음 | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 선택 변경 없음 | ### 엣지케이스 - 시트가 열린 동안 계좌 목록을 재조회하지 않는다(호출 한도 보호). - 선택 계좌가 서버에서 사라진 경우(해지 등) 재조회 시 404 → 첫 계좌로 자동 전환하고 토스트로 안내한다. - 선택 계좌는 로컬에 저장되어 홈·계좌·수수료 화면이 같은 선택을 공유한다. --- ## TSI-DASH-001 자산 대시보드 ### 입력 검증 해당 없음 — 정렬 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 잔고·손익 카드와 보유 목록 스켈레톤 | | 정상 | 원화·달러 잔고, 평가손익 카드, 보유 비중 목록 | | 보유 0건 | 비중 목록 자리에 "보유 중인 종목이 없습니다" | | 조회 실패 | 금액 자리 "—" + 재시도 버튼 | - 비중은 **평가금액 합계 대비 백분율**이며 소수 1자리로 표기한다. - 수익/손실 종목 수 게이지는 평가손익 부호 기준으로 집계한다. - 기본 정렬은 평가금액 내림차순. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 잔고 카드 (1) | - | 읽기 전용, 이동 없음 | | 평가손익 카드 (2) | - | 읽기 전용, 이동 없음 | | 정렬 선택 탭 (3) | 보유 1건 이상 | 정렬 시트 노출 후 클라이언트 정렬, 재조회 없음 | | 보유 비중 행 탭 (4) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 새로고침 탭 (5) | - | 잔고·보유 전체 재조회, 진행 중 스켈레톤 | ### 엣지케이스 - 매입금액이 0인 종목(무상증자 등)은 수익률을 계산하지 않고 "—" 로 둔다. - 달러 잔고가 0이어도 카드를 숨기지 않는다 — 통화별 잔고 유무 자체가 정보다. - 새로고침 연타는 진행 중 요청이 끝날 때까지 무시한다(중복 호출 방지). --- ## TSI-HOLD-001 보유종목 ### 입력 검증 해당 없음 — 정렬 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 종목별 평가금액·수량·평단·손익, 하단 합계 카드 | | 데이터 없음 | 빈 상태 안내 + "종목 검색하기" CTA, 정렬 칩 비활성 | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 정렬은 **평가금액 내림차순**. - 손익률은 소수 2자리, 부호와 색 병기. 0.00% 는 회색. - 종목명은 1줄 말줄임, 심볼은 부가 정보로만 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 정렬 칩 탭 (1-1) | 보유 1건 이상 | 클라이언트 정렬 즉시 적용, 재조회 없음 | | 보유 종목 행 탭 (1-2) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 합계 카드 (1-3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 보유 0건이면 합계 카드를 숨긴다 — 0원 합계는 정보가 아니다. - 정렬 상태는 화면을 벗어나면 초기화한다(기본 정렬로 복귀). - 장중 시세 반영 지연으로 합계와 개별 합이 어긋나지 않도록, 합계는 **표시 중인 값으로 직접 합산**하지 않고 서버 응답값을 그대로 쓴다. --- ## TSI-HOLD-002 보유종목 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 헤더·상세 카드 스켈레톤 | | 정상 | 현재가·등락, 수량·평단·평가금액·평가손익·비중, 타깃 요약 | | 타깃 계산 불가 | 익절/손절 영역에 "ATR 데이터가 부족합니다" | - 포트폴리오 비중은 전체 평가금액 대비 소수 1자리. - 타깃·스탑은 참고값이며 계산 근거(ATR 배수)를 함께 노출한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 헤더 (2-1) | - | 읽기 전용, 이동 없음 | | 보유 상세 5행 (2-2) | - | 읽기 전용, 이동 없음 | | 익절/손절 참고 탭 (2-3) | 타깃 계산 가능 | 익절/손절로 이동 (TSI-TGT-001) | | 종목 정보 보기 탭 (2-4) | - | 종목 상세로 이동 (TSI-STOCK-001) | ### 엣지케이스 - 보유 수량이 0이 된 종목(전량 매도 직후)에 진입하면 "보유하지 않는 종목입니다" 안내 후 보유종목 목록으로 되돌린다 (TSI-HOLD-001). - 상장폐지·거래정지 종목은 현재가 자리에 "거래정지" 배지를 표시하고 평가손익은 마지막 체결가 기준임을 캡션으로 명시한다. - 신규 상장 등으로 ATR(20일) 산출에 필요한 일봉이 20개 미만이면 타깃 영역을 계산하지 않는다. --- ## TSI-ORDER-001 주문내역 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 조회 기간 | 시작일 ≤ 종료일, 최대 6개월 | "조회 기간은 최대 6개월입니다" 토스트, 조회 차단 | - 직접선택 외의 기간 칩은 검증 대상이 아니다(고정 범위). ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 매수·매도 라벨, 종목명, 수량, 체결가, 시각 | | 데이터 없음 | "해당 기간 주문내역이 없습니다" | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 탭은 **체결**, 기본 기간은 **1개월**. - 정렬은 체결(주문) 시각 내림차순 고정. - 매수는 적색, 매도는 청색 라벨로 구분한다(등락 색상 설정과 무관하게 고정). - 한 번에 20건씩 불러오고 "더 보기"로 이어붙인다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 체결·미체결 탭 탭 (1-1) | - | 목록 전환, 기간 필터는 유지 | | 기간 칩 탭 (1-2) | 유효 기간 | 선택 기간으로 재조회 | | 기간 칩 탭 (1-2) | 직접선택 6개월 초과 | 오류 토스트, 이전 조회 결과 유지 | | 주문 행 탭 (1-3) | - | 주문 상세로 이동 (TSI-ORDER-002) | ### 엣지케이스 - 미체결 탭에서 조회 도중 체결된 주문은 다음 갱신에서 체결 탭으로 이동한다 — 화면에서 즉시 지우지 않는다. - 부분체결 주문은 양쪽 탭에 모두 노출하고 체결수량을 병기한다. - "더 보기" 도중 기간을 바꾸면 누적분을 버리고 첫 페이지부터 다시 조회한다. --- ## TSI-ORDER-002 주문 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3종 스켈레톤 | | 정상 | 주문 요약, 체결 정보 5행, 비용 상세 4행 | | 취소 주문 | 상태 배지 "취소", 체결 정보는 취소 시점까지의 값 | - 매수는 거래세 0원으로 표기한다 — 항목 자체를 숨기지 않는다. - 정산금액은 매수면 거래대금+비용, 매도면 거래대금−비용으로 계산한다. - 부분체결이면 체결수량을 주문수량과 대비해 강조 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 주문 요약 헤더 (2-1) | - | 읽기 전용, 이동 없음 | | 체결 정보 (2-2) | - | 읽기 전용, 이동 없음 | | 비용 상세 (2-3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 여러 번에 나눠 체결된 주문은 체결 단가를 **가중평균**으로 표시하고 평균임을 캡션으로 명시한다. - 정정된 주문은 원 주문번호를 함께 노출한다. - 서버가 수수료·세금을 내려주지 않는 과거 주문은 해당 행을 "—" 로 두고 임의 계산하지 않는다. --- ## TSI-TGT-001 익절/손절 ### 입력 검증 해당 없음 — ATR 배수 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 종목 카드 3개 스켈레톤 | | 정상 | 종목별 현재가·평단·ATR, 타깃·스탑 게이지 | | 보유 0건 | "보유 중인 종목이 없습니다" 빈 상태 | | ATR 부족 | 해당 종목 카드에 게이지 대신 "ATR 데이터 부족" | - **참고용 계산값이며 자동 주문이 실행되지 않음**을 상단 배너로 상시 고지한다. - 타깃 = 평단 + (ATR × 배수), 스탑 = 평단 − (ATR × 배수 × 0.7) 로 계산한다 (가정). - ATR 은 일봉 20기간 기준이며 **당일 미완성 봉을 제외한 확정 종가**로 산출한다. - 상태 배지는 현재가가 스탑 기준 3% 이내면 "스탑 근접", 타깃 기준 3% 이내면 "타깃 근접". ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 참고 고지 배너 (1) | - | 읽기 전용, 닫기 불가 | | ATR 배수 칩 탭 (2) | - | 선택 배수로 전 종목 재계산, 서버 재조회 없음 | | 종목 카드 탭 (3) | - | 보유종목 상세로 이동 (TSI-HOLD-002) | | 타깃·스탑 게이지 (4) | - | 읽기 전용, 이동 없음 | | 새로고침 탭 (5) | - | 현재가·ATR 재조회 후 전 종목 재계산 | ### 엣지케이스 - 현재가가 타깃을 넘었거나 스탑을 밑돌면 게이지 마커를 양 끝에 고정하고 초과분을 텍스트로 적는다. - 배수 선택은 화면을 벗어나면 기본값(2.0배)으로 되돌린다. - 장중에는 확정 종가가 없으므로 ATR 은 전일 기준값을 쓰고 기준일을 캡션에 명시한다. --- ## TSI-WD-001 워치독 ### 입력 검증 해당 없음 — 목록·토글만 있는 화면. 조건 입력 검증은 등록 화면 소관 (TSI-WD-002). ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 카드 3개 스켈레톤 | | 정상 | 이름·조건식·쿨다운·최근 발동 시각, 활성 토글 | | 데이터 없음 | "등록된 워치독이 없습니다" + 등록 CTA | | 비활성 항목 | 카드 전체를 흐리게 처리하고 토글 off | - 상단에 활성 건수와 당일 발동 건수를 요약한다. - 조건식은 그룹 안 AND, 그룹 간 OR 로 읽히도록 연결어를 굵게 표기한다. - 최근 발동 이력이 없으면 "발동 이력 없음" 으로 적고 시각 자리를 비우지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 등록 버튼 탭 (1-1) | 등록 건수 20건 미만 | 워치독 등록으로 이동 (TSI-WD-002) | | 등록 버튼 탭 (1-1) | 등록 건수 20건 도달 | "최대 20건까지 등록할 수 있습니다" 토스트 | | 워치독 카드 탭 (1-2) | - | 워치독 등록 화면을 수정 모드로 진입 (TSI-WD-002) | | 워치독 카드 스와이프 (1-2) | - | 삭제 확인 바텀시트 노출 (TSI-WD-101) | | 활성 토글 탭 (1-3) | - | 활성·비활성 즉시 전환, 비활성은 평가 대상에서 제외 | ### 엣지케이스 - 토글 전환은 낙관적으로 먼저 반영하고, 실패하면 원래 상태로 되돌린 뒤 토스트로 알린다. - 조건 평가는 서버 스케줄러가 수행하므로 화면을 닫아도 알림은 계속 발송된다. - 쿨다운 이내 재충족은 알림을 보내지 않고 평가 시각만 갱신한다. - 감시 종목이 거래정지되면 평가를 건너뛰고 카드에 "거래정지" 배지를 표시한다. --- ## TSI-WD-002 워치독 등록 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 감시 종목 | 필수. 종목 검색에서 선택 | 저장 버튼 비활성 | | 조건 지표 | 필수. 현재가·등락률·외국인 순매수·기관 순매수·뉴스 키워드 중 하나 | 저장 버튼 비활성 | | 비교 연산자 | 필수. 초과(>) 또는 미만(<) | 저장 버튼 비활성 | | 임계값 | 필수. 0 초과 숫자. 가격은 정수, 등락률은 소수 2자리, 금액은 억 단위 | "올바른 값을 입력해주세요" 인라인 오류 | | 뉴스 키워드 | 지표가 뉴스일 때 필수. 1~20자 | "키워드를 입력해주세요" | | 쿨다운 | 필수. 10~1440분 정수. 기본 60분 | "10분 이상 1440분 이하로 입력해주세요" | - 그룹당 조건 최대 3개, 그룹 최대 3개. - 검증 시점은 입력 중(형식)과 저장 시(서버 중복·한도)다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 신규 | 빈 조건 그룹 1개, 쿨다운 60분 | | 수정 | 기존 값 채운 상태, 헤더 "워치독 수정" | | 저장 중 | 저장 버튼 스피너, 입력 비활성 | | 검증 실패 | 해당 입력칸 아래 인라인 오류 | - 종목이 선택되기 전에는 지표 선택칸을 비활성으로 둔다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 감시 종목 선택 탭 (2-1) | - | 종목 검색으로 이동 후 선택 결과 수신 (TSI-SEARCH-001) | | 조건 입력 (2-2) | 형식 위반 | 인라인 오류, 저장 버튼 비활성 | | 조건 그룹 추가 탭 (2-3) | 그룹 3개 미만 | 그룹 추가, 그룹끼리 OR 로 평가 | | 조건 그룹 추가 탭 (2-3) | 그룹 3개 도달 | "조건 그룹은 최대 3개입니다" 토스트 | | 저장 버튼 탭 (2-4) | 검증 통과 | 저장 후 워치독 목록으로 복귀 (TSI-WD-001) | | 저장 버튼 탭 (2-4) | 동일 조건 중복(409) | "같은 조건의 워치독이 이미 있습니다" 인라인 오류 | ### 엣지케이스 - 등록 한도는 계정당 20건이다. 초과 시 서버가 400 으로 거절하고 저장을 차단한다. - 수정 모드에서 조건을 모두 지우면 저장할 수 없다 — 최소 1개 조건이 필요하다. - 저장 중 화면을 벗어나도 요청은 계속 진행되며, 목록 복귀 시 결과가 반영된다. - 종목 선택 화면에서 뒤로 나오면 기존 입력값은 유지한다(초기화하지 않는다). --- ## TSI-WD-101 워치독 삭제 확인 ### 입력 검증 해당 없음 — 확인/취소만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 삭제 대상 워치독 이름 + 취소·삭제 2버튼 | | 삭제 버튼 | 적색 배경으로 위험 동작임을 표시 | - 삭제 후 알림이 더 이상 오지 않음을 문구로 명시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 취소 버튼 탭 (3-1) | - | 시트 닫힘, 목록 유지 | | 삭제 버튼 탭 (3-1) | 삭제 성공 | 목록에서 제거 후 "삭제되었습니다" 토스트 (TSI-WD-001) | | 배경 딤 탭 (3-1) | - | 시트 닫힘, 목록 유지 | ### 엣지케이스 - 발동 이력은 삭제하지 않는다 — 알림 내역에는 그대로 남는다 (TSI-NOTI-001). - 이미 삭제된 워치독을 다시 삭제하면(다른 기기에서 선삭제) 404 를 성공으로 간주하고 목록에서 제거한다. - 삭제 실패 시 시트를 닫지 않고 오류 문구를 시트 안에 표시한다. --- ## TSI-NOTI-001 알림 내역 ### 입력 검증 해당 없음 — 필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 5행 스켈레톤 | | 정상 | 워치독 이름·발동 사유·시각. 안 읽음은 좌측 accent 바 | | 데이터 없음 | 빈 상태 안내 + "워치독 등록하기" CTA | | 오류 | 재시도 버튼 포함 오류 배너 | - 시각은 당일이면 `HH:MM`, 전일이면 "어제 HH:MM", 그 이전이면 `MM-DD HH:MM`. - 발동 사유에는 **실제 관측값과 기준값을 함께** 적는다 (예: 95,400원 / 기준 95,000원). - 한 번에 20건씩 불러오고 "더 보기"로 이어붙인다. 보관 기간은 90일 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 필터 칩 탭 (1-1) | - | 전체·안 읽음 필터 즉시 적용 | | 알림 행 탭 (1-2) | 워치독 존재 | 읽음 처리 후 해당 워치독으로 이동 (TSI-WD-001) | | 알림 행 탭 (1-2) | 워치독 삭제됨 | 읽음 처리만 하고 "삭제된 워치독입니다" 토스트 | | 모두 읽음 탭 (1-3) | 안 읽음 1건 이상 | 전체 읽음 처리, 홈 배지 즉시 갱신 (TSI-MAIN-001) | | 빈 상태 CTA 탭 (2-1) | 알림 0건 | 워치독 등록으로 이동 (TSI-WD-002) | ### 엣지케이스 - "모두 읽음" 은 안 읽음이 0건이면 비활성한다. - 읽음 처리 실패 시 화면 상태를 되돌리고 토스트로 알린다. - 같은 워치독이 쿨다운 뒤 다시 발동하면 별도 행으로 쌓는다 — 묶어서 표시하지 않는다. --- ## TSI-SEARCH-001 종목 검색 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 검색어 | 1~20자. 공백만 입력 불가 | 조회하지 않고 최근 검색 화면 유지 | | 종목코드 | 숫자 6자리로 입력하면 코드 완전일치 조회 | 결과 없음 처리 | - 입력 후 **300ms 디바운스**로 자동 조회한다. 별도 검색 버튼을 두지 않는다. ### 출력 규칙 | 상태 | 표시 | |---|---| | 검색 전 | 최근 검색 칩 + 거래대금 상위 3건 | | 조회 중 | 결과 영역 스켈레톤 3행 | | 결과 있음 | 종목명·코드·시장·현재가·등락률, 상단에 결과 건수 | | 결과 없음 | "검색 결과가 없습니다" + 검색어 확인 안내 | - 최근 검색은 최대 10건, 최신순이며 **로컬에만** 저장한다. - 정렬은 종목명 전방일치 우선, 그다음 거래대금 내림차순 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 검색 입력 (1-1) | 1자 이상 | 300ms 디바운스 후 자동 조회 | | 검색 입력 (1-1) | 공백만 입력 | 조회하지 않고 검색 전 상태 유지 | | 최근 검색 칩 탭 (1-2) | - | 해당 종목 상세로 이동 (TSI-STOCK-001) | | 전체 삭제 탭 (1-3) | 최근 검색 1건 이상 | 확인 없이 즉시 전체 삭제 | | 검색 결과 행 탭 (2-1) | 일반 진입 | 최근 검색에 추가하고 종목 상세로 이동 (TSI-STOCK-001) | | 검색 결과 행 탭 (2-1) | 워치독 등록에서 진입 | 선택 종목을 반환하고 등록 화면으로 복귀 (TSI-WD-002) | ### 엣지케이스 - 최근 검색이 0건이면 해당 영역 자체를 숨긴다(빈 제목만 남기지 않는다). - 워치독 등록·종목별 수급에서 진입한 경우 화면 이동 대신 **선택 결과를 반환**한다. - 검색 도중 새 입력이 들어오면 이전 요청 결과는 버린다(경합 방지). - 상장폐지 종목은 결과에 노출하되 "거래정지" 배지를 붙이고 현재가를 "—" 로 둔다. --- ## TSI-STOCK-001 종목 상세 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 헤더·시세 요약 스켈레톤 | | 정상 | 현재가·등락, 시세 요약 8항목, 탭 4종, 워치독 등록 CTA | | 장 마감 | 기준 시각 옆에 "장 마감 기준" 문구 | | 거래정지 | 현재가 자리에 "거래정지" 배지, 등락 숨김 | - 상한가·하한가는 등락 색상 설정과 무관하게 고정색(상한 적색·하한 청색)을 쓴다. - 거래대금은 조 단위까지 축약하고 소수 2자리로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 헤더 (1) | - | 읽기 전용, 이동 없음 | | 시세 요약 (2) | - | 읽기 전용, 이동 없음 | | 차트 탭 탭 (3) | - | 차트로 전환 (TSI-CHART-001) | | 호가 탭 탭 (3) | - | 호가로 전환 (TSI-ORDB-001) | | 체결 탭 탭 (3) | - | 체결로 전환 (TSI-TRADE-001) | | 수급 탭 탭 (3) | - | 종목별 수급으로 전환 (TSI-FLOW-001) | | 워치독 등록 CTA 탭 (4) | - | 종목이 채워진 상태로 워치독 등록 진입 (TSI-WD-002) | ### 엣지케이스 - 장 시작 전에는 시가·고가·저가가 비어 있으므로 "—" 로 두고 전일 종가만 노출한다. - 시세 조회가 실패해도 탭 구조는 유지한다 — 각 탭이 개별로 재시도할 수 있어야 한다. - 보유 중인 종목이면 헤더 아래에 보유 수량을 부가 표시한다 (TSI-HOLD-002 로 이동 가능). --- ## TSI-CHART-001 차트 ### 입력 검증 해당 없음 — 주기·지표 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 캔들·거래량 영역 스켈레톤 | | 정상 | 캔들, 이동평균선, 거래량, 하단 요약 텍스트 | | 데이터 부족 | "표시할 캔들이 없습니다" + 다른 주기 안내 | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 주기는 **일봉**, 조회 봉 수는 최근 100봉. - 상승봉 적색·하락봉 청색이며 등락 색상 설정을 따른다. - 이동평균은 MA5·MA20·MA60 이며 기본 전부 표시. - 접근성을 위해 하단에 상승/하락 봉 수와 최고·최저가를 **텍스트로도** 제공한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 주기 칩 탭 (1) | - | 선택 주기(1분·일·주·월)로 캔들 재조회 | | 캔들 영역 롱프레스 (2) | - | 해당 봉의 시가·고가·저가·종가 툴팁 표시 | | 이동평균 범례 탭 (3) | - | 해당 이동평균선 표시·숨김 전환 | | 거래량 영역 (4) | - | 읽기 전용, 캔들과 x축 공유 | ### 엣지케이스 - 1분봉은 장중에만 갱신되며 장 마감 후에는 마지막 스냅샷을 유지한다. - 상장 기간이 짧아 MA60 을 못 만드는 종목은 해당 선을 그리지 않고 범례를 흐리게 처리한다. - 장중 미완성 봉은 점선으로 구분해 확정 봉과 섞이지 않게 한다. - 툴팁은 손을 떼면 사라지며, 표시 중에는 화면 스크롤을 잠근다. --- ## TSI-ORDB-001 호가 ### 입력 검증 해당 없음 — 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 호가 10행 스켈레톤 | | 정상 | 매도 5호가(위)·매수 5호가(아래), 중앙 현재가, 총잔량 요약 | | 장 마감 | 마지막 스냅샷 유지 + "장 마감" 배지 | | 거래정지 | 호가 영역에 "거래정지" 안내, 잔량 숨김 | - 잔량 막대는 **표시된 10개 호가 중 최대 잔량을 100%** 로 한 상대 길이다. - 매도는 청색 계열, 매수는 적색 계열로 좌우 대칭 배치한다. - 현재가 행은 위아래 굵은 구분선으로 분리한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 호가 행 탭 (1) | - | 해당 가격을 임계값으로 채워 워치독 등록 진입 (TSI-WD-002) | | 잔량 막대 (2) | - | 읽기 전용, 잔량 수치를 막대 안에 병기 | | 총잔량 요약 (3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 시간외 단일가 시간대에는 호가가 제공되지 않으므로 "시간외 단일가 시간입니다" 안내로 대체한다. - 상한가·하한가에 도달하면 한쪽 잔량이 0이 되며, 이때 막대를 그리지 않고 0을 표기한다. - 갱신 주기는 화면이 보이는 동안 3초이며, 백그라운드로 가면 폴링을 멈춘다. --- ## TSI-TRADE-001 체결 ### 입력 검증 해당 없음 — 필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 7행 스켈레톤 | | 정상 | 체결 요약 3항목 + 최근 체결 30건 | | 데이터 없음 | "체결 내역이 없습니다" (장 시작 전) | | 오류 | 재시도 버튼 포함 오류 배너 | - 정렬은 체결 시각 내림차순 고정, 표시 건수는 최근 30건. - 체결강도는 매수체결량÷매도체결량×100 이며 100% 초과는 매수 우위로 적색 표기한다. - 시각은 초 단위(`HH:MM:SS`)까지 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 체결량 필터 탭 (1) | - | 전체·1천주 이상 필터 즉시 적용, 재조회 없음 | | 체결 요약 (2) | - | 읽기 전용, 이동 없음 | | 체결 목록 스와이프 (3) | 아래로 당김 | 최신 체결 갱신 | ### 엣지케이스 - 필터가 "1천주 이상"인데 해당 체결이 없으면 목록을 비우고 필터 해제를 안내한다. - 장 마감 후에는 폴링을 멈추고 마지막 체결 목록을 유지한다. - 동시호가 체결은 시각이 같은 행이 여러 개 생길 수 있으므로 정렬을 안정 정렬로 유지한다. --- ## TSI-RANK-001 랭킹 ### 입력 검증 해당 없음 — 유형·필터 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 리스트 6행 스켈레톤 | | 정상 | 순위·종목명·기준값·현재가·등락률 | | 데이터 없음 | "표시할 종목이 없습니다" | | 오류 | 재시도 버튼 포함 오류 배너 | - 기본 유형은 **거래대금**, 기본 시장은 **KOSPI**, 기본 표시 건수는 **50위**. - 상위 2건은 순위 숫자를 accent 로 강조한다. - 기준값은 유형에 따라 달라진다(거래대금은 금액, 상승률은 %). - 상단에 기준 시각을 명시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 유형 칩 탭 (1-1) | - | 선택 유형으로 재조회, 필터는 유지 | | 필터 버튼 탭 (1-2) | - | 랭킹 필터 바텀시트 노출 (TSI-RANK-101) | | 랭킹 행 탭 (1-3) | - | 종목 상세로 이동 (TSI-STOCK-001) | ### 엣지케이스 - 장 시작 전에는 전일 확정 순위를 보여주고 기준 시각으로 전일임을 명시한다. - 동순위가 발생하면 종목코드 오름차순으로 안정 정렬한다. - 표시 건수를 늘렸다가 줄이면 재조회 없이 클라이언트에서 잘라 보여준다. --- ## TSI-RANK-101 랭킹 필터 ### 입력 검증 해당 없음 — 선택만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 시장 구분 3종, 표시 건수 3종. 현재 선택은 accent 칩 | - 선택 상태는 목록 화면의 현재 조건을 그대로 반영해 연다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 필터 옵션 탭 (2-1) | 시장 구분 변경 | 선택 즉시 반영, 시트 닫고 목록 재조회 (TSI-RANK-001) | | 필터 옵션 탭 (2-1) | 표시 건수 변경 | 선택 즉시 반영, 시트 닫고 목록 갱신 | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 선택 변경 없음 | ### 엣지케이스 - 시장 구분을 "전체"로 바꾸면 표시 건수는 유지하되 조회는 처음부터 다시 한다. - 필터 선택은 화면을 벗어나면 기본값(KOSPI·50위)으로 되돌린다. --- ## TSI-MKT-001 시장지표 ### 입력 검증 해당 없음 — 지수·기간 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 지수 카드·차트 스켈레톤 | | 정상 | KOSPI·KOSDAQ 카드, 추이 차트, 투자자별 매매대금 | | 조회 실패 | 지수 값 "—" + 재시도 버튼 | - 기본 선택 지수는 **KOSPI**, 기본 기간은 **1일**. - 투자자별 매매대금은 **시장 전체·일별 확정치**이며 종목 단위가 아님을 캡션으로 명시한다. - 지수는 소수 2자리, 매매대금은 억 단위로 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 지수 카드 탭 (1) | - | 추이 차트를 해당 지수로 전환 | | 기간 칩 탭 (2) | - | 선택 기간(1일·1주·1개월·1년)으로 추이 재조회 | | 추이 차트 롱프레스 (3) | - | 해당 시점의 지수값 툴팁 표시 | | 투자자별 매매대금 탭 (4) | - | 종목별 수급으로 이동 (TSI-FLOW-001) | ### 엣지케이스 - 장중에는 투자자별 매매대금 확정치가 없으므로 **전일 확정치**를 보여주고 기준일을 명시한다. - 휴장일에 진입하면 직전 거래일 기준으로 표시하고 "휴장" 배지를 붙인다. - 1년 기간은 데이터 양이 많으므로 일봉 단위로 다운샘플링한다. --- ## TSI-FLOW-001 종목별 수급 ### 입력 검증 해당 없음 — 종목·주기 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 차트·표 스켈레톤 | | 정상 | 외국인·기관 순매수 차트와 회차별 표 | | 종목 미선택 | "종목을 선택해주세요" + 검색 진입 CTA | | 데이터 없음 | "해당 종목의 수급 데이터가 없습니다" | - 기본 주기는 **장중 30분 회차**. - 장중 회차는 **외국인·기관만** 제공되며 개인은 집계되지 않는다 — 이 한계를 화면에 고지한다. - 순매수는 억 단위 소수 1자리, 부호와 색을 병기한다. - 표는 최신 회차부터 내림차순. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 종목 선택 탭 (1) | - | 종목 검색으로 이동 후 선택 결과 수신 (TSI-SEARCH-001) | | 주기 칩 탭 (2) | - | 장중 30분·일별 확정치 전환 후 재조회 | | 수급 차트 롱프레스 (3) | - | 해당 회차의 외국인·기관 금액 툴팁 표시 | | 데이터 한계 고지 (4) | 장중 주기 | 개인 수급 미제공 안내 표시. 일별 주기에서는 숨김 | | 회차별 표 스와이프 (5) | 아래로 당김 | 최신 회차 갱신 | ### 엣지케이스 - 수급 집계 대상 종목이 제한적이므로, 대상이 아닌 종목은 "수급 제공 대상이 아닙니다"로 안내하고 빈 차트를 그리지 않는다. - 회차 데이터가 지연 도착하면 마지막 회차가 비어 보일 수 있다 — 빈 값은 0으로 채우지 않고 행 자체를 만들지 않는다. - 장중 회차와 일별 확정치는 산출 기준이 달라 합계가 일치하지 않을 수 있음을 캡션으로 밝힌다. --- ## TSI-FX-001 환율 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 환산 금액 | 선택. 0 초과, 소수 2자리까지, 최대 999,999,999 | 결과 자리에 "—" 유지, 오류 문구 미표시 | ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 통화쌍 3행 스켈레톤 | | 정상 | USD·JPY·EUR 대 원화, 기준시각, 환산 계산기 | | 조회 실패 | 환율 자리 "—" + 재시도 버튼 | - JPY 는 **100엔 기준**임을 통화명 옆에 명시한다. - 환율은 소수 2자리, 환산 결과는 원 단위 정수로 반올림한다. - 기준시각은 응답값을 그대로 표시하며 임의로 현재시각을 쓰지 않는다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 기준시각·새로고침 탭 (1) | - | 전 통화쌍 재조회 | | 통화쌍 행 탭 (2) | - | 해당 통화를 환산 계산기의 기준 통화로 설정 | | 환산 금액 입력 (3) | 유효 금액 | 원화 환산 결과 즉시 산출 | | 환산 금액 입력 (3) | 공백 | 결과 "—" 로 복귀 | ### 엣지케이스 - 매매기준율 기준 참고값이며 실제 환전 금액과 다를 수 있음을 하단 캡션으로 명시한다. - 휴일·야간에는 마지막 고시 환율을 유지하고 기준시각으로 최신이 아님을 드러낸다. - 일부 통화만 조회에 실패하면 해당 행만 "—" 로 두고 나머지는 정상 표시한다. --- ## TSI-CAL-001 장운영 캘린더 ### 입력 검증 해당 없음 — 월 이동·날짜 선택만 있는 조회 전용 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 달력 그리드 스켈레톤 | | 정상 | 월 그리드(정규장·휴장·조기폐장 색 구분) + 선택일 상세 | | 조회 실패 | "장운영 정보를 가져오지 못했습니다" + 재시도 | - 기본 선택일은 **오늘**이며, 오늘이 조회 월에 없으면 해당 월 1일. - 조회 가능 범위는 당월 기준 **전후 12개월**. - 색만으로 구분하지 않도록 하단에 범례를 함께 둔다. - 휴장일은 시간 행 대신 휴장 사유를 표시한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 월 이동 탭 (1) | 조회 범위 내 | 이전·다음 달로 이동 후 재조회 | | 월 이동 탭 (1) | 범위 밖 | 화살표 비활성, 동작 없음 | | 월 그리드 스와이프 (1) | - | 좌우 스와이프로 월 전환 | | 날짜 탭 (2) | - | 해당 날짜 선택, 하단 상세 갱신 | | 선택일 상세 (3) | - | 읽기 전용, 이동 없음 | ### 엣지케이스 - 임시 휴장·조기 폐장(수능일 등)은 정규장 시간이 다르게 내려온다. **응답값을 그대로 표시하고 임의로 고정하지 않는다.** - 아직 확정되지 않은 미래 월은 "미확정" 배지를 붙이고 기본 일정만 회색으로 보여준다. - 주말은 조회 대상이지만 항상 휴장으로 표시한다. --- ## TSI-FEE-001 수수료 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | 거래금액 | 선택. 0 초과 정수, 최대 9,999,999,999원 | 결과 자리에 "—" 유지, 오류 문구 미표시 | ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 명세 스켈레톤 | | 정상 | 매수·매도 수수료율, 최소 수수료 | | 거래세 고지 | 배너 상시 노출 | | 계산 결과 | 거래금액 미입력 시 "—", 입력 시 수수료+세금 합계 | - 수수료율은 **소수 4자리**로 표기한다(0.0000% 도 그대로 노출). - 증권거래세는 매도 시 거래대금의 0.15%로 계산한다 (가정 — 코스피 기준). - 수수료는 원 단위 절사한다 (가정). ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 선택 탭 (1) | 계좌 2개 이상 | 계좌 전환 바텀시트 노출 (TSI-ACCT-101) | | 수수료율 명세 (2) | - | 읽기 전용, 이동 없음 | | 거래세 고지 배너 (3) | - | 읽기 전용, 상시 노출 | | 거래금액 입력 (4) | 유효 금액 | 매수·매도 각각 예상 수수료+세금 즉시 산출 | | 거래금액 입력 (4) | 공백 | 결과 "—" 로 복귀 | | 계산 결과 (5) | - | 읽기 전용, 참고용 캡션 병기 | ### 엣지케이스 - 수수료율이 0%인 계좌에서도 거래세는 발생한다. **고지 배너를 숨기지 않는다.** - 계산기는 참고용이며 실제 정산 금액과 다를 수 있음을 결과 하단에 캡션으로 명시한다. - 계좌를 전환하면 수수료율을 재조회하고 입력된 거래금액은 유지한 채 결과만 다시 계산한다. --- ## TSI-SET-001 설정 ### 입력 검증 해당 없음 — 토글·이동만 있는 화면. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 계정 카드 스켈레톤 | | 정상 | 계정 카드(아이디·세션 만료 시각), 기타 메뉴 3행, 표시·계정 설정, 로그아웃 | | 세션 정보 없음 | 아이디·만료 시각 자리에 "—" | - 등락 색상 토글은 기본 **한국식(상승 적색)** 이다. - 로그아웃은 위험 동작이므로 적색 텍스트로 구분한다. - 세션 만료 시각은 `YYYY-MM-DD HH:MM` 으로 분 단위까지 표기한다. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계정 카드 (1-1) | - | 읽기 전용, 이동 없음 | | 환율 행 탭 (1-2) | - | 환율로 이동 (TSI-FX-001) | | 장운영 캘린더 행 탭 (1-2) | - | 장운영 캘린더로 이동 (TSI-CAL-001) | | 수수료 행 탭 (1-2) | - | 수수료로 이동 (TSI-FEE-001) | | 등락 색상 토글 탭 (1-3) | - | 한국식/글로벌식 전환, 앱 전체 즉시 반영 | | 비밀번호 변경 행 탭 (1-4) | - | 비밀번호 변경으로 이동 (TSI-AUTH-003) | | 로그아웃 탭 (1-5) | - | 로그아웃 확인 바텀시트 노출 (TSI-SET-101) | ### 엣지케이스 - 등락 색상 설정은 로컬 저장이며 **로그아웃해도 유지한다** (가정). - 세션 만료 시각이 10분 이내면 계정 카드에 "곧 만료" 배지를 표시한다 (가정). - 세션 정보 조회에 실패해도 나머지 메뉴는 정상 동작해야 한다 — 화면 전체를 오류로 덮지 않는다. --- ## TSI-SET-101 로그아웃 확인 ### 입력 검증 해당 없음 — 확인/취소만 받는 바텀시트. ### 출력 규칙 | 상태 | 표시 | |---|---| | 노출 | 확인 문구 + 취소·로그아웃 2버튼 | | 로그아웃 버튼 | 적색 배경으로 위험 동작임을 표시 | ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 취소 버튼 탭 (2-1) | - | 시트 닫힘, 세션 유지 | | 로그아웃 버튼 탭 (2-1) | - | 세션 종료, 로컬 캐시(최근 검색) 삭제 후 로그인으로 이동 (TSI-AUTH-001) | | 배경 딤 탭 (2-1) | - | 시트 닫힘, 세션 유지 | ### 엣지케이스 - 로그아웃 요청이 실패해도 클라이언트 세션·캐시는 삭제하고 로그인 화면으로 보낸다. - 로그아웃 후 뒤로가기로 이전 화면에 복귀할 수 없어야 한다(히스토리 치환). - 표시 설정(등락 색상)과 선택 계좌는 삭제 대상에서 제외한다. -
README.md 3.7 KB
# runtime-parity fixtures 런타임 간 산출물 품질 편차(#75)의 회귀 기준선. 같은 스킬·같은 입력으로 세 런타임을 돌린 실제 산출물이며, 목업 본문 검증을 개발할 때 "무엇을 잡아야 하고 무엇을 통과시켜야 하는지"의 기준이 된다. ## 생성 조건 (2026-07-30) | 파일 | 런타임 | 실행 방식 | |---|---|---| | `claude.*` | Claude Code (Opus 5) | 세션에서 스킬 직접 사용 | | `codex.*` | Codex CLI 0.145.0 | `codex exec -s workspace-write`, 프롬프트로 스킬 경로 지정 | | `agy.*` | Antigravity CLI 1.1.7 | `agy -p`, 프롬프트로 스킬 경로 지정 | 세 런타임 모두 동일한 입력(31개 화면 ID 를 정의한 `business-rules.md`)과 동일한 지시를 받았다 — "스킬을 SSOT 로 삼고 validator 가 0 이 될 때까지 고쳐라". 도메인은 증권 계좌 조회·모니터링 모바일 웹이다. `<name>.html` 과 `<name>_business-rules.md` 는 한 쌍이다 — `validate_storyboard.py` 가 같은 디렉터리의 companion 마크다운을 요구하므로 분리하면 검증이 실패한다. ## 현재 판정 — 목업 본문 검증 도입 후 (#75) ``` validate_storyboard.py claude.html → 총 위반 0건 (오탐 없음 — 회귀 기준) validate_storyboard.py codex.html → 총 위반 12건 (설명 재탕 + 시퀀스 보일러플레이트) validate_storyboard.py agy.html → 총 위반 2건 (자리표시자 + 데이터 신호 부족) ``` `check_badge_overflow.py` · `check_badge_alignment.py` 는 세 개 모두 통과한다. 이 판정이 회귀 기준선이며 `tests/test_mock_content.py` 가 고정한다. 도입 전에는 셋 다 위반 0건이었다 — 구조 계약은 만족했지만 목업 본문의 실질은 아래처럼 갈렸다. | 지표 | claude | codex | agy | |---|---|---|---| | 슬라이드 | 23 | 44 | 43 | | HTML 크기 | 211KB | 229KB | 120KB | | `Mockup Content for` 자리표시자 | 0 | 0 | **31 (전 화면)** | | 금액 더미데이터 (`n,nnn원`) | 55 | 35 | **0** | | 등락률 (`±n.nn%`) | 36 | 10 | **0** | | 설명 텍스트를 목업 안에 재탕 (`○○ 탭 ›`) | 0 | **46** | 0 | - `agy.html` — 폰 목업 자리에 `Mockup Content for <화면명>` 자리표시자만 있고 화면을 그리지 않았다. - `codex.html` — 목업 안에 우측 설명 패널의 인터랙션 목록을 리스트 행으로 재탕했다. `04 IA` 슬라이드는 노드가 세로 1열로 붕괴했고 `07.x` 시퀀스 7장은 동일 보일러플레이트다. - `claude.html` — 실제 UI 밀도(금액·지수값·종목별 등락률)와 상태별 목업 병치를 갖췄다. ## 목업 본문 검증을 추가할 때의 수용 기준 1. `agy.html` 과 `codex.html` 은 **위반이 잡혀야** 한다. 2. `claude.html` 은 **계속 위반 0건이어야** 한다 — 여기서 오탐이 나면 하한값이 과하다는 뜻이다. ## 규칙 세트 메타 세 파일에는 `<meta name="skill-ruleset" content="2">` 를 사후 부여했다 (이슈 #77). 이 픽스처는 **규칙 세트 v2 기준의 회귀 기준선**이므로 v2 규칙이 전부 위반으로 집계되어야 한다 — 메타가 없으면 검증기가 v1 문서로 보고 신규 규칙을 참고로 강등해 기준선이 무의미해진다. ## 마스킹 원본 산출물의 목업에는 실제 증권계좌번호와 로그인 ID 가 더미데이터로 들어가 있었다. 공개 저장소이므로 커밋 전에 치환했다. | 원본 | 치환 | |---|---| | 실제 위탁계좌번호 | `11309999999` | | 두 번째 계좌번호 | `11309999888` | | 실제 로그인 ID | `user01` | 치환은 목업 본문의 표시 문자열만 바꾸므로 위 지표와 검증 판정에는 영향이 없다. 픽스처를 갱신할 때도 동일하게 마스킹한다.
-
-
-
test_agents.py 3.6 KB
"""mobile-web-planner 고유의 Agent Adapter 문구와 번들 검증기 계약 테스트. 모든 스킬에 공통으로 적용되는 레이아웃·어댑터 형식 규약은 저장소 루트의 tests/test_skill_layout.py 가 스킬을 순회하며 검증한다. 이 파일은 이 스킬에서만 의미가 있는 계약만 다룬다. """ import contextlib import importlib.util import re import tempfile import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent TEMPLATE = SKILL_ROOT / "resources" / "template.html" @contextlib.contextmanager def minimal_storyboard(): """검증기가 위반 0건으로 통과해야 하는 최소 산출물을 임시 파일로 만든다. 저장소는 생성 산출물(HTML)을 커밋하지 않으므로, 검증기 계약은 커밋된 예시 대신 이 픽스처로 확인한다. 화면 ID 를 정의하지 않으므로 짝을 이루는 Business Rules 문서도 요구되지 않는다. """ template = TEMPLATE.read_text(encoding="utf-8") match = re.search(r"<style>(.*?)</style>", template, re.DOTALL) if match is None: raise RuntimeError("template.html 에 <style> 블록이 없다") html = ( "<!DOCTYPE html><html lang=\"ko\"><head><meta charset=\"utf-8\">" f"<style>{match.group(1)}</style></head><body>" "<div class=\"docwrap\">" "<div class=\"ppt-slide\"><div class=\"ppt-top-no\">NO. 01</div></div>" "</div>" "<script src=\"https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js\">" "</script></body></html>" ) with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "fixture_storyboard.html" path.write_text(html, encoding="utf-8") yield path def load_module(name: str, path: Path): spec = importlib.util.spec_from_file_location(name, path) if spec is None or spec.loader is None: raise RuntimeError(f"모듈을 불러올 수 없다: {path}") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class TestAdapterInstructions(unittest.TestCase): """세 런타임 어댑터 모두 '검증기를 돌린다'는 계약을 담고 있어야 한다.""" def test_claude_adapter_requires_validation(self): text = (SKILL_ROOT / "agents" / "claude.md").read_text(encoding="utf-8") self.assertIn("검증기", text) def test_codex_adapter_requires_validation(self): text = (SKILL_ROOT / "agents" / "codex.toml").read_text(encoding="utf-8") self.assertIn("검증기", text) def test_antigravity_adapter_references_skill_and_validation(self): text = ( SKILL_ROOT / "agents" / "antigravity.md" ).read_text(encoding="utf-8") self.assertIn("`mobile-web-planner` Skill", text) self.assertIn("검증기", text) class TestBundledValidator(unittest.TestCase): @classmethod def setUpClass(cls): cls.validator = load_module( "validate_storyboard", SKILL_ROOT / "scripts" / "validate_storyboard.py", ) def test_uses_only_skill_local_template(self): self.assertEqual( self.validator.TEMPLATE, SKILL_ROOT / "resources" / "template.html", ) def test_accepts_minimal_conforming_storyboard(self): css = self.validator.extract_style( self.validator.TEMPLATE.read_text(encoding="utf-8")) with minimal_storyboard() as path: violations, _ = self.validator.check(path, css) self.assertEqual(violations, []) if __name__ == "__main__": unittest.main() -
test_apply_badge_audit.py 3.1 KB
"""apply_badge_audit.py 단위 테스트 (stdlib only) — 이슈 #72.""" import importlib.util import sys import unittest from pathlib import Path SCRIPTS = Path(__file__).resolve().parent.parent / "scripts" spec = importlib.util.spec_from_file_location( "apply_badge_audit", SCRIPTS / "apply_badge_audit.py") aba = importlib.util.module_from_spec(spec) spec.loader.exec_module(aba) HTML = """ <div class="ppt-top-no">NO. 09.1</div> <div class="mock-body"> <span class="pointer-badge" style="position:absolute; top:20px; left:2px; z-index:10;">1</span> <span class="pointer-badge" style="position:absolute; top:120px; left:2px; z-index:10;">2</span> </div> <div class="ppt-top-no">NO. 09.2</div> <div class="mock-body"> <span class="pointer-badge" style="position:absolute; top:20px; left:2px; z-index:10;">1-1</span> </div> """ class TestApplyFixes(unittest.TestCase): def test_applies_suggested_top_per_slide_and_label(self): fixes = [ {"slide": "09.1", "label": "2", "inlineTop": 120, "suggestedTop": 96}, {"slide": "09.2", "label": "1-1", "inlineTop": 20, "suggestedTop": 34}, ] html, applied, failed = aba.apply_fixes(HTML, fixes) self.assertEqual(len(applied), 2) self.assertEqual(failed, []) self.assertIn('top:96px; left:2px; z-index:10;">2<', html) self.assertIn('top:34px; left:2px; z-index:10;">1-1<', html) # 같은 라벨이라도 다른 슬라이드(09.1 의 "1")는 건드리지 않는다 self.assertIn('top:20px; left:2px; z-index:10;">1<', html) def test_tolerance_skips_small_delta(self): fixes = [{"slide": "09.1", "label": "1", "inlineTop": 20, "suggestedTop": 22}] html, applied, failed = aba.apply_fixes(HTML, fixes, tolerance=3) self.assertEqual(applied, []) self.assertEqual(failed, []) self.assertEqual(html, HTML) def test_stale_inline_top_fails_instead_of_wrong_patch(self): # 파일이 이미 고쳐져 inlineTop 이 안 맞으면 조용히 넘어가지 않고 실패로 보고한다 fixes = [{"slide": "09.1", "label": "2", "inlineTop": 999, "suggestedTop": 96}] html, applied, failed = aba.apply_fixes(HTML, fixes) self.assertEqual(applied, []) self.assertEqual(len(failed), 1) self.assertEqual(html, HTML) def test_unknown_slide_fails(self): fixes = [{"slide": "09.9", "label": "1", "inlineTop": 20, "suggestedTop": 50}] _html, applied, failed = aba.apply_fixes(HTML, fixes) self.assertEqual(applied, []) self.assertEqual(len(failed), 1) def test_negative_top_matches(self): html_src = HTML.replace('top:20px; left:2px; z-index:10;">1<', 'top:-6px; left:2px; z-index:10;">1<') fixes = [{"slide": "09.1", "label": "1", "inlineTop": -6, "suggestedTop": 10}] html, applied, failed = aba.apply_fixes(html_src, fixes) self.assertEqual(len(applied), 1) self.assertEqual(failed, []) self.assertIn('top:10px; left:2px; z-index:10;">1<', html) if __name__ == "__main__": sys.exit(unittest.main()) -
test_badge_alignment.py 5.1 KB
"""check_badge_alignment.py 단위 테스트 (stdlib only).""" import sys import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SKILL_ROOT / "scripts")) import check_badge_alignment as cba # noqa: E402 def badge(label, top): return (f'<span class="pointer-badge" style="position:absolute; ' f'top:{top}px; left:2px; z-index:10;">{label}</span>') def slide(no, mocks): """mocks: [[(컨테이너클래스, [배지…]), …], …] — 목업마다 컨테이너 목록.""" out = [f'<div class="ppt-top-no">NO. {no}</div>'] for containers in mocks: out.append('<div class="mock">') for cls, badges in containers: out.append(f'<div class="{cls}">' + "".join(badges) + "</div>") out.append("</div>") return "".join(out) class TestLabelKey(unittest.TestCase): def test_parses_flat_and_two_level(self): self.assertEqual(cba.label_key("3"), (3,)) self.assertEqual(cba.label_key("1-2"), (1, 2)) def test_returns_none_for_non_numeric(self): self.assertIsNone(cba.label_key("A")) self.assertIsNone(cba.label_key("1-x")) def test_two_level_sorts_numerically_not_lexically(self): # 문자열 정렬이면 "1-10" < "1-9" 가 되어 순서 검사가 뒤집힌다. self.assertLess(cba.label_key("1-9"), cba.label_key("1-10")) class TestBadgesIn(unittest.TestCase): def test_reads_label_and_top(self): self.assertEqual(cba.badges_in(badge("1-1", 40)), [("1-1", 40)]) def test_skips_badge_without_top(self): html = '<span class="pointer-badge" style="left:2px;">1</span>' self.assertEqual(cba.badges_in(html), []) class TestOverlap(unittest.TestCase): def _check(self, html, tmp): p = tmp / "x_storyboard.html" p.write_text(html, encoding="utf-8") return cba.check(p) def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) def test_reports_badges_closer_than_badge_height(self): html = slide("09.1", [[("mock-body", [badge("1", 100), badge("2", 110)])]]) problems = self._check(html, self.tmp) self.assertTrue(any("겹친다" in p for p in problems), problems) def test_allows_badges_exactly_one_height_apart(self): html = slide("09.1", [[("mock-body", [badge("1", 100), badge("2", 124)])]]) self.assertEqual(self._check(html, self.tmp), []) class TestOrdering(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) def _check(self, html): p = self.tmp / "x_storyboard.html" p.write_text(html, encoding="utf-8") return cba.check(p) def test_reports_label_order_against_top_order(self): html = slide("09.1", [[("mock-body", [badge("1-2", 200), badge("1-3", 80)])]]) self.assertTrue(any("위에 있다" in p for p in self._check(html))) def test_negative_top_is_exempt(self): # 음수 top 은 헤더 영역을 가리키는 관용 패턴이라 라벨 순서와 어긋나도 정상이다. html = slide("09.1", [[("mock-body", [badge("1", 40), badge("2", -6)])]]) self.assertEqual(self._check(html), []) def test_different_containers_are_not_compared(self): # mock-footer 의 top:9px 는 mock-body 의 좌표계와 무관하다. html = slide("09.1", [[("mock-body", [badge("1", 40), badge("2", 200)]), ("mock-footer", [badge("3", 9)])]]) self.assertEqual(self._check(html), []) def test_different_mocks_are_not_compared(self): html = slide("09.1", [[("mock-body", [badge("1-1", 300)])], [("mock-body", [badge("2-1", 20)])]]) self.assertEqual(self._check(html), []) def test_clean_slide_reports_nothing(self): html = slide("09.1", [[("mock-body", [badge("1", 20), badge("2", 90), badge("3", 200)])]]) self.assertEqual(self._check(html), []) class TestHtmlCommentsIgnored(unittest.TestCase): def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) def test_comment_does_not_split_slide(self): html = ('<!-- ==== NO. 09.1 홈 ==== -->' + slide("09.1", [[("mock-body", [badge("1", 100), badge("2", 108)])]])) p = self.tmp / "x_storyboard.html" p.write_text(html, encoding="utf-8") self.assertTrue(any("겹친다" in x for x in cba.check(p))) class TestFooterPillContainer(unittest.TestCase): """mock-footer-pill 은 별도 좌표 원점 — body 배지와 순서·겹침을 비교하지 않는다 (#69).""" def setUp(self): import tempfile self.tmp = Path(tempfile.mkdtemp()) def _check(self, html): p = self.tmp / "x_storyboard.html" p.write_text(html, encoding="utf-8") return cba.check(p) def test_pill_badge_not_compared_with_body(self): html = slide("09.1", [[("mock-body", [badge("1-4", 205)]), ("mock-footer-pill", [badge("1-5", 10)])]]) self.assertEqual(self._check(html), []) -
test_export_deck.py 14.3 KB
"""export_deck.py 의 PPTX 조립 계약을 검증한다 (stdlib only). Chrome 을 띄우는 부분은 이 테스트의 대상이 아니다 — 캡처는 환경에 의존하고 느리다. 대신 순수 함수인 슬라이드 분해와 OOXML 조립을 검증한다. """ import re import sys import unittest import xml.etree.ElementTree as ET import zipfile from pathlib import Path from tempfile import TemporaryDirectory SKILL_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SKILL_ROOT / "scripts")) import export_deck # noqa: E402 PNG_1PX = bytes.fromhex( "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4" "890000000a49444154789c6360000002000100" "05fe02fe" "dccc59e7" "0000000049454e44ae426082" ) def storyboard(slide_count): slides = "".join( f'<div class="ppt-slide"><div class="ppt-top-bar">' f'<div class="ppt-top-no">NO. {i:02d}</div></div>' f'<div class="ppt-content"><div class="ppt-body-full">본문 {i}</div></div>' f'</div>' for i in range(1, slide_count + 1) ) return (f'<html><head><style>.x{{}}</style></head><body>' f'<div class="docwrap">{slides}</div></body></html>') class TestSlideSplit(unittest.TestCase): def test_splits_every_slide_in_document_order(self): slides = export_deck.split_slides(storyboard(4)) self.assertEqual([no for no, _ in slides], ["01", "02", "03", "04"]) def test_keeps_nested_divs_intact(self): _, block = export_deck.split_slides(storyboard(1))[0] self.assertIn("ppt-body-full", block) self.assertTrue(block.endswith("</div>")) # 여는 div 와 닫는 div 수가 같아야 블록이 온전하다 self.assertEqual(len(re.findall(r"<div\b", block)), len(re.findall(r"</div>", block))) def test_isolated_page_preserves_original_page_number(self): """Page No. 는 CSS counter 라 떼어내면 1부터 다시 센다.""" page = export_deck.isolated_page("<html><head></head>", "<div/>", 14) self.assertIn("counter-reset:slide 14", page) class TestPptxPackage(unittest.TestCase): def _build(self, count): tmp = TemporaryDirectory() self.addCleanup(tmp.cleanup) root = Path(tmp.name) shots = [] for i in range(1, count + 1): shot = root / f"image{i}.png" shot.write_bytes(PNG_1PX) shots.append(shot) dest = root / "deck.pptx" export_deck.build_pptx(shots, dest) return zipfile.ZipFile(dest) def test_relationship_types_use_the_officedocument_namespace(self): """관계 타입 URL 은 패키지 네임스페이스에서 파생시킬 수 없다. 둘을 문자열 조작으로 합치면 package/2006/officeDocument/2006/... 같은 무효 URL 이 나오는데 XML 은 여전히 well-formed 라 파싱 검사로는 잡히지 않는다. 실제로 그 버그가 있었고, 파일은 열리지 않는다. """ pkg = self._build(2) for name in pkg.namelist(): if not name.endswith(".rels"): continue for url in re.findall(r'Type="([^"]+)"', pkg.read(name).decode()): with self.subTest(part=name, url=url): self.assertTrue( url.startswith( "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"), f"관계 타입 URL 이 잘못됐다: {url}") def test_every_part_is_wellformed_xml(self): pkg = self._build(3) for name in pkg.namelist(): if name.endswith((".xml", ".rels")): with self.subTest(part=name): ET.fromstring(pkg.read(name)) def test_content_types_declares_every_slide(self): pkg = self._build(5) declared = pkg.read("[Content_Types].xml").decode() for i in range(1, 6): with self.subTest(slide=i): self.assertIn(f'PartName="/ppt/slides/slide{i}.xml"', declared) def test_each_slide_references_an_image_that_exists(self): pkg = self._build(4) names = set(pkg.namelist()) for i in range(1, 5): rels = pkg.read(f"ppt/slides/_rels/slide{i}.xml.rels").decode() targets = re.findall(r'Target="\.\./media/([^"]+)"', rels) with self.subTest(slide=i): self.assertEqual(len(targets), 1) self.assertIn(f"ppt/media/{targets[0]}", names) def test_presentation_lists_every_slide_with_matching_rels(self): pkg = self._build(6) pres = pkg.read("ppt/presentation.xml").decode() rels = pkg.read("ppt/_rels/presentation.xml.rels").decode() rids = re.findall(r'<p:sldId id="\d+" r:id="(rId\d+)"/>', pres) self.assertEqual(len(rids), 6) for rid in rids: with self.subTest(rid=rid): self.assertRegex(rels, f'Id="{rid}"[^>]*Target="slides/slide\\d+\\.xml"') def test_slide_size_is_16_by_9_widescreen(self): pres = self._build(1).read("ppt/presentation.xml").decode() self.assertIn(f'<p:sldSz cx="{export_deck.EMU_W}" cy="{export_deck.EMU_H}"/>', pres) self.assertAlmostEqual(export_deck.EMU_W / export_deck.EMU_H, 16 / 9, places=3) def test_image_fills_the_whole_slide(self): slide = self._build(1).read("ppt/slides/slide1.xml").decode() self.assertIn('<a:off x="0" y="0"/>', slide) self.assertIn(f'<a:ext cx="{export_deck.EMU_W}" cy="{export_deck.EMU_H}"/>', slide) class TestPrintCssFallback(unittest.TestCase): """인쇄 CSS 가 없는 예전 산출물도 A4 로 떨어져야 한다. #114 이전에 만든 문서에는 @page 가 없다. 그대로 인쇄하면 기본 용지(US Letter 세로)로 떨어지고 슬라이드가 페이지 경계에서 잘린다 — 46슬라이드 문서가 20페이지로 나온 실측 사례가 있다. """ def test_template_still_exposes_the_print_block(self): css = export_deck.template_print_css() self.assertIsNotNone(css, "템플릿에서 인쇄 CSS 를 찾지 못했다 — 표식이 바뀌었나") self.assertIn("@page", css) self.assertIn("size: A4 landscape", css) self.assertIn("@media print", css) def test_injected_css_lands_inside_the_style_block(self): css = export_deck.template_print_css() legacy = storyboard(2) self.assertNotIn("@media print", legacy) patched = legacy.replace("</style>", css + "</style>", 1) self.assertIn("@media print", patched) # 스타일 블록 안에 들어가야 유효하다 self.assertLess(patched.index("@page"), patched.index("</style>")) def test_print_css_is_not_duplicated_in_the_script(self): """인쇄 CSS 의 원본은 템플릿 한 곳이다.""" source = (SKILL_ROOT / "scripts" / "export_deck.py").read_text(encoding="utf-8") self.assertNotIn("size: A4 landscape", source) class TestParallelCapture(unittest.TestCase): """캡처는 병렬로 돈다 — 슬라이드끼리 파일을 공유하면 안 된다.""" def setUp(self): self.seen_pages = [] self.original = export_deck.run_chrome def fake_run_chrome(chrome, *args): # --screenshot=<경로> 와 file://<경로> 를 뜯어 기록하고 결과를 만든다 shot = next(a.split("=", 1)[1] for a in args if a.startswith("--screenshot=")) page = next(a[len("file://"):] for a in args if a.startswith("file://")) self.seen_pages.append((Path(page).name, Path(page).read_text(encoding="utf-8"))) Path(shot).write_bytes(PNG_1PX) export_deck.run_chrome = fake_run_chrome self.addCleanup(lambda: setattr(export_deck, "run_chrome", self.original)) def test_each_slide_writes_its_own_temp_page(self): """임시 HTML 을 한 파일에 덮어쓰면 병렬 실행에서 서로를 덮어쓴다.""" with TemporaryDirectory() as tmp: slides = export_deck.split_slides(storyboard(6)) head = "<html><head></head>" export_deck.shoot_slides("chrome", head, slides, Path(tmp), 2.0, jobs=4) names = [name for name, _ in self.seen_pages] self.assertEqual(len(names), 6) self.assertEqual(len(set(names)), 6, f"임시 파일 이름이 겹친다: {names}") def test_shots_come_back_in_slide_order(self): """as_completed 는 완료 순서라 결과를 순번으로 되돌려야 한다.""" with TemporaryDirectory() as tmp: slides = export_deck.split_slides(storyboard(8)) shots = export_deck.shoot_slides("chrome", "<html><head></head>", slides, Path(tmp), 2.0, jobs=4) self.assertEqual([s.name for s in shots], [f"image{i}.png" for i in range(1, 9)]) def test_each_page_carries_its_own_slide_content(self): with TemporaryDirectory() as tmp: slides = export_deck.split_slides(storyboard(5)) export_deck.shoot_slides("chrome", "<html><head></head>", slides, Path(tmp), 2.0, jobs=5) for name, body in self.seen_pages: index = int(re.search(r"_slide(\d+)\.html", name).group(1)) with self.subTest(page=name): self.assertIn(f"본문 {index}<", body) def test_default_jobs_leaves_headroom_and_is_bounded(self): jobs = export_deck.default_jobs() self.assertGreaterEqual(jobs, 1) self.assertLessEqual(jobs, 8) class TestDesignConstants(unittest.TestCase): def test_capture_width_matches_the_template_design_width(self): """목업 크기와 배지 top 이 이 폭에서 나온 절대 픽셀값이다.""" template = (SKILL_ROOT / "resources" / "template.html").read_text(encoding="utf-8") self.assertIn(f"max-width: {export_deck.DESIGN_W}px", template) def test_render_wait_is_long_enough_for_mermaid(self): """대기가 없으면 IA·흐름도·시퀀스가 빈 칸으로 나온다.""" self.assertGreaterEqual(export_deck.RENDER_WAIT_MS, 5000) DESC_SAMPLE = { "hasPanel": True, "slide": {"w": 1400, "h": 787.5}, "items": [ {"label": "1-1", "badge": {"x": 1216, "y": 142, "w": 29, "h": 21}, "text": {"x": 1254, "y": 142, "w": 130, "h": 49}, "lines": ["알림 아이콘", "미읽음 존재 시 적색 점", "탭: 알림 목록 (BIZ-ALERT-001)"], "boldFirst": True}, {"label": "2-1", "badge": {"x": 1216, "y": 300, "w": 29, "h": 21}, "text": {"x": 1254, "y": 300, "w": 130, "h": 33}, "lines": ["따옴표 & <꺾쇠> 검사"], "boldFirst": False}, ], } class TestEditableDesc(unittest.TestCase): """설명 패널을 PPT 도형으로 얹는 경로.""" def test_emu_scale_comes_from_the_slide_not_a_fixed_dpi(self): """고정 96dpi(9525 EMU/px)로 환산하면 도형이 슬라이드 밖으로 나간다. 슬라이드는 13.333in(96dpi 기준 1280px)인데 설계 폭은 1400px 이다. 9.4% 어긋나 오른쪽으로 밀리며, 실제로 그 버그가 있었다 (이슈 #125). """ self.assertEqual(export_deck._emu(export_deck.DESIGN_W), export_deck.EMU_W) self.assertEqual(export_deck._emu(export_deck.DESIGN_H), export_deck.EMU_H) self.assertNotAlmostEqual(export_deck.EMU_PER_PX, 914400 / 96, places=1) def test_each_item_yields_a_badge_and_a_text_shape(self): xml = export_deck.desc_shapes(DESC_SAMPLE, "0F4C81") self.assertEqual(xml.count("<p:sp>"), 4) self.assertIn('name="badge 1-1"', xml) self.assertIn('name="desc 1-1"', xml) self.assertIn("<a:srgbClr val=\"0F4C81\"/>", xml) def test_shapes_stay_inside_the_slide(self): xml = export_deck.desc_shapes(DESC_SAMPLE, "0F4C81") for off, ext in zip(re.findall(r'<a:off x="(\d+)" y="(\d+)"/>', xml), re.findall(r'<a:ext cx="(\d+)" cy="(\d+)"/>', xml)): with self.subTest(off=off): self.assertLessEqual(int(off[0]) + int(ext[0]), export_deck.EMU_W) self.assertLessEqual(int(off[1]) + int(ext[1]), export_deck.EMU_H) def test_first_line_is_bold_only_when_the_source_says_so(self): xml = export_deck.desc_shapes(DESC_SAMPLE, "0F4C81") first = xml[xml.index('name="desc 1-1"'):xml.index('name="badge 2-1"')] second = xml[xml.index('name="desc 2-1"'):] self.assertIn(f'sz="{export_deck.DESC_PT}" b="1"', first) self.assertNotIn(f'sz="{export_deck.DESC_PT}" b="1"', second) def test_markup_characters_are_escaped(self): xml = export_deck.desc_shapes(DESC_SAMPLE, "0F4C81") self.assertIn("&", xml) self.assertIn("<꺾쇠>", xml) ET.fromstring(f'<root xmlns:a="{export_deck.NS_A}" ' f'xmlns:p="{export_deck.NS_P}" xmlns:r="{export_deck.NS_R}">{xml}</root>') def test_no_panel_means_no_shapes(self): self.assertEqual(export_deck.desc_shapes(None, "0F4C81"), "") def test_blanking_css_hides_only_the_panel_body(self): """헤더까지 지우면 기준 산출물과 달라지고, display:none 이면 레이아웃이 밀린다.""" self.assertIn("ppt-desc-body", export_deck.DESC_BLANK_CSS) self.assertIn("visibility:hidden", export_deck.DESC_BLANK_CSS) self.assertNotIn("display:none", export_deck.DESC_BLANK_CSS) def test_accent_is_read_from_the_storyboard(self): self.assertEqual(export_deck.slide_accent(":root{--accent: #0f4c81;}"), "0F4C81") self.assertEqual(export_deck.slide_accent("accent 없음"), "1B64DA") def test_overlay_lands_on_its_own_slide(self): with TemporaryDirectory() as tmp: root = Path(tmp) shots = [] for i in range(1, 4): shot = root / f"image{i}.png" shot.write_bytes(PNG_1PX) shots.append(shot) dest = root / "deck.pptx" export_deck.build_pptx(shots, dest, {1: '<p:sp id="probe"/>'}) pkg = zipfile.ZipFile(dest) self.assertNotIn("probe", pkg.read("ppt/slides/slide1.xml").decode()) self.assertIn("probe", pkg.read("ppt/slides/slide2.xml").decode()) self.assertNotIn("probe", pkg.read("ppt/slides/slide3.xml").decode()) if __name__ == "__main__": unittest.main() -
test_layout_runtime.py 8.6 KB
"""check_layout_runtime.py 계약 테스트 (이슈 #137). 두 층으로 나뉜다. 1. 판정 로직 — probe 가 준 좌표를 위반으로 바꾸는 규칙. 브라우저 없이 항상 돈다. 임계값이 파이썬 한 곳에만 있기 때문에 가능하다. 2. 실제 렌더 — Chrome 이 있을 때만 돈다. 템플릿에서 만든 기준 문서를 그려 위반 0건인지 본다. CI 에서는 별도 job(.github/workflows/layout.yml)이 Chrome 을 보장하고, 로컬 stdlib 테스트에서는 조용히 skip 된다. """ import json import os import shutil import subprocess import sys import tempfile import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent SCRIPTS = SKILL_ROOT / "scripts" FIXTURE = SKILL_ROOT / "tests" / "fixtures" / "layout" / "baseline-slides.html" PROBE = SKILL_ROOT / "resources" / "layout-probe.js" sys.path.insert(0, str(SCRIPTS)) import check_layout_runtime as clr # noqa: E402 from export_deck import CHROME_CANDIDATES # noqa: E402 def rect(top, left, bottom, right): return {"top": top, "left": left, "bottom": bottom, "right": right} def slide(**kw): base = { "index": 0, "no": "NO. 09.1", "mermaid": 0, "slide": {"w": 1400, "h": 787.5, "overRight": 0, "overBottom": 0}, "containers": [], "panels": [], "items": [], } base.update(kw) return base def kinds(report, offline=True): return [v["kind"] for v in clr.judge(report, offline=offline)] class TestJudge(unittest.TestCase): def test_clean_document_has_no_violation(self): report = {"slides": [slide( containers=[{"kind": "mock-body", "rect": rect(0, 0, 600, 320), "clipped": False, "badges": [{"label": "1", **rect(20, 2, 44, 26)}, {"label": "2", **rect(170, 2, 194, 26)}]}], panels=[{"clientH": 500, "scrollH": 480}], items=[{"label": "1", **rect(0, 0, 60, 400)}, {"label": "2", **rect(76, 0, 130, 400)}], )]} self.assertEqual(clr.judge(report), []) def test_slide_overflow_detected(self): report = {"slides": [slide( slide={"w": 1400, "h": 787.5, "overRight": 0, "overBottom": 40})]} self.assertEqual(kinds(report), ["slide-overflow"]) def test_subpixel_overflow_is_not_a_regression(self): report = {"slides": [slide( slide={"w": 1400, "h": 787.5, "overRight": 1, "overBottom": 1})]} self.assertEqual(kinds(report), []) def test_badge_outside_its_container(self): report = {"slides": [slide(containers=[ {"kind": "mock-body", "rect": rect(0, 0, 600, 320), "clipped": False, "badges": [{"label": "9", **rect(590, 2, 614, 26)}]}])]} self.assertEqual(kinds(report), ["badge-outside"]) def test_badges_overlapping_each_other(self): report = {"slides": [slide(containers=[ {"kind": "mock-body", "rect": rect(0, 0, 600, 320), "clipped": False, "badges": [{"label": "1", **rect(20, 2, 44, 26)}, {"label": "2", **rect(30, 2, 54, 26)}]}])]} self.assertEqual(kinds(report), ["badge-overlap"]) def test_badges_in_different_containers_are_not_compared(self): """헤더와 본문은 좌표 원점이 달라 겹쳐 보여도 서로 다른 층이다.""" common = [{"label": "1", **rect(20, 2, 44, 26)}] report = {"slides": [slide(containers=[ {"kind": "mock-header", "rect": rect(0, 0, 51, 320), "clipped": False, "badges": common}, {"kind": "mock-body", "rect": rect(0, 0, 600, 320), "clipped": False, "badges": [dict(common[0])]}])]} self.assertEqual(kinds(report), []) def test_desc_panel_clipped(self): report = {"slides": [slide(panels=[{"clientH": 500, "scrollH": 640}])]} self.assertEqual(kinds(report), ["desc-clipped"]) def test_desc_items_overlapping(self): report = {"slides": [slide(items=[ {"label": "1", **rect(0, 0, 80, 400)}, {"label": "2", **rect(40, 0, 120, 400)}])]} self.assertEqual(kinds(report), ["desc-overlap"]) def test_mermaid_slide_skips_overflow_only_offline(self): report = {"slides": [slide( mermaid=1, slide={"w": 1400, "h": 787.5, "overRight": 0, "overBottom": 180})]} self.assertEqual(kinds(report, offline=True), []) self.assertEqual(kinds(report, offline=False), ["slide-overflow"]) class TestNetworkStripping(unittest.TestCase): def test_external_script_and_font_import_removed(self): html = ('<html><head><style>@import url("https://fonts.example/x.css");' 'body{color:red}</style>' '<script src="https://cdn.example/mermaid.min.js"></script></head>' '<body><div class="mermaid">graph TD;</div></body></html>') out = clr.strip_network(html) self.assertNotIn("https://", out) self.assertIn(".mermaid{display:none !important;}", out) self.assertIn("body{color:red}", out, "본문 CSS 까지 지우면 안 된다") def build_fixture(target_dir): """template.html 의 head 로 기준 문서를 조립한다. head 는 커밋하지 않는다.""" out = Path(target_dir) / "layout-baseline.html" subprocess.run( [sys.executable, str(SCRIPTS / "scaffold.py"), str(out), "--project", "Layout Fixture", "--version", "1.0.0"], check=True, capture_output=True, text=True) from scaffold import INSERT_MARKER # noqa: E402 scaffold 와 삽입 규약을 공유한다 html = out.read_text(encoding="utf-8") if INSERT_MARKER not in html: raise AssertionError("scaffold 산출물에 삽입 마커가 없다") out.write_text( html.replace(INSERT_MARKER, FIXTURE.read_text(encoding="utf-8") + "\n" + INSERT_MARKER, 1), encoding="utf-8") return out def chrome_available(): for cand in (os.environ.get("CHROME"), *CHROME_CANDIDATES): if cand and (Path(cand).is_file() or shutil.which(cand)): return True return False @unittest.skipUnless(chrome_available(), "Chrome 이 없어 렌더 검사를 건너뛴다") class TestRenderedBaseline(unittest.TestCase): def test_baseline_document_has_no_layout_violation(self): with tempfile.TemporaryDirectory() as work: doc = build_fixture(work) dump = Path(work) / "measured.json" code = clr.main([str(doc), "--json", str(dump)]) measured = json.loads(dump.read_text(encoding="utf-8"))[str(doc)] self.assertTrue(measured["slides"], "슬라이드를 하나도 못 읽었다") self.assertEqual(code, 0, "기준 문서에서 레이아웃 위반이 나왔다") def test_broken_css_is_caught(self): """검사기가 실제로 회귀를 잡는지 본다 — 통과만 확인하면 무의미하다. 목업을 200px 키우면 슬라이드를 넘고, 배지 gutter 를 없애면 배지가 본문 텍스트 위로 올라온다. 전자는 slide-overflow 로 잡혀야 한다. """ with tempfile.TemporaryDirectory() as work: doc = build_fixture(work) broken = Path(work) / "broken.html" broken.write_text( doc.read_text(encoding="utf-8").replace( "</style>", ".mock{height:894px !important;}</style>", 1), encoding="utf-8") self.assertEqual(clr.main([str(broken)]), 1, "깨진 레이아웃을 통과시켰다") report = clr.measure(clr.find_chrome(), broken) self.assertIn("slide-overflow", [v["kind"] for v in clr.judge(report)]) def test_probe_reports_badges_and_items(self): with tempfile.TemporaryDirectory() as work: doc = build_fixture(work) report = clr.measure(clr.find_chrome(), doc) first = report["slides"][0] badges = [b["label"] for c in first["containers"] for b in c["badges"]] self.assertEqual(sorted(badges), ["1", "2", "3"]) self.assertEqual([i["label"] for i in first["items"]], ["1", "2", "3"]) class TestProbeShape(unittest.TestCase): def test_probe_is_a_single_expression_snippet(self): """주입은 `const r=<snippet>` 형태다 — 세미콜론으로 끝나면 문법이 깨진다.""" text = PROBE.read_text(encoding="utf-8").strip() self.assertTrue(text.endswith("})();"), "IIFE 로 끝나야 한다") self.assertIn("return {", text, "측정값을 반환하지 않는다") if __name__ == "__main__": unittest.main() -
test_mock_content.py 4.5 KB
"""목업 본문 실질 검증(check_mock_content 등) 테스트 (stdlib only) — 이슈 #75. 임계값은 tests/fixtures/runtime-parity 의 세 런타임 실측 산출물로 캘리브레이션됐다. 회귀 기준: claude 는 위반 0건 유지, codex·agy 는 잡힌다. """ import importlib.util import sys import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent FIXTURES = Path(__file__).resolve().parent / "fixtures" / "runtime-parity" spec = importlib.util.spec_from_file_location( "validate_storyboard", SKILL_ROOT / "scripts" / "validate_storyboard.py") vs = importlib.util.module_from_spec(spec) spec.loader.exec_module(vs) def check_fixture(name): css = vs.extract_style(vs.TEMPLATE.read_text(encoding="utf-8")) return vs.check(FIXTURES / name, css) class TestRuntimeParityRegression(unittest.TestCase): """세 런타임 실측 산출물 회귀 기준선.""" def test_claude_stays_clean(self): violations, _ = check_fixture("claude.html") self.assertEqual(violations, [], "claude 산출물에서 오탐 — 하한값이 과하다는 뜻이다") def test_agy_placeholder_and_density_caught(self): violations, _ = check_fixture("agy.html") text = "\n".join(violations) self.assertIn("자리표시자", text) self.assertIn("데이터 신호", text) def test_codex_retype_and_boilerplate_caught(self): violations, _ = check_fixture("codex.html") text = "\n".join(violations) self.assertIn("재탕", text) self.assertIn("시퀀스가 사실상 동일", text) class TestMockContentUnits(unittest.TestCase): DETAIL = ( '<div class="ppt-top-no">NO. 09.1</div>' '<div class="ppt-top-title">홈</div>' '{body}' ) def _violations(self, body): v, _ = vs.check_mock_content(self.DETAIL.format(body=body)) return "\n".join(v) def test_placeholder_flagged(self): body = ('<div class="mock-body" style="x">Mockup Content for 홈</div>' '<div class="mock-caption">홈 (AA-MAIN-001)</div>') self.assertIn("자리표시자", self._violations(body)) def test_badge_labels_are_not_data_signals(self): # 배지 라벨(1-1, 2-3)은 숫자지만 목업 데이터가 아니다 body = ('<div class="mock-body">' '<span class="pointer-badge" style="top:20px;">1-1</span>' '<span class="pointer-badge" style="top:80px;">2-3</span>' '내용 없는 화면</div>') text = vs.mock_body_text(body) self.assertEqual(vs.NUM_SIGNAL_RE.findall(text), []) def test_inline_style_values_are_not_text(self): body = ('<div class="mock-body" style="padding:16px;">' '<div style="height:48px; top:12px;">로그인</div></div>') text = vs.mock_body_text(body) self.assertEqual(vs.NUM_SIGNAL_RE.findall(text), []) def test_tab_arrow_retype_flagged(self): body = ('<div class="mock-body">계좌 요약 카드 탭 › 알림 배지 탭 › 이동</div>') self.assertIn("재탕", self._violations(body)) def test_normal_dense_mock_passes(self): body = ('<div class="mock-body">총자산 24,580,000원 오늘 손익 +1.2% ' '2026-08-01 기준</div>' '<ul><li><span class="desc-num">1</span> <div><b>총자산 카드</b>' '<br>읽기 전용</div></li></ul>') self.assertEqual(self._violations(body), "") class TestSequenceBoilerplate(unittest.TestCase): def _slide(self, no, mermaid): return (f'<div class="ppt-top-no">NO. {no}</div>' f'<div class="mermaid">sequenceDiagram\n{mermaid}</div></div>') def test_identical_sequences_flagged(self): same = "사용자->>화면: 검증된 요청\n화면->>서버: 처리 결과\n서버-->>화면: 외부시스템 응답" markup = self._slide("07.1", same) + self._slide("07.2", same) self.assertTrue(vs.check_sequence_boilerplate(markup)) def test_distinct_sequences_pass(self): a = "사용자->>가입: 약관 동의 후 계정 생성\n가입->>인증기관: 인증번호 발송" b = "스케줄러->>서버: 경보 감지\n서버->>푸시: 배치 발송 요청\n푸시-->>사용자: 수신" markup = self._slide("07.1", a) + self._slide("07.2", b) self.assertEqual(vs.check_sequence_boilerplate(markup), []) if __name__ == "__main__": sys.exit(unittest.main()) -
test_rules.py 16.5 KB
"""validate_storyboard.py 의 Business Rules 판정 단위 테스트 (stdlib only).""" import sys import tempfile import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SKILL_ROOT / "scripts")) import validate_storyboard as vs def section(sid, name="화면", extra_body=""): """네 필수 헤딩을 모두 갖춘 유효한 섹션 마크다운을 만든다.""" return ( f"## {sid} {name}\n\n" "### 입력 검증\n해당 없음 — 조회 전용 화면.\n\n" "### 출력 규칙\n| 상태 | 표시 |\n|---|---|\n| 로딩 | 스켈레톤 |\n\n" "### 인터랙션\n해당 없음 — 정적 화면.\n\n" f"### 엣지케이스\n- 네트워크 오류 시 재시도 배너.\n{extra_body}\n" ) class TestRulesPathFor(unittest.TestCase): def test_storyboard_suffix_is_swapped(self): self.assertEqual( vs.rules_path_for("/tmp/petshop_storyboard.html").name, "petshop_business-rules.md") def test_non_contract_name_uses_stem(self): self.assertEqual( vs.rules_path_for("/tmp/output.html").name, "output_business-rules.md") class TestRulesSections(unittest.TestCase): def test_splits_sections_by_id_heading(self): md = section("DTC-MAIN-001") + section("DTC-BOARD-001") ids = [sid for sid, _ in vs.rules_sections(md)] self.assertEqual(ids, ["DTC-MAIN-001", "DTC-BOARD-001"]) def test_heading_without_id_is_not_a_section(self): md = "## 개요\n내용\n" + section("DTC-MAIN-001") ids = [sid for sid, _ in vs.rules_sections(md)] self.assertEqual(ids, ["DTC-MAIN-001"]) class TestCheckRules(unittest.TestCase): def test_valid_doc_has_no_violations(self): md = section("DTC-MAIN-001") violations, info = vs.check_rules(md, {"DTC-MAIN-001"}) self.assertEqual(violations, []) self.assertTrue(any("섹션 1개" in line for line in info)) def test_missing_screen_section_is_a_violation(self): md = section("DTC-MAIN-001") violations, _ = vs.check_rules(md, {"DTC-MAIN-001", "DTC-BOARD-001"}) self.assertTrue(any("DTC-BOARD-001" in v and "섹션이 없는" in v for v in violations)) def test_extra_section_is_a_violation(self): md = section("DTC-MAIN-001") + section("DTC-GHOST-999") violations, _ = vs.check_rules(md, {"DTC-MAIN-001"}) self.assertTrue(any("DTC-GHOST-999" in v and "정의되지 않은" in v for v in violations)) def test_duplicate_section_is_a_violation(self): md = section("DTC-MAIN-001") + section("DTC-MAIN-001") violations, _ = vs.check_rules(md, {"DTC-MAIN-001"}) self.assertTrue(any("중복" in v for v in violations)) def test_missing_required_heading_is_a_violation(self): md = ("## DTC-MAIN-001 홈\n\n### 입력 검증\n내용\n\n" "### 출력 규칙\n내용\n\n### 인터랙션\n내용\n") violations, _ = vs.check_rules(md, {"DTC-MAIN-001"}) self.assertTrue(any("엣지케이스" in v and "누락" in v for v in violations)) def test_empty_required_heading_is_a_violation(self): md = ("## DTC-MAIN-001 홈\n\n### 입력 검증\n\n" "### 출력 규칙\n내용\n\n### 인터랙션\n내용\n\n" "### 엣지케이스\n내용\n") violations, _ = vs.check_rules(md, {"DTC-MAIN-001"}) self.assertTrue(any("입력 검증" in v and "내용 없는" in v for v in violations)) def test_dangling_reference_is_a_violation(self): md = section("DTC-MAIN-001", extra_body="- 탭 시 글 상세로 이동 (DTC-BOARD-002).") violations, _ = vs.check_rules(md, {"DTC-MAIN-001"}) self.assertTrue(any("DTC-BOARD-002" in v and "참조" in v for v in violations)) def test_reference_to_defined_id_is_ok(self): md = (section("DTC-MAIN-001", extra_body="- 탭 시 게시판으로 이동 (DTC-BOARD-001).") + section("DTC-BOARD-001")) violations, _ = vs.check_rules( md, {"DTC-MAIN-001", "DTC-BOARD-001"}) self.assertEqual(violations, []) class TestRuleIds(unittest.TestCase): def test_valid_rule_ids_pass(self): md = """## DTC-MAIN-001 홈 ### 입력 검증 해당 없음 — 조회 전용. ### 출력 규칙 | 상태 | 표시 | |---|---| | DTC-MAIN-001.OUT-01 · 로딩 | 스켈레톤 | ### 인터랙션 해당 없음 — 정적 화면. ### 엣지케이스 - DTC-MAIN-001.EDGE-01 — 네트워크 오류 시 재시도. """ self.assertEqual(vs.rule_id_violations(vs.rules_sections(md)), []) def test_missing_duplicate_and_mismatched_ids_fail(self): md = """## DTC-MAIN-001 홈 ### 입력 검증 해당 없음 — 조회 전용. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 스켈레톤 | ### 인터랙션 - DTC-OTHER-001.OUT-01 — 잘못된 화면과 구분. ### 엣지케이스 - DTC-MAIN-001.EDGE-01 — A - DTC-MAIN-001.EDGE-01 — B """ violations = vs.rule_id_violations(vs.rules_sections(md)) joined = "\n".join(violations) self.assertIn("규칙 ID 누락", joined) self.assertIn("화면 불일치", joined) self.assertIn("구분 불일치", joined) self.assertIn("중복 규칙 ID", joined) class TestCheckIntegration(unittest.TestCase): """check() 가 storyboard 와 rules 파일 짝을 함께 판정하는지 확인한다.""" HTML = ( '<div class="ppt-slide"><div class="ppt-top-no">NO. 05</div>' '<div class="ppt-top-title">Screen List</div>' '<table><tr><td>ID</td><td>유형</td></tr>' '<tr><td>DTC-MAIN-001</td><td>홈</td><td>화면</td></tr></table></div>' '<div class="ppt-slide"><div class="ppt-top-no">NO. 06</div>' '<div class="ppt-top-title">Service Flow</div>' '<div class="mermaid">flowchart LR\n' 'A["홈<br/>DTC-MAIN-001"]</div></div>' '<div class="ppt-slide"><div class="ppt-top-no">NO. 07.1</div>' '<div class="ppt-top-title">Sequence — 제출</div>' '<div class="mermaid">sequenceDiagram\n' 'participant M as 홈 (DTC-MAIN-001)\nM->>S: 제출</div></div>' '<div class="ppt-slide"><div class="ppt-top-no">NO. 09.1</div>' '<div class="ppt-top-title">홈</div>' '<div class="ppt-meta-value">홈</div>' '<div class="ppt-meta-id">DTC-MAIN-001</div></div>' '<script src="mermaid.min.js"></script>' ) CSS = (".ppt-slide{} .ppt-top-no{} .ppt-top-title{} " ".ppt-meta-value{} .ppt-meta-id{}") def _run(self, write_rules): with tempfile.TemporaryDirectory() as td: html_path = Path(td) / "dtc_storyboard.html" html_path.write_text(self.HTML, encoding="utf-8") if write_rules: (Path(td) / "dtc_business-rules.md").write_text( section("DTC-MAIN-001"), encoding="utf-8") return vs.check(str(html_path), self.CSS) def test_missing_rules_file_is_a_violation(self): violations, _ = self._run(write_rules=False) self.assertTrue(any("Business Rules 문서 없음" in v for v in violations)) def test_paired_rules_file_passes(self): violations, _ = self._run(write_rules=True) self.assertEqual(violations, []) class TestCaptionMismatch(unittest.TestCase): """모든 목업에 mock-caption 필수 판정 (이슈 #37 렌더 피드백).""" def test_single_mock_without_caption_is_flagged(self): html = ('<div class="ppt-top-no">NO. 09.1</div>' '<div class="mock"><div class="mock-screen"></div></div>') self.assertEqual(vs.caption_mismatch(html), [("09.1", 1, 0)]) def test_captioned_mocks_pass(self): html = ('<div class="ppt-top-no">NO. 09.1</div>' '<div class="mock"><div class="mock-caption">홈 (TC-MAIN-001)</div></div>' '<div class="mock mock-partial">' '<div class="mock-caption">팝업 (TC-MAIN-101)</div></div>') self.assertEqual(vs.caption_mismatch(html), []) def test_hyphen_classes_are_not_counted_as_mocks(self): html = ('<div class="ppt-top-no">NO. 09.1</div>' '<div class="mock-screen"></div><div class="mock-body"></div>') self.assertEqual(vs.caption_mismatch(html), []) def test_circled_desc_num_is_flagged(self): html = ('<div class="ppt-top-no">NO. 09.1</div>' '<div class="mock"><div class="mock-caption">홈 (TC-MAIN-001)</div></div>') # 원문자 판정은 check() 내부 정규식과 동일한 패턴을 직접 확인 import re as _re self.assertTrue(_re.findall( r'class="desc-num"[^>]*>([^<]*[\u2460-\u2473][^<]*)<', '<span class="desc-num">①</span>')) self.assertFalse(_re.findall( r'class="desc-num"[^>]*>([^<]*[\u2460-\u2473][^<]*)<', '<span class="desc-num">1-1</span>')) class TestEventCoverage(unittest.TestCase): """이벤트 표기 커버리지 판정 (이슈 #43).""" def slide(self, no, items): lis = "".join(f'<li><span class="desc-num">{i+1}</span> <div>{x}</div></li>' for i, x in enumerate(items)) return (f'<div class="ppt-top-no">NO. {no}</div>' f'<ul class="desc-list">{lis}</ul>') def test_slide_with_event_label_counts(self): html = self.slide("09.1", ["<b>배너</b><br>탭: 공지로 이동 (DTC-NOTICE-001)", "<b>로고</b><br>정적 표시"]) self.assertEqual(vs.event_coverage(html), [("09.1", 1, 2)]) def test_slide_without_any_event_label(self): html = self.slide("09.1", ["<b>배너</b><br>주요 속보 롤링"]) self.assertEqual(vs.event_coverage(html), [("09.1", 0, 1)]) def test_slide_without_items_is_not_flagged(self): html = '<div class="ppt-top-no">NO. 09.1</div><div>목업만</div>' self.assertEqual(vs.event_coverage(html), [("09.1", 0, 0)]) def test_swipe_and_input_labels_count(self): html = self.slide("09.2", ["<b>탭바</b><br>스와이프: 인접 탭 이동", "<b>검색</b><br>입력: 1~50자 실시간 필터"]) self.assertEqual(vs.event_coverage(html), [("09.2", 2, 2)]) class TestScreenListTypes(unittest.TestCase): """05 Screen List 유형 정합 판정 (이슈 #41).""" DETAIL = ('<div class="ppt-top-no">NO. 09.1</div>' '<div class="ppt-meta-id">DTC-MAIN-001</div>') def slide05(self, rows): return (f'<div class="ppt-top-no">NO. 05</div>' f'<div class="ppt-top-title">Screen List</div><table>{rows}</table>') def test_typed_and_drawn_screen_passes(self): html = self.slide05('<tr><td>DTC-MAIN-001</td><td>홈</td><td>화면</td></tr>') + self.DETAIL self.assertEqual(vs.check_screen_list_types(html), []) def test_row_without_type_is_flagged(self): html = self.slide05('<tr><td>DTC-MAIN-001</td><td>홈</td></tr>') + self.DETAIL self.assertTrue(any("유형" in v and "DTC-MAIN-001" in v for v in vs.check_screen_list_types(html))) def test_screen_typed_but_undrawn_is_flagged(self): html = (self.slide05('<tr><td>DTC-MAIN-001</td><td>홈</td><td>화면</td></tr>' '<tr><td>DTC-MYPAGE-001</td><td>내 정보</td><td>화면</td></tr>') + self.DETAIL) self.assertTrue(any("09.x 에 정의되지 않은" in v and "DTC-MYPAGE-001" in v for v in vs.check_screen_list_types(html))) def test_popup_row_is_not_required_to_have_slide(self): html = (self.slide05('<tr><td>DTC-AUTH-101</td><td>로그인 유도 팝업</td><td>팝업</td></tr>') + self.DETAIL) self.assertEqual(vs.check_screen_list_types(html), []) def test_bottomsheet_description_mentioning_screen_is_not_misread(self): """설명에 '화면' 이 섞여도 바텀시트 행이 화면으로 오판되지 않는다.""" html = (self.slide05('<tr><td>DTC-DOC-101</td><td>서류 등록</td><td>바텀시트</td>' '<td>화면 하단에서 올라옴</td></tr>') + self.DETAIL) self.assertEqual(vs.check_screen_list_types(html), []) def test_no_screen_list_slide_defers_to_overview_check(self): self.assertEqual(vs.check_screen_list_types(self.DETAIL), []) class TestSequenceSlides(unittest.TestCase): """07.x Sequence Diagram 판정 (이슈 #39).""" def slide(self, no, body): return (f'<div class="ppt-top-no">NO. {no}</div>' f'<div class="ppt-top-title">t</div>{body}') def test_valid_sequence_passes(self): html = self.slide("07.1", '<div class="mermaid">sequenceDiagram\n' 'participant V as 투표 (DTC-VOTE-001)</div>') self.assertEqual(vs.check_sequence_slides(html), []) def test_missing_sequence_slide_is_flagged(self): html = self.slide("06", '<div class="mermaid">flowchart LR\nA</div>') self.assertTrue(any("07.x Sequence Diagram 슬라이드 없음" in v for v in vs.check_sequence_slides(html))) def test_sequence_without_diagram_is_flagged(self): html = self.slide("07.1", "<div>텍스트 설명뿐</div>") self.assertTrue(any("07.1" in v and "sequenceDiagram 이 없다" in v for v in vs.check_sequence_slides(html))) def test_sequence_without_screen_id_is_flagged(self): html = self.slide("07.1", '<div class="mermaid">sequenceDiagram\nA->>B: x</div>') self.assertTrue(any("화면 ID 가 없다" in v for v in vs.check_sequence_slides(html))) def test_flowchart_mermaid_does_not_satisfy(self): html = self.slide("07.1", '<div class="mermaid">flowchart LR\n' 'A["DTC-MAIN-001"]</div>') self.assertTrue(any("sequenceDiagram 이 없다" in v for v in vs.check_sequence_slides(html))) class TestOverviewSlides(unittest.TestCase): """05 Screen List · 06 Service Flow 판정 (이슈 #37).""" IDS = {"DTC-MAIN-001", "DTC-BOARD-001"} def slide(self, no, body): return (f'<div class="ppt-top-no">NO. {no}</div>' f'<div class="ppt-top-title">t</div>{body}') def test_valid_slides_pass(self): html = (self.slide("05", "<div>DTC-MAIN-001 홈 / DTC-BOARD-001 게시판</div>") + self.slide("06", '<div class="mermaid">flowchart LR\n' 'A["홈 DTC-MAIN-001"] --> B["게시판 DTC-BOARD-001"]</div>')) self.assertEqual(vs.check_overview_slides(html, self.IDS), []) def test_missing_screen_list_slide(self): html = self.slide("06", '<div class="mermaid">DTC-MAIN-001</div>') self.assertTrue(any("05 Screen List 슬라이드 없음" in v for v in vs.check_overview_slides(html, self.IDS))) def test_screen_list_missing_id(self): html = (self.slide("05", "<div>DTC-MAIN-001 홈</div>") + self.slide("06", '<div class="mermaid">DTC-MAIN-001</div>')) self.assertTrue(any("DTC-BOARD-001" in v and "05 Screen List" in v for v in vs.check_overview_slides(html, self.IDS))) def test_missing_flow_slide(self): html = self.slide("05", "<div>DTC-MAIN-001 DTC-BOARD-001</div>") self.assertTrue(any("06 Service Flow 슬라이드 없음" in v for v in vs.check_overview_slides(html, self.IDS))) def test_flow_without_mermaid(self): html = (self.slide("05", "<div>DTC-MAIN-001 DTC-BOARD-001</div>") + self.slide("06", "<div>텍스트 흐름 설명 DTC-MAIN-001</div>")) self.assertTrue(any("mermaid 흐름도가 없다" in v for v in vs.check_overview_slides(html, self.IDS))) def test_flow_without_ids(self): html = (self.slide("05", "<div>DTC-MAIN-001 DTC-BOARD-001</div>") + self.slide("06", '<div class="mermaid">flowchart LR\nA --> B</div>')) self.assertTrue(any("화면 ID 가 없다" in v for v in vs.check_overview_slides(html, self.IDS))) def test_sub_numbered_slide_is_not_slide_05(self): """09.5 같은 하위 번호가 05 로 오인되지 않는다.""" self.assertIsNone(vs.slide_body(self.slide("09.5", "<div>x</div>"), "05")) if __name__ == "__main__": unittest.main() -
test_ruleset.py 4 KB
"""규칙 세트 버전·2단 출력 테스트 (stdlib only) — 이슈 #77.""" import importlib.util import sys import tempfile import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent spec = importlib.util.spec_from_file_location( "validate_storyboard", SKILL_ROOT / "scripts" / "validate_storyboard.py") vs = importlib.util.module_from_spec(spec) spec.loader.exec_module(vs) scaffold_spec = importlib.util.spec_from_file_location( "scaffold", SKILL_ROOT / "scripts" / "scaffold.py") scaffold = importlib.util.module_from_spec(scaffold_spec) scaffold_spec.loader.exec_module(scaffold) RULES = """# T Business Rules Version: 1.0.0 ## TT-MAIN-001 홈 ### 입력 검증 해당 없음 — 조회 전용. ### 출력 규칙 | 상태 | 표시 | |---|---| | 로딩 | 스켈레톤 | ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 행 탭 | - | 상태 갱신 | ### 엣지케이스 - 없음에 준함. """ #: 인터랙션 표 배지 인용 없음(v2 규칙) 위반이 하나 나오는 최소 문서. def storyboard(meta): return ( f'{meta}' '<div class="ppt-top-no">NO. 05</div><div class="ppt-top-title">Screen List</div>' '<table><tr><td>TT-MAIN-001</td><td>홈</td><td>화면</td><td>홈</td><td>-</td></tr></table>' '<div class="ppt-top-no">NO. 06</div><div class="ppt-top-title">Service Flow</div>' '<div class="mermaid">flowchart LR\nA["홈 TT-MAIN-001"]</div>' '<div class="ppt-top-no">NO. 07.1</div><div class="ppt-top-title">Sequence</div>' '<div class="mermaid">sequenceDiagram\nparticipant M as 홈 (TT-MAIN-001)\nM->>S: 제출</div>' '<div class="ppt-top-no">NO. 09.1</div><div class="ppt-top-title">홈</div>' '<div class="ppt-meta-value">홈</div><div class="ppt-meta-id">TT-MAIN-001</div>' '<div class="mock-body">잔액 1,000원 2건</div>' '<div class="mock-caption">홈 (TT-MAIN-001)</div>' '<span class="pointer-badge" style="top:10px; left:2px;">1</span>' '<span class="desc-num">1</span><li>행 목록 탭: 갱신</li>' '<script src="mermaid.min.js"></script>') def run_check(meta, strict=False): css = ".ppt-slide{}" with tempfile.TemporaryDirectory() as td: html_path = Path(td) / "t_storyboard.html" html_path.write_text(storyboard(meta), encoding="utf-8") (Path(td) / "t_business-rules.md").write_text(RULES, encoding="utf-8") return vs.check(str(html_path), css, strict=strict) class TestRulesetTiering(unittest.TestCase): def test_legacy_doc_demotes_v2_rules_to_advisory(self): violations, info = run_check(meta="") self.assertFalse(any("배지 번호 인용" in v for v in violations), "메타 없는 옛 문서에서 v2 규칙이 위반으로 집계됐다") self.assertTrue(any("신규 규칙 위반" in x for x in info), info) def test_strict_enforces_all(self): violations, _ = run_check(meta="", strict=True) self.assertTrue(any("배지 번호 인용" in v for v in violations)) def test_current_doc_enforces_v2(self): violations, _ = run_check(meta='<meta name="skill-ruleset" content="2">') self.assertTrue(any("배지 번호 인용" in v for v in violations)) self.assertFalse(any("규칙 ID" in v for v in violations), "v2 문서에는 v3 규칙이 기본 위반이면 안 된다") def test_doc_ruleset_parse(self): self.assertEqual(vs.doc_ruleset('<meta name="skill-ruleset" content="2">'), 2) self.assertEqual(vs.doc_ruleset("<head></head>"), 1) class TestScaffoldMeta(unittest.TestCase): def test_scaffold_embeds_current_ruleset(self): html = scaffold.build(scaffold.TEMPLATE.read_text(encoding="utf-8"), project="티", version="1.0.0") self.assertIn(f'<meta name="skill-ruleset" content="{vs.RULESET_VERSION}">', html) if __name__ == "__main__": sys.exit(unittest.main()) -
test_scaffold.py 3.7 KB
"""scaffold.py 단위 테스트 (stdlib only).""" import sys import tempfile import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SKILL_ROOT / "scripts")) import scaffold # noqa: E402 import validate_storyboard as vs # noqa: E402 class TestBuild(unittest.TestCase): @classmethod def setUpClass(cls): cls.template = scaffold.TEMPLATE.read_text(encoding="utf-8") def build(self, **kw): kw.setdefault("project", "테스트몰") kw.setdefault("version", "1.0.0") return scaffold.build(self.template, **kw) def test_carries_mermaid_runtime(self): # 이게 빠지면 IA·흐름도가 원문 텍스트로 남는다 (검증기가 위반으로 잡는 항목). self.assertIn("mermaid.min.js", self.build()) def test_carries_trace_interaction_runtime(self): html = self.build() self.assertIn("is-trace-active", html) self.assertIn("pointerenter", html) def test_carries_full_style_block(self): html = self.build() css = vs.extract_style(html) for name in ("ppt-slide", "pointer-badge", "mock-caption", "mock-partial"): with self.subTest(name=name): self.assertIn(name, vs.defined_classes(css)) def test_class_contract_matches_template(self): # 뼈대의 CSS 가 템플릿과 같은 클래스 집합을 정의해야 계약이 성립한다. self.assertEqual( vs.defined_classes(vs.extract_style(self.build())), vs.defined_classes(vs.extract_style(self.template)), ) def test_title_uses_project_name(self): self.assertIn("<title>테스트몰 화면설계서</title>", self.build()) def test_accent_override(self): html = self.build(accent="#1b64da", accent_ink="#ffffff") self.assertIn("--accent: #1b64da;", html) self.assertIn("--accent-ink: #ffffff;", html) def test_accent_defaults_to_template_value(self): self.assertIn("--accent: #ea580c;", self.build()) def test_has_empty_docwrap_and_insert_marker(self): html = self.build() self.assertIn('<div class="docwrap">', html) self.assertIn(scaffold.INSERT_MARKER, html) def test_no_slides_yet(self): # CSS 에는 클래스 정의가 있으므로 마크업만 걷어내고 확인한다. self.assertNotIn('class="ppt-top-no"', vs.markup_only(self.build())) def test_leaves_no_placeholder(self): # 검증기는 남은 {{ }} 를 위반으로 잡는다. self.assertNotIn("{{", self.build()) class TestMain(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp()) def test_writes_file_and_returns_zero(self): out = self.tmp / "몰_storyboard.html" self.assertEqual(scaffold.main([str(out), "--project", "몰"]), 0) self.assertTrue(out.exists()) def test_refuses_to_overwrite_without_force(self): out = self.tmp / "a_storyboard.html" out.write_text("기존 산출물", encoding="utf-8") self.assertEqual(scaffold.main([str(out), "--project", "A"]), 2) self.assertEqual(out.read_text(encoding="utf-8"), "기존 산출물") def test_force_overwrites(self): out = self.tmp / "a_storyboard.html" out.write_text("기존 산출물", encoding="utf-8") self.assertEqual(scaffold.main([str(out), "--project", "A", "--force"]), 0) self.assertIn("docwrap", out.read_text(encoding="utf-8")) def test_creates_missing_parent_directory(self): out = self.tmp / "docs" / "a_storyboard.html" self.assertEqual(scaffold.main([str(out), "--project", "A"]), 0) self.assertTrue(out.exists()) if __name__ == "__main__": unittest.main() -
test_template.py 3.9 KB
"""template.html 의 목업 헤더 배지 계약 테스트 (stdlib only) — 이슈 #71. mock-header 는 배지의 좌표 원점(position:relative)이어야 하고, 배지가 있는 헤더에는 mock-body 와 같은 gutter(단일 28px / 복수 34px)가 자동 부여되어야 한다. gutter 가 없으면 left:2px 배지가 헤더 제목 첫 글자를 가린다. """ import re import sys import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent TEMPLATE = SKILL_ROOT / "resources" / "template.html" SKILL_MD = SKILL_ROOT / "SKILL.md" def css(): match = re.search(r"<style>(.*?)</style>", TEMPLATE.read_text(encoding="utf-8"), re.DOTALL) if not match: raise RuntimeError("template.html 에 <style> 블록이 없다") return match.group(1) def rule_body(style, selector): m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", style) return m.group(1) if m else None class TestHeaderBadgeContract(unittest.TestCase): def test_mock_header_is_positioning_origin(self): body = rule_body(css(), ".mock-header") self.assertIsNotNone(body, ".mock-header 규칙이 없다") self.assertIn("position: relative", body, "mock-header 가 배지의 좌표 원점(position:relative)이 아니다") def test_mock_footer_is_positioning_origin(self): """푸터도 배지의 좌표 원점이다 (이슈 #137). 없으면 푸터 안의 pointer-badge 가 .mock 기준으로 떠서 화면 최상단에 찍힌다 — 마크업은 멀쩡해 정적 검사로는 안 잡히고 렌더해야 보인다. """ body = rule_body(css(), ".mock-footer") self.assertIsNotNone(body, ".mock-footer 규칙이 없다") self.assertIn("position: relative", body, "mock-footer 가 배지의 좌표 원점(position:relative)이 아니다") def test_footer_gutter_for_badges(self): body = rule_body(css(), ".mock-footer:has(.pointer-badge)") self.assertIsNotNone(body, "배지 있는 푸터의 gutter 규칙이 없다") self.assertIn("padding-left: 28px", body) def test_header_gutter_single_mock(self): body = rule_body(css(), ".mock-header:has(.pointer-badge)") self.assertIsNotNone(body, "배지 있는 헤더의 gutter 규칙이 없다") self.assertIn("padding-left: 28px", body) def test_header_gutter_multi_mock(self): body = rule_body( css(), ".ppt-wireframe:has(.mock ~ .mock) .mock-header:has(.pointer-badge)") self.assertIsNotNone(body, "복수 목업의 헤더 gutter 규칙이 없다") self.assertIn("padding-left: 34px", body) def test_skill_md_no_longer_teaches_negative_top(self): text = SKILL_MD.read_text(encoding="utf-8") self.assertNotIn("음수 `top`(예: `-6px`)을 쓴다", text, "SKILL.md 가 여전히 잘리는 음수 top 패턴을 지시한다") self.assertIn("`mock-header` 안에", text, "SKILL.md 에 헤더 배지 배치 규칙이 없다") class TestTraceInteraction(unittest.TestCase): def test_template_links_badges_and_descriptions_with_keyboard_support(self): text = TEMPLATE.read_text(encoding="utf-8") for phrase in (".pointer-badge", ".desc-num", "pointerenter", "event.key === 'Enter'", "event.key === ' '", "is-trace-active", "is-trace-ping"): with self.subTest(phrase=phrase): self.assertIn(phrase, text) def test_reduced_motion_is_supported(self): self.assertIn("prefers-reduced-motion: reduce", TEMPLATE.read_text(encoding="utf-8")) def test_runtime_classes_are_documented(self): text = SKILL_MD.read_text(encoding="utf-8") self.assertIn("`is-trace-active`", text) self.assertIn("`is-trace-ping`", text) if __name__ == "__main__": sys.exit(unittest.main()) -
test_validator.py 14.7 KB
"""번들 검증기(validate_storyboard.py) 의 클래스 계약 단위 테스트 (stdlib only).""" import re import sys import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SKILL_ROOT / "scripts")) import validate_storyboard as vs class TestExtractStyle(unittest.TestCase): def test_returns_css_between_style_tags(self): html = "<head><style>\n.a { color: red; }\n</style></head>" self.assertIn(".a { color: red; }", vs.extract_style(html)) def test_raises_when_no_style_block(self): with self.assertRaises(ValueError): vs.extract_style("<head></head>") class TestDefinedClasses(unittest.TestCase): def test_extracts_simple_class_selectors(self): css = ".ppt-slide { color: red; }\n.mock-tab { color: blue; }" self.assertEqual(vs.defined_classes(css), {"ppt-slide", "mock-tab"}) def test_extracts_compound_and_descendant_selectors(self): css = ".mock-tab.active { color: red; }\n.desc-list li { margin: 0; }" self.assertEqual(vs.defined_classes(css), {"mock-tab", "active", "desc-list"}) def test_ignores_element_and_pseudo_selectors(self): css = "body { margin: 0; }\n* { box-sizing: border-box; }\ncode { color: red; }" self.assertEqual(vs.defined_classes(css), set()) def test_ignores_decimal_values_in_declarations(self): css = ".a { transform: scale(0.9); box-shadow: 0 2px 4px rgba(0,0,0,0.3); }" self.assertEqual(vs.defined_classes(css), {"a"}) def test_ignores_at_import_url_extension(self): css = "@import url('https://cdn.example.com/pretendard.css');\n.a { color: red; }" self.assertEqual(vs.defined_classes(css), {"a"}) class TestUsedClasses(unittest.TestCase): def test_extracts_single_and_multiple_classes(self): html = '<div class="ppt-slide"><span class="mock-tab active"></span></div>' self.assertEqual(vs.used_classes(html), {"ppt-slide", "mock-tab", "active"}) def test_collapses_extra_whitespace(self): html = '<div class=" a b "></div>' self.assertEqual(vs.used_classes(html), {"a", "b"}) def test_returns_empty_when_no_class_attribute(self): self.assertEqual(vs.used_classes("<div></div>"), set()) class TestUndefinedClasses(unittest.TestCase): CSS = ".ppt-slide { color: red; }\n.mock-tab { color: blue; }" def test_returns_empty_when_all_defined(self): html = '<div class="ppt-slide"><span class="mock-tab"></span></div>' self.assertEqual(vs.undefined_classes(html, self.CSS), []) def test_reports_undefined_sorted(self): html = '<div class="storyboard"><span class="dochead"></span></div>' self.assertEqual(vs.undefined_classes(html, self.CSS), ["dochead", "storyboard"]) def test_mermaid_is_whitelisted(self): html = '<div class="mermaid">mindmap</div>' self.assertEqual(vs.undefined_classes(html, self.CSS), []) def test_whitelist_contains_mermaid(self): self.assertIn("mermaid", vs.WHITELIST) class TestAgainstRealTemplate(unittest.TestCase): """실제 template.html 로 계약이 성립하는지 확인한다.""" def setUp(self): self.css = vs.extract_style(vs.TEMPLATE.read_text(encoding="utf-8")) def test_core_classes_are_defined(self): for name in ("docwrap", "ppt-slide", "ppt-top-no", "ppt-body-full", "ppt-wireframe", "ppt-desc-panel", "desc-num", "pointer-badge", "mock", "mock-tab", "ppt-footer", "icon"): with self.subTest(name=name): self.assertIn(name, vs.defined_classes(self.css)) class TestSkillClassQuickReference(unittest.TestCase): """SKILL.md 의 Class Quick Reference 표가 template.html 의 CSS 계약과 맞는지 확인한다. 표는 에이전트가 template.html 을 열지 않고도 사용 가능한 클래스를 알 수 있게 하는 권위이므로, 표가 template.html 과 어긋나면 이 테스트가 잡아야 한다. """ #: CSS 클래스가 아니라 엘리먼트 셀렉터로 정의된 항목. 표에는 등재되어 #: 있으나 defined_classes() 에는 나오지 않는 게 정상이다. NOT_A_CLASS = frozenset({"code"}) @classmethod def setUpClass(cls): skill_path = ( SKILL_ROOT / "SKILL.md" ) cls.skill_text = skill_path.read_text(encoding="utf-8") cls.css = vs.extract_style(vs.TEMPLATE.read_text(encoding="utf-8")) cls.defined = vs.defined_classes(cls.css) def _table_class_names(self) -> set[str]: """SKILL.md 의 '# Class Quick Reference' 섹션(다음 '#' 헤딩 전까지)에서 표 행의 첫 번째 셀에 있는 backtick 식별자를 모두 추출한다. 한 셀에 여러 클래스가 나열된 행(예: ppt-top-bar)도 지원한다.""" match = re.search( r"# Class Quick Reference\n(.*?)(?=\n# )", self.skill_text, re.DOTALL, ) self.assertIsNotNone( match, "SKILL.md 에 '# Class Quick Reference' 섹션이 없다" ) section = match.group(1) names: set[str] = set() for line in section.splitlines(): line = line.strip() if not line.startswith("|"): continue cells = [c.strip() for c in line.strip("|").split("|")] if not cells: continue first_cell = cells[0] # 헤더/구분선 행 스킵 ("클래스" 헤더, "---" 구분선) if first_cell in ("클래스", "") or set(first_cell) <= {"-", ":"}: continue names.update(re.findall(r"`([^`]+)`", first_cell)) return names def test_every_table_class_is_defined_or_allowed(self): table_names = self._table_class_names() self.assertTrue(table_names, "표에서 클래스명을 하나도 못 찾았다") for name in sorted(table_names): # code 는 '<code>' 처럼 태그 형태로 적혀 있어 이름 자체가 # 클래스명이 아니다 — <> 를 벗겨 NOT_A_CLASS 와 비교한다. bare_name = name.strip("<>") if bare_name in self.NOT_A_CLASS: continue with self.subTest(name=name): self.assertTrue( name in self.defined or name in vs.WHITELIST, f"SKILL.md 표의 '{name}' 이 template.html 의 CSS 에도, " f"validate_storyboard.WHITELIST 에도 없다. template.html 에 " "정의를 추가하거나, SKILL.md 표에서 항목을 빼거나, " "WHITELIST/NOT_A_CLASS 에 명시적으로 등록할 것.", ) class TestScreenIds(unittest.TestCase): """검증기의 화면 ID 정의/참조 판정 (이슈 #26).""" co = vs def test_meta_id_is_a_definition(self): html = '<div class="ppt-meta-id">DTC-MAIN-001</div>' self.assertEqual(self.co.screen_ids(html), {"DTC-MAIN-001"}) def test_caption_id_is_a_definition(self): """목업 2개짜리 슬라이드의 두 번째 화면 ID 는 캡션이 정의 자리다.""" html = '<div class="mock-caption">게시글 상세 (DTC-BOARD-002)</div>' self.assertEqual(self.co.screen_ids(html), {"DTC-BOARD-002"}) def test_meta_id_may_hold_several_ids(self): """런타임이 두 화면을 한 칸에 묶어 적어도 각각 정의로 읽는다.""" html = '<div class="ppt-meta-id">DTC-BOARD-001 / DTC-BOARD-002</div>' self.assertEqual(self.co.screen_ids(html), {"DTC-BOARD-001", "DTC-BOARD-002"}) def test_caption_definition_is_not_counted_as_a_reference(self): html = ('<div class="mock-caption">게시글 상세 (DTC-BOARD-002)</div>' '<div>글 상세로 이동 (DTC-BOARD-003)</div>') self.assertEqual(self.co.referenced_ids(html), {"DTC-BOARD-003"}) def test_reference_defined_only_in_caption_is_not_dangling(self): html = ('<div class="ppt-meta-id">DTC-BOARD-001</div>' '<div class="mock-caption">게시글 상세 (DTC-BOARD-002)</div>' '<div>탭 시 글 상세로 이동 (DTC-BOARD-002)</div>') dangling = self.co.referenced_ids(html) - self.co.screen_ids(html) self.assertEqual(dangling, set()) class TestIgnoresStyleBlock(unittest.TestCase): """CSS 주석의 사용 예시를 실제 마크업으로 세지 않는다 (이슈 #26).""" co = vs def test_markup_only_drops_style_block(self): html = '<style>/* <div class="mock mock-partial"> */</style><div class="mock"></div>' self.assertNotIn("mock-partial", self.co.markup_only(html)) self.assertIn('class="mock"', self.co.markup_only(html)) def test_partial_mock_in_css_comment_is_not_counted(self): template = vs.TEMPLATE.read_text(encoding="utf-8") self.assertIn('class="mock mock-partial"', template, "템플릿 CSS 주석의 사용 예시가 사라졌다면 이 테스트의 전제가 깨진다") css = vs.extract_style(template) self.assertNotIn("mock-partial", self.co.markup_only(f"<style>{css}</style>")) def _slide(no, body=""): """상단 바 앵커를 갖춘 최소 슬라이드 마크업.""" return f'<div class="ppt-top-no">NO. {no}</div>{body}' class TestSlideSectioning(unittest.TestCase): """슬라이드 구간은 상단 바 앵커로만 자른다 (이슈 #61). 본문 텍스트나 HTML 주석에 등장하는 "NO. 09.1" 같은 문자열이 구간 경계로 잡히면 한 슬라이드가 둘로 쪼개지고, 그 자리에서 배지와 desc-num 이 서로 다른 구간으로 갈려 불일치를 놓친다. """ def test_html_comment_is_stripped_by_markup_only(self): html = '<!-- ============ NO. 09.1 홈 ============ -->' + _slide("09.1") self.assertNotIn("<!--", vs.markup_only(html)) self.assertEqual([no for no, _ in vs.detail_slides(vs.markup_only(html))], ["09.1"]) def test_comment_between_badge_and_desc_does_not_split_slide(self): # 배지 2개 / desc-num 1개 — 반드시 불일치로 잡혀야 한다. html = _slide( "09.1", '<span class="pointer-badge">1</span>' "<!-- NO. 09.1 설명 패널 -->" '<span class="pointer-badge">2</span><span class="desc-num">1</span>', ) self.assertEqual(vs.badge_desc_mismatch(vs.markup_only(html)), [("09.1", 2, 1)]) def test_index_table_text_is_not_a_slide(self): html = _slide("03", "<td>NO. 09.1</td><td>홈</td>") + _slide("09.1") self.assertEqual([no for no, _ in vs.detail_slides(html)], ["09.1"]) def test_last_detail_slide_stops_at_next_slide(self): # 09.x 뒤에 부록 슬라이드가 와도 그 배지가 마지막 화면 상세에 합산되지 않는다. html = (_slide("09.1", '<span class="pointer-badge">1</span>' '<span class="desc-num">1</span>') + _slide("10", '<span class="pointer-badge">X</span>')) self.assertEqual(vs.badge_desc_mismatch(html), []) def test_slide_sections_filters_by_pattern(self): html = _slide("07.1") + _slide("08") + _slide("09.1") self.assertEqual([no for no, _ in vs.slide_sections(html, r"07\.\d+")], ["07.1"]) self.assertEqual([no for no, _ in vs.slide_sections(html)], ["07.1", "08", "09.1"]) class TestRowScreenType(unittest.TestCase): """05 Screen List 유형은 유형 열의 셀 값으로 판정한다 (이슈 #61).""" def test_reads_type_cell(self): row = ('<td>DTC-DOC-101</td><td>서류등록</td><td>바텀시트</td>' '<td>홈 > 서류</td><td>파일 선택</td>') self.assertEqual(vs.row_screen_type(row), "바텀시트") def test_description_wording_does_not_override_type_cell(self): # "주요 내용" 칸에 '화면' 이 섞여도 팝업 행이 화면으로 오판되면 안 된다. row = ('<td>DTC-DOC-101</td><td>서류등록</td><td>팝업</td>' '<td>홈</td><td>화면 일부를 덮는 선택 시트</td>') self.assertEqual(vs.row_screen_type(row), "팝업") def test_falls_back_to_row_text_when_no_type_cell(self): row = "<td>DTC-DOC-101</td><td>서류등록 바텀시트</td>" self.assertEqual(vs.row_screen_type(row), "바텀시트") def test_returns_none_when_untyped(self): self.assertIsNone(vs.row_screen_type("<td>DTC-DOC-101</td><td>서류등록</td>")) def test_screen_row_with_popup_wording_in_description_is_not_screen(self): markup = ( _slide("05", '<tr><td>DTC-A-001</td><td>목록</td><td>화면</td>' '<td>홈</td><td>목록 조회</td></tr>') + _slide("09.1", '<div class="ppt-meta-id">DTC-A-001</div>') ) self.assertEqual(vs.check_screen_list_types(markup), []) class TestInteractionBadgeCitation(unittest.TestCase): """BR 인터랙션 표의 트리거 칸은 배지 번호를 인용해야 한다 (이슈 #61).""" SECTION = """ ### 입력 검증 해당 없음 — 조회 전용 화면. ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | 계좌 카드 탭 (1) | - | 계좌로 이동 (DTC-ACCT-001) | | 알림 배지 탭 | - | 알림으로 이동 (DTC-NOTI-001) | """ def test_reports_row_without_citation(self): self.assertEqual(vs.interaction_rows_without_badge(self.SECTION), ["알림 배지 탭"]) def test_accepts_two_level_citation(self): body = ("### 인터랙션\n| 트리거 | 조건/검증 | 동작 |\n|---|---|---|\n" "| 필터 칩 탭 (1-2) | - | 목록 갱신 |\n") self.assertEqual(vs.interaction_rows_without_badge(body), []) def test_screen_id_in_action_cell_does_not_count_as_citation(self): body = ("### 인터랙션\n| 트리거 | 조건/검증 | 동작 |\n|---|---|---|\n" "| 행 탭 | - | 상세로 이동 (DTC-BOARD-002) |\n") self.assertEqual(vs.interaction_rows_without_badge(body), ["행 탭"]) def test_none_section_is_exempt(self): body = "### 인터랙션\n해당 없음 — 표시 전용 배너다.\n" self.assertEqual(vs.interaction_rows_without_badge(body), []) def test_missing_section_is_exempt(self): self.assertEqual(vs.interaction_rows_without_badge("### 출력 규칙\n표시만.\n"), []) def test_check_rules_reports_it(self): md = "# X Business Rules\n\n## DTC-MAIN-001 홈\n" + self.SECTION + """ ### 출력 규칙 표시. ### 엣지케이스 없음. """ violations, _info = vs.check_rules(md, {"DTC-MAIN-001"}) self.assertTrue(any("배지 번호 인용이 없는 행" in v for v in violations), violations) if __name__ == "__main__": unittest.main() -
test_version_history.py 2.3 KB
"""Cover/Document History 버전 정합 검사 테스트 (stdlib only) — 이슈 #74.""" import importlib.util import sys import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent spec = importlib.util.spec_from_file_location( "validate_storyboard", SKILL_ROOT / "scripts" / "validate_storyboard.py") vs = importlib.util.module_from_spec(spec) spec.loader.exec_module(vs) def doc(cover_ver, rows): trs = "".join(f"<tr><td>{v}</td><td>2026-07-30</td><td>UX 기획</td><td>{d}</td></tr>" for v, d in rows) return ( f'<div class="ppt-top-no">NO. 01</div><div class="ppt-top-title">Cover</div>' f'<table><tr><td>Version</td><td>{cover_ver}</td></tr></table>' f'<div class="ppt-top-no">NO. 02</div><div class="ppt-top-title">History</div>' f'<table><tr><td>Version</td><td>Date</td><td>Author</td><td>Description</td></tr>{trs}</table>' ) class TestVersionHistory(unittest.TestCase): def test_matching_versions_pass(self): markup = doc("1.0.0", [("1.0.0", "최초 작성")]) self.assertEqual(vs.check_version_history(markup), []) def test_regeneration_appends_row_passes(self): markup = doc("2.0.0", [("1.0.0", "최초 작성"), ("2.0.0", "템플릿 v2 재생성 — 목업 밀도 상향")]) self.assertEqual(vs.check_version_history(markup), []) def test_cover_history_mismatch_flagged(self): # 콜드 재생성에서 1.0.0 행을 그대로 두고 Cover 만 2.0.0 으로 올린 사고 markup = doc("2.0.0", [("1.0.0", "최초 작성")]) v = vs.check_version_history(markup) self.assertTrue(any("다르다" in x for x in v), v) def test_replaced_row_keeps_first_write_label_flagged(self): # tossinvest v2 사고 재현: 1.0.0 행을 2.0.0 으로 치환해 "최초 작성" 이 남음 markup = doc("2.0.0", [("1.5.0", "화면 추가"), ("2.0.0", "최초 작성")]) v = vs.check_version_history(markup) self.assertTrue(any("최초 작성" in x for x in v), v) def test_missing_slides_not_judged(self): markup = '<div class="ppt-top-no">NO. 09.1</div>' self.assertEqual(vs.check_version_history(markup), []) if __name__ == "__main__": sys.exit(unittest.main())
-
-
SKILL.md 47.7 KB
--- name: mobile-web-planner description: 사용자가 모바일 웹/앱의 기획서 / 화면설계서 / 스토리보드(storyboard) / 와이어프레임(wireframe) / IA / 화면기획을 요청할 때 도메인 불문(쇼핑, 커뮤니티, 예약, 뉴스, O2O, ...) 사용한다. PPT 스타일 16:9 슬라이드로 구성된 자체 완결형 HTML 파일 하나와, 화면 ID 를 키로 하는 Business Rules 마크다운 명세(검증·인터랙션·엣지케이스)를 산출한다. --- # Role 당신은 모바일 웹/앱 UX/UI 수석 기획자다. 실무 화면설계서(PPT 스타일) 관례를 따라, 요청받은 도메인의 정보구조(IA)와 화면 상세를 누락 없이 작성한다. 산출물은 **두 파일 한 쌍**이다. 1. **Storyboard** — 자체 완결된 단일 HTML 파일. 16:9 슬라이드를 세로로 나열하며, 각 슬라이드는 상단 바(회색 번호 + 제목 + 프로젝트명) · 중간 콘텐츠 · 하단 accent 컬러 푸터 구조를 갖는다. "화면이 어떻게 보이는가"를 답한다. 2. **Business Rules** — 화면 ID 를 키로 storyboard 와 연결되는 마크다운 문서. 입력 검증 · 출력 규칙 · 인터랙션 · 엣지케이스를 화면마다 명세한다. "화면이 정확히 어떻게 동작하는가"를 답한다. 개발자가 이 두 문서만 보고 구현에 착수할 수 있어야 한다. 형식은 아래 `# Business Rules` 절을 따른다. 이 두 파일은 **기획 산출물**이다. 데이터 모델(테이블/컬럼)·API 스펙·인프라 설계는 범위 밖이며 흉내 내지 않는다 — 어설픈 시스템 설계가 섞이면 어느 쪽 문서도 권위가 없어진다. # Placeholders 마크업의 `{{PROJECT_NAME}}` 과 `{{VERSION}}` 을 채운다. | 플레이스홀더 | 채우는 방법 | |---|---| | `{{PROJECT_NAME}}` | 사용자가 서비스명을 주면 그대로. 안 주면 요청 내용에서 유추한다 (예: "반려동물 용품 쇼핑몰" → `펫샵`). 상단 바와 하단 푸터에 같은 값을 쓴다. | | `{{VERSION}}` | 사용자가 지정하지 않으면 `1.0.0` | 플레이스홀더는 이 둘뿐이다. 새로 만들지 않는다. 특정 블로그·회사·개인 이름을 산출물에 넣지 않는다. # Workflow 아래 실행 순서를 끝까지 수행한다. 이 문서에서 `<스킬경로>` 는 이 SKILL.md 가 있는 디렉터리다. 런타임마다 설치 위치가 달라(`~/.claude/skills/`, `~/.agents/skills/`, `~/.gemini/config/skills/`) 고정 경로를 쓸 수 없고, 작업 디렉터리는 사용자 프로젝트이지 스킬 디렉터리가 아니다. 스크립트와 리소스는 반드시 이 접두사를 붙여 실제 경로로 치환해 쓴다. 1. 요청에서 프로젝트명, 사용자 유형, 플랫폼, 기능과 제약을 추출한다. 2. 결과를 크게 바꾸는 누락 정보만 질문한다. 안전하게 유추 가능한 항목은 가정으로 정리하고 작업을 계속한다. 3. **덮어쓸 산출물이 이미 있으면 먼저 백업한다** — 같은 디렉터리의 `archive/` 아래에 `<이름>_v<이전버전>.<확장자>` 로 복사한다. 화면설계서는 합의의 기록이라 이전 판을 잃으면 "왜 이렇게 정했는지" 를 되짚을 수 없다. 4. `<스킬경로>/scripts/scaffold.py` 로 빈 뼈대를 만든다 — 템플릿 head(mermaid 런타임 + 전체 CSS)를 손으로 옮겨 적지 않는다. scaffold 가 `<meta name="skill-ruleset">` 로 생성 당시 규칙 세트를 새긴다 — 지우거나 값을 바꾸지 않는다 (검증기가 읽어 사후 도입 규칙을 구분한다). ```sh python3 <스킬경로>/scripts/scaffold.py docs/<프로젝트>_storyboard.html \ --project "<프로젝트명>" --version 1.0.0 --accent '#1b64da' ``` 5. IA와 화면 목록을 확정한 뒤 아래 슬라이드 순서로 Storyboard를 작성한다. **한 번에 다 쓰지 않는다** — 아래 "분할 작성" 을 따른다. 6. Storyboard 의 모든 화면 ID(팝업·바텀시트 포함)에 대해 `# Business Rules` 절의 형식으로 Business Rules 문서를 작성해 같은 디렉터리에 저장한다. 7. 저장 후 이 Skill 디렉터리의 검증기 세 개를 모두 실행한다. ```sh python3 <스킬경로>/scripts/validate_storyboard.py <생성한 HTML 경로> # 구조 계약 + Business Rules python3 <스킬경로>/scripts/check_badge_overflow.py <생성한 HTML 경로> # 배지가 목업 밖으로 나갔는지 python3 <스킬경로>/scripts/check_badge_alignment.py <생성한 HTML 경로> # 배지 겹침·순서 역전 ``` 8. 위반이 있으면 산출물을 수정하고 검증을 다시 실행한다. 위반이 0건이 될 때까지 반복한다. 9. Chrome 을 쓸 수 있으면 레이아웃 회귀도 함께 확인한다 — 정적 검사는 마크업만 보므로 슬라이드 밖으로 넘친 내용, 설명 패널 잘림은 렌더해야 보인다. ```sh python3 <스킬경로>/scripts/check_layout_runtime.py <생성한 HTML 경로> ``` 10. 브라우저 또는 HTML 렌더링 도구를 사용할 수 있으면 **`<스킬경로>/resources/badge-audit.js` 를 실행해 배지 정렬을 실측한다**(아래 "배지 좌표는 실측한다"). 반환값을 JSON 으로 저장해 `<스킬경로>/scripts/apply_badge_audit.py` 로 인라인 top 을 일괄 반영한다 — 손 환산 금지. 그다음 각 슬라이드의 잘림, 겹침과 가독성을 확인하고 발견한 문제를 수정한 뒤 다시 검증한다. 11. 구조 검증을 통과한 두 파일의 경로와 결과에 영향을 준 주요 가정을 전달한다. 기존 Storyboard 수정 요청에서는 기존 화면 ID를 가능한 한 유지한다. 삭제된 ID를 새 화면에 재사용하지 않고, 추가 화면에는 새 ID를 부여한다. 변경 범위 밖의 디자인은 보존하고 `{{VERSION}}`과 Document History를 갱신한 뒤 전체 문서를 다시 검증한다. **Document History 는 누적이다 — 수정이든 콜드 재생성(archive 백업 후 새로 작성)이든 이전 버전 행을 지우지 않는다.** 기존 행을 그대로 두고 새 버전 행을 아래에 추가하며, 새 행의 Description 에는 "최초 작성" 이 아니라 재생성/수정 사유와 변경 요약을 적는다 (예: `2.0.0 / 2026-07-30 / UX 기획 / 템플릿 v2 재생성 — 목업 밀도 상향, 화면 2종 추가`). "최초 작성" 은 첫 행(최초 버전)에만 쓴다. Cover 의 Version, History 마지막 행의 Version, 푸터의 `Ver.x` 세 값은 항상 같아야 한다 — 검증기가 Cover 와 History 최신 행의 일치를 잰다. 화면을 추가·삭제·변경했다면 Business Rules 문서의 해당 섹션도 같은 커밋 단위로 함께 갱신한다 — 두 문서의 화면 ID 집합이 어긋나면 검증기가 실패한다. ## 분할 작성 — 화면이 10장을 넘으면 필수 화면 20장이면 HTML 이 200KB 에 이른다. 한 번에 쓰려 하면 출력이 잘리거나, 더 나쁘게는 **분량을 맞추려 화면을 조용히 줄이게 된다** — 이 스킬이 가장 경계하는 실패다. 1. `scaffold.py` 로 뼈대를 만든다. 2. `01`~`08` 슬라이드를 먼저 붙이고 검증기를 돌린다. 이 단계에서 화면 ID 집합과 Screen List 가 확정되므로, 이후 09.x 는 그 목록을 그대로 따라가면 된다. 3. `09.x` 를 **5~6장 단위**로 이어붙인다. 삽입 지점은 파일 끝의 `</div>\n</body>` 바로 앞이다. 배치마다 검증기를 돌려 배지·캡션 불일치를 그 자리에서 잡는다. 4. 09.x 를 다 붙인 뒤 Business Rules 를 쓴다 — 화면 ID 집합이 확정된 다음이라 섹션 누락이 생기지 않는다. 배치를 줄이려고 화면을 합치지 않는다. 배치 수는 늘어나도 되지만 화면 수는 IA 가 정한다. ## 배지 좌표는 실측한다 `pointer-badge` 는 `top` 을 인라인으로 적는데, 목업은 `transform:scale(0.9)`(목업 1개) 또는 `zoom:0.9`(2개 이상)로 축소되고 콘텐츠 높이는 렌더해야 정해진다. **인라인 값만 보고는 배지가 의도한 요소 옆에 있는지 알 수 없다.** 실제로 23화면 산출물에서 16곳이 엉뚱한 요소를 가리킨 사례가 있다. - 정적 검사(`check_badge_alignment.py`)로 잡히는 것은 **겹침과 순서 역전**까지다. - 두 실측 도구는 보는 것이 다르다. `badge-audit.js` 는 배지가 **무엇을 가리키는가**(의미), `check_layout_runtime.py` 는 레이아웃이 **깨졌는가**(구조 — 슬라이드 overflow, 배지가 자기 컨테이너 밖으로 이탈, 배지 겹침, 설명 패널 잘림)를 본다. 후자는 설계 기준 폭 (1400px)으로 고정해 렌더하므로 창 폭 때문에 생긴 잘림을 회귀로 보고하지 않는다. - 브라우저를 쓸 수 있으면 `<스킬경로>/resources/badge-audit.js` 를 실행한다. 반환값의 `misaligned` 가 비어 있어야 한다. 어긋난 배지는 반환값을 JSON 으로 저장해 **`<스킬경로>/scripts/apply_badge_audit.py <산출물.html> <audit.json>`** 으로 일괄 반영한다 — `fixes[].suggestedTop` 이 배지의 인라인 top 좌표계로 이미 환산돼 있고, 반영 후 정적 검증기 재실행까지 한 번에 된다. 인라인 top 을 손으로 되돌리지 않는다. - **시트(부분 목업) 배지의 좌표 원점은 mock-body 상단이 아니다.** 바텀시트 내부의 `position:relative` 컨테이너가 원점이라, 실측값을 `measured / 0.9` 로 손 환산하면 수십 px 이 어긋난다. 반드시 `fixes` 의 suggestedTop 을 쓴다. - 반영 후 재검증에서 **overflow 위반이 새로 나면 그 배지는 의도적 클램프 대상**이다 — 타깃이 가시 한계 근처라 정확히 맞추면 프레임을 벗어나는 경우로, 검증기가 알려주는 가시 한계 안으로 top 을 되돌리고(예: 615 - 24 - 여유) 그 값을 유지한다. - 좌표 원점은 **컨테이너마다 다르다**. `mock-footer` 안의 `top:9px` 와 `mock-body` 안의 `top:9px` 는 전혀 다른 위치다. 서로 비교하지 않는다. - `mock-body` 위쪽(헤더 영역) 요소를 가리킬 때는 배지를 **`mock-header` 안에** 둔다 (`position:absolute; top:10px; left:2px` — 헤더가 자체 좌표 원점이다). 배지가 있으면 템플릿이 헤더에도 gutter(28/34px)를 자동 확보해 제목 첫 글자를 가리지 않는다. `mock-body` 기준 음수 `top` 은 본문이 스크롤 컨테이너라 잘려 렌더되지 않으므로 쓰지 않는다. 헤더 배지는 별도 컨테이너라 `mock-body` 배지와 좌표 순서를 비교하지 않는다. 슬라이드를 아래 순서·번호로 작성한다. | NO. | 슬라이드 | 레이아웃 | 내용 | |---|---|---|---| | 01 | Cover | `ppt-body-full` | 서비스명, 문서 제목, Version / Date / Author | | 02 | Document History | `ppt-body-full` | 개정 이력 표 (Version / Date / Author / Description) | | 03 | Index | `ppt-body-full` | 슬라이드 목차 표 (NO. / 제목 / 설명) | | 04 | Information Architecture | `ppt-body-full` + `mermaid` | 화면 트리. mermaid `flowchart` (노드 13개 이상이면 `subgraph`) | | 05 | Screen List | `ppt-body-full` | 화면 목록 표 — 모든 화면 ID ↔ 실제 화면 매핑 (팝업·바텀시트 포함) | | 06 | Service Flow | `ppt-body-full` + `mermaid` | 정상 케이스 전체 흐름도. mermaid `flowchart`, 노드에 화면명+ID | | 07.1 ~ 07.n | Sequence Diagram | `ppt-body-full` + `mermaid` | 상태 변경 트랜잭션당 1장. mermaid `sequenceDiagram` | | 08 | General Rule | `ppt-body-full` | 공통 규칙 — 그리드/여백, 타이포그래피, 컬러, 컴포넌트, 예외처리, 접근성 | | 09.1 ~ 09.n | 화면 상세 | 좌우 분할 | 화면당 슬라이드 1장 | **`04 Information Architecture` 는 화면의 계층 구조다.** 노드 수에 따라 배치를 고른다 — 슬라이드는 16:9 라 세로로만 긴 그래프는 좌우가 절반 넘게 빈다. - **노드 12개 이하**: `flowchart LR` 단순 트리로 충분하다. - **노드 13개 이상**: 최상위 묶음(탭·영역)을 `subgraph` 로 감싼다. 묶음이 가로로 늘어서면서 그래프가 슬라이드 비율에 가까워진다. - 노드 라벨은 화면명과 화면 ID 를 함께 적는다 — `I1["계좌<br/>TSI-ACCT-001"]`. - 노드 배열 순서는 `09.x` 슬라이드 순서를 따른다. **`05 Screen List` 는 화면 ID ↔ 실제 화면 매핑의 기준표다.** 기획자가 ID 만 보고 어떤 화면인지 세부 슬라이드를 뒤지지 않게 한다. - 표 열: **화면 ID / 화면명 / 유형 / Location / 주요 내용**. 유형은 `화면` `팝업` `바텀시트` 중 하나 — 모든 행에 반드시 적는다. - **유형은 유형 칸에만 적는다.** 검증기가 유형 열의 셀 값으로 판정하므로, "주요 내용" 칸에 "화면 일부를 덮는다" 같은 문구가 있어도 오판하지 않는다. 다만 열 순서를 바꾸면 판정이 행 텍스트 매칭으로 되돌아가 오판할 수 있으니 위 열 순서를 지킨다. - **유형이 `화면` 인 ID 는 `09.x` 슬라이드(`ppt-meta-id` 또는 `mock-caption`)에 정의돼 있어야 한다.** 목록에만 있고 그려지지 않은 화면은 구현 단계에서 범위를 즉석 결정하게 만든다. 검증기가 잰다. - **Storyboard 에 정의된 모든 화면 ID 가 한 행씩 들어간다** — 슬라이드가 없는 팝업·바텀시트도 빠뜨리지 않는다 (`03 Index` 는 슬라이드 목차라 이들을 담지 못한다). 검증기가 커버리지를 잰다. - 행 순서는 `09.x` 슬라이드 순서를 따르고, 팝업·바텀시트는 그것을 여는 부모 화면 행 바로 아래에 둔다. - 표는 인라인 `style` 로 그린다 — 새 클래스를 만들지 않는다. **`06 Service Flow` 는 서비스 전체의 정상(happy path) 흐름도다.** `04 IA` 가 "화면이 어떻게 묶여 있는가"(계층)라면 이 슬라이드는 "사용자가 어떤 순서로 화면을 오가는가"(이동)를 답한다 — 요건과 흐름을 대조하며 리터치할 때 기준이 된다. - mermaid `flowchart` (`TD` 또는 `LR`) 로 그린다. `mindmap` 은 흐름을 표현하지 못하므로 쓰지 않는다. - **노드 라벨은 화면명과 화면 ID 를 함께 적는다** — 예: `A["홈<br/>DTC-MAIN-001"]`. 엣지 라벨에는 트리거 액션을 적는다 — 예: `A -->|게시글 탭| B`. - **정상 시나리오만 그린다.** 오류·권한 없음·빈 상태 같은 예외 분기는 Business Rules 문서 소관이다. 조건 분기는 서비스의 핵심 갈림길(예: 로그인 여부)만 마름모 노드로 남긴다. - 진입점(온보딩 또는 메인 홈)에서 시작해 `09.x` 의 모든 주요 화면을 거치는 경로를 담는다. 팝업·바텀시트는 흐름상 의미 있을 때만 노드로 넣는다. **`07.x Sequence Diagram` 은 상태 변경 트랜잭션의 시스템 관점 흐름이다.** `06 Service Flow` 가 "사용자가 어떤 순서로 화면을 오가는가"(이동)라면, 시퀀스는 "한 번의 액션이 화면·서버·외부시스템 사이에서 어떤 순서로 처리되는가"(메시지 교환)를 답한다. 서비스 전체를 시퀀스 하나로 그리지 않는다 — **트랜잭션당 1장**이다. 각 화면의 인터랙션이 아래 중 **하나라도 해당하면 그 트랜잭션의 시퀀스를 1장 그린다.** | 트리거 | 예 | |---|---| | 서버 데이터 상태를 바꾼다 (생성·제출·확정·취소) | 글 등록, 투표 제출, 예약 확정 | | 조건 분기로 결과가 갈린다 (정원·한도·권한·마감) | 정원 초과 → 대기 등록 | | 잠금·동시성 처리가 필요하다 | 슬롯 선점, 중복 제출 방지 | | 외부 시스템·비동기 연동이 있다 | 알림 발송, 결제 | - **조회-응답뿐인 화면은 그리지 않는다** — 요청/응답 두 줄짜리 시퀀스는 정보가 없다. 트리거에 해당하는데 없는 것도, 해당 없는데 있는 것도 위반이다. - mermaid `sequenceDiagram` 으로 그린다. participant 는 **사용자 / 화면(화면 ID 병기) / 서버 / 외부시스템** 수준으로 유지한다 — 내부 모듈 단위로 쪼개지 않는다. - 정상 흐름과 트리거가 된 분기(`alt`)만 담는다. 그 외 예외·오류 처리는 Business Rules 소관이다. - 슬라이드 제목(`ppt-top-title`)에 트랜잭션명을 적는다 — 예: `Sequence — 참석투표 제출`. participant 라벨이나 note 에 관련 화면 ID 를 적어 어느 화면의 트랜잭션인지 잇는다 — 예: `participant V as 참석투표 (TC-VOTE-001)`. - 순서는 대상 화면의 `09.x` 순서를 따른다. **입도 — 한 기능의 등록·수정·삭제를 몇 장으로 쪼갤 것인가.** 기준은 "메시지 교환 순서가 다른가" 하나다. - **묶는다**: 같은 엔드포인트에 같은 순서로 오가고 결과만 갈리는 것. 등록과 수정은 보통 한 장에 `alt` 로 담는다. - **나눈다**: participant 구성이나 순서가 다른 것. 삭제는 확인 바텀시트가 끼어 화면이 하나 늘어나므로 별도 장이 맞다. 스케줄러가 주체인 비동기 흐름(조건 발동 → 알림 발송)은 사용자 액션이 아예 없으므로 반드시 따로 그린다. - **그리지 않는다**: 단일 필드 토글처럼 요청 한 번에 상태 한 칸이 바뀌고 분기가 없는 것. Business Rules 의 인터랙션 표로 충분하다. **화면 상세는 04 IA 에 정의한 모든 주요 화면을 빠짐없이 각각 별도 슬라이드로 만든다.** 작성을 마치기 전에 스스로 점검한다: IA 의 주요 화면 수와 `09.x` 슬라이드 수가 같은가. 다르면 빠진 화면을 추가한다. **슬라이드 수에 상한은 없다 — 화면 수는 요청 범위가 정한다.** - **사용자가 기능을 나열했으면 그 기능들(+ 필요한 진입 화면)이 범위다.** 임의로 줄이거나 늘리지 않는다. - **예외 — 회원 전용 동작(작성·제출·예약·구매·투표 등)이 하나라도 있으면 인증·온보딩(로그인/가입)과 내 정보 화면도 범위다.** 나열에 없어도 포함한다. 정말 뺀다면(예: 사내 SSO 전제) "인증은 범위 외" 를 가정으로 명시해 전달한다 — 실구현에서 인증 화면을 설계 단계에 다시 그리게 되는 것이 가장 흔한 누락이다. - **나열이 없으면**("당근 같은 중고거래 앱 기획해줘") 해당 도메인 **상용 서비스의 표준 IA 를 스스로 도출해 누락 없이** 만든다 — 핵심 루프(탐색·상세·작성·거래)만이 아니라 **온보딩/인증, 프로필, 내역·관리(수정/삭제/상태변경), 알림, 설정, 신고/차단** 같은 보조 플로우까지. 화면이 30장이면 `09.x` 도 30장이다. - **문서 길이를 이유로 화면을 생략하지 않는다.** "n장이면 충분하다" 는 판단 기준이 아니다 — 기준은 "이 문서만 보고 서비스 전체를 구현할 수 있는가" 다. 분량이 부담스러우면 사용자에게 화면 목록을 먼저 제시하고 범위를 좁힐지 물어볼 수는 있으나, 스스로 조용히 축소하지 않는다. - 범위를 도출했으면 작성 시작 전에 화면 목록을 한 줄 요약으로 알린다 (확인 대기는 불필요 — 결과를 크게 바꾸는 애매함이 있을 때만 질문 규칙을 따른다). **화면 내부(mock-body)는 뼈대만 최소한으로 만들지 않는다.** 각 화면의 목적과 기능 복잡도를 스스로 분석하여, 실제 상용 서비스에서 기대되는 컴포넌트(필터, 탭, 상태 라벨, 메타데이터, CTA 버튼 등)와 더미 데이터를 **최대한 밀도 있게** 꽉 채워 넣는다. **목업 밀도 기준은 "Figma 시안급"이다.** 회색 상자 나열이 아니라 실제 앱 스크린샷처럼 읽혀야 한다. 아래를 기본으로 쓴다. 이 밀도 요구는 산문이 아니라 **검증 대상이다** — `validate_storyboard.py` 가 자리표시자 (`Mockup Content`/`TODO`/`Lorem` 류), 설명 패널 재탕(제목 복사·`탭 ›` 표기), 도메인 데이터 신호(숫자 리터럴) 부족, 시퀀스 보일러플레이트 복제를 위반으로 잡는다. 화면을 스크립트로 찍어내도 되지만 **목업 본문은 화면마다 서로 달라야 하며 공용 상수 문자열을 쓰면 안 된다** — 상수 문자열 경로는 위 검사가 그대로 차단한다. - **상태바**: `<div class="mock-status"></div>` 하나 — 9:41·신호·배터리는 CSS 가 그린다. - **헤더 백 버튼**: 화면 헤더 좌측에 `‹` 를 기본으로 둔다 (iOS 대응). 최상위 탭 화면도 예외가 아니다. - **카드**: `background:#fff; border-radius:16px; padding:16px; box-shadow:0 1px 4px rgba(2,32,71,0.05);` — 본문 배경은 `#f2f4f6`. - **아바타 칩**: 종목·사용자 등 엔티티 행 앞에 이니셜 원형 칩 — `width:30px; height:30px; border-radius:50%; background:<브랜드색>; color:#fff; display:inline-flex; align-items:center; justify-content:center; font-weight:800;`. - **스파크라인**: 추세 있는 수치 행에는 인라인 `<svg>` polyline 미니 차트를 넣는다 — `<svg width="56" height="20" viewBox="0 0 56 20"><polyline points="0,15 18,16 36,11 56,7" fill="none" stroke="#f04452" stroke-width="1.6"/></svg>`. - **수치 강조**: 금액은 큰 굵은 타이포(letter-spacing -0.02em), 등락·상태는 연한 배경 칩(`background:#fdeef0; border-radius:6px; padding:3px 8px;`)으로. 설명 배지(`pointer-badge`)는 아래 '터치 요소 전수 규칙'을 따른다 — 재량이 아니다. **`09.x` 순서는 사용자가 기능을 나열한 순서를 따른다.** 중요도나 자기 판단으로 재배열하지 않는다 — 같은 요청에 항상 같은 순서가 나와야 사용자가 자기가 적은 순서대로 나왔는지 바로 확인할 수 있고, 문서를 다시 생성해도 순서가 흔들리지 않는다. - 사용자가 나열하지 않았지만 필요한 진입 화면(메인 홈 등)은 **`09.1`** 에 둔다. 나열한 기능은 그 뒤에 적힌 순서대로 `09.2` 부터 이어서 매긴다. - 나열 순서가 정보구조상 부자연스러워도 순서를 바꾸지 않는다. 대신 `04 IA` 다이어그램의 노드 배열을 `09.x` 순서에 맞춘다. - `03 Index` 표의 행 순서, `04 IA` 의 노드 순서, `05 Screen List` 의 행 순서, `09.x` 슬라이드 순서 **네 곳이 모두 같아야 한다.** (`06 Service Flow` 는 이동 그래프라 순서 제약이 없다.) **`09.x` 슬라이드 상단은 2행 헤더다 — 각 24px, 별도 행을 늘리지 않는다.** - **1행 = `ppt-top-bar`**: `ppt-top-no`(NO.) · `ppt-top-title`(화면명) 다음에 `ppt-head-label`/`ppt-head-value` 쌍으로 **화면 Type · 요구사항 ID** 두 칸을 같은 줄에 잇는다. 우측 끝 Page 박스는 CSS 가 자동으로 붙인다. - 화면 Type 값은 `APP` `MOBILE WEB` `WEB` 중 하나다. 이 스킬의 기본 산출물은 `MOBILE WEB`. - 요구사항 ID 는 요청에 주어졌을 때만 적고, 없으면 `-` 로 둔다. 지어내지 않는다. - `ppt-head-bar` 로 **행을 따로 만들지 않는다** — 구버전 호환용 클래스다. - **2행 = `ppt-meta-bar`**: `화면 ID` 라벨 + `ppt-meta-id`(좌측), `Location` 라벨 + `ppt-meta-value`, 끝에 `작업자` 라벨 + 값. 작업자 라벨에 인라인 `margin-left:auto` 를 줘 우측에 붙인다. - Location 은 진입점부터 그 화면까지의 경로를 `>` 로 잇는다 — 예: `홈 > 게시판 > 글 상세`. `04 IA` 의 연결 관계에서 그대로 끌어온다. - 작업자 칸에는 **역할명**(예: `UX 기획`)을 적는다 — 산출물에 개인 이름을 넣지 않는 규칙은 여기에도 적용된다. 화면 상세(`09.x`) 외 슬라이드에는 `ppt-meta-bar` 와 헤더 칸을 넣지 않는다 — 화면이 아니므로 화면 메타가 없다. **화면마다 화면 ID 를 부여하고 이동을 그 ID 로 가리킨다.** 슬라이드 번호(`09.2`)는 화면이 추가되면 밀리므로 참조가 어긋나고, 팝업처럼 슬라이드가 없는 대상은 가리킬 수도 없다. - 형식은 `<서비스약어>-<기능>-<3자리>` 다. 예: `DTC-BOARD-001`, `DTC-NOTICE-002`. - 서비스약어는 프로젝트명에서 만든다 (테니스클럽 → `TC`, 반려동물용품몰 → `PET`). 대문자 2~4자. - 기능은 영문 대문자 단어 하나 (`MAIN` `BOARD` `NOTICE` `VOTE` `AWARD` `BOOKING` `MEMBER`). - 같은 기능의 화면이 여럿이면 뒤 3자리로 구분한다 — 목록 `001`, 상세 `002`. - `ppt-meta-id` 에 표시한다. `03 Index` 표에도 ID 열을 둔다. - **이동 서술은 이름과 ID 를 함께 적는다** — `글 상세로 이동 (DTC-BOARD-002)`. ID 만 쓰면 읽기 어렵다. - 팝업·바텀시트에도 ID 를 준다. 슬라이드가 없어도 참조 대상이므로 필요하다. - **본문에서 참조한 ID 는 모두 이 문서 안에 정의되어 있어야 한다.** 정의 없는 ID 를 가리키면 끊어진 참조다. 화면 상세 슬라이드는 좌측 `ppt-wireframe` 에 모바일 목업을, 우측 `ppt-desc-panel` 에 설명을 넣는다. 목업 위의 `pointer-badge` 와 설명 리스트의 `desc-num` 을 **1:1 로 대응**시킨다. 표기는 양쪽이 항상 같다 — 목업이 1개면 `1, 2, 3`, 2개 이상이면 2단 번호(`1-1`, `2-1`). **원문자(①②③)는 쓰지 않는다** — `desc-num` 은 배지와 같은 accent 칩으로 렌더되므로 표기까지 같아야 대응이 즉시 읽힌다. 설명 항목 수와 배지 수가 같아야 한다 — `mock-footer` 처럼 `mock-body` 밖의 요소를 설명하는 항목도 배지를 빠뜨리지 않는다(아래 마크업 참고). **터치 가능한 모든 요소에 배지를 단다.** 버튼·탭·리스트 행·칩·토글·FAB·링크·입력 필드 — 사용자가 누르거나 조작할 수 있으면 배지와 설명 항목이 있어야 한다. 배지 없는 터치 요소는 그 동작이 문서에서 증발해, 구현자가 "이 버튼 누르면 뭐가 되는지" 를 되물어야 한다. 장식·정적 텍스트·읽기 전용 표시는 배지를 생략한다. 같은 동작의 반복 요소(리스트 행 20개)는 대표 1개에만 단다. **설명 항목은 영역 설명과 이벤트를 분리해 적는다.** 인터랙티브 요소의 설명 항목에는 이벤트 줄이 최소 1줄 있어야 한다 — 검증기가 화면 상세마다 이벤트 표기 하한선을 잰다. ```html <li><span class="desc-num">2</span> <div><b>이번 주 운동 카드</b><br> 일시·장소·참석 게이지 표시<br> 탭: 참석투표 상세로 이동 (TC-VOTE-002)</div></li> ``` - 이벤트 라벨은 **`탭:` `스와이프:` `롱프레스:` `입력:`** 네 개로 고정한다. 다른 표기(클릭 시, 터치하면 등)를 만들지 않는다 — 표기가 흔들리면 검증기도 사람도 이벤트를 못 찾는다. - 이동이면 이름과 화면 ID 를 함께 적는다(기존 규칙) — `탭: 글 상세로 이동 (DTC-BOARD-002)`. 화면 이동이 아니면 결과 상태를 적는다 — `탭: 참석 반영, 게이지 갱신`. - 한 요소에 이벤트가 여럿이면 줄을 나눈다 — `탭: …` / `롱프레스: …`. - **읽기 전용 항목에는 이벤트 라벨을 달지 않는다.** 금액 요약, 상태 배지, 차트처럼 조작할 수 없는 요소는 "읽기 전용" 이라고 적는 것이 맞다. 억지로 `탭:` 을 붙이면 없는 동작을 구현하게 된다. 조회 중심 서비스에서 라벨 비율이 절반 남짓인 것은 정상이며, 검증기도 **슬라이드당 최소 1개**만 요구한다. **모든 `mock` 에 `mock-caption` 을 붙인다 — 목업이 1개여도.** 형식은 `화면명 (화면 ID)` 이고, 변형 케이스 목업이면 `화면명_변형명 (화면 ID)` 처럼 변형을 이름에 잇는다 — 예: `거주성 문진_Default (APN-SURVEY-001)`. 캡션은 목업 **바로 위 남색 타이틀 바**로 렌더된다(실무 화면설계서의 변형 케이스 바). 우상단 `ppt-meta-id` 는 대표 화면 표기이고, 캡션은 "이 목업이 어느 화면·어느 케이스인지" 를 읽게 한다 — 단일 목업 슬라이드만 캡션이 없으면 문서 전체에서 표현이 어긋난다. 검증기가 목업 수와 캡션 수를 대조한다. **설명 항목은 한 슬라이드에 12개를 넘기지 않는다.** 8개 이상이면 템플릿이 목록을 자동 압축해 하단 잘림을 막지만, 12개를 넘으면 압축으로도 안 들어가므로 목업을 나눠 슬라이드를 분할한다. **터치 요소 전수 규칙이 12개 상한보다 우선한다.** 밀도 높은 화면(홈, 목록+필터+정렬)은 배지가 금방 12개를 넘는데, 그때 **배지를 빼서 맞추지 않는다** — 빠진 배지는 그 동작이 문서에서 사라진 것이고, 그건 잘린 슬라이드보다 나쁘다. 넘치면 이 순서로 해소한다. 1. 같은 동작의 반복 요소를 대표 1개로 합친다 (리스트 행 20개 → 1개). 2. 화면을 기능 축으로 나눠 슬라이드를 분할한다 — 예: `09.4 목록` / `09.5 목록 필터`. 화면 ID 는 그대로 두고 슬라이드만 나눠도 된다(같은 ID 를 두 슬라이드의 `mock-caption` 에 적는다). 3. 그래도 넘으면 화면 자체가 과적재라는 신호다. IA 로 돌아가 화면을 쪼갠다. `pointer-badge` 는 `left:2px` 로 둔다. `mock-body` 좌측 여백이 배지 자리이며 폭은 템플릿이 정한다 — 목업 1개면 28px, 2개 이상이면 2단 번호가 넓어지므로 34px 다. **`mock-body` 에 인라인 `padding` 을 줄 때는 `padding-left` 를 이 값 이상으로 유지한다**(목업 1개 28px, 2개 이상 34px) — 그러지 않으면 배지가 본문 텍스트를 가린다. ## 목업 여러 개 배치 각 `09.x` 화면이 아래 네 조건 중 **하나라도 해당하면 `ppt-wireframe` 안에 `mock` 을 2개 놓는다.** 화면을 억지로 여러 슬라이드로 쪼개지 않는다. | 조건 | 목업 2개 구성 | |---|---| | 목록과 그 상세를 같은 기능에서 다룬다 | 목록 / 상세 | | 사용자 입력을 받는다 | 입력 전 / 입력 후 (또는 검증 실패) | | 데이터 유무에 따라 표시가 크게 달라진다 | 데이터 있음 / 빈 상태 | | 다단계 플로우의 중간 단계다 | 단계 N / 단계 N+1 | **해당하지 않으면 1개로 둔다.** 단순 조회·나열 화면(예: 회원 목록, 설정 메뉴)에 억지로 2개를 넣지 않는다 — 비교할 변형이 없으면 두 번째 목업은 같은 화면의 중복일 뿐이다. - **개수는 최대 4개.** 템플릿이 개수를 감지해 축소율을 조절한다(1개: 90%, 2~3개: 90%, 4개: 77%). 5개 이상은 잘리므로 슬라이드를 나눈다. - **각 목업에 `mock-caption` 으로 라벨을 붙인다** — `mock` 의 마지막 자식으로 두면 프레임 바로 위 남색 타이틀 바로 표시된다. 무엇의 변형인지 알 수 없으면 비교 슬라이드의 의미가 없다. (캡션은 단일 목업에도 필수다 — 위 공통 규칙.) - **`pointer-badge` 번호는 2단이다** — `<목업번호>-<요소번호>`. 첫 목업의 요소는 `1-1` `1-2`, 두 번째 목업은 `2-1` `2-2` 로 매긴다. 목업이 몇 번째인지가 번호에서 바로 읽히므로 "어느 목업의 항목인지" 를 따로 적을 필요가 없다. - **`desc-num` 도 같은 2단 표기를 쓴다** — 배지가 `1-1` 이면 설명도 `1-1`. - 설명 리스트는 목업 순서대로 묶어 적는다 — `1-1` `1-2` 를 먼저, 그다음 `2-1` `2-2`. - **각 목업의 화면 ID 는 `mock-caption` 에 이름과 함께 적는다** — `<div class="mock-caption">게시글 상세 (DTC-BOARD-002)</div>`. 목업이 2개면 화면도 2개인데 `ppt-meta-id` 는 슬라이드에 한 칸뿐이므로, 두 번째 화면의 ID 는 캡션이 정의 자리다. 캡션에 안 적으면 설명에서 `(DTC-BOARD-002)` 로 참조해도 문서 안에 정의가 없는 끊어진 참조가 된다. - **`ppt-meta-id` 에는 그 슬라이드의 대표 화면, 즉 첫 목업의 ID 를 둔다.** `ppt-meta-value` 의 위치도 첫 목업 기준으로 적는다. - 목업 간 간격·정렬·축소는 템플릿이 처리한다. `ppt-wireframe` 이나 `mock` 에 인라인 `width`·`transform`·`zoom`·`margin` 을 주지 않는다. **팝업·바텀시트는 부분 목업으로 그린다.** 전체 화면 목업으로 그리면 별개 화면처럼 보이고, 본 목업 안에 인라인으로 그리면 열리기 전 상태를 함께 보여줄 수 없다. 사용자와의 상호작용(예: 필터, 옵션 선택, 알림, 완료 메시지 등)이 발생하는 지점에서는 부분 목업 생성을 적극적으로 고려하여 기획의 깊이를 더한다. - `<div class="mock mock-partial">` 로 만든다. 높이가 줄어 화면 일부만 덮는다는 사실이 그림으로 전달된다. - 위쪽 배경 힌트는 인라인 `style` 로 회색 블록을 채운다 — 팝업 뒤에 화면이 있다는 표시다. - 배지는 **부모-자식 관계**로 매긴다. 팝업을 여는 버튼이 `2-3` 이면 팝업 자체는 `3-1` 이 아니라 여는 쪽 번호를 이어받아 표기하고, 설명에서 어느 버튼이 여는지 명시한다. - 팝업에도 화면 ID 를 준다. 여는 쪽 설명에 `탭 시 서류등록 바텀시트 노출 (DTC-DOC-101)` 처럼 적는다. - `mock-caption` 은 부분 목업에도 붙인다 — 무엇의 팝업인지 알 수 없으면 의미가 없다. # Color `template.html` 의 `:root` 에 정의된 `--accent` / `--accent-ink` 두 변수가 강조색 계약이다. `pointer-badge` 배경, `mock-tab.active` 글자색, `code` 글자색, 그리고 목업 본문에서 강조 용도로 쓰는 인라인 색(배너 배경, 카테고리 라벨, 활성 탭 밑줄, CTA 버튼 등)은 전부 이 두 변수를 참조한다 — 개별 요소에 `#ea580c` 같은 값을 직접 흩어 쓰지 않는다. - **덮어쓰는 곳은 `:root` 하나뿐이다.** 산출물 `<style>` 안의 `:root { --accent: ...; --accent-ink: ...; }` 값만 바꾼다. 나머지 규칙은 `var(--accent)` / `var(--accent-ink)` 를 그대로 참조하므로 손댈 필요가 없다. - **도메인에 맞는 색을 고른다.** 예: 스포츠/동호회 = 코트 그린, 뉴스 = 뉴트럴 블루, 쇼핑 = 웜 레드. 요청에 브랜드 컬러가 주어지면 그것을 우선한다. - **명도 대비를 확인한다.** `--accent` 배경 위에 `--accent-ink` 글자가 얹힌다 (`pointer-badge`, 목업 배너 등). 밝은 accent(예: 라임, 파스텔)를 고르면 `--accent-ink` 를 어두운 색(예: `#1a1a1a`)으로 함께 바꿔 가독성을 유지한다. - **상태색은 별개다.** 참석 초록 / 마감 회색처럼 의미 고정 상태색은 accent 와 분리해 `05 General Rule` 슬라이드에 문서화한다. accent 변수를 상태색 용도로 재사용하지 않는다. - **프레임 색은 고정이다.** 슬라이드 캔버스(`#e5e7eb`), 상단 번호 블록의 회색(`#737373`), Page No. 박스(`#3f3f46`), 목업 타이틀 바(`mock-caption`)·하단 푸터(`ppt-footer`)의 남색(`#1e2a5c`), 헤더 표 라벨 칸 회색(`#d4d4d8`), 목업 내부의 상태바/구분선 회색(`#f4f4f5`, `#e2e8f0`, `#94a3b8` 등)은 이 스킬이 "정통 PPT 화면설계서"로 읽히게 하는 고정 프레임이므로 변수화 대상이 아니다. 바꾸지 않는다. # Class Quick Reference `<스킬경로>/resources/template.html` 에 정의된 클래스만 사용한다. **이 표에 없는 클래스를 새로 만들지 않는다.** 목업 내부의 세부 스타일은 인라인 `style` 속성으로 처리한다. | 클래스 | 용도 | |---|---| | `docwrap` | 전체 슬라이드 컨테이너. `body` 직하위에 하나 | | `ppt-slide` | 슬라이드 1장 (16:9) | | `ppt-top-bar` | 상단 바. 우측 끝 Page No. 박스는 CSS counter 로 자동 표기 — 마크업으로 넣지 않는다 | | `ppt-top-no` | 상단 바 좌측 회색 번호 블록 (`NO. 01`) | | `ppt-top-title` | 상단 바 제목 | | `ppt-top-proj` | 상단 바 우측 프로젝트명 | | `ppt-head-label` | 헤더 칸 회색 라벨 (`화면 Type` `요구사항 ID`). **`ppt-top-bar` 안에** 둔다 | | `ppt-head-value` | 헤더 칸 값. 넘치면 말줄임 | | `ppt-head-bar` | (구버전 호환) 별도 헤더 행 — **새 문서에서 쓰지 않는다** | | `ppt-meta-bar` | 2행 헤더의 2행 (화면 ID · Location · 작업자). **화면 상세(`09.x`)에만** 둔다 | | `ppt-meta-label` | 메타 줄의 회색 라벨 칸 (`Location`) | | `ppt-meta-value` | 메타 줄의 값 칸. 넘치면 말줄임 | | `ppt-meta-id` | 화면 ID 칸. `화면 ID` 라벨(`ppt-meta-label`) 바로 뒤, 메타 줄 **좌측**에 둔다 | | `ppt-content` | 중간 영역 컨테이너 | | `ppt-body-full` | 좌우 분할하지 않는 통짜 콘텐츠 — 화면 상세(09.x)를 제외한 모든 슬라이드 | | `ppt-wireframe` | 좌측 와이어프레임 패널 (09.x). **`mock` 을 1개 이상(최대 4개) 배치할 수 있다** — 개수에 따라 축소율과 간격을 템플릿이 자동 조절한다 | | `ppt-desc-panel` | 우측 설명 패널 (09.x) | | `ppt-desc-header` | 설명 패널 헤더 | | `ppt-desc-body` | 설명 패널 본문 | | `desc-list` | 설명 리스트 (`ul`) | | `desc-num` | 설명 항목 번호. `pointer-badge` 와 같은 accent 칩으로 렌더되며 표기도 배지와 동일 (`1` 또는 `1-1`). 원문자(①②③) 금지 | | `pointer-badge` | 목업 위 accent 컬러 번호 배지. `desc-num` 과 1:1 대응. **`left:2px`** 로 둘 것 — `mock-body` 좌측 여백(1개 28px · 2개 이상 34px)이 배지 자리다. 폭은 내용에 맞춰 늘어난다. 음수 `left` 는 `mock-body`·`mock-screen` 의 overflow 에 절반이 잘린다 | | `is-trace-active` | 배지·설명 hover/focus 연결 강조. 템플릿 JS가 런타임에만 부여하며 산출물에 직접 쓰지 않는다 | | `is-trace-ping` | 설명 활성화 시 대응 배지 Ping. 템플릿 JS가 런타임에만 부여하며 `prefers-reduced-motion`에서는 애니메이션을 끈다 | | `mock` | 모바일 목업 외곽 프레임 320×694 (2.17:1 — 아이폰 17·갤럭시 S26 비율). 라운드·섀도는 템플릿이 처리, 인라인으로 덮지 않는다 | | `mock-caption` | 목업 상단 남색 타이틀 바. `mock` 의 마지막 자식으로 두면 프레임 위에 표시된다. **모든 목업에 필수** — `화면명 (화면 ID)` 형식으로 그 목업의 화면 ID 를 적고, 변형 케이스면 `화면명_변형명 (화면 ID)`. 예: `필터 선택됨 (DTC-FILTER-002)` | | `mock-partial` | 부분 목업(팝업·바텀시트). `mock` 과 **함께** 쓴다 — `class="mock mock-partial"` | | `mock-screen` | 목업 화면 | | `mock-status` | 목업 상태바 — 빈 `<div>` 하나면 9:41·신호·배터리 글리프까지 CSS 가 렌더한다. 내용물을 넣지 않는다 | | `mock-header` | 목업 헤더. 헤더 요소(알림 아이콘·건너뛰기 등)를 가리키는 배지는 이 안에 둔다 — 배지가 있으면 템플릿이 gutter 를 자동 확보한다 | | `mock-body` | 목업 본문 | | `mock-footer` | 목업 하단 탭 바 (클래식 풀폭형) | | `mock-footer-pill` | **Liquid Glass 플로팅 필 탭 바 (iOS 26)** — `mock-footer` 대신 같은 자리(`mock-body` 다음 형제)에 둔다. 탭 바 있는 화면의 **기본 선택지**. 내부는 인라인 아이콘 svg, 활성 탭은 유리 버블(아래 마크업 예시) | | `mock-tab` | 하단 탭 항목 (`mock-footer` 용). 활성 탭에 `active` 추가 | | `ppt-footer` | 하단 남색 푸터 바 (28px). 좌측 "화면설계서" 라벨은 CSS 자동 — 마크업에는 우측 텍스트(`프로젝트명 | Ver.x`)만 넣는다 | | `<code>` (클래스 아님 · 엘리먼트) | 디자인 시스템 컴포넌트명 인라인 표기 | | `icon` | Phosphor 인라인 SVG 아이콘 | | `mermaid` | IA·Service Flow·Sequence 다이어그램. 도형은 mermaid.js 가 렌더하고, 슬라이드를 채우는 크기 규칙만 템플릿이 갖는다. `ppt-body-full` 의 **유일한 자식**일 때 크기 규칙이 적용되므로 텍스트와 섞지 않는다 | ## 저장 전 자체 점검 산출물 저장 전 [18항목 자체 점검](references/self-check.md)을 모두 수행한다. # Icons **이모지를 아이콘으로 쓰지 않는다.** 아이콘이 필요하면 Phosphor Icons(MIT) 의 `path` 만 인라인 SVG 로 넣는다. ```html <svg class="icon" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></svg> ``` `path` 는 `https://raw.githubusercontent.com/phosphor-icons/core/main/assets/regular/<name>.svg` 에서 가져온다. 뒤로가기 `‹` 나 케밥 메뉴 `⋮` 같은 타이포그래피 문자는 그대로 써도 된다. # Business Rules Storyboard 와 같은 디렉터리에 `<프로젝트명>_business-rules.md` 를 만든다. 화면설계서의 목업이 "무엇이 보이는가"라면 이 문서는 "무엇을 입력받고, 무엇을 검사하고, 어떤 조건에서 어떻게 동작하는가"다. 중고거래 서비스라면 "가격은 10원 단위, 최소 1,000원", "판매완료 처리 시 진행 중 채팅방 상단에 상태 배너 표시" 수준까지 적는다 — 이 문서를 읽은 개발자가 추가 질문 없이 검증 로직과 상태 처리를 구현할 수 있어야 한다. ## 권한 매트릭스 — 역할 2개 이상이면 필수 문서에 역할이 2개 이상 등장하면(회원/운영진, 구매자/판매자, 강사/수강생 등) Business Rules 문서 상단 — `Version:` 줄과 첫 화면 섹션 사이 — 에 `## 권한 매트릭스` 섹션을 둔다. BR 인터랙션 표 곳곳에 흩어지는 권한 분기의 집계 뷰다 — 이 표가 없으면 구현자가 역할별 기능 목록을 손으로 긁어모아야 한다. ```markdown ## 권한 매트릭스 | 역할 | 정의 | |---|---| | 회원 | 승인된 일반 회원 | | 운영진 | 클럽 운영 권한 보유 회원 | | 기능 | 화면 ID | 회원 | 운영진 | |---|---|---|---| | 게시글 작성 | DTC-BOARD-003 | O | O | | 공지 작성 | DTC-NOTICE-001 | X | O | ``` - 행은 **역할에 따라 가부가 갈리는 기능만** 적는다 — 전원 가능한 조회까지 다 적으면 집계 뷰의 의미가 없다. - 각 화면 섹션의 인터랙션 표에 권한 분기가 있으면 이 매트릭스와 일치해야 한다. - 역할이 하나뿐인 서비스는 이 섹션을 만들지 않는다. ## 형식 — 기계 검증 대상 ```markdown # {{PROJECT_NAME}} Business Rules Version: {{VERSION}} ## DTC-BOARD-001 게시판 목록 ### 입력 검증 | 필드 | 규칙 | 실패 시 | |---|---|---| | DTC-BOARD-001.IN-01 · 검색어 | 1~50자, 공백만 입력 불가 | 검색 버튼 비활성 유지 | ### 출력 규칙 | 상태 | 표시 | |---|---| | DTC-BOARD-001.OUT-01 · 로딩 | 스켈레톤 리스트 5행 | | DTC-BOARD-001.OUT-02 · 데이터 없음 | "게시글이 없습니다" + 글쓰기 유도 CTA | | DTC-BOARD-001.OUT-03 · 오류 | 재시도 버튼 포함 오류 배너 | ### 인터랙션 | 트리거 | 조건/검증 | 동작 | |---|---|---| | DTC-BOARD-001.INT-01 · 게시글 행 탭 (1) | - | 글 상세로 이동 (DTC-BOARD-002) | | DTC-BOARD-001.INT-02 · 글쓰기 버튼 탭 (2) | 로그인 상태 | 글 작성 화면으로 이동 (DTC-BOARD-003) | | DTC-BOARD-001.INT-03 · 글쓰기 버튼 탭 (2) | 비로그인 | 로그인 유도 바텀시트 노출 (DTC-AUTH-101) | ### 엣지케이스 - DTC-BOARD-001.EDGE-01 — 목록 마지막 페이지 도달 시 "더 보기" 숨김, 무한 스크롤 종료. - DTC-BOARD-001.EDGE-02 — 새로고침 중 삭제된 글 탭 → "삭제된 게시글입니다" 토스트 후 목록 갱신. ``` 구조 규칙 — 검증기(`validate_storyboard.py`)가 그대로 잰다. - **`##` 헤딩은 `<화면 ID> <화면 이름>` 형식이다.** Storyboard 에 정의된 **모든** 화면 ID(팝업·바텀시트 포함)가 각각 정확히 하나의 `##` 섹션을 가져야 한다. Storyboard 에 없는 ID 로 섹션을 만들지 않는다. - **각 섹션에는 `### 입력 검증` `### 출력 규칙` `### 인터랙션` `### 엣지케이스` 네 헤딩이 모두 있어야 한다.** 해당 없는 항목은 비워 두지 말고 `해당 없음 — <이유>` 한 줄을 적는다 (예: 조회 전용 화면의 입력 검증). - **본문에서 참조하는 화면 ID 는 Storyboard 에 정의돼 있어야 한다.** 이동 서술은 Storyboard 와 같은 규칙 — 이름과 ID 를 함께 적는다. - **모든 실제 규칙 행과 목록 항목에는 규칙 ID를 붙인다.** 형식은 `<화면ID>.<구분>-<2자리 번호>`이며 구분은 `IN`(입력 검증), `OUT`(출력 규칙), `INT`(인터랙션), `EDGE`(엣지케이스)다. 화면·구분 안에서 01부터 문서 순서대로 부여하고, ID를 재사용하지 않는다. `해당 없음 — <이유>`는 규칙이 아니므로 ID를 붙이지 않는다. ## 내용 지침 - **입력 검증** — 필드마다 타입 · 필수 여부 · 길이/범위 · 포맷 · 중복 검사, 검증 시점(입력 중 / 포커스 아웃 / 제출 시), 실패 시 UI 반응(인라인 메시지 · 토스트 · 버튼 비활성)과 사용자에게 보이는 문구를 적는다. - **출력 규칙** — 로딩 · 빈 상태 · 오류 · 부분 데이터의 표시 방식, 목록의 정렬 기본값과 페이징 단위, 금액 · 날짜 · 마스킹(전화번호, 계좌) 포맷. - **인터랙션** — Storyboard 의 `pointer-badge` 가 가리키는 요소별로 탭 · 스와이프 · 롱프레스가 무엇을 트리거하는지, 조건 분기(로그인 여부, 권한, 데이터 상태)와 결과(화면 이동 · 상태 변화 · 팝업 노출)를 적는다. **각 행의 트리거 칸에 배지 번호(1, 1-2)를 인용한다 — 필수다.** 인용 없는 행은 어느 요소의 이벤트인지 추적할 수 없다. 검증기가 트리거 칸의 `(1)` · `(1-2)` 패턴을 잰다 — `### 인터랙션` 이 `해당 없음` 인 섹션만 면제된다. - **엣지케이스** — 권한 없음(비로그인 · 타인 소유), 동시성(이미 마감된 투표, 판매완료된 상품), 네트워크 오류와 중복 제출 방지(더블탭), 한도 도달(업로드 개수 초과) 시의 동작을 적는다. - **수치는 구체적으로 적는다.** "적당히 제한"이 아니라 "최소 1,000원 / 최대 99,999,000원, 10원 단위". 요청에 없어 정할 수 없는 값은 합리적으로 정하되 끝에 `(가정)` 을 붙인다 — Storyboard 의 가정 전달 규칙과 같다. # Output `<스킬경로>/resources/template.html` 의 `<head>` 전체 — `preconnect` 링크, mermaid `<script>` 태그, `mermaid.initialize({...})` 설정, `<style>` 블록 — 를 그대로 인라인한 단일 HTML 파일을 만든다. **손으로 옮겨 적지 말고 `<스킬경로>/scripts/scaffold.py` 로 뼈대를 만든다** — 430줄 CSS 를 재작성하면 토큰을 크게 쓰고, 오타 하나에 검증기가 미정의 클래스로 막는다. `<style>` 만 가져오면 `04 IA` · `06 Service Flow` · `07.x Sequence Diagram` 슬라이드의 `mermaid` 다이어그램이 렌더러 없이 원문 텍스트로 남는다. 채팅에 코드 블록으로 출력하지 않는다 — 사용 중인 런타임의 파일 쓰기 수단으로 `<프로젝트명>_storyboard.html` 로 저장하고, 같은 디렉터리에 `<프로젝트명>_business-rules.md` 를 저장한 뒤, 두 저장 경로를 사용자에게 알린다. 파일명 접미사(`_storyboard.html` / `_business-rules.md`)를 지켜야 검증기가 두 파일을 짝으로 인식한다. `<스킬경로>/scripts/validate_storyboard.py`의 종료 코드가 0이 아닌 산출물은 완료로 간주하지 않는다. Business Rules 문서의 위반도 같은 종료 코드에 합산된다. `check_badge_overflow.py` 와 `check_badge_alignment.py` 도 같은 기준이다. 세 검증을 통과하기 전에는 최종 산출물로 전달하지 않는다. Chrome 이 있으면 `check_layout_runtime.py` 도 exit 0 이어야 한다 — 없으면 그 사실을 결과에 적는다. ## PDF · PPTX 내보내기 사용자가 PDF/PPTX 를 요청하면 [내보내기 절차](references/export.md)를 읽고 실행한다. # Markup 화면 상세 마크업을 작성할 때 [마크업 예제](references/markup-examples.md)를 참조한다.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.