@pghoya2956/livemap 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ # 검사 결과
2
+
3
+ 상황판의 검사 신호, 검사 파일별 마지막 실행, 기능 단계 등급 A는 검사 결과 JSON에서 읽는다(1.2.0). 러너가 파일마다 낸 실행 수·통과·실패를 그대로 받고, 결과를 만든 커밋 뒤에 코드가 바뀌었는지로 결과가 최신인지 정한다. 엔진은 검사를 해석하거나 다시 돌리지 않는다.
4
+
5
+ ## 결과 JSON
6
+
7
+ 경로는 설정 `tests.report`와 같은 폴더의 `test-results.json`이다(기본 `map/.out/test-results.json`). livemap이 소유하는 형식이고 필드 이름은 CTRF를 따른다.
8
+
9
+ ```json
10
+ {
11
+ "schema": 1,
12
+ "runs": [
13
+ {
14
+ "runner": "node",
15
+ "source": "livemap test-report",
16
+ "sha": "4f1c2d0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d",
17
+ "dirtyPaths": [],
18
+ "at": "2026-01-01T01:02:03.000Z",
19
+ "exit": 1,
20
+ "outsideRoot": 0,
21
+ "files": [
22
+ { "filePath": "tests/booking.test.mjs", "tests": 36, "passed": 35, "failed": 1, "skipped": 0, "pending": 0, "tags": [] }
23
+ ]
24
+ },
25
+ {
26
+ "runner": "playwright",
27
+ "source": "map/.out/playwright.json",
28
+ "stamped": "import",
29
+ "sha": "4f1c2d0e9b8a7c6d5e4f3a2b1c0d9e8f7a6b5c4d",
30
+ "dirtyPaths": [],
31
+ "at": "2026-01-01T01:10:00.000Z",
32
+ "exit": null,
33
+ "outsideRoot": 0,
34
+ "files": [
35
+ { "filePath": "tests/search.spec.mjs", "tests": 10, "passed": 10, "failed": 0, "skipped": 0, "pending": 0, "flaky": 0, "tags": ["diver/search"] }
36
+ ]
37
+ }
38
+ ]
39
+ }
40
+ ```
41
+
42
+ | 필드 | 뜻 |
43
+ |---|---|
44
+ | `runner` | `node`, `playwright`, `junit` |
45
+ | `source` | 실행 출처. `livemap test-report`, 가져온 파일의 루트 기준 경로 |
46
+ | `stamped` | `import`면 `sha`·`dirtyPaths`를 러너가 아니라 가져올 때 적었다 |
47
+ | `sha` | 실행한 커밋(`git rev-parse HEAD`). git이 없으면 `null` |
48
+ | `dirtyPaths` | 실행 때 커밋되지 않은 변경 경로(`git status --porcelain`, 루트 기준 정렬) |
49
+ | `at` | 실행 시각(UTC ISO). Playwright는 `stats.startTime`, JUnit은 첫 `testsuite`의 `timestamp` |
50
+ | `exit` | 러너 종료 코드. 모르면 `null` |
51
+ | `outsideRoot` | 프로젝트 루트 밖이라 뺀 파일 수 |
52
+ | `files[]` | 파일별 `tests`·`passed`·`failed`·`skipped`·`pending`, Playwright만 `flaky`, 러너 태그 `tags` |
53
+ | `totals` | JUnit 실행만. 파일 속성이 없어 `files`가 비어도 실행 전체 합계가 남는다 |
54
+
55
+ - 같은 러너·같은 출처의 실행은 바꾸고, 새 실행은 뒤에 붙인다. 파일은 임시 파일에 쓴 뒤 이름을 바꿔 넣어, 두 실행이 동시에 써도 깨지지 않고 나중 실행이 남는다.
56
+ - `filePath`는 검사 노드 id(검사 파일 경로)와 글자 그대로 맞춘다. `tests/booking.test.mjs`의 결과는 `tests/booking-journey.test.mjs`에 붙지 않는다.
57
+
58
+ ## 결과 만들기
59
+
60
+ ### Node 검사: livemap test-report
61
+
62
+ ```bash
63
+ npx --no livemap test-report
64
+ ```
65
+
66
+ `tests.dir`의 `*.test.mjs`를 `node --test`로 한 번 돌리면서 JUnit 리포터(1.x 호환 `tests.report`와 옆 `.json` 메타)와 livemap 리포터를 함께 붙인다. 결과 JSON의 `node`·`livemap test-report` 실행을 바꾸고, 종료 코드는 러너 종료 코드다. 이 명령을 `node --test` 안에서 불러도(검사 래퍼) 자식 러너가 파일을 건너뛰지 않는다.
67
+
68
+ ### Node 검사: 리포터를 기존 명령에 붙이기
69
+
70
+ 검사 명령이 따로 있으면(파일 목록·환경 변수·제외 파일) 그 명령에 livemap 리포터를 더하고 출력 파일을 가져온다.
71
+
72
+ ```bash
73
+ node --test \
74
+ --test-reporter=spec --test-reporter-destination=stdout \
75
+ --test-reporter=@pghoya2956/livemap/src/reporters/node-results.mjs \
76
+ --test-reporter-destination=map/.out/node-results.json \
77
+ tests/*.test.mjs
78
+ npx --no livemap test-report --import map/.out/node-results.json
79
+ ```
80
+
81
+ - 리포터 경로 `@pghoya2956/livemap/src/reporters/node-results.mjs`는 공개 경로다. 옮기면 major다.
82
+ - 리포터는 `test:pass`·`test:fail` 이벤트에서 `details.type`이 `suite`인 것을 빼고 파일별로 센다. `skip`이면 `skipped`, `todo`면 `pending`이다. 불러오기에 실패한 파일은 실패 1로 센다. Node v22.0.0 문서에 있는 이벤트 필드만 쓰고 `test:summary`는 쓰지 않는다.
83
+ - 리포터는 러너의 작업 폴더를 프로젝트 루트로 본다. 프로젝트 루트에서 러너를 부른다.
84
+ - 리포터는 종료 코드를 모르므로 출력의 `exit`는 `null`이다. 실패 신호는 파일별 `failed`로 난다.
85
+
86
+ ### Playwright
87
+
88
+ ```bash
89
+ PLAYWRIGHT_JSON_OUTPUT_NAME=map/.out/playwright.json npx --no playwright test --reporter=list,json
90
+ npx --no livemap test-report --import map/.out/playwright.json
91
+ ```
92
+
93
+ - 파일은 `config.rootDir`와 `suites[].file`(중첩 suite 포함)을 합친 경로다. 결과는 `tests[].status`로 센다: `expected` 통과, `unexpected` 실패, `flaky` 통과이며 `flaky` 수에 더함, `skipped` 건너뜀. 태그는 `specs[].tags`의 합집합이다(`@`를 뗀 값, Playwright 1.63.0 실측).
94
+ - Playwright `outputDir`(기본 `test-results/`)가 작업트리 안에 생기고 git이 무시하지 않으면 가져올 때 `dirtyPaths`에 들어가 결과가 낡는다. `.gitignore`에 넣거나 `map/.out/` 아래로 둔다.
95
+
96
+ ### JUnit
97
+
98
+ ```bash
99
+ npx --no livemap test-report --import reports/junit.xml --sha "$(git rev-parse HEAD)"
100
+ ```
101
+
102
+ 최상위·중첩 `<testcase>`를 모두 센다. `file` 속성(없으면 가장 가까운 `testsuite`의 속성)이 있으면 파일별로 나누고, 없으면 실행 합계(`totals`)만 남는다.
103
+
104
+ ### --import 규칙
105
+
106
+ - 형식은 내용으로 가린다. `schema 1`과 `runs` 배열이면 livemap 결과(리포터 출력 포함), `config` 객체와 `suites` 배열이면 Playwright JSON, `<testsuites`·`<testsuite`·`<testcase`가 있으면 JUnit이다. 가릴 수 없거나 파일이 없으면 종료 코드 2이고 결과 JSON을 쓰지 않는다.
107
+ - livemap 결과는 파일에 적힌 `sha`·`dirtyPaths`·`at`을 지킨다. `source`가 `null`인 실행(리포터 출력)만 가져온 파일 경로를 채운다.
108
+ - Playwright·JUnit 출력에는 커밋이 없어 `--sha`(없으면 가져올 때의 HEAD)와 가져올 때의 `dirtyPaths`를 적고 `stamped: "import"`를 남긴다. 러너를 돌린 커밋과 가져오는 커밋이 다르면 `--sha`로 실행 커밋을 준다. 오래된 결과를 지금 HEAD로 가져오면 최신으로 보이므로 CI에서는 러너 바로 뒤에 가져온다.
109
+
110
+ ## 최신 판정
111
+
112
+ testreport 어댑터는 결과 JSON이 있으면 그것만 읽는다. 실행마다 다음 셋이 모두 맞으면 최신이다.
113
+
114
+ 1. 실행 `sha`가 git 어댑터의 HEAD(`git.branch` → origin → HEAD)와 같거나 그 조상이다.
115
+ 2. `sha` 뒤 HEAD까지 문서 경로 밖을 바꾼 커밋이 0이다(`git rev-list --count <sha>..<HEAD> -- . ':(exclude)<문서 경로>'…`).
116
+ 3. `dirtyPaths`에 문서 경로 밖 경로가 없다. `dirtyPaths`가 없는 실행(JUnit 메타)은 이 조건을 건너뛴다.
117
+
118
+ 문서 경로는 새 설정 키 없이 기존 설정에서 정한다: `tasks.dir`, `wiki.index`의 폴더(루트 파일이면 그 파일), `semantic`, `roadmap.file`, `captures.site`, `deploy.manifest`, `map/judgments`. 설정에 없는 키는 뺀다. 코드·CI·로컬 스택·설정 파일 변경은 모두 결과를 낡게 한다. 그래서 작업 문서·판정 파일·여정만 바꾼 커밋 뒤에도 결과는 최신으로 남고, 코드를 바꾼 커밋 뒤에는 다시 돌려야 최신이 된다.
119
+
120
+ 결과가 최신이 아니면 검사 노드 `readingNotes.lastRun`에 이유가 남는다.
121
+
122
+ | 이유 문장 | 뜻 |
123
+ |---|---|
124
+ | 결과 커밋이 이력에 없음 | git이 없거나 얕은 클론이라 `sha`를 찾지 못함 |
125
+ | 결과 커밋이 HEAD의 조상이 아님 | 다른 브랜치에서 낸 결과 |
126
+ | 결과 커밋 뒤 문서 경로 밖 변경 커밋 n | 조건 2 |
127
+ | 실행 때 문서 경로 밖 변경 n(경로 3개까지) | 조건 3 |
128
+ | 결과에 이 검사 파일이 없음 | 결과 JSON에 그 파일이 없음(`lastRun` 읽기 상태 `unknown`) |
129
+ | JUnit에 파일(file 속성)이 없어 파일별 결과 없음 | JUnit 대체 읽기에서 파일을 나눌 수 없음 |
130
+
131
+ ## 상황판에 들어가는 값
132
+
133
+ - 검사 노드 `props.lastRun`: `passed`(`failed === 0`), `tests`, `failed`, `skipped`, `pending`, `flaky`(Playwright만), `runner`, `sha`, `at`, `fresh`, `tags`(러너 태그). 같은 파일이 여러 실행에 있으면 최신 실행을 먼저, 같으면 나중 `at`을 쓴다.
134
+ - 검사 개수: 최신 결과가 있는 파일은 실행 수로 바꾸고 `count` 읽기 상태가 `observed`다. 낡은 결과는 개수를 바꾸지 않고 `runCount`에만 싣는다.
135
+ - 개요 `signals.tests`: 최신 실행 중 실패가 있거나 `exit`가 0이 아니면 `fail`, 실행이 모두 최신이고 실패 0이면 `ok`, 최신이 아닌 실행이 있으면 `stale`, 실행이 없으면 `none`.
136
+ - 등급 A: 단계의 화면·API·함수를 덮는 검사 파일 중 하나 이상이 최신 실행에서 `tests` 1 이상, `failed` 0이다.
137
+ - `data.json` `testRuns[]`: `{ runner, source, sha, at, exit, fresh }`. `dirtyPaths`는 싣지 않는다(`data.json`은 서빙된다).
138
+
139
+ 결과 JSON이 없으면 1.x처럼 `tests.report`(JUnit)와 옆 메타(`sha`·`exit`)를 같은 해석기로 읽는다. 메타 `exit`가 0이 아니면 실패다. 둘 다 없으면 어댑터 상태가 partial "검사 리포트 없음(npm run test:report 미실행)"이고 검사 `lastRun` 읽기 상태는 `unknown`이다.
140
+
141
+ ## CI 배선 예
142
+
143
+ 검사 잡과 상황판 이미지·정적 사이트 빌드 잡이 따로 도는 파이프라인의 예다. 검사 잡이 결과 JSON을 아티팩트로 올리고, 빌드 잡이 `livemap build` 전에 받는다.
144
+
145
+ ```yaml
146
+ jobs:
147
+ tests:
148
+ runs-on: ubuntu-latest
149
+ steps:
150
+ - uses: actions/checkout@v4
151
+ with: { fetch-depth: 0 }
152
+ - uses: actions/setup-node@v4
153
+ with: { node-version: 22 }
154
+ - run: npm ci
155
+ - run: >
156
+ node --test
157
+ --test-reporter=spec --test-reporter-destination=stdout
158
+ --test-reporter=@pghoya2956/livemap/src/reporters/node-results.mjs
159
+ --test-reporter-destination=map/.out/node-results.json
160
+ tests/*.test.mjs
161
+ - run: npx --no playwright test --reporter=list,json
162
+ env: { PLAYWRIGHT_JSON_OUTPUT_NAME: map/.out/playwright.json }
163
+ - if: always()
164
+ run: |
165
+ npx --no livemap test-report --import map/.out/node-results.json
166
+ npx --no livemap test-report --import map/.out/playwright.json
167
+ - if: always()
168
+ uses: actions/upload-artifact@v4
169
+ with: { name: test-results, path: map/.out/test-results.json }
170
+
171
+ board:
172
+ needs: [tests]
173
+ if: always()
174
+ runs-on: ubuntu-latest
175
+ steps:
176
+ - uses: actions/checkout@v4
177
+ with: { fetch-depth: 0 }
178
+ - uses: actions/setup-node@v4
179
+ with: { node-version: 22 }
180
+ - run: npm ci
181
+ - uses: actions/download-artifact@v4
182
+ with: { name: test-results, path: map/.out }
183
+ continue-on-error: true
184
+ - run: npm run map && npm run map:export
185
+ ```
186
+
187
+ - `fetch-depth: 0`이 없으면 얕은 클론이라 결과 커밋을 찾지 못해 검사 신호가 "?"다.
188
+ - 빌드 잡이 검사 잡을 기다리지 않는 파이프라인(검사가 제품 경로가 바뀐 push에서만 돌거나 이미지와 동시에 도는 경우)은 가장 최근에 결과 아티팩트를 올린 실행에서 받는다(`gh run list`로 실행을 찾고 `gh run download`, 잡 권한에 `actions: read`). 문서만 바꾼 push의 상황판은 그 결과를 최신으로 싣고, 코드를 바꾼 push는 그 커밋 검사가 끝나기 전이라 검사 신호가 "?"(stale)다.
189
+ - 로컬에서는 검사를 돌린 세션이 `npm run test:report`와 브라우저 검사 `--import`로 결과 JSON을 갱신한다. `map:serve`는 코드가 그대로인 동안 그 결과를 최신으로 본다.
@@ -35,6 +35,24 @@
35
35
 
