feat(device): 기기 알아채기 + 공통 틀 — <html> data-size · pointer · input · touch 표시 · ☰ 서랍(위 메뉴 · 언어 · 테마 · 로그인 · 터치 모드 스위치) · 좁은 폭 좌측 패널 서랍 + 덮개 · 진행 판 바텀시트 · 오른쪽 패널 아래로 쌓기 · 휴대폰 팝업 전체 화면(.ui-popup) · 표 첫 열 고정 · 터치 누를 자리 44px · 100vh → 100dvh · 호버 정보 톡 (기기대응 2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
This commit is contained in:
2026-10-13 13:14:45 +09:00
co-authored by Claude Opus 5.5
parent aabd50a5c4
commit 9423bd4202
48 changed files with 518 additions and 65 deletions
+185 -18
View File
@@ -11,6 +11,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_l
import { createButton } from "@ui/ui_template_elements";
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
import { navigateTo, isAuthenticated } from "./router";
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
@@ -82,21 +83,7 @@ function buildHeader(): HTMLElement {
// 네비게이션 링크
const nav = document.createElement("nav");
nav.className = "app-nav";
const navItems: [keyof typeof ui_locales, string][] = [
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
["App_Nav_Support", ROUTES.A08_SUPPORT],
];
for (const [labelKey, route] of navItems) {
const link = createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
});
nav.append(link);
}
nav.append(...buildNavButtons());
// 우측 액션 (언어/테마/로그인)
const actions = document.createElement("div");
@@ -120,10 +107,118 @@ function buildHeader(): HTMLElement {
actions.append(authSlot);
void fillAuthActions(authSlot);
header.append(brand, nav, actions);
header.append(brand, nav, actions, ...buildDrawer(header));
return header;
}
const NAV_ITEMS: [keyof typeof ui_locales, string][] = [
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
["App_Nav_Support", ROUTES.A08_SUPPORT],
];
function buildNavButtons(): HTMLElement[] {
return NAV_ITEMS.map(([labelKey, route]) =>
createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
}),
);
}
/* -----------------------------------------------------------------------------
* ☰ 서랍(PLAN_기기대응 2-2) — 폭 900 이하(위 메뉴가 숨는 폭) · 터치 모드에서 보임.
* 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치. 데스크탑 마우스엔 안 보임.
* -------------------------------------------------------------------------- */
function buildDrawer(header: HTMLElement): HTMLElement[] {
const burger = document.createElement("button");
burger.type = "button";
burger.className = "app-burger";
burger.textContent = "☰";
burger.setAttribute("aria-label", L("App_Menu"));
burger.setAttribute("aria-expanded", "false");
const drawer = document.createElement("div");
drawer.className = "app-drawer";
drawer.setAttribute("role", "dialog");
drawer.setAttribute("aria-label", L("App_Menu"));
const scrim = document.createElement("div");
scrim.className = "app-drawer__scrim";
const setOpen = (open: boolean): void => {
header.classList.toggle("is-drawer-open", open);
burger.setAttribute("aria-expanded", String(open));
};
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 navList = document.createElement("nav");
navList.className = "app-drawer__nav";
navList.append(...buildNavButtons());
const prefs = document.createElement("div");
prefs.className = "app-drawer__row";
prefs.append(
createButton({
label: currentLanguageIndex === 0 ? "EN" : "한",
variant: "pill",
onClick: onApp_Lang_Toggle_Click,
}),
createButton({
label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾",
variant: "pill",
onClick: onApp_Theme_Toggle_Click,
}),
);
const auth = document.createElement("div");
auth.className = "app-drawer__row app-drawer__auth";
void fillAuthActions(auth);
drawer.append(navList, prefs, auth, buildTouchModeSwitch());
return [burger, scrim, drawer];
}
function buildTouchModeSwitch(): HTMLElement {
const box = document.createElement("div");
box.className = "app-drawer__touch";
const label = document.createElement("span");
label.textContent = L("App_TouchMode");
const group = document.createElement("div");
group.className = "app-drawer__row";
group.setAttribute("role", "radiogroup");
group.setAttribute("aria-label", L("App_TouchMode"));
const modes: [TouchMode, keyof typeof ui_locales][] = [
["auto", "App_TouchMode_Auto"],
["on", "App_TouchMode_On"],
["off", "App_TouchMode_Off"],
];
const buttons = modes.map(([mode, key]) => {
const b = createButton({
label: L(key),
variant: "pill",
onClick: () => {
setTouchMode(mode);
mark();
},
});
b.setAttribute("role", "radio");
b.dataset.touchMode = mode;
return b;
});
const mark = (): void => {
const now = readTouchMode();
for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now));
};
mark();
group.append(...buttons);
box.append(label, group);
return box;
}
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
async function fillAuthActions(slot: HTMLElement): Promise<void> {
try {
@@ -259,10 +354,10 @@ const SHELL_CSS = `
gap: var(--spacing-8);
}
.app-outlet {
min-height: calc(100vh - 64px - 56px);
min-height: calc(100dvh - 64px - 56px);
}
.app-outlet--without-footer {
min-height: calc(100vh - 64px);
min-height: calc(100dvh - 64px);
}
.app-footer {
height: 56px;
@@ -285,7 +380,79 @@ const SHELL_CSS = `
}
@media (max-width: 900px) {
.app-nav { display: none; }
.app-actions { margin-left: auto; }
}
/* ☰ 서랍 — 폭 900 이하 · 터치 모드에서만 보임(데스크탑 마우스엔 없음) */
.app-burger,
.app-drawer,
.app-drawer__scrim {
display: none;
}
.app-burger {
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
font-size: 20px;
cursor: pointer;
}
@media (max-width: 900px) {
.app-burger { display: inline-flex; }
}
html[data-touch="on"] .app-burger { display: inline-flex; }
.app-header.is-drawer-open .app-drawer__scrim {
display: block;
position: fixed;
inset: 64px 0 0 0;
background: rgba(38, 17, 74, 0.35);
}
.app-header.is-drawer-open .app-drawer {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
position: fixed;
top: 64px;
right: 0;
bottom: 0;
width: min(320px, 100vw);
padding: var(--spacing-16);
overflow-y: auto;
background: var(--color-surface-raised);
border-left: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
}
.app-drawer__nav {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; }
.app-drawer__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.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);
}
.app-drawer__touch [aria-checked="true"] {
background: var(--color-mist-violet);
color: var(--color-accent);
}
/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */
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; }
`;
export function injectShellStyles(): void {
+1 -1
View File
@@ -174,7 +174,7 @@ const SCAFFOLD_CSS = `
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
.b-scaffold-wf { height: calc(100vh - 64px - 56px); }
.b-scaffold-wf { height: calc(100dvh - 64px - 56px); }
.b-pending {
display: flex;
+78
View File
@@ -0,0 +1,78 @@
/* =============================================================================
* device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6)
* 모든 규칙은 `<html>` 표시(data-touch · data-size) 안에서만 — 데스크탑 마우스 화면은 안 바뀜.
* 표시는 `device_context.ts` 가 적음.
* ========================================================================== */
/* 누를 자리 44px — 손가락(주 입력 coarse) 또는 터치 모드 켬 */
html[data-touch="on"]
:is(
.ui-btn,
.ui-workflow-overlay__toggle,
.ui-sidebar-row,
.ui-workflow-layout__step,
.ui-ctx-menu__item,
select,
input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"])
) {
min-height: 44px;
}
html[data-touch="on"] .ui-btn {
min-width: 44px;
}
/* 체크 · 라디오 — 상자를 키우고 글까지 한 줄 전체가 누를 자리 */
html[data-touch="on"] :is(input[type="checkbox"], input[type="radio"]) {
width: 22px;
height: 22px;
}
html[data-touch="on"] .ui-checkbox {
min-height: 44px;
}
/* 좌측 패널 손잡이 — 손가락 폭 */
html[data-touch="on"] .ui-workflow-overlay__handle--side {
width: 32px;
right: -31px;
}
/* 팝업(2-4) — 폭 600 미만은 전체 화면 · 겹치면 나중 것(맨 위)만 보이고 × 로 앞 창.
공용 모달(.ui-modal) · 페이지 팝업은 공용 클래스 `.ui-popup` 만 붙여 맞춤(자리 · 크기 인라인 값도 덮음) */
html[data-size="compact"] :is(.ui-modal, .ui-popup) {
position: fixed !important;
inset: 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
border-radius: 0 !important;
transform: none !important;
overflow: auto;
}
html[data-size="compact"] .ui-confirm__panel {
min-width: 0;
max-width: calc(100vw - var(--spacing-32));
}
/* 공용 표(2-5) — 폭 840 미만은 고정 열이 없는 표도 첫 열을 붙임(머리줄은 늘 붙어 있음 · 가로는 밀기) */
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
tr
> :first-child {
position: sticky;
left: 0;
z-index: 1;
}
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
thead
tr
> :first-child {
z-index: 3;
}
+116
View File
@@ -0,0 +1,116 @@
/* =============================================================================
* device_context.ts
* 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `<html>` 에 지금 상태를 늘 적음.
*
* - data-size = compact(<600) · medium(<840) · expanded(<1200) · large ← 배치는 이것만 봄
* - data-pointer = 주 입력 coarse · fine(미디어 `pointer`)
* - data-input = 마지막 누름의 pointerType(mouse · touch · pen)
* - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장)
* - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때)
* 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태).
* 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수.
* 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘.
* ========================================================================== */
import "./device_context.css";
export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const;
export type SizeClass = "compact" | "medium" | "expanded" | "large";
export type TouchMode = "auto" | "on" | "off";
export interface DeviceContext {
size: SizeClass;
pointer: "coarse" | "fine";
input: string;
touchMode: TouchMode;
touch: boolean;
}
const TOUCH_MODE_KEY = "frd_touch_mode";
export function sizeClassOf(width: number): SizeClass {
if (width < SIZE_BREAKS.medium) return "compact";
if (width < SIZE_BREAKS.expanded) return "medium";
if (width < SIZE_BREAKS.large) return "expanded";
return "large";
}
let modeInMemory: TouchMode | null = null; // 저장이 막힌 브라우저용
export function readTouchMode(): TouchMode {
if (modeInMemory) return modeInMemory;
try {
const v = localStorage.getItem(TOUCH_MODE_KEY);
return v === "on" || v === "off" ? v : "auto";
} catch {
return "auto";
}
}
const coarseQuery = window.matchMedia("(pointer: coarse)");
let current: DeviceContext | null = null;
let lastInput = "";
function read(): DeviceContext {
const pointer = coarseQuery.matches ? "coarse" : "fine";
const touchMode = readTouchMode();
return {
size: sizeClassOf(window.innerWidth),
pointer,
input: lastInput || (pointer === "coarse" ? "touch" : "mouse"),
touchMode,
touch: touchMode === "on" || (touchMode === "auto" && pointer === "coarse"),
};
}
function apply(): void {
const next = read();
if (current && JSON.stringify(current) === JSON.stringify(next)) return;
const html = document.documentElement;
html.dataset.size = next.size;
html.dataset.pointer = next.pointer;
html.dataset.input = next.input;
html.dataset.touchMode = next.touchMode;
html.dataset.touch = next.touch ? "on" : "off";
const first = !current;
current = next;
if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next }));
}
export function deviceContext(): DeviceContext {
return current ?? read();
}
/** 터치 모드 스위치(☰ 서랍) — 이 기기 브라우저에만 저장 */
export function setTouchMode(mode: TouchMode): void {
modeInMemory = mode;
try {
if (mode === "auto") localStorage.removeItem(TOUCH_MODE_KEY);
else localStorage.setItem(TOUCH_MODE_KEY, mode);
} catch {
/* 저장 막힌 브라우저 — 이번 화면만 */
}
apply();
}
function init(): void {
const html = document.documentElement;
for (const [name, px] of Object.entries(SIZE_BREAKS))
html.style.setProperty(`--bp-${name}`, `${px}px`);
apply();
coarseQuery.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.visualViewport?.addEventListener("resize", apply);
screen.orientation?.addEventListener("change", apply);
window.addEventListener(
"pointerdown",
(e) => {
if (e.pointerType === lastInput) return;
lastInput = e.pointerType;
apply();
},
{ capture: true, passive: true },
);
}
init();
+1
View File
@@ -9,6 +9,7 @@
* ========================================================================== */
import "./random_uuid_polyfill";
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
@@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
attrs: { type: "button", title: TEXT.close },
});
const dialog = el("div", {
className: "ss-dialog",
className: "ss-dialog ui-popup",
attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
});
@@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void {
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
});
const box = el("div", {
className: "ss-steps__box",
className: "ss-steps__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-steps", children: [box] });
@@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" });
const box = el("div", {
className: "ss-sort__box",
className: "ss-sort__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-sort", children: [box] });
+1 -1
View File
@@ -560,7 +560,7 @@
display: flex;
flex-direction: column;
width: min(380px, calc(100vw - 32px));
height: min(680px, calc(100vh - 104px));
height: min(680px, calc(100dvh - 104px));
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface, #fff);
+1 -1
View File
@@ -1,5 +1,5 @@
.b03-file {
min-height: calc(100vh - var(--app-header-height, 0px));
min-height: calc(100dvh - var(--app-header-height, 0px));
background: linear-gradient(
180deg,
var(--color-canvas, #ffffff) 0%,
+1 -1
View File
@@ -74,7 +74,7 @@ export function createTempPicker(
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div");
modal.className = "b03-file__modal b03-file__temp-modal";
modal.className = "b03-file__modal b03-file__temp-modal ui-popup";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
+1 -1
View File
@@ -119,7 +119,7 @@
flex-direction: column;
width: 100%;
max-width: none;
min-height: calc(100vh - var(--spacing-64));
min-height: calc(100dvh - var(--spacing-64));
}
.b04-surface__workspace > .b04-surface__status {
@@ -45,7 +45,7 @@ export function createRouteEditChrome(): RouteEditChrome {
const overlay = document.createElement("div");
overlay.className = "ui-modal__backdrop b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__box ui-popup" role="dialog" aria-label="계획노선 편집">
<div class="ui-modal__top b05-routeedit__head">
<h3>계획노선 편집</h3>
<span class="b05-routeedit__rec-now" data-rec-now></span>
+1 -1
View File
@@ -1,5 +1,5 @@
.b05-route-layout {
height: calc(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
min-height: 0;
overflow: hidden;
+1 -1
View File
@@ -9,7 +9,7 @@
/* --- 좌측 입력 폼 --- */
.b06-profile-layout {
height: calc(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
min-height: 0;
overflow: hidden;
@@ -5,7 +5,7 @@
/* 워크플로우 레이아웃 높이 (B06 패턴 준수) */
.b07-design-layout {
height: calc(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
min-height: 0;
}
+2 -2
View File
@@ -39,11 +39,11 @@
}
}
/* 카드 높이 = 화면 따라(B08 다른 표와 같은 calc(100vh − N)) · 넘치면 카드 몸만 스크롤 */
/* 카드 높이 = 화면 따라(B08 다른 표와 같은 calc(100dvh − N)) · 넘치면 카드 몸만 스크롤 */
.b08-hydro__card {
display: flex;
flex-direction: column;
max-height: calc(100vh - 240px);
max-height: calc(100dvh - 240px);
}
.b08-hydro__card .ui-card__body {
+1 -1
View File
@@ -113,7 +113,7 @@ export function openRowPopup(o: PopupOpen): void {
listen();
const level = stack.length;
const box = document.createElement("div");
box.className = "b09-estimation b09-popup";
box.className = "b09-estimation b09-popup ui-popup";
box.setAttribute("role", "dialog");
box.setAttribute("aria-label", tabName(tab));
box.dataset.b09Popup = tab;
@@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: calc(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
padding: var(--spacing-16) var(--spacing-24);
box-sizing: border-box;
}
@@ -82,7 +82,7 @@ function openTablePick(onPick: (table: TableHead) => void): void {
const search = createInputField({ type: "search", placeholder: tl("Copy_PickSearch") });
const list = el("div", { className: "m01-logic__pick-list" });
const dialog = el("div", {
className: "m01-logic__pick",
className: "m01-logic__pick ui-popup",
attrs: { role: "dialog", "aria-label": tl("Copy_PickTitle") },
children: [
el("div", {
@@ -47,7 +47,7 @@ export function openPicker(mode: PickMode, group: string, done: PickDone): void
const list = el("div", { className: "m01-logic__pick-list" });
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick",
className: "m01-logic__pick ui-popup",
attrs: { role: "dialog", "aria-label": tx("Pick_Title") },
children: [
el("div", {
@@ -51,7 +51,7 @@ export function openPickTable(opt: PickTableOptions): void {
const search = searchField.input;
const host = el("div", { className: "m01-logic__pick-list" });
const dialog = el("div", {
className: "m01-logic__pick m01-logic__pick--table",
className: "m01-logic__pick m01-logic__pick--table ui-popup",
attrs: { role: "dialog", "aria-label": opt.title },
children: [
el("div", {
@@ -347,7 +347,7 @@ input.m01-logic__input {
width: min(720px, 92vw);
/* 위에서 고정 거리 — 공용 .ui-modal 과 같은 규칙(찾기 칸이 안 움직임) */
max-height: var(--modal-max, 80vh);
margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);
margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);
padding: var(--spacing-16);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
+1 -1
View File
@@ -91,7 +91,7 @@
}
.m01-master__grid-wrap {
max-height: calc(100vh - 300px);
max-height: calc(100dvh - 300px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
@@ -121,7 +121,7 @@ button.m02-dfpanel-chip:focus-visible {
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
.m02-dfpanel-sheet {
position: relative;
height: max(640px, calc(100vh - 260px));
height: max(640px, calc(100dvh - 260px));
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
overflow: hidden;
@@ -5,7 +5,7 @@
표 양식일 때는 페이지 쪽 모양 그대로 두려고 도면을 품은 때만 건다. */
.m02-main:has(> .m02-main__host > .m02-drawing) {
box-sizing: border-box;
height: calc(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
min-height: 0;
}
@@ -7,7 +7,7 @@
}
.m02-itempick .m01-master__grid-wrap {
max-height: min(60vh, calc(100vh - 300px));
max-height: min(60vh, calc(100dvh - 300px));
}
.ui-modal.m02-itempick__dialog {
@@ -252,7 +252,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
});
const dialog = el("div", {
className: "m01-logic__pick m01-combo",
className: "m01-logic__pick m01-combo ui-popup",
attrs: { role: "dialog", "aria-label": tc("Pick_Title") },
children: [
el("div", {
@@ -40,7 +40,7 @@
/* 카드가 보이면 상세 칸은 창 높이까지 — 표 + 카드가 넘치면 상세 칸 전체가 스크롤 */
.m02-main:has(> .m02-main__extra > .m02-struct:not([hidden])) {
box-sizing: border-box;
max-height: calc(100vh - var(--spacing-64));
max-height: calc(100dvh - var(--spacing-64));
overflow-y: auto;
}
@@ -137,7 +137,7 @@
/* 면적산출 · 재료산출 전체 편집 — `.ss` 는 height:100% 로 부모 높이를 받음 · 도면 편집과 같이 메인 칸을 화면 높이로 채움 */
.m02-main:has(> .m02-main__host > .ss) {
box-sizing: border-box;
height: calc(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
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(100vh - var(--spacing-64));
height: calc(100dvh - var(--spacing-64));
min-height: 0;
}
@@ -23,7 +23,7 @@
.m02-opts__kinds,
.m02-opts__list {
max-height: calc(100vh - 20rem);
max-height: calc(100dvh - 20rem);
overflow-y: auto;
display: flex;
flex-direction: column;
+1 -1
View File
@@ -72,7 +72,7 @@ def test_공용_표_옵션과_연결():
def test_합계_줄_고정_그대로():
css = _read("ui_template", "ui_template_data_table.css")
assert re.search(r"tfoot[^{]*\{[^}]*position:\s*sticky[^}]*bottom:\s*0", css, re.S)
assert "max-height: calc(100vh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로
assert "max-height: calc(100dvh - 260px)" in css # 옵션 안 준 표의 기본값은 그대로
@pytest.mark.parametrize(
+1 -1
View File
@@ -238,7 +238,7 @@ def test_창_열_제목_강조_줄_자리():
" table: box.children[1].dataset.b09Table, left: box.style.left, top: box.style.top }));"
)
assert out["boxes"] == ["bill"] and out["n"] == 1
assert out["cls"] == "b09-estimation b09-popup" # 창 안 = b09 표 꼴(빨강 테두리)
assert out["cls"] == "b09-estimation b09-popup ui-popup" # 창 안 = b09 표 꼴(빨강 테두리)
assert out["head"] == ["설계내역서", "탭으로", "×"]
assert out["rows"] == ["b1", "b2", "b3"] # 그 줄 + 자손
assert out["rowCls"] == ["is-title is-pick is-pick-top", "is-pick", "is-pick is-pick-end"]
+8
View File
@@ -157,6 +157,14 @@ def _open(page, route):
page.wait_for_load_state("networkidle", timeout=15000)
except Exception:
pass
try: # 불러오는 중 표시(돌이 · 진행 원)가 사라질 때까지 — 반쯤 그린 화면을 찍지 않게
page.wait_for_function(
"() => ![...document.querySelectorAll('.ui-loading, .ui-progress-circle')]"
".some((e) => e.getClientRects().length)",
timeout=45000,
)
except Exception:
pass
page.wait_for_timeout(1500)
+1 -1
View File
@@ -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(100vh - var(--spacing-64));\n overflow-y: auto;" in css
assert "max-height: calc(100dvh - var(--spacing-64));\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
@@ -28,7 +28,7 @@ def test_공용_모달_위_고정():
assert "align-items: flex-start;" in _block(css, ".ui-modal__backdrop")
modal = _block(css, ".ui-modal")
assert "max-height: var(--modal-max, 80vh);" in modal
assert "margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);" in modal
assert "margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);" in modal
# 큰 창은 max-height 대신 --modal-max(위 거리가 함께 맞음)
# 고르기 창 = 공용 큰 창 곁값(147-2)
assert "--modal-max: 94vh;" in _block(css, ".ui-modal.ui-modal--wide")
@@ -44,7 +44,7 @@ def test_공용_모달_위_고정():
def test_따로_둔_모달도_위_고정():
logic = _text("M01_MasterData/M01_MasterData_UI_Logic_Style.css")
assert "align-items: flex-start;" in _block(logic, ".m01-logic__backdrop")
assert "margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);" in _block(
assert "margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);" in _block(
logic, ".m01-logic__pick"
)
assert "--modal-max: 90vh;" in _block(
+1 -1
View File
@@ -29,7 +29,7 @@
.ui-sheet__scroll {
overflow: auto;
max-height: calc(100vh - 260px);
max-height: calc(100dvh - 260px);
border: 1px solid var(--color-border);
background: var(--color-canvas);
}
+1 -1
View File
@@ -6,7 +6,7 @@
:where(.ui-ctx-menu) {
position: absolute;
z-index: var(--z-dropdown);
max-height: calc(100vh - 8px);
max-height: calc(100dvh - 8px);
}
.ui-ctx-menu {
+1 -1
View File
@@ -19,7 +19,7 @@
--dt-lv5-size: var(--text-caption);
width: fit-content; /* 표가 좁으면 틀도 표만큼 */
max-width: 100%;
max-height: calc(100vh - 260px);
max-height: calc(100dvh - 260px);
overflow: auto;
border: 1px solid var(--dt-line);
border-radius: var(--radius-lg);
+1 -1
View File
@@ -1,5 +1,5 @@
.ui-general-layout {
min-height: calc(100vh - var(--spacing-64));
min-height: calc(100dvh - var(--spacing-64));
padding: var(--spacing-40) var(--spacing-24) var(--spacing-48);
background: var(--color-bg);
}
+6
View File
@@ -121,11 +121,17 @@ export function attachHoverInfo(root: HTMLElement, text: HoverText, selector?: s
};
const over = (e: Event): void => enter(e, HOVER_DELAY_MS);
const focus = (e: Event): void => enter(e, 0);
// 손가락 — hover 가 없으니 톡 하면 바로 뜸(기기대응 2-6 · 다음 누름에 닫힘)
const tap = (e: PointerEvent): void => {
if (e.pointerType === "touch") enter(e, 0);
};
root.addEventListener("pointerup", tap);
root.addEventListener("mouseover", over);
root.addEventListener("mouseout", leave);
root.addEventListener("focusin", focus);
root.addEventListener("focusout", leave);
return () => {
root.removeEventListener("pointerup", tap);
root.removeEventListener("mouseover", over);
root.removeEventListener("mouseout", leave);
root.removeEventListener("focusin", focus);
+5
View File
@@ -100,6 +100,11 @@ export const ui_locales_common = {
App_Nav_News: ["소식", "News"],
App_Nav_Education: ["교육", "Education"],
App_Nav_Support: ["기술지원", "Support"],
App_Menu: ["메뉴", "Menu"],
App_TouchMode: ["터치 모드", "Touch mode"],
App_TouchMode_Auto: ["자동", "Auto"],
App_TouchMode_On: ["켬", "On"],
App_TouchMode_Off: ["끔", "Off"],
App_Footer_Copyright: [
"© 2026 임도설계 및 견적 자동화. All rights reserved.",
"© 2026 Forest Road Design & Estimation. All rights reserved.",
+1 -1
View File
@@ -30,7 +30,7 @@
left: 0;
top: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
transform-origin: 0 0;
}
+1 -1
View File
@@ -16,7 +16,7 @@
gap: var(--spacing-8);
width: min(720px, 92vw);
max-height: var(--modal-max, 80vh);
margin-top: calc((100vh - var(--modal-max, 80vh)) / 2);
margin-top: calc((100dvh - var(--modal-max, 80vh)) / 2);
padding: var(--spacing-16);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
+30 -2
View File
@@ -8,7 +8,7 @@
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
max-height: calc(100vh - var(--spacing-64) - var(--spacing-32));
max-height: calc(100dvh - var(--spacing-64) - var(--spacing-32));
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
@@ -305,7 +305,7 @@
.ui-workflow-overlay__panel--progress {
top: calc(var(--spacing-64) + var(--spacing-8));
right: var(--spacing-8);
max-height: calc(100vh - var(--spacing-64) - var(--spacing-16));
max-height: calc(100dvh - var(--spacing-64) - var(--spacing-16));
}
}
@@ -523,3 +523,31 @@
background: var(--color-mist-violet);
color: var(--color-accent);
}
/* 기기 대응(PLAN_기기대응 2-3) — 폭 840 미만(data-size compact · medium)만.
좌측 패널 = 서랍(본문 위에 겹침 · 덮개를 누르면 닫힘) · 진행단계 판 = 아래에서 올라오는 판(뒤 화면 계속 조작). */
.ui-workflow-overlay__scrim {
display: none;
}
html:is([data-size="compact"], [data-size="medium"])
.ui-workflow-overlay:has(> .ui-workflow-overlay__panel--title:not(.is-collapsed))
> .ui-workflow-overlay__scrim {
display: block;
position: fixed;
inset: var(--spacing-64) 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;
}
+8 -1
View File
@@ -7,6 +7,7 @@ import { makePanelDraggable } from "./ui_template_overlay_drag";
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { SIZE_BREAKS } from "../A00_Common/device_context";
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
@@ -99,6 +100,8 @@ const NARROW_START_COLLAPSED_MAX_PX = 560;
* 위에 겹쳐 떠서, 펼친 채로 열면 본문이 가려진 화면부터 보게 된다(2026-09-08).
*/
function readOpenState(key: string, collapseWhenNarrow = false): boolean {
// 폭 840 미만 = 서랍(기기대응 2-3) — 저장값과 상관없이 접은 채로 시작
if (collapseWhenNarrow && window.innerWidth < SIZE_BREAKS.expanded) return false;
const raw = readByKey(key);
if (raw !== null) return raw !== "false";
if (!collapseWhenNarrow) return true;
@@ -258,7 +261,11 @@ export function createWorkflowOverlays(options: WorkflowOverlayOptions): Workflo
options.onOptionsOpenChange,
options.showProjectName === false ? undefined : createProjectNameTag(),
);
root.append(titlePanel.root);
// 좁은 폭 서랍의 덮개 — 누르면 닫힘(보임은 CSS · 폭 840 미만 + 펼침)
const scrim = document.createElement("div");
scrim.className = "ui-workflow-overlay__scrim";
scrim.addEventListener("click", () => titlePanel.setOpen(false));
root.append(scrim, titlePanel.root);
setTitleOpen = titlePanel.setOpen;
}
let setProgressOpen = (_isOpen: boolean): void => {};
+38 -1
View File
@@ -23,7 +23,7 @@
flex: none;
align-self: flex-start;
/* 높이 · 붙는 자리 = JS(followView) · 처음 한 번 그릴 때까지 */
height: calc(100vh - 80px);
height: calc(100dvh - 80px);
/* 손잡이 자리(24) + 틈 */
margin-left: calc(var(--spacing-24) + var(--spacing-8));
}
@@ -130,3 +130,40 @@
overflow: auto;
overscroll-behavior: contain; /* 몸 끝에서 메인으로 이어 굴리지 않음(85-5) */
}
/* 기기 대응(PLAN_기기대응 2-3) — 폭 840 미만은 오른쪽 패널을 본문 아래로 쌓음(좁으면 보조 칸은 아래) ·
손잡이 줄은 패널 위 가로 · 붙어 따라오기(followView 인라인 높이 · 이동)는 끔 */
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels-row {
flex-direction: column;
}
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels,
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels.is-fill,
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels.is-collapsed {
flex-direction: column;
align-self: stretch;
width: auto;
height: auto !important;
margin: var(--spacing-8) 0 0;
transform: none !important;
}
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels__handles {
position: static;
flex-direction: row;
transform: none;
}
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels__panel,
html:is([data-size="compact"], [data-size="medium"])
.ui-side-panels.is-fill
> .ui-side-panels__panel {
position: relative;
inset: auto;
width: auto;
height: 60dvh;
}
html:is([data-size="compact"], [data-size="medium"]) .ui-side-panels__grip {
display: none;
}
+3 -3
View File
@@ -1,6 +1,6 @@
.ui-workflow-layout {
position: relative;
min-height: calc(100vh - var(--spacing-64));
min-height: calc(100dvh - var(--spacing-64));
background: var(--color-bg);
}
@@ -108,7 +108,7 @@
}
.ui-workflow-layout__body {
min-height: calc(100vh - var(--spacing-64));
min-height: calc(100dvh - var(--spacing-64));
transition: padding-left var(--transition-base);
}
@@ -132,7 +132,7 @@
.ui-workflow-layout__main {
width: 100%;
min-width: 0;
min-height: calc(100vh - var(--spacing-64));
min-height: calc(100dvh - var(--spacing-64));
overflow: auto;
background: var(--color-canvas);
}