@pghoya2956/livemap 1.2.0 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,58 @@
2
2
 
3
3
  버전마다 `## [X.Y.Z] - YYYY-MM-DD` 절을 둔다. 릴리스 워크플로가 태그 버전의 절이 있는지 확인한다.
4
4
 
5
+ ## [1.4.0] - 2026-09-19
6
+
7
+ 작업 문서·검사 파일을 읽는 규칙을 넓히고 바뀐 화면의 검사를 고르는 명령을 더한다. 더하기만 하고 지우는 것은 없다.
8
+
9
+ ### 추가
10
+
11
+ - `livemap affected [--base <ref>]`: 바뀐 파일 → 그 파일을 쓰는 화면 → 그 화면을 지나는 브라우저 검사와 실행 명령. 화면 밖 코드가 섞이면 전체 실행, 문서 경로만 바뀌면 고를 검사 없음.
12
+ - 검사 어댑터가 Playwright 단계 태그 `@<여정>/<단계>`를 읽어 검사를 그 단계의 화면에 잇는다. 여정에 없는 태그는 `tests.tag-unknown` 경고이고, 템플릿 태그는 읽기 상태 `partial`이다.
13
+ - `src/lib/md-props.mjs`: 제목 2단 절마다 `- 키: 값`·목표 문장·표를 읽는 파서. 로드맵 어댑터가 이것을 쓴다.
14
+ - `src/lib/literals.mjs`에 `extractPathLiterals`(임의 접두어).
15
+
16
+ ### 고침
17
+
18
+ - 검사→화면 연결을 이동 호출(`page.goto`와 닫힘 안에서 goto를 부르는 헬퍼) 기준으로 좁혔다. 글자 어디에나 있는 주소를 세던 규칙은 "이 화면에는 닿지 않는다"는 음성 단언의 주소까지 덮은 것으로 잡았다.
19
+ - md 속성 파서가 NBSP·엔 스페이스·전각 공백을 일반 공백으로 맞춘다. 편집기가 `- 사용자 확인:` 뒤에 넣는 문자 때문에 키·값을 놓치던 자리다.
20
+
21
+ ## [1.3.0] - 2026-09-19
22
+
23
+ 로드맵 화면이 항목 목록에서 선행 관계가 보이는 기술 트리로 바뀐다. 열은 자료가 정한다 — 로드맵 파일에 `## 마일스톤:` 절이 있으면 열이 마일스톤이고, 없으면 열이 선행 깊이다. 개요에서 기능을 고르면 화면 캡처 패널이 그 기능의 캡처만 돌린다. 고르기 전 첫 화면은 1.2.0과 같다. 1.2.0 생성물의 필드는 지우거나 이름을 바꾸지 않았고 새 설정 키도 없다. 값이 바뀌는 것은 `overview.json`의 `captures` 항목 수 하나다.
24
+
25
+ ### 추가
26
+
27
+ 로드맵 기술 트리
28
+
29
+ - 로드맵 화면에 트리 패널이 선다. 노드가 로드맵 항목, 선이 선행, 열 머리가 그 열의 상태별 인원이다. 노드를 누르면 그 항목의 조상·자손 사이 선만 밝아지고 나머지는 흐려진다. 고른 항목의 카드는 트리 아래 상세 슬롯에 선다.
30
+ - 열 모드가 둘이다. 마일스톤이 한 건이라도 있으면 열 하나가 마일스톤 하나이고 열 순서는 로드맵 파일의 절 순서다(화면이 재정렬하지 않는다). 마일스톤이 없으면 열 하나가 선행 깊이 한 단계다.
31
+ - 어느 마일스톤에도 안 묶인 항목과 없는 마일스톤 id를 가리키는 항목은 맨 오른쪽 "마일스톤 없음" 열에 모인다. 0건이면 그 열을 만들지 않는다.
32
+ - 선행이 끝나지 않은 항목에 잠김 표시가 붙고 무엇이 막는지 이름으로 보인다. 카드에도 층 칩과 잠김 칩이 붙어 좁은 화면에서 트리가 감춰져도 정보가 남는다.
33
+ - 키보드로 트리를 돈다. Tab으로 첫 노드에 닿고 화살표 왼쪽·오른쪽이 첫 선행·첫 후속, 위·아래가 같은 열 이웃이다. 모션 줄임에서 애니메이션이 없다.
34
+ - 폭 720px 미만에서는 트리를 감추고 카드 목록만 보인다. 노드 폭이 150px 바닥에 닿으면 트리 상자에 가로 스크롤이 생긴다.
35
+
36
+ 기능별 캡처 연결
37
+
38
+ - 개요에서 기능 지도·기능 추세·기능 표 어디를 눌러도 화면 캡처 패널이 그 기능의 캡처만 돌린다. 캡처가 없는 기능을 고르면 빈 상태를 보인다. 범위가 바뀌면 장 번호와 멈춤이 되돌아간다.
39
+ - `CapturePanelProps`에 `selected`·`userPicked`·`previewCount` 셋을 더했다. 셋 다 선택이라 기존 호출은 그대로 돈다.
40
+
41
+ 엔진
42
+
43
+ - 로드맵 항목의 `problems`에 문장 둘을 더한다 — 선행 순환과 마일스톤 순서 역행이다. 둘 다 경고이고 화면은 멈추지 않는다. 코드는 `docs/issue-codes.md`에 있다.
44
+ - 예산 검사에 로드맵 화면 절과 캡처 패널 절이 생겼다. 로드맵 절은 개요의 개수 임계값을 물려받지 않고 성질만 잰다.
45
+ - 클릭 대상 크롤러가 개요 밖 화면의 대화형 요소도 표와 대조한다. 그전에는 개요에서만 셌다.
46
+
47
+ ### 값이 바뀌는 것
48
+
49
+ - `overview.json`의 `captures`가 늘어난다. 1.2.0은 기능마다 한 장씩 전체 5장이 상한이었는데, 1.3.0은 기능마다 최대 다섯 장이고 전체 상한이 없다. 여정 9개·단계 35개 규모의 실제 프로젝트에서 5개가 15개가 되고 파일이 666바이트 늘었다. 배열 항목이 느는 것이고 항목의 필드 모양은 그대로다.
50
+ - 고르기 전 개요 첫 화면에 보이는 썸네일은 그대로 5장이다. 미리보기가 1.2.0과 같은 알고리즘(기능마다 첫 장)으로 앞 다섯을 고르기 때문이다.
51
+
52
+ ### 알려진 한계
53
+
54
+ - 열 머리 고정이 가로 스크롤이 없을 때만 걸린다. 가로로 스크롤하는 상자 안에서는 `position: sticky`가 페이지가 아니라 그 상자를 기준으로 붙기 때문이다. 열이 많아 가로 스크롤이 켜지는 자료는 열마다 인원이 적어 머리가 화면 밖으로 나갈 일이 드물다.
55
+ - 마일스톤 모드에서 열을 건너뛰는 선행은 가운데 열 노드 뒤를 지난다. 그 모드는 더미 꺾임점을 만들지 않는다.
56
+
5
57
  ## [1.2.0] - 2026-09-18