36
36
  스크린샷은 모션 줄임 컨텍스트에서 `document.fonts.ready` 뒤에 찍는다. 새로 더한 숨은 스크롤·모션 줄임·상태 모양 검사는 엔진 화면 요소만 보므로, 프로젝트 자료가 달라서 새로 실패하지 않는다. 내부 용어·글자 대비·글자 크기 집합은 프로젝트 문구와 브라우저에 따라 달라져 예산 검사에 넣지 않는다.
37
37
 
38
+ ## 로드맵 화면
39
+
40
+ 로드맵(`#/roadmap`)은 30초 안에 읽는 첫 화면이 아니라 찾아 들어와 따라 읽는 화면이다. 그래서 개요 임계값(스크롤 0, 패널 8, 목록 행 6)을 물려받지 않고 페이지가 세로로 스크롤한다. 항목 수가 프로젝트마다 달라 개수 상한을 두면 남의 자료가 실패하므로 개수 임계값을 두지 않고 성질만 잰다. `budget/view-budget.spec.mjs`의 로드맵 절(`-g 로드맵`)이 재는 것은 다음이다. `data.roadmap`이 비면 건너뛴다.
41
+
42
+ - CSP 위반·콘솔 오류 0.
43
+ - 모션 줄임에서 `document.getAnimations()` 0.
44
+ - 트리 노드(`.rt-node`) 수 = `data.roadmap` 수, 선(`.rt-edge`) 수 = 있는 선행 수.
45
+ - 같은 열 노드는 왼쪽 좌표가 같고 열이 다르면 다르다.
46
+ - 잠긴 노드의 접근 가능한 이름에 막는 선행 제목이 있다.
47
+ - 노드를 누르면 밝아지는 선(`.rt-edge.on`)이 그 항목의 조상·자손 사이 선과 같다.
48
+ - 노드가 전부 뷰포트 안이거나 트리 상자의 가로 스크롤 안에 있다.
49
+ - 트리 상자에 세로 숨은 스크롤이 없다. 개요 규칙과 같은 식(`overflow-y`가 `auto`·`scroll`이면서 `scrollHeight − clientHeight > 1`)으로 잰다. 트리 상자는 가로 스크롤이 필요할 때만 `overflow-x`를 갖고, 브라우저가 이때 `overflow-y` 계산값을 `auto`로 올리므로 계산값만으로는 판정하지 않는다.
50
+ - Tab으로 첫 노드에 닿고, 화살표 네 방향(← 첫 선행, → 첫 후속, ↑·↓ 같은 열 이웃)으로 초점이 옮겨 간다.
51
+
52
+ ## 캡처 패널
53
+
54
+ 개요 캡처 패널은 사용자가 기능 지도·기능별 변경·기능 현황에서 기능을 누르면 그 기능 캡처만 돌리고, 캡처가 없으면 "캡처 없음"을 보인다. 누르기 전 첫 화면은 1.2.0과 같다. 기능마다 첫 캡처를 한 장씩 최대 5장 보이고 보조 문구도 그대로다. `overview.json`의 `captures`는 기능당 5장까지 실리므로 배열 앞 5장을 그대로 쓰면 썸네일 그림이 바뀌고 같은 그림이 두 번 나올 수 있다. 캡처 절(`-g 캡처`)은 누르기 전 썸네일 5장 이하, 기능을 누른 뒤 썸네일 수 = 그 기능 캡처 수, 캡처 0건 기능의 "캡처 없음", 범위를 바꾼 직후 장 번호 1을 잰다.
55
+
38
56
  ## 개요 조각
