chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합

- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환
- Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용
- pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관)
- worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md)
- D:\Rhino-MCP 경로 참조를 새 위치로 치환

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

+569
View File
@@ -0,0 +1,569 @@
# T형 교각 모델링 앱 화면 PRD — 「입력 묶음 확인·확정」 개편 중심
| 버전 | 수정일 | 수정자 | 주요 변경 내용 | 비고 |
| :--- | :--- | :--- | :--- | :--- |
| v0.1.00 | 2026-10-15 | 심영표 | 신규 작성 — 기획서 v0.1.00의 기능을 구현하는 화면 13종의 요구사항 | 초안 `[미확정]`. 코드는 고치지 않음. 결정 대기 항목은 11장 |
| v0.1.01 | 2026-10-15 | 심영표 | 11.1절 D3~D6을 권고안대로 결정 반영 | 사용자 지시 "권고안대로" |
| v0.1.02 | 2026-10-15 | 심영표 | 11.2절 UX1~UX6 결정 반영 | 사용자 지시(UX1 대체, UX2 변경, UX3 내장, UX4 승인, UX5 제외, UX6 제작) |
> 상태: **초안 `[미확정]`**. 근거는 `DOC/01_기획/기획_입력묶음확인확정개편_2026-10-14_v0.1.00.md`(이하 기획서)와 현행 앱(`Code/교각모델링앱/web/`)이다. 화면 디자인 규칙은 같은 폴더의 `design.md`가 정하고, 그 근거는 `RAG/design system_v0.1.1`(Prism)이다. `[추론]`은 확인이 필요한 판단이다.
---
## 1. 문서 개요
### 1.1 목적
기획서가 제안한 네 가지 기능을 사용자가 실제로 쓸 수 있는 **화면 단위의 요구사항**으로 바꾼다.
| 기획서 기능 | 이 문서의 화면 |
| :--- | :--- |
| 「2. 입력 묶음 확인·확정」 개편 (4.1) | S03, S04, S10 |
| 앱이 수행하는 확인과 근거 제시 (4.3) | S04, S05, S12 |
| 도면 표 밖 치수 찾기 (4.2) | S07, S08 |
| 같은 템플릿에서 변수 가능과 불가능 구분 (4.4) | S02, S06, S13 |
### 1.2 범위
| 포함 | 제외 |
| :--- | :--- |
| 교각 작업 화면의 1~4단계(도면·묶음·게이트·보고서) 중 변경이 필요한 화면 | 게이트 실행·승인의 동작 자체(그대로) |
| 신규 화면 5종(S02, S05, S08, S10, S13)과 S04의 요약 띠 | 새 교각 형식의 생성기·검사기 |
| 화면 공통 어휘(상태·등급·출처)와 문안 | 모바일·태블릿 화면(로컬 데스크톱 전용) |
| 수용 기준과 시험 방법 | 서버·엔진의 내부 구현(필요한 자료 계약만 6장에 기술) |
### 1.3 사용자와 전제
| 구분 | 내용 |
| :--- | :--- |
| 주 사용자 | 담당자(교각 도면을 모델링하는 구조엔지니어). 한 번에 한 사람이 사용한다 |
| 보조 사용자 | 검토자(보고서와 확정 기록을 읽는 사람). 읽기 전용 |
| 환경 | 사용자 PC의 로컬 Flask 앱. 화면 폭 1280px 이상 데스크톱. 인터넷 없이 동작한다 |
| 전제 | 확정과 게이트 승인은 **사람의 검토 기록**이다. 앱은 어느 화면에서도 대신하지 않는다 |
| 언어 | 화면 문구는 한국어, 코드·API·키 이름은 영어 |
### 1.4 용어
기획서 1.3절의 용어(묶음·확인·확정·표 밖 치수·앵커)를 그대로 쓴다. 이 문서에서 추가하는 용어는 다음과 같다.
| 용어 | 뜻 |
| :--- | :--- |
| 근거 | 앱 확인 한 건이 어떤 두 값을 비교해 어떤 결과가 나왔는지 적은 한 문장과 그 출처 |
| 원본값 | 템플릿 원본 교각에서 복사한 뒤 새 도면 근거 없이 그대로인 값 |
| 변수 등급 | V1 값 변수 · V2 목록 변수 · V3 생성 변수 · F 고정 (기획서 4.4) |
| 작업대 | 교각 한 개의 작업 화면 전체(S03). 1~4단계를 한곳에서 진행한다 |
---
## 2. 제품 원칙
| ID | 원칙 | 화면에 미치는 영향 |
| :--- | :--- | :--- |
| **PP-1** | **앱은 확인하고, 사람은 확정한다.** 앱 확인은 확정 기록을 만들지 않는다 | 확정 버튼은 사람의 이름 입력과 클릭으로만 동작한다. 어느 화면에도 자동 확정이 없다 |
| **PP-2** | **근거 없음을 숨기지 않는다.** 억지 후보를 만들지 않는다 | '근거 없음'은 일치와 같은 비중으로 표시하고 요약 띠에 개수를 낸다 |
| **PP-3** | **원본 값 그대로 확정되는 사고를 막는다.** (기획서 P2) | '원본값' 표시를 묶음 표에 상시 노출하고 일괄 확정에서 제외한다 |
| **PP-4** | **색만으로 구분하지 않는다.** | 모든 상태는 글자와 형태를 함께 쓴다(Prism BD-1) |
| **PP-5** | **판정 위에 근거를 둔다.** 결론을 먼저, 경위를 뒤에 | 각 화면의 첫 영역이 결론(요약 띠·판정)이고 근거는 이어서 둔다 |
| **PP-6** | **값을 바꾸면 이전 판단이 무효가 된다.** (현행 해시 방식 유지) | 앱 확인·사람 확정·게이트 승인 모두 '재확인 필요'를 같은 어휘로 표시한다 |
---
## 3. 화면 목록
### 3.1 화면 지도
```
S01 교각 목록 ──┬─ S02 새 교각 적합성 판별 ── (만들기)
│
└─ S03 교각 작업대 (1 도면 · 2 입력 묶음 · 3 게이트 · 4 보고서)
├─ 1 도면 ── S09 도면 문자 검색·핸들 조회
├─ 2 묶음 ── S04 입력 묶음 확인·확정
│ ├─ S05 근거 보기 ── S06 값 편집
│ ├─ S07 표 후보값·표 밖 치수 ── S08 도면 보기
│ └─ S10 일괄 확정 확인
├─ 3 게이트 ── S11 게이트
└─ 4 보고서 ── S12 검토보고서 초안
S13 변수 등급 안내 (템플릿별, S01·S02·S06에서 진입)
```
### 3.2 화면 일람
| ID | 화면 | 구분 | 패턴 | 해결하는 문제(기획서) | 단계 |
| :--- | :--- | :--- | :--- | :--- | :-: |
| S01 | 교각 목록 | 변경 | W5 | P6 (템플릿 형식 설명 노출) | A |
| S02 | 새 교각 적합성 판별 | **신규** | W3 | P6 | A |
| S03 | 교각 작업대 | 변경 | W1 + W2 | P1~P3, P8 (진행 요약) | A·B |
| S04 | 입력 묶음 확인·확정 | **변경(핵심)** | W5 | P1, P2, P3, P5, P6, P8 | A·B |
| S05 | 근거 보기 | **신규** | W5 | P3, P5, P7 | B |
| S06 | 값 편집 | 변경 | W3 | P6, P7 | A |
| S07 | 표 후보값·표 밖 치수 | 변경 | W5 | P4, P7 | C |
| S08 | 도면 보기 | **신규** | 모달 | P3, P4 | C |
| S09 | 도면 문자 검색·핸들 조회 | 유지(토큰 적용) | W3 + W5 | — | — |
| S10 | 일괄 확정 확인 | **신규** | 모달 | P1 | B |
| S11 | 게이트 | 유지 + 소변경 | W2 | 앱 확인 요약 링크만 추가 | D |
| S12 | 검토보고서 초안 | 변경 | W4 | 8장에 앱 확인 요약 | B·D |
| S13 | 변수 등급 안내 | **신규** | W4 | P6 | A |
단계는 기획서 6장의 A 분류·표시, B 앱 확인 엔진, C 표 밖 치수 탐색, D 통합·문서이다. 화면은 해당 단계가 끝나야 켜진다. 아직 구현되지 않은 단계의 열은 화면에 나타나지 않는다(빈 열을 두지 않는다).
---
## 4. 공통 요구사항
### 4.1 화면 골격
| 항목 | 요구 |
| :--- | :--- |
| 헤더 | 서비스명, 화면 ID, 교각 이름, 마지막 저장 시각, 실행 환경(로컬)을 한 줄에 둔다 |
| 좌측 레일 | 1280px 이상에서 교각 안의 단계 내비게이션(1 도면 · 2 입력 묶음 · 3 게이트 · 4 보고서)과 단계별 완료 표시를 둔다 |
| 본문 폭 | 연속 문장은 38em 이내. 표는 가로 스크롤을 허용한다 |
| 언어 | 안내·근거·오류 문장은 규정문체(평서 종결 `~한다`)를 쓴다. 버튼 레이블은 동사로 시작하고 8자 이내로 쓴다 |
| 폭 | 1280px에서 표 외 가로 스크롤이 없다 |
### 4.2 상태 어휘 (전 화면 공통)
모든 상태는 **글자 + 형태 + 색**을 함께 쓴다. 형태와 색의 대응은 `design.md` 6장이 정한다.
| 어휘 | 값 | 표시 위치 |
| :--- | :--- | :--- |
| **앱 확인** | 일치 · 주의 · 불일치 · 근거 없음 · —(해당 없음) · 확인 안 함 | S03, S04, S05, S10, S12 |
| **사람 확정** | 확정 · 미확정 · 변경됨 | S03, S04, S05, S10, S12 |
| **변수 등급** | V1 · V2 · V3 · F | S04, S05, S06, S13 |
| **값 출처** | 표 · 표 밖 치수 · 판독 스크립트 · 원본값 · 사람 입력 | S04, S05 |
| **신뢰 등급** | A · B · C · 없음 | S07, S08 |
| **게이트 판정** | PASS · FAIL · 계획 FAIL · 재실행 필요 | S03, S11, S12 |
규칙은 다음과 같다.
- 상태 글자는 6자 이내다. 설명이 필요하면 상태 옆이 아니라 근거 문장(S05)에 쓴다.
- 값이 바뀌면 앱 확인과 사람 확정이 함께 '변경됨 · 재확인 필요'가 된다. 표시 어휘를 두 개로 나누지 않는다.
- '해당 없음'은 배지가 아니라 `—`로 쓴다. '확인 안 함'은 배지가 아니라 [확인 실행] 버튼으로 대신한다.
### 4.3 오류·빈 상태·진행 표시
| 상황 | 요구 |
| :--- | :--- |
| 오류 | 무엇이·왜·어떻게 3요소와 기호를 함께 쓴다. 예: "도면 색인이 없다. DWG 변환이 끝나지 않았다. 1단계에서 도면을 다시 올린다" |
| 빈 상태 | 원인 + 다음 행동 한 문장(60자 이내). 예: "올린 도면이 없다. DWG 파일을 올린다" |
| 진행 | 2초를 넘는 작업(앱 확인, 게이트)은 진행 표시를 보이고 `aria-live`로 낭독한다. 중요 결과를 토스트로만 알리지 않는다 |
| 파괴적 동작 | 삭제·승인 반려·형식 변경 확정은 확인 절차를 거친다 |
### 4.4 접근성·시험
| 항목 | 기준 |
| :--- | :--- |
| 표준 | WCAG 2.2 AA를 목표로 한다 |
| 키보드 | 전 기능을 키보드만으로 조작한다. 탭 순서가 시각 순서와 같다 |
| 히트 영역 | 44px 이상. 인접 간격 8px 이상 |
| 표 | `caption`, `th scope`, 고정 헤더. 상태 셀은 글자를 포함한다 |
| 색 | 그레이스케일로 인쇄해도 모든 상태를 구분할 수 있다 |
---
## 5. 화면별 요구사항
각 화면은 **목적 → 진입 → 구성 → 행동 → 상태 → 수용 기준** 순서로 쓴다. 문안은 예시이며 최종 문구는 디자인 시안에서 확정한다.
### S01 교각 목록 (변경)
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 교각을 고르고 새 교각을 만든다. 현행 화면의 기능을 유지한다 |
| 구성 | ① 교각 표(교각·형식·만든 날·템플릿·진행 요약) ② 새 교각 영역 ③ 보호 템플릿 표 |
| 변경 | 교각 표에 **진행 요약** 열(예: 묶음 27 중 사람 확정 12, 게이트 3/5 승인)을 추가한다. 템플릿 표의 '형식 설명' 열에 [변수 등급 보기](S13) 링크를 둔다 |
| 행동 | [새 교각 만들기] → S02, 교각 이름 → S03, 삭제(확인 절차) |
| 수용 기준 | 진행 요약이 실제 `confirm.json`·게이트 승인 기록과 100% 일치한다 |
### S02 새 교각 적합성 판별 (신규)
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 새 도면이 선택한 템플릿으로 모델링 가능한 형식인지 교각을 만들기 전에 판별한다 (기획서 4.4 체크리스트 6문항) |
| 진입 | S01의 [새 교각 만들기] |
| 구성 | ① **도면 올리기** ② **추천 순위(근거 표시, 맞는 것이 없으면 "판단 불가")** ③ 이름·템플릿 선택(추천 1위 기본) ④ 6문항(추정 답 미리 채움, 사람이 변경) ⑤ 각도 값 확인(skew·받침 회전·기초 회전) ⑥ 결과 판정 영역 ⑦ [교각 만들기] |
| 6문항 | 기초 형식(말뚝 유무) · 기둥 단면 형식 · 코핑 하면 형상 · 받침 배치 구조 · 철근 형상 종류 · 철근 생성 방식. 평면 사각·직각과 각도는 문항이 아니라 ⑤의 값으로 확인한다 |
| 추천 | 도면 단서로 문항 값을 추정하고 5개 템플릿과 일치 문항 수로 순위를 낸다. 단서와 도면 위치를 근거로 보인다. 추정은 확정이 아니며 사람이 바꾼다. 교각 폴더는 [교각 만들기] 시점에 생성한다 |
| 추정 범위 `[구현 2026-10-15]` | 6문항 중 **3문항만 추정**한다: 기초 형식(도면의 '말뚝' 문자 유무) · 코핑 하면 형상(정면도 R 문자: 10 m 이상 곡선, 1~10 m 나팔형(약함), 없으면 직선 경사, 작은 R은 평면 모서리로 제외) · 받침 개수(ELEVATION TABLE 행 수). 기둥 단면·철근 형상·철근 생성 방식은 도면 문자로 판별할 근거가 없어 '미확인'으로 두고 사람이 답한다. 근거마다 신뢰(보통·약함)를 표시한다. 규칙은 9개 도면 세트로만 확인했다 |
| 행동 | 모든 문항에 답하면 판정이 나온다. 전부 '예'이면 "이 템플릿으로 만들 수 있다". 하나라도 '아니오'이면 "같은 템플릿으로 만들 수 없다. 가장 가까운 템플릿에 생성기·검사기를 추가해야 한다"를 보이고, 문항별로 어떤 부분이 다른지 적는다 |
| 상태 | 미응답 문항은 오류로 표시하고 어느 문항인지 이름으로 알린다 |
| 진행 허용 | '아니오'가 있어도 [그래도 만들기]로 진행할 수 있다. 이 경우 교각 이름 옆과 S03·S04 상단에 **형식 불일치 경고**가 상시 표시된다(경고는 닫을 수 없다) |
| 수용 기준 | 6문항의 답이 교각 메타에 저장된다. 경고 교각의 S03·S04에서 경고가 100% 보인다 |
| 구현 현황 | **전부 구현됨(2026-10-15)**: 추천 순위·근거, 6문항 답과 각도 값 3종(skew·받침 회전·기초 회전)을 `pier.json`의 `fit_answers`에 저장, 결과 판정 영역(답을 바꾸면 즉시 갱신, 불일치 시 버튼이 [그래도 만들기]로 바뀜), 불일치 항목을 `fit_mismatch`에 저장하고 교각의 모든 화면 상단에 닫을 수 없는 경고 표시(S03·S04 포함). 화면 시안: `시안/index.html`의 `#/new` |
### S03 교각 작업대 (변경)
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 교각 한 개의 진행 상태를 한눈에 보고 다음 단계로 간다 |
| 구성 | ① 결론 띠(다음에 할 일 한 가지) ② 1~4단계 카드 ③ 단계별 상세 진입 |
| 결론 띠 | 현재 상태에서 가장 앞선 미완 단계를 말한다. 예: "필요 묶음 27개 중 앱 확인 일치 19 · 주의 4 · 불일치 1 · 근거 없음 3, 사람 확정 0. 2단계에서 근거를 확인한다" |
| 1 도면 | DWG 목록(DXF 변환 상태, 해시 앞 16자), 색인 문자 수, 도면 올리기, S09·S07 링크 |
| 2 입력 묶음 | S04의 요약(4.2절 어휘별 개수)과 [입력 묶음 열기] |
| 3 게이트 | G1~G5 판정·승인 요약과 [게이트 열기] |
| 4 보고서 | 초안 생성 여부와 [보고서 열기] |
| 행동 | 단계 카드의 주 버튼은 화면에 **하나**(결론 띠가 가리키는 단계의 버튼)만 강조한다 |
| 상태 | 도면 없음 → 1단계만 활성 안내. 형식 불일치 경고가 있으면 결론 띠 위에 경고 |
| 수용 기준 | 결론 띠의 숫자가 S04 표의 집계와 100% 일치한다. 주 버튼이 화면에 1개다 |
### S04 입력 묶음 확인·확정 (변경, 핵심)
기획서 4.1절의 화면이다.
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 모든 입력 묶음의 앱 확인 결과와 사람 확정 상태를 보고, 근거를 확인한 뒤 확정한다 |
| 진입 | S03의 2단계, 레일 |
| 구성 | ① **요약 띠** ② 도구 줄(필터·확인 실행·일괄 확정) ③ **묶음 표** ④ 형식 불일치 경고(해당 시) |
**① 요약 띠**
- 한 줄: "필요 27개 중 앱 확인 일치 19 · 주의 4 · 불일치 1 · 근거 없음 3 · 사람 확정 0".
- 각 숫자는 필터 버튼이다(누르면 표가 해당 상태만 보인다).
- 마지막 앱 확인 시각과 검사 버전을 함께 보인다. 값이 바뀌어 오래된 결과가 있으면 "재확인 필요 N개"를 별도 표시한다.
**③ 묶음 표 (열 정의)**
| 열 | 내용 | 비고 |
| :--- | :--- | :--- |
| 묶음 | 파일·이름. 누르면 S05 | 이름은 영문 키와 한글 설명 병기 |
| 변수 등급 | V1 · V2 · V3 · F | S13 링크 |
| 값 출처 | 표 · 표 밖 치수 · 판독 스크립트 · 원본값 · 사람 입력 | '원본값'은 강조(PP-3) |
| 앱 확인 | 일치 · 주의 · 불일치 · 근거 없음 · — | 누르면 S05의 해당 근거로 |
| 사람 확정 | 확정 · 미확정 · 변경됨, 확정자·시각 | |
| 확인 | 개별 [확인 실행] | 앱 확인 결과가 오래되었거나 없을 때 |
- 정렬 기본값: 앱 확인이 '불일치 → 근거 없음 → 주의 → 일치' 순, 같은 상태는 파일 순. 사용자가 열 머리를 눌러 바꿀 수 있다.
- '확정 불필요(참고)' 묶음은 표의 맨 끝 구분 영역으로 내리고 기본으로 접는다. 접힌 개수를 표시한다.
- 숫자 열은 우측 정렬이며 등폭 숫자를 쓴다.
**② 도구 줄**
| 요소 | 동작 |
| :--- | :--- |
| 필터 | 앱 확인 상태·사람 확정 상태·변수 등급·출처로 좁힌다. 현재 적용 필터를 글자로 보인다 |
| [전체 확인] | 필요 묶음 전체의 앱 확인 C1~C8을 실행한다. 진행 표시 후 요약 띠를 갱신한다 |
| [일치분 확정] | 앱 확인이 '일치'이고 원본값이 아닌 묶음만 대상으로 S10을 연다 (기획서 D4) |
**상태**
| 상태 | 동작 |
| :--- | :--- |
| 앱 확인 전 | 앱 확인 열이 모두 [확인 실행]. 요약 띠는 "앱 확인 전 — [전체 확인]을 실행한다" |
| 확인 실행 중 | 행마다 진행 표시, 결과가 나오는 대로 갱신. 실행 중에도 표 읽기는 가능 |
| 원본값 묶음 | 앱 확인은 '근거 없음(원본값)'. 일괄 확정 대상에서 제외하고 행에 제외 사유를 쓴다 |
| 형식 불일치 경고 | 표 상단에 닫을 수 없는 경고 띠 |
**수용 기준**
| # | 기준 |
| :-: | :--- |
| 1 | 필수 묶음 100%에 앱 확인 상태(또는 [확인 실행])가 있다 |
| 2 | '근거 없음'과 '원본값'이 숨겨지지 않는다 |
| 3 | 새로 복사한 교각(템플릿 직후)에서 값이 바뀌지 않은 묶음은 전부 '원본값'이다 |
| 4 | 표와 요약 띠의 개수가 일치한다 |
| 5 | 1280px 폭에서 표 외 가로 스크롤이 없다 |
| 6 | 키보드만으로 필터·행 열기·확인 실행·일괄 확정 진입이 가능하다 |
### S05 근거 보기 (신규)
기획서 4.3절의 C1~C8 결과를 사람이 읽는 화면이다.
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 한 묶음의 앱 확인 근거를 문장으로 읽고 확정 여부를 판단한다 |
| 진입 | S04의 묶음 이름 또는 앱 확인 셀 |
| 패턴 | W5 목록-상세. 좌측에 묶음 이동 목록(상태 포함), 우측에 상세 |
**상세 구성**
1. **결론**: 묶음 이름, 변수 등급, 앱 확인 결과 한 줄, 사람 확정 상태.
2. **근거 표**: C1~C8 각각의 `검사 · 결과 · 근거 문장 · 도면 위치`. 해당 없는 검사는 `—`.
3. **값 비교**: 현재 값과 후보 값 또는 템플릿 원본 값을 나란히 보인다(차이가 있는 항목만).
4. **확정 영역**: 확정자 이름, 사유(주의·불일치·근거 없음이면 필수), [확정].
**근거 문장 규칙** (R1: "일치"를 "도면이 맞다"로 오해하지 않게)
- 모든 근거는 '무엇과 무엇이 같은가'를 쓴다. 예: "기둥 지름 2,200은 도면 단면도(D1, 좌표 …)의 치수 문자 '2.200'과 같다."
- 규칙으로 만든 후보와의 비교임을 밝힌다. 예: "이 결과는 후보를 만든 규칙과 값이 같다는 뜻이다. 도면이 맞다는 판정이 아니다."
- 검사에 쓴 방법의 독립성을 표기한다(예: C8 재생성 일치는 '독립성 낮음').
**행동**
| 행동 | 조건·동작 |
| :--- | :--- |
| [도면 보기] | 근거의 도면 위치마다 S08을 연다 |
| [값 편집] | S06으로 이동 |
| [확정] | 확정자 이름이 있고, 주의·불일치·근거 없음이면 사유가 있어야 한다. 조건이 모자라면 버튼은 활성인 채 오류 메시지를 보인다 |
| 이전·다음 묶음 | 목록의 순서로 이동. 확정하면 다음 미확정 묶음으로 이동하는 선택지를 준다 |
**상태**
| 상태 | 동작 |
| :--- | :--- |
| 앱 확인 전 | 근거 표 대신 [확인 실행]과 "이 묶음은 아직 확인하지 않았다" |
| 값 변경됨 | 결론에 "값이 바뀌었다. 이전 확인과 확정이 무효가 되었다" 표시, [다시 확인] |
| V3 묶음 | 값 편집 대신 "판독 스크립트로 다시 만든다" 안내와 C8 결과 |
| 원본값 | 값 비교에 "템플릿 원본과 같다. 새 도면 근거가 없다" |
**수용 기준**
| # | 기준 |
| :-: | :--- |
| 1 | 검사 8종 각각의 결과·문장·도면 위치가 보인다 |
| 2 | '일치' 근거마다 비교한 두 값과 출처가 문장에 있다 |
| 3 | 사유 없는 '주의·불일치·근거 없음' 확정이 불가능하다(오류 메시지 표시) |
| 4 | 확정 기록에는 사람이 입력한 이름만 들어가고, 앱 확인 서버 경로는 확정 기록을 쓰지 않는다 |
### S06 값 편집 (변경)
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 묶음의 값을 수정하고 저장한다. 현행 `section` 화면의 기능 유지 |
| 변경 | ① 상단에 **변수 등급 안내**(이 묶음에서 바꿔도 되는 값과 안 되는 값) ② F 항목 편집 시 경고 ③ V3 묶음은 편집 차단 ④ 저장 뒤 앱 확인 결과가 무효가 됨을 알림 ⑤ [저장된 값으로 확정] 버튼 제거(확정은 S05·S10에서만) |
| F 항목 | 필드 수준 등급이 F이면 편집 영역에 "형식을 정의하는 값이다. 바꾸면 같은 템플릿이 아니다" 경고. 저장하려면 사유를 입력해야 한다(기획서 D3 권고). 저장하면 묶음에 '형식 변경' 표시가 붙고 S03에 새 템플릿 필요 안내가 뜬다 |
| V3 묶음 | 편집창 대신 읽기 전용 요약(개수·크기·해시)과 "판독 스크립트로 다시 만든다" 안내, 스크립트 경로 |
| 입력 보조 | 치수 사슬 입력(`22@120=2.640`)을 지원하는 필드는 합계 검산 결과를 즉시 보인다 |
| 수용 기준 | V3 묶음에서 편집 영역이 없다. F 편집은 사유 없이 저장되지 않는다. 저장 직후 S04 상태가 '변경됨'으로 바뀐다 |
### S07 표 후보값·표 밖 치수 (변경)
기획서 4.2절의 화면이다. 현행 `candidates` 화면을 확장한다.
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 도면 표와 표 밖 치수에서 찾은 후보를 현재 값과 비교하고, 사람이 골라 반영한다 |
| 구성 | ① 요약 띠(표 후보 N · 표 밖 치수 후보 N(A·B·C 개수) · 다름 N) ② 후보 비교 표 ③ 인식한 표 ④ EL 표기 목록 |
| 후보 비교 표 열 | 반영(선택) · 대상 · 현재 값 · 후보 값 · 구분(표/표 밖 치수) · 상태(같음/다름) · 신뢰 등급 · 도면 위치 · 근거 |
| 표 밖 치수 구역 | 규칙 R1~R6 중 어느 규칙이 찾았는지 '규칙' 열에 쓴다. 신뢰 등급 A·B·C·없음을 병기한다 |
| 행동 | 행 선택 → [선택한 후보값 반영]. 등급 C는 반영 시 사유 필수. 등급 '없음'은 후보 행이 아니라 "근거 없음" 목록에 따로 나열하고 반영 버튼이 없다 |
| params·expected 분리 | 한 후보가 params와 expected 양쪽에 반영될 수 있는 경우, 반영 대상 줄을 **둘 중 하나씩** 고르게 하고 둘을 함께 고르면 경고한다(기획서 P7) |
| 상태 | 후보 0건 → "후보가 없다. 도면의 틀이 앱의 규칙과 다르면 정상이다. 문자 검색(S09)으로 직접 찾는다" |
| 수용 기준 | 자동 반영이 없다. 모든 반영은 사람이 선택한 행에만 적용된다. 등급 '없음'이 후보로 둔갑하지 않는다 |
### S08 도면 보기 (신규)
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 근거·후보의 도면 위치를 확대 이미지로 확인한다 (`dxftools.render`와 같은 이미지) |
| 형태 | 모달. 대상 좌표 주변 확대 이미지에 대상 위치를 표시한다 |
| 구성 | 이미지, 캡션(도면 이름·뷰·좌표·핸들), 확대·축소·이동, 인접 후보로 이동 |
| 행동 | Esc로 닫는다. 닫으면 호출한 행으로 포커스가 돌아간다 |
| 접근성 | 이미지에 대체 텍스트로 위치·객체 설명을 쓴다. 이동·확대는 버튼과 방향키 둘 다 지원한다 |
| 수용 기준 | 모든 이미지에 캡션이 있다. 모달 안에 모달이 열리지 않는다 |
### S09 도면 문자 검색·핸들 조회 (유지)
현행 기능을 유지한다. 디자인 토큰만 새 규칙으로 바꾼다. 검색 결과 행에 [후보로 보내기]는 두지 않는다(자동 반영 금지, PP-1).
### S10 일괄 확정 확인 (신규)
기획서 4.1절 D4의 화면이다.
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 앱 확인이 '일치'인 묶음을, 근거를 확인한 뒤 한꺼번에 확정한다 |
| 형태 | 모달 |
| 구성 | ① 결론("일치 묶음 19개를 확정한다. 주의·불일치·근거 없음·원본값 8개는 대상이 아니다") ② **근거표**(묶음·근거 한 줄·출처) ③ 확정자 이름 ④ [확정] ⑤ [취소] |
| 근거표 열람 조건 | 근거표를 끝까지 보지 않으면 [확정]이 오류를 낸다. 오류는 "근거표를 먼저 확인한다. 일치 묶음의 근거를 읽지 않으면 확정할 수 없다. 근거표를 끝까지 내린다"로 알린다 (비활성화하지 않고 오류 메시지를 낸다) |
| 제외 목록 | 대상에서 빠진 묶음과 사유(주의·불일치·근거 없음·원본값)를 같은 모달에서 보여 준다. 누르면 S05로 이동 |
| 완료 | 확정 기록 개수와 확정자·시각을 결과로 보인다. 토스트만으로 알리지 않고 S04 요약 띠에도 반영한다 |
| 수용 기준 | 원본값·주의·불일치·근거 없음 묶음이 대상에 0건이다. 근거표를 읽지 않은 상태에서는 확정되지 않는다. 확정자 이름이 비어 있으면 확정되지 않는다 |
### S11 게이트 (유지 + 소변경)
현행 기능을 유지한다. 변경은 두 가지이다.
- 게이트 실행 조건 문구에 "필요 묶음이 사람 확정됨" 외에 앱 확인 요약 링크(S04)를 추가한다. 앱 확인은 조건이 아니라 판단 자료임을 문구로 밝힌다.
- 판정 표시를 4.2절 게이트 어휘로 통일한다.
### S12 검토보고서 초안 (변경)
| 항목 | 내용 |
| :--- | :--- |
| 변경 | 보고서 8장(검증 방법)에 **앱 확인 결과 요약**을 넣는다 |
| 요약 내용 | 묶음별 앱 확인 상태·근거 한 줄·신뢰 등급, 사람 확정자·시각, 확정 시점의 앱 확인 검사 버전, 근거 없음·원본값 목록 |
| 화면 | 생성한 초안을 화면에서 열람하고 MD·PDF를 받는다. 앱 확인 요약이 어느 시점 결과인지 표기한다 |
| 수용 기준 | 보고서의 요약 숫자가 S04 요약 띠와 같다. 값이 바뀐 뒤 만든 초안과 그 이전 초안을 구분한다 |
### S13 변수 등급 안내 (신규)
| 항목 | 내용 |
| :--- | :--- |
| 목적 | 한 템플릿에서 바꿔도 되는 값과 바꾸면 안 되는 값을 문서처럼 열람한다 (기획서 4.4, 부록 A) |
| 패턴 | W4 문서. 좌측 목차(묶음), 우측 본문 |
| 구성 | ① 등급 4종 정의 ② 묶음별 등급 표 ③ "바꿔도 되는가" 예시 표 ④ 새 도면 판별 체크리스트 요약(S02 링크) |
| 진입 | S01 템플릿 표, S02 템플릿 선택, S04·S06의 등급 배지 |
| 수용 기준 | 4개 보호 템플릿 모두에 등급 표가 있다. 등급 배지에서 해당 묶음 위치로 이동한다 |
---
## 6. 자료 계약 (화면이 읽고 쓰는 파일)
화면 구현에 필요한 자료만 적는다. 내부 구현은 기획서 4.3~4.4절과 엔진 설계를 따른다.
| 파일 | 위치 | 내용 | 쓰는 주체 | 읽는 화면 |
| :--- | :--- | :--- | :--- | :--- |
| `confirm.json` | 교각 폴더 | 사람 확정 기록(확정자·시각·해시) | **사람 조작만** | S03~S05, S10, S12 |
| `autocheck.json` | 교각 폴더 | 묶음 해시·앱 확인 결과·근거 목록·검사 버전·시각 | 앱 확인 엔진 | S03~S05, S10, S12 |
| `variability.json` | 템플릿 폴더 | 묶음·필드 경로 → 변수 등급 | 템플릿 담당자 | S04~S06, S13 |
| 앵커 | 템플릿 폴더 | 값이 도면의 어디서 읽혔는지 기록 | 판독 스크립트 | S07, S08 |
| 교각 메타 | 교각 폴더 | 적합성 6문항 답, 형식 불일치 여부 | S02 | S03, S04 |
`autocheck.json`의 근거 항목은 다음 필드를 가진다: `check`(C1~C8), `result`(match / caution / mismatch / no_basis / n/a), `sentence`(근거 문장), `refs`(도면 이름·뷰·좌표·핸들 또는 표 행), `confidence`(A / B / C / none), `independence`(high / low).
---
## 7. 비기능 요구
| 항목 | 요구 |
| :--- | :--- |
| 성능 | 필요 묶음 전체의 앱 확인(C1~C8, 도면 재대조 제외)을 10초 이내에 마친다 `[추론]`. 도면 재대조(C2)가 포함되면 진행 표시로 대신한다 |
| 신뢰 | 앱 확인 서버 경로는 `confirm.json`을 쓰지 않는다. 자동화 시험이 이를 확인한다 |
| 보호 | 시험은 실제 교각 데이터가 아닌 시험 폴더에서만 확정 흐름을 실행한다 (OPEN #25 사례) |
| 이력 | 앱 확인 결과는 검사 버전과 시각을 남긴다. 규칙이 바뀌면 이전 결과에 '이전 버전' 표시를 붙인다 |
| 호환 | 기존 `confirm.json`·`params.json`·`expected.json`을 바꾸지 않는다. 새 파일을 추가하는 방식이다 |
| 테마 | 라이트를 기본으로 하고 다크 토큰을 같이 정의한다. 다크 전수 시험은 사용자 요청 시 수행한다 `[추론]` |
---
## 8. 수용 기준 요약 (기획서 6장 승계)
| # | 기준 | 시험 방법 |
| :-: | :--- | :--- |
| 1 | 모든 필수 묶음에 앱 확인 상태가 있다 | 5개 교각 데이터에서 S04 행 전수 확인 |
| 2 | '근거 없음'을 숨기지 않는다 | 근거 없음 묶음이 표·요약 띠·S10 제외 목록에 모두 나온다 |
| 3 | 거짓 일치 0, 거짓 불일치 0 | 4개 도면 기준. 값을 일부러 바꾼 음성 시험에서 '불일치'가 나온다 |
| 4 | 앱이 확정 기록을 만들지 않는다 | 앱 확인 실행 전후 `confirm.json` 해시 불변 |
| 5 | 기존 시험 FAIL 0 | 기존 회귀 시험 일괄 |
| 6 | 화면 접근성 | 모든 상태 셀에 글자 100%, 1280px 가로 스크롤 0, 키보드 전 기능, axe 위반 0 |
| 7 | 디자인 시스템 적합 | `design.md` 11장 점검표 전항 |
---
## 9. 단계와 화면의 대응
| 단계 | 켜지는 화면·기능 | 선행 |
| :--- | :--- | :--- |
| A 분류·표시 | S01 진행 요약, S02, S03 결론 띠(확정 기준), S04의 변수 등급·값 출처·원본값, S06 등급 안내·V3 차단, S13 | 변수 등급 파일 작성 |
| B 앱 확인 엔진 | S04 앱 확인 열·요약 띠·전체 확인, S05, S10, S12 | A |
| C 표 밖 치수 탐색 | S07 표 밖 치수 구역, S08, 앵커 | B 검증 후 |
| D 통합·문서 | S11 문구, 사용설명서 개정, 회귀 | A~C |
---
## 10. 리스크
| # | 리스크 | 대응 |
| :-: | :--- | :--- |
| R1 | 사용자가 '일치'를 '도면이 맞다'로 읽는다 | S05 근거 문장에 비교 대상을 쓰고 한계 문장을 고정으로 넣는다 |
| R2 | 요약 띠·표 숫자가 서로 어긋난다 | 같은 집계 함수를 쓰고 수용 기준 4로 시험한다 |
| R3 | 일괄 확정이 다시 도장이 된다 | S10 근거표 열람 조건과 제외 목록, 사유 필수 |
| R4 | 상태 어휘가 늘어나 판독이 어려워진다 | 4.2절에서 어휘를 고정하고 배지는 열마다 4종 이내 |
| R5 | 디자인 시스템에 없는 요소가 필요하다 | `design.md` 10장의 공백 목록으로 제안하고 승인 전에는 조합 컴포넌트로 대신한다 |
---
## 11. 결정 사항
### 11.1 기획서에서 승계한 결정 (기획서 7.2절)
| # | 내용 | 상태 |
| :-- | :--- | :--- |
| D1 | 확정 주체: 앱 확인 + 사람 확정 | **결정 2026-10-14** |
| D2 | 표 밖 치수 탐색: 규칙 + 템플릿 앵커 | **결정 2026-10-14** |
| D3 | F(고정) 항목 편집 처리 | **결정 2026-10-15 / 심영표: 권고안** — 경고 + 사유 입력, 차단은 생성기 개발자 모드에서만 |
| D4 | 일괄 확정 조건 | **결정 2026-10-15 / 심영표: 권고안** — '일치' 묶음만, 근거표 열람 뒤 확정 |
| D5 | 착수 범위·순서 | **결정 2026-10-15 / 심영표: 권고안** — A → B 먼저, C는 B 검증 뒤 |
| D6 | 앵커 소급 추출 | **결정 2026-10-15 / 심영표: 권고안** — 옥성교·Ramp-A교부터, 나머지는 필요 시. S07·S08에만 영향 |
### 11.2 화면 설계에서 새로 생긴 결정
| # | 내용 | 상태 |
| :-- | :--- | :--- |
| UX1 | 디자인 시스템 적용: 기존 R8 기준(맑은 고딕 14px·흰 배경·철근 팔레트 판정색)을 Prism 앱 프로파일(`design.md`)로 대체한다 | **결정 2026-10-15 / 심영표: 대체.** `디자인지침_경로.md` 갱신 완료 |
| UX2 | 판정 색: Prism 상태색을 쓴다. 철근 팔레트는 Rhino 모델과 범례에만 둔다 | **결정 2026-10-15 / 심영표: 변경** |
| UX3 | 서체: Pretendard를 앱에 내장한다 | **결정 2026-10-15 / 심영표: 내장.** 파일을 `web/static/fonts/PretendardVariable.woff2`에 배치(OFL). 적용은 A단계 구현 시 |
| UX4 | 상태 마커(●▲■◆◇) 도형 사용 | **결정 2026-10-15 / 심영표: 승인** |
| UX5 | 다크 테마 시험 | **결정 2026-10-15 / 심영표: 제외.** 토큰은 정의하되 시험하지 않는다 |
| UX6 | 핵심 화면 HTML 시안 | **결정 2026-10-15 / 심영표: 제작.** `시안/index.html` (S04·S05·S07·S10 동작 시안) |
## 부록 A. 핵심 화면 와이어프레임 (정보 구조, 외형 아님)
### S04 입력 묶음 확인·확정 (1280px)
```
┌ 헤더 ──────────────────────────────────────────────────────────────────────────────┐
│ 서비스명 · S04 · 옥성교P1 · 저장 14:32 · 로컬 │
├ 레일 ┬─────────────────────────────────────────────────────────────────────────────┤
│ 1 도면 ✓│ 2. 입력 묶음 확인·확정 │
│ 2 묶음 ●│ ┌ 요약 띠 ─────────────────────────────────────────────────────────────┐ │
│ 3 게이트│ │ 필요 27개 중 일치 19 · 주의 4 · 불일치 1 · 근거 없음 3 · 사람 확정 0 │ │
│ 4 보고서│ │ 마지막 앱 확인 14:30 · 검사 v0.1 · 재확인 필요 0 │ │
│ │ └──────────────────────────────────────────────────────────────────────┘ │
│ │ 필터: [전체 ▾] [전체 확인] [일치분 확정] (주 버튼 1개) │
│ │ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ │묶음 │등급│출처 │앱 확인 │사람 확정 │확인 │ │
│ │ │G4.stirrups │V2 │표 밖치수│● 일치 │미확정 │ │ │
│ │ │footing │V1 │원본값 │◆ 근거 없음 │미확정 │[확인 실행] │ │
│ │ │coping_rebar │V3 │스크립트│▲ 주의 │미확정 │ │ │
│ │ │… │ │
│ │ └────────────────────────────────────────────────────────────────────┘ │
│ │ ▸ 확정 불필요(참고) 5개 (접힘) │
└─────────┴─────────────────────────────────────────────────────────────────────────┘
```
### S05 근거 보기 (1280px)
```
├ 레일 ┬───────────────┬────────────────────────────────────────────────────────────┤
│ │ 묶음 이동 │ G4.stirrups V2 목록 변수 앱 확인: ● 일치 사람: 미확정 │
│ │ ● G4.stirrups │ ┌ 근거 표 ──────────────────────────────────────────────┐ │
│ │ ▲ coping_rebar │ │ C1 출처 ● 일치 표 밖 치수(D1 정면도) … [도면 보기] │ │
│ │ ◆ footing │ │ C4 내부 ● 일치 사슬 합 9,000 = 전체 치수 9,000 │ │
│ │ … │ │ C5 독립 ● 일치 params와 expected의 근거가 다르다 │ │
│ │ │ └────────────────────────────────────────────────────────┘ │
│ │ │ 이 결과는 후보를 만든 규칙과 값이 같다는 뜻이다. 도면이 맞다는 판정이 아니다. │
│ │ │ ┌ 값 비교 ┐ ┌ 확정 ─────────────────────────────────────┐ │
│ │ │ │ … │ │ 확정자 [ ] 사유 [ ] [확정] │ │
│ │ │ └─────────┘ └────────────────────────────────────────────┘ │
└──────┴───────────────┴────────────────────────────────────────────────────────────┘
```
### S10 일괄 확정 확인 (모달)
```
┌ 일치 묶음 확정 ─────────────────────────────────────────────┐
│ 일치 19개를 확정한다. 주의 4 · 불일치 1 · 근거 없음 3 · 원본값 1은 대상이 아니다. │
│ ┌ 근거표 (끝까지 내려 확인) ─────────────────────────────┐ │
│ │ G4.stirrups 사슬 합 9,000 = 전체 치수 9,000 │ │
│ │ … │ │
│ └────────────────────────────────────────────────────────┘ │
│ ▸ 제외된 묶음 9개 (누르면 근거 보기) │
│ 확정자 [ ] │
│ [취소] [확정] │
└──────────────────────────────────────────────────────────────┘
```
### S07 표 후보값·표 밖 치수
```
요약: 표 후보 14 · 표 밖 치수 후보 31 (A 18 · B 9 · C 4) · 다름 12 · 근거 없음 3
┌────────────────────────────────────────────────────────────────────────────────┐
│반영│대상 │현재 │후보 │구분 │상태│등급│도면 위치 │근거 │
│ ☐ │footing.height │2,500 │2,500 │표 │같음│ — │D1 표 행 3 │보기 │
│ ☑ │coping.z_tip │31,232 │31,231.5│표 밖 치수│다름│ B │D1 정면 (225817,…) │보기 │
└────────────────────────────────────────────────────────────────────────────────┘
[선택한 후보값 반영] 근거 없음 3개 ▸ (후보 없음, 문자 검색으로 직접 찾는다)
```
---
## 부록 B. 근거 파일
| 구분 | 경로 |
| :--- | :--- |
| 기획서 | `DOC/01_기획/기획_입력묶음확인확정개편_2026-10-14_v0.1.00.md` |
| 디자인 시스템 | `RAG/design system_v0.1.1/` (`Design.md`, `design.tokens.json`, `design.components.md`, `webapp/module.webapp.md`) |
| 현행 화면 | `Code/교각모델링앱/web/templates/` (`index`, `pier`, `section`, `candidates`, `search`, `gates`) |
| 화면 설계 규칙 | `DOC/01_기획/웹앱디자인시안/design.md` |
| 디자인 지침 기록 | `context-migration/디자인지침_경로.md` (R8) |
@@ -0,0 +1,740 @@
# T형 교각 모델링 앱 디자인 시스템 — Prism 앱 프로파일
| 버전 | 수정일 | 수정자 | 주요 변경 내용 | 비고 |
| :--- | :--- | :--- | :--- | :--- |
| v0.1.00 | 2026-10-15 | 심영표 | 신규 작성 — Prism Design System v0.1.1을 교각 모델링 앱에 적용하는 프로파일(토큰·상태 체계·조합 컴포넌트·문안·점검표) | 초안 `[미확정]`. PRD v0.1.00의 화면 13종이 소비한다 |
| v0.1.01 | 2026-10-15 | 심영표 | PRD UX1~UX6 결정 반영: R8 대체 확정, Pretendard 내장 파일 지정, 마커 도형 승인, 다크 시험 제외 | 사용자 결정 |
| 메타 | 값 |
| :--- | :--- |
| 문서 | 앱 프로파일 (Prism 4계층 중 **Layer 3 모듈의 앱 한정 보강**) |
| 상위 | `RAG/design system_v0.1.1/` — `Design.md` 0.1.1 · `design.tokens.json` 0.1.1 · `design.foundations.md` · `design.medium.screen.md` · `design.components.md` · `webapp/module.webapp.md` 0.1.0 |
| 브랜드 | `saman` (활성). 앱 전용 브랜드를 새로 만들지 않는다 |
| 매체 | `screen.web` (로컬 데스크톱, 1280px 이상) |
| 독자 | 화면을 구현하는 에이전트와 개발자(1차), 디자이너(2차) |
| 짝 문서 | `PRD.md` (같은 폴더) — 이 문서의 규칙이 어느 화면에서 쓰이는지는 PRD 5장이 정한다 |
---
## 1. 위치와 우선순위
### 1.1 목적
PRD의 13개 화면을 **같은 부품과 같은 규칙으로** 만들도록 디자인을 시스템으로 묶는다. 이 문서는 Prism을 대체하지 않고, Prism이 정하지 않은 앱 고유의 부분(상태 어휘의 형태 부호, 조합 컴포넌트, 문안)만 더한다.
### 1.2 값 충돌 우선순위
Prism `Design.md` 1.4절을 그대로 따른다.
| 순위 | 출처 |
| :-: | :--- |
| 1 | 사용자의 명시적 지시(해당 화면 한정) |
| 2 | `design.tokens.json` 정식값 |
| 3 | Prism 각 `.md`의 표·수치 |
| 4 | **이 문서** — Prism과 충돌하면 Prism이 이긴다 |
> 이 문서의 어느 규칙이 Prism과 충돌하면 이 문서를 고친다. 충돌을 해소하려고 Prism 값을 바꾸지 않는다.
### 1.3 기존 기준(R8)과의 관계
`context-migration/디자인지침_경로.md`의 R8(2026-10-12)은 조직 디자인 지침이 없던 때의 대체 수용 기준이다(맑은 고딕 14px, 흰 배경, 철근 팔레트 판정색). R8은 2026-10-15에 **대체되었다**(PRD 11.2절 UX1, `디자인지침_경로.md` 갱신). 두 기준이 같이 적용되는 기간을 두지 않는다.
R8에서 **유지하는** 조건은 세 가지이다.
| R8 조건 | 이 문서에서 |
| :--- | :--- |
| 색과 글자를 함께 쓴다(색만으로 구분 금지) | 6장으로 강화(형태 부호 추가) |
| 1280px 폭에서 표 외 가로 스크롤 없음 | 4.2절 |
| 화면 문구 한국어 | 9장 |
철근 팔레트(D32 적·D29 주황 등)는 **Rhino 모델 철근과 범례 전용**이다. 화면의 판정·상태 표시에는 쓰지 않는다.
---
## 2. 모듈 선언
```yaml
module:
id: pier-app
name: T형 교각 모델링 앱
base_module: webapp # RAG/design system_v0.1.1/webapp/module.webapp.md
brand: saman
medium: screen
medium_variant: screen.web
dual_medium: false
format: desktop-first # lg(1240~1599) 기준 설계, md 이하는 지원하되 시험 제외
min_width_px: 1280
columns: 12
type_set: compact
patterns: [w1-shell, w2-dashboard, w3-form, w4-document, w5-list-detail]
color_theme: [light, dark] # light 기본, dark는 토큰만 정의
accessibility: WCAG 2.2 AA
depends_on: { design_system: 0.1.1, webapp_module: 0.1.0 }
```
화면별 패턴 배정(Prism webapp 10절 1단계)은 PRD 3.2절 일람의 '패턴' 열을 따른다.
---
## 3. 토큰
### 3.1 원칙
| ID | 규칙 |
| :--- | :--- |
| **TK-1** | 산출물 CSS는 **변수 정의부 밖에 HEX·px 리터럴을 두지 않는다** (Prism W1·W2). 보더·헤어라인도 stroke 토큰을 쓴다 |
| **TK-2** | 3.2절 `prism.sys` 블록은 Prism `webapp` 3절의 정본을 **그대로** 옮긴 것이다. 값을 고치지 않는다 |
| **TK-3** | 3.3절 `comp` 토큰은 `sys`·`ref`만 참조한다. 리터럴을 두지 않는다 |
| **TK-4** | `ref` 토큰(원색)은 컴포넌트에서 직접 참조하지 않는다 |
| **TK-5** | 철근 팔레트는 UI 토큰이 아니다. Rhino 범례 이미지로만 쓴다 |
| **TK-6** | 새 토큰이 필요하면 `comp` 계층에 추가하고 이 문서의 버전을 올린다. `sys` 이하는 건드리지 않는다 |
### 3.2 sys 토큰 (Prism 정본, 수정 금지)
```css
:root {
/* color (light) */
--prism-bg-canvas: #F4F3F0;
--prism-bg-card: #FFFFFF;
--prism-text-body: #474235;
--prism-text-heading: #1F524B;
--prism-text-subtle: #474235;
--prism-text-accent: #D05C26; /* 대형 전용(24px 이상 또는 19px 이상 Bold) */
--prism-border-default: #474235;
--prism-border-strong: #1F524B;
--prism-status-do: #005A4E; --prism-status-do-bg: #DCEAE6;
--prism-status-dont: #8F2A11; --prism-status-dont-bg: #F6DFDA;
--prism-status-caution: #6B4A00; --prism-status-caution-bg: #F5E7CC;
--prism-status-tip: #0A4E86; --prism-status-tip-bg: #DDE8F3;
/* space (기본 8px = 0.5rem) */
--prism-space-025: 0.125rem; --prism-space-050: 0.25rem; --prism-space-100: 0.5rem;
--prism-space-150: 0.75rem; --prism-space-200: 1rem; --prism-space-300: 1.5rem;
--prism-space-400: 2rem; --prism-space-600: 3rem; --prism-space-800: 4rem;
/* type */
--prism-type-caption: 0.8125rem; --prism-lh-caption: 1.55;
--prism-type-body-sm: 0.875rem; --prism-lh-body: 1.70;
--prism-type-body: 1rem; --prism-type-body-lg: 1.125rem;
--prism-type-title-sm: 1.25rem; --prism-lh-title: 1.50;
--prism-type-title: 1.625rem; --prism-type-title-lg: 2rem;
/* radius · stroke · z · motion · focus · measure */
--prism-radius-sm: 4px; --prism-radius-md: 8px; --prism-radius-lg: 12px; --prism-radius-pill: 9999px;
--prism-stroke-hairline: 1px; --prism-stroke-regular: 2px; --prism-stroke-bold: 3px;
--prism-z-raised: 100; --prism-z-sticky: 200; --prism-z-dropdown: 300; --prism-z-scrim: 400;
--prism-z-modal: 500; --prism-z-popover: 600; --prism-z-toast: 700; --prism-z-tooltip: 800;
--prism-scrim: rgba(0, 0, 0, 0.32);
--prism-motion-fast: 100ms; --prism-motion-moderate: 200ms; --prism-motion-slow: 300ms;
--prism-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--prism-focus-ring: 3px; --prism-focus-offset: 2px;
--prism-measure-max: 38em;
}
:root[data-theme="dark"] {
--prism-bg-canvas: #111111; --prism-bg-card: #111111;
--prism-text-body: #F4F3F0; --prism-text-heading: #F4F3F0; /* 다크에서 그린 텍스트 금지 */
--prism-text-subtle: #D3D3D3; --prism-text-accent: #D05C26;
--prism-border-default: #D3D3D3; --prism-border-strong: #F4F3F0;
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E;
--prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00;
--prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { /* data-theme dark 블록과 같은 값 세트를 적용 */ }
}
```
Prism 토큰 파일에 있는 `--prism-dv-*`(데이터 시각화 6색), `--prism-type-display*`, `--prism-ease-enter/exit`는 이 앱에서 쓰지 않아 옮기지 않는다. 쓰게 되면 정본에서 그대로 옮긴다.
### 3.3 comp 토큰 (앱 전용, 이 문서가 정의)
모두 `sys`를 참조한다. 라이트·다크 전환은 `sys` 값이 바뀌므로 `comp`는 두 번 정의하지 않는다.
```css
:root {
/* 표면 */
--prism-comp-surface-page: var(--prism-bg-canvas);
--prism-comp-surface-card: var(--prism-bg-card);
--prism-comp-surface-sunken: var(--prism-bg-canvas);
--prism-comp-line: var(--prism-border-default);
--prism-comp-line-strong: var(--prism-border-strong);
/* 상태색 4쌍의 앱 의미 */
--prism-comp-state-good-fg: var(--prism-status-do); --prism-comp-state-good-bg: var(--prism-status-do-bg);
--prism-comp-state-bad-fg: var(--prism-status-dont); --prism-comp-state-bad-bg: var(--prism-status-dont-bg);
--prism-comp-state-warn-fg: var(--prism-status-caution); --prism-comp-state-warn-bg: var(--prism-status-caution-bg);
--prism-comp-state-info-fg: var(--prism-status-tip); --prism-comp-state-info-bg: var(--prism-status-tip-bg);
/* 배지 */
--prism-comp-badge-pad-y: var(--prism-space-025);
--prism-comp-badge-pad-x: var(--prism-space-100);
--prism-comp-badge-radius: var(--prism-radius-sm);
--prism-comp-badge-font: var(--prism-type-caption);
--prism-comp-badge-line-solid: var(--prism-stroke-regular);
--prism-comp-badge-line-bold: var(--prism-stroke-bold);
/* 표 */
--prism-comp-table-cell-pad-y: var(--prism-space-100);
--prism-comp-table-cell-pad-x: var(--prism-space-150);
--prism-comp-table-font: var(--prism-type-body-sm);
--prism-comp-table-head-bg: var(--prism-bg-canvas);
--prism-comp-table-rule: var(--prism-stroke-hairline);
/* 레이아웃 */
--prism-comp-rail-width: 15rem; /* 240px */
--prism-comp-header-height: 3.5rem; /* 56px */
--prism-comp-hit-min: 2.75rem; /* 44px */
/* 도면 이미지 */
--prism-comp-drawing-frame-line: var(--prism-border-default);
}
```
### 3.4 상태 시프트 토큰 (공백 — 산출 필요)
Prism은 hover ±4·active ±8·selected ±12의 **GRADE 시프트**를 정하지만(screen 6.2절), 시프트된 HEX 표는 토큰 파일에 없다. 이 문서는 값을 지어내지 않는다.
| 처리 | 내용 |
| :--- | :--- |
| 지금 | hover는 보강일 뿐이므로(HV-3) 색 변화 없이도 모든 기능이 성립한다. 시안 단계에서는 hover에 밑줄 또는 보더 굵기 변화만 쓴다 |
| 구현 전 | `design.foundations.md` 2장의 GRADE 산출식으로 green·brown·white 면의 ±4·±8·±12 HEX를 산출해 `comp` 토큰(`--prism-comp-*-hover/-active/-selected`)으로 추가한다 |
| 책임 | 산출은 Prism 담당자 승인 사항(공백 목록 10장 G7) |
---
## 4. 레이아웃
### 4.1 브레이크포인트
Prism webapp 2절을 따른다. 임의 브레이크포인트를 두지 않는다(WA-1).
| BP | 범위(px) | 컬럼 | 마진 | 거터 | 이 앱에서 |
| :--- | :--- | :-: | ---: | ---: | :--- |
| `lg` | 1240–1599 | 12 | 32 | 24 | **설계 기준.** 1280px에서 모든 화면이 성립해야 한다 |
| `xl` | 1600– | 12 | 24 | 24 | 컨테이너 1440 고정 |
| `md` | 904–1239 | 8 | 32 | 24 | 지원하되 시험 제외. 레일은 상단으로 접힌다 |
| `sm` `xs` | 0–903 | 8 · 4 | 24 · 16 | 16 | 비대상. 가로 스크롤 없이 단일 열로만 표시 |
### 4.2 1280px 규칙
| ID | 규칙 |
| :--- | :--- |
| **LY-1** | 1280px 폭에서 **표 외** 가로 스크롤이 없다 |
| **LY-2** | 표는 가로 스크롤을 허용한다(MS-3 예외). 스크롤 시 첫 열(묶음 이름)과 헤더를 고정한다 |
| **LY-3** | 연속 문장 블록은 `max-width: var(--prism-measure-max)`. 표·도면 이미지·근거 표는 제외 |
| **LY-4** | 최상위 영역만 그리드에 맞춘다. 영역 내부는 간격 토큰으로 배치한다 (WA-4) |
### 4.3 앱 셸 (W1)
```
┌ header (sticky, comp-header-height) ──────────────────────────────────────────┐
│ 서비스명 │ 화면 ID · 교각 │ │ 저장 시각 · 환경(로컬) │ 테마 │
├ rail (comp-rail-width) ┬ main ────────────────────────────────────────────────┤
│ 교각 목록 │ 화면 제목 (h1) │
│ ─ 교각 이름 │ 결론 영역 (요약 띠 · 판정) │
│ 1 도면 │ 본문 영역 (표 · 비교 · 폼) │
│ 2 입력 묶음 │ │
│ 3 게이트 │ │
│ 4 보고서 │ │
└─────────────────────────┴──────────────────────────────────────────────────────┘
```
| 요소 | 규칙 |
| :--- | :--- |
| header | 메타 4슬롯(화면 ID·권한·갱신 시각·환경)을 쓴다. 권한 슬롯은 로컬 앱에서 "담당자"로 고정 표시한다 |
| rail | `stepper` 컴포넌트(단계 3~7개 규칙, SP-2)로 구현한다. 완료·현재·미완을 형태로 구분한다(SP-3) |
| main | 첫 영역이 결론이다(PD-1). 결론 영역은 viewport당 1개 |
| skip link | 모든 화면 첫 요소가 "본문 바로가기"다 |
### 4.4 패턴 적용
| 패턴 | 화면 | 이 앱에서의 구체화 |
| :--- | :--- | :--- |
| W1 앱 셸 | 전 화면 | 4.3절 |
| W2 대시보드 | S03, S11 | 카드 그리드. 존당 stat 3개 이하. 1280px에서 3열 |
| W3 폼 | S02, S06, S09 검색 | 1열 수직, 다열 폼 금지, 본문 폭 38em, 주 버튼 1개. 단 `S06`의 값 편집창(JSON)은 38em 예외를 둔다(코드 영역) |
| W4 문서 | S12, S13 | 좌측 목차 4col + 본문 8col, 본문 38em |
| W5 목록-상세 | S01, S04, S05, S07 | 목록이 7열 이상의 표이면 분할 뷰를 쓰지 않고 **전폭 표 + 별도 상세 화면**으로 나눈다. S05만 `4:8` 분할 |
---
## 5. 타이포그래피
### 5.1 서체
| 슬롯 | 값 |
| :--- | :--- |
| 패밀리 | Pretendard. 폴백 `-apple-system, BlinkMacSystemFont, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif` |
| 웨이트 | 400 · 500 · 700 |
| 숫자 | `font-variant-numeric: tabular-nums` 전 화면 적용 (표의 수치 열은 필수) |
| 내장 | `web/static/fonts/PretendardVariable.woff2`(OFL, 가변 폰트)를 `@font-face`로 불러온다 (PRD UX3 결정). 파일이 없으면 폴백 맑은 고딕으로 렌더되며, 이 경우에도 5.2절 크기는 유지한다 |
`html { font-size: ... }` 선언을 금지한다(CSS-1).
### 5.2 크기 사용표
| 용도 | 토큰 | 웨이트 | 비고 |
| :--- | :--- | :-: | :--- |
| 화면 제목(h1) | `type-title` | 700 | 한 화면에 1개. 20자 이내 |
| 영역 제목(h2) | `type-title-sm` | 500 | 규정문체 명사형 |
| 소영역 제목(h3) | `type-body-lg` | 700 | |
| 본문 | `type-body` | 400 | 38em 이내 |
| **표 셀** | `type-body-sm` | 400 | 표 최소 13px (TB 규정) |
| 배지·캡션·출처 | `type-caption` | 500 | |
| 큰 숫자(요약 띠) | `type-title` | 700 | 숫자만 키운다 |
### 5.3 기술 표기 (해시·핸들·좌표·키)
해시 앞 16자, DXF 핸들, 좌표, JSON 키는 **본문과 구분**해야 한다. Prism에는 등폭 서체가 없다(공백 G3). 임시 규칙은 다음과 같다.
| 규칙 | 내용 |
| :--- | :--- |
| 형태 | 보더 1px(hairline)이 있는 인라인 칩. 폰트는 본문과 같고 `tabular-nums` |
| 복사 | 칩을 누르면 복사한다. 키보드로 접근 가능하다 |
| 줄바꿈 | 칩 내부에서 줄바꿈하지 않는다. 길면 앞 16자만 보이고 전체는 `title` 대신 도움말로 연다 |
| 금지 | 등폭 서체 임의 지정 금지(토큰 외 서체). 승인 후 `sys` 토큰으로 편입 |
---
## 6. 상태 표시 체계
PRD 4.2절의 어휘를 **글자 + 형태 + 색** 세 신호로 부호화한다. 한 열에서 같은 신호를 두 상태가 공유하지 않는다.
### 6.1 형태 신호
| 신호 | 의미 |
| :--- | :--- |
| 선행 마커 `●` | 정상·완료·충족 |
| 선행 마커 `▲` | 주의·확인 필요 |
| 선행 마커 `■` | 실패·불일치 |
| 선행 마커 `◆` | 변경됨·재실행 필요 |
| 선행 마커 `◇` | 근거 없음(빈 마름모) |
| 보더 실선 2px | 확정된 상태 |
| 보더 점선 2px | 근거·확정이 없는 상태 |
| 보더 굵게 3px | 실패 |
마커는 CSS로 그리는 도형이며 아이콘이 아니다(PRD UX4: 2026-10-15 승인). 이모지와 아이콘 폰트를 쓰지 않는다 (IC-3).
### 6.2 앱 확인
| 값 | 글자 | 마커 | 보더 | 전경·배경 |
| :--- | :--- | :-: | :--- | :--- |
| 일치 | 일치 | ● | 실선 2px | good-fg / good-bg |
| 주의 | 주의 | ▲ | 실선 2px | warn-fg / warn-bg |
| 불일치 | 불일치 | ■ | 굵게 3px | bad-fg / bad-bg |
| 근거 없음 | 근거 없음 | ◇ | 점선 2px | text-body / 투명 |
| 해당 없음 | `—` | — | — | text-body (배지 아님) |
| 확인 안 함 | [확인 실행] 버튼 | — | — | (배지 아님) |
### 6.3 사람 확정
| 값 | 글자 | 마커 | 보더 | 전경·배경 |
| :--- | :--- | :-: | :--- | :--- |
| 확정 | 확정 | ● | 실선 2px | `surface-emphasis`: green 면 + white 글자 |
| 미확정 | 미확정 | — | 점선 2px | text-body / 투명 |
| 변경됨 | 변경됨 | ◆ | 실선 2px | warn-fg / warn-bg |
앱 확인(6.2)이 상태색 면을 쓰고 사람 확정(6.3)이 green 면을 쓰므로, 한 행 안에서 두 배지가 **색·형태 모두 다르다**.
### 6.4 변수 등급
| 값 | 글자 | 보더 | 면 |
| :--- | :--- | :--- | :--- |
| V1 | V1 값 | 실선 2px | 없음 |
| V2 | V2 목록 | 실선 2px | 없음 |
| V3 | V3 생성 | 점선 2px | 없음 |
| F | F 고정 | 실선 2px | green 면 + white 글자 |
편집 가능(V1·V2) → 스크립트 필요(V3, 점선) → 편집 불가(F, 면 채움)의 순서로 시각 무게가 커진다.
### 6.5 신뢰 등급
| 값 | 글자 | 보더 |
| :--- | :--- | :--- |
| A | 등급 A | 실선 2px |
| B | 등급 B | 실선 2px + 마커 ▲ |
| C | 등급 C | 점선 2px |
| 없음 | 후보 구역에 행으로 나오지 않는다. '근거 없음' 목록에만 나온다 | — |
### 6.6 게이트 판정
| 값 | 글자 | 마커 | 보더 | 전경·배경 |
| :--- | :--- | :-: | :--- | :--- |
| PASS | PASS | ● | 실선 2px | good-fg / good-bg |
| FAIL | FAIL | ■ | 굵게 3px | bad-fg / bad-bg |
| 계획 FAIL | 계획 FAIL | ▲ | 실선 2px | warn-fg / warn-bg |
| 재실행 필요 | 재실행 | ◆ | 실선 2px | info-fg / info-bg |
### 6.7 규칙
| ID | 규칙 |
| :--- | :--- |
| **ST-1** | 배지 텍스트는 6자 이내(BD-3). 문장을 넣지 않는다 |
| **ST-2** | 한 열의 배지 종류는 4종 이내(BD-2). 5종 이상이 필요하면 열을 나눈다 |
| **ST-3** | 색을 지우고(그레이스케일) 읽어도 모든 상태가 구분되어야 한다 |
| **ST-4** | 배지 색은 악센트 경제에 세지 않는다(BD-4). 그러나 **orange는 상태에 쓰지 않는다** |
| **ST-5** | 다크 테마에서 상태색 쌍은 `sys` 정의대로 전경·배경이 반전된다. `comp`가 따로 정의하지 않는다 |
| **ST-6** | 배지 안에 `aria-label`로 "앱 확인: 일치"처럼 열 이름을 포함해 낭독하게 한다 |
---
## 7. 컴포넌트
### 7.1 Prism 컴포넌트 사용표
Prism이 정한 20종 중 이 앱이 쓰는 것과 쓰는 곳이다. 카탈로그에 있는 변형만 쓴다.
| 컴포넌트 | 변형 | 쓰는 곳 | 비고 |
| :--- | :--- | :--- | :--- |
| `header` | 1 | 전 화면 셸 | 메타 4슬롯 |
| `eyebrow` | 2 | 화면 제목 위 단계 표기("2단계") | |
| `rule` | 1 | 영역 구분 | |
| `card` | 4 | S03 단계 카드, S05 값 비교 | |
| `table` | `base` `compact` | S01, S04, S05, S07, S11 | S04는 `compact` + sticky |
| `stat` | 1 | S03·S04 요약 띠의 숫자 | 존당 3개 이하 |
| `specsheet` | 1 | S05 결론(묶음 메타), S03 도면 목록 | |
| `directive` | `caution` `tip` 등 3 | 형식 불일치 경고, 원본값 경고, 한계 문장 | |
| `badge` | 4 | 6장의 모든 상태 | |
| `scope` | 1 | 각 화면 최상단 "적용 조건/제외 조건" | 해당 없음 즉시 이탈 |
| `toc` | 1 | S13 좌측 목차 | |
| `stepper` | 1 | 레일의 1~4단계 | |
| `xref` | 1 | 화면 간 링크(S05 → S06 등) | 방향어 금지 |
| `mediaframe` | 1 | S08 도면 이미지 | 캡션 필수 |
| `button` | `primary` `secondary` `quiet` `danger` | 전 화면 | 화면당 primary 1개 |
| `input` | 1 | 이름·확정자·사유·검색 | 레이블 필수 |
| `modal` | 1 | S08, S10 | 포커스 트랩·Esc |
| `toast` | 1 | 보조 알림 | 단독 전달 금지 |
| `disclosure` | 1 | 참고 묶음 접기, 제외 목록 | 인쇄 시 전개 |
| `chartframe` | — | **쓰지 않는다** | |
`hero`, `footer`는 미선별이며 이 앱에서 쓰지 않는다.
### 7.2 조합 컴포넌트 (이 문서가 정의)
Prism 카탈로그의 컴포넌트를 **조합·제약**해 만든 앱 전용 구성이다. 새 원시 컴포넌트가 아니므로 카탈로그 외 0건 원칙(webapp A-검증)에 어긋나지 않는다. 단, 조합이 반복되므로 이름을 붙여 일관성을 보장한다.
#### C-1 요약 띠 (`summary-strip`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | `stat`(숫자 3개 이하의 강조) + `badge`(상태별 개수) + 갱신 시각 캡션 |
| 해부 | 결론 문장 → 상태별 개수 → 시각·버전 |
| 규칙 | 각 개수는 필터 버튼이다(`button quiet`). 합계는 표와 같은 집계를 쓴다(PRD 수용 기준). 숫자는 등폭 |
| 접근성 | 전체를 `role="group"` + 요약 문장 `aria-label`. 개수 변경은 `aria-live="polite"` |
| 쓰는 곳 | S03, S04, S07 |
#### C-2 묶음 표 (`section-table`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | `table compact` + sticky 헤더 + 첫 열 고정 + 행 선택 |
| 열 | 묶음 · 변수 등급 · 값 출처 · 앱 확인 · 사람 확정 · 확인 |
| 규칙 | 값 출처 열은 배지가 아닌 글자다(5종, ST-2). 단 '원본값'만 `badge marked`(▲ warn)를 단다. 정렬 기본값 불일치 → 근거 없음 → 주의 → 일치. 빈 셀은 `—` (TB-4) |
| 행 상태 | hover(보강), selected(GRADE 시프트), focus(링). 행 전체가 링크가 아니라 **묶음 이름만** 링크다 |
| 접근성 | `caption` "입력 묶음", `th scope="col"`, 정렬 가능 열은 `aria-sort` |
| 쓰는 곳 | S04 |
#### C-3 근거 행 (`evidence-row`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | 검사 ID(C1~C8) · 검사 이름 · 결과 배지 · 근거 문장 · 도면 위치 칩 · [도면 보기] |
| 규칙 | 근거 문장은 40어절 이내, **비교한 두 값과 출처를 모두 쓴다**. 문장 끝에 독립성(낮음)이 있으면 괄호로 쓴다. 해당 없는 검사는 한 줄로 접는다 |
| 한계 문장 | 표 하단에 고정 `directive tip`: "일치는 후보를 만든 규칙과 값이 같다는 뜻이다. 도면이 맞다는 판정이 아니다." |
| 쓰는 곳 | S05, S10 근거표 |
#### C-4 값 비교 (`diff-row`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | 항목 이름 · 현재 값 · 비교 값(후보/원본) · 차이 표시 |
| 규칙 | 차이는 색이 아닌 **글자(`다름`) + 마커 `▲`**로 표시한다. 같은 항목은 기본으로 접는다. 수치는 우측 정렬·등폭, 단위는 열 헤더에 1회(TB-6) |
| 쓰는 곳 | S05, S07 |
#### C-5 확정 폼 (`confirm-form`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | `input`(확정자) + `input`(사유, 조건부 필수) + `button primary` |
| 규칙 | 필수 표시는 `*`가 아니라 글자 "필수"(IN-4). 조건이 모자라도 버튼은 활성이고 클릭 시 오류 메시지를 낸다(BT-6). 오류는 무엇이·왜·어떻게 + 색·기호·텍스트 3중(IN-2·IN-3) |
| 사유 필수 조건 | 앱 확인이 주의·불일치·근거 없음이거나 F 항목 변경일 때 |
| 쓰는 곳 | S05, S06(F 변경 사유), S10(확정자만) |
#### C-6 일괄 확정 대화상자 (`bulk-confirm-dialog`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | `modal` + 결론 문장 + 근거표(스크롤 영역) + `disclosure`(제외 묶음) + `confirm-form` |
| 규칙 | 근거표 스크롤 끝 도달 여부를 기록하고, 도달 전 [확정]은 오류 메시지를 낸다(비활성 아님). 포커스 트랩·Esc 닫기·배경 스크롤 잠금 (MD-1~5). 모달 안에 모달을 열지 않는다. 제외 묶음 링크는 모달을 닫고 S05로 이동한다 |
| 쓰는 곳 | S10 |
#### C-7 도면 뷰 (`drawing-view`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | `mediaframe` + 캡션(도면·뷰·좌표·핸들 칩) + 확대·축소·이동 버튼 |
| 규칙 | 이미지 대체 텍스트는 대상 설명, 캡션은 필수. 대상 위치 표시는 **링 + 십자선**(색 단독 금지). 이동은 방향키 지원. 새 윈도우 이미지 생성은 `dxftools.render`와 같은 결과를 쓴다 |
| 쓰는 곳 | S08, S05(축소 보기) |
#### C-8 경고 띠 (`state-banner`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | `directive` (`caution`) + 한 문장 + 조치 링크 |
| 종류 | 형식 불일치(닫을 수 없음) · 원본값 포함 · 값 변경됨(재확인 필요) · 앱 확인 규칙 버전 변경 |
| 규칙 | 한 viewport에 2개를 넘기지 않는다. 겹치면 가장 심각한 한 개 + "외 N건" |
| 쓰는 곳 | S03, S04, S05 |
#### C-9 진행 표시 (`run-progress`)
| 항목 | 내용 |
| :--- | :--- |
| 구성 | 단계 문장("11/27 묶음 확인 중") + 막대 + [중단] |
| 규칙 | 색이 아닌 **길이와 숫자**로 읽는다. `role="progressbar"` + `aria-valuenow`. 완료는 토스트가 아니라 결과 영역에 남긴다. 모션은 `transform`만 쓴다 |
| 쓰는 곳 | S04 전체 확인, S11 게이트 실행 |
### 7.3 버튼 배치
| 규칙 | 내용 |
| :--- | :--- |
| 화면당 `primary` 1개 | 다음에 할 일을 가리키는 한 개. S04는 [일치분 확정], S05는 [확정], S10은 [확정] |
| 위험 동작 | `danger` + 확인 절차. 교각 삭제, 반려, 형식 변경 확정 |
| 순서 | 취소·보조 → 주 버튼(우측 끝). 키보드 순서도 같다 |
| 레이블 | 동사로 시작, 8자 이내, 결과를 말한다 (9.2절 표) |
| 면 | `primary`는 `surface-emphasis`(green 면 + white 글자), `secondary`는 보더만, `quiet`는 보더 없는 텍스트, `danger`는 `bad` 상태색 보더 + 확인 절차. hover·active의 면 변화는 3.4절 산출 뒤에 적용한다 |
---
## 8. 인터랙션과 접근성
### 8.1 상태와 포커스
| 항목 | 규칙 |
| :--- | :--- |
| 상태 | default · hover(보강) · focus(링만) · active · selected · disabled(저강조 토큰, 불투명도 금지) · loading(진행 표시, 색 유지) |
| 포커스 링 | 3px 이중 링 + 오프셋 2px, `:focus-visible`. 대체 없는 `outline: none` 금지 (FC-1·FC-2) |
| disabled | 가능하면 활성 + 오류 메시지. 비활성은 권한·물리적 불가능일 때만 |
| hover | `@media (hover: hover)`에 격리. hover 없이 전 기능이 성립해야 한다 |
| 히트 영역 | 44px 이상, 인접 간격 8px 이상 |
### 8.2 키보드
| 화면 | 필수 동작 |
| :--- | :--- |
| S04 | Tab으로 필터 → 도구 줄 → 표(행의 링크·[확인 실행]) 순. 열 머리 Enter로 정렬. 요약 띠 필터는 Enter·Space |
| S05 | 좌측 목록은 위/아래 방향키 이동, Enter로 묶음 전환. [이전][다음] 단축 버튼 제공 |
| S08 | 방향키 이동, `+`/`-` 확대·축소, Esc 닫기 |
| S10 | 포커스 트랩. 근거표는 방향키·PageDown으로 끝까지 내릴 수 있다 |
### 8.3 오류 메시지
3요소(무엇이·왜·어떻게)를 한 문장군으로 쓴다. 색·기호·텍스트를 함께 쓴다.
| 상황 | 문구 예시(60자 이내) |
| :--- | :--- |
| 확정자 없음 | "확정자 이름이 비어 있다. 확정은 사람의 기록이다. 이름을 입력한다" |
| 근거표 미열람 | "근거표를 읽지 않았다. 일치 묶음의 근거를 읽어야 확정한다. 근거표를 끝까지 내린다" |
| 사유 없음 | "사유가 비어 있다. 주의 묶음은 사유가 있어야 확정된다. 사유를 입력한다" |
| 도면 없음 | "도면이 없다. 앱 확인에 쓸 색인이 없다. 1단계에서 도면을 올린다" |
### 8.4 모션
`transform`·`opacity`만 애니메이션한다. `prefers-reduced-motion`에서는 모션을 제거하고 상태 변화는 즉시 반영한다 (MT-3·RM-1). 진행 표시는 정지 상태에서도 숫자로 읽혀야 한다.
---
## 9. 문안 규정
### 9.1 보이스
Prism `saman` 인스턴스를 따른다.
| 속성 | 규칙 | 금지 예 |
| :--- | :--- | :--- |
| 자세 | 단정적·3인칭 | "~인 것 같습니다" → "~이다" 또는 `[추론]` |
| 거리 | 전문가 대 전문가 | "우리 모두", "여러분" |
| 격 | 규정문체(평서 종결). 조작 안내도 같다 | "꼭 확인해 주세요!" → "확인한다" |
| 절제 | 과장·감탄·마케팅 어휘 금지 | "혁신적", "간편하게" |
어미는 4종만 쓴다: `~한다`(의무) · `~할 수 있다`(재량) · `~를 권장한다`(권고) · `~하지 않는다`(금지).
### 9.2 버튼 레이블 (동사로 시작, 8자 이내)
| 동작 | 레이블 | 근거 |
| :--- | :--- | :--- |
| 앱 확인 한 묶음 | 확인 실행 | 검사한다 ≠ 확정한다 |
| 앱 확인 전체 | 전체 확인 | |
| 일치 묶음 확정 | 일치분 확정 | "확정"은 사람의 기록임을 명시. 대상을 앞에 둔다 |
| 확정 | 확정 | |
| 후보 반영 | 후보 반영 | 반영 = 기존 문서에 편입(동사 판별표) |
| 값 저장 | 저장 | |
| 도면 확대 | 도면 보기 | |
| 교각 만들기 | 교각 만들기 | |
| 삭제 | 삭제 | `danger` |
'변경'과 '수정'을 구분한다: 승인 효력에 영향이 있으면 **변경**, 오기 정정이면 **수정**(동사 판별표). 형식 불일치 확정은 "변경"이다.
### 9.3 상태 문구
| 대상 | 문구 |
| :--- | :--- |
| 일치 근거 | "<항목>은 <출처>의 <대상>과 같다" |
| 주의 근거 | "<항목>이 <기준>과 <차이>만큼 다르다. 허용 범위 안이다" |
| 불일치 근거 | "<항목>이 <출처>의 <대상>과 다르다 (현재 <값>, 도면 <값>)" |
| 근거 없음 | "<항목>의 출처를 찾지 못했다. 사람이 도면에서 직접 확인한다" |
| 원본값 | "템플릿 원본과 같다. 새 도면 근거가 없다" |
| 값 변경됨 | "값이 바뀌었다. 이전 확인과 확정이 무효가 되었다" |
### 9.4 금지 목록
| # | 금지 | 근거 |
| :-: | :--- | :--- |
| 1 | 이모지 | IC-3 |
| 2 | 순백 전면 배경 (캔버스는 sand, 카드만 white) | 브랜드 금지 |
| 3 | 오렌지 본문 텍스트, 오렌지 상태 표시 | 브랜드 금지, ST-4 |
| 4 | 다크 배경 위 그린 텍스트 | CSS-4 |
| 5 | 악센트 3개 이상(viewport당) | 악센트 경제 |
| 6 | 그라디언트 배경, 장식 이미지 | 브랜드 금지 |
| 7 | 방향어("위의 표", "아래 버튼", "앞서") | 5.7. 절 번호·항목 이름으로 참조한다 |
| 8 | "100% 자동", "완벽", "간편" 등 과장 표현 | 절제 |
| 9 | 앱이 "확정했다"고 쓰는 문장 | PP-1 |
| 10 | 상태를 색만으로 알리는 표현 | PP-4 |
---
## 10. 디자인 시스템의 공백과 제안
PRD 구현에 필요하지만 Prism에 없는 요소이다. 승인 전에는 **조합 컴포넌트(7.2절)와 임시 규칙**으로 대신한다. 새 아이콘과 새 서체는 Prism 거버넌스(제안 → 승인 대기 → 등재)를 따른다.
| ID | 공백 | 쓰는 곳 | 임시 처리 | 제안 |
| :-: | :--- | :--- | :--- | :--- |
| G1 | 라디오·체크박스·셀렉트 (`input` 변형) | S02 6문항, 필터, S07 반영 선택 | 네이티브 요소 + 토큰 보더·포커스 링 | `input` 변형 3종 신설 |
| G2 | 파일 선택 | 도면 올리기 | 네이티브 `input[type=file]` + 레이블·도움말 | `input` 변형 |
| G3 | 등폭 서체 | 해시·핸들·좌표·JSON | 5.3절 칩 | 등폭 패밀리를 `ref.type`에 편입 |
| G4 | 진행 표시 | 앱 확인, 게이트 실행 | C-9 | `progress` 컴포넌트 신설 |
| G5 | 행 선택·정렬이 있는 표 | S04, S07 | C-2 | `table` 변형 `selectable` |
| G6 | 확대 뷰어 | S08 | C-7 | `mediaframe` 변형 `zoomable` |
| G7 | 상태 시프트 HEX | 전 상호작용 | 3.4절 | foundations 산출표 등재 |
| G8 | 상태 마커 도형(●▲■◆◇) | 6장 | CSS 도형 | 아이콘 거버넌스 승인 (PRD UX4) |
| G9 | 코드·로그 블록 | S11 로그, S06 편집창 | 본문 서체 + 보더 | `codeblock` 신설 |
| G10 | 탭 | (사용하지 않음) | 별도 화면으로 분리 | 필요 시 제안 |
---
## 11. 적용 점검표
PRD 수용 기준 7번의 근거이다. 시안·구현 모두 전항을 확인한다.
### 11.1 자동
| # | 항목 | 기준 |
| :-: | :--- | :--- |
| A1 | 색·치수 리터럴 | 변수 정의부 밖 0건 |
| A2 | root font-size 고정 | 0건 |
| A3 | `outline: none` (대체 없음) | 0건 |
| A4 | z-index, tabindex | 토큰 외 0건 / 양수 0건 |
| A5 | 그리드 정합 | 전 BP 잔차 0 |
| A6 | axe 대비·이름·alt | 위반 0건 |
| A7 | 헤딩 레벨 건너뜀 | 0건 |
| A8 | `prefers-reduced-motion` | 구현 존재 |
| A9 | 히트 영역 | 44px 이상 |
| A10 | 본문 max-width | 38em 이하 |
| A11 | 다크 페어 | green 텍스트 0건 |
| A12 | 텍스트 알파·hover 의존 | 0건 |
| **A13** | **상태 셀 글자** | 모든 상태 셀에 글자 100% |
| **A14** | **배지 길이** | 6자 초과 0건 |
| **A15** | **primary 버튼** | 화면당 1개 |
| **A16** | **orange 사용** | 상태·본문 0건 |
### 11.2 수동
| # | 항목 |
| :-: | :--- |
| V1 | 키보드만으로 PRD 5장 모든 화면의 모든 행동 수행 |
| V2 | 스크린리더로 S04 → S05 → S10 흐름 낭독 |
| V3 | 200% 확대 시 손실·가로 스크롤 없음(표 제외) |
| V4 | 그레이스케일로 6장 모든 상태 구분 |
| V5 | 1280px에서 가로 스크롤(표 외) 0 |
| V6 | 9.4절 금지 목록 0건 |
| V7 | 도면 이미지 캡션 100% |
| V8 | '앱이 확정했다'로 읽히는 문장 0건 |
---
## 12. 거버넌스
| 항목 | 내용 |
| :--- | :--- |
| 변경 | 이 문서의 개정은 버전표에 기록한다. Prism 개정은 이 문서에 자동 소급되지 않는다 (Prism DC-1) |
| 소유 | 앱 화면 규칙은 담당자(심영표). 공백 G1~G10의 Prism 반영은 Prism 소유자가 결정한다 |
| 드리프트 | 3.2절 sys 블록이 `design.tokens.json`·`webapp` 3절과 달라지면 이 문서를 고친다. 정합은 구현 시 빌드 게이트(A1)로 검증한다 |
| R8 | 확정 시 `디자인지침_경로.md`의 R8 행을 대체 표기하고 '디자인 지침 폴더 경로'를 `RAG/design system_v0.1.1`로 갱신한다 (사용자 결정 UX1 후) |
| 면책 | 이 프로파일 준수가 서비스의 접근성·적합성을 보증하지 않는다 (Prism DC-3). 서비스 단위 검증은 별도로 한다 |
---
## 부록 A. 핵심 컴포넌트 스타일 골격
토큰만 참조하는 CSS 골격이다. 값은 3장 변수에서 온다.
```css
.pier-badge {
display: inline-flex; align-items: center; gap: var(--prism-space-050);
padding: var(--prism-comp-badge-pad-y) var(--prism-comp-badge-pad-x);
font-size: var(--prism-comp-badge-font); font-weight: 500;
border: var(--prism-comp-badge-line-solid) solid var(--prism-comp-line);
border-radius: var(--prism-comp-badge-radius);
color: var(--prism-text-body); background: transparent; white-space: nowrap;
}
.pier-badge[data-kind="good"] { color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); border-color: var(--prism-comp-state-good-fg); }
.pier-badge[data-kind="warn"] { color: var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); border-color: var(--prism-comp-state-warn-fg); }
.pier-badge[data-kind="info"] { color: var(--prism-comp-state-info-fg); background: var(--prism-comp-state-info-bg); border-color: var(--prism-comp-state-info-fg); }
.pier-badge[data-kind="bad"] { color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); border-color: var(--prism-comp-state-bad-fg);
border-width: var(--prism-comp-badge-line-bold); }
.pier-badge[data-kind="none"] { border-style: dashed; }
.pier-badge[data-kind="fixed"] { color: var(--prism-bg-card); background: var(--prism-text-heading); border-color: var(--prism-text-heading); }
/* 선행 마커: CSS 도형, 아이콘이 아님 */
.pier-badge[data-mark]::before { content: attr(data-mark); aria-hidden: true; }
.pier-table { width: 100%; border-collapse: collapse; font-size: var(--prism-comp-table-font); font-variant-numeric: tabular-nums; }
.pier-table caption { text-align: left; font-weight: 700; padding-block: var(--prism-space-100); }
.pier-table th, .pier-table td { padding: var(--prism-comp-table-cell-pad-y) var(--prism-comp-table-cell-pad-x);
border-bottom: var(--prism-comp-table-rule) solid var(--prism-comp-line); text-align: left; vertical-align: top; }
.pier-table thead th { position: sticky; top: 0; z-index: var(--prism-z-sticky); background: var(--prism-comp-table-head-bg); }
.pier-table td.num { text-align: right; }
:focus-visible { outline: var(--prism-focus-ring) solid var(--prism-comp-line-strong); outline-offset: var(--prism-focus-offset); }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
```
`.pier-badge[data-mark]::before`의 `aria-hidden`은 CSS 속성이 아니므로 구현 시 마커를 `<span aria-hidden="true">`로 둔다(골격 단순화를 위해 표기).
## 부록 B. 화면별 소비 매트릭스
| 화면 | 패턴 | 주요 컴포넌트 (7.1·7.2절) |
| :--- | :--- | :--- |
| S01 | W5 | table, button, badge, xref |
| S02 | W3 | input(G1), directive, button, scope |
| S03 | W2 | card, stat, summary-strip, stepper, state-banner, button |
| S04 | W5 | summary-strip, section-table, badge, state-banner, disclosure, button |
| S05 | W5 | evidence-row, diff-row, confirm-form, specsheet, directive, xref |
| S06 | W3 | input, confirm-form, state-banner, button, codeblock(G9) |
| S07 | W5 | summary-strip, table(selectable), diff-row, badge, button |
| S08 | 모달 | modal, drawing-view, mediaframe |
| S09 | W3 + W5 | input, table, button |
| S10 | 모달 | bulk-confirm-dialog, evidence-row, disclosure, confirm-form |
| S11 | W2 | card, table, badge, run-progress, button |
| S12 | W4 | toc, table, badge, directive |
| S13 | W4 | toc, table, badge, directive, scope |
## 부록 C. 근거 파일
| 구분 | 경로 |
| :--- | :--- |
| Prism 진입점 | `RAG/design system_v0.1.1/Design.md` |
| 토큰 정본 | `RAG/design system_v0.1.1/design.tokens.json` |
| 웹앱 모듈 | `RAG/design system_v0.1.1/webapp/module.webapp.md` |
| 컴포넌트·브랜드 | `RAG/design system_v0.1.1/design.components.md`, `design.brand.md` |
| 화면 요구사항 | `DOC/01_기획/웹앱디자인시안/PRD.md` |
| 기존 디자인 기준 | `context-migration/디자인지침_경로.md` (R8) |
@@ -0,0 +1,431 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PierProof 시안 — 입력 묶음·새 교각 판별</title>
<link rel="stylesheet" href="pier.css">
<style>
@font-face { font-family: "Pretendard"; src: url("fonts/PretendardVariable.woff2") format("woff2-variations"); font-weight: 45 920; font-display: swap; }
body { margin: 0; }
.toast { position: fixed; right: var(--prism-space-300); bottom: var(--prism-space-300); z-index: 700; max-width: 28rem; background: var(--prism-bg-card);
border: var(--prism-stroke-regular) solid var(--prism-comp-line-strong); border-radius: var(--prism-radius-md); padding: var(--prism-space-150) var(--prism-space-200); }
.toast[hidden] { display: none; }
.scrim { position: fixed; }
.demo { background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis); font-size: var(--prism-type-caption); padding: var(--prism-space-050) var(--prism-space-300); }
.rail a.step { cursor: pointer; }
a[data-r] { cursor: pointer; }
td .btn.sm { min-height: var(--prism-comp-hit-min); }
.pbar.run > i { transition: width var(--prism-motion-fast) linear; }
</style>
</head>
<body>
<a class="skip" href="#main">본문 바로가기</a>
<div class="demo">시안 — 동작 흐름 확인용. 데이터는 예시이며 실제 앱 결과가 아니다. 새로고침하면 처음 상태로 돌아간다.</div>
<div class="app" id="app">
<header class="top">
<div class="brand"><i></i>PierProof</div>
<span class="where" id="where"></span>
<div class="meta"><span>권한 담당자</span><span>저장 14:32</span><span>환경 로컬</span></div>
</header>
<div class="body">
<nav class="rail" aria-label="교각 작업 단계">
<span class="cap">교각</span>
<span class="pier">옥성교P1</span>
<ol>
<li><a class="step done" tabindex="0"><span class="n" aria-hidden="true">✓</span>1 도면</a></li>
<li><a class="step" id="nav-bundles" href="#/bundles"><span class="n">2</span>2 입력 묶음</a></li>
<li><a class="step" tabindex="0"><span class="n">3</span>3 게이트</a></li>
<li><a class="step" tabindex="0"><span class="n">4</span>4 보고서</a></li>
</ol>
<hr>
<div class="sub"><a href="#/candidates" id="nav-cand">표 후보값</a></div>
<div class="sub"><a href="#/new" id="nav-new">새 교각 (S02)</a></div>
</nav>
<main class="main" id="main" tabindex="-1"></main>
</div>
</div>
<div id="modalHost"></div>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
<script>
(function () {
'use strict';
/* ---------- data (sample) ---------- */
var GRADE = { V1: ['', 'V1 값'], V2: ['', 'V2 목록'], V3: ['dash', 'V3 생성'], F: ['fixed', 'F 고정'] };
var ST = { g: ['good', '●', '일치', 3], w: ['warn', '▲', '주의', 2], b: ['bad', '■', '불일치', 0], n: ['dash', '◇', '근거 없음', 1] };
var RAW = [
['params · lean_concrete', 'V1', '표 밖 치수', 'g'], ['params · footing', 'V1', '표 밖 치수', 'g'], ['params · column', 'V1', '표 밖 치수', 'g'],
['params · materials', 'V1', '표', 'g'], ['params · coping', 'V2', '표 밖 치수', 'w'], ['params · bearing_seats', 'V1', '표', 'g'],
['params · sole_plates', 'V1', '표', 'g'], ['params · rebar_marks', 'V1', '표', 'g'], ['params · column_rebar', 'V2', '표 밖 치수', 'g'],
['params · footing_rebar', 'V3', '판독 스크립트', 'g'], ['params · coping_rebar', 'V3', '판독 스크립트', 'w'], ['params · display', 'V1', '', 'n', true],
['expected · G1', 'V1', '표 밖 치수', 'g'], ['expected · G1.pedestal', 'V1', '표', 'g'], ['expected · G2', 'V2', '표 밖 치수', 'g'],
['expected · G3', 'V2', '표 밖 치수', 'g'], ['expected · G4', 'V2', '표 밖 치수', 'b'], ['expected · G5', 'V1', '표', 'g'],
['expected · planned_fail', 'V1', '사람 입력', 'n'], ['expected · rebar_palette', 'V1', '', 'n', true], ['expected · checkers', 'F', '템플릿 고정', 'g'],
['expected · schedule', 'V1', '표', 'g'], ['expected · dia', 'V1', '표', 'g'], ['expected · fixed_length', 'V2', '표 밖 치수', 'w'],
['expected · loops', 'V2', '표 밖 치수', 'w'], ['expected · seats', 'V2', '표 밖 치수', 'g'], ['expected · c18', 'V2', '표 밖 치수', 'g']
];
var rows = RAW.map(function (r, i) {
return { i: i, key: r[0], g: r[1], src: r[2], st: r[3], orig: !!r[4], human: null, stale: false };
});
var SRC_TEXT = { '표': '도면 표(D1)', '표 밖 치수': '구조도 정면·단면의 치수 문자(D1)', '판독 스크립트': '판독 스크립트 실행 기록', '사람 입력': '담당자 입력 기록', '템플릿 고정': '템플릿 고정값' };
var CAND = [
{ id: 1, bundle: 'params · coping', file: 'params', target: 'coping.z_tip', cur: 31232, cand: 31231.5, kind: '표 밖 치수', rule: 'R1 레벨', trust: 'B', pos: 'D1 (225817, 1802)', pair: 'ztip' },
{ id: 2, bundle: 'expected · G1', file: 'expected', target: 'G1.coping.z_tip', cur: 31232, cand: 31231.5, kind: '표 밖 치수', rule: 'R1 레벨', trust: 'B', pos: 'D1 (225817, 1802)', pair: 'ztip' },
{ id: 3, bundle: 'params · coping', file: 'params', target: 'coping.z_top_right', cur: 32936, cand: 32936, kind: '표 밖 치수', rule: 'R1 레벨', trust: 'A', pos: 'D1 (225817, 4366)' },
{ id: 4, bundle: 'params · footing', file: 'params', target: 'footing.height', cur: 2500, cand: 2450, kind: '표 밖 치수', rule: 'R2 사슬', trust: 'A', pos: 'D1 (231004, 902)' },
{ id: 5, bundle: 'params · bearing_seats', file: 'params', target: 'bearing_seats[0].h1', cur: 209, cand: 209, kind: '표', rule: 'ELEVATION TABLE', trust: 'T', pos: 'D1 표 행 1' },
{ id: 6, bundle: 'params · sole_plates', file: 'params', target: 'sole_plates[3].S', cur: -0.46, cand: -0.52, kind: '표', rule: 'SOLE PLATE TABLE', trust: 'T', pos: 'D1 표 행 4' },
{ id: 7, bundle: 'params · column_rebar', file: 'params', target: 'column_rebar.hoops[2]', cur: '4@300', cand: '4@300', kind: '표 밖 치수', rule: 'R2 사슬', trust: 'A', pos: 'D2 (224410, 7120)' },
{ id: 8, bundle: 'params · coping_rebar', file: 'params', target: 'coping_rebar.stirrups.count', cur: 104, cand: 106, kind: '표 밖 치수', rule: 'R6 수량 대조', trust: 'C', pos: 'D1 점 94곳' }
];
var TRUST = { A: ['', '등급 A'], B: ['warn', '등급 B'], C: ['dash', '등급 C'], T: ['', '표'] };
/* ---------- state ---------- */
var S = { filter: 'all', fGrade: '', fSrc: '', by: '심영표', checking: false, checkedAt: '14:30', sel: {}, lastBulk: '', flash: null };
/* ---------- helpers ---------- */
function $(sel, root) { return (root || document).querySelector(sel); }
function esc(t) { return String(t).replace(/[&<>"']/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); }
function badge(kind, mark, label) { return '<span class="b ' + kind + '">' + (mark ? '<span class="m" aria-hidden="true">' + mark + '</span>' : '') + esc(label) + '</span>'; }
function stBadge(r) {
if (r.stale) return badge('warn', '◆', '변경됨');
var s = ST[r.st]; return badge(s[0], s[1], s[2]);
}
function humanBadge(r) { return r.human ? '<span class="b fixed"><span class="m" aria-hidden="true">●</span>확정</span>' : (r.stale && r.staleHuman ? badge('warn', '◆', '변경됨') : '<span class="b dash">미확정</span>'); }
function fmt(v) { return typeof v === 'number' ? v.toLocaleString('ko-KR') : v; }
function toast(msg) {
var t = $('#toast'); t.textContent = msg; t.hidden = false;
clearTimeout(toast._t); toast._t = setTimeout(function () { t.hidden = true; }, 6000);
}
function counts() {
var c = { g: 0, w: 0, b: 0, n: 0, human: 0 };
rows.forEach(function (r) { c[r.st]++; if (r.human) c.human++; });
return c;
}
function sorted() { return rows.slice().sort(function (a, b) { return (ST[a.st][3] - ST[b.st][3]) || (a.i - b.i); }); }
/* ---------- evidence ---------- */
function evidence(r) {
var src = SRC_TEXT[r.src] || '';
var hex = (0x2c41 + r.i * 37).toString(16).toUpperCase();
var pos = 'D1 · 핸들 ' + hex;
var isExp = r.key.indexOf('expected') === 0;
var ev = [
{ id: 'C1', name: '출처 있음', st: 'g', text: '이 묶음의 값은 ' + src + '에서 읽었다.', pos: pos },
{ id: 'C2', name: '도면 재대조', st: 'g', text: '현재 값이 ' + src + '의 후보 값과 같다.', pos: pos },
{ id: 'C3', name: '템플릿 원본 잔존', st: 'g', text: '원본과 다른 값은 ' + (30 + r.i) + '개이고 같은 값 ' + (r.i % 4) + '개는 모두 도면 근거가 있다.', pos: '—' },
{ id: 'C4', name: '내부 정합', st: 'g', text: '치수 사슬 합이 전체 치수와 같고 본수가 재료표 수량과 같다.', pos: '재료표' },
{ id: 'C5', name: 'params와 독립', st: isExp ? 'g' : 'x', text: isExp ? 'expected의 출처가 params의 출처와 다르다.' : '해당 없음. params 묶음이다.', pos: '—' },
{ id: 'C6', name: '범위·단위', st: 'g', text: 'EL과 길이가 상식 범위 안에 있고 단위가 mm이다.', pos: '—' },
{ id: 'C7', name: '변수 등급 위반', st: 'g', text: r.g === 'F' ? 'F 고정 항목이 템플릿 원본과 같다.' : 'F 고정 항목이 템플릿 원본과 다르지 않다.', pos: '—' },
{ id: 'C8', name: '재생성 일치', st: r.g === 'V3' ? 'g' : 'x', text: r.g === 'V3' ? '재생성 목록의 해시가 현재 값과 같다 (독립성 낮음).' : '해당 없음. V3 묶음이 아니다.', pos: '판독 스크립트' }
];
function set(id, st, text) { ev.forEach(function (e) { if (e.id === id) { e.st = st; e.text = text; } }); }
if (r.src === '표') set('C1', 'g', '이 묶음의 값은 도면 표(D1)의 행에서 읽었다.');
if (r.key === 'expected · G4') set('C4', 'b', '스터럽 곳 수 합 104가 재료표 C8~C13 수량 합 106(39 + 27 + 40)과 다르다.');
if (r.key === 'params · coping') set('C2', 'w', '단 높이가 표 값과 1.1 mm 어긋난다. 허용 범위 안이다.');
if (r.key === 'params · coping_rebar') set('C8', 'w', '재생성 목록의 해시가 같다. 독립성이 낮아 도면 직접 대조가 필요하다.');
if (r.key === 'expected · fixed_length') set('C4', 'w', '길이가 재료표 L과 겹이음만큼 다르다. 허용 범위 안이다.');
if (r.key === 'expected · loops') set('C2', 'w', '측면 고리 한 단이 점 목록과 3 mm 다르다. 허용 범위 안이다.');
if (r.st === 'n') {
set('C1', 'n', r.key === 'expected · planned_fail' ? '사람 결정 기록이다. 도면 출처가 없다.' : '출처를 찾지 못했다. 사람이 도면에서 직접 확인한다.');
set('C2', 'x', '비교할 후보가 없다.'); set('C4', 'x', '비교할 항목이 없다.'); set('C6', 'x', '검사 대상 수치가 없다.');
if (r.orig) set('C3', 'n', '현재 값이 템플릿 원본과 같고 새 도면 근거가 없다.');
}
return ev;
}
var EVST = { g: ['good', '●', '일치'], w: ['warn', '▲', '주의'], b: ['bad', '■', '불일치'], n: ['dash', '◇', '근거 없음'], x: ['', '', '—'] };
/* ---------- views ---------- */
var main = $('#main');
function setWhere(t) { $('#where').textContent = t; }
function viewBundles() {
setWhere('S04 · 옥성교P1 · 입력 묶음');
var c = counts();
var list = sorted().filter(function (r) {
if (S.filter !== 'all' && r.st !== S.filter) return false;
if (S.fGrade && r.g !== S.fGrade) return false;
if (S.fSrc && (r.orig ? '원본값' : r.src) !== S.fSrc) return false;
return true;
});
var origN = rows.filter(function (r) { return r.orig; }).length;
var html = '<div class="head"><span class="eyebrow">2단계</span><h1>입력 묶음 확인·확정</h1><p class="lead">앱이 묶음을 도면과 대조해 근거를 보인다. 확정은 근거를 읽은 담당자가 한다.</p></div>';
if (S.flash) { html += '<div class="banner good" role="status"><span class="m" aria-hidden="true">●</span><div>' + esc(S.flash) + '</div></div>'; }
html += '<section class="strip" aria-label="요약"><p class="concl">필요 27개 중 앱 확인 일치 ' + c.g + ' · 주의 ' + c.w + ' · 불일치 ' + c.b + ' · 근거 없음 ' + c.n + ' · 사람 확정 ' + c.human + '</p><div class="counts">';
[['all', '전체', '', 27], ['g', '일치', ST.g, c.g], ['w', '주의', ST.w, c.w], ['b', '불일치', ST.b, c.b], ['n', '근거 없음', ST.n, c.n]].forEach(function (f) {
var lab = f[2] ? badge(f[2][0], f[2][1], f[1]) : '<span>' + f[1] + '</span>';
html += '<button class="cnt' + (S.filter === f[0] ? ' sel' : '') + '" type="button" data-filter="' + f[0] + '" aria-pressed="' + (S.filter === f[0]) + '">' + lab + '<b class="num">' + f[3] + '</b></button>';
});
html += '</div>';
if (S.checking) html += '<div class="stack" style="gap:0.25rem"><span class="cap" id="runtxt">앱 확인 중 0/27 묶음</span><div class="pbar run" role="progressbar" aria-valuemin="0" aria-valuemax="27" aria-valuenow="0" id="runbar"><i style="width:0%"></i></div></div>';
else html += '<span class="cap muted">마지막 앱 확인 ' + S.checkedAt + ' · 검사 v0.1 · 재확인 필요 ' + rows.filter(function (r) { return r.stale; }).length + '개</span>';
html += '</section>';
html += '<div class="banner"><span class="m" aria-hidden="true">▲</span><div><b>원본값 ' + origN + '개.</b> 템플릿 원본과 같고 새 도면 근거가 없다. 일괄 확정에서 제외되며, 새 도면과 대조해 값이 바뀌거나 근거가 붙어야 풀린다.</div></div>';
html += '<div class="row"><div class="field"><label for="fg">변수 등급</label><select id="fg"><option value="">전체</option>' +
['V1', 'V2', 'V3', 'F'].map(function (g) { return '<option value="' + g + '"' + (S.fGrade === g ? ' selected' : '') + '>' + GRADE[g][1] + '</option>'; }).join('') + '</select></div>' +
'<div class="field"><label for="fs">값 출처</label><select id="fs"><option value="">전체</option>' +
['표', '표 밖 치수', '판독 스크립트', '원본값', '사람 입력', '템플릿 고정'].map(function (g) { return '<option value="' + g + '"' + (S.fSrc === g ? ' selected' : '') + '>' + g + '</option>'; }).join('') + '</select></div>' +
'<span class="grow"></span><button class="btn" type="button" id="runAll">전체 확인</button><button class="btn primary" type="button" id="bulk">일치분 확정</button></div>';
html += '<div class="tw"><table class="t"><caption>입력 묶음 ' + list.length + '개 · 불일치 → 근거 없음 → 주의 → 일치 순</caption><thead><tr><th scope="col">묶음</th><th scope="col">변수 등급</th><th scope="col">값 출처</th><th scope="col">앱 확인</th><th scope="col">사람 확정</th><th scope="col">근거</th></tr></thead><tbody>';
if (!list.length) html += '<tr><td colspan="6">조건에 맞는 묶음이 없다. 필터를 바꾼다.</td></tr>';
list.forEach(function (r) {
html += '<tr><td><a href="#/evidence/' + r.i + '">' + esc(r.key) + '</a></td><td>' + badge(GRADE[r.g][0], '', GRADE[r.g][1]) + '</td><td>' + esc(r.src) +
(r.orig ? badge('warn', '▲', '원본값') : '') + '</td><td>' + stBadge(r) + (r.stale ? ' <button class="btn quiet sm" type="button" data-recheck="' + r.i + '">확인 실행</button>' : '') +
'</td><td>' + (r.human ? humanBadge(r) + ' <span class="cap muted">' + esc(r.human.by) + ' ' + esc(r.human.at) + '</span>' : humanBadge(r)) +
'</td><td><a class="btn quiet sm" href="#/evidence/' + r.i + '">근거 보기</a></td></tr>';
});
html += '</tbody></table></div>';
html += '<details class="card" style="padding:0"><summary style="min-height:2.75rem;display:flex;align-items:center;cursor:pointer;font-weight:700;padding:0 0.75rem">확정 불필요(참고) 5개</summary><div style="padding:0 1rem 1rem" class="muted">_schema · _meta · pier_id_src · hitl_decisions_note · display_notes. 확정 대상이 아니며 앱 확인도 하지 않는다.</div></details>';
main.innerHTML = html;
main.querySelectorAll('[data-filter]').forEach(function (b) { b.onclick = function () { S.filter = b.getAttribute('data-filter'); viewBundles(); $('[data-filter="' + S.filter + '"]').focus(); }; });
$('#fg').onchange = function (e) { S.fGrade = e.target.value; viewBundles(); $('#fg').focus(); };
$('#fs').onchange = function (e) { S.fSrc = e.target.value; viewBundles(); $('#fs').focus(); };
$('#runAll').onclick = runAll;
$('#bulk').onclick = openBulk;
main.querySelectorAll('[data-recheck]').forEach(function (b) { b.onclick = function () { var r = rows[+b.getAttribute('data-recheck')]; r.stale = false; r.staleHuman = false; toast(r.key + ' 앱 확인을 다시 실행했다. 결과는 이전과 같다.'); viewBundles(); }; });
S.flash = null;
}
function runAll() {
if (S.checking) return;
S.checking = true; viewBundles();
var n = 0, bar = $('#runbar'), txt = $('#runtxt');
var timer = setInterval(function () {
n += 3; if (n > 27) n = 27;
bar.firstElementChild.style.width = (n / 27 * 100) + '%'; bar.setAttribute('aria-valuenow', n); txt.textContent = '앱 확인 중 ' + n + '/27 묶음';
if (n >= 27) {
clearInterval(timer); S.checking = false; S.checkedAt = '14:36';
rows.forEach(function (r) { r.stale = false; r.staleHuman = false; });
toast('앱 확인을 마쳤다. 결과는 요약 띠에 반영되었다.'); viewBundles();
}
}, 120);
}
function viewEvidence(idx) {
var r = rows[idx]; if (!r) { location.hash = '#/bundles'; return; }
setWhere('S05 · 옥성교P1 · 근거 보기');
var ev = evidence(r), s = ST[r.st];
var order = sorted();
var pos = order.indexOf(r);
var next = null;
for (var k = 1; k <= order.length; k++) { var cand = order[(pos + k) % order.length]; if (!cand.human && cand !== r) { next = cand; break; } }
var html = '<div class="head"><span class="eyebrow">2단계 · 입력 묶음</span><h1>근거 보기</h1></div><div class="split"><aside class="card" style="padding:0" aria-label="묶음 이동"><h3 style="padding:1rem 1rem 0.5rem">묶음 이동</h3><ul class="list">';
order.forEach(function (o) {
html += '<li class="' + (o === r ? 'cur' : '') + '"><a href="#/evidence/' + o.i + '"' + (o === r ? ' aria-current="true"' : '') + '>' + esc(o.key) + '</a>' + stBadge(o) + '</li>';
});
html += '</ul><div class="row" style="padding:0.75rem 1rem"><a class="btn sm" href="#/bundles">목록으로</a></div></aside><div class="stack">';
var concl = r.stale ? esc(r.key) + ' — 값이 바뀌었다. 이전 확인과 확정이 무효가 되었다.' :
esc(r.key) + ' — 앱 확인 ' + s[2] + '. ' + (r.st === 'g' ? '근거를 읽고 확정한다.' : '사람 확정 전에 사유가 필요하다.');
html += '<section class="strip"><p class="concl">' + concl + '</p><dl class="kv"><dt>변수 등급</dt><dd>' + badge(GRADE[r.g][0], '', GRADE[r.g][1]) + '</dd><dt>앱 확인</dt><dd>' + stBadge(r) + '</dd><dt>사람 확정</dt><dd>' +
(r.human ? humanBadge(r) + ' ' + esc(r.human.by) + ' · ' + esc(r.human.at) + (r.human.why ? ' · 사유: ' + esc(r.human.why) : '') : '<span class="b dash">미확정</span>') + '</dd><dt>검사</dt><dd>v0.1 · ' + S.checkedAt + '</dd></dl></section>';
html += '<div class="tw"><table class="t"><caption>앱 확인 근거 C1 ~ C8</caption><thead><tr><th scope="col">검사</th><th scope="col">결과</th><th scope="col">근거</th><th scope="col">도면 위치</th></tr></thead><tbody>';
ev.forEach(function (e) { var q = EVST[e.st]; html += '<tr><td style="white-space:nowrap"><b>' + e.id + '</b> ' + e.name + '</td><td>' + (e.st === 'x' ? '—' : badge(q[0], q[1], q[2])) + '</td><td style="max-width:36rem">' + esc(e.text) + '</td><td>' + esc(e.pos) + '</td></tr>'; });
html += '</tbody></table></div><div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>일치는 후보를 만든 규칙과 값이 같다는 뜻이다. 도면이 맞다는 판정이 아니다.</div></div>';
if (r.st === 'b' || r.st === 'w') {
html += '<section class="card"><h3>값 비교</h3><table class="t"><caption class="cap">다른 항목만 표시</caption><thead><tr><th scope="col">항목</th><th class="num" scope="col">현재</th><th class="num" scope="col">기준</th><th scope="col">차이</th></tr></thead><tbody>' +
(r.st === 'b' ? '<tr><td>스터럽 곳 수 합(개)</td><td class="num">104</td><td class="num">106</td><td>' + badge('warn', '▲', '다름') + '</td></tr>' : '<tr><td>표시된 항목</td><td class="num">1.1</td><td class="num">0</td><td>' + badge('warn', '▲', '다름') + '</td></tr>') + '</tbody></table></section>';
}
if (!r.human) {
html += '<section class="card"><h3>확정</h3><div class="field"><label for="by">확정자<span class="req">필수</span></label><input id="by" type="text" value="' + esc(S.by) + '" style="max-width:20rem"></div>' +
'<div class="field" id="whyf"><label for="why">사유<span class="req">' + (r.st === 'g' ? '선택' : '주의·불일치·근거 없음 확정에는 필수') + '</span></label><textarea id="why" rows="3"></textarea><span class="err" id="err" hidden></span></div>' +
'<div class="row"><button class="btn primary" type="button" id="conf">확정</button>' + (next ? '<a class="btn" href="#/evidence/' + next.i + '">다음 미확정 묶음</a>' : '') + '</div></section>';
}
html += '</div></div>';
main.innerHTML = html;
var cb = $('#conf');
if (cb) cb.onclick = function () {
var by = $('#by').value.trim(), why = $('#why').value.trim(), err = $('#err'), f = $('#whyf');
function fail(m) { err.hidden = false; err.innerHTML = '<span aria-hidden="true">■</span>' + esc(m); f.classList.add('bad'); }
f.classList.remove('bad'); err.hidden = true;
if (!by) { fail('확정자 이름이 비어 있다. 확정은 사람의 기록이다. 이름을 입력한다'); $('#by').focus(); return; }
if (r.st !== 'g' && !why) { fail('사유가 비어 있다. ' + ST[r.st][2] + ' 묶음은 사유가 있어야 확정된다. 사유를 입력한다'); $('#why').focus(); return; }
S.by = by; r.human = { by: by, at: '14:41', why: why };
toast(r.key + ' 확정을 기록했다. 확정자 ' + by + '.'); viewEvidence(idx);
};
}
function viewCandidates() {
setWhere('S07 · 옥성교P1 · 표 후보값');
var diffN = CAND.filter(function (c) { return c.cur !== c.cand; }).length;
var html = '<div class="head"><span class="eyebrow">2단계 · 입력 묶음</span><h1>표 후보값·표 밖 치수</h1><p class="lead">도면 표와 표 밖 치수에서 찾은 후보를 현재 값과 비교한다. 반영은 선택한 행에만 적용되고 자동 반영은 없다.</p></div>';
html += '<section class="strip"><p class="concl">후보 ' + CAND.length + '건 — 다름 ' + diffN + '건, 근거 없음 3건</p><div class="counts"><span class="b">표 2</span><span class="b">표 밖 치수 6</span><span class="b dash">등급 C 1</span></div></section>';
html += '<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>params와 expected에 같은 후보를 함께 반영하면 검사의 독립성이 사라진다. 반영 대상 줄은 하나씩 고른다.</div></div>';
html += '<div class="tw"><table class="t"><caption>후보 비교 — 선택 ' + Object.keys(S.sel).filter(function (k) { return S.sel[k]; }).length + '건</caption><thead><tr><th scope="col">반영</th><th scope="col">대상</th><th scope="col">줄</th><th class="num" scope="col">현재</th><th class="num" scope="col">후보</th><th scope="col">구분</th><th scope="col">규칙</th><th scope="col">상태</th><th scope="col">신뢰</th><th scope="col">도면 위치</th></tr></thead><tbody>';
CAND.forEach(function (c) {
var d = c.cur !== c.cand;
html += '<tr' + (S.sel[c.id] ? ' class="sel"' : '') + '><td><input type="checkbox" data-c="' + c.id + '"' + (S.sel[c.id] ? ' checked' : '') + ' aria-label="' + esc(c.target) + ' 반영" style="width:1.25rem;height:1.25rem;accent-color:var(--prism-text-heading)"></td><td><b>' + esc(c.target) + '</b></td><td>' + c.file + '</td><td class="num">' + fmt(c.cur) + '</td><td class="num">' + fmt(c.cand) + '</td><td>' + c.kind + '</td><td>' + c.rule + '</td><td>' +
(d ? badge('warn', '▲', '다름') : '같음') + '</td><td>' + badge(TRUST[c.trust][0], c.trust === 'B' ? '▲' : '', TRUST[c.trust][1]) + '</td><td><span class="chip">' + esc(c.pos) + '</span></td></tr>';
});
html += '</tbody></table></div><div class="field" id="cwhy" hidden><label for="cw">반영 사유<span class="req">등급 C 반영에는 필수</span></label><input id="cw" type="text" style="max-width:36rem"></div><span class="err" id="cerr" hidden></span>';
html += '<div class="row"><button class="btn primary" type="button" id="apply">후보 반영</button></div>';
main.innerHTML = html;
function needWhy() { return CAND.some(function (c) { return S.sel[c.id] && c.trust === 'C'; }); }
$('#cwhy').hidden = !needWhy();
main.querySelectorAll('[data-c]').forEach(function (cb) { cb.onchange = function () { S.sel[cb.getAttribute('data-c')] = cb.checked; var y = window.scrollY; viewCandidates(); window.scrollTo(0, y); $('[data-c="' + cb.getAttribute('data-c') + '"]').focus(); }; });
$('#apply').onclick = function () {
var picks = CAND.filter(function (c) { return S.sel[c.id]; }), err = $('#cerr');
function fail(m) { err.hidden = false; err.innerHTML = '<span aria-hidden="true">■</span>' + esc(m); }
err.hidden = true;
if (!picks.length) { fail('선택한 행이 없다. 반영할 후보가 없다. 행을 선택한다'); return; }
var pairs = {}, bad = null;
picks.forEach(function (c) { if (c.pair) { if (pairs[c.pair] && pairs[c.pair] !== c.file) bad = c; pairs[c.pair] = c.file; } });
if (bad) { fail('같은 후보를 params와 expected에 함께 반영할 수 없다. 두 줄의 값이 같은 근거에서 나오면 검사가 무의미해진다. 한 줄만 선택한다'); return; }
if (needWhy() && !$('#cw').value.trim()) { fail('반영 사유가 비어 있다. 등급 C 후보는 사유가 있어야 반영된다. 사유를 입력한다'); $('#cw').focus(); return; }
var touched = {};
picks.forEach(function (c) { c.cur = c.cand; touched[c.bundle] = true; });
rows.forEach(function (r) { if (touched[r.key]) { r.stale = true; if (r.human) r.staleHuman = true; r.human = null; } });
S.sel = {};
toast(picks.length + '건을 반영했다. 영향받은 묶음 ' + Object.keys(touched).length + '개의 앱 확인과 확정이 무효가 되었다.');
viewCandidates();
};
}
/* ---------- bulk confirm (S10) ---------- */
var lastFocus = null;
function closeModal() { $('#modalHost').innerHTML = ''; document.removeEventListener('keydown', trap, true); if (lastFocus) lastFocus.focus(); }
function trap(e) {
var m = $('#modalHost .modal'); if (!m) return;
if (e.key === 'Escape') { e.preventDefault(); closeModal(); return; }
if (e.key !== 'Tab') return;
var f = m.querySelectorAll('button, [href], input, select, textarea, summary, [tabindex="0"]');
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
function openBulk() {
lastFocus = document.activeElement;
var targets = rows.filter(function (r) { return r.st === 'g' && !r.orig && !r.human && !r.stale; });
var ex = rows.filter(function (r) { return !(r.st === 'g' && !r.orig) && !r.human || r.stale; });
var read = false;
var html = '<div class="scrim"><div class="modal" role="dialog" aria-modal="true" aria-labelledby="mt"><header><h2 id="mt">일치 묶음 확정</h2><button class="btn quiet sm" type="button" id="mx" aria-label="닫기">닫기</button></header>';
if (!targets.length) {
html += '<p>확정할 일치 묶음이 없다. 모든 일치 묶음이 확정되었거나 재확인이 필요하다.</p><div class="row end"><button class="btn" type="button" id="mc">닫기</button></div></div></div>';
$('#modalHost').innerHTML = html; $('#mx').onclick = $('#mc').onclick = closeModal; document.addEventListener('keydown', trap, true); $('#mx').focus(); return;
}
html += '<p class="concl" style="font-size:var(--prism-type-body-lg);font-weight:700;color:var(--prism-text-heading)">일치 ' + targets.length + '개를 확정한다. 주의·불일치·근거 없음·변경됨 ' + ex.length + '개는 대상이 아니다.</p>' +
'<div><h3>근거표 <span class="cap muted">끝까지 내려 확인한다</span></h3><div class="scroll" id="sc" tabindex="0" role="region" aria-label="일치 묶음 근거표"><table class="t"><caption class="cap" style="position:absolute;left:-999rem">일치 묶음 근거</caption><thead><tr><th scope="col">묶음</th><th scope="col">근거</th><th scope="col">출처</th></tr></thead><tbody>';
targets.forEach(function (r) { html += '<tr><td><b>' + esc(r.key) + '</b></td><td>' + esc(evidence(r)[1].text) + '</td><td>' + esc(r.src) + '</td></tr>'; });
html += '</tbody></table></div></div><details class="card sunken" style="padding:0 1rem"><summary style="min-height:2.75rem;display:flex;align-items:center;cursor:pointer;font-weight:700">제외된 묶음 ' + ex.length + '개 — 누르면 근거 보기로 이동</summary><table class="t" style="margin-bottom:1rem"><caption class="cap" style="position:absolute;left:-999rem">제외된 묶음</caption><thead><tr><th scope="col">묶음</th><th scope="col">사유</th></tr></thead><tbody>';
ex.forEach(function (r) { html += '<tr><td><a href="#/evidence/' + r.i + '" data-close="1">' + esc(r.key) + '</a></td><td>' + stBadge(r) + (r.orig ? ' 원본값. 새 도면 근거가 없다' : '') + '</td></tr>'; });
html += '</tbody></table></details><div class="field"><label for="mby">확정자<span class="req">필수</span></label><input id="mby" type="text" value="' + esc(S.by) + '" style="max-width:20rem"><span class="help">확정 기록에는 입력한 이름만 남는다. 앱은 확정하지 않는다</span></div>' +
'<div class="err" id="merr" hidden></div><div class="row end"><button class="btn" type="button" id="mc">취소</button><button class="btn primary" type="button" id="mok">확정</button></div></div></div>';
$('#modalHost').innerHTML = html;
var sc = $('#sc');
function chk() { if (sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 2) read = true; }
sc.addEventListener('scroll', chk); chk();
$('#mx').onclick = $('#mc').onclick = closeModal;
$('#modalHost').querySelectorAll('[data-close]').forEach(function (a) { a.onclick = function () { closeModal(); }; });
$('#mok').onclick = function () {
var err = $('#merr'), by = $('#mby').value.trim();
function fail(m, el) { err.hidden = false; err.innerHTML = '<span aria-hidden="true">■</span>' + esc(m); if (el) el.focus(); }
if (!read) { fail('근거표를 읽지 않았다. 일치 묶음의 근거를 읽어야 확정할 수 있다. 근거표를 끝까지 내린다', sc); return; }
if (!by) { fail('확정자 이름이 비어 있다. 확정은 사람의 기록이다. 이름을 입력한다', $('#mby')); return; }
S.by = by;
targets.forEach(function (r) { r.human = { by: by, at: '14:41', why: '' }; });
S.flash = targets.length + '개를 ' + by + '이(가) 확정했다. 확정 기록에는 입력한 이름만 남았다.';
closeModal(); route();
};
document.addEventListener('keydown', trap, true); $('#mx').focus();
}
/* ---------- S02 new pier fit (sample: Ungok P1 drawings uploaded) ---------- */
var QS = [['foundation', '기초 형식(말뚝 유무)'], ['soffit', '코핑 하면 형상'], ['bearings', '받침 개수']];
var OPTS = { foundation: ['기초판+말뚝', '기초판만', '말뚝만'], soffit: ['곡선', '직선 경사', '나팔형'], bearings: ['2', '4', '10'] };
var TPLS = [
{ n: '운곡교P1형', p: { foundation: '기초판+말뚝', soffit: '곡선', bearings: '10' } },
{ n: '옥성교P1형', p: { foundation: '기초판+말뚝', soffit: '직선 경사', bearings: '10' } },
{ n: '새재1교P1형', p: { foundation: '기초판만', soffit: '직선 경사', bearings: '10' } },
{ n: 'Ramp-A교P1형', p: { foundation: '말뚝만', soffit: '나팔형', bearings: '2' } },
{ n: '산외교울산P1형', p: { foundation: '기초판만', soffit: '직선 경사', bearings: '4' } }
];
var EST = {
foundation: ['기초판+말뚝', "'말뚝' 문자 7개: D1 CS-TEXT (복합말뚝)", '보통'],
soffit: ['곡선', "R 12000 mm: D1 CS-DIMT (8257, 16513) 'R=12.000'", '보통'],
bearings: ['10', 'ELEVATION TABLE 행 10개: D2 (26179, 9342)', '보통']
};
var N = { up: false, ans: null, tpl: '운곡교P1형', name: '', rs: '미확인', rm: '미확인', skew: '', brot: '', frot: '', err: '' };
function nMatch(t) { return QS.filter(function (q) { return t.p[q[0]] === EST[q[0]][0]; }).length; }
function nBad() {
var t = TPLS.filter(function (x) { return x.n === N.tpl; })[0], bad = [];
QS.forEach(function (q) { if (t.p[q[0]] !== N.ans[q[0]]) bad.push(q[1]); });
if (N.rs === '아니오') bad.push('철근 형상 종류');
if (N.rm === '아니오') bad.push('철근 생성 방식');
return bad;
}
function viewNew() {
setWhere('S02 · 새 교각 적합성 판별');
var html = '<div class="head"><span class="eyebrow">새 교각</span><h1>도면으로 템플릿 추천</h1><p class="lead">도면을 먼저 올리면 앱이 가장 비슷한 템플릿을 추천한다. 추천은 제안이고, 6문항은 사람이 확인한다.</p></div>';
if (!N.up) {
html += '<section class="card" style="max-width:38em"><div class="field"><label for="dw">교각 도면(DWG, 여러 장)</label><input id="dw" type="file" accept=".dwg" multiple><span class="help">시안에서는 운곡교 P1 도면 3장을 올린 것으로 본다</span></div><div class="row"><button class="btn primary" type="button" id="up">도면 읽고 추천 받기</button></div></section>';
main.innerHTML = html;
$('#up').onclick = function () { N.up = true; N.ans = {}; QS.forEach(function (q) { N.ans[q[0]] = EST[q[0]][0]; }); viewNew(); };
return;
}
var ranked = TPLS.slice().sort(function (a, b) { return nMatch(b) - nMatch(a); });
html += '<section class="strip"><p class="concl">도면 3장, 문자 2,366개를 읽었다 — 1위 ' + ranked[0].n + ' (3 / 3 일치)</p></section>';
html += '<h2>1 추천 순위</h2><div class="tw"><table class="t"><caption>추정한 3문항과 일치한 수</caption><thead><tr><th scope="col">순위</th><th scope="col">템플릿</th><th class="num" scope="col">일치</th>' + QS.map(function (q) { return '<th scope="col">' + q[1] + '</th>'; }).join('') + '</tr></thead><tbody>';
ranked.forEach(function (t, i) {
html += '<tr><td class="num">' + (i + 1) + '</td><td><b>' + t.n + '</b></td><td class="num">' + nMatch(t) + ' / 3</td>' + QS.map(function (q) {
var ok = t.p[q[0]] === EST[q[0]][0];
return '<td>' + esc(t.p[q[0]]) + ' ' + (ok ? badge('good', '●', '일치') : badge('bad', '■', '다름')) + '</td>';
}).join('') + '</tr>';
});
html += '</tbody></table></div>';
html += '<h2>2 추정 근거</h2><div class="tw"><table class="t"><thead><tr><th scope="col">문항</th><th scope="col">추정</th><th scope="col">근거(도면 위치)</th><th scope="col">신뢰</th></tr></thead><tbody>';
QS.forEach(function (q) { var e = EST[q[0]]; html += '<tr><td>' + q[1] + '</td><td><b>' + e[0] + '</b></td><td><span class="chip">' + esc(e[1]) + '</span></td><td>' + e[2] + '</td></tr>'; });
html += '<tr><td>기둥 단면·철근 형상·철근 생성 방식</td><td>' + badge('dash', '◇', '판단 불가') + '</td><td>도면 문자로 판별할 근거가 없다. 사람이 답한다</td><td>-</td></tr></tbody></table></div>';
html += '<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>코핑 하면 형상은 정면도의 큰 R 문자(10 m 이상)만 본다. 평면 모서리의 작은 R은 제외한다. 평면의 사각·직각과 각도는 문항이 아니라 아래 값으로 입력한다.</div></div>';
html += '<h2>3 6문항 확인 후 교각 만들기</h2><div style="max-width:38em;display:flex;flex-direction:column;gap:var(--prism-space-200)">';
QS.forEach(function (q) {
html += '<div class="field"><label for="a_' + q[0] + '">' + q[1] + '</label><select id="a_' + q[0] + '" data-a="' + q[0] + '">' + OPTS[q[0]].map(function (o) { return '<option' + (N.ans[q[0]] === o ? ' selected' : '') + '>' + o + '</option>'; }).join('') + '</select></div>';
});
[['rs', '철근 형상 종류가 템플릿의 재료표 형식 열과 같은가'], ['rm', '철근 생성 방식(규칙 값 / 철근별 중심선)이 같은가']].forEach(function (q) {
html += '<div class="field"><label for="' + q[0] + '">' + q[1] + '</label><select id="' + q[0] + '" data-r="' + q[0] + '">' + ['미확인', '예', '아니오'].map(function (o) { return '<option' + (N[q[0]] === o ? ' selected' : '') + '>' + o + '</option>'; }).join('') + '</select></div>';
});
html += '<fieldset class="card sunken"><legend class="lbl">각도 값 확인 <span class="muted">템플릿을 가르지 않는 값. 비워도 된다</span></legend><div class="grid3">' +
[['skew', '코핑 평면 skew (°)'], ['brot', '받침 회전 (°)'], ['frot', '기초 회전 (°)']].map(function (q) { return '<div class="field"><label for="g_' + q[0] + '">' + q[1] + '</label><input id="g_' + q[0] + '" type="text" inputmode="decimal" data-g="' + q[0] + '" value="' + esc(N[q[0]]) + '"></div>'; }).join('') + '</div></fieldset>';
var bad = nBad();
html += '<section class="strip" role="status" aria-live="polite"><h3 style="margin:0 0 .25rem">결과 판정</h3><p class="concl">' + (bad.length ? badge('bad', '■', '같은 템플릿으로 만들 수 없음') + ' 다른 문항: ' + esc(bad.join(', ')) : badge('good', '●', '이 템플릿으로 만들 수 있음')) + '</p>' +
(bad.length ? '<p class="muted" style="margin:.25rem 0 0">가장 가까운 템플릿에 생성기·검사기를 추가해야 한다. 그래도 진행하면 교각 이름 옆과 모든 화면 상단에 형식 불일치 경고가 계속 표시된다(닫을 수 없다).</p>' : '') + '</section>';
html += '<div class="field' + (N.err ? ' bad' : '') + '"><label for="nm">교각 이름<span class="req">필수</span></label><input id="nm" type="text" value="' + esc(N.name) + '" style="max-width:20rem">' + (N.err ? '<span class="err"><span aria-hidden="true">■</span>' + esc(N.err) + '</span>' : '') + '</div>';
html += '<div class="field"><label for="tp">템플릿</label><select id="tp" style="max-width:20rem">' + ranked.map(function (t, i) { return '<option value="' + t.n + '"' + (N.tpl === t.n ? ' selected' : '') + '>' + (i + 1) + '위 ' + t.n + '</option>'; }).join('') + '</select></div>';
html += '<div class="row"><button class="btn primary" type="button" id="mk">' + (bad.length ? '그래도 만들기' : '교각 만들기') + '</button></div></div>';
main.innerHTML = html;
var y = window.scrollY;
main.querySelectorAll('[data-a]').forEach(function (e) { e.onchange = function () { N.ans[e.getAttribute('data-a')] = e.value; viewNew(); window.scrollTo(0, y); $('#' + e.id).focus(); }; });
main.querySelectorAll('[data-r]').forEach(function (e) { e.onchange = function () { N[e.getAttribute('data-r')] = e.value; viewNew(); window.scrollTo(0, y); $('#' + e.id).focus(); }; });
main.querySelectorAll('[data-g]').forEach(function (e) { e.oninput = function () { N[e.getAttribute('data-g')] = e.value; }; });
$('#nm').oninput = function () { N.name = this.value; };
$('#tp').onchange = function () { N.tpl = this.value; viewNew(); window.scrollTo(0, y); $('#tp').focus(); };
$('#mk').onclick = function () {
N.name = $('#nm').value.trim();
if (!N.name) { N.err = '교각 이름이 비어 있다. 폴더를 만들 이름이 필요하다. 이름을 입력한다'; viewNew(); $('#nm').focus(); return; }
N.err = '';
toast("교각 '" + N.name + "'을(를) 만들었다. 올린 도면이 교각 폴더로 옮겨졌고 6문항 답과 각도 값이 저장되었다." + (bad.length ? ' 형식 불일치 경고가 표시된다.' : ''));
viewNew();
};
}
/* ---------- router ---------- */
function route() {
var h = location.hash || '#/bundles', m;
$('#nav-bundles').classList.toggle('cur', h.indexOf('#/bundles') === 0 || h.indexOf('#/evidence') === 0);
$('#nav-cand').style.fontWeight = h.indexOf('#/candidates') === 0 ? '700' : '500';
$('#nav-new').style.fontWeight = h.indexOf('#/new') === 0 ? '700' : '500';
if ((m = h.match(/^#\/evidence\/(\d+)/))) viewEvidence(+m[1]);
else if (h.indexOf('#/candidates') === 0) viewCandidates();
else if (h.indexOf('#/new') === 0) viewNew();
else viewBundles();
if (document.activeElement === document.body) main.focus();
}
window.addEventListener('hashchange', function () { window.scrollTo(0, 0); route(); });
route();
})();
</script>
</body>
</html>
@@ -0,0 +1,182 @@
/* PierProof — Prism app profile tokens (design.md 3.2 / 3.3) and components. Literals live only in :root. */
:root {
--prism-bg-canvas: #F4F3F0; --prism-bg-card: #FFFFFF;
--prism-text-body: #474235; --prism-text-heading: #1F524B; --prism-text-subtle: #474235; --prism-text-accent: #D05C26;
--prism-border-default: #474235; --prism-border-strong: #1F524B;
--prism-status-do: #005A4E; --prism-status-do-bg: #DCEAE6;
--prism-status-dont: #8F2A11; --prism-status-dont-bg: #F6DFDA;
--prism-status-caution: #6B4A00; --prism-status-caution-bg: #F5E7CC;
--prism-status-tip: #0A4E86; --prism-status-tip-bg: #DDE8F3;
--prism-space-025: 0.125rem; --prism-space-050: 0.25rem; --prism-space-100: 0.5rem; --prism-space-150: 0.75rem;
--prism-space-200: 1rem; --prism-space-300: 1.5rem; --prism-space-400: 2rem; --prism-space-600: 3rem; --prism-space-800: 4rem;
--prism-type-caption: 0.8125rem; --prism-lh-caption: 1.55; --prism-type-body-sm: 0.875rem; --prism-lh-body: 1.7;
--prism-type-body: 1rem; --prism-type-body-lg: 1.125rem; --prism-type-title-sm: 1.25rem; --prism-lh-title: 1.5;
--prism-type-title: 1.625rem; --prism-type-title-lg: 2rem;
--prism-radius-sm: 4px; --prism-radius-md: 8px; --prism-radius-lg: 12px; --prism-radius-pill: 9999px;
--prism-stroke-hairline: 1px; --prism-stroke-regular: 2px; --prism-stroke-bold: 3px;
--prism-z-raised: 100; --prism-z-sticky: 200; --prism-z-scrim: 400; --prism-z-modal: 500;
--prism-scrim: rgba(0, 0, 0, 0.32);
--prism-motion-fast: 100ms; --prism-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--prism-focus-ring: 3px; --prism-focus-offset: 2px; --prism-measure-max: 38em;
--prism-font: Pretendard, -apple-system, BlinkMacSystemFont, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
--prism-comp-line: var(--prism-border-default); --prism-comp-line-strong: var(--prism-border-strong);
--prism-comp-state-good-fg: var(--prism-status-do); --prism-comp-state-good-bg: var(--prism-status-do-bg);
--prism-comp-state-bad-fg: var(--prism-status-dont); --prism-comp-state-bad-bg: var(--prism-status-dont-bg);
--prism-comp-state-warn-fg: var(--prism-status-caution); --prism-comp-state-warn-bg: var(--prism-status-caution-bg);
--prism-comp-state-info-fg: var(--prism-status-tip); --prism-comp-state-info-bg: var(--prism-status-tip-bg);
--prism-comp-rail-width: 15rem; --prism-comp-header-height: 3.5rem; --prism-comp-hit-min: 2.75rem;
--prism-comp-table-head-bg: var(--prism-bg-canvas);
--prism-comp-on-emphasis: var(--prism-bg-card);
}
:root[data-theme="dark"] {
--prism-bg-canvas: #111111; --prism-bg-card: #111111; --prism-text-body: #F4F3F0; --prism-text-heading: #F4F3F0;
--prism-text-subtle: #D3D3D3; --prism-border-default: #D3D3D3; --prism-border-strong: #F4F3F0;
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E; --prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00; --prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--prism-bg-canvas); color: var(--prism-text-body); font-family: var(--prism-font);
font-size: var(--prism-type-body-sm); line-height: var(--prism-lh-body); font-variant-numeric: tabular-nums; }
a { color: var(--prism-text-heading); }
h1, h2, h3, p, dl, dd, ol, ul { margin: 0; padding: 0; }
:focus-visible { outline: var(--prism-focus-ring) solid var(--prism-comp-line-strong); outline-offset: var(--prism-focus-offset); }
.skip { position: absolute; left: -999rem; }
/* shell */
.app { min-height: 100%; display: flex; flex-direction: column; position: relative; }
.top { height: var(--prism-comp-header-height); background: var(--prism-bg-card); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line);
display: flex; align-items: center; gap: var(--prism-space-300); padding-inline: var(--prism-space-300); position: sticky; top: 0; z-index: var(--prism-z-sticky); }
.brand { display: flex; align-items: center; gap: var(--prism-space-100); font-weight: 700; font-size: var(--prism-type-body-lg); color: var(--prism-text-heading); letter-spacing: -0.01em; }
.brand i { width: 0.7rem; height: 0.7rem; background: var(--prism-text-accent); transform: rotate(45deg); display: block; }
.top .where { font-size: var(--prism-type-caption); font-weight: 500; }
.top .meta { margin-left: auto; display: flex; gap: var(--prism-space-300); font-size: var(--prism-type-caption); }
.body { display: flex; flex: 1; min-height: 0; }
.rail { width: var(--prism-comp-rail-width); flex: none; background: var(--prism-bg-card); border-right: var(--prism-stroke-hairline) solid var(--prism-comp-line);
padding: var(--prism-space-300) var(--prism-space-200); display: flex; flex-direction: column; gap: var(--prism-space-200); }
.rail .cap { font-size: var(--prism-type-caption); font-weight: 500; letter-spacing: 0.06em; }
.rail .pier { font-weight: 700; font-size: var(--prism-type-body); color: var(--prism-text-heading); }
.rail ol { list-style: none; display: flex; flex-direction: column; gap: var(--prism-space-050); }
.step { display: flex; align-items: center; gap: var(--prism-space-150); min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-100);
border-radius: var(--prism-radius-sm); color: var(--prism-text-body); text-decoration: none; }
.step .n { width: 1.5rem; height: 1.5rem; flex: none; border: var(--prism-stroke-regular) dashed var(--prism-comp-line); border-radius: var(--prism-radius-pill);
display: grid; place-items: center; font-size: var(--prism-type-caption); font-weight: 700; }
.step.done .n { background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis); border: var(--prism-stroke-regular) solid var(--prism-text-heading); }
.step.cur { background: var(--prism-bg-canvas); font-weight: 700; }
.step.cur .n { border: var(--prism-stroke-bold) solid var(--prism-comp-line-strong); }
.rail .sub { display: flex; flex-direction: column; margin-left: 2.1rem; }
.rail .sub a { min-height: 2rem; display: flex; align-items: center; font-size: var(--prism-type-caption); color: var(--prism-text-body); }
.rail hr { border: 0; border-top: var(--prism-stroke-hairline) solid var(--prism-comp-line); width: 100%; margin: 0; }
.main { flex: 1; min-width: 0; padding: var(--prism-space-400); display: flex; flex-direction: column; gap: var(--prism-space-300); }
.main > .head { display: flex; flex-direction: column; gap: var(--prism-space-100); }
h1 { font-size: var(--prism-type-title); line-height: var(--prism-lh-title); color: var(--prism-text-heading); font-weight: 700; }
h2 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 500; color: var(--prism-text-heading); }
h3 { font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); }
.eyebrow { font-size: var(--prism-type-caption); font-weight: 500; letter-spacing: 0.06em; }
.lead { max-width: var(--prism-measure-max); font-size: var(--prism-type-body); }
.cap { font-size: var(--prism-type-caption); }
.muted { color: var(--prism-text-subtle); }
.row { display: flex; gap: var(--prism-space-200); align-items: center; flex-wrap: wrap; }
.row.end { justify-content: flex-end; }
.stack { display: flex; flex-direction: column; gap: var(--prism-space-200); }
.grid3 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--prism-space-300); }
.grid2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--prism-space-300); }
.split { display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); gap: var(--prism-space-300); align-items: start; }
.grow { flex: 1; }
/* scope, card, strip, banner */
.scope { background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); padding: var(--prism-space-150) var(--prism-space-200);
display: flex; gap: var(--prism-space-400); font-size: var(--prism-type-body-sm); border-radius: var(--prism-radius-sm); }
.scope b { font-weight: 700; }
.card { background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-md); padding: var(--prism-space-200);
display: flex; flex-direction: column; gap: var(--prism-space-150); }
.card.sunken { background: var(--prism-bg-canvas); }
.strip { background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line-strong); border-radius: var(--prism-radius-md);
padding: var(--prism-space-200) var(--prism-space-300); display: flex; flex-direction: column; gap: var(--prism-space-150); }
.strip .concl { font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); max-width: none; }
.counts { display: flex; gap: var(--prism-space-200); flex-wrap: wrap; align-items: stretch; }
.cnt { display: flex; align-items: baseline; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200);
background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); font: inherit; color: inherit; cursor: pointer; }
.cnt b { font-size: var(--prism-type-title); font-weight: 700; line-height: 1.2; }
.cnt.sel { background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis); border-color: var(--prism-text-heading); }
.banner { border: var(--prism-stroke-regular) solid var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); color: var(--prism-comp-state-warn-fg);
border-radius: var(--prism-radius-sm); padding: var(--prism-space-150) var(--prism-space-200); display: flex; gap: var(--prism-space-150); align-items: flex-start; }
.banner b { font-weight: 700; }
.banner.tip { border-color: var(--prism-comp-state-info-fg); background: var(--prism-comp-state-info-bg); color: var(--prism-comp-state-info-fg); }
.banner.dont { border-color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); color: var(--prism-comp-state-bad-fg); }
.banner.good { border-color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); color: var(--prism-comp-state-good-fg); }
.banner .m { flex: none; }
/* badge */
.b { display: inline-flex; align-items: center; gap: var(--prism-space-050); padding: var(--prism-space-025) var(--prism-space-100); white-space: nowrap;
font-size: var(--prism-type-caption); font-weight: 500; line-height: 1.5; border: var(--prism-stroke-regular) solid var(--prism-comp-line);
border-radius: var(--prism-radius-sm); color: var(--prism-text-body); background: transparent; }
.b.good { color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); border-color: var(--prism-comp-state-good-fg); }
.b.warn { color: var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); border-color: var(--prism-comp-state-warn-fg); }
.b.info { color: var(--prism-comp-state-info-fg); background: var(--prism-comp-state-info-bg); border-color: var(--prism-comp-state-info-fg); }
.b.bad { color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); border-color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold); }
.b.dash { border-style: dashed; }
.b.fixed { color: var(--prism-comp-on-emphasis); background: var(--prism-text-heading); border-color: var(--prism-text-heading); }
.b .m { font-size: 0.7em; }
/* buttons, fields */
.btn { min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200); border-radius: var(--prism-radius-sm); font: inherit; font-weight: 500;
cursor: pointer; border: var(--prism-stroke-regular) solid var(--prism-text-heading); background: var(--prism-bg-card); color: var(--prism-text-heading);
display: inline-flex; align-items: center; justify-content: center; gap: var(--prism-space-100); text-decoration: none; white-space: nowrap; }
.btn.primary { background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis); }
.btn.quiet { border-color: transparent; background: transparent; text-decoration: underline; }
.btn.danger { border-color: var(--prism-comp-state-bad-fg); color: var(--prism-comp-state-bad-fg); }
.btn.sm { min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); font-size: var(--prism-type-caption); }
.field { display: flex; flex-direction: column; gap: var(--prism-space-050); }
.field label, .lbl { font-weight: 700; font-size: var(--prism-type-body-sm); }
.req { font-weight: 500; font-size: var(--prism-type-caption); margin-left: var(--prism-space-100); }
.field input, .field select, .field textarea { min-height: var(--prism-comp-hit-min); padding: var(--prism-space-100) var(--prism-space-150); font: inherit;
color: var(--prism-text-body); background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); }
.field textarea { font-size: var(--prism-type-body-sm); line-height: 1.6; resize: none; }
.field .help { font-size: var(--prism-type-caption); }
.field.bad input, .field.bad textarea { border-color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold); }
.err { color: var(--prism-comp-state-bad-fg); font-size: var(--prism-type-caption); font-weight: 500; display: flex; gap: var(--prism-space-050); }
.choice { display: flex; gap: var(--prism-space-300); align-items: center; }
.choice label { display: inline-flex; align-items: center; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); font-weight: 500; }
.choice input { width: 1.25rem; height: 1.25rem; accent-color: var(--prism-text-heading); }
/* table */
.tw { overflow-x: auto; background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); }
table.t { width: 100%; border-collapse: collapse; font-size: var(--prism-type-body-sm); }
table.t caption { text-align: left; font-weight: 700; padding: var(--prism-space-100) var(--prism-space-150); color: var(--prism-text-heading); }
table.t th, table.t td { padding: var(--prism-space-100) var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line); text-align: left; vertical-align: middle; }
table.t thead th { position: sticky; top: 0; background: var(--prism-comp-table-head-bg); font-weight: 700; white-space: nowrap; border-bottom: var(--prism-stroke-regular) solid var(--prism-comp-line); }
table.t td.num, table.t th.num { text-align: right; }
table.t tr.sel td { background: var(--prism-comp-table-head-bg); }
table.t tr.sep td { background: var(--prism-comp-table-head-bg); font-weight: 700; color: var(--prism-text-heading); }
table.t td a { font-weight: 700; }
.chip { display: inline-block; border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);
padding: 0 var(--prism-space-100); font-size: var(--prism-type-caption); white-space: nowrap; background: var(--prism-bg-card); }
/* key-value, evidence, diff */
dl.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--prism-space-100) var(--prism-space-300); font-size: var(--prism-type-body-sm); }
dl.kv dt { font-weight: 700; }
.pbar { height: 0.75rem; border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-pill); overflow: hidden; background: var(--prism-bg-card); }
.pbar > i { display: block; height: 100%; background: var(--prism-text-heading); }
.list { list-style: none; display: flex; flex-direction: column; }
.list li { display: flex; align-items: center; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line); }
.list li a { flex: 1; font-weight: 500; text-decoration: none; }
.list li.cur { background: var(--prism-bg-canvas); }
.list li.cur a { font-weight: 700; }
/* modal */
.scrim { position: absolute; inset: 0; background: var(--prism-scrim); z-index: var(--prism-z-scrim); display: flex; align-items: center; justify-content: center; padding: var(--prism-space-400); }
.modal { background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line-strong); border-radius: var(--prism-radius-lg);
width: 100%; max-width: 62rem; padding: var(--prism-space-300); display: flex; flex-direction: column; gap: var(--prism-space-200); z-index: var(--prism-z-modal); }
.modal header { display: flex; align-items: center; gap: var(--prism-space-200); }
.modal header h2 { flex: 1; }
.scroll { max-height: 18rem; overflow-y: auto; border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); }
.drawing { background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); display: block; width: 100%; height: auto; }
.toc a { display: flex; align-items: center; min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); text-decoration: none; font-weight: 500; border-radius: var(--prism-radius-sm); }
.toc a.cur { background: var(--prism-bg-canvas); font-weight: 700; }
.toc ol { list-style: none; }
.num { font-variant-numeric: tabular-nums; }
pre.code { margin: 0; background: var(--prism-bg-canvas); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);
padding: var(--prism-space-150); font-family: var(--prism-font); font-size: var(--prism-type-caption); line-height: 1.6; white-space: pre-wrap; }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }