From 25adc2f0e78666c163c2e5355d4d5c37f59ecab9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:56:42 +0900 Subject: [PATCH] =?UTF-8?q?refactor(=EA=B3=B5=EA=B0=9C=ED=99=94=EB=A9=B4):?= =?UTF-8?q?=20A01~A09=20=C2=B7=20B10=20=C2=B7=20B11=20=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7=20?= =?UTF-8?q?=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));