39
57
 
40
58
  개요는 `overview.json`만 읽고 다시 계산하지 않는다. `derive.mjs`의 `overviewSlice(d, opts)`가 경로·파일명·sha를 뺀 조각을 만들고, 검사는 개요 본문에서 `/api/`·`.tsx`·`.mjs`·`.sql`·`web/src`·7자 이상 16진수를 찾아 하나라도 있으면 실패한다. 개요에 무언가를 더할 때 이 조각을 거치지 않으면 식별자가 새어 들어온다.
@@ -58,5 +76,5 @@
58
76
  3. `ui/components/panels.jsx`에 컴포넌트를 만들고 `ui/Overview.jsx` 격자에 놓는다. 틀은 `ui/components/primitives.jsx`의 `Panel`이고, 목록 패널은 `budget="list"`와 `.rows > .row`, 행 수는 `ui/lib/fit.js`의 `useFitRows`로 정한다. 마크업의 `style` 속성·`<style>` 주입은 쓰지 않고 크기·위치만 React `style` prop으로 준다. 프로젝트가 적은 문구를 담는 요소에는 `data-text="project"`, 커밋 제목에는 `data-text="commit"`을 단다.
59
77
  4. 자료가 더 필요하면 `overviewSlice`에 필드를 더한다(추가만 한다. 1.0.1 필드의 이름·형·값은 바꾸지 않는다).
