From 4581194ca3cb44f57b58cfdc22b361bb479b1db5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 17:54:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(B03):=20=EC=A2=81=EC=9D=80=20=ED=8F=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=EC=9D=B4=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EB=B0=96=EC=9C=BC=EB=A1=9C=20=EB=84=98=EC=B9=98=EB=8D=98=20?= =?UTF-8?q?=EA=B2=83=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 지적 — 창을 줄이면 내용이 밖으로 나가고 글자를 못 읽었음. 원인은 세 자리의 **고정 최소폭**과 flex 기본값이었음. - 계획노선|지형 2열이 `repeat(2, minmax(0,1fr))` 로 **고정**이라, 좁아지면 각 열이 카드 하나도 못 담고 넘쳤음 → `auto-fit` + `min(360px,100%)` 로 바꿔 스스로 1열로 접힘. - 카드 격자 `minmax(260px,…)` · 고르는 줄 `minmax(200px,…)` 는 그 폭 아래에서 넘침 → `min(…, 100%)` 로 컨테이너까지만 줄어들게 함. - 카드 머리줄에 min-width/max-width 를 주어 배지·[선택]·[×] 가 안 밀려 나가게 함. - 페이지 몸통(flex)에 min-width: 0 — 안 주면 안쪽 격자가 넘칠 때 몸통이 통째로 가로로 밀렸음. 실측(공용 브라우저): 폭 1400·1000·760·620·480 에서 여섯 컨테이너 모두 **넘침 0px**, 페이지 가로 스크롤 없음(scrollWidth = clientWidth). 트랙 폭도 확인 — 1400/1200 은 580·580(남는 트랙 0px), 1000·820 은 2열, 700 이하 1열. Co-Authored-By: Claude Opus 5 (1M context) --- B03_FileInput/B03_FileInput_UI_Style.css | 32 +++++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 1aa9cba5..086bbfad 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -14,6 +14,13 @@ display: flex; flex-direction: column; gap: var(--spacing-32); + /* ⚠ flex 자식은 기본이 `min-width: auto` 라 **내용보다 안 줄어든다** — 그래서 안쪽 + 격자가 넘치면 페이지 몸통이 통째로 가로로 밀렸다(2026-09-08). 여기서 끊는다. */ + min-width: 0; +} + +.b03-file__main-layout > * { + min-width: 0; } .b03-file__control-panel { @@ -52,7 +59,8 @@ 칸을 먼저 나누므로 내용과 무관하게 정확히 1:1:1 이 된다. 좁아지면 칸이 접힌다. */ .b03-file__pick-row { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + /* ⚠ 좁은 폭에서는 200px 도 넘친다 — `min(200px, 100%)` 로 컨테이너까지만 줄인다. */ + grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); align-items: stretch; gap: var(--spacing-8); } @@ -213,10 +221,15 @@ } /* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시). - 노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. */ + 노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. + + ⚠ **좁아지면 한 줄로 접는다**(2026-09-08 사용자 지적) — 2열을 고정하면 좁은 폭에서 + 각 열이 카드 하나도 못 담아 내용이 컨테이너 밖으로 넘치고 글자가 안 읽혔다. + `auto-fit` + `min(360px, 100%)` 이라, 폭이 모자라면 칸 수가 스스로 1로 줄고 + **최소폭이 컨테이너를 넘지 않는다**(고정 minmax 는 그 아래에서 넘친다). */ .b03-file__columns { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr)); gap: var(--spacing-16); align-items: start; } @@ -353,9 +366,12 @@ /* 카드가 좁아지면 한 줄에 제목·상태·[선택]이 못 들어가 제목이 글자 단위로 접힌다 (2026-09-03 실측: 안내 패널을 연 상태에서 카드 폭 210px). 폭이 모자라면 열을 줄인다. */ +/* ⚠ `minmax(260px, …)` 는 **칸이 260px 보다 좁아질 수 없다**는 뜻이라, 컨테이너가 + 그보다 좁아지면 카드가 밖으로 넘친다(2026-09-08 좁은 폭 실측). `min(260px, 100%)` + 으로 두면 좁을 때 **컨테이너 폭까지 줄어들어** 넘치지 않는다. */ .b03-file__group-content { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: var(--spacing-12); } @@ -425,11 +441,19 @@ 글자 단위로 접히므로, 자리가 모자라면 배지·삭제 버튼이 다음 줄로 내려가게 한다. */ /* 제목·상태·[선택]이 **한 줄**에 선다(2026-09-03) — 줄바꿈을 허용하면 [선택]이 아래로 내려가 카드가 두 줄이 됐다. 자리가 모자라면 제목이 줄어든다(min-width: 0). */ +/* ⚠ 좁은 폭에서 배지·[선택]·[×] 가 밀려 나가지 않게 **줄 자체가 넘치지 않도록** 막는다 + (2026-09-08). 제목은 이미 말줄임이라, 넘치는 것은 늘 오른쪽 붙박이들이었다. */ .b03-file__card-header { display: flex; flex-wrap: nowrap; gap: var(--spacing-8); align-items: center; + min-width: 0; + max-width: 100%; +} + +.b03-file__card-header > * { + min-width: 0; } .b03-file__card-icon { From f5cd898c16317621226d5ee67091864bd765ab75 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 18:12:36 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(=EA=B0=9C=EB=B0=9C):=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=A0=84=20=EB=8B=A8=EA=B3=84=EB=8F=84=20=EB=88=8C?= =?UTF-8?q?=EB=9F=AC=EC=84=9C=20=EC=9D=B4=EB=8F=99=20=E2=80=94=20B08=C2=B7?= =?UTF-8?q?B09=20=EC=A0=91=EA=B7=BC=20=EA=B8=B8=20=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B08 의 「확정 없이 다음으로」 단추가 B08 화면 안에 있어, 상세설계(B07)에서 B08 로 못 넘어가면 그 단추에 닿을 길이 없었음(닫힌 문 안에 열쇠). - 단계 표시줄이 NOT_STARTED·STALE 을 막던 것을 개발환경에서만 품 - 열어 둔 단계는 「확정 전이라 값이 비어 보일 수 있음」을 툴팁으로 알림 - 잠금을 푸는 것이 아니라 가기만 하는 것 — 실제 우회는 B08 의 그 단추가 서버(common_util_dev_unlock)로 하고, 운영에서는 ENVIRONMENT 로 거절 Co-Authored-By: Claude Opus 5 (1M context) --- ui_template/ui_template_workflow_layout.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index f2a766be..3d1853fc 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -100,7 +100,15 @@ export function createStepBar( options?.stages?.find((item) => item.stage_no === index) ?? options?.stages?.[index]; const hasStages = Boolean(options?.stages?.length); const isBlockedState = stage?.state === "NOT_STARTED" || stage?.state === "STALE"; - const isEnabled = !hasStages || !isBlockedState || stage?.stage_no === options?.currentStage; + // ⚠ 개발 전용 — 막힌 단계도 눌러서 들어간다 (2026-09-09 사용자 지시). + // B08 의 「확정 없이 다음으로」 단추가 **B08 화면 안에** 있어서, B07 에서 B08 로 못 + // 넘어가면 그 단추에 닿을 길이 없었다(닫힌 문 안에 열쇠가 있는 꼴). + // ⚠ **잠금을 푸는 것이 아니라 가기만 한다** — 값이 없어 「미확보」로 뜨는 것이 정상이고, + // 실제 우회는 B08 의 그 단추가 서버(`common_util_dev_unlock`)로 한다. + // ⚠ **서버는 그대로 막혀 있다** — 운영에서는 `ENVIRONMENT` 로 거절한다. + const devBypass = import.meta.env.DEV; + const isEnabled = + devBypass || !hasStages || !isBlockedState || stage?.stage_no === options?.currentStage; button.classList.toggle("is-enabled", isEnabled); button.disabled = !isEnabled; @@ -133,6 +141,13 @@ export function createStepBar( } } + // 개발 우회로 열어 둔 단계는 **그렇다고 말해 준다** — 조용히 열면 다음 사람이 + // 「왜 값이 없나」로 헤맨다(B08 의 안내 줄과 같은 뜻). + if (devBypass && isBlockedState) { + button.classList.add("is-dev-bypass"); + button.title = `${button.title} (개발: 확정 전이라 값이 비어 보일 수 있습니다)`; + } + if (isEnabled && options?.routes?.[index]) { button.addEventListener("click", () => { options.onStepClick?.(index, options.routes![index]); From 107883a35444590f0a307ab305b0641ff8b98e68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 8 Sep 2026 18:12:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(B07):=20=EB=AC=B4=ED=95=9C=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EB=91=90=20=EC=9E=90=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B0=9B=EA=B8=B0=EA=B0=80=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=EC=9D=84=20=EB=A7=89=EB=8D=98=20=EA=B2=83=EA=B3=BC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=97=86=EB=8A=94=20CAD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무한 로딩으로 보이던 자리가 둘이었음. 1) 미리받기가 클릭 앞을 막음 (진짜 원인) 진입 직후 도면 31 장을 순서대로 미리 받는데, 한 장이 0.3~1.0초라 그 사이 클릭한 도면이 앞의 여러 장 뒤에 줄을 서서 30초 넘게 안 열렸음. 화면은 「불러오는 중」 그대로였음. - 클릭이 도는 동안 미리받기가 쉬게 함 - 클릭이 이미 받아 둔 장은 건너뜀 2) CAD 가 아무 말도 안 하면 끝이 없었음 끝을 알리는 것이 drawing-loaded · drawing-error 두 통지뿐이라, iframe 이 안 뜨거나(ready 없음) 도면을 여는 중에 멈추면 영원히 돎. - 지켜보는 시계 추가 (ready 20초 · 도면 15초) - iframe 자체가 못 뜨면 기다리지 않고 바로 끊음 - 화면 표시만 끊고 요청은 살려 둠 — 늦게 오면 그대로 받음 곁들여 B07 의 「모든 도면 확정 뒤에 이동」 가드를 개발환경에서만 품 (B09 로 가는 길. 운영에서는 그대로 막음). 확인: 단계 표시줄 B08·B09 가 눌리고 B07 → B08 이동됨. tsc 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 78 +++++++++++++++++--- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 3cb61056..fa37be21 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -134,6 +134,50 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { frameFields: Record; } | undefined; + // ⚠ CAD 는 iframe 이라 **저쪽이 아무 말도 안 하면 화면이 영원히 「불러오는 중」에 머문다** + // (2026-09-09 사용자 보고 — 무한 로딩). 끝을 알리는 것은 `drawing-loaded` · + // `drawing-error` 두 통지뿐이고, 그것이 안 오는 길이 둘 있다. + // ① iframe 이 아예 안 뜸 — `dist/` 가 없거나 스크립트가 죽음 ⇒ `ready` 가 안 옴 + // ② 떴는데 도면을 여는 중에 멈춤 ⇒ `loaded` 도 `error` 도 안 옴 + // 아래 시계가 그 자리를 끊는다. ⚠ **화면 표시만 끊는다** — 뒤늦게 응답이 오면 + // 그대로 받아 정상으로 되돌아간다(요청을 취소하지 않는다). + const CAD_READY_TIMEOUT_MS = 20000; + const CAD_LOAD_TIMEOUT_MS = 15000; + let loadWatchdog: number | undefined; + + const failCad = (detail: string): void => { + cadHost.dataset.loading = "false"; + cadHost.dataset.error = detail; + showToast(detail, "error"); + }; + + const stopLoadWatchdog = (): void => { + if (loadWatchdog === undefined) return; + window.clearTimeout(loadWatchdog); + loadWatchdog = undefined; + }; + + const startLoadWatchdog = (): void => { + stopLoadWatchdog(); + // 아직 `ready` 를 못 받았으면 iframe 이 뜨기를 기다리는 중이라 더 길게 준다. + const wait = cadReady ? CAD_LOAD_TIMEOUT_MS : CAD_READY_TIMEOUT_MS; + loadWatchdog = window.setTimeout(() => { + loadWatchdog = undefined; + if (cadHost.dataset.loading !== "true") return; + failCad( + cadReady + ? "CAD 가 도면을 여는 데 너무 오래 걸립니다. 다시 눌러 보세요." + : "CAD 화면이 응답하지 않습니다. 새로고침해도 같으면 CAD 빌드(dist)를 확인하세요.", + ); + }, wait); + }; + + // iframe 자체가 못 뜨는 경우 — 이때는 `ready` 가 영영 안 오므로 기다릴 것 없이 끊는다. + frame.addEventListener("error", () => { + stopLoadWatchdog(); + failCad("CAD 화면을 불러오지 못했습니다. CAD 빌드(dist)를 확인하세요."); + }); + let currentDrawing: DesignDrawingItem | undefined; let currentIndex = -1; let currentConfirmed = false; @@ -261,10 +305,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { return request; }; - /** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. */ + /** 목록 전체를 순서대로 미리 받는다. 화면을 막지 않도록 한 번에 하나씩만 간다. + * + * ⚠ **사용자가 누른 도면에 자리를 내준다** (2026-09-09 — 무한 로딩으로 보이던 자리). + * 도면이 31 장이고 한 장에 0.3~1.0초라, 미리 받기가 도는 동안 클릭하면 그 요청이 + * **앞의 여러 장 뒤에 줄을 서서** 30초 넘게 안 열렸다. 화면은 「불러오는 중」 그대로였다. + * 한 장 받을 때마다 **클릭이 끼어들 틈**을 주고, 클릭이 도는 동안은 쉬어 준다. + */ const prefetchAllDrawings = async (): Promise => { if (!projectId) return; for (const item of drawings) { + // 클릭이 도는 중이면 끝날 때까지 기다린다 — 미리 받기는 급하지 않다. + while (loadInFlight) await new Promise((done) => window.setTimeout(done, 120)); + if (drawingCache.has(item.id)) continue; // 클릭이 이미 받아 둔 장은 건너뛴다 try { await requestDrawing(item); } catch { @@ -294,6 +347,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { if (button) button.dataset.loading = "true"; cadHost.dataset.loading = "true"; cadHost.dataset.error = ""; // 앞선 실패 표시를 지운다 + startLoadWatchdog(); // 저쪽이 말이 없으면 여기서 끊는다 try { const response = await requestDrawing(drawing); currentDrawing = drawing; @@ -304,10 +358,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { updateInfoPanel(drawing, response); sendLoad(response.drawing, buildMeta(drawing, response, index)); } catch (error) { - cadHost.dataset.loading = "false"; - cadHost.dataset.error = - error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."; - showToast(cadHost.dataset.error, "error"); + stopLoadWatchdog(); // 여기서 이미 끝났다 — 시계를 두면 늦게 또 오류를 띄운다 + failCad(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다."); if (currentDrawing) highlightActive(currentDrawing.id); } finally { loadInFlight = false; @@ -493,19 +545,22 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { ); } else if (message.type === CAD_READY_MESSAGE) { cadReady = true; - if (pendingLoad) + if (pendingLoad) { sendLoad( pendingLoad.drawing, pendingLoad.meta, pendingLoad.frameEdit, pendingLoad.frameFields, ); + // 기다리던 것이 「iframe 이 뜨기」에서 「도면이 열리기」로 바뀌었다 — 시계를 다시 건다. + if (cadHost.dataset.loading === "true") startLoadWatchdog(); + } } else if (message.type === CAD_LOADED_MESSAGE) { + stopLoadWatchdog(); cadHost.dataset.loading = "false"; } else if (message.type === CAD_ERROR_MESSAGE) { - cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다."; - cadHost.dataset.loading = "false"; - showToast(cadHost.dataset.error, "error"); + stopLoadWatchdog(); + failCad(message.detail ?? "CAD 도면을 표시하지 못했습니다."); } else if (message.type === CAD_CHANGED_MESSAGE) { // 편집 통지는 **미저장 표시**만 세운다. 확정을 푸는 것은 [수정] 하나뿐이다 // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 @@ -551,7 +606,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { if (!projectId) return; - if (stepIndex > 5 && !allDrawingsConfirmed) { + // ⚠ 개발에서는 막지 않는다 (2026-09-09 사용자 지시) — B08·B09 를 확정 전에 봐야 + // 화면 검증이 되고, 그 우회 단추가 **B08 안에** 있어 여기서 막히면 닿을 길이 없다. + // 운영에서는 그대로 막는다. + if (stepIndex > 5 && !allDrawingsConfirmed && !import.meta.env.DEV) { showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning"); return; }