refactor(B02·B03): 페이지 자체 변종을 부품 상자 부품으로 — 파일 카드 · 띠 알림 · 진행 막대 · 켜기 · 패널 머리 · 단추 (PLAN 553-1)

- B03 파일 카드 = 카드 부품 · [선택] · [×] = 단추 · 아이콘 단추 · 진행 막대 · 오류 띠 = 부품
- B03 현황 · 이어올리기 · 예정노선 바뀜 = 띠 알림 · LAS 없이 설계 = 켜기 · 노선 · 지형 제목 = 패널 머리
- B03 · B02 죽은 CSS 걷음 · 날 색 → 공통 값 · 가이드 촬영 선택자 · 지킴 시험 test_553_1_pages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 21:53:32 +09:00
co-authored by Claude Sonnet 5.5
parent 9729892a3d
commit a826804277
11 changed files with 244 additions and 368 deletions
+3 -3
View File
@@ -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
@@ -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;
+5 -3
View File
@@ -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) => {
+68 -57
View File
@@ -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<void> {
// 대시보드 임시 보관함에서 가져올 자료 선택기 — 선택되면 [업로드]가 이동을 수행한다.
const tempPicker = createTempPicker(() => updateUploadButton());
const cardMap = new Map<FileSlot, HTMLElement>();
const progressMap = new Map<FileSlot, ProgressBarHandle>();
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<void> {
? 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<void> {
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<void> {
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
const progress = progressMap.get(slot);
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
const error = card.querySelector<HTMLElement>(".b03-file__error-message .ui-banner__text");
const remove = card.querySelector<HTMLButtonElement>(".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<void> {
? `${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<void> {
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<void> {
renderExtensionLabel(card, state);
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".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<void> {
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<void> {
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<void> {
);
// 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<void> {
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<void> {
// 골라 놓고도 [파일 업로드]가 잠긴 채 남는다(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");
+10 -3
View File
@@ -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;
}
+25 -21
View File
@@ -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);
},
}),
}),
);
}
+26 -140
View File
@@ -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 보다 좁아질 수 없다**는 뜻이라, 컨테이너가
+42 -116
View File
@@ -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 {
@@ -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 {
+5 -7
View File
@@ -227,7 +227,7 @@ export function createFileCardTemplate(): HTMLTemplateElement {
const template = document.createElement("template");
template.id = "file-card-template";
template.innerHTML = `
<article class="b03-file__card b03-file__card--empty">
<article class="ui-card b03-file__card b03-file__card--empty" data-pad="12">
<div class="b03-file__card-header">
<span class="b03-file__card-icon" aria-hidden="true"></span>
<div class="b03-file__card-heading">
@@ -235,8 +235,8 @@ export function createFileCardTemplate(): HTMLTemplateElement {
<span class="b03-file__card-ext"></span>
</div>
<div class="b03-file__card-badge-container"></div>
<button class="b03-file__card-select" type="button"></button>
<button class="b03-file__card-remove" type="button"></button>
<span data-slot="select"></span>
<span data-slot="remove"></span>
</div>
<div class="b03-file__card-content">
<input class="b03-file__slot-input" type="file" />
@@ -246,16 +246,14 @@ export function createFileCardTemplate(): HTMLTemplateElement {
</div>
<div class="b03-file__preview"></div>
<div class="b03-file__progress-section">
<div class="b03-file__progress-bar-container">
<div class="b03-file__progress-bar"></div>
</div>
<span data-slot="progress"></span>
<div class="b03-file__progress-info">
<span class="b03-file__progress-bytes"></span>
<span class="b03-file__progress-speed"></span>
<span class="b03-file__progress-eta"></span>
</div>
</div>
<div class="b03-file__error-message" role="alert"></div>
<span data-slot="error"></span>
</div>
</article>
`;
+59
View File
@@ -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 "<button" not in support # 날 단추 없음 — 단추는 부품이 만듦
assert 'class="ui-card b03-file__card' in support # 파일 카드 = 카드 부품
def test_b03_css_common_values_only():
for name, css in _src("B03_FileInput", ".css").items():
assert not OLD_ALIAS.search(css), f"{name}: 옛 보라 별칭"
body = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
assert not RAW_COLOR.search(body), f"{name}: 날 색"
def test_b02_no_dead_select_and_no_raw_widgets():
css = "".join(_src("B02_ProjRegister", ".css").values())
assert "b02-proj__select" not in css # 안 쓰던 고르기 화살표 CSS — 고르기 부품이 냄
ts = "".join(_src("B02_ProjRegister", ".ts").values())
assert "<button" not in ts and 'createElement("button")' not in ts