From 3fc50044e12fc2215800af8d217ba01e2dd3a6ae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 14:44:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(device):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EB=92=A4=20=E2=98=B0=20=EC=83=81=ED=83=9C=20=EC=9C=84=20?= =?UTF-8?q?=EC=A4=84=20=EA=B1=B7=EA=B8=B0=20=C2=B7=20=E2=98=B0=20=EB=8A=94?= =?UTF-8?q?=20=EC=9C=84=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EB=AA=A8=EC=84=9C?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EB=96=A0=20=EC=9E=88=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EB=8B=A8=EA=B3=84=EB=A5=BC=20=E2=98=B0=20?= =?UTF-8?q?=EC=84=9C=EB=9E=8D=20=EB=AC=B6=EC=9D=8C=EC=9C=BC=EB=A1=9C(?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=9A=94=EC=86=8C=EB=A5=BC=20=EB=B9=8C?= =?UTF-8?q?=EB=A0=A4=20=EC=98=B4=20=C2=B7=20=ED=95=9C=20=EB=B2=8C)=20?= =?UTF-8?q?=C2=B7=20=EC=84=9C=EB=9E=8D=20=EC=B0=A8=EB=A1=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=20=E2=94=80=20=EC=A7=84=ED=96=89=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=20=E2=94=80=20=EB=A9=94=EB=89=B4=20=E2=94=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EC=9C=84=20=EC=A4=84=20=EB=86=92=EC=9D=B4?= =?UTF-8?q?=20=3D=20--app-header-h=20=ED=95=98=EB=82=98=20=C2=B7=20data-bu?= =?UTF-8?q?rger=20(=EA=B8=B0=EA=B8=B0=EB=8C=80=EC=9D=91=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- A00_Common/app_shell.ts | 105 +++++++++++++----- A00_Common/b_page_scaffold.ts | 2 +- A00_Common/device_context.css | 9 ++ A00_Common/device_context.ts | 9 +- B04_PreProcess/B04_PreProcess_UI_Style.css | 2 +- B05_Profile/B05_Profile_UI_Style.css | 4 +- B06_Section/B06_Section_UI_Style.css | 4 +- .../B07_DesignDetail_UI_Style.css | 4 +- B09_Estimation/draft/B09_Draft_Print_Page.css | 2 +- .../M02_MasterTemplete_Drawing.css | 2 +- .../M02_MasterTemplete_Structure.css | 4 +- .../M02_MasterTemplete_UI_Style.css | 4 +- resources/tester/test_device_audit.py | 1 + resources/tester/test_device_drawer.py | 99 +++++++++++++++++ resources/tester/test_m02_tree_paste.py | 2 +- ui_template/ui_template_general_layout.css | 2 +- ui_template/ui_template_overlay.css | 26 ++--- ui_template/ui_template_workflow_layout.css | 6 +- ui_template/ui_template_workflow_layout.ts | 3 + 19 files changed, 227 insertions(+), 63 deletions(-) create mode 100644 resources/tester/test_device_drawer.py diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 02eab493e..f187e7319 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -66,19 +66,7 @@ function buildHeader(): HTMLElement { const header = document.createElement("header"); header.className = "app-header"; - // 로고 (클릭 시 홈) - const brand = document.createElement("button"); - brand.className = "app-brand"; - brand.type = "button"; - const brandIcon = document.createElement("img"); - brandIcon.className = "app-brand__icon"; - brandIcon.src = BRAND_ICON_URL; - brandIcon.alt = ""; - const brandName = document.createElement("span"); - brandName.className = "app-brand__name"; - brandName.textContent = L("App_BrandName"); - brand.append(brandIcon, brandName); - brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME)); + const brand = buildBrand(); // 네비게이션 링크 const nav = document.createElement("nav"); @@ -111,6 +99,31 @@ function buildHeader(): HTMLElement { return header; } +/** 로고 (클릭 시 홈) — 위 줄 · ☰ 서랍 첫 묶음이 같이 씀 */ +function buildBrand(): HTMLButtonElement { + const brand = document.createElement("button"); + brand.className = "app-brand"; + brand.type = "button"; + const brandIcon = document.createElement("img"); + brandIcon.className = "app-brand__icon"; + brandIcon.src = BRAND_ICON_URL; + brandIcon.alt = ""; + const brandName = document.createElement("span"); + brandName.className = "app-brand__name"; + brandName.textContent = L("App_BrandName"); + brand.append(brandIcon, brandName); + brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME)); + return brand; +} + +/* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠. + * 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */ +let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null; + +export function setDrawerSteps(node: HTMLElement | null): void { + drawerSteps = node?.parentElement ? { node, home: node.parentElement } : null; +} + const NAV_ITEMS: [keyof typeof ui_locales, string][] = [ ["App_Nav_Program", ROUTES.A02_PROG_DETAIL], ["App_Nav_Company", ROUTES.A03_COMP_DETAIL], @@ -146,20 +159,31 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { drawer.setAttribute("aria-label", L("App_Menu")); const scrim = document.createElement("div"); scrim.className = "app-drawer__scrim"; + const steps = document.createElement("div"); + steps.className = "app-drawer__group app-drawer__steps"; const setOpen = (open: boolean): void => { header.classList.toggle("is-drawer-open", open); burger.setAttribute("aria-expanded", String(open)); + if (!drawerSteps) return; + if (open) steps.append(drawerSteps.node); + else if (steps.contains(drawerSteps.node)) drawerSteps.home.append(drawerSteps.node); }; burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); scrim.addEventListener("click", () => setOpen(false)); drawer.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); - // 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) + // 로고 · 진행 단계 · 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) drawer.addEventListener("click", (e) => { - if ((e.target as Element).closest(".app-drawer__nav, .app-drawer__auth")) setOpen(false); + const hit = (e.target as Element).closest("button"); + if (hit?.closest(".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth")) + setOpen(false); }); + // 묶음 차례 = 로고 · 홈 ─ 진행 단계(프로젝트 안) ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드 + const brandRow = document.createElement("div"); + brandRow.className = "app-drawer__group app-drawer__brand"; + brandRow.append(buildBrand()); const navList = document.createElement("nav"); - navList.className = "app-drawer__nav"; + navList.className = "app-drawer__group app-drawer__nav"; navList.append(...buildNavButtons()); const prefs = document.createElement("div"); prefs.className = "app-drawer__row"; @@ -178,7 +202,10 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { const auth = document.createElement("div"); auth.className = "app-drawer__row app-drawer__auth"; void fillAuthActions(auth); - drawer.append(navList, prefs, auth, buildTouchModeSwitch()); + const settings = document.createElement("div"); + settings.className = "app-drawer__group"; + settings.append(prefs, auth, buildTouchModeSwitch()); + drawer.append(brandRow, steps, navList, settings); return [burger, scrim, drawer]; } @@ -283,6 +310,7 @@ function buildFooter(): HTMLElement { * -------------------------------------------------------------------------- */ export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement { appRoot.innerHTML = ""; + drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김 const header = buildHeader(); const outlet = document.createElement("main"); outlet.className = "app-outlet"; @@ -354,10 +382,10 @@ const SHELL_CSS = ` gap: var(--spacing-8); } .app-outlet { - min-height: calc(100dvh - 64px - 56px); + min-height: calc(100dvh - var(--app-header-h) - 56px); } .app-outlet--without-footer { - min-height: calc(100dvh - 64px); + min-height: calc(100dvh - var(--app-header-h)); } .app-footer { height: 56px; @@ -400,14 +428,12 @@ const SHELL_CSS = ` font-size: 20px; cursor: pointer; } -@media (max-width: 900px) { - .app-burger { display: inline-flex; } -} -html[data-touch="on"] .app-burger { display: inline-flex; } +html[data-burger="on"] .app-burger { display: inline-flex; } +.app-header.is-drawer-open { z-index: var(--z-overlay); } .app-header.is-drawer-open .app-drawer__scrim { display: block; position: fixed; - inset: 64px 0 0 0; + inset: var(--app-header-h) 0 0 0; background: rgba(38, 17, 74, 0.35); } .app-header.is-drawer-open .app-drawer { @@ -415,7 +441,7 @@ html[data-touch="on"] .app-burger { display: inline-flex; } flex-direction: column; gap: var(--spacing-16); position: fixed; - top: 64px; + top: var(--app-header-h); right: 0; bottom: 0; width: min(320px, 100vw); @@ -436,12 +462,20 @@ html[data-touch="on"] .app-burger { display: inline-flex; } flex-wrap: wrap; gap: var(--spacing-8); } +.app-drawer__group { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} +.app-drawer__group + .app-drawer__group { + padding-top: var(--spacing-16); + border-top: 1px solid var(--color-border); +} +.app-drawer__steps:empty { display: none; } .app-drawer__touch { display: flex; flex-direction: column; gap: var(--spacing-8); - padding-top: var(--spacing-16); - border-top: 1px solid var(--color-border); color: var(--color-text-secondary); font-size: var(--text-caption); } @@ -453,6 +487,23 @@ html[data-touch="on"] .app-burger { display: inline-flex; } html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); } html[data-size="compact"] .app-actions { display: none; } html[data-size="compact"] .app-burger { margin-left: auto; } +/* 로그인 뒤 쪽(B · M) + ☰ — 위 줄을 걷고 ☰ 만 화면 위 오른쪽 모서리에 띄움(6-1) · 본문이 화면 높이를 다 씀 */ +html.is-bm-route[data-burger="on"] .app-header { + height: 0; + padding: 0; + border: 0; + z-index: calc(var(--z-dropdown) + 1); +} +html.is-bm-route[data-burger="on"] .app-header > :is(.app-brand, .app-nav, .app-actions) { + display: none; +} +html.is-bm-route[data-burger="on"] .app-burger { + position: fixed; + top: var(--spacing-8); + right: var(--spacing-8); + box-shadow: var(--shadow-lg); +} +html.is-bm-route[data-burger="on"] .app-drawer__brand .app-brand { align-self: flex-start; } `; export function injectShellStyles(): void { diff --git a/A00_Common/b_page_scaffold.ts b/A00_Common/b_page_scaffold.ts index aeda37272..f78642155 100644 --- a/A00_Common/b_page_scaffold.ts +++ b/A00_Common/b_page_scaffold.ts @@ -174,7 +174,7 @@ const SCAFFOLD_CSS = ` font-size: var(--text-body-sm); color: var(--color-text-muted); } -.b-scaffold-wf { height: calc(100dvh - 64px - 56px); } +.b-scaffold-wf { height: calc(100dvh - var(--app-header-h) - 56px); } .b-pending { display: flex; diff --git a/A00_Common/device_context.css b/A00_Common/device_context.css index 6bed90047..4d84c8a30 100644 --- a/A00_Common/device_context.css +++ b/A00_Common/device_context.css @@ -83,3 +83,12 @@ html:is([data-size="compact"], [data-size="medium"]) html:not([data-touch="on"]) .ui-touch-only { display: none !important; } + +/* 위 줄 높이(6-1) — 화면 높이 계산이 모두 이 값을 씀 · ☰ 가 메뉴를 맡는 로그인 뒤 쪽(B · M)은 위 줄을 걷어 0 */ +:root { + --app-header-h: 64px; +} + +html.is-bm-route[data-burger="on"] { + --app-header-h: 0px; +} diff --git a/A00_Common/device_context.ts b/A00_Common/device_context.ts index d59dfc34f..b868a3e19 100644 --- a/A00_Common/device_context.ts +++ b/A00_Common/device_context.ts @@ -7,6 +7,7 @@ * - data-input = 마지막 누름의 pointerType(mouse · touch · pen) * - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장) * - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때) + * - data-burger = on · off — ☰ 서랍이 메뉴를 맡는지(폭 900 이하 · 터치) — 로그인 뒤 쪽은 위 줄을 걷음(6-1) * 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태). * 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수. * 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘. @@ -15,6 +16,8 @@ import "./device_context.css"; export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const; +/** 위 메뉴가 숨는 폭(app_shell `@media (max-width: 900px)` 과 짝) — 이하면 ☰ 서랍 */ +export const BURGER_MAX = 900; export type SizeClass = "compact" | "medium" | "expanded" | "large"; export type TouchMode = "auto" | "on" | "off"; @@ -24,6 +27,7 @@ export interface DeviceContext { input: string; touchMode: TouchMode; touch: boolean; + burger: boolean; } const TOUCH_MODE_KEY = "frd_touch_mode"; @@ -54,12 +58,14 @@ let lastInput = ""; function read(): DeviceContext { const pointer = coarseQuery.matches ? "coarse" : "fine"; const touchMode = readTouchMode(); + const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse"); return { size: sizeClassOf(window.innerWidth), pointer, input: lastInput || (pointer === "coarse" ? "touch" : "mouse"), touchMode, - touch: touchMode === "on" || (touchMode === "auto" && pointer === "coarse"), + touch, + burger: touch || window.innerWidth <= BURGER_MAX, }; } @@ -72,6 +78,7 @@ function apply(): void { html.dataset.input = next.input; html.dataset.touchMode = next.touchMode; html.dataset.touch = next.touch ? "on" : "off"; + html.dataset.burger = next.burger ? "on" : "off"; const first = !current; current = next; if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next })); diff --git a/B04_PreProcess/B04_PreProcess_UI_Style.css b/B04_PreProcess/B04_PreProcess_UI_Style.css index fefe8d440..566e0999e 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Style.css +++ b/B04_PreProcess/B04_PreProcess_UI_Style.css @@ -119,7 +119,7 @@ flex-direction: column; width: 100%; max-width: none; - min-height: calc(100dvh - var(--spacing-64)); + min-height: calc(100dvh - var(--app-header-h)); } .b04-surface__workspace > .b04-surface__status { diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 712bcb7fa..60fbf4df3 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -1,6 +1,6 @@ .b05-route-layout { - height: calc(100dvh - var(--spacing-64)); - height: calc(100dvh - var(--spacing-64)); + height: calc(100dvh - var(--app-header-h)); + height: calc(100dvh - var(--app-header-h)); min-height: 0; overflow: hidden; } diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index 7c3d27d53..6553a39f4 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -9,8 +9,8 @@ /* --- 좌측 입력 폼 --- */ .b06-profile-layout { - height: calc(100dvh - var(--spacing-64)); - height: calc(100dvh - var(--spacing-64)); + height: calc(100dvh - var(--app-header-h)); + height: calc(100dvh - var(--app-header-h)); min-height: 0; overflow: hidden; } diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index d46accf59..e9d5398a5 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -5,8 +5,8 @@ /* 워크플로우 레이아웃 높이 (B06 패턴 준수) */ .b07-design-layout { - height: calc(100dvh - var(--spacing-64)); - height: calc(100dvh - var(--spacing-64)); + height: calc(100dvh - var(--app-header-h)); + height: calc(100dvh - var(--app-header-h)); min-height: 0; } diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.css b/B09_Estimation/draft/B09_Draft_Print_Page.css index aaedbfa0f..7bf7b3265 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.css +++ b/B09_Estimation/draft/B09_Draft_Print_Page.css @@ -4,7 +4,7 @@ display: flex; flex-direction: column; gap: var(--spacing-8); - height: calc(100dvh - var(--spacing-64)); + height: calc(100dvh - var(--app-header-h)); padding: var(--spacing-16) var(--spacing-24); box-sizing: border-box; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css index 69b9063c4..02d2c40af 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css @@ -5,7 +5,7 @@ 표 양식일 때는 페이지 쪽 모양 그대로 두려고 도면을 품은 때만 건다. */ .m02-main:has(> .m02-main__host > .m02-drawing) { box-sizing: border-box; - height: calc(100dvh - var(--spacing-64)); + height: calc(100dvh - var(--app-header-h)); min-height: 0; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index b8f7ea590..598a10220 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -40,7 +40,7 @@ /* 카드가 보이면 상세 칸은 창 높이까지 — 표 + 카드가 넘치면 상세 칸 전체가 스크롤 */ .m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) { box-sizing: border-box; - max-height: calc(100dvh - var(--spacing-64)); + max-height: calc(100dvh - var(--app-header-h)); overflow-y: auto; } @@ -159,7 +159,7 @@ /* ? 도움말 상자 — 카드 오른쪽 위(제목 줄 · 부제 아래 · 도면 · 엑셀 카드) */ .m02-struct__card > .m02-struct__keys { - top: calc(var(--spacing-64) + var(--spacing-8)); + top: calc(var(--app-header-h) + var(--spacing-8)); } .m02-struct__keys { position: absolute; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 0dc8bfdfb..3fe97039b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -137,7 +137,7 @@ /* 면적산출 · 재료산출 전체 편집 — `.ss` 는 height:100% 로 부모 높이를 받음 · 도면 편집과 같이 메인 칸을 화면 높이로 채움 */ .m02-main:has(> .m02-main__host > .ss) { box-sizing: border-box; - height: calc(100dvh - var(--spacing-64)); + height: calc(100dvh - var(--app-header-h)); min-height: 0; } @@ -155,7 +155,7 @@ /* 일위대가 상세(85-5) — 면적 상세와 같은 고정 높이 · 본문 칸만 굴림 · 오른쪽 패널은 칸 높이(따로 스크롤) */ .m02-main:has(> .m02-main__host > .ui-side-panels-row) { box-sizing: border-box; - height: calc(100dvh - var(--spacing-64)); + height: calc(100dvh - var(--app-header-h)); min-height: 0; } diff --git a/resources/tester/test_device_audit.py b/resources/tester/test_device_audit.py index bad042ad6..ba919812f 100644 --- a/resources/tester/test_device_audit.py +++ b/resources/tester/test_device_audit.py @@ -117,6 +117,7 @@ AUDIT_JS = r""" } out.pointer = matchMedia('(pointer: coarse)').matches ? 'coarse' : 'fine'; out.rendered = !!(outlet && outlet.childElementCount); + out.outlet_top = outlet ? Math.round(outlet.getBoundingClientRect().top) : null; // 본문 위 끝(위 줄 걷힘 = 0 · 6-3) return out; } """ diff --git a/resources/tester/test_device_drawer.py b/resources/tester/test_device_drawer.py new file mode 100644 index 000000000..3bfc51c44 --- /dev/null +++ b/resources/tester/test_device_drawer.py @@ -0,0 +1,99 @@ +"""☰ 서랍 · 위 줄 걷기(PLAN_기기대응 6 · `app_shell.ts` · `ui_template_workflow_layout.ts`) — 실제 브라우저. + +① 휴대폰 · 로그인 뒤 B 쪽 = 위 줄 걷힘(본문 위 끝 0) · ☰ 는 위 오른쪽 모서리 · 떠 있는 진행 판 없음 +② ☰ 서랍 = 로고 · 홈 ─ 진행 단계 ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드 차례 · 진행 단계는 진행 판의 **그 요소**(한 벌) + 지금 단계 강조 그대로 · 닫으면 제자리(진행 판)로 돌아감 · 단계 누름 = 이동 + 서랍 닫힘 +③ 휴대폰 · 공개 쪽(A01) = 위 줄 그대로(본문 위 끝 64) +④ 데스크탑(1440 · 마우스) = 위 줄 · 진행 판 그대로 · ☰ 없음 +""" + +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import test_device_audit as audit # noqa: E402 + +OUTLET_TOP = "Math.round(document.getElementById('app-outlet').getBoundingClientRect().top)" +GROUPS = "[...document.querySelectorAll('.app-drawer > *')].map(e => e.className)" + + +def _ctx(browser, cred, size): + spec = audit.SIZES[size] + ctx = browser.new_context( + viewport=spec["viewport"], has_touch=spec["touch"], is_mobile=spec["touch"] + ) + audit._login(ctx, cred) + pid = audit._project_id(ctx) + ctx.add_init_script(f"localStorage.setItem('frd_current_project_id', {json.dumps(pid)})") + return ctx + + +@pytest.mark.skipif(not audit._server_up(), reason="sub1 화면 서버(5174) 꺼짐") +def test_drawer_holds_steps_and_header_folds(): + from playwright.sync_api import sync_playwright + + cred = json.loads((audit.ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + page = _ctx(browser, cred, "phone_390x844_touch").new_page() + audit._open(page, "b07-design-detail") + + # ① + assert page.evaluate(OUTLET_TOP) == 0 + burger = page.evaluate( + "(() => { const r = document.querySelector('.app-burger').getBoundingClientRect();" + " return [Math.round(r.right), Math.round(r.top), Math.round(r.width)]; })()" + ) + assert burger[0] >= 390 - 12 and burger[1] <= 12 and burger[2] >= 44, burger + assert page.evaluate( + "!document.querySelector('.ui-workflow-overlay__panel--progress').getClientRects().length" + ) + + # ② + page.evaluate( + "window.__steps = document.querySelector('.ui-workflow-overlay__panel--progress .ui-workflow-layout__steps')" + ) + page.evaluate("document.querySelector('.app-burger').click()") + groups = page.evaluate(GROUPS) + order = [ + next((k for k in ("brand", "steps", "nav", "touch") if k in c), "settings") + for c in groups + ] + assert order == ["brand", "steps", "nav", "settings"], groups + assert page.evaluate( + "document.querySelector('.app-drawer__steps').firstElementChild === window.__steps" + ) + active = page.evaluate( + "document.querySelector('.app-drawer__steps .ui-workflow-layout__step.is-active')?.textContent" + ) + assert active and "상세" in active, active + page.evaluate("document.querySelector('.app-drawer__scrim').click()") # 닫기 = 제자리 + assert page.evaluate( + "window.__steps.closest('.ui-workflow-overlay__panel--progress') !== null" + ) + page.evaluate("document.querySelector('.app-burger').click()") + page.evaluate( + "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step.is-enabled')]" + ".find(b => b.textContent.includes('수량')).click()" + ) + page.wait_for_function("location.hash.includes('b08-quantity')", timeout=10000) + assert not page.evaluate( + "document.querySelector('.app-header')?.classList.contains('is-drawer-open')" + ) + + # ③ + audit._open(page, "a01-home") + assert page.evaluate(OUTLET_TOP) == 64 + + # ④ + desk = _ctx(browser, cred, audit.DESKTOP).new_page() + audit._open(desk, "b07-design-detail") + assert desk.evaluate(OUTLET_TOP) == 64 + assert not desk.evaluate("document.querySelector('.app-burger').getClientRects().length") + assert desk.evaluate( + "document.querySelector('.ui-workflow-overlay__panel--progress').getClientRects().length > 0" + ) + browser.close() diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 17da9b13c..757936cb8 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -155,6 +155,6 @@ def test_항목_추가_창_카드_격자_표_접기(): assert "grid-auto-rows: var(--m02-struct-card-h);" in css assert "--m02-struct-card-h: min(640px, 70vh);" in css # 31-6 화면 높이에 맞춤 assert "flex: 1 0 var(--m02-struct-grid-min);" in css # 엑셀 격자가 카드 높이를 채움 - assert "max-height: calc(100dvh - var(--spacing-64));\n overflow-y: auto;" in css + assert "max-height: calc(100dvh - var(--app-header-h));\n overflow-y: auto;" in css main = (m02 / "M02_MasterTemplete_UI_Main.ts").read_text("utf-8") assert "host.hidden = !host.hidden;" in main and 'title.classList.toggle("is-folded"' in main diff --git a/ui_template/ui_template_general_layout.css b/ui_template/ui_template_general_layout.css index 44222cb2c..12df27588 100644 --- a/ui_template/ui_template_general_layout.css +++ b/ui_template/ui_template_general_layout.css @@ -1,5 +1,5 @@ .ui-general-layout { - min-height: calc(100dvh - var(--spacing-64)); + min-height: calc(100dvh - var(--app-header-h)); padding: var(--spacing-40) var(--spacing-24) var(--spacing-48); background: var(--color-bg); } diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index 8e0d91096..f218b5432 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -4,11 +4,11 @@ .ui-workflow-overlay__panel { position: fixed; - top: calc(var(--spacing-64) + var(--spacing-16)); + top: calc(var(--app-header-h) + var(--spacing-16)); z-index: var(--z-dropdown); display: flex; flex-direction: column; - max-height: calc(100dvh - var(--spacing-64) - var(--spacing-32)); + max-height: calc(100dvh - var(--app-header-h) - var(--spacing-32)); overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-cards); @@ -19,7 +19,7 @@ /* title 패널: 헤더 아래 전체 높이 도킹 사이드바 (열림: 본문 밀기 / 닫힘: 좌측으로 슬라이드 아웃) */ .ui-workflow-overlay__panel--title { - top: var(--spacing-64); + top: var(--app-header-h); bottom: 0; left: 0; width: min(var(--wf-left-panel-width), calc(100vw - var(--spacing-48))); @@ -303,9 +303,9 @@ @media (max-width: 860px) { .ui-workflow-overlay__panel--progress { - top: calc(var(--spacing-64) + var(--spacing-8)); + top: calc(var(--app-header-h) + var(--spacing-8)); right: var(--spacing-8); - max-height: calc(100dvh - var(--spacing-64) - var(--spacing-16)); + max-height: calc(100dvh - var(--app-header-h) - var(--spacing-16)); } } @@ -525,7 +525,7 @@ } /* 기기 대응(PLAN_기기대응 2-3) — 폭 840 미만(data-size compact · medium)만. - 좌측 패널 = 서랍(본문 위에 겹침 · 덮개를 누르면 닫힘) · 진행단계 판 = 아래에서 올라오는 판(뒤 화면 계속 조작). */ + 좌측 패널 = 서랍(본문 위에 겹침 · 덮개를 누르면 닫힘). */ .ui-workflow-overlay__scrim { display: none; } @@ -535,21 +535,15 @@ html:is([data-size="compact"], [data-size="medium"]) > .ui-workflow-overlay__scrim { display: block; position: fixed; - inset: var(--spacing-64) 0 0 0; + inset: var(--app-header-h) 0 0 0; z-index: calc(var(--z-dropdown) - 1); background: rgba(38, 17, 74, 0.35); pointer-events: auto; } -html:is([data-size="compact"], [data-size="medium"]) .ui-workflow-overlay__panel--progress { - top: auto !important; - right: 0 !important; - bottom: 0 !important; - left: 0 !important; - width: auto !important; - max-height: 50dvh; - flex-direction: column; - border-radius: var(--radius-cards) var(--radius-cards) 0 0; +/* ☰ 가 메뉴를 맡는 때(폭 900 이하 · 터치)는 떠 있는 진행 판 대신 ☰ 서랍 안 진행 단계 묶음(6-2) */ +html[data-burger="on"] .ui-workflow-overlay__panel--progress { + display: none; } /* 접힌 서랍 — 화면 밖 내용은 안 보이게(손가락 · 읽기 프로그램이 밖의 단추를 잡지 않음) · 손잡이만 남김 */ diff --git a/ui_template/ui_template_workflow_layout.css b/ui_template/ui_template_workflow_layout.css index ea9b80790..a8a7f8113 100644 --- a/ui_template/ui_template_workflow_layout.css +++ b/ui_template/ui_template_workflow_layout.css @@ -1,6 +1,6 @@ .ui-workflow-layout { position: relative; - min-height: calc(100dvh - var(--spacing-64)); + min-height: calc(100dvh - var(--app-header-h)); background: var(--color-bg); } @@ -108,7 +108,7 @@ } .ui-workflow-layout__body { - min-height: calc(100dvh - var(--spacing-64)); + min-height: calc(100dvh - var(--app-header-h)); transition: padding-left var(--transition-base); } @@ -132,7 +132,7 @@ .ui-workflow-layout__main { width: 100%; min-width: 0; - min-height: calc(100dvh - var(--spacing-64)); + min-height: calc(100dvh - var(--app-header-h)); overflow: auto; background: var(--color-canvas); } diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index fb5a9c0e5..50b68bd70 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -3,6 +3,7 @@ import { ROUTES } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { createWorkflowOverlays } from "./ui_template_overlay"; import { navigateTo } from "../A00_Common/router"; +import { setDrawerSteps } from "../A00_Common/app_shell"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; let dashboardRolePromise: Promise | undefined; @@ -198,6 +199,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa }); root.append(body, overlays.root); + // ☰ 서랍 진행 단계 묶음(6-2) — 같은 단계 목록을 서랍이 열릴 때 빌려 감(그리기 · 누름 한 벌) + setDrawerSteps(progressContent); const steps = [ ...progressContent.querySelectorAll( ".ui-workflow-layout__step:not(.ui-workflow-layout__step--home)",