From 065ca8b983888ee8c10c3a1ecd88cf666a75276e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:51:18 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(D01=C2=B7D02):=20=EB=82=A0=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=C2=B7=20=EC=95=88=EB=82=B4=20=EA=B8=80?= =?UTF-8?q?=EC=9D=84=20=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=91=9C=EA=B8=B0=20=C2=B7=20=EC=97=B0?= =?UTF-8?q?=EB=8F=84=20=EC=A7=80=EC=A0=90=20=EC=B9=B8=20=C2=B7=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EC=A4=84=20(PLAN=20553-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- D01_PlanRegister/D01_PlanRegister_UI_Page.ts | 4 +-- D02_PlanBase/D02_PlanBase_UI_Page.ts | 28 +++++++++------- D02_PlanBase/D02_PlanBase_UI_Style.css | 12 ------- resources/tester/test_553_4_de_parts.py | 34 ++++++++++++++++++++ 4 files changed, 52 insertions(+), 26 deletions(-) create mode 100644 resources/tester/test_553_4_de_parts.py diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 49c819408..aad82e483 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -17,6 +17,7 @@ import { } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createHelpButton } from "@ui/ui_template_help"; +import { createText } from "@ui/ui_template_text"; import { t } from "@ui/ui_template_locale"; import { stationLabel } from "../common_util/common_util_station"; import { createGroupProject } from "../A00_Common/group_workflow"; @@ -70,8 +71,7 @@ export function renderD01PlanRegister(root: HTMLElement): void { const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 }); // 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로) for (const field of [startM, endM]) { - const shown = document.createElement("span"); - shown.style.cssText = "font-size:12px;color:var(--color-text-muted)"; + const shown = createText("", "muted"); shown.dataset.station = ""; field.root.insertBefore(shown, field.root.lastElementChild); field.input.addEventListener("input", () => { diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index 779805682..d4e9c89fb 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -11,12 +11,14 @@ import "./D02_PlanBase_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { createButton, + createInputField, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createPanelCard } from "@ui/ui_template_collapsible"; +import { createText } from "@ui/ui_template_text"; import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; import { t } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; @@ -66,9 +68,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const mapHost = el("div", { className: "d02-map" }); const table = el("div", { className: "d02-points" }); - const info = el("p", { className: "d02-info" }); + const info = createText("", "muted"); + info.classList.add("d02-info"); const card = createPanelCard(t("D02_Points_Title"), false); - const pointsHint = el("p", { className: "d02-info", text: t("D02_Points_Hint") }); + const pointsHint = createText(t("D02_Points_Hint"), "muted"); + pointsHint.classList.add("d02-info"); card.append(pointsHint, table, info); const save = createButton({ label: t("D02_Save"), @@ -139,12 +143,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { function drawTable(): void { const rows = points.map((p, i) => { - const lon = el("input", { className: "d02-points__input" }) as HTMLInputElement; - const lat = el("input", { className: "d02-points__input" }) as HTMLInputElement; - lon.type = lat.type = "number"; + const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input; + const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input; + lon.classList.add("d02-points__input"); + lat.classList.add("d02-points__input"); lon.step = lat.step = "any"; - lon.placeholder = t("D02_Lon"); - lat.placeholder = t("D02_Lat"); lon.value = p ? String(p.lon) : ""; lat.value = p ? String(p.lat) : ""; lon.disabled = lat.disabled = !editable; @@ -154,8 +157,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { const typed = (): void => { const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) }; const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45; - lon.classList.toggle("is-invalid", !ok); - lat.classList.toggle("is-invalid", !ok); + for (const box of [lon, lat]) { + box.classList.toggle("is-error", !ok); + if (ok) box.removeAttribute("aria-invalid"); + else box.setAttribute("aria-invalid", "true"); + } if (ok) place(i, at); }; lon.addEventListener("change", typed); @@ -254,8 +260,6 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { active = firstEmpty(); paint(); } catch (error) { - table.replaceChildren( - el("p", { className: "d02-info", text: error instanceof Error ? error.message : "" }), - ); + table.replaceChildren(createText(error instanceof Error ? error.message : "", "muted")); } } diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css index ff48d662e..39cf82ad8 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Style.css +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -35,17 +35,5 @@ } .d02-points__input { - width: 100%; min-width: 0; - font-size: var(--text-caption); -} - -.d02-points__input.is-invalid { - outline: 1px solid var(--color-danger, #d92d20); -} - -.d02-info { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); } diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py new file mode 100644 index 000000000..e37297b87 --- /dev/null +++ b/resources/tester/test_553_4_de_parts.py @@ -0,0 +1,34 @@ +"""553-4 예정노선(D01~D04) · 안정성평가(E01~E07) 부품 옮김 지킴 시험 — 서버 · 화면 없이 소스만 본다. +옮긴 페이지는 날 `` · `` · 날 색 · 안 쓰는 변종 CSS 가 다시 생기면 안 됨.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _src(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def _no_hex(path: str) -> None: + css = re.sub(r"/\*.*?\*/", "", _src(path), flags=re.S) + assert not re.search(r"#[0-9a-fA-F]{3,8}\b", css), f"{path}: 날 색" + + +def test_d01_station_label_is_text_part(): + ts = _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") + assert "style.cssText" not in ts + assert 'createText("", "muted")' in ts + assert 'dataset.station = ""' in ts # 시험 · 촬영이 잡음 + + +def test_d02_parts(): + ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts") + css = _src("D02_PlanBase/D02_PlanBase_UI_Style.css") + assert 'el("input"' not in ts + assert 'createInputField({ type: "number"' in ts + assert 'classList.add("d02-info")' in ts # 촬영 장면이 `p.d02-info` 로 잡음 + for dead in (".d02-info", "is-invalid", "font-size: var(--text-caption);\n}\n\n.d02-info"): + assert dead not in css + assert "data-axis" in ts or "dataset.axis" in ts From a8268042771ec5c642d77751dcc1dca46a2ac849 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:53:32 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor(B02=C2=B7B03):=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=9E=90=EC=B2=B4=20=EB=B3=80=EC=A2=85?= =?UTF-8?q?=EC=9D=84=20=EB=B6=80=ED=92=88=20=EC=83=81=EC=9E=90=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=C2=B7=20=EB=9D=A0=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EC=A7=84=ED=96=89=20=EB=A7=89=EB=8C=80=20=C2=B7=20?= =?UTF-8?q?=EC=BC=9C=EA=B8=B0=20=C2=B7=20=ED=8C=A8=EB=84=90=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EB=8B=A8=EC=B6=94=20(PLAN=20553-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B03 파일 카드 = 카드 부품 · [선택] · [×] = 단추 · 아이콘 단추 · 진행 막대 · 오류 띠 = 부품 - B03 현황 · 이어올리기 · 예정노선 바뀜 = 띠 알림 · LAS 없이 설계 = 켜기 · 노선 · 지형 제목 = 패널 머리 - B03 · B02 죽은 CSS 걷음 · 날 색 → 공통 값 · 가이드 촬영 선택자 · 지킴 시험 test_553_1_pages Co-Authored-By: Claude Sonnet 5.5 --- A05_EduDetail/guide/B03/_scenes.json | 6 +- .../B02_ProjRegister_UI_Style.css | 17 -- B03_FileInput/B03_FileInput_State.ts | 8 +- B03_FileInput/B03_FileInput_UI_Page.ts | 125 +++++++------ B03_FileInput/B03_FileInput_UI_Page_Flow.ts | 13 +- B03_FileInput/B03_FileInput_UI_PlanImport.ts | 46 ++--- B03_FileInput/B03_FileInput_UI_Style.css | 166 +++--------------- B03_FileInput/B03_FileInput_UI_Style_Card.css | 158 +++++------------ B03_FileInput/B03_FileInput_UI_Style_Temp.css | 2 +- B03_FileInput/B03_FileInput_UI_Support.ts | 12 +- resources/tester/test_553_1_pages.py | 59 +++++++ 11 files changed, 244 insertions(+), 368 deletions(-) create mode 100644 resources/tester/test_553_1_pages.py diff --git a/A05_EduDetail/guide/B03/_scenes.json b/A05_EduDetail/guide/B03/_scenes.json index a3d912b18..3d6328975 100644 --- a/A05_EduDetail/guide/B03/_scenes.json +++ b/A05_EduDetail/guide/B03/_scenes.json @@ -38,11 +38,11 @@ }, { "n": 6, - "sel": ".b03-file__columns > :nth-child(1) .b03-file__group-title" + "sel": ".b03-file__columns > :nth-child(1) .ui-panel-head__title" }, { "n": 7, - "sel": ".b03-file__columns > :nth-child(2) .b03-file__group-title" + "sel": ".b03-file__columns > :nth-child(2) .ui-panel-head__title" } ] }, @@ -215,7 +215,7 @@ "wait": ".b03-file__lasfree" }, { - "click": ".b03-file__lasfree input" + "click": ".b03-file__lasfree" }, { "wait_ms": 800 diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Style.css b/B02_ProjRegister/B02_ProjRegister_UI_Style.css index 4186b3f80..b3f6ebcfc 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Style.css +++ b/B02_ProjRegister/B02_ProjRegister_UI_Style.css @@ -19,23 +19,6 @@ gap: var(--spacing-16); } -/* select는 공통 .ui-input 상속 + 드롭다운 화살표 여백 확보 */ -.b02-proj__select { - appearance: none; - cursor: pointer; - background-image: - linear-gradient(45deg, transparent 50%, var(--color-slate) 50%), - linear-gradient(135deg, var(--color-slate) 50%, transparent 50%); - background-position: - calc(100% - 18px) 50%, - calc(100% - 13px) 50%; - background-size: - 5px 5px, - 5px 5px; - background-repeat: no-repeat; - padding-right: var(--spacing-32); -} - @media (max-width: 640px) { .b02-proj__grid { grid-template-columns: 1fr; diff --git a/B03_FileInput/B03_FileInput_State.ts b/B03_FileInput/B03_FileInput_State.ts index 5e26d331b..eae8fa787 100644 --- a/B03_FileInput/B03_FileInput_State.ts +++ b/B03_FileInput/B03_FileInput_State.ts @@ -1,3 +1,5 @@ +import { createBanner } from "@ui/ui_template_banner"; + export type B03AnalysisStatus = "pending" | "in_progress" | "completed" | "failed"; export interface B03StoredFileState { @@ -78,9 +80,9 @@ export function restoreB03ProjectState(options: { const state = loadB03ProjectState(options.projectId); if (!state) return; options.container.replaceChildren(); - const message = document.createElement("span"); - message.textContent = `${options.label}: ${state.analysisProgress}%`; - options.container.append(message); + options.container.append( + createBanner({ tone: "info", text: `${options.label}: ${state.analysisProgress}%` }), + ); options.container.classList.add("is-visible"); if (state.analysisStatus === "in_progress" || state.analysisStatus === "pending") { void options.poll(options.projectId).then((completed) => { diff --git a/B03_FileInput/B03_FileInput_UI_Page.ts b/B03_FileInput/B03_FileInput_UI_Page.ts index f9de843c3..4a63ac235 100644 --- a/B03_FileInput/B03_FileInput_UI_Page.ts +++ b/B03_FileInput/B03_FileInput_UI_Page.ts @@ -5,7 +5,12 @@ import { UPLOAD_MAX_FILES, } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; -import { createButton, createTag } from "@ui/ui_template_elements"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton, createIconButton, createTag } from "@ui/ui_template_elements"; +import { createPanelHead } from "@ui/ui_template_panel_head"; +import { createProgressBar, type ProgressBarHandle } from "@ui/ui_template_progress"; +import { createText } from "@ui/ui_template_text"; +import { createToggle } from "@ui/ui_template_toggle"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout"; @@ -63,6 +68,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다. const tempPicker = createTempPicker(() => updateUploadButton()); const cardMap = new Map(); + const progressMap = new Map(); let uploadButton: HTMLButtonElement; let resumeBanner: HTMLDivElement; let activeProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; @@ -75,9 +81,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { ? localStorage.getItem(`b03_las_free_${activeProjectId}`) === "1" : false; - const subtitle = document.createElement("p"); - subtitle.className = "b03-file__subtitle"; - subtitle.textContent = L("B03_File_Subtitle"); + const subtitle = createText(L("B03_File_Subtitle"), "muted", 14); + subtitle.classList.add("b03-file__subtitle"); const fileInput = document.createElement("input"); fileInput.type = "file"; @@ -94,7 +99,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { dropzone.append(dropzoneLabel, fileInput); const pageError = document.createElement("p"); - pageError.className = "b03-file__error"; + pageError.className = "ui-text ui-text--error b03-file__error"; pageError.setAttribute("role", "alert"); // 업로드 버튼 안에서 도는 원 — 공용 스피너를 버튼 크기로 줄여 쓴다. @@ -200,11 +205,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const fileName = card.querySelector(".b03-file__file-name"); const fileSize = card.querySelector(".b03-file__file-size"); - const progress = card.querySelector(".b03-file__progress-bar"); + const progress = progressMap.get(slot); const progressBytes = card.querySelector(".b03-file__progress-bytes"); const progressSpeed = card.querySelector(".b03-file__progress-speed"); const progressEta = card.querySelector(".b03-file__progress-eta"); - const error = card.querySelector(".b03-file__error-message"); + const error = card.querySelector(".b03-file__error-message .ui-banner__text"); const remove = card.querySelector(".b03-file__card-remove"); const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0; @@ -217,7 +222,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { ? `${state.serverUploaded.sizeMb.toFixed(2)} MB` : ""; } - if (progress) progress.style.width = `${percent}%`; + progress?.set(percent / 100); if (progressBytes) { progressBytes.textContent = `${L("B03_File_Progress_Bytes")}: ${formatBytes( state.progressBytes, @@ -413,26 +418,27 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const lock = overview.analysis_lock; if (lock?.running) { const who = lock.name ?? lock.email ?? null; - const busy = document.createElement("p"); - busy.className = "b03-file__overview-lock"; - busy.textContent = who - ? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.` - : "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다."; - notes.push(busy); + notes.push( + createBanner({ + tone: "warning", + text: who + ? `${who} 님이 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.` + : "다른 자리에서 올린 자료를 분석하는 중입니다 — 끝난 뒤에 새 자료를 올릴 수 있습니다.", + }), + ); } if (overview.required_complete && overview.analysis_complete) { - const complete = document.createElement("p"); - complete.className = "b03-file__overview-complete"; - complete.textContent = L("B03_File_Overview_Complete"); - notes.push(complete); + notes.push(createBanner({ tone: "success", text: L("B03_File_Overview_Complete") })); } for (const session of overview.pending_sessions) { - const pending = document.createElement("p"); - pending.className = "b03-file__overview-pending"; - pending.textContent = - `${session.original_filename} — ${session.progress_percent}% ` + - L("B03_File_Overview_Pending"); - notes.push(pending); + notes.push( + createBanner({ + tone: "info", + text: + `${session.original_filename} — ${session.progress_percent}% ` + + L("B03_File_Overview_Pending"), + }), + ); } if (notes.length) { overviewBanner.append(...notes); @@ -453,18 +459,30 @@ export async function renderB03FileInput(root: HTMLElement): Promise { renderExtensionLabel(card, state); const input = card.querySelector(".b03-file__slot-input")!; input.accept = state.extensions.join(","); - const select = card.querySelector(".b03-file__card-select")!; - select.textContent = L("B03_File_Card_Select"); + const select = createButton({ label: L("B03_File_Card_Select"), variant: "ghost", size: 24 }); + select.classList.add("b03-file__card-select"); + card.querySelector('[data-slot="select"]')!.replaceWith(select); select.addEventListener("click", () => input.click()); input.addEventListener("change", () => { onFileSelected(input.files ? Array.from(input.files) : [], state.slot); input.value = ""; }); - const remove = card.querySelector(".b03-file__card-remove")!; - remove.textContent = "×"; - remove.title = L("B03_File_Card_Remove"); - remove.setAttribute("aria-label", L("B03_File_Card_Remove")); - remove.addEventListener("click", () => removeFile(state.slot)); + const remove = createIconButton({ + glyph: "×", + title: L("B03_File_Card_Remove"), + variant: "outline", + size: 24, + onClick: () => removeFile(state.slot), + }); + remove.classList.add("b03-file__card-remove"); + card.querySelector('[data-slot="remove"]')!.replaceWith(remove); + const progress = createProgressBar({ thick: 6 }); + progress.root.classList.add("b03-file__progress"); + card.querySelector('[data-slot="progress"]')!.replaceWith(progress.root); + progressMap.set(state.slot, progress); + const errorBanner = createBanner({ tone: "danger", text: "" }); + errorBanner.classList.add("b03-file__error-message"); + card.querySelector('[data-slot="error"]')!.replaceWith(errorBanner); cardMap.set(state.slot, card); return card; } @@ -478,18 +496,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise { const group = document.createElement("section"); group.className = "b03-file__group"; if (modifier) group.classList.add(modifier); - if (title) { - const groupTitle = document.createElement("h3"); - groupTitle.className = "b03-file__group-title"; - groupTitle.textContent = title; - group.append(groupTitle); - } - if (hint) { - const groupHint = document.createElement("p"); - groupHint.className = "b03-file__group-hint"; - groupHint.textContent = hint; - group.append(groupHint); - } + if (title) group.append(createPanelHead({ title, sub: hint }).root); const content = document.createElement("div"); content.className = "b03-file__group-content"; for (const slot of groupSlots) { @@ -560,7 +567,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { pickRow.append(dropzone, tempPicker.root, uploadButton); const uploadControlPanel = document.createElement("div"); - uploadControlPanel.className = "b03-file__control-panel"; + uploadControlPanel.dataset.pad = "16"; + uploadControlPanel.className = "ui-card b03-file__control-panel"; uploadControlPanel.append(subtitle, pickRow, resumeBanner, pageError); // 자료의 출처가 둘로 갈린다 — 원청이 준 계획노선, 측량이 준 지형(LAS·래스터). @@ -581,16 +589,17 @@ export async function renderB03FileInput(root: HTMLElement): Promise { ); // LAS 없는 설계 토글 — 켜면 포인트클라우드 카드를 비활성화하고 필수에서 뺀다. - const lasFreeRow = document.createElement("label"); - lasFreeRow.className = "b03-file__lasfree"; + const lasFreeToggle = createToggle({ + label: L("B03_File_LasFree_Toggle"), + checked: lasFreeDesign, + size: 36, + onChange: onLasFreeChange, + }); + const lasFreeRow = lasFreeToggle.root; + lasFreeRow.classList.add("b03-file__lasfree"); lasFreeRow.title = L("B03_File_LasFree_Hint"); - const lasFreeCheck = document.createElement("input"); - lasFreeCheck.type = "checkbox"; - const lasFreeText = document.createElement("span"); - lasFreeText.textContent = L("B03_File_LasFree_Toggle"); - lasFreeRow.append(lasFreeCheck, lasFreeText); function applyLasFreeState(): void { - lasFreeCheck.checked = lasFreeDesign; + lasFreeToggle.set(lasFreeDesign); terrainGroup.classList.toggle("b03-file__group--disabled", lasFreeDesign); for (const slot of TERRAIN_SLOTS) { const card = cardMap.get(slot); @@ -608,8 +617,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise { if (lasFreeDesign && slots.get(slot)?.file) removeFile(slot); } } - lasFreeCheck.addEventListener("change", () => { - lasFreeDesign = lasFreeCheck.checked; + function onLasFreeChange(checked: boolean): void { + lasFreeDesign = checked; if (activeProjectId) { localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0"); } @@ -619,19 +628,21 @@ export async function renderB03FileInput(root: HTMLElement): Promise { // 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(2026-09-03 실측: 파일을 먼저 고르고 // 토글을 나중에 켠 순서에서 재현). updateUploadButton(); - }); + } // LAS 토글은 **파일 입력 컨테이너**의 것이다(2026-09-03 사용자 지시) — 무엇을 받을지 // 정하는 스위치라 고르는 자리 옆에 선다. 켜면 지형 컨테이너가 통째로 잠긴다. uploadControlPanel.insertBefore(lasFreeRow, resumeBanner); const routePanel = document.createElement("div"); - routePanel.className = "b03-file__control-panel b03-file__cards-container-panel"; + routePanel.dataset.pad = "16"; + routePanel.className = "ui-card b03-file__control-panel b03-file__cards-container-panel"; routePanel.append(routeGroup); mountPlanImport(routePanel, uploadControlPanel, () => void applyUploadOverview()); const terrainPanel = document.createElement("div"); - terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel"; + terrainPanel.dataset.pad = "16"; + terrainPanel.className = "ui-card b03-file__control-panel b03-file__cards-container-panel"; terrainPanel.append(terrainGroup); const cardsContainer = document.createElement("div"); diff --git a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts index bc8c31045..d00b1902a 100644 --- a/B03_FileInput/B03_FileInput_UI_Page_Flow.ts +++ b/B03_FileInput/B03_FileInput_UI_Page_Flow.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { CURRENT_PROJECT_ID_KEY, type RoutePath } from "@config/config_frontend"; +import { createBanner } from "@ui/ui_template_banner"; import { createButton, showToast } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { navigateTo } from "../A00_Common/router"; @@ -81,16 +82,16 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { state.uploadSessionId = session.uploadSessionId; state.progressBytes = session.completedChunks * session.chunkSizeBytes; ctx.renderSlot(state.slot); - const text = document.createElement("span"); - text.textContent = `${L("B03_File_Status_Detected")}: ${session.fileName}`; const resume = createButton({ label: L("B03_File_Resume_Button"), variant: "ghost", + size: 24, onClick: () => void startChunkedUpload([state]), }); const fresh = createButton({ label: L("B03_File_New_Button"), variant: "ghost", + size: 24, onClick: () => { localStorage.removeItem(session.key); state.uploadSessionId = undefined; @@ -100,7 +101,13 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle { ctx.resumeBanner.classList.remove("is-visible"); }, }); - ctx.resumeBanner.append(text, resume, fresh); + ctx.resumeBanner.append( + createBanner({ + tone: "info", + text: `${L("B03_File_Status_Detected")}: ${session.fileName}`, + actions: [resume, fresh], + }), + ); ctx.resumeBanner.classList.add("is-visible"); break; } diff --git a/B03_FileInput/B03_FileInput_UI_PlanImport.ts b/B03_FileInput/B03_FileInput_UI_PlanImport.ts index 9c26aac8b..548f660b8 100644 --- a/B03_FileInput/B03_FileInput_UI_PlanImport.ts +++ b/B03_FileInput/B03_FileInput_UI_PlanImport.ts @@ -8,6 +8,7 @@ * ========================================================================== */ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { createBanner } from "@ui/ui_template_banner"; import { createButton, el, @@ -17,6 +18,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; +import { createListRow } from "@ui/ui_template_list_row"; import { openModal } from "@ui/ui_template_modal"; interface AssignedRoute { @@ -99,7 +101,7 @@ export function mountPlanImport( const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; if (!projectId) return; const lasFree = (): boolean => localStorage.getItem(`b03_las_free_${projectId}`) === "1"; - const banner = el("div", { className: "b03-file__overview-pending b03-file__plan-update" }); + const banner = el("div", { className: "b03-file__plan-update" }); banner.hidden = true; bannerHost.prepend(banner); @@ -117,12 +119,17 @@ export function mountPlanImport( if (!source?.update_available || source.assignee_id === undefined) return; const assigneeId = source.assignee_id; banner.replaceChildren( - el("span", { text: `${t("D04_Update_Banner")} (${source.plan_name} ${source.year}) — ` }), - createButton({ - label: t("D04_Update_Reimport"), - variant: "ghost", - size: "sm", - onClick: () => void done(assigneeId), + createBanner({ + tone: "info", + text: `${t("D04_Update_Banner")} (${source.plan_name} ${source.year})`, + actions: [ + createButton({ + label: t("D04_Update_Reimport"), + variant: "ghost", + size: 24, + onClick: () => void done(assigneeId), + }), + ], }), ); } @@ -149,20 +156,17 @@ export function mountPlanImport( ? ` · ${t("D04_Linked")}: ${route.linked_project_name}` : ""; body.append( - el("div", { - className: "b03-file__plan-pick", - children: [ - el("span", { text: `${route.project_name} · ${route.year}${linked}` }), - createButton({ - label: t("D04_Import_Btn"), - variant: "filled", - size: "sm", - onClick: () => { - close(); - void done(route.assignee_id); - }, - }), - ], + createListRow({ + label: `${route.project_name} · ${route.year}${linked}`, + trailing: createButton({ + label: t("D04_Import_Btn"), + variant: "filled", + size: 24, + onClick: () => { + close(); + void done(route.assignee_id); + }, + }), }), ); } diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index fb01e0657..ae9a2ca63 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -1,10 +1,6 @@ .b03-file { min-height: calc(100dvh - var(--app-header-height, 0px)); - background: linear-gradient( - 180deg, - var(--color-canvas, #ffffff) 0%, - var(--color-mist-violet, #edecff) 100% - ); + background: linear-gradient(180deg, var(--color-canvas) 0%, var(--color-accent-soft) 100%); padding: 0 var(--spacing-24) var(--spacing-48) var(--spacing-24); /* 상단 여백은 main-layout 마진으로 조정하므로 padding-top은 0 */ } @@ -27,31 +23,17 @@ display: flex; flex-direction: column; gap: var(--spacing-16); - background: var(--color-canvas, #ffffff); - /* 여백 32 → 16 (2026-09-03) — 고르는 자리만 남기고 안내를 좌측 패널로 뺐다. - `--spacing-20`은 테마에 없는 토큰이라 선언째 무효가 된다(패딩이 통째로 사라짐). */ - padding: var(--spacing-16); - border-radius: var(--radius-large, 24px); /* Wiza 24px 대형 둥글기 */ - border: 1px solid var(--color-mist, #e6e2e3); - box-shadow: var(--shadow-lg); } .b03-file__cards-container-panel { margin-top: 0; /* 단일 layout gap에 의해 제어되도록 설정 */ } -.b03-file__subtitle { - font-size: var(--text-body-sm, 14px); - color: var(--color-slate, #615e6e); - margin: 0 0 var(--spacing-8) 0; -} - .b03-file__native-input, .b03-file__slot-input { display: none; } -/* Wiza 스타일 가이드의 Lavender Glow 그라데이션 및 soft purple border hover */ /* 고르는 자리 한 줄 — [입력 파일 선택] · [임시 보관함] · [파일 업로드](2026-09-03). 좁아지면 아래로 접히되, 선택 영역이 남는 폭을 먹는다. */ /* 셋을 같은 폭·같은 높이로 나눈다 — 열 1:1:1(2026-09-03 사용자 지시). @@ -80,9 +62,9 @@ 패널로 갔으므로 여기는 「끌어 놓거나 눌러서 고르는 자리」 표시만 한다. */ .b03-file__dropzone { padding: var(--spacing-12) var(--spacing-16); - border: 1px dashed var(--color-mist, #e6e2e3); - border-radius: var(--radius-cards, 8px); - background: var(--color-paper, #f6f7fa); + border: 1px dashed var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); display: flex; flex-direction: row; flex-wrap: wrap; @@ -92,100 +74,54 @@ cursor: pointer; text-align: center; transition: - background var(--transition-base, 0.2s), - border-color var(--transition-base, 0.2s), - box-shadow var(--transition-base, 0.2s); + background var(--transition-base), + border-color var(--transition-base), + box-shadow var(--transition-base); } .b03-file__dropzone:hover { - background: var(--color-mist-violet, #edecff); - border-color: var(--color-royal-amethyst, #3e0079); + background: var(--color-accent-soft); + border-color: var(--color-accent); } .b03-file__dropzone:focus-visible, .b03-file__dropzone.is-dragging { outline: none; - background: var(--color-mist-violet, #edecff); - border-color: var(--color-royal-amethyst, #3e0079); - box-shadow: 0 0 0 4px rgba(62, 0, 121, 0.15); + background: var(--color-accent-soft); + border-color: var(--color-accent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 15%, transparent); } .b03-file__dropzone strong { - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); - font-weight: var(--font-weight-medium, 500); + color: var(--color-text); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-medium); } .b03-file__dropzone span { - font-size: var(--text-body-sm, 14px); - color: var(--color-slate, #615e6e); -} - -/* 빈 오류 줄이 자리를 잡고 있으면 토글 아래가 통째로 비어 보인다 — 글자가 있을 때만 - 자리를 차지한다(2026-09-03 사용자 지시). */ -.b03-file__error { - color: var(--color-danger, #dc2626); - font-size: var(--text-body-sm, 14px); - margin: 0; + font-size: var(--text-body-sm); + color: var(--color-text-secondary); } +/* 빈 오류 줄이 자리를 잡고 있으면 토글 아래가 통째로 비어 보인다 — 글자가 있을 때만 자리를 차지한다. */ .b03-file__error:empty { display: none; } -.b03-file__resume { - display: none; - align-items: center; - gap: var(--spacing-8); - flex-wrap: wrap; - padding: var(--spacing-12) var(--spacing-16); - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-cards, 8px); - background: var(--color-paper, #f6f7fa); - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); - margin-top: var(--spacing-8); -} - -.b03-file__resume.is-visible { - display: flex; -} - -/* 재접속 현황(서버 정본) 배너 — 완료 안내와 중단 세션 이어올리기 안내를 담는다. */ +/* 띠 알림(부품)을 담는 자리 — 글이 있을 때(is-visible)만 선다. */ +.b03-file__resume, .b03-file__overview { display: none; flex-direction: column; gap: var(--spacing-4); margin-top: var(--spacing-8); - padding: var(--spacing-12) var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards, 8px); - background: var(--color-surface); - font-size: var(--text-body-sm, 14px); } +.b03-file__resume.is-visible, .b03-file__overview.is-visible { display: flex; } -.b03-file__overview-complete { - margin: 0; - color: var(--color-success, #3fbb6f); - font-weight: 600; -} - -.b03-file__overview-pending { - margin: 0; - color: var(--color-text-secondary); -} - -/* 잠금 표시 — 지금 누가 올리는 중인지. 막힌 까닭이라 눈에 띄게 둔다. */ -.b03-file__overview-lock { - margin: 0; - color: var(--color-warning, #d98324); - font-weight: 600; -} - /* 완료 슬롯 재업로드 확인 모달 — 페이지 위를 덮는 단순 확인창. */ .b03-file__modal-backdrop { position: fixed; @@ -194,7 +130,7 @@ display: flex; align-items: center; justify-content: center; - background: rgb(0 0 0 / 45%); + background: var(--color-scrim); } .b03-file__modal { @@ -204,9 +140,9 @@ max-width: 420px; padding: var(--spacing-24); border: 1px solid var(--color-border); - border-radius: var(--radius-cards, 8px); + border-radius: var(--radius-cards); background: var(--color-surface-raised); - box-shadow: 0 12px 32px rgb(0 0 0 / 30%); + box-shadow: var(--shadow-lg); } .b03-file__modal p { @@ -221,12 +157,6 @@ justify-content: flex-end; } -.b03-file__cards-grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--spacing-32); -} - /* 계획노선 | 지형(LAS) — 컨테이너를 둘로 나눈다(2026-08-31 사용자 지시). 노선은 카드 5장(shapefile 한 벌), 지형은 4장이라 폭을 5:4 비슷하게 준다. @@ -241,39 +171,9 @@ align-items: start; } -/* LAS 없이 설계 토글 — 파일 입력 컨테이너의 스위치(2026-09-03 사용자 지시). - B04 좌측 패널의 체크 줄(`b04-surface__check`)과 같은 서식이다. */ +/* LAS 없이 설계 — 켜기 부품(스위치) · 자리만 정함 */ .b03-file__lasfree { - display: flex; - align-items: center; - gap: var(--spacing-8); align-self: flex-start; - padding: var(--spacing-8) var(--spacing-12); - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-buttons, 8px); - font-size: var(--text-body-sm, 14px); - color: var(--color-deep-iris, #26114a); - cursor: pointer; - transition: border-color var(--transition-base, 0.2s); -} - -.b03-file__lasfree:hover { - border-color: var(--color-royal-amethyst, #3e0079); -} - -.b03-file__lasfree input { - width: 16px; - height: 16px; - margin: 0; - accent-color: var(--color-royal-amethyst, #3e0079); - cursor: pointer; -} - -/* 켜져 있으면 스위치 자신도 켜진 티를 낸다 — 지형 컨테이너가 왜 잠겼는지의 근거. */ -.b03-file__lasfree:has(input:checked) { - border-color: var(--color-royal-amethyst, #3e0079); - background: var(--color-mist-violet, #edecff); - font-weight: var(--font-weight-medium, 500); } /* LAS 없이 설계를 켜면 지형 컨테이너는 받지 않는다 — 잠긴 것이 보이게 흐린다. */ @@ -282,7 +182,7 @@ } .b03-file__card--disabled { - background: var(--color-paper, #f6f7fa); + background: var(--color-surface); border-style: dashed; } @@ -324,20 +224,6 @@ gap: var(--spacing-12); } -.b03-file__group-hint { - font-size: var(--text-body-sm, 14px); - color: var(--color-slate, #615e6e); - margin: calc(var(--spacing-8) * -1) 0 0 0; -} - -/* 제목 24px → 본문 크기(2026-09-03) — 컨테이너가 둘뿐이라 큰 제목이 자리만 먹었다. */ -.b03-file__group-title { - font-size: var(--text-body, 16px); - color: var(--color-deep-iris, #26114a); - font-family: var(--font-britti-sans, inherit); - margin: 0; -} - /* 카드가 좁아지면 한 줄에 제목·상태·[선택]이 못 들어가 제목이 글자 단위로 접힌다 (2026-09-03 실측: 안내 패널을 연 상태에서 카드 폭 210px). 폭이 모자라면 열을 줄인다. */ /* ⚠ `minmax(260px, …)` 는 **칸이 260px 보다 좁아질 수 없다**는 뜻이라, 컨테이너가 diff --git a/B03_FileInput/B03_FileInput_UI_Style_Card.css b/B03_FileInput/B03_FileInput_UI_Style_Card.css index 74cc766e2..4c65d3139 100644 --- a/B03_FileInput/B03_FileInput_UI_Style_Card.css +++ b/B03_FileInput/B03_FileInput_UI_Style_Card.css @@ -14,50 +14,34 @@ width: 14px; height: 14px; border-width: 2px; - border-color: rgb(255 255 255 / 40%); + border-color: color-mix(in srgb, var(--color-primary-text) 40%, transparent); border-top-color: var(--color-primary-text); } -/* Wiza 8px radius 카드 */ -/* 카드 한 장 = 파일 한 칸. 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) — - 파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. */ +/* 카드 한 장 = 파일 한 칸(카드 부품) · 빈 칸은 **한 줄**로 끝난다(2026-09-03 사용자 지시) — + 파일이 붙거나 올라가는 중에만 아래 내용(파일명·진행바)이 펼쳐진다. 상태는 선 색으로 가른다. */ .b03-file__card { - padding: var(--spacing-12) var(--spacing-16); - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-cards, 8px); - background: var(--color-canvas, #ffffff); - box-shadow: var(--shadow-sm); display: flex; flex-direction: column; gap: var(--spacing-8); - transition: - transform var(--transition-base, 0.2s), - border-color var(--transition-base, 0.2s), - box-shadow var(--transition-base, 0.2s); -} - -.b03-file__card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); - border-color: var(--color-royal-amethyst, #3e0079); } .b03-file__card--selected { - border-color: var(--color-royal-amethyst, #3e0079); - background: var(--color-paper, #f6f7fa); + border-color: var(--color-accent); + background: var(--color-surface); } .b03-file__card--uploading { - border-color: var(--color-royal-amethyst, #3e0079); - box-shadow: var(--shadow-lg-2); + border-color: var(--color-accent); + box-shadow: var(--shadow-lg); } .b03-file__card--completed { - border-color: #10b981; /* Success Green */ + border-color: var(--color-success); } .b03-file__card--error { - border-color: var(--color-danger, #dc2626); + border-color: var(--color-danger); } /* 카드가 좁아졌다(컨테이너 2열 × 카드 2열, 2026-08-31). 격자로 고정하면 제목이 @@ -83,13 +67,13 @@ flex: 0 0 auto; width: 28px; height: 28px; - border-radius: var(--radius-icons, 8px); + border-radius: var(--radius-icons); display: inline-flex; align-items: center; justify-content: center; - color: var(--color-royal-amethyst, #3e0079); - background: var(--color-mist-violet, #edecff); - font-size: var(--text-body-sm, 14px); + color: var(--color-accent); + background: var(--color-accent-soft); + font-size: var(--text-body-sm); margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */ } @@ -102,9 +86,9 @@ } .b03-file__card-label { - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); - font-weight: var(--font-weight-medium, 500); + color: var(--color-text); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-medium); /* 제목은 한 줄로 두고 모자라면 말줄임 — 접히면 카드가 세로로 길어진다(2026-09-03). */ white-space: nowrap; overflow: hidden; @@ -114,8 +98,8 @@ .b03-file__card-ext, .b03-file__file-size, .b03-file__progress-info { - color: var(--color-slate, #615e6e); - font-size: var(--text-caption, 12px); + color: var(--color-text-secondary); + font-size: var(--text-caption); } .b03-file__card-badge-container { @@ -125,28 +109,9 @@ margin-right: var(--spacing-4); } -.b03-file__card-remove { - width: 24px; - height: 24px; - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-buttons, 8px); - color: var(--color-slate, #615e6e); - background: var(--color-canvas, #ffffff); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: var(--text-body-sm, 14px); - line-height: 1; - padding: 0; - margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */ - transition: all var(--transition-base, 0.2s); -} - -.b03-file__card-remove:hover { - color: var(--color-danger, #dc2626); - border-color: var(--color-danger, #dc2626); - background: #fef2f2; +/* 파일 없는 칸에서는 지움 단추를 접는다(아이콘 단추 부품은 hidden 보다 display 가 앞섬) */ +.b03-file__card-remove[hidden] { + display: none; } .b03-file__card-content { @@ -160,24 +125,6 @@ display: none; } -/* [선택]은 제목 줄 오른쪽 끝의 작은 버튼 — 종전 전폭 40px 버튼이 카드 높이를 키웠다. */ -.b03-file__card-select { - flex: 0 0 auto; - min-height: 26px; - padding: 0 var(--spacing-8); - border: 1px solid var(--color-mist, #e6e2e3); - border-radius: var(--radius-buttons, 8px); - color: var(--color-deep-iris, #26114a); - background: var(--color-canvas, #ffffff); - font-weight: var(--font-weight-medium, 500); - font-size: var(--text-caption, 12px); - white-space: nowrap; - cursor: pointer; - transition: - background var(--transition-base, 0.2s), - border-color var(--transition-base, 0.2s); -} - .b03-file__file-info { display: flex; flex-direction: column; @@ -186,9 +133,9 @@ } .b03-file__file-name { - color: var(--color-deep-iris, #26114a); - font-size: var(--text-body-sm, 14px); - font-weight: var(--font-weight-medium, 500); + color: var(--color-text); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-medium); overflow-wrap: anywhere; } @@ -204,36 +151,15 @@ display: flex; } -.b03-file__progress-bar-container { - width: 100%; - height: 6px; - overflow: hidden; - border-radius: var(--radius-pills, 1440px); - background: var(--color-mist, #e6e2e3); -} - -.b03-file__progress-bar { - width: 0; - height: 100%; - border-radius: var(--radius-pills, 1440px); - background: var(--color-royal-amethyst, #3e0079); - transition: width var(--transition-base, 0.2s); -} - .b03-file__progress-info { display: flex; flex-direction: column; gap: 2px; } -.b03-file__error-message { +/* 오류 띠(띠 알림 부품)는 오류 난 카드에서만 보인다 */ +.b03-file__card .b03-file__error-message { display: none; - padding: var(--spacing-8) var(--spacing-12); - border-radius: var(--radius-cards, 8px); - color: var(--color-danger, #dc2626); - background: #fef2f2; - font-size: var(--text-caption, 12px); - border: 1px solid rgba(220, 38, 38, 0.15); } .b03-file__card--empty .b03-file__file-info, @@ -260,9 +186,9 @@ width: 100%; height: 120px; overflow: hidden; - border: 1px solid var(--color-border, #dcdce6); - border-radius: var(--radius-cards, 8px); - background: var(--color-surface, #fff); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); } .b03-file__preview svg { @@ -274,7 +200,7 @@ /* 라이다 탑뷰 점 그림 — 점이 5천 개라 선 하나로 묶어 그린다. */ .b03-file__preview-cloud { fill: none; - stroke: var(--color-text-muted, #5a5a72); + stroke: var(--color-text-muted); stroke-linecap: round; stroke-opacity: 0.55; stroke-width: 0.7; @@ -286,7 +212,7 @@ .b03-file__preview-thumb { max-width: 100%; max-height: 100%; - border: 1px dashed var(--color-border, #9aa); + border: 1px dashed var(--color-border); image-rendering: pixelated; object-fit: contain; } @@ -302,24 +228,24 @@ } .b03-file__preview-fact { - color: var(--color-text, #1c1c28); + color: var(--color-text); font-size: var(--text-body, 15px); font-weight: 600; } .b03-file__preview-fact-sub { - color: var(--color-text-muted, #5a5a72); - font-size: var(--text-caption, 12px); + color: var(--color-text-muted); + font-size: var(--text-caption); } .b03-file__preview-empty { - color: var(--color-text-muted, #8a8aa0); - font-size: var(--text-caption, 12px); + color: var(--color-text-muted); + font-size: var(--text-caption); } .b03-file__preview-extent { - fill: color-mix(in srgb, var(--color-border, #9aa) 12%, transparent); - stroke: var(--color-border, #9aa); + fill: color-mix(in srgb, var(--color-border) 12%, transparent); + stroke: var(--color-border); stroke-dasharray: 4 3; stroke-width: 1; } @@ -328,7 +254,7 @@ fill: none; /* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서 붉은색으로 덮어써 경고로 쓴다. */ - stroke: var(--color-success, #16a34a); + stroke: var(--color-success); stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.5; @@ -336,11 +262,11 @@ /* 계획노선이 지형 자료 범위를 벗어난 경우 — 눈으로 바로 잡히게 경고색. */ .b03-file__preview.is-warning .b03-file__preview-route { - stroke: var(--color-danger, #d33); + stroke: var(--color-danger); } .b03-file__preview.is-warning .b03-file__preview-extent { - stroke: var(--color-danger, #d33); + stroke: var(--color-danger); stroke-dasharray: 3 2; } @@ -355,7 +281,7 @@ } .b03-file__card--error .b03-file__error-message { - display: block; + display: flex; } .b03-file__card--error .b03-file__progress-section { diff --git a/B03_FileInput/B03_FileInput_UI_Style_Temp.css b/B03_FileInput/B03_FileInput_UI_Style_Temp.css index 11edd0e06..c9aa2a262 100644 --- a/B03_FileInput/B03_FileInput_UI_Style_Temp.css +++ b/B03_FileInput/B03_FileInput_UI_Style_Temp.css @@ -68,7 +68,7 @@ .b03-file__temp-option.is-selected { border-color: var(--color-primary); - background: var(--color-mist-violet); + background: var(--color-accent-soft); } .b03-file__temp-option-files { diff --git a/B03_FileInput/B03_FileInput_UI_Support.ts b/B03_FileInput/B03_FileInput_UI_Support.ts index 9caaa008f..79f7b3891 100644 --- a/B03_FileInput/B03_FileInput_UI_Support.ts +++ b/B03_FileInput/B03_FileInput_UI_Support.ts @@ -227,7 +227,7 @@ export function createFileCardTemplate(): HTMLTemplateElement { const template = document.createElement("template"); template.id = "file-card-template"; template.innerHTML = ` -
+
@@ -235,8 +235,8 @@ export function createFileCardTemplate(): HTMLTemplateElement {
- - + +
@@ -246,16 +246,14 @@ export function createFileCardTemplate(): HTMLTemplateElement {
-
-
-
+
- +
`; diff --git a/resources/tester/test_553_1_pages.py b/resources/tester/test_553_1_pages.py new file mode 100644 index 000000000..8d28ada8d --- /dev/null +++ b/resources/tester/test_553_1_pages.py @@ -0,0 +1,59 @@ +"""553-1 B02 · B03 · B04 · B07 페이지 부품 옮김 지킴 시험 — 자체 변종이 부품으로 바뀐 자리 · 가이드 촬영 선택자 · 날 색 없음.""" + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OLD_ALIAS = re.compile(r"--color-(royal-amethyst|deep-iris|mist-violet|plum-velvet|lavender-wash)") +RAW_COLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b|\brgba?\(") + + +def _src(folder: str, suffix: str) -> dict[str, str]: + return {p.name: p.read_text(encoding="utf-8") for p in (ROOT / folder).glob(f"*{suffix}")} + + +def _guide_selectors(page: str, prefix: str) -> set[str]: + text = (ROOT / f"A05_EduDetail/guide/{page}/_scenes.json").read_text(encoding="utf-8") + return set(re.findall(rf"\.{re.escape(prefix)}[a-z0-9_-]+", text)) + + +def _defined(folder: str, name: str) -> bool: + body = "".join(_src(folder, ".ts").values()) + "".join(_src(folder, ".css").values()) + return name.lstrip(".") in body + + +def test_b03_guide_selectors_alive(): + """가이드 촬영 장면이 잡는 B03 클래스는 소스에 살아 있어야 함.""" + for sel in _guide_selectors("B03", "b03-file__"): + assert _defined("B03_FileInput", sel), f"가이드 선택자 {sel} 가 B03 소스에 없음" + + +def test_b03_uses_parts(): + ts = "".join(_src("B03_FileInput", ".ts").values()) + for part in ( + "createBanner", + "createProgressBar", + "createToggle", + "createPanelHead", + "createIconButton", + "createText", + ): + assert part in ts, f"B03 이 부품 {part} 를 안 씀" + support = _src("B03_FileInput", ".ts")["B03_FileInput_UI_Support.ts"] + assert " Date: Mon, 26 Oct 2026 21:56:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor(=EA=B3=B5=EA=B0=9C=ED=99=94?= =?UTF-8?q?=EB=A9=B4):=20A01~A09=20=C2=B7=20B10=20=C2=B7=20B11=20=EC=9D=84?= =?UTF-8?q?=20=EA=B3=B5=EA=B0=9C=20=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=C2=B7=20=EC=95=8C=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=C2=B7=20=EC=B2=B4=ED=81=AC=20=C2=B7=20?= =?UTF-8?q?=EC=A0=91=EC=9D=B4=20=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C=20(PL?= =?UTF-8?q?AN=20553-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A01~A05 hero 다섯 벌 · 구역 제목 복제 CSS 걷고 공개 화면 틀 한 벌(wide 옵션 · 구역 제목 가운데 · 머리 아랫줄은 자리 안 먹는 그림자) - 카드 = 카드 부품(로그인 · 가입 · 지원 · 약관 · 기능 카드 · 소식 줄 · B11 단계 카드) · 지원 성공 · B10 안내 = 띠 알림 · 태그 = 배지 - A07 약관 날 체크 · 날 details = 체크 + 접이 부품 · A08 날 textarea = 입력 여러 줄 · 빈 소식 = 빈 상태 - 죽은 A07_Register_UI_Page · 쓰이지 않는 CSS 걷음 · 띠 알림 hidden 받음 - 지킴 시험 test_553_7_public_pages Co-Authored-By: Claude Sonnet 5.5 --- A01_Home/A01_Home_UI_Page.ts | 100 ++++--------- A01_Home/A01_Home_UI_Style.css | 82 +---------- A02_ProgDetail/A02_ProgDetail_UI_Page.ts | 89 ++++-------- A02_ProgDetail/A02_ProgDetail_UI_Style.css | 80 +---------- A03_CompDetail/A03_CompDetail_UI_Page.ts | 75 +++------- A03_CompDetail/A03_CompDetail_UI_Style.css | 67 +-------- A04_NewsHistory/A04_NewsHistory_UI_Page.ts | 64 +++------ A04_NewsHistory/A04_NewsHistory_UI_Style.css | 72 +--------- A05_EduDetail/A05_EduDetail_UI_Page.ts | 74 +++------- A05_EduDetail/A05_EduDetail_UI_Style.css | 63 +-------- A06_Login/A06_Login_UI_Auth_Page.ts | 6 +- A06_Login/A06_Login_UI_Style.css | 24 +--- A07_Register/A07_Register_UI_Auth_Page.ts | 41 +++--- A07_Register/A07_Register_UI_Page.ts | 141 ------------------- A07_Register/A07_Register_UI_Style.css | 51 +------ A08_Support/A08_Support_UI_Page.ts | 70 ++------- A08_Support/A08_Support_UI_Style.css | 28 +--- A09_Security/A09_Security_UI_Page.ts | 9 +- A09_Security/A09_Security_UI_Style.css | 9 +- B10_Payment/B10_Payment_UI_Page.ts | 10 +- B10_Payment/B10_Payment_UI_Style.css | 8 -- B11_Status/B11_Status_UI_Page.ts | 15 +- B11_Status/B11_Status_UI_Style.css | 12 +- resources/tester/test_553_7_public_pages.py | 117 +++++++++++++++ ui_template/ui_template_banner.css | 3 + ui_template/ui_template_public_layout.css | 11 +- ui_template/ui_template_public_layout.ts | 5 +- 27 files changed, 338 insertions(+), 988 deletions(-) delete mode 100644 A07_Register/A07_Register_UI_Page.ts create mode 100644 resources/tester/test_553_7_public_pages.py diff --git a/A01_Home/A01_Home_UI_Page.ts b/A01_Home/A01_Home_UI_Page.ts index 110f0c492..53cb011d7 100644 --- a/A01_Home/A01_Home_UI_Page.ts +++ b/A01_Home/A01_Home_UI_Page.ts @@ -5,12 +5,15 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A01_Home_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createCard/createTag) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createBadge/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createCard, createTag } from "@ui/ui_template_elements"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import { navigateTo } from "../A00_Common/router"; import { ROUTES } from "@config/config_frontend"; import "./A01_Home_UI_Style.css"; @@ -49,57 +52,12 @@ function onA01_Home_Explore_Click(): void { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a01-hero"; - - const inner = document.createElement("div"); - inner.className = "a01-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a01-hero__title"; - title.textContent = L("A01_Home_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a01-hero__subtitle"; - subtitle.textContent = L("A01_Home_Hero_Subtitle"); - - const ctas = document.createElement("div"); - ctas.className = "a01-hero__ctas"; - ctas.append( - createButton({ - label: L("A01_Home_Hero_CtaPrimary"), - variant: "filled", - onClick: onA01_Home_GetStarted_Click, - }), - createButton({ - label: L("A01_Home_Hero_CtaSecondary"), - variant: "ghost", - onClick: onA01_Home_Explore_Click, - }), - ); - - inner.append(title, subtitle, ctas); - hero.append(inner); - return hero; -} - function buildNews(): HTMLElement { - const section = document.createElement("section"); - section.className = "a01-section"; - - const heading = document.createElement("h2"); - heading.className = "a01-section__title"; - heading.textContent = L("A01_Home_News_SectionTitle"); - const list = document.createElement("div"); list.className = "a01-news-list"; for (const item of MOCK_NEWS) { - const row = document.createElement("div"); - row.className = "a01-news-row"; - - const tag = createTag(L("A01_Home_News_Tag"), "accent"); + const tag = createBadge(L("A01_Home_News_Tag"), "accent"); const titleEl = document.createElement("span"); titleEl.className = "a01-news-row__title"; titleEl.textContent = item.title; @@ -107,22 +65,14 @@ function buildNews(): HTMLElement { dateEl.className = "a01-news-row__date"; dateEl.textContent = item.date; - row.append(tag, titleEl, dateEl); + const row = createCard({ body: [tag, titleEl, dateEl] }); + row.classList.add("a01-news-row"); list.append(row); } - - section.append(heading, list); - return section; + return list; } function buildFeatures(): HTMLElement { - const section = document.createElement("section"); - section.className = "a01-section"; - - const heading = document.createElement("h2"); - heading.className = "a01-section__title"; - heading.textContent = L("A01_Home_Features_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a01-feature-grid"; @@ -133,24 +83,34 @@ function buildFeatures(): HTMLElement { ]; for (const [titleKey, descKey] of features) { - const desc = document.createElement("p"); - desc.className = "a01-feature__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a01-feature-card"); grid.append(card); } - - section.append(heading, grid); - return section; + return grid; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA01Home(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a01-home"; - page.append(buildHero(), buildNews(), buildFeatures()); - root.append(page); + const frame = createPublicFrame({ + title: L("A01_Home_Title"), + lead: L("A01_Home_Hero_Subtitle"), + actions: [ + createButton({ + label: L("A01_Home_Hero_CtaPrimary"), + variant: "filled", + onClick: onA01_Home_GetStarted_Click, + }), + createButton({ + label: L("A01_Home_Hero_CtaSecondary"), + variant: "ghost", + onClick: onA01_Home_Explore_Click, + }), + ], + }); + frame.addSection(L("A01_Home_News_SectionTitle")).append(buildNews()); + frame.addSection(L("A01_Home_Features_SectionTitle")).append(buildFeatures()); + root.append(frame.root); } diff --git a/A01_Home/A01_Home_UI_Style.css b/A01_Home/A01_Home_UI_Style.css index bd3a21451..bc3bf0878 100644 --- a/A01_Home/A01_Home_UI_Style.css +++ b/A01_Home/A01_Home_UI_Style.css @@ -1,66 +1,8 @@ /* ============================================================================= * A01_Home_UI_Style.css - * 홈 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 홈 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a01-home { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a01-hero { - /* 라벤더 그라디언트는 흰 표면 아래에만: 히어로 배경으로 사용 */ - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a01-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a01-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 14ch; -} -.a01-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 52ch; - margin: 0; -} -.a01-hero__ctas { - display: flex; - gap: var(--spacing-16); - margin-top: var(--spacing-8); -} - -/* --- 공통 섹션 --- */ -.a01-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a01-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; -} - /* --- News --- */ .a01-news-list { display: flex; @@ -68,21 +10,20 @@ gap: var(--spacing-8); } .a01-news-row { - display: flex; + padding: var(--spacing-16) var(--spacing-24); +} +.a01-news-row > .ui-card__body { + flex-direction: row; align-items: center; gap: var(--spacing-16); - padding: var(--spacing-16) var(--spacing-24); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); } .a01-news-row__title { flex: 1; - color: var(--color-charcoal); + color: var(--color-text); font-size: var(--text-body-sm); } .a01-news-row__date { - color: var(--color-ash); + color: var(--color-text-muted); font-size: var(--text-caption); } @@ -95,18 +36,9 @@ .a01-feature-card { min-height: 140px; } -.a01-feature__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} @media (max-width: 900px) { .a01-feature-grid { grid-template-columns: 1fr; } - .a01-hero__title { - font-size: var(--text-heading); - } } diff --git a/A02_ProgDetail/A02_ProgDetail_UI_Page.ts b/A02_ProgDetail/A02_ProgDetail_UI_Page.ts index 595fd2494..d87533d79 100644 --- a/A02_ProgDetail/A02_ProgDetail_UI_Page.ts +++ b/A02_ProgDetail/A02_ProgDetail_UI_Page.ts @@ -5,12 +5,14 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A02_ProgDetail_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createCard) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import { navigateTo } from "../A00_Common/router"; import { ROUTES } from "@config/config_frontend"; import "./A02_ProgDetail_UI_Style.css"; @@ -30,40 +32,7 @@ function onA02_ProgDetail_Start_Click(): void { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a02-hero"; - - const inner = document.createElement("div"); - inner.className = "a02-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a02-hero__title"; - title.textContent = L("A02_ProgDetail_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a02-hero__subtitle"; - subtitle.textContent = L("A02_ProgDetail_Hero_Subtitle"); - - const cta = createButton({ - label: L("A02_ProgDetail_Hero_Cta"), - variant: "filled", - onClick: onA02_ProgDetail_Start_Click, - }); - - inner.append(title, subtitle, cta); - hero.append(inner); - return hero; -} - function buildWorkflow(): HTMLElement { - const section = document.createElement("section"); - section.className = "a02-section"; - - const heading = document.createElement("h2"); - heading.className = "a02-section__title"; - heading.textContent = L("A02_ProgDetail_Workflow_SectionTitle"); - const list = document.createElement("div"); list.className = "a02-workflow-list"; @@ -77,9 +46,6 @@ function buildWorkflow(): HTMLElement { ]; steps.forEach(([titleKey, descKey], index) => { - const row = document.createElement("div"); - row.className = "a02-workflow-row"; - const badge = document.createElement("span"); badge.className = "a02-workflow-row__badge"; badge.textContent = String(index + 1); @@ -91,27 +57,15 @@ function buildWorkflow(): HTMLElement { stepTitle.className = "a02-workflow-row__title"; stepTitle.textContent = L(titleKey); - const stepDesc = document.createElement("p"); - stepDesc.className = "a02-workflow-row__desc"; - stepDesc.textContent = L(descKey); - - text.append(stepTitle, stepDesc); - row.append(badge, text); + text.append(stepTitle, createText(L(descKey), "muted", 14)); + const row = createCard({ body: [badge, text] }); + row.classList.add("a02-workflow-row"); list.append(row); }); - - section.append(heading, list); - return section; + return list; } function buildTech(): HTMLElement { - const section = document.createElement("section"); - section.className = "a02-section"; - - const heading = document.createElement("h2"); - heading.className = "a02-section__title"; - heading.textContent = L("A02_ProgDetail_Tech_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a02-tech-grid"; @@ -122,24 +76,31 @@ function buildTech(): HTMLElement { ]; for (const [titleKey, descKey] of techs) { - const desc = document.createElement("p"); - desc.className = "a02-tech__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a02-tech-card"); grid.append(card); } - - section.append(heading, grid); - return section; + return grid; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA02ProgDetail(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a02-prog-detail"; - page.append(buildHero(), buildWorkflow(), buildTech()); - root.append(page); + const frame = createPublicFrame({ + title: L("A02_ProgDetail_Title"), + lead: L("A02_ProgDetail_Hero_Subtitle"), + actions: [ + createButton({ + label: L("A02_ProgDetail_Hero_Cta"), + variant: "filled", + onClick: onA02_ProgDetail_Start_Click, + }), + ], + wide: true, + }); + frame.root.classList.add("a02-prog-detail"); + frame.addSection(L("A02_ProgDetail_Workflow_SectionTitle")).append(buildWorkflow()); + frame.addSection(L("A02_ProgDetail_Tech_SectionTitle")).append(buildTech()); + root.append(frame.root); } diff --git a/A02_ProgDetail/A02_ProgDetail_UI_Style.css b/A02_ProgDetail/A02_ProgDetail_UI_Style.css index 0e6d57f2c..7b8d85415 100644 --- a/A02_ProgDetail/A02_ProgDetail_UI_Style.css +++ b/A02_ProgDetail/A02_ProgDetail_UI_Style.css @@ -1,58 +1,11 @@ /* ============================================================================= * A02_ProgDetail_UI_Style.css - * 프로그램 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 프로그램 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a02-prog-detail { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a02-hero { - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a02-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a02-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 18ch; -} -.a02-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 56ch; - margin: 0; -} - -/* --- 공통 섹션 --- */ -.a02-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a02-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; +/* 단추 하나뿐인 머리 — 단추 줄 위 여백 없음 */ +.a02-prog-detail .ui-public-frame__actions { + margin-top: 0; } /* --- Workflow (번호 배지 + 텍스트) --- */ @@ -61,14 +14,10 @@ flex-direction: column; gap: var(--spacing-16); } -.a02-workflow-row { - display: flex; +.a02-workflow-row > .ui-card__body { + flex-direction: row; align-items: flex-start; gap: var(--spacing-24); - padding: var(--spacing-24); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); } .a02-workflow-row__badge { flex-shrink: 0; @@ -90,15 +39,9 @@ } .a02-workflow-row__title { font-size: var(--text-body); - color: var(--color-charcoal); + color: var(--color-text); margin: 0; } -.a02-workflow-row__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} /* --- Tech --- */ .a02-tech-grid { @@ -109,18 +52,9 @@ .a02-tech-card { min-height: 140px; } -.a02-tech__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} @media (max-width: 900px) { .a02-tech-grid { grid-template-columns: 1fr; } - .a02-hero__title { - font-size: var(--text-heading); - } } diff --git a/A03_CompDetail/A03_CompDetail_UI_Page.ts b/A03_CompDetail/A03_CompDetail_UI_Page.ts index 0e1bb5bf8..024508d5e 100644 --- a/A03_CompDetail/A03_CompDetail_UI_Page.ts +++ b/A03_CompDetail/A03_CompDetail_UI_Page.ts @@ -5,11 +5,13 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A03_CompDetail_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createCard) 우선 사용. + * - 공통 부품(createPublicFrame/createCard) 우선 사용. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import "./A03_CompDetail_UI_Style.css"; /** locale 헬퍼 */ @@ -20,50 +22,14 @@ function L(key: keyof typeof ui_locales): string { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a03-hero"; - - const inner = document.createElement("div"); - inner.className = "a03-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a03-hero__title"; - title.textContent = L("A03_CompDetail_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a03-hero__subtitle"; - subtitle.textContent = L("A03_CompDetail_Hero_Subtitle"); - - inner.append(title, subtitle); - hero.append(inner); - return hero; -} - function buildMission(): HTMLElement { - const section = document.createElement("section"); - section.className = "a03-section"; - - const heading = document.createElement("h2"); - heading.className = "a03-section__title"; - heading.textContent = L("A03_CompDetail_Mission_SectionTitle"); - const body = document.createElement("p"); body.className = "a03-mission__body"; body.textContent = L("A03_CompDetail_Mission_Body"); - - section.append(heading, body); - return section; + return body; } function buildValues(): HTMLElement { - const section = document.createElement("section"); - section.className = "a03-section"; - - const heading = document.createElement("h2"); - heading.className = "a03-section__title"; - heading.textContent = L("A03_CompDetail_Value_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a03-value-grid"; @@ -74,40 +40,31 @@ function buildValues(): HTMLElement { ]; for (const [titleKey, descKey] of values) { - const desc = document.createElement("p"); - desc.className = "a03-value__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a03-value-card"); grid.append(card); } - - section.append(heading, grid); - return section; + return grid; } function buildContact(): HTMLElement { - const section = document.createElement("section"); - section.className = "a03-section"; - - const heading = document.createElement("h2"); - heading.className = "a03-section__title"; - heading.textContent = L("A03_CompDetail_Contact_SectionTitle"); - const email = document.createElement("p"); email.className = "a03-contact__email"; email.textContent = L("A03_CompDetail_Contact_Email"); - - section.append(heading, email); - return section; + return email; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA03CompDetail(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a03-comp-detail"; - page.append(buildHero(), buildMission(), buildValues(), buildContact()); - root.append(page); + const frame = createPublicFrame({ + title: L("A03_CompDetail_Title"), + lead: L("A03_CompDetail_Hero_Subtitle"), + wide: true, + }); + frame.addSection(L("A03_CompDetail_Mission_SectionTitle")).append(buildMission()); + frame.addSection(L("A03_CompDetail_Value_SectionTitle")).append(buildValues()); + frame.addSection(L("A03_CompDetail_Contact_SectionTitle")).append(buildContact()); + root.append(frame.root); } diff --git a/A03_CompDetail/A03_CompDetail_UI_Style.css b/A03_CompDetail/A03_CompDetail_UI_Style.css index 5fb77e907..97ac0c55f 100644 --- a/A03_CompDetail/A03_CompDetail_UI_Style.css +++ b/A03_CompDetail/A03_CompDetail_UI_Style.css @@ -1,66 +1,14 @@ /* ============================================================================= * A03_CompDetail_UI_Style.css - * 회사 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 회사 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a03-comp-detail { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a03-hero { - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a03-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a03-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 18ch; -} -.a03-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 56ch; - margin: 0; -} - -/* --- 공통 섹션 --- */ -.a03-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a03-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; -} - /* --- Mission --- */ .a03-mission__body { margin: 0 auto; max-width: 60ch; text-align: center; - color: var(--color-slate); + color: var(--color-text-secondary); font-size: var(--text-body); line-height: var(--leading-body); } @@ -74,18 +22,12 @@ .a03-value-card { min-height: 140px; } -.a03-value__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} /* --- Contact --- */ .a03-contact__email { margin: 0; text-align: center; - color: var(--color-charcoal); + color: var(--color-text); font-size: var(--text-body); } @@ -93,7 +35,4 @@ .a03-value-grid { grid-template-columns: 1fr; } - .a03-hero__title { - font-size: var(--text-heading); - } } diff --git a/A04_NewsHistory/A04_NewsHistory_UI_Page.ts b/A04_NewsHistory/A04_NewsHistory_UI_Page.ts index ab4dbadab..d237b8275 100644 --- a/A04_NewsHistory/A04_NewsHistory_UI_Page.ts +++ b/A04_NewsHistory/A04_NewsHistory_UI_Page.ts @@ -5,12 +5,15 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A04_NewsHistory_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createTag) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createBadge) 우선 사용. * - Mock 데이터는 나중에 GET /api/a04/history로 교체. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createTag, type TagVariant } from "@ui/ui_template_elements"; +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createCard } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; import "./A04_NewsHistory_UI_Style.css"; /** locale 헬퍼 */ @@ -38,7 +41,7 @@ const MOCK_HISTORY: HistoryItem[] = [ ]; /** 이력 종류별 태그 변형/라벨 매핑 */ -const KIND_META: Record = { +const KIND_META: Record = { feature: { variant: "accent", labelKey: "A04_NewsHistory_Tag_Feature" }, fix: { variant: "success", labelKey: "A04_NewsHistory_Tag_Fix" }, notice: { variant: "neutral", labelKey: "A04_NewsHistory_Tag_Notice" }, @@ -47,47 +50,14 @@ const KIND_META: Record .ui-card__body { + flex-direction: row; align-items: center; gap: var(--spacing-16); - padding: var(--spacing-16) var(--spacing-24); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); } .a04-timeline-row__title { flex: 1; - color: var(--color-charcoal); + color: var(--color-text); font-size: var(--text-body-sm); } .a04-timeline-row__date { - color: var(--color-ash); + color: var(--color-text-muted); font-size: var(--text-caption); } -.a04-timeline__empty { - text-align: center; - color: var(--color-slate); - font-size: var(--text-body); -} - -@media (max-width: 900px) { - .a04-hero__title { - font-size: var(--text-heading); - } -} diff --git a/A05_EduDetail/A05_EduDetail_UI_Page.ts b/A05_EduDetail/A05_EduDetail_UI_Page.ts index 2012da88e..b12a561ae 100644 --- a/A05_EduDetail/A05_EduDetail_UI_Page.ts +++ b/A05_EduDetail/A05_EduDetail_UI_Page.ts @@ -5,12 +5,14 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A05_EduDetail_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createCard) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import { navigateTo } from "../A00_Common/router"; import { ROUTES } from "@config/config_frontend"; import "./A05_EduDetail_UI_Style.css"; @@ -30,34 +32,7 @@ function onA05_EduDetail_Inquiry_Click(): void { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a05-hero"; - - const inner = document.createElement("div"); - inner.className = "a05-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a05-hero__title"; - title.textContent = L("A05_EduDetail_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a05-hero__subtitle"; - subtitle.textContent = L("A05_EduDetail_Hero_Subtitle"); - - inner.append(title, subtitle); - hero.append(inner); - return hero; -} - function buildCourses(): HTMLElement { - const section = document.createElement("section"); - section.className = "a05-section"; - - const heading = document.createElement("h2"); - heading.className = "a05-section__title"; - heading.textContent = L("A05_EduDetail_Course_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a05-course-grid"; @@ -68,38 +43,31 @@ function buildCourses(): HTMLElement { ]; for (const [titleKey, descKey] of courses) { - const desc = document.createElement("p"); - desc.className = "a05-course__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a05-course-card"); grid.append(card); } - - section.append(heading, grid); - return section; -} - -function buildCta(): HTMLElement { - const section = document.createElement("section"); - section.className = "a05-section a05-cta"; - - section.append( - createButton({ - label: L("A05_EduDetail_Cta"), - variant: "filled", - onClick: onA05_EduDetail_Inquiry_Click, - }), - ); - return section; + return grid; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA05EduDetail(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a05-edu-detail"; - page.append(buildHero(), buildCourses(), buildCta()); - root.append(page); + const frame = createPublicFrame({ + title: L("A05_EduDetail_Title"), + lead: L("A05_EduDetail_Hero_Subtitle"), + wide: true, + }); + frame.addSection(L("A05_EduDetail_Course_SectionTitle")).append(buildCourses()); + const cta = frame.addSection(); + cta.classList.add("a05-cta"); + cta.append( + createButton({ + label: L("A05_EduDetail_Cta"), + variant: "filled", + onClick: onA05_EduDetail_Inquiry_Click, + }), + ); + root.append(frame.root); } diff --git a/A05_EduDetail/A05_EduDetail_UI_Style.css b/A05_EduDetail/A05_EduDetail_UI_Style.css index cc025e28b..c36e0df2b 100644 --- a/A05_EduDetail/A05_EduDetail_UI_Style.css +++ b/A05_EduDetail/A05_EduDetail_UI_Style.css @@ -1,60 +1,8 @@ /* ============================================================================= * A05_EduDetail_UI_Style.css - * 교육 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 교육 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a05-edu-detail { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a05-hero { - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a05-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a05-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 18ch; -} -.a05-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 56ch; - margin: 0; -} - -/* --- 공통 섹션 --- */ -.a05-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a05-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; -} - /* --- Courses --- */ .a05-course-grid { display: grid; @@ -64,12 +12,6 @@ .a05-course-card { min-height: 140px; } -.a05-course__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} /* --- CTA --- */ .a05-cta { @@ -80,7 +22,4 @@ .a05-course-grid { grid-template-columns: 1fr; } - .a05-hero__title { - font-size: var(--text-heading); - } } diff --git a/A06_Login/A06_Login_UI_Auth_Page.ts b/A06_Login/A06_Login_UI_Auth_Page.ts index 45e92a9af..36497618f 100644 --- a/A06_Login/A06_Login_UI_Auth_Page.ts +++ b/A06_Login/A06_Login_UI_Auth_Page.ts @@ -2,6 +2,7 @@ import { ROUTES } from "@config/config_frontend"; import { isBlank, isValidEmail } from "@util/common_util_validate"; import { createButton, + createCard, createInputField, hideLoadingOverlay, showLoadingOverlay, @@ -67,8 +68,6 @@ function clearOtpPending(): void { export function renderA06Login(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a06-login"; - const card = document.createElement("div"); - card.className = "a06-login__card"; const title = document.createElement("h1"); title.className = "a06-login__title"; title.textContent = L("A06_Login_Title"); @@ -215,7 +214,8 @@ export function renderA06Login(root: HTMLElement): void { variant: "ghost", onClick: () => navigateTo(ROUTES.A07_REGISTER), }); - card.append(title, form, register); + const card = createCard({ body: [title, form, register] }); + card.classList.add("a06-login__card"); page.append(card); root.append(page); diff --git a/A06_Login/A06_Login_UI_Style.css b/A06_Login/A06_Login_UI_Style.css index b88717ea5..9a56c358f 100644 --- a/A06_Login/A06_Login_UI_Style.css +++ b/A06_Login/A06_Login_UI_Style.css @@ -1,6 +1,6 @@ /* ============================================================================= * A06_Login_UI_Style.css - * 로그인 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 로그인 페이지 고유 배치 — 카드 겉은 공용 부품(카드)이 냄. * ========================================================================== */ .a06-login { @@ -13,29 +13,16 @@ .a06-login__card { width: 100%; max-width: 400px; - display: flex; - flex-direction: column; - gap: var(--spacing-16); padding: var(--spacing-40); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); } .a06-login__title { font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); + color: var(--color-text); text-align: center; margin: 0; } -.a06-login__subtitle { - font-size: var(--text-body-sm); - color: var(--color-slate); - text-align: center; - margin: 0 0 var(--spacing-8); -} .a06-login__form { display: flex; @@ -50,10 +37,3 @@ .a06-login__otp-actions > .ui-btn { flex: 1; } -.a06-login__submit { - width: 100%; - margin-top: var(--spacing-8); -} -.a06-login__link { - width: 100%; -} diff --git a/A07_Register/A07_Register_UI_Auth_Page.ts b/A07_Register/A07_Register_UI_Auth_Page.ts index 4dd8a8097..a86591cfd 100644 --- a/A07_Register/A07_Register_UI_Auth_Page.ts +++ b/A07_Register/A07_Register_UI_Auth_Page.ts @@ -2,11 +2,14 @@ import { ROUTES } from "@config/config_frontend"; import { hasMinLength, isBlank, isValidEmail } from "@util/common_util_validate"; import { createButton, + createCard, createInputField, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { navigateTo } from "../A00_Common/router"; import { requestRegistration, verifyRegistration } from "./A07_Register_Api_Fetch"; @@ -22,38 +25,29 @@ interface AgreementField { function checkboxWithAccordion(label: string, contentText: string): AgreementField { const container = document.createElement("div"); - container.className = "a07-register__agreement"; + container.className = "ui-collapsible is-collapsed a07-register__agreement"; const summary = document.createElement("div"); - summary.className = "a07-register__agreement-summary"; + summary.className = "ui-collapsible__title a07-register__agreement-summary"; - const input = document.createElement("input"); - input.type = "checkbox"; + const box = createCheckbox(""); + // 체크 상자를 누를 땐 접힘이 바뀌지 않게 — 줄의 나머지를 누르면 약관이 펴짐 + box.root.addEventListener("click", (e) => e.stopPropagation()); const labelSpan = document.createElement("span"); labelSpan.className = "a07-register__agreement-label"; labelSpan.textContent = label; - summary.append(input, labelSpan); + summary.append(box.root, labelSpan); - const details = document.createElement("details"); - details.className = "a07-register__agreement-details"; + const content = document.createElement("div"); + content.className = "a07-register__agreement-content"; + content.textContent = contentText; - const detailsContent = document.createElement("div"); - detailsContent.className = "a07-register__agreement-content"; - detailsContent.textContent = contentText; + container.append(summary, content); + attachCollapsible(container); - details.appendChild(detailsContent); - - summary.addEventListener("click", (e) => { - if (e.target !== input) { - details.open = !details.open; - } - }); - - container.append(summary, details); - - return { root: container, input }; + return { root: container, input: box.input }; } async function loadAgreementText(filename: string): Promise { @@ -69,8 +63,6 @@ async function loadAgreementText(filename: string): Promise { export function renderA07Register(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a07-register"; - const card = document.createElement("div"); - card.className = "a07-register__card"; const title = document.createElement("h1"); title.className = "a07-register__title"; title.textContent = L("A07_Register_Title"); @@ -210,7 +202,8 @@ export function renderA07Register(root: HTMLElement): void { variant: "ghost", onClick: () => navigateTo(ROUTES.A06_LOGIN), }); - card.append(title, form, login); + const card = createCard({ body: [title, form, login] }); + card.classList.add("a07-register__card"); page.append(card); root.append(page); }); diff --git a/A07_Register/A07_Register_UI_Page.ts b/A07_Register/A07_Register_UI_Page.ts deleted file mode 100644 index c9adb23be..000000000 --- a/A07_Register/A07_Register_UI_Page.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* ============================================================================= - * A07_Register_UI_Page.ts - * 로그인 전 07: 회원가입 (회사명 + 이름 + 이메일 + 비밀번호 폼) - * - * 제약 준수: - * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). - * - 색상/간격은 A07_Register_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createInputField) 우선 사용. - * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. - * - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4). - * ========================================================================== */ - -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField } from "@ui/ui_template_elements"; -import { isBlank, isValidEmail, hasMinLength } from "@util/common_util_validate"; -import { navigateTo } from "../A00_Common/router"; -import { ROUTES } from "@config/config_frontend"; -import "./A07_Register_UI_Style.css"; - -/** locale 헬퍼 */ -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/** 비밀번호 최소 길이 */ -const PASSWORD_MIN_LENGTH = 8; - -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ -export function renderA07Register(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a07-register"; - - const card = document.createElement("div"); - card.className = "a07-register__card"; - - const title = document.createElement("h1"); - title.className = "a07-register__title"; - title.textContent = L("A07_Register_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a07-register__subtitle"; - subtitle.textContent = L("A07_Register_Subtitle"); - - // 입력 필드 - const companyField = createInputField({ - label: L("A07_Register_Field_Company"), - placeholder: L("A07_Register_Field_Company_Placeholder"), - type: "text", - required: true, - }); - const nameField = createInputField({ - label: L("A07_Register_Field_Name"), - placeholder: L("A07_Register_Field_Name_Placeholder"), - type: "text", - required: true, - }); - const emailField = createInputField({ - label: L("A07_Register_Field_Email"), - placeholder: L("A07_Register_Field_Email_Placeholder"), - type: "email", - required: true, - }); - const passwordField = createInputField({ - label: L("A07_Register_Field_Password"), - placeholder: L("A07_Register_Field_Password_Placeholder"), - type: "password", - required: true, - }); - - const form = document.createElement("form"); - form.className = "a07-register__form"; - form.noValidate = true; - - const submitBtn = createButton({ - label: L("A07_Register_Submit"), - variant: "filled", - type: "submit", - }); - submitBtn.classList.add("a07-register__submit"); - - /* 이벤트 핸들러: 제출 시 1차 유효성 검사 */ - function onA07_Register_Submit_Click(event: Event): void { - event.preventDefault(); - [companyField, nameField, emailField, passwordField].forEach((f) => f.setError()); - - const company = companyField.input.value; - const name = nameField.input.value; - const email = emailField.input.value; - const password = passwordField.input.value; - - // 필수값 검사 - let hasError = false; - if (isBlank(company)) { - companyField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (isBlank(name)) { - nameField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (isBlank(email)) { - emailField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (isBlank(password)) { - passwordField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (hasError) return; - - // 형식 검사 - if (!isValidEmail(email)) { - emailField.setError(L("A07_Register_Error_Email")); - return; - } - if (!hasMinLength(password, PASSWORD_MIN_LENGTH)) { - passwordField.setError(L("A07_Register_Error_Password")); - return; - } - - // TODO: POST /api/a07/register 연동. 성공 시 로그인 페이지로. - navigateTo(ROUTES.A06_LOGIN); - } - - form.addEventListener("submit", onA07_Register_Submit_Click); - form.append(companyField.root, nameField.root, emailField.root, passwordField.root, submitBtn); - - // 로그인 링크 - const toLogin = createButton({ - label: L("A07_Register_ToLogin"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.A06_LOGIN), - }); - toLogin.classList.add("a07-register__link"); - - card.append(title, subtitle, form, toLogin); - page.append(card); - root.append(page); -} diff --git a/A07_Register/A07_Register_UI_Style.css b/A07_Register/A07_Register_UI_Style.css index baa1c21bb..745d864f7 100644 --- a/A07_Register/A07_Register_UI_Style.css +++ b/A07_Register/A07_Register_UI_Style.css @@ -1,6 +1,6 @@ /* ============================================================================= * A07_Register_UI_Style.css - * 회원가입 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 회원가입 페이지 고유 배치 — 카드 · 체크 · 접이 겉은 공용 부품이 냄. * ========================================================================== */ .a07-register { @@ -13,49 +13,22 @@ .a07-register__card { width: 100%; max-width: 420px; - display: flex; - flex-direction: column; - gap: var(--spacing-16); padding: var(--spacing-40); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); } .a07-register__title { font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); + color: var(--color-text); text-align: center; margin: 0; } -.a07-register__subtitle { - font-size: var(--text-body-sm); - color: var(--color-slate); - text-align: center; - margin: 0 0 var(--spacing-8); -} .a07-register__form { display: flex; flex-direction: column; gap: var(--spacing-16); } -.a07-register__submit { - width: 100%; - margin-top: var(--spacing-8); -} -.a07-register__link { - width: 100%; -} -.a07-register__check { - display: flex; - align-items: center; - gap: var(--spacing-8); - color: var(--color-text); - font-size: var(--text-body-sm); -} .a07-register__agreement { border: 1px solid var(--color-border); @@ -64,17 +37,9 @@ } .a07-register__agreement-summary { - display: flex; - align-items: center; gap: var(--spacing-8); padding: var(--spacing-12) var(--spacing-16); background-color: var(--color-canvas); - cursor: pointer; - user-select: none; -} - -.a07-register__agreement-summary input[type="checkbox"] { - flex-shrink: 0; } .a07-register__agreement-label { @@ -83,20 +48,14 @@ color: var(--color-text); } -.a07-register__agreement-details { - border-top: 1px solid var(--color-border); -} - -.a07-register__agreement-details[open] { - padding: var(--spacing-12) var(--spacing-16); -} - .a07-register__agreement-content { max-height: 200px; overflow-y: auto; + padding: var(--spacing-12) var(--spacing-16); + border-top: 1px solid var(--color-border); white-space: pre-wrap; word-break: break-word; font-size: var(--text-body-sm); - color: var(--color-slate); + color: var(--color-text-secondary); line-height: var(--leading-relaxed); } diff --git a/A08_Support/A08_Support_UI_Page.ts b/A08_Support/A08_Support_UI_Page.ts index 3f050a1eb..43d4ca2df 100644 --- a/A08_Support/A08_Support_UI_Page.ts +++ b/A08_Support/A08_Support_UI_Page.ts @@ -5,13 +5,14 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A08_Support_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createInputField) 우선 사용. + * - 공통 부품(createCard/createBanner/createInputField/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField } from "@ui/ui_template_elements"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton, createCard, createInputField } from "@ui/ui_template_elements"; import { isBlank, isValidEmail } from "@util/common_util_validate"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import "./A08_Support_UI_Style.css"; @@ -21,48 +22,6 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 문의내용 textarea 핸들 (createInputField와 유사한 형태로 에러 슬롯 포함) */ -interface TextAreaHandle { - root: HTMLDivElement; - textarea: HTMLTextAreaElement; - setError: (msg?: string) => void; -} - -/** 여러 줄 입력용 textarea 필드 생성 (공통 .ui-field/.ui-input 스타일 상속). */ -function createTextAreaField(label: string, placeholder: string): TextAreaHandle { - const root = document.createElement("div"); - root.className = "ui-field"; - - const labelEl = document.createElement("label"); - labelEl.className = "ui-field__label"; - labelEl.textContent = label; - - const textarea = document.createElement("textarea"); - textarea.className = "ui-input a08-support__textarea"; - textarea.placeholder = placeholder; - textarea.rows = 6; - - const errorSlot = document.createElement("span"); - errorSlot.className = "ui-field__error"; - errorSlot.setAttribute("role", "alert"); - - root.append(labelEl, textarea, errorSlot); - - const setError = (msg?: string): void => { - if (msg) { - textarea.classList.add("ui-input--error"); - errorSlot.textContent = msg; - errorSlot.classList.add("is-visible"); - } else { - textarea.classList.remove("ui-input--error"); - errorSlot.textContent = ""; - errorSlot.classList.remove("is-visible"); - } - }; - - return { root, textarea, setError }; -} - /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ @@ -70,9 +29,6 @@ export function renderA08Support(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a08-support"; - const card = document.createElement("div"); - card.className = "a08-support__card"; - const title = document.createElement("h1"); title.className = "a08-support__title"; title.textContent = L("A08_Support_Title"); @@ -82,9 +38,7 @@ export function renderA08Support(root: HTMLElement): void { subtitle.textContent = L("A08_Support_Subtitle"); // 성공 메시지 슬롯 (제출 성공 시 표시) - const successMsg = document.createElement("p"); - successMsg.className = "a08-support__success"; - successMsg.textContent = L("A08_Support_Success"); + const successMsg = createBanner({ tone: "success", text: L("A08_Support_Success") }); successMsg.hidden = true; // 입력 필드 @@ -113,10 +67,11 @@ export function renderA08Support(root: HTMLElement): void { type: "text", required: true, }); - const messageField = createTextAreaField( - L("A08_Support_Field_Message"), - L("A08_Support_Field_Message_Placeholder"), - ); + const messageField = createInputField({ + label: L("A08_Support_Field_Message"), + placeholder: L("A08_Support_Field_Message_Placeholder"), + multiline: 6, + }); const form = document.createElement("form"); form.className = "a08-support__form"; @@ -142,7 +97,7 @@ export function renderA08Support(root: HTMLElement): void { const email = emailField.input.value; const phone = phoneField.input.value; const subject = subjectField.input.value; - const message = messageField.textarea.value; + const message = messageField.input.value; // 필수값 검사 let hasError = false; @@ -184,7 +139,7 @@ export function renderA08Support(root: HTMLElement): void { throw new Error(error.detail ?? "요청 실패"); } form.reset(); - messageField.textarea.value = ""; + messageField.input.value = ""; successMsg.hidden = false; } catch (err) { const msg = err instanceof Error ? err.message : "오류 발생"; @@ -203,7 +158,8 @@ export function renderA08Support(root: HTMLElement): void { submitBtn, ); - card.append(title, subtitle, successMsg, form); + const card = createCard({ body: [title, subtitle, successMsg, form] }); + card.classList.add("a08-support__card"); page.append(card); root.append(page); diff --git a/A08_Support/A08_Support_UI_Style.css b/A08_Support/A08_Support_UI_Style.css index 07eaf5c2e..eb636bfad 100644 --- a/A08_Support/A08_Support_UI_Style.css +++ b/A08_Support/A08_Support_UI_Style.css @@ -1,6 +1,6 @@ /* ============================================================================= * A08_Support_UI_Style.css - * 기술지원 요청 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 기술지원 요청 페이지 고유 배치 — 카드 · 알림 · 칸 겉은 공용 부품이 냄. * ========================================================================== */ .a08-support { @@ -13,50 +13,28 @@ .a08-support__card { width: 100%; max-width: 520px; - display: flex; - flex-direction: column; - gap: var(--spacing-16); padding: var(--spacing-40); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); } .a08-support__title { font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); + color: var(--color-text); text-align: center; margin: 0; } .a08-support__subtitle { font-size: var(--text-body-sm); - color: var(--color-slate); + color: var(--color-text-secondary); text-align: center; margin: 0 0 var(--spacing-8); } -.a08-support__success { - margin: 0; - padding: var(--spacing-16); - text-align: center; - color: var(--color-success); - background-color: var(--color-mist-violet); - border-radius: var(--radius-cards); - font-size: var(--text-body-sm); -} - .a08-support__form { display: flex; flex-direction: column; gap: var(--spacing-16); } -.a08-support__textarea { - resize: vertical; - min-height: 120px; - font-family: inherit; -} .a08-support__submit { width: 100%; margin-top: var(--spacing-8); diff --git a/A09_Security/A09_Security_UI_Page.ts b/A09_Security/A09_Security_UI_Page.ts index d4c028ae6..814893194 100644 --- a/A09_Security/A09_Security_UI_Page.ts +++ b/A09_Security/A09_Security_UI_Page.ts @@ -1,3 +1,4 @@ +import { createCard } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { SECURITY_TERMS_VERSION, securityTerms } from "./A09_Security_Terms"; import "./A09_Security_UI_Style.css"; @@ -5,13 +6,11 @@ import "./A09_Security_UI_Style.css"; const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; export function renderA09Security(root: HTMLElement): void { - const page = document.createElement("article"); - page.className = "a09-security"; const title = document.createElement("h1"); title.textContent = L("A09_Security_Title"); const version = document.createElement("p"); version.textContent = `${L("A09_Security_Version")} ${SECURITY_TERMS_VERSION}`; - page.append(title, version); + const blocks: HTMLElement[] = [title, version]; const sections = [ ["A09_Security_Terms", securityTerms.terms], ["A09_Security_Privacy", securityTerms.privacy], @@ -24,7 +23,9 @@ export function renderA09Security(root: HTMLElement): void { const paragraph = document.createElement("p"); paragraph.textContent = body[currentLanguageIndex]; section.append(heading, paragraph); - page.append(section); + blocks.push(section); } + const page = createCard({ body: blocks }); + page.classList.add("a09-security"); root.append(page); } diff --git a/A09_Security/A09_Security_UI_Style.css b/A09_Security/A09_Security_UI_Style.css index 4b92c170b..d130ac6d2 100644 --- a/A09_Security/A09_Security_UI_Style.css +++ b/A09_Security/A09_Security_UI_Style.css @@ -1,12 +1,13 @@ +/* 약관 문서 — 카드 겉은 공용 부품(카드) · 여기는 폭 · 여백 · 글 흐름만 */ .a09-security { max-width: 800px; margin: var(--spacing-64) auto; padding: var(--spacing-40); color: var(--color-text); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); +} + +.a09-security > .ui-card__body { + display: block; } .a09-security section { diff --git a/B10_Payment/B10_Payment_UI_Page.ts b/B10_Payment/B10_Payment_UI_Page.ts index 3a6ddb7ef..973278451 100644 --- a/B10_Payment/B10_Payment_UI_Page.ts +++ b/B10_Payment/B10_Payment_UI_Page.ts @@ -1,4 +1,6 @@ -import { createButton, createTag } from "@ui/ui_template_elements"; +import { createBadge } from "@ui/ui_template_badge"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton } from "@ui/ui_template_elements"; import { section, table, text } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; @@ -13,15 +15,13 @@ export function renderB10Payment(root: HTMLElement): void { invoiceBody.className = "b10-payment__summary"; const invoiceDescription = document.createElement("p"); invoiceDescription.textContent = L("B10_Payment_Invoice_Description"); - invoiceBody.append(invoiceDescription, createTag(L("B10_Payment_Invoice_Status"), "neutral")); + invoiceBody.append(invoiceDescription, createBadge(L("B10_Payment_Invoice_Status"), "neutral")); const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [ createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }), ]); - const depositNote = document.createElement("p"); - depositNote.className = "b10-payment__note"; - depositNote.textContent = L("B10_Payment_Deposit_Note"); + const depositNote = createBanner({ tone: "info", text: L("B10_Payment_Deposit_Note") }); const depositBody = document.createElement("div"); depositBody.className = "b10-payment__summary"; depositBody.append( diff --git a/B10_Payment/B10_Payment_UI_Style.css b/B10_Payment/B10_Payment_UI_Style.css index 76d5a4378..c016deb8a 100644 --- a/B10_Payment/B10_Payment_UI_Style.css +++ b/B10_Payment/B10_Payment_UI_Style.css @@ -4,11 +4,3 @@ gap: var(--spacing-16); color: var(--color-text-body); } - -.b10-payment__note { - padding: var(--spacing-16); - border-radius: var(--radius-cards); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} diff --git a/B11_Status/B11_Status_UI_Page.ts b/B11_Status/B11_Status_UI_Page.ts index b4f079f26..ec50067ab 100644 --- a/B11_Status/B11_Status_UI_Page.ts +++ b/B11_Status/B11_Status_UI_Page.ts @@ -1,4 +1,5 @@ -import { createButton, createTag } from "@ui/ui_template_elements"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createCard } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; @@ -9,8 +10,9 @@ function L(key: keyof typeof ui_locales): string { } function buildStatusFlow(): HTMLElement { - const flow = document.createElement("ol"); + const flow = document.createElement("div"); flow.className = "b11-status__flow"; + flow.setAttribute("role", "list"); const steps = [ L("B11_Status_Step_Request"), L("B11_Status_Step_Issue"), @@ -18,11 +20,14 @@ function buildStatusFlow(): HTMLElement { L("B11_Status_Step_Complete"), ]; for (const label of steps) { - const item = document.createElement("li"); - item.className = "b11-status__step"; const title = document.createElement("strong"); title.textContent = label; - item.append(title, createTag(L("B11_Status_NotStarted"), "neutral")); + const item = createCard({ + body: [title, createBadge(L("B11_Status_NotStarted"), "neutral")], + pad: 16, + }); + item.classList.add("b11-status__step"); + item.setAttribute("role", "listitem"); flow.append(item); } return flow; diff --git a/B11_Status/B11_Status_UI_Style.css b/B11_Status/B11_Status_UI_Style.css index 9d87977fe..03ca7bb8b 100644 --- a/B11_Status/B11_Status_UI_Style.css +++ b/B11_Status/B11_Status_UI_Style.css @@ -2,20 +2,10 @@ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-16); - margin: 0; - padding: 0; - list-style: none; } -.b11-status__step { - display: flex; - flex-direction: column; +.b11-status__step > .ui-card__body { gap: var(--spacing-12, var(--spacing-8)); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - color: var(--color-text-body); } .b11-status__download { diff --git a/resources/tester/test_553_7_public_pages.py b/resources/tester/test_553_7_public_pages.py new file mode 100644 index 000000000..5a46c8aa8 --- /dev/null +++ b/resources/tester/test_553_7_public_pages.py @@ -0,0 +1,117 @@ +"""553-7 공개 화면(A01~A09) · 로그인 · B10 · B11 부품 옮김 지킴 시험 — 서버 · 화면 없이 소스로. + +· A01~A05 = 공개 화면 틀(createPublicFrame) 한 벌 — 페이지마다 hero · section 복제 CSS 없음 +· 카드 = createCard · 알림 = createBanner · 배지 = createBadge · 여러 줄 = createInputField(multiline) +· 페이지 CSS 에 날 색(#hex · rgb · 옛 보라 이름) 없음 · 죽은 A07_Register_UI_Page 없음 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + +PUBLIC_PAGES = { + "A01_Home/A01_Home_UI_Page.ts": ("A01_Home/A01_Home_UI_Style.css", "a01"), + "A02_ProgDetail/A02_ProgDetail_UI_Page.ts": ( + "A02_ProgDetail/A02_ProgDetail_UI_Style.css", + "a02", + ), + "A03_CompDetail/A03_CompDetail_UI_Page.ts": ( + "A03_CompDetail/A03_CompDetail_UI_Style.css", + "a03", + ), + "A04_NewsHistory/A04_NewsHistory_UI_Page.ts": ( + "A04_NewsHistory/A04_NewsHistory_UI_Style.css", + "a04", + ), + "A05_EduDetail/A05_EduDetail_UI_Page.ts": ("A05_EduDetail/A05_EduDetail_UI_Style.css", "a05"), +} +CARD_PAGES = [ + "A06_Login/A06_Login_UI_Auth_Page.ts", + "A07_Register/A07_Register_UI_Auth_Page.ts", + "A08_Support/A08_Support_UI_Page.ts", + "A09_Security/A09_Security_UI_Page.ts", +] +CSS_FILES = [css for css, _ in PUBLIC_PAGES.values()] + [ + "A06_Login/A06_Login_UI_Style.css", + "A07_Register/A07_Register_UI_Style.css", + "A08_Support/A08_Support_UI_Style.css", + "A09_Security/A09_Security_UI_Style.css", + "B10_Payment/B10_Payment_UI_Style.css", + "B11_Status/B11_Status_UI_Style.css", +] +RAW_COLOR = re.compile( + r"#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|var\(--(?:b01|draw)-|" + r"var\(--color-(?:deep-iris|mist-violet|slate|charcoal|ash|plum-velvet)\)" +) + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _no_comments(css: str) -> str: + return re.sub(r"/\*.*?\*/", "", css, flags=re.S) + + +@pytest.mark.parametrize("page", PUBLIC_PAGES) +def test_public_page_uses_frame(page: str) -> None: + src = _text(page) + assert "createPublicFrame" in src + for tag in ("hero", "__inner", "__subtitle", "__ctas"): + assert tag not in src, f"{page}: 손 hero 조각 {tag} 남음" + + +@pytest.mark.parametrize("page", PUBLIC_PAGES) +def test_public_css_has_no_hero_copy(page: str) -> None: + css = _no_comments(_text(PUBLIC_PAGES[page][0])) + prefix = PUBLIC_PAGES[page][1] + assert f".{prefix}-hero" not in css + assert f".{prefix}-section" not in css + + +@pytest.mark.parametrize("page", CARD_PAGES) +def test_card_pages_use_card_part(page: str) -> None: + assert "createCard" in _text(page) + + +@pytest.mark.parametrize("css", CSS_FILES) +def test_page_css_no_raw_color(css: str) -> None: + assert not RAW_COLOR.search(_no_comments(_text(css))) + + +def test_a07_agreement_uses_checkbox_and_collapsible() -> None: + src = _text("A07_Register/A07_Register_UI_Auth_Page.ts") + assert "createCheckbox" in src and "attachCollapsible" in src + assert " None: + src = _text("A08_Support/A08_Support_UI_Page.ts") + assert "createBanner" in src and "multiline" in src + assert "createTextAreaField" not in src and 'createElement("textarea")' not in src + + +def test_b10_b11_use_parts() -> None: + b10 = _text("B10_Payment/B10_Payment_UI_Page.ts") + b11 = _text("B11_Status/B11_Status_UI_Page.ts") + assert "createBanner" in b10 and "createBadge" in b10 and "createTag" not in b10 + assert "createCard" in b11 and "createBadge" in b11 and "createTag" not in b11 + assert ".b10-payment__note" not in _text("B10_Payment/B10_Payment_UI_Style.css") + assert ".b11-status__step {" not in _text("B11_Status/B11_Status_UI_Style.css") + + +def test_dead_register_page_gone() -> None: + assert not (ROOT / "A07_Register/A07_Register_UI_Page.ts").exists() + + +def test_public_frame_wide_option() -> None: + ts = _text("ui_template/ui_template_public_layout.ts") + css = _text("ui_template/ui_template_public_layout.css") + assert "wide?: boolean" in ts + assert "[data-wide] .ui-public-frame__title" in css diff --git a/ui_template/ui_template_banner.css b/ui_template/ui_template_banner.css index 6955ea3c9..67e6387c6 100644 --- a/ui_template/ui_template_banner.css +++ b/ui_template/ui_template_banner.css @@ -14,6 +14,9 @@ font-size: 13px; line-height: 1.45; } +.ui-banner[hidden] { + display: none; +} .ui-banner--warning { --banner-tone: var(--color-warning); } diff --git a/ui_template/ui_template_public_layout.css b/ui_template/ui_template_public_layout.css index 7f537a983..98c35c3d4 100644 --- a/ui_template/ui_template_public_layout.css +++ b/ui_template/ui_template_public_layout.css @@ -8,7 +8,7 @@ .ui-public-frame__hero { padding: var(--spacing-96) var(--spacing-32); - border-bottom: 1px solid var(--color-border); + box-shadow: inset 0 -1px 0 var(--color-border); background: linear-gradient(to bottom, var(--color-accent-soft), var(--color-canvas)); } @@ -35,6 +35,14 @@ line-height: var(--leading-heading-lg); } +.ui-public-frame__hero[data-wide] .ui-public-frame__title { + max-width: 18ch; +} + +.ui-public-frame__hero[data-wide] .ui-public-frame__lead { + max-width: 56ch; +} + .ui-public-frame__lead { max-width: 52ch; margin: 0; @@ -66,6 +74,7 @@ color: var(--color-text); font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); + text-align: center; } .ui-public-frame__section-body { diff --git a/ui_template/ui_template_public_layout.ts b/ui_template/ui_template_public_layout.ts index 29b9120c8..edf92c797 100644 --- a/ui_template/ui_template_public_layout.ts +++ b/ui_template/ui_template_public_layout.ts @@ -3,7 +3,7 @@ * 공개 화면 틀 `public-frame`(552-4 틀) — 로그인 전 공개 페이지(A01 ~ A09) 몸통: 머리 띠(hero) · 구역 · 본문. * 꼴 = 머리 띠(제목 · 설명 · 주 단추 줄 · 가운데 / 왼쪽 맞춤) · 구역(제목 + 몸) 여럿 * 공개 머리(로고 · 메뉴)와 바닥글(`.app-footer`)은 앱 셸 몫 — 이 틀은 그 사이 본문만 - * 크기 = 폭 = --page-max-width · 제목 = --text-heading-lg(좁으면 --text-heading) · 구역 제목 = --text-heading-sm + * 크기 = 폭 = --page-max-width · 제목 = --text-heading-lg(좁으면 --text-heading · `wide` = 18 자) · 구역 제목 = --text-heading-sm(가운데) * 상태 = 없음 * 쓰는 법 = const f = createPublicFrame({ title: "임도 설계", lead: "…", actions: [btn] }); f.addSection("기능").append(grid) * 대신하는 옛 변종 = A01 ~ A05 `.a0N-hero` · `__inner` · `__title` · `__subtitle` · `__ctas` 다섯 벌 · `.a0N-section(__title)` @@ -17,6 +17,8 @@ export interface PublicFrameOptions { actions?: HTMLElement[]; /** 머리 띠 맞춤 — 기본 가운데 */ align?: "center" | "start"; + /** 제목 18 자 · 설명 56 자 폭(기본 14 · 52) */ + wide?: boolean; } export interface PublicFrame { @@ -41,6 +43,7 @@ export function createPublicFrame(opts: PublicFrameOptions): PublicFrame { const root = node("div", "ui-public-frame"); const hero = node("section", "ui-public-frame__hero"); hero.dataset.align = opts.align ?? "center"; + if (opts.wide) hero.dataset.wide = ""; const inner = node("div", "ui-public-frame__hero-inner"); inner.append(node("h1", "ui-public-frame__title", opts.title)); if (opts.lead) inner.append(node("p", "ui-public-frame__lead", opts.lead)); From f54eed25bc1f3e03a57ffacc3d10a554df85e4c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:57:01 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor(D03=C2=B7D04):=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EB=A7=89=EB=8C=80=20=C2=B7=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EB=8A=94=20=EC=B9=B4=EB=93=9C=20=C2=B7=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=C2=B7=20=EB=9D=A0=20=EC=95=8C=EB=A6=BC=EC=9D=84=20?= =?UTF-8?q?=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C=20(PLAN=20553-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts | 22 ++++--- D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts | 3 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 65 ++++++++++--------- .../D03_PlanAnalysis_UI_Style.css | 38 +---------- D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts | 30 +++++---- D04_PlanConfirm/D04_PlanConfirm_UI_Style.css | 9 --- resources/tester/test_553_4_de_parts.py | 25 +++++++ 7 files changed, 93 insertions(+), 99 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts index 4cdb60430..49a9d99f0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts @@ -7,6 +7,8 @@ * ========================================================================== */ import "../B05_Profile/B05_Profile_UI_Style.css"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createSelectField } from "@ui/ui_template_input"; import { openModal } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; @@ -51,17 +53,19 @@ function gridBounds(grid: DtmGrid): { export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void { if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning"); let viewer: RouteViewer | null = createRouteViewer(); - const pick = document.createElement("select"); - pick.className = "d03-plan__3d-year"; - for (const item of years) - pick.append(new Option(`${item.year}년`, String(item.year), false, item.year === firstYear)); - const corridorBox = document.createElement("label"); - corridorBox.className = "d03-plan__3d-corridor"; - const corridorOn = Object.assign(document.createElement("input"), { - type: "checkbox", + const pick = createSelectField({ + options: years.map((item) => ({ value: String(item.year), text: `${item.year}년` })), + value: String(firstYear), + size: 28, + ariaLabel: "연도", + }).select; + pick.classList.add("d03-plan__3d-year"); + const corridor = createCheckbox(" 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)", { checked: true, }); - corridorBox.append(corridorOn, " 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)"); + const corridorBox = corridor.root; + corridorBox.classList.add("d03-plan__3d-corridor"); + const corridorOn = corridor.input; corridorOn.addEventListener("change", () => viewer?.setCorridorVisible(corridorOn.checked)); const top = document.createElement("div"); top.className = "d03-plan__3d-top"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts index 2f2c41eee..1232ffa41 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts @@ -8,6 +8,7 @@ import "../B05_Profile/B05_Profile_UI_Style_RouteEdit.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createText } from "@ui/ui_template_text"; import { stationLabel } from "../common_util/common_util_station"; import type { DtmGrid } from "../common_util/common_util_surface_sampler"; import { buildCrossSvg } from "../B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw"; @@ -63,7 +64,7 @@ export function createPlanCross(): PlanCross { const clear = (text: string): void => { head.textContent = ""; - body.replaceChildren(Object.assign(document.createElement("p"), { textContent: text })); + body.replaceChildren(createText(text, "muted", 14)); delete root.dataset.chainage; }; clear("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다."); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index dbad0a984..185ed67b9 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -15,7 +15,10 @@ import "./D03_PlanAnalysis_UI_Style.css"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { createCard } from "@ui/ui_template_card"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createProgressBar } from "@ui/ui_template_progress"; +import { createText } from "@ui/ui_template_text"; import { createButton, hideLoadingOverlay, @@ -118,12 +121,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { main.className = "d03-plan"; main.append(map.root, cross.root); - const terrainText = document.createElement("p"); - terrainText.className = "d03-plan__hint"; - const bar = document.createElement("progress"); - bar.className = "d03-plan__bar"; - bar.max = 100; - bar.hidden = true; + const terrainText = createText("", "muted"); + terrainText.classList.add("d03-plan__hint"); + const bar = createProgressBar(); + bar.root.hidden = true; const more = createButton({ label: "범위 밖 — 더 받기", variant: "filled", @@ -145,7 +146,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { actions.className = "ui-sidebar-actions"; actions.append(analyzeButton, saveButton, viewButton, undoButton); const terrainCard = createPanelCard("지형", false); - terrainCard.append(terrainText, bar, more); + terrainCard.append(terrainText, bar.root, more); const routeCard = createPanelCard("연도별 노선", false); routeCard.append(yearList); const panel = document.createElement("div"); @@ -334,29 +335,33 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { for (const mode of PLAN_MODES) { const route = result.routes[mode]; if (!route) continue; - const card = document.createElement("button"); - card.type = "button"; - card.className = "d03-plan__mode" + (row.mode === mode ? " is-picked" : ""); - card.dataset.mode = mode; const metrics = route.metrics; + const values = createText( + !metrics + ? "노선 없음" + : metrics.max_grade_pct === undefined + ? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈` + : `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`, + "muted", + ); + const card = createCard({ + title: MODE_LABEL[mode], + body: [values], + pad: 12, + selected: row.mode === mode, + onSelect: () => { + if (row.mode === mode) return; + remember(); + choices = { ...choices, [result.year]: mode }; + keepDraft(); + refresh(); + }, + }); + card.classList.add("d03-plan__mode"); + card.classList.toggle("is-picked", row.mode === mode); + card.dataset.mode = mode; card.disabled = !!route.error || !metrics; card.title = route.error ?? ""; - const name = document.createElement("strong"); - name.textContent = MODE_LABEL[mode]; - const values = document.createElement("span"); - values.textContent = !metrics - ? "노선 없음" - : metrics.max_grade_pct === undefined - ? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈` - : `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`; - card.append(name, values); - card.addEventListener("click", () => { - if (row.mode === mode) return; - remember(); - choices = { ...choices, [result.year]: mode }; - keepDraft(); - refresh(); - }); box.append(card); } return box; @@ -526,13 +531,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { /** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ async function watch(kind: "terrain" | "analyze"): Promise { busy = kind; - bar.hidden = false; + bar.root.hidden = false; refresh(); try { for (;;) { if (!root.isConnected) return null; const job = (await fetchPlanProgress(projectId))[kind]; - bar.value = job.percent; + bar.set(job.percent / 100); terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${job.percent}% · ${job.message ?? ""}`; if (!job.running) { if (job.error) showToast(job.error, "error"); @@ -546,7 +551,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { return null; } finally { busy = null; - bar.hidden = true; + bar.root.hidden = true; refresh(); } } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index fc5773e7a..234fe200b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -63,17 +63,10 @@ .d03-plan__cross-body p { margin: 12px 0; - color: var(--color-text-secondary); } .d03-plan__hint { margin: 0 0 6px; - font-size: 0.85rem; - color: var(--color-text-secondary); -} - -.d03-plan__bar { - width: 100%; } .d03-plan__years { @@ -93,37 +86,10 @@ font-size: 0.85rem; } -.d03-plan__mode { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - padding: 6px 8px; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: var(--color-surface); - color: var(--color-text); - font-size: 0.8rem; - text-align: left; - cursor: pointer; -} - -.d03-plan__mode.is-picked { - border-color: var(--color-primary); - box-shadow: inset 0 0 0 1px var(--color-primary); -} - -.d03-plan__mode:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.d03-plan__mode span { - color: var(--color-text-secondary); -} - .d03-plan__3d-top { display: flex; + gap: var(--spacing-8); + align-items: center; justify-content: flex-end; margin-bottom: 6px; } diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts index 6a3ee8416..aa595d520 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -14,6 +14,7 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_fro import { kstText } from "../common_util/common_util_time_kst"; import { createButton, + createInputField, createTag, el, hideLoadingOverlay, @@ -21,6 +22,8 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createBanner } from "@ui/ui_template_banner"; +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createPanelCard } from "@ui/ui_template_collapsible"; import { t } from "@ui/ui_template_locale"; import { stationLabel } from "../common_util/common_util_station"; @@ -146,14 +149,11 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { const listId = `d04-companies-${year}`; const options = el("datalist", { attrs: { id: listId } }); const found = new Map(); - const firm = el("input", { - className: "d04-confirm__input", - attrs: { list: listId, placeholder: t("D04_Company") }, - }); - const mail = el("input", { - className: "d04-confirm__input", - attrs: { type: "email", placeholder: t("D04_Email") }, - }); + const firm = createInputField({ placeholder: t("D04_Company"), size: 28 }).input; + firm.setAttribute("list", listId); + const mail = createInputField({ type: "email", placeholder: t("D04_Email"), size: 28 }).input; + firm.classList.add("d04-confirm__input"); + mail.classList.add("d04-confirm__input"); firm.addEventListener("input", () => { const query = firm.value.trim(); if (!query) return; @@ -245,18 +245,20 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { className: "d04-confirm__table", children: [el("thead", { children: [head] }), el("tbody", { children: rows })], }); - const notes = draft() - ? [el("p", { className: "d04-confirm__dirty", text: t("D04_Dirty") })] - : []; + const notes = draft() ? [createBanner({ tone: "warning", text: t("D04_Dirty") })] : []; + notes[0]?.classList.add("d04-confirm__dirty"); main.replaceChildren(el("h2", { text: summary.name }), ...notes, table); pickList.replaceChildren( ...summary.years .filter((row) => row.saved) .map((row) => { - const box = el("input", { attrs: { type: "checkbox", value: String(row.year) } }); - box.checked = !row.confirmed_at; - return el("label", { className: "d04-confirm__pick", children: [box, ` ${row.year}`] }); + const pick = createCheckbox(String(row.year), { + checked: !row.confirmed_at, + value: String(row.year), + }); + pick.root.classList.add("d04-confirm__pick"); + return pick.root; }), ); } diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css index 05556bae3..2260840d3 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css @@ -44,15 +44,6 @@ .d04-confirm__input { min-width: 10rem; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); -} - -.d04-confirm__dirty { - color: var(--color-warning, var(--color-text-secondary)); } .d04-confirm__picks { diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index e37297b87..7cc5d81d6 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -32,3 +32,28 @@ def test_d02_parts(): for dead in (".d02-info", "is-invalid", "font-size: var(--text-caption);\n}\n\n.d02-info"): assert dead not in css assert "data-axis" in ts or "dataset.axis" in ts + + +def test_d03_parts(): + ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts") + three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts") + css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css") + assert 'createElement("progress")' not in ts and "createProgressBar()" in ts + assert "createCard({" in ts and "onSelect:" in ts + assert 'classList.toggle("is-picked"' in ts # 촬영 장면이 `.d03-plan__mode.is-picked` 로 잡음 + assert "card.dataset.mode = mode" in ts + assert 'createElement("select")' not in three and "createSelectField" in three + assert 'createElement("input")' not in three and "createCheckbox" in three + for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"): + assert dead not in css, dead + + +def test_d04_parts(): + ts = _src("D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts") + css = _src("D04_PlanConfirm/D04_PlanConfirm_UI_Style.css") + assert 'el("input"' not in ts + assert ts.count("createInputField(") == 2 and "createCheckbox(" in ts + assert 'createBanner({ tone: "warning"' in ts + assert "input:checked" in ts # [확정] 이 고른 연도를 읽는 길 + assert "border:" not in css.split(".d04-confirm__input")[1].split("}")[0] + assert ".d04-confirm__dirty" not in css