60
78
  5. `node scripts/build-ui.mjs`로 `site/`를 다시 만들어 함께 커밋한다. CI는 다시 빌드한 결과가 커밋과 같은지 본다.
61
- 6. 누르거나 이동하는 요소를 더하거나 바꿨으면 `scripts/click-targets.json`에 행을 맞춘다. 표에 없는 대화형 요소가 있으면 클릭 경로 크롤이 실패한다. 엔진 저장소에서 `node scripts/route-crawl.mjs --url http://127.0.0.1:<포트>/map/ --targets scripts/click-targets.json`(한 화면만 `--only <overview|journeys|roadmap|tasks|more>`, 이동 없이 개요만 `--overview-only`)로 확인한다. 크롤러는 팩에 없어 엔진 저장소에서만 돈다.
79
+ 6. 누르거나 이동하는 요소를 더하거나 바꿨으면 `scripts/click-targets.json`에 행을 맞춘다. 표에 없는 대화형 요소가 있으면 클릭 경로 크롤이 실패한다. 엔진 저장소에서 `node scripts/route-crawl.mjs --url http://127.0.0.1:<포트>/map/ --targets scripts/click-targets.json`(한 화면만 `--only <overview|journeys|roadmap|tasks|more>`, 이동 없이 개요만 `--overview-only`)로 확인한다. 통과 판정은 `--only` 없이 전체로 돌린다. `--only roadmap`은 1.2.0에서도 다른 화면을 거쳐 닿는 행 하나가 실패한다(원인 미확정). 크롤러는 팩에 없어 엔진 저장소에서만 돈다.
62
80
  7. 엔진 CI의 스모크(픽스처: 예산, 하위 화면 콘솔 0, 설정 경로를 바꾼 복사본의 화면 문구, 클릭 경로)와, 쓰는 프로젝트에 `npm install --no-save --install-links <엔진 저장소>`로 끼운 `npm run map:budget`으로 스크롤·숨은 스크롤·행·식별자를 잰다. 스크린샷 `map/.out/overview-1440.png`을 사용자에게 보인다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pghoya2956/livemap",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Project status board engine: scans a repository into a graph and serves a one-screen map of journeys, screens, APIs, tests and work.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/site/map.css CHANGED
