feat(site): rework 19 marketing showcases for composition diversity
Some checks are pending
ci / build (push) Waiting to run

Measured convergence before: split hero 13/19, top-bar nav 19/19,
one-line notice footer 14/19, body Pretendard 17/21, 10 similarity
pairs above 0.78. Each showcase got a pre-assigned hero family, nav
form, footer form, type pair, background, accent hue, radius, motion
grammar and material from outputs/rework-2026-10-06/plan.md; direction
cards and the chosen reason are recorded in every design.md.

After: 0 similarity warnings, 19 distinct display faces, 7 hero
families, 5 nav forms, 5 footer forms, no scroll reveals, 5 showcases
with SVG filter materials. Example contact values replace bracket
placeholders under the existing fictional-brand notices. Thumbnails,
OG images, card copy and three e2e assertions updated; dead CSS in
dure-enrollment and hannam-jari removed; similarity.mjs learns the new
font families.
This commit is contained in:
Yun Chan 2026-10-06 16:55:50 +09:00
parent 88c11682b1
commit 8e3cdb0d3b
296 changed files with 19131 additions and 17538 deletions

View file

@ -18,7 +18,7 @@
fee: "월 48만 원",
included: "공용 관리, 청소, 승강기",
excluded: "전기, 가스, 수도",
rooms: "방 3 · 욕실 2",
rooms: "방 3, 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
},
@ -38,7 +38,7 @@
fee: "월 56만 원",
included: "공용 관리, 보안, 승강기",
excluded: "전기, 가스, 수도, 냉난방",
rooms: "방 4 · 욕실 2",
rooms: "방 4, 욕실 2",
image: "./assets/hannam-evening-1600.webp",
imageAlt: "서울 언덕 생활권의 현대 주거 외관을 표현한 이미지"
},
@ -59,7 +59,7 @@
fee: "월 52만 원",
included: "공용 관리, 청소",
excluded: "전기, 가스, 수도, 승강기 점검",
rooms: "방 3 · 욕실 2",
rooms: "방 3, 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
}
@ -95,7 +95,7 @@
const unitPriceText = (home) => {
const perPyeong = Math.round(((home.priceEok * 10000) / home.areaPyeong) / 10) * 10;
if (home.trade === "monthly") return `보증금 평당 약 ${formatNumber(perPyeong)}만 · 월 ${formatNumber(home.monthlyManwon)}만`;
if (home.trade === "monthly") return `보증금 평당 약 ${formatNumber(perPyeong)}만, 월 ${formatNumber(home.monthlyManwon)}만`;
return `평당 약 ${formatNumber(perPyeong)}만 원`;
};
@ -136,44 +136,44 @@
const check = [];
if (home.trade === conditions.trade) {
fit.push(claim("거래 형태", `${home.tradeLabel} 조건과 같습니다.`, "입력한 조건 · 매물 정보"));
fit.push(claim("거래 형태", `${home.tradeLabel} 조건과 같습니다.`, "입력한 조건과 매물 정보"));
} else {
adjust.push(claim("거래 형태", `입력은 ${tradeLabel(conditions.trade)}, 선택한 집은 ${home.tradeLabel}입니다.`, "입력한 조건 · 매물 정보"));
adjust.push(claim("거래 형태", `입력은 ${tradeLabel(conditions.trade)}, 선택한 집은 ${home.tradeLabel}입니다.`, "입력한 조건과 매물 정보"));
}
if (home.trade === "monthly") {
if (home.priceEok > conditions.budget) {
adjust.push(claim("보증금 상한", `보증금이 입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
adjust.push(claim("보증금 상한", `보증금이 입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건과 매물 정보"));
} else {
adjust.push(claim("월 납부 조건", `보증금은 상한 안쪽이지만 월 ${formatNumber(home.monthlyManwon)}만 원을 별도로 살펴야 합니다.`, "입력한 조건 · 매물 정보"));
adjust.push(claim("월 납부 조건", `보증금은 상한 안쪽이지만 월 ${formatNumber(home.monthlyManwon)}만 원을 별도로 살펴야 합니다.`, "입력한 조건과 매물 정보"));
}
} else if (home.priceEok <= conditions.budget) {
fit.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(conditions.budget - home.priceEok)}억 원 낮습니다.`, "입력한 조건 · 매물 정보"));
fit.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(conditions.budget - home.priceEok)}억 원 낮습니다.`, "입력한 조건과 매물 정보"));
} else {
adjust.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
adjust.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건과 매물 정보"));
}
if (home.areaM2 >= conditions.minArea) {
fit.push(claim("전용면적", `최소 면적보다 ${formatNumber(home.areaM2 - conditions.minArea, 2)}㎡ 넓습니다.`, "입력한 조건 · 매물 정보"));
fit.push(claim("전용면적", `최소 면적보다 ${formatNumber(home.areaM2 - conditions.minArea, 2)}㎡ 넓습니다.`, "입력한 조건과 매물 정보"));
} else {
adjust.push(claim("전용면적", `최소 면적보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작습니다.`, "입력한 조건 · 매물 정보"));
adjust.push(claim("전용면적", `최소 면적보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작습니다.`, "입력한 조건과 매물 정보"));
}
if (home.parking >= conditions.parking) {
fit.push(claim("주차", `${home.parking}대로 필요한 수를 충족합니다.`, "입력한 조건 · 매물 정보"));
fit.push(claim("주차", `${home.parking}대로 필요한 수를 충족합니다.`, "입력한 조건과 매물 정보"));
} else {
adjust.push(claim("주차", `필요한 수보다 ${conditions.parking - home.parking}대 부족합니다.`, "입력한 조건 · 매물 정보"));
adjust.push(claim("주차", `필요한 수보다 ${conditions.parking - home.parking}대 부족합니다.`, "입력한 조건과 매물 정보"));
}
if (!conditions.moveIn) {
adjust.push(claim("입주 희망일", "날짜를 정하면 입주 가능 시점과 대조할 수 있습니다.", "입력한 조건"));
} else if (home.moveDate <= conditions.moveIn) {
fit.push(claim("입주 시점", `${home.moveLabel}으로 희망일 전후 협의가 가능합니다.`, "입력한 조건 · 매물 정보"));
fit.push(claim("입주 시점", `${home.moveLabel}으로 희망일 전후 협의가 가능합니다.`, "입력한 조건과 매물 정보"));
} else {
adjust.push(claim("입주 시점", `${home.moveLabel}으로 입력한 희망일보다 늦습니다.`, "입력한 조건 · 매물 정보"));
adjust.push(claim("입주 시점", `${home.moveLabel}으로 입력한 희망일보다 늦습니다.`, "입력한 조건과 매물 정보"));
}
check.push(claim("권리 관계", "등기와 계약 조건은 서류를 통해 다시 살펴야 합니다.", "공인중개사 확인"));
check.push(claim("권리 관계", "등기와 계약 조건은 등기부등본과 계약서로 다시 확인해야 합니다.", "공인중개사 확인"));
check.push(claim("현장 상태", "소음, 마감과 채광은 방문해서 확인해야 합니다.", "현장 확인"));
return { fit, adjust, check };
@ -206,7 +206,7 @@
const fillClaimList = (selector, items, emptyText) => {
const list = $(selector);
if (!list) return;
const nextItems = items.length ? items : [claim(emptyText, "현재 입력에서 바로 조정할 항목이 없습니다.", "입력한 조건 · 매물 정보")];
const nextItems = items.length ? items : [claim(emptyText, "현재 입력에서 바로 조정할 항목이 없습니다.", "입력한 조건과 매물 정보")];
list.replaceChildren(...nextItems.map(createClaimItem));
};
@ -255,7 +255,7 @@
setMatrixNote("[data-matrix-trade-note]", home.trade === conditions.trade ? "입력과 같음" : `${tradeLabel(conditions.trade)}에서 조정 필요`, home.trade === conditions.trade ? "" : "is-adjust");
if (home.trade === "monthly") {
const depositNote = home.priceEok <= conditions.budget ? "보증금은 상한 안쪽 · 월 납부 별도" : `보증금이 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`;
const depositNote = home.priceEok <= conditions.budget ? "보증금은 상한 안쪽, 월 납부 별도" : `보증금이 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`;
setMatrixNote("[data-matrix-price-note]", depositNote, "is-check");
} else {
const pricePass = home.priceEok <= conditions.budget;
@ -276,18 +276,56 @@
}
};
// 첫 화면 격자. 분석 전에는 등록 순서(01·02·03), 분석 후에는 조건 점수 순서로 놓고
// 칸마다 맞는 조건 수와 조정할 조건 수를 적는다. 순서 변경은 CSS order 로만 바꾼다 —
// DOM 순서를 바꾸지 않아야 키보드 순서와 E2E 선택자가 그대로다.
const renderCandidateRail = () => {
const conditions = state.conditions || readConditions();
const ranked = state.analyzed
? [...homes].sort((a, b) => scoreHome(b, conditions) - scoreHome(a, conditions))
: homes;
$$('[data-select-listing]').forEach((button) => {
const home = getHome(button.dataset.selectListing);
const active = home.id === state.selectedId;
button.setAttribute("aria-pressed", String(active));
button.closest(".candidate-option")?.classList.toggle("is-selected", active);
const option = button.closest(".candidate-option");
option?.classList.toggle("is-selected", active);
if (option) option.style.order = String(ranked.indexOf(home) + 1);
const content = button.nextElementSibling;
const small = content?.querySelector("small");
if (small) small.textContent = `${home.tradeLabel} · ${formatArea(home)}`;
if (small) small.textContent = formatArea(home);
const price = content?.querySelector("[data-candidate-price]");
if (price) price.textContent = formatPrice(home);
const verdict = content?.querySelector("[data-candidate-verdict]");
if (verdict) {
if (!state.analyzed) {
verdict.hidden = true;
verdict.textContent = "";
verdict.className = "candidate-verdict";
} else {
const result = evaluateHome(home, conditions);
const lead = ranked[0]?.id === home.id;
verdict.hidden = false;
verdict.textContent = lead
? `조건과 가장 가까움, 맞는 조건 ${result.fit.length}`
: `맞는 조건 ${result.fit.length}, 조정 ${result.adjust.length}`;
verdict.className = lead ? "candidate-verdict is-lead" : "candidate-verdict";
}
}
});
};
// 격자 재정렬은 View Transitions 로 감싼다(R3). 미지원 브라우저와 감소 모션은 즉시 바뀐다.
const withViewTransition = (update) => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || typeof document.startViewTransition !== "function") {
update();
return Promise.resolve();
}
const transition = document.startViewTransition(update);
return transition.finished.catch(() => {});
};
const renderCompareScene = (home) => {
const image = $("[data-compare-image]");
if (image) {
@ -338,6 +376,8 @@
$("[data-dossier-life]").textContent = home.life;
const consultHome = $("[data-consult-home]");
if (consultHome) consultHome.textContent = home.name;
const prepHome = $("[data-prep-home]");
if (prepHome) prepHome.textContent = home.name;
$("[data-location-heading]").textContent = `${home.life}을 직접 살핍니다.`;
$("[data-page-budget]").textContent = `${formatNumber(conditions.budget)}억 원`;
$("[data-page-price]").textContent = totalPriceText(home);
@ -384,7 +424,9 @@
state.analyzed = true;
const best = homes.reduce((current, home) => scoreHome(home, conditions) > scoreHome(current, conditions) ? home : current, homes[0]);
state.selectedId = best.id;
renderSelection();
// 스크롤은 전환을 기다리지 않고 바로 시작한다 — 전환이 끝난 뒤 스크롤하면 그 사이에
// 좌표를 잡은 클릭(실측: E2E 후보 클릭)이 다른 자리에 떨어진다.
withViewTransition(renderSelection);
$("#briefing")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
announce(`${best.name}을 가장 가까운 후보로 골랐습니다. 맞는 점, 조정할 점, 확인할 점을 정리했습니다.`);
};
@ -392,7 +434,7 @@
const markConditionsChanged = () => {
if (!state.analyzed) return;
resetBriefing();
renderSelection();
withViewTransition(renderSelection);
announce("조건이 바뀌어 이전 브리핑을 비웠습니다. 다시 분석해 주세요.");
};
@ -465,13 +507,19 @@
strong.textContent = getHome().name;
copy.append(strong, "을 기준으로 준비할 항목을 골라 보세요.");
content.append(copy);
[["rights", "권리 관계와 계약 조건", true], ["visit", "현장 방문 때 볼 항목", true], ["move", "입주 가능일 조율", false]].forEach(([value, label, checked]) => {
// 푸터 준비 폼에서 고른 항목을 그대로 가져온다. 폼이 없으면 기본값(권리·현장)이다.
const prepForm = $("[data-consult-prep]");
const prepChecked = (value, fallback) => {
const input = prepForm?.querySelector(`input[name="agenda"][value="${value}"]`);
return input ? input.checked : fallback;
};
[["rights", "권리 관계와 계약 조건", true], ["visit", "현장 방문 때 볼 항목", true], ["move", "입주 가능일 조율", false]].forEach(([value, label, fallback]) => {
const wrapper = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.name = "agenda";
input.value = value;
input.checked = checked;
input.checked = prepChecked(value, fallback);
wrapper.append(input, ` ${label}`);
content.append(wrapper);
});