6
58
 
7
59
  엔진이 못 읽은 곳을 0으로 세지 않고 "?"와 근거 줄이 달린 이슈로 드러내고, 에이전트나 사람이 적은 판정 파일을 원문과 대조해 값으로 받는다. 검사 결과는 러너가 낸 파일별 결과 JSON으로, 화면→API 호출은 화면 코드의 경로 리터럴로 관측한다. 엔진은 계속 LLM·네트워크를 부르지 않는다. README 「버전」이 major로 정한 항목(config 키, 여정 형식, 어댑터 계약, 명령·종료 코드, 생성물 파일 이름, export 배치, 예산 설정 경로)은 더하기만 했다. 새 설정 키는 없다. 1.1.1 생성물의 필드는 지우거나 이름을 바꾸지 않았고, 규칙이 바로잡히며 값이 바뀌는 필드는 아래 「값이 바뀌는 것」에 모았다.
@@ -3,6 +3,7 @@
3
3
  // · 내비 5가 모두 보임 · #root 글자에 시스템 식별자 0 · 기능 지도 선택 → a.open → 단계 카드 → 단계 상세 3번 안
4
4
  // · 패널 숨은 스크롤 0 · 모션 줄임에서 애니메이션 0 · 범례 상태 모양 4종 구분 · 모션 줄임 스크린샷
5
5
  // 임계값은 map/config.json의 budget(viewport·maxPanels·maxRowsPerPanel·navItems)이다.
6
+ // 1.3.0: 로드맵 화면 절(-g 로드맵)과 캡처 패널 절(-g 캡처)을 더한다. 로드맵은 개요 임계값을 물려받지 않고 개수 상한 없이 성질만 잰다.
6
7
  import { test, expect } from '@playwright/test';
7
8
  import { readFileSync } from 'node:fs';
8
9
  import { resolve } from 'node:path';
@@ -120,3 +121,200 @@ test.describe('모션 줄임', () => {
120
121
  await page.screenshot({ path: resolve(process.cwd(), 'map/.out/overview-1440.png') });
121
122
  });