@@ -326,6 +326,61 @@ a.row:hover .tt,button.row:hover .tt{color:#fff}
326
326
  [data-screen="roadmap"] .rm-ms-hd .rm-wait{margin:0;font-size:12.5px}
327
327
  [data-screen="roadmap"] .rm-ms .rm{margin-left:12px}
328
328
  [data-screen="roadmap"] .rm-src{margin-top:14px}
329
+ /* 로드맵 기술 트리(1.3.0). 좌표·크기는 React style prop이 준다. 트리 상자에는 가로 스크롤이 필요할 때 overflow-x만 준다(세로는 페이지가 스크롤, DEC-14).
330
+ 가로 스크롤이 없으면 패널 overflow도 풀어 열 머리 sticky가 페이지 기준으로 선다(DEC-51) */
331
+ [data-screen="roadmap"] .rt-panel{margin:0 0 12px}
332
+ [data-screen="roadmap"] .rt-faded{font-size:11.5px;color:var(--amber);white-space:nowrap}
333
+ [data-screen="roadmap"] .rt-bar{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;padding:10px 14px 0}
334
+ [data-screen="roadmap"] .rt-legend{display:flex;flex-wrap:wrap;gap:4px 14px;align-items:center;font-size:11.5px;color:var(--muted)}
335
+ [data-screen="roadmap"] .rt-legend>span{display:inline-flex;align-items:center;gap:6px}
336
+ [data-screen="roadmap"] .rt-sw{flex:none;display:block}
337
+ [data-screen="roadmap"] .rt-key{display:inline-block;width:14px;height:10px;border:1px dashed var(--plan);border-radius:3px}
338
+ [data-screen="roadmap"] .rt-panel.is-fit{overflow:visible}
339
+ [data-screen="roadmap"] .rt-box{padding:8px 0 10px}
340
+ [data-screen="roadmap"] .rt-box.is-scroll{overflow-x:auto;scrollbar-width:thin;scrollbar-color:var(--line2) transparent}
341
+ [data-screen="roadmap"] .rt-canvas{position:relative}
342
+ [data-screen="roadmap"] .rt-edges{position:absolute;left:0;overflow:visible}
343
+ [data-screen="roadmap"] .rt-line{fill:none;stroke:var(--plan);stroke-width:1.5}
344
+ [data-screen="roadmap"] .rt-line.is-cyc{stroke-dasharray:5 4}
345
+ [data-screen="roadmap"] .rt-line.is-back{stroke:var(--amber);stroke-dasharray:5 4}
346
+ [data-screen="roadmap"] .rt-line.on{stroke:var(--cyan);stroke-width:2}
347
+ [data-screen="roadmap"] .rt-line.dim{stroke:var(--dim)}
348
+ [data-screen="roadmap"] .rt-col{position:absolute;top:0;border-radius:8px}
349
+ [data-screen="roadmap"] .rt-col.on{background:var(--cyan-soft)}
350
+ [data-screen="roadmap"] .rt-head{position:sticky;top:0;z-index:2;margin:0;display:flex;flex-direction:column;justify-content:center;gap:3px;padding:0 4px;font-size:11.5px;font-weight:500;color:var(--muted);background:var(--panel);border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
351
+ [data-screen="roadmap"] .rt-head.on{border-bottom-color:var(--cyan)}
352
+ [data-screen="roadmap"] .rt-head-t{font-weight:600;color:var(--ink);overflow:hidden;text-overflow:ellipsis}
353
+ [data-screen="roadmap"] .rt-head-m{display:flex;gap:6px;align-items:center;overflow:hidden}
354
+ [data-screen="roadmap"] .rt-list{position:absolute;left:0;right:0;margin:0;padding:0;list-style:none}
355
+ [data-screen="roadmap"] .rt-node{position:absolute;display:flex;flex-direction:column;gap:4px;padding:9px 10px 8px;background:var(--panel2);border:1px solid var(--line2);border-radius:8px;color:var(--ink);text-decoration:none;overflow:hidden}
356
+ [data-screen="roadmap"] .rt-node.is-done{border-color:var(--green)}
357
+ [data-screen="roadmap"] .rt-node.is-doing{border-color:var(--cyan)}
358
+ [data-screen="roadmap"] .rt-node.is-locked{border-style:dashed;border-color:var(--plan)}
359
+ [data-screen="roadmap"] .rt-node:hover{border-color:var(--ink)}
360
+ [data-screen="roadmap"] .rt-node:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}
361
+ [data-screen="roadmap"] .rt-node.on{box-shadow:0 0 0 1px var(--cyan)}
362
+ [data-screen="roadmap"] .rt-node.sel{border-color:var(--cyan);box-shadow:0 0 0 2px var(--cyan)}
363
+ [data-screen="roadmap"] .rt-node.dim{opacity:.5}
364
+ [data-screen="roadmap"] .rt-node.faded{opacity:.3}
365
+ [data-screen="roadmap"] .rt-top{display:flex;gap:6px;align-items:flex-start}
366
+ [data-screen="roadmap"] .rt-t{flex:1;min-width:0;font-size:12.5px;font-weight:600;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
367
+ [data-screen="roadmap"] .rt-date{flex:none;font:10.5px var(--mono);color:var(--green)}
368
+ [data-screen="roadmap"] .rt-block{flex:none;display:inline-flex;align-items:center;height:16px;padding:0 5px;border:1px solid rgba(245,165,36,.5);border-radius:4px;font-size:10.5px;color:var(--amber);background:var(--amber-soft)}
369
+ [data-screen="roadmap"] .rt-m{font-size:10.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
370
+ [data-screen="roadmap"] .rt-ticks{display:inline-flex;gap:3px;align-items:center;margin-top:auto}
371
+ [data-screen="roadmap"] .rt-tick{width:8px;height:8px;border-radius:50%;background:var(--muted)}
372
+ [data-screen="roadmap"] .rt-tick.s-live{background:var(--green)}
373
+ [data-screen="roadmap"] .rt-tick.s-mock{background:var(--amber)}
374
+ [data-screen="roadmap"] .rt-tick.s-planned{background:none;border:1.5px solid var(--plan)}
375
+ [data-screen="roadmap"] .rt-tick.s-next{background:none;border:1.5px dashed var(--violet)}
376
+ [data-screen="roadmap"] .rt-tick.is-miss{background:none;border:1.5px solid var(--red)}
377
+ [data-screen="roadmap"] .rt-more{font-size:10px;color:var(--muted)}
378
+ [data-screen="roadmap"] .rt-detail{margin:0 0 12px;max-width:980px}
379
+ [data-screen="roadmap"] .rm-lock{border-style:dashed;border-color:var(--plan)}
380
+ [data-screen="roadmap"] .rm-stepw{display:inline-flex;flex-direction:column;gap:2px;max-width:260px}
381
+ [data-screen="roadmap"] .rm-also{font-size:11px;line-height:1.35;color:var(--muted)}
382
+ @media (prefers-reduced-motion:no-preference){[data-screen="roadmap"] .rt-node{transition:opacity .15s,border-color .15s,box-shadow .15s}[data-screen="roadmap"] .rt-line{transition:stroke .15s}}
383
+ @media (max-width:719.98px){[data-screen="roadmap"] .rt-panel{display:none}}
329
384
  @media (max-width:640px){[data-screen="roadmap"] .rm-item{grid-template-columns:1fr}[data-screen="roadmap"] .rm-num{text-align:left}[data-screen="roadmap"] .rm-kv{grid-template-columns:1fr}[data-screen="roadmap"] .rm-ms .rm{margin-left:0}}
330
385
 
331
386
  /* routes: tasks */
@@ -339,6 +394,24 @@ a.row:hover .tt,button.row:hover .tt{color:#fff}
339
394
  [data-screen="tasks"] .tk-num{font:12px var(--mono);white-space:nowrap}
340
395
  [data-screen="tasks"] .t-폐기{color:var(--red);background:var(--red-soft)}
341
396
  [data-screen="tasks"] .t-기록{color:var(--muted);background:transparent}
397
+ [data-screen="tasks"] .tk-cols{margin-top:10px}
398
+ [data-screen="tasks"] .tk-src{margin-top:8px}
399
+ [data-screen="tasks"] .tk-reading{margin-top:12px;padding-top:8px;border-top:1px solid var(--line)}
400
+ [data-screen="tasks"] .tk-reading h4{margin:0 0 6px}
401
+
402
+ /* 읽기 상태 표기(작업·더보기): "?"·이유 분류·판정 */
403
+ .rd-q{color:var(--amber);font-weight:700}
404
+ .rd-why{margin-left:6px;font:10.5px var(--sans);color:var(--amber);white-space:nowrap}
405
+ .rd-list{list-style:none;margin:0 0 8px;padding:0}
406
+ .rd-list li{padding:5px 0;border-bottom:1px solid #121820;font-size:12px;color:var(--ink)}
407
+ .rd-list li.warn{color:var(--amber)}
408
+ .rd-list li.error{color:var(--red)}
409
+ .rd-note{margin-top:2px;font-size:11.5px;color:var(--muted);white-space:normal;overflow-wrap:anywhere}
410
+ .rd-code{font:10.5px var(--mono);color:var(--muted)}
411
+ .rd-excerpt{color:var(--dim)}
412
+ .rd-judged{font-size:12px}
413
+ .rd-ok{margin:0;font-size:12px}
414
+ .t-judged{color:var(--cyan);background:var(--cyan-soft)}
342
415
 
343
416
  /* routes: more */
344
417
  [data-screen="more"] .tabs{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 14px}
@@ -377,6 +450,13 @@ a.row:hover .tt,button.row:hover .tt{color:#fff}
377
450
  [data-screen="more"] .mm-issue{padding:6px 0;border-bottom:1px solid #121820;font-size:12.5px}
378
451
  [data-screen="more"] .mm-issue.warn{color:var(--amber)}
379
452
  [data-screen="more"] .mm-issue.error{color:var(--red)}
453
+ [data-screen="more"] .mm-issue-group{padding:4px 0 8px}
454
+ [data-screen="more"] .mm-issue-hd{padding:4px 0;font-size:12.5px;color:var(--ink)}
455
+ [data-screen="more"] .mm-issue-hd.warn b{color:var(--amber)}
456
+ [data-screen="more"] .mm-issue-hd.error b{color:var(--red)}
457
+ [data-screen="more"] .mm-rd-values{display:flex;flex-wrap:wrap;gap:6px 14px;margin:8px 0 10px;font-size:12px;color:var(--muted)}
458
+ [data-screen="more"] .mm-rd-v b{color:var(--ink)}
459
+ [data-screen="more"] .mm-rd-v.warn b,[data-screen="more"] .mm-warn{color:var(--amber)}
380
460
  [data-screen="more"] .mm-prose h3{margin:14px 0 6px;font-size:13px}
381
461
  [data-screen="more"] .mm-prose h3:first-child{margin-top:0}
382
462
  [data-screen="more"] .mm-prose p{margin:0 0 8px;font-size:12.5px;color:#C9D2DB;line-height:1.55}