122
123
  });
124
+
125
+ // ---- 로드맵 화면(1.3.0, DEC-26·DEC-44): 개요 임계값을 물려받지 않고 개수 상한 없이 성질만 잰다. data.roadmap이 비면 건너뛴다 ----
126
+ // 기대값은 data.json에서 이 파일 안에서 계산한다(패키지에 ui/가 없어 화면 모듈을 부르지 못한다). 규칙은 ui/lib/tree.js와 같다:
127
+ // 있는 선행만 선, 잠김 = 완료·진행이 아니고 선행 중 미완, 고른 길 = 고른 항목과 조상끼리·고른 항목과 자손끼리의 선.
128
+ const loadData = (page) => page.evaluate(() => fetch('data/data.json', { cache: 'no-store' }).then((r) => r.json()));
129
+ const openRoadmap = async (page, hash = '#/roadmap') => {
130
+ await page.goto(`/map/${hash}`);
131
+ await page.waitForSelector('[data-screen="roadmap"]', { timeout: 10_000 });
132
+ await page.evaluate(() => document.fonts.ready);
133
+ return loadData(page);
134
+ };
135
+ function roadmapModel(d) {
136
+ const items = [...d.roadmap].sort((a, b) => a.order - b.order);
137
+ const byId = new Map(items.map((m) => [m.id, m]));
138
+ const deps = new Map(items.map((m) => [m.id, [...new Set((m.deps || []).map((x) => (typeof x === 'string' ? x : x.id)))].filter((x) => byId.has(x))]));
139
+ const children = new Map(items.map((m) => [m.id, []]));
140
+ for (const m of items) for (const x of deps.get(m.id)) children.get(x).push(m.id);
141
+ const edges = items.flatMap((m) => deps.get(m.id).map((x) => `${x}>${m.id}`));
142
+ const locked = items.filter((m) => !['완료', '진행'].includes(m.status) && deps.get(m.id).some((x) => byId.get(x).status !== '완료'));
143
+ const reach = (id, next) => { const seen = new Set(); const st = [...next.get(id)]; while (st.length) { const x = st.pop(); if (x === id || seen.has(x)) continue; seen.add(x); st.push(...next.get(x)); } return seen; };
144
+ const pathEdges = (id) => {
145
+ const up = new Set([id, ...reach(id, deps)]), down = new Set([id, ...reach(id, children)]);
146
+ return edges.filter((e) => { const [a, b] = e.split('>'); return (up.has(a) && up.has(b)) || (down.has(a) && down.has(b)); });
147
+ };
148
+ return { items, byId, deps, children, edges, locked, pathEdges };
149
+ }
150
+
151
+ test.describe('로드맵 화면', () => {
152
+ test('SC-1·SC-2 로드맵: CSP 아래 오류 없이 노드 수·선 수가 자료와 같고 같은 열은 같은 x에 선다', async ({ page }) => {
153
+ const errors = [];
154
+ page.on('pageerror', (e) => errors.push(String(e.message)));
155
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
156
+ await page.addInitScript(() => document.addEventListener('securitypolicyviolation', (e) => console.error(`CSP ${e.violatedDirective} ${e.blockedURI}`)));
157
+ const d = await openRoadmap(page);
158
+ test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
159
+ await page.waitForSelector('.rt-node');
160
+ const M = roadmapModel(d);
161
+ expect(await page.locator('.rt-node').count(), '노드 수').toBe(M.items.length);
162
+ expect((await page.$$eval('.rt-edge', (es) => es.map((e) => e.dataset.edge))).sort(), '선').toEqual([...M.edges].sort());
163
+ // 열(.rt-col)마다 노드의 왼쪽 x가 하나이고 열끼리는 다르다
164
+ const cols = await page.$$eval('.rt-col', (cs) => cs.map((c) => [...new Set([...c.querySelectorAll('.rt-node')].map((n) => Math.round(n.getBoundingClientRect().left)))]));
165
+ for (const [i, xs] of cols.entries()) expect(xs.length, `열 ${i} 노드 x ${xs}`).toBeLessThanOrEqual(1);
166
+ const xs = cols.filter((c) => c.length).map((c) => c[0]);
167
+ expect(new Set(xs).size, '열끼리 x가 겹친다').toBe(xs.length);
168
+ const left = Object.fromEntries(await page.$$eval('.rt-node', (ns) => ns.map((n) => [n.dataset.id, n.getBoundingClientRect().left])));
169
+ if (!(d.milestones || []).length) {
170
+ // 층 모드: 선행은 항목보다 왼쪽 열이다(순환 선은 뺀다)
171
+ const cyc = new Set(await page.$$eval('.rt-edge.is-cyc', (es) => es.map((e) => e.dataset.edge)));
172
+ for (const e of M.edges.filter((x) => !cyc.has(x))) { const [a, b] = e.split('>'); expect(left[a], `${e} 선행이 오른쪽`).toBeLessThan(left[b]); }
173
+ } else {
174
+ // 마일스톤 모드: 같은 마일스톤(목록에 없는 id는 미배정 하나로) 항목은 같은 열이다
175
+ const known = new Set(d.milestones.map((m) => m.id));
176
+ const group = new Map();
177
+ for (const m of M.items) { const k = known.has(m.milestone) ? m.milestone : ''; group.set(k, [...(group.get(k) || []), Math.round(left[m.id])]); }
178
+ for (const [k, ls] of group) expect(new Set(ls).size, `마일스톤 ${k || '없음'} 열이 갈림`).toBe(1);
179
+ }
180
+ expect(errors, errors.join('\n')).toEqual([]);
181
+ });
182
+
183
+ test('SC-3 로드맵: 잠긴 노드 이름에 "잠김"과 막는 선행 제목이 있다', async ({ page }) => {
184
+ const d = await openRoadmap(page);
185
+ test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
186
+ await page.waitForSelector('.rt-node');
187
+ const M = roadmapModel(d);
188
+ const names = Object.fromEntries(await page.$$eval('.rt-node', (ns) => ns.map((n) => [n.dataset.id, n.getAttribute('aria-label') || ''])));
189
+ expect(Object.values(names).filter((n) => n.includes('잠김')).length, '잠김 수').toBe(M.locked.length);
190
+ for (const m of M.locked) {
191
+ const blocking = M.deps.get(m.id).filter((x) => M.byId.get(x).status !== '완료');
192
+ for (const x of blocking) expect(names[m.id], `${m.id} 이름에 선행 ${x}`).toContain(M.byId.get(x).title);
193
+ }
194
+ });
195
+
196
+ test('SC-4 로드맵: 노드를 누르면 그 항목의 조상·자손 사이 선만 밝아지고 카드가 문서에 하나다', async ({ page }) => {
197
+ const d = await openRoadmap(page);
198
+ test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
199
+ await page.waitForSelector('.rt-node');
200
+ const M = roadmapModel(d);
201
+ // 선이 가장 많이 켜지는 항목을 고른다(선이 없으면 첫 항목)
202
+ const pick = [...M.items].sort((a, b) => M.pathEdges(b.id).length - M.pathEdges(a.id).length)[0];
203
+ await page.locator(`.rt-node[data-id="${pick.id}"]`).click();
204
+ await page.waitForFunction((id) => location.hash === `#/roadmap/${id}`, pick.id);
205
+ // 해시가 바뀐 뒤 화면이 다시 그려질 때까지 기다린다(고른 노드에 .sel)
206
+ await expect(page.locator(`.rt-node.sel[data-id="${pick.id}"]`)).toHaveCount(1);
207
+ const on = await page.$$eval('.rt-edge.on', (es) => es.map((e) => e.dataset.edge));
208
+ expect(on.sort()).toEqual([...M.pathEdges(pick.id)].sort());
209
+ expect(await page.locator(`[id="rm-${pick.id}"]`).count(), 'rm-<id> 유일').toBe(1);
210
+ });
211
+
212
+ test('로드맵: 노드가 뷰포트나 트리 상자 가로 스크롤 안에 있고 트리 상자에 세로 숨은 스크롤이 없다', async ({ page }) => {
213
+ const d = await openRoadmap(page);
214
+ test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
215
+ await page.waitForSelector('.rt-node');
216
+ const outside = await page.evaluate(() => {
217
+ const box = document.querySelector('.rt-box'), b = box.getBoundingClientRect();
218
+ const lo = b.left - box.scrollLeft - 1, hi = b.left - box.scrollLeft + box.scrollWidth + 1;
219
+ return [...document.querySelectorAll('.rt-node')].filter((n) => { const r = n.getBoundingClientRect(); return !r.width || r.left < lo || r.right > hi; }).map((n) => n.dataset.id);
220
+ });
221
+ expect(outside, outside.join(', ')).toEqual([]);
222
+ // 개요와 같은 식: 계산값이 auto·scroll이어도 실제 넘침이 1px 이하면 숨은 스크롤이 아니다(가로 스크롤 상자는 브라우저가 overflow-y를 auto로 올린다)
223
+ const hidden = await page.evaluate(() => {
224
+ const set = new Set(document.querySelectorAll('[data-screen="roadmap"] .panel'));
225
+ document.querySelectorAll('[data-screen="roadmap"] .panel *').forEach((e) => { const o = getComputedStyle(e).overflowY; if (o === 'auto' || o === 'scroll') set.add(e); });
226
+ return [...set].filter((e) => { const c = getComputedStyle(e).webkitLineClamp; return !c || c === 'none'; })
227
+ .filter((e) => e.scrollHeight - e.clientHeight > 1).map((e) => `${e.className} +${e.scrollHeight - e.clientHeight}px`);
228
+ });
229
+ expect(hidden, hidden.join('\n')).toEqual([]);
230
+ });
231
+
232
+ test('SC-12 로드맵: Tab으로 첫 노드에 닿고 화살표 ←첫 선행 →첫 후속 ↑↓같은 열 이웃으로 옮긴다', async ({ page }) => {
233
+ const d = await openRoadmap(page);
234
+ test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
235
+ await page.waitForSelector('.rt-node');
236
+ const M = roadmapModel(d);
237
+ const first = await page.locator('.rt-node').first().getAttribute('data-id');
238
+ let reached = null;
239
+ for (let i = 0; i < 80 && !reached; i += 1) { await page.keyboard.press('Tab'); reached = await page.evaluate(() => document.activeElement?.closest('.rt-node')?.dataset.id || null); }
240
+ expect(reached, 'Tab으로 노드에 닿지 않는다').toBe(first);
241
+ const column = Object.fromEntries((await page.$$eval('.rt-col', (cs) => cs.map((c) => [...c.querySelectorAll('.rt-node')].map((n) => n.dataset.id)))).flatMap((ids) => ids.map((id, i) => [id, [ids[i - 1], ids[i + 1]]])));
242
+ const expectMove = (id, key) => ({ ArrowLeft: M.deps.get(id)[0], ArrowRight: M.children.get(id)[0], ArrowUp: column[id][0], ArrowDown: column[id][1] }[key] ?? id);
243
+ // 네 방향이 모두 움직이는 항목이 있으면 그것, 없으면 가장 많이 움직이는 항목
244
+ const score = (id) => ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].filter((k) => expectMove(id, k) !== id).length;
245
+ const from = [...M.items].map((m) => m.id).sort((a, b) => score(b) - score(a))[0];
246
+ const moved = [];
247
+ for (const key of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown']) {
248
+ await page.locator(`.rt-node[data-id="${from}"]`).focus();
249
+ await page.keyboard.press(key);
250
+ const now = await page.evaluate(() => document.activeElement?.dataset.id);
251
+ expect(now, `${from} ${key}`).toBe(expectMove(from, key));
252
+ if (now !== from) moved.push(key);
253
+ }
254
+ if (M.items.length > 1) expect(moved.length, '화살표로 움직인 방향이 없다').toBeGreaterThan(0);
255
+ });
256
+
257
+ test.describe('모션 줄임', () => {
258
+ test.use({ reducedMotion: 'reduce' });
259
+ test('로드맵: 모션 줄임에서 애니메이션이 없다', async ({ page }) => {
260
+ const d = await openRoadmap(page);
261
+ test.skip(!(d.roadmap || []).length, '로드맵 항목이 없다');
262
+ await page.waitForSelector('.rt-node');
263
+ await page.locator('.rt-node').first().click();
264
+ await page.waitForTimeout(300);
265
+ const names = await page.evaluate(() => document.getAnimations().map((a) => a.animationName || a.constructor.name));
266
+ expect(names, names.join(', ')).toEqual([]);
267
+ });
268
+ });
269
+ });
270
+
271
+ // ---- 캡처 패널(1.3.0, DEC-24·DEC-38~DEC-40·DEC-52): 누르기 전 5장 이하, 누른 기능 캡처만, 없으면 빈 상태, 범위를 바꾸면 1장째 ----
272
+ test.describe('캡처 패널', () => {
273
+ test.use({ reducedMotion: 'reduce' });
274
+ const overviewData = (page) => page.evaluate(() => fetch('data/overview.json', { cache: 'no-store' }).then((r) => r.json()));
275
+ const count = (o, id) => o.captures.filter((c) => c.journey === id).length;
276
+ const row = (page, o, id) => page.locator(`.jrow[aria-label^=${JSON.stringify(o.journeys.find((j) => j.id === id).title)}]`).first();
277
+
278
+ test('SC-14 캡처: 누르기 전 썸네일은 5장 이하다', async ({ page }) => {
279
+ await openOverview(page);
280
+ const o = await overviewData(page);
281
+ test.skip(!o.captures.length, '캡처가 없다');
282
+ expect(await page.locator('.capture .thumbs button').count()).toBeLessThanOrEqual(5);
283
+ });
284
+
285
+ test('SC-7·SC-8 캡처: 기능을 누르면 그 기능 캡처만 돌고, 캡처가 없는 기능은 "캡처 없음"', async ({ page }) => {
286
+ await openOverview(page);
287
+ const o = await overviewData(page);
288
+ const onMap = new Set(await page.$$eval('.jrow', (rs) => rs.map((r) => r.getAttribute('aria-label') || '')));
289
+ const shown = o.journeys.filter((j) => [...onMap].some((l) => l.startsWith(j.title)));
290
+ const withCaps = shown.filter((j) => count(o, j.id) > 0).sort((a, b) => count(o, b.id) - count(o, a.id))[0];
291
+ const without = shown.find((j) => count(o, j.id) === 0);
292
+ test.skip(!withCaps && !without, '지도에 누를 기능이 없다');
293
+ if (withCaps) {
294
+ await row(page, o, withCaps.id).click();
295
+ await expect(page.locator('.capture .thumbs button')).toHaveCount(count(o, withCaps.id));
296
+ await expect(page.locator('.capture .cnt')).toHaveText(new RegExp(`^1/${count(o, withCaps.id)}$`));
297
+ }
298
+ if (without) {
299
+ await row(page, o, without.id).click();
300
+ await expect(page.locator('.capture .live')).toContainText('캡처 없음');
301
+ await expect(page.locator('.capture .thumbs button')).toHaveCount(0);
302
+ }
303
+ });
304
+
305
+ test('DEC-39 캡처: 범위를 바꾸면 장 번호가 1로 돌아간다', async ({ page }) => {
306
+ await openOverview(page);
307
+ const o = await overviewData(page);
308
+ const onMap = new Set(await page.$$eval('.jrow', (rs) => rs.map((r) => r.getAttribute('aria-label') || '')));
309
+ const many = o.journeys.filter((j) => [...onMap].some((l) => l.startsWith(j.title)) && count(o, j.id) >= 2);
310
+ test.skip(many.length < 1 || o.journeys.filter((j) => count(o, j.id) > 0).length < 2, '캡처 2장 이상 기능과 다른 캡처 기능이 필요하다');
311
+ const a = many[0];
312
+ const b = o.journeys.find((j) => j.id !== a.id && count(o, j.id) > 0 && [...onMap].some((l) => l.startsWith(j.title)));
313
+ test.skip(!b, '두 번째로 누를 캡처 기능이 지도에 없다');
314
+ await row(page, o, a.id).click();
315
+ await page.locator('.capture .thumbs button').nth(1).click();
316
+ await expect(page.locator('.capture .cnt')).toHaveText(/^2\//);
317
+ await row(page, o, b.id).click();
318
+ await expect(page.locator('.capture .cnt')).toHaveText(new RegExp(`^1/${count(o, b.id)}$`));
319
+ });
320
+ });
@@ -101,6 +101,17 @@ g.issue('warn', '작업 문서', '완료 작업에 닫히지 않은 잔여 질
101
101
 
102
102
  로드맵 항목과 마일스톤의 노드 종류 이름은 1.x 동안 `milestone`·`release`이고, 2.0.0에서 `roadmapItem`·`milestone`으로 바꾼다(어댑터 계약 변경이라 major).
103
103
 
104
+ 로드맵 화면의 트리(1.3.0)는 이 두 종류의 속성만으로 열을 정한다. 로드맵을 직접 만드는 어댑터는 아래를 지킨다.
105
+
106
+ | 화면이 읽는 것 | 노드·속성 | 규칙 |
107
+ |---|---|---|
108
+ | 열 모드 | `release` 노드 수 | 한 건이라도 있으면 열 하나가 마일스톤 하나, 없으면 열 하나가 선행 깊이 한 단계(`semantic-authoring.md` 「로드맵 화면의 열」 문안 1) |
109
+ | 열 순서 | `release`의 `props.order` | 마일스톤 절이 나온 차례. 화면은 선행에 맞춰 다시 정렬하지 않는다(문안 2) |
110
+ | 항목의 열 | `milestone`의 `props.milestone` | 마일스톤 id. 비었거나 `release`에 없는 id면 맨 오른쪽 "마일스톤 없음" 열(문안 3) |
111
+ | 선 | `milestone`의 `props.deps` | 로드맵 항목 id 목록. 없는 id는 선을 그리지 않고 엔진이 `선행 항목 없음` 문장을 낸다 |
112
+
113
+ 역행·순환 문장은 어댑터가 아니라 엔진(`src/derive.mjs`)이 `roadmap[].problems`에 싣고 `check`가 경고로 낸다. 역행 문장 틀은 `마일스톤 순서 역행: {선행 id}({마일스톤}) → {항목 id}({마일스톤})`, 순환은 `선행 순환: {id} → {id} → …`다(코드는 `issue-codes.md` 「1.3.0 새 코드」).
114
+
104
115
  엣지: `shows`(step→screen, 파생이 만든다), `calls`(screen→api), `invokes`(api→function), `touches`(function→table), `covers`(test→screen|api|function), `changes`(commit→screen|api|migration), `defines`(task→decision), `contains`(migration→table|function, release→milestone), `tracks`(milestone→task). 새 종류가 필요하면 엔진 저장소의 `src/lib/graph.mjs` 목록에 더한다(minor 릴리스). 화면이 그 종류를 그리려면 `src/derive.mjs`와 화면 원본 `ui/`도 손봐야 하므로, 먼저 기존 종류로 표현할 수 없는지 본다.
105
116
 
106
117
  ## 순서
@@ -98,6 +98,15 @@ g.issue('warn', '작업 문서', '완료 작업에 닫히지 않은 잔여 질
98
98
  | `router.hookapi-only` | warn | 설정 | code·config | hookApi로만 연결되는 항목, 설정 키 하나에 한 건 |
99
99
  | `journey.api-not-observed` | warn | 단계 | source·code | 여정 `apis`에 있으나 어느 화면에서도 관측되지 않음 |
100
100
 
101
+ ## 1.3.0 새 코드
102
+
103
+ 로드맵 선행의 흐름을 보는 경고 두 건이다. 문장은 `data.json`의 `roadmap[].problems`에 실리고 로드맵 화면 카드의 경고 줄로 보인다. `check`는 둘 다 경고(`△`)로 내므로 CI를 막지 않는다. 판정 규칙은 로드맵 화면 트리(`ui/lib/tree.js`)와 같다. 1.2.0 새 코드처럼 6건 이상을 한 줄로 묶지 않는다.
104
+
105
+ | 코드 | 수준 | 대상 | 처리 | 뜻 |
106
+ |---|---|---|---|---|
107
+ | `roadmap.dep-cycle` | warn | 로드맵 항목 | source | 선행이 돌고 돌아 자기에게 되돌아온다. 순환에 든 항목마다 `선행 순환: {id} → {id} → …`(자기에서 시작해 선행 → 후속 방향으로 돌아오는 가장 짧은 경로). 순환 항목을 선행으로 가진 뒤쪽 항목에는 싣지 않는다 |
108
+ | `roadmap.milestone-backward` | warn | 로드맵 항목 | source | 마일스톤 절이 있을 때 선행이 항목보다 오른쪽 열(마일스톤 절 순서, 미배정·없는 id는 맨 오른쪽)에 있다. 엣지마다 항목 쪽에 `마일스톤 순서 역행: {선행 id}({마일스톤}) → {항목 id}({마일스톤})`. `{마일스톤}`은 마일스톤 id이고 미배정이면 `마일스톤 없음`이다. 고치는 방법은 둘이다 — 파일에서 마일스톤 절 순서를 바꾸거나, 그 항목의 선행을 고친다 |
109
+
101
110
  ## 1.1.1 check 줄의 코드
102
111
 
103
112
  문구는 1.1.1과 같다. 괄호 안 문구는 줄의 앞부분이다.
@@ -292,3 +292,54 @@ tasks 어댑터(`tasks.dir`, `tasks.index`)가 작업 폴더 문서에서 결정
292
292
  | 경고 | 완료인데 `완료일` 없음, 또는 완료가 아닌데 `완료일` 있음 | `마일스톤 {제목}: 완료일과 상태가 맞지 않음` |
293
293
  | 경고 | 진행 마일스톤 2개 이상 | `진행 마일스톤 {n}개: {제목들}` |
294
294
  | 경고 | 마일스톤이 있는데 진행 항목에 `마일스톤` 키 없음 | `로드맵 {항목 제목}: 진행인데 마일스톤 없음` |
295
+ | 경고 | 선행이 항목보다 오른쪽 열에 있음(1.3.0, 아래 「로드맵 화면의 열」 문안 4) | `로드맵 {항목 제목}: 마일스톤 순서 역행: {선행 id}({마일스톤}) → {항목 id}({마일스톤})` |
296
+
297
+ ### 로드맵 화면의 열(1.3.0)
298
+
299
+ 로드맵 화면은 항목을 노드, 선행을 선으로 잇는 트리를 카드 목록 위에 그린다. 아래 네 문안은 화면 코드(`ui/lib/tree.js`)와 엔진 경고 문장이 따르는 규칙이다.
300
+
301
+ #### 문안 1: 열 모드 판정
302
+
303
+ 로드맵 화면의 트리는 열을 세로로 놓는다. 열이 무엇을 뜻하는지는 로드맵 파일이 정한다.
304
+
305
+ `## 마일스톤:` 절이 **한 건이라도 있으면** 열 하나가 마일스톤 하나다. 항목은 `- 마일스톤: <id>`로 가리킨 열에 든다. 선행 깊이는 열 안의 세로 순서만 정한다.
306
+
307
+ 절이 **한 건도 없으면** 열 하나가 선행 깊이 한 단계다. 깊이 0이 첫 열이고 오름차순으로 오른쪽으로 간다. 깊이는 선행이 없으면 0, 있으면 `1 + max(선행들의 깊이)`다.
308
+
309
+ 두 모드가 같은 함수로 내는 것은 선행 깊이, 잠김 판정, 조상·자손 집합, 순환 판정이다. 모드는 열을 무엇으로 나눌지만 가른다.
310
+
311
+ #### 문안 2: 열 순서
312
+
313
+ 마일스톤 모드에서 열의 좌우 순서는 **로드맵 파일에 `## 마일스톤:` 절이 나온 차례**다. 화면은 선행 관계에 맞춰 열을 다시 정렬하지 않는다.
314
+
315
+ 정본은 마크다운이다. 사람이 적은 시대 순서를 화면이 뒤집으면 그림이 정본을 부정한다. 순서를 바꾸려면 파일에서 절을 옮긴다.
316
+
317
+ 절 순서는 항목의 순서와 자동 id에 영향을 주지 않는다. 엔진은 항목 절만 세어 순서를 매긴다.
318
+
319
+ 층 모드에서 열 순서는 깊이 0부터 오름차순이고 사람이 정할 여지가 없다.
320
+
321
+ #### 문안 3: 미배정 열
322
+
323
+ `- 마일스톤:` 키가 없거나, 값이 비었거나, 없는 마일스톤 id를 가리키는 항목은 **맨 오른쪽 "마일스톤 없음" 열**에 모인다. 카드 목록이 쓰는 묶음 이름과 같다.
324
+
325
+ 없는 id를 가리킨 경우 엔진은 `data.json`에 그 값을 그대로 두고 `로드맵 {항목 제목}: 마일스톤 없음 {id}` 문장을 따로 낸다. 화면이 마일스톤 목록에 없는 id를 미배정으로 본다. 화면은 그래도 선다.
326
+
327
+ 미배정 항목이 0건이면 이 열을 만들지 않는다. 항목을 전부 마일스톤에 배정할 필요는 없다.
328
+
329
+ #### 문안 4: 역행 경고
330
+
331
+ 파일 순서는 선행 방향을 보장하지 않는다. 오른쪽 열 항목이 왼쪽 열 항목의 선행이 되면 그 엣지는 오른쪽에서 왼쪽으로 흐른다.
332
+
333
+ 엔진은 그런 엣지마다 문장 하나를 낸다.
334
+
335
+ ```
336
+ 마일스톤 순서 역행: {선행 id}({마일스톤}) → {항목 id}({마일스톤})
337
+ ```
338
+
339
+ 화면은 그 엣지를 점선에 `--amber`로 그리고 범례에 "마일스톤 순서를 거스르는 선행" 한 줄을 둔다. 경고이고 오류가 아니다. 화면은 멈추지 않는다. 고치는 방법은 둘이다 — 파일에서 마일스톤 절 순서를 바꾸거나, 그 항목의 선행을 고친다.
340
+
341
+ 문장은 항목 쪽 경고 줄에 붙는다. `{마일스톤}`은 마일스톤 id이고, 미배정 항목(맨 오른쪽 열)이 선행이면 그 자리는 `마일스톤 없음`이다.
342
+
343
+ #### 선행 순환
344
+
345
+ 선행이 돌고 돌아 자기에게 되돌아오면 순환이다. 엔진은 순환에 든 항목마다 `로드맵 {항목 제목}: 선행 순환: {id} → {id} → …`(자기에서 시작해 돌아오는 가장 짧은 경로) 경고를 낸다. 층 모드 화면은 순환 항목을 마지막 열 다음 열에 모으고, 마일스톤 모드는 자기 열에 둔다. 순환 선은 점선이다. 마일스톤 절과 상관없이 나오는 경고다.
@@ -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.2.0",
3
+ "version": "1.4.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 */