release: v0.10.0
All checks were successful
ci / build (push) Successful in 42s
release / release (push) Successful in 1m5s

This commit is contained in:
Yun Chan 2026-08-29 21:19:26 +09:00
parent 2b7fa32b5b
commit 6930906583
61 changed files with 15165 additions and 82 deletions

View file

@ -1135,3 +1135,26 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고
- 위 전용 E2E는 저장소 전체 `verify.mjs`의 L6에 포함한다. 배포 전에는 전체 게이트를 다시 실행한다. - 위 전용 E2E는 저장소 전체 `verify.mjs`의 L6에 포함한다. 배포 전에는 전체 게이트를 다시 실행한다.
- 2026-08-26 국소 조정: 배포 텍스트에서 한자를 전부 차단하고, 쇼케이스의 세로 조판·한지/먹·인장/도장·다도 연출은 가로 라벨, 중립 상태 배지, 금속 활자·편집 도구, 독서·정밀 목공 언어로 교체했다. 한국어 고유명사와 역사 소재는 유지하되 동아시아 전통 장식을 제품의 시각 단축어로 쓰지 않는다. - 2026-08-26 국소 조정: 배포 텍스트에서 한자를 전부 차단하고, 쇼케이스의 세로 조판·한지/먹·인장/도장·다도 연출은 가로 라벨, 중립 상태 배지, 금속 활자·편집 도구, 독서·정밀 목공 언어로 교체했다. 한국어 고유명사와 역사 소재는 유지하되 동아시아 전통 장식을 제품의 시각 단축어로 쓰지 않는다.
## 한남자리 · 철판클럽 쇼케이스 듀오 (2026-08-29)
### 한남자리 — 집 목록이 아닌 개인 브리핑 데스크
- 공개 페이지는 `맞춤 브리핑 → 세 집 비교 → 선택 서류철 → 중개사 인계`의 다섯 장면으로 끝낸다. 같은 경고와 검수 원장을 공개 페이지에 반복하지 않는다.
- 입력 기준은 거래 형태·예산·최소 전용면적·입주 희망일·필요 주차 다섯 개뿐이다. 화면에 없는 조건을 점수나 추천 근거로 사용하지 않는다.
- AI는 조건 관계를 해석하고, 공인중개사는 권리·현장·계약 사실을 확인한다. 분석 전 화면은 중립 상태이며 생성 이미지와 예시 자료는 소유 범위 안에서 바로 표시한다.
- 시각 방향은 석재색 종이 면, 서울의 청회색, 제한된 브론즈 한 색이다. 어두운 면은 마지막 인계 장면에만 써서 검정·금색 부동산 광고 문법을 피한다.
- 관리자 화면은 공개 페이지의 설명을 복제하지 않고 게시 전 사실·표시·AI 근거·변경 이력을 검수한다. 선택 전에는 표가 전체 폭을 쓰고 선택 뒤에만 조사관이 열린다.
### 철판클럽 — 도심 철판 버거와 운영 조건의 두 갈래
- 공개 페이지는 주문 뒤 눌러 굽고 조립하는 한 끼의 속도와 버거 조합기를 보여 준다. 가맹 페이지는 상권·점포·전용 면적·주방 동선·미정 비용을 먼저 보여 준다.
- 코발트는 서울 도심 간판처럼 제한적으로 쓰고, 크림 종이 면·검정 활자·실제 조리 동선에 가까운 생성 이미지로 국내 빠른 식사 매장의 감각을 만든다.
- 가상의 브랜드이므로 매출·수익률·실제 매장·계약 조건을 꾸미지 않는다. 미정 비용은 미정으로 남기고 문의 전 확인 목록으로 연결한다.
### 6단계 증거
- 실제 Chromium·WebKit E2E: **90/90 시나리오 · 350/350 단언 · 12/12 캡처** 통과.
- 공개·하위 화면 실렌더 감사: 320·390·768·1440에서 **8/8 통과**. axe, HTML, CSS, JS, 한자 검사도 통과했다.
- 갤러리 카드 네 장과 공유 이미지 두 장은 확정 화면을 실제 Chrome으로 다시 캡처했다.
- 구현 중 발견한 21개 교훈은 `research/showcase-duo-learning-ledger-2026-08-29.md`에 근거와 함께 남겼고, 재사용 가능한 규칙은 `packages/skill/references/trustworthy-showcases.md`와 관련 게이트 문서에 반영했다.

View file

@ -1,18 +1,18 @@
# 게이트 리포트 — 2026-08-26 09:11 # 게이트 리포트 — 2026-08-29 12:12
**전 항목 통과 — 배포 가능** **전 항목 통과 — 배포 가능**
``` ```
PASS L0 정적 — SKIP — checks.static=false PASS L0 정적 — SKIP — checks.static=false
PASS 죽은 선택자 — SKIP — checks.deadCss=false PASS 죽은 선택자 — SKIP — checks.deadCss=false
PASS twentyoz-products 토큰 위생 — 색 0·radius 1 리터럴(토큰 1파일) PASS twentyoz-products 토큰 위생 — 색 0/0건(0종)·radius 1종·토큰 1파일/73개
PASS twentyoz-products SEO/meta — title 17자·desc 85자·OG✓·alt 0결 PASS twentyoz-products SEO/meta — title 17자·desc 85자·OG✓·alt 0결
PASS twentyoz-products/single 오버플로 — 320/375/390/768/1440 PASS twentyoz-products/single 오버플로 — 320/375/390/768/1440
PASS twentyoz-products/single h1 1줄 PASS twentyoz-products/single h1 1줄
PASS twentyoz-products/single 수축 없음 PASS twentyoz-products/single 수축 없음
PASS twentyoz-products/single 리듬·트랙 PASS twentyoz-products/single 리듬·트랙
PASS twentyoz-products/single 대비 AA PASS twentyoz-products/single 대비 AA
PASS twentyoz-products 스케일×폭 h1 — scales 1/1.3 PASS twentyoz-products 스케일×폭 h1 — 최대 1줄 · scales 1/1.3
PASS twentyoz-products 브랜드 1줄 PASS twentyoz-products 브랜드 1줄
PASS twentyoz-products/single 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px PASS twentyoz-products/single 터치타깃 — 컨트롤 ≥44px·인라인 ≥24px
PASS twentyoz-products/single 왼쪽 인셋 — 최소 텍스트 시작 20px PASS twentyoz-products/single 왼쪽 인셋 — 최소 텍스트 시작 20px

View file

@ -41,6 +41,124 @@
], ],
"h1": "h1", "h1": "h1",
"brand": ".wordmark" "brand": ".wordmark"
},
{
"name": "hannam-jari",
"path": "public/work/hannam-jari/index.html",
"views": [
"single"
],
"h1": "h1",
"h1MaxLines": 2,
"brand": ".brand",
"staticContract": {
"selectorFiles": [
"public/work/hannam-jari/styles/landing.css"
],
"selectorDocuments": [
"public/work/hannam-jari/index.html",
"public/work/hannam-jari/scripts/landing.js"
],
"hygieneFiles": [
"public/work/hannam-jari/styles/landing.css"
],
"tokenFiles": [
"public/work/hannam-jari/styles/tokens.css",
"public/work/hannam-jari/styles/landing.css"
],
"budgets": {
"colorLiterals": 64,
"radiusLiterals": 2
}
}
},
{
"name": "hannam-jari-admin",
"path": "public/work/hannam-jari/admin/index.html",
"views": [
"single"
],
"h1": "h1",
"brand": ".sidebar-brand",
"staticContract": {
"selectorFiles": [
"public/work/hannam-jari/styles/admin.css"
],
"selectorDocuments": [
"public/work/hannam-jari/admin/index.html",
"public/work/hannam-jari/scripts/admin.js"
],
"hygieneFiles": [
"public/work/hannam-jari/styles/admin.css"
],
"tokenFiles": [
"public/work/hannam-jari/styles/tokens.css"
],
"budgets": {
"colorLiterals": 256,
"radiusLiterals": 15
}
}
},
{
"name": "griddle-club",
"path": "public/work/griddle-club/index.html",
"views": [
"single"
],
"h1": "h1",
"h1MaxLines": 2,
"brand": ".brand",
"staticContract": {
"selectorFiles": [
"public/work/griddle-club/styles/site.css"
],
"selectorDocuments": [
"public/work/griddle-club/index.html",
"public/work/griddle-club/scripts/site.js",
"public/work/griddle-club/franchise/index.html",
"public/work/griddle-club/scripts/franchise.js"
],
"hygieneFiles": [
"public/work/griddle-club/styles/site.css"
],
"tokenFiles": [
"public/work/griddle-club/styles/tokens.css"
],
"budgets": {
"colorLiterals": 41,
"radiusLiterals": 6
}
}
},
{
"name": "griddle-club-franchise",
"path": "public/work/griddle-club/franchise/index.html",
"views": [
"single"
],
"h1": "h1",
"h1MaxLines": 2,
"brand": ".brand",
"staticContract": {
"selectorFiles": [
"public/work/griddle-club/styles/franchise.css"
],
"selectorDocuments": [
"public/work/griddle-club/franchise/index.html",
"public/work/griddle-club/scripts/franchise.js"
],
"hygieneFiles": [
"public/work/griddle-club/styles/franchise.css"
],
"tokenFiles": [
"public/work/griddle-club/styles/tokens.css"
],
"budgets": {
"colorLiterals": 26,
"radiusLiterals": 0
}
}
} }
], ],
"widths": [ "widths": [

View file

@ -10,6 +10,8 @@
"test:design:products": "node tools/design-gate.mjs --config=gate.twentyoz-products.config.json", "test:design:products": "node tools/design-gate.mjs --config=gate.twentyoz-products.config.json",
"test:e2e:recent-showcases": "node tools/recent-showcases-e2e.mjs", "test:e2e:recent-showcases": "node tools/recent-showcases-e2e.mjs",
"test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs", "test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs",
"test:e2e:showcase-duo": "node tools/showcase-duo-e2e.mjs",
"capture:showcase-duo": "node tools/capture-showcase-duo.mjs",
"prebuild": "npm run build:showcase-assets", "prebuild": "npm run build:showcase-assets",
"build": "astro build", "build": "astro build",
"postbuild": "node tools/no-hanja.mjs --root dist", "postbuild": "node tools/no-hanja.mjs --root dist",

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

View file

@ -0,0 +1,358 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>철판클럽 가맹 안내 — 운영 조건부터 확인하기</title>
<meta
name="description"
content="가상 버거 브랜드 철판클럽의 상권, 점포, 면적, 개설 절차와 비용 미정 항목을 먼저 살펴보는 가맹 문의 쇼케이스입니다."
/>
<meta property="og:title" content="철판클럽 가맹 안내 — 운영 조건부터" />
<meta
property="og:description"
content="수익 약속 없이 상권, 점포, 주방 동선과 비용 미정 항목을 먼저 보여 주는 국내형 가맹 안내 화면."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="../og.png" />
<meta property="og:image:alt" content="직원들이 철판 조리와 포장을 나누어 맡는 주방" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="철판클럽 가맹 안내 — 운영 조건부터" />
<meta
name="twitter:description"
content="상권, 점포, 주방 동선과 비용 미정 항목을 먼저 살펴보는 가맹 안내 쇼케이스."
/>
<meta name="twitter:image" content="../og.png" />
<meta name="theme-color" content="#fffdf8" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="../styles/tokens.css?v=1" />
<link rel="stylesheet" href="../styles/site.css?v=1" />
<link rel="stylesheet" href="../styles/franchise.css?v=1" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%232147e7'/%3E%3Cpath d='M6 9h20M8 16h16M10 23h12' stroke='%23fffdf8' stroke-width='3' stroke-linecap='square'/%3E%3C/svg%3E"
/>
<script>document.documentElement.classList.add("is-js");</script>
<script src="../scripts/franchise.js?v=1" defer></script>
</head>
<body class="franchise-page">
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<div class="truth-bar" role="note">
<p>쇼케이스용 가상 브랜드 · 실제 가맹 모집, 상담, 계약을 제공하지 않음</p>
</div>
<header class="site-head">
<div class="wrap head-row franchise-head-row">
<a class="brand" href="../" aria-label="철판클럽 홈으로">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>철판클럽</span>
</a>
<nav class="head-nav" aria-label="가맹 안내 메뉴">
<a href="#model">운영 방식</a>
<a href="#site-fit">상권과 점포</a>
<a href="#process">개설 절차</a>
<a href="#cost">비용 항목</a>
</nav>
<a class="head-action" href="#inquiry">문의 준비하기</a>
</div>
</header>
<main id="main">
<section class="franchise-hero" aria-labelledby="franchise-hero-heading">
<div class="franchise-hero-copy">
<p class="section-kicker">철판클럽 가맹 안내</p>
<h1 id="franchise-hero-heading">매출 약속보다<br />운영 조건부터.</h1>
<p>
버거 사진만 보고 결정하지 않도록 상권, 점포, 전용 면적, 주방 동선과 아직 정할 수 없는 비용을 순서대로 보여 줍니다.
</p>
<div class="hero-actions">
<a class="button button-light" href="#site-fit">내 조건 살펴보기</a>
<a class="text-link light-link" href="#cost">비용 미정 항목 보기 <span aria-hidden="true">↓</span></a>
</div>
<div class="franchise-truth">
<strong>먼저 확인해 주세요</strong>
<p>실제 가맹 모집이 아닌 디자인 쇼케이스입니다. 금액, 수익, 매장 수와 개점 기간을 꾸며내지 않습니다.</p>
</div>
</div>
<figure class="franchise-hero-media image-shell">
<img
src="../assets/kitchen-flow.webp"
width="1400"
height="933"
alt="직원 두 명이 좁은 주방에서 철판 조리와 포장 작업을 나누어 맡는 모습"
fetchpriority="high"
/>
<span class="image-fallback" aria-hidden="true">주방 운영 동선 장면</span>
<figcaption>생성 이미지 · 실제 매장과 가맹 시설 아님</figcaption>
</figure>
</section>
<section class="fact-rail" aria-label="가맹 안내 원칙">
<div class="wrap fact-rail-grid">
<p><span>01</span> 수익률을 약속하지 않음</p>
<p><span>02</span> 점포를 보기 전 금액을 확정하지 않음</p>
<p><span>03</span> 실제 개인정보를 받지 않음</p>
</div>
</section>
<section class="section model-section" id="model" aria-labelledby="model-heading">
<div class="wrap">
<div class="section-head split-head">
<div class="model-heading-copy"><p class="section-kicker">어떤 운영 방식인지</p><h2 id="model-heading">좁은 주방에서<br />역할을 분명하게.</h2></div>
<p class="section-note">조리, 조립, 포장, 전달이 서로 겹치지 않는 흐름을 먼저 검토합니다. 실제 설비와 인원은 점포마다 달라집니다.</p>
</div>
<div class="flow-board">
<article><span>01</span><h3>재료 준비</h3><p>냉장 보관과 당일 준비 동선을 분리합니다.</p></article>
<article><span>02</span><h3>철판 조리</h3><p>패티와 양파가 움직이는 구간을 짧게 둡니다.</p></article>
<article><span>03</span><h3>조립과 포장</h3><p>주문표, 유산지, 상자를 한 방향에 둡니다.</p></article>
<article><span>04</span><h3>전달과 픽업</h3><p>매장 손님과 배달 픽업이 부딪히지 않게 나눕니다.</p></article>
</div>
<div class="operator-fit">
<div class="operator-heading-copy"><p class="section-kicker">운영자에게 필요한 태도</p><h3>직접 확인하고,<br />같은 품질을 반복하기.</h3></div>
<ul>
<li><strong>현장 참여</strong><span>조리와 응대 흐름을 직접 익힐 준비</span></li>
<li><strong>위생 기록</strong><span>보관, 조리, 마감 상태를 남길 준비</span></li>
<li><strong>지역 이해</strong><span>점심과 저녁 수요가 다른 상권을 살필 준비</span></li>
<li><strong>팀 운영</strong><span>작은 인원에서 역할을 나누고 교육할 준비</span></li>
</ul>
</div>
</div>
</section>
<section class="section site-fit-section" id="site-fit" aria-labelledby="site-fit-heading">
<div class="wrap">
<div class="section-head split-head">
<div class="site-fit-heading-copy"><p class="section-kicker">상권과 점포</p><h2 id="site-fit-heading">주소보다 먼저<br />운영 장면을 봅니다.</h2></div>
<p class="section-note">아래 분류는 적합 판정이 아니라 문의 때 빠뜨리지 않을 조건 목록입니다. 권장 면적 수치는 실제 검토 뒤에만 제시할 수 있습니다.</p>
</div>
<div class="fit-grid">
<article class="fit-main">
<p class="fit-number mono">A</p>
<h3>상권 흐름</h3>
<p>업무가, 주거지, 역 주변, 대학가, 상점가 또는 여러 수요가 섞인 곳인지 먼저 적습니다.</p>
<ul><li>점심과 저녁 수요 시간</li><li>도보와 차량 접근</li><li>포장 대기 공간</li><li>배달 픽업 접근</li></ul>
</article>
<article>
<p class="fit-number mono">B</p><h3>점포 상태</h3><p>점포 없음, 찾는 중, 협의 중, 보유함으로 나눕니다.</p>
</article>
<article>
<p class="fit-number mono">C</p><h3>전용 면적</h3><p>사용자가 입력한 ㎡와 환산한 평을 함께 봅니다. 적합 판정은 하지 않습니다.</p>
</article>
<article>
<p class="fit-number mono">D</p><h3>설비 조건</h3><p>전기, 배기, 급수, 배수와 반입 경로는 점포 자료로 따로 확인합니다.</p>
</article>
<article>
<p class="fit-number mono">E</p><h3>이전 용도</h3><p>음식점, 소매점, 빈 점포 등 이전 사용 상태를 확인합니다.</p>
</article>
</div>
</div>
</section>
<section class="section franchise-process" id="process" aria-labelledby="process-heading-franchise">
<div class="wrap">
<div class="section-head split-head">
<div class="process-heading-copy"><p class="section-kicker">개설 절차</p><h2 id="process-heading-franchise">기간을 꾸미지 않고<br />순서만 보여 줍니다.</h2></div>
<p class="section-note">점포, 자료, 협의와 공사 조건에 따라 기간이 달라집니다. 아래 흐름은 화면 설명용 예시이며 계약이나 개점을 보장하지 않습니다.</p>
</div>
<ol class="franchise-timeline">
<li><span>01</span><div><h3>문의 준비</h3><p>희망 지역과 시작 시기를 정리합니다.</p></div><small>준비 전</small></li>
<li><span>02</span><div><h3>희망 조건 확인</h3><p>상권 종류, 점포 상태와 운영 경험을 봅니다.</p></div><small>자료 필요</small></li>
<li><span>03</span><div><h3>상권과 점포 검토</h3><p>주소가 정해졌다면 면적, 설비와 동선을 확인합니다.</p></div><small>조건에 따라 다름</small></li>
<li><span>04</span><div><h3>정보 제공과 협의</h3><p>확인 가능한 조건과 미정 항목을 나눕니다.</p></div><small>검토 필요</small></li>
<li><span>05</span><div><h3>계약 내용 검토</h3><p>실제 자료와 조건을 받은 뒤 각 항목을 확인합니다.</p></div><small>자동 확정 아님</small></li>
<li><span>06</span><div><h3>설계와 공사 계획</h3><p>주방, 포장대와 픽업 동선을 점포에 맞춥니다.</p></div><small>점포별 다름</small></li>
<li><span>07</span><div><h3>조리와 응대 교육</h3><p>조리 순서와 위생 기록을 함께 익힙니다.</p></div><small>계획 필요</small></li>
<li><span>08</span><div><h3>개점 전 점검</h3><p>설비, 재료, 동선과 운영 준비를 확인합니다.</p></div><small>완료 보장 아님</small></li>
</ol>
</div>
</section>
<section class="section cost-section" id="cost" aria-labelledby="cost-heading">
<div class="wrap">
<div class="cost-head">
<div class="cost-heading-copy"><p class="section-kicker">비용 항목</p><h2 id="cost-heading">합계를 만들 수 없는<br />이유부터 적습니다.</h2></div>
<div class="unknown-cost">
<span>현재 상태</span>
<strong>비용 미정</strong>
<p>점포, 면적, 설비와 계약 자료가 확인되지 않아 금액을 제시하지 않습니다.</p>
</div>
</div>
<div class="cost-table-wrap" role="region" aria-label="비용 항목 표" tabindex="0">
<table class="cost-table">
<caption class="sr-only">가맹 비용 항목과 현재 확인 상태</caption>
<thead><tr><th scope="col">항목</th><th scope="col">현재 상태</th><th scope="col">금액</th><th scope="col">다음 확인</th></tr></thead>
<tbody>
<tr><th scope="row">가맹 관련 비용</th><td>자료 미제공</td><td>미정</td><td>실제 안내 자료 확인</td></tr>
<tr><th scope="row">교육 비용</th><td>범위 미정</td><td>미정</td><td>인원과 교육 범위 확인</td></tr>
<tr><th scope="row">계약 보증 관련 비용</th><td>조건 미정</td><td>미정</td><td>실제 계약 자료 확인</td></tr>
<tr><th scope="row">주방 설비</th><td>점포 필요</td><td>미정</td><td>전기, 배기와 설비 확인</td></tr>
<tr><th scope="row">가구와 집기</th><td>사양 필요</td><td>미정</td><td>좌석과 포장 방식 확인</td></tr>
<tr><th scope="row">인테리어</th><td>면적 필요</td><td>미정</td><td>전용 면적과 현장 확인</td></tr>
<tr><th scope="row">간판과 외부 표시</th><td>건물 조건 필요</td><td>미정</td><td>건물과 지역 조건 확인</td></tr>
<tr><th scope="row">주문 기기</th><td>운영 방식 필요</td><td>미정</td><td>주문과 결제 방식 확인</td></tr>
<tr><th scope="row">허가와 신고</th><td>지역 조건 필요</td><td>미정</td><td>관할 안내 확인</td></tr>
<tr><th scope="row">첫 물류</th><td>메뉴 범위 필요</td><td>미정</td><td>품목과 보관량 확인</td></tr>
<tr><th scope="row">임대 보증 관련 비용</th><td>점포 계약 필요</td><td>미정</td><td>임대 조건 직접 확인</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">합계</th><td colspan="2">계산하지 않음 · 확인되지 않은 숫자를 더하지 않습니다</td></tr></tfoot>
</table>
</div>
<p class="cost-warning">평균 매출, 예상 수익, 회수 기간과 최저 비용은 근거 자료가 없어 제공하지 않습니다.</p>
</div>
</section>
<section class="section inquiry-section" id="inquiry" aria-labelledby="inquiry-heading">
<div class="wrap inquiry-layout">
<div class="inquiry-intro">
<p class="section-kicker">문의 준비 체험</p>
<h2 id="inquiry-heading">연락처 없이도<br />조건부터 정리하기.</h2>
<p>실제 이름과 연락처를 받지 않습니다. 화면 안에서 상권과 점포 조건을 정리하고 전송 전 검토 상태를 체험합니다.</p>
<div class="privacy-note" role="note">
<strong>실제 개인정보를 입력하지 마세요.</strong>
<p>입력 내용은 서버로 보내거나 저장하지 않습니다. 성공 화면도 실제 접수가 아닙니다.</p>
</div>
</div>
<div class="inquiry-panel">
<ol class="step-indicator" aria-label="문의 준비 단계">
<li class="is-current" data-step-indicator="1" aria-current="step"><span>1</span><small>상권</small></li>
<li data-step-indicator="2"><span>2</span><small>점포</small></li>
<li data-step-indicator="3"><span>3</span><small>검토</small></li>
</ol>
<form id="franchise-form" novalidate>
<section class="form-step" data-step="1" aria-labelledby="step-one-heading">
<p class="step-count">1 / 3</p>
<h3 id="step-one-heading">어느 지역에서 시작할까요?</h3>
<div class="form-field">
<label for="demo-name">예시 호칭</label>
<input id="demo-name" name="demoName" type="text" minlength="2" autocomplete="off" placeholder="실명 대신 예시 호칭" required />
<small>실제 이름을 입력하지 마세요.</small>
<p class="field-error" id="demo-name-error"></p>
</div>
<div class="form-field">
<label for="region">희망 지역</label>
<select id="region" name="region" required>
<option value="">지역 고르기</option><option>서울 동쪽</option><option>서울 서쪽</option><option>서울 남쪽</option><option>서울 북쪽</option><option>수도권</option><option>아직 모름</option>
</select>
<p class="field-error" id="region-error"></p>
</div>
<fieldset class="form-field">
<legend>희망 상권</legend>
<div class="radio-grid">
<label><input type="radio" name="areaKind" value="업무가" required /><span>업무가</span></label>
<label><input type="radio" name="areaKind" value="주거지" /><span>주거지</span></label>
<label><input type="radio" name="areaKind" value="역 주변" /><span>역 주변</span></label>
<label><input type="radio" name="areaKind" value="대학가" /><span>대학가</span></label>
<label><input type="radio" name="areaKind" value="상점가" /><span>상점가</span></label>
<label><input type="radio" name="areaKind" value="아직 모름" /><span>아직 모름</span></label>
</div>
<p class="field-error" id="area-kind-error"></p>
</fieldset>
<div class="form-actions"><button class="button button-primary" type="button" data-next>점포 조건으로</button></div>
</section>
<section class="form-step" data-step="2" aria-labelledby="step-two-heading">
<p class="step-count">2 / 3</p>
<h3 id="step-two-heading">점포는 어느 단계인가요?</h3>
<fieldset class="form-field">
<legend>점포 상태</legend>
<div class="radio-grid">
<label><input type="radio" name="storePossession" value="아직 없음" required /><span>아직 없음</span></label>
<label><input type="radio" name="storePossession" value="찾는 중" /><span>찾는 중</span></label>
<label><input type="radio" name="storePossession" value="협의 중" /><span>협의 중</span></label>
<label><input type="radio" name="storePossession" value="보유함" /><span>보유함</span></label>
</div>
<p class="field-error" id="store-possession-error"></p>
</fieldset>
<div class="form-field area-field" id="area-field" hidden>
<label for="exclusive-area">전용 면적</label>
<div class="unit-input"><input id="exclusive-area" name="exclusiveArea" type="number" min="0.1" step="0.1" inputmode="decimal" /><span>㎡</span></div>
<output id="area-conversion" for="exclusive-area">면적을 입력하면 평으로도 보여 드립니다.</output>
<small>환산값은 적합 판정이 아닙니다.</small>
<p class="field-error" id="exclusive-area-error"></p>
</div>
<div class="form-field">
<label for="opening-window">희망 시작 시기</label>
<select id="opening-window" name="openingWindow" required>
<option value="">시기 고르기</option><option>석 달 안</option><option>석 달에서 여섯 달</option><option>여섯 달에서 열두 달</option><option>아직 모름</option>
</select>
<p class="field-error" id="opening-window-error"></p>
</div>
<fieldset class="form-field">
<legend>매장 운영 경험</legend>
<div class="radio-grid radio-grid-two">
<label><input type="radio" name="experience" value="있음" required /><span>있음</span></label>
<label><input type="radio" name="experience" value="없음" /><span>없음</span></label>
</div>
<p class="field-error" id="experience-error"></p>
</fieldset>
<div class="form-actions split-actions"><button class="button button-ghost" type="button" data-back>이전</button><button class="button button-primary" type="button" data-next>입력 내용 검토</button></div>
</section>
<section class="form-step" data-step="3" aria-labelledby="step-three-heading">
<p class="step-count">3 / 3</p>
<h3 id="step-three-heading">보내기 전에 확인하세요.</h3>
<dl class="review-list" id="review-list">
<div><dt>예시 호칭</dt><dd data-review="demoName">—</dd></div>
<div><dt>희망 지역</dt><dd data-review="region">—</dd></div>
<div><dt>희망 상권</dt><dd data-review="areaKind">—</dd></div>
<div><dt>점포 상태</dt><dd data-review="storePossession">—</dd></div>
<div><dt>전용 면적</dt><dd data-review="exclusiveArea">해당 없음</dd></div>
<div><dt>희망 시기</dt><dd data-review="openingWindow">—</dd></div>
<div><dt>운영 경험</dt><dd data-review="experience">—</dd></div>
</dl>
<label class="consent-check"><input type="checkbox" id="demo-consent" required /><span>실제 문의가 아니며 개인정보를 입력하지 않았음을 확인했습니다.</span></label>
<p class="field-error" id="demo-consent-error"></p>
<div class="form-actions split-actions"><button class="button button-ghost" type="button" data-back>수정하기</button><button class="button button-primary" type="submit" id="submit-inquiry">문의 준비 마치기</button></div>
<p class="submit-state" id="submit-state" role="alert"></p>
</section>
</form>
<section class="inquiry-success" id="inquiry-success" hidden aria-labelledby="success-heading" tabindex="-1">
<span class="success-mark" aria-hidden="true">✓</span>
<p class="section-kicker">화면 체험 완료</p>
<h3 id="success-heading">문의 준비가 끝났어요.</h3>
<p>제출 절차를 보여 주는 예시이며 실제 담당자에게 전송되지 않았습니다.</p>
<div class="success-actions"><button class="button button-ghost" type="button" id="review-again">입력 내용 다시 보기</button><button class="button button-primary" type="button" id="restart-form">처음부터 보기</button></div>
</section>
</div>
</div>
</section>
<section class="franchise-close" aria-labelledby="close-heading">
<div class="wrap close-row">
<div class="close-heading-copy"><p class="section-kicker">철판클럽</p><h2 id="close-heading">숫자를 꾸미지 않아야<br />조건을 제대로 볼 수 있습니다.</h2></div>
<a class="button button-light" href="../">소비자 홈으로 돌아가기</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-grid franchise-foot-grid">
<div><a class="brand brand-foot" href="../"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span>철판클럽</span></a><p>눌러 굽고, 바로 건넨다.</p></div>
<div><strong>가맹 안내</strong><a href="#model">운영 방식</a><a href="#site-fit">상권과 점포</a><a href="#process">개설 절차</a><a href="#cost">비용 항목</a></div>
<div class="foot-truth"><strong>쇼케이스 고지</strong><p>가상 브랜드이며 실제 가맹 모집, 수익 안내, 계약과 문의 전송을 제공하지 않습니다.</p><p class="mono">DESIGN SHOWCASE · 2026</p></div>
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,462 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>철판클럽 — 눌러 굽고, 바로 건넨다</title>
<meta
name="description"
content="서울의 빠른 한 끼를 위한 가상 눌림 버거 브랜드 철판클럽의 메뉴, 조합기, 예시 매장과 운영 방식을 살펴보세요."
/>
<meta property="og:title" content="철판클럽 — 눌러 굽고, 바로 건넨다" />
<meta
property="og:description"
content="메뉴를 고르고, 재료를 조합하고, 예시 매장 운영 상태까지 확인하는 국내형 버거 쇼케이스."
/>
<meta property="og:type" content="website" />
<meta property="og:image" content="./og.png" />
<meta property="og:image:alt" content="철판 주방에서 포장한 버거를 건네는 장면" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="철판클럽 — 눌러 굽고, 바로 건넨다" />
<meta
name="twitter:description"
content="메뉴를 고르고 재료를 조합해 보는 국내형 버거 브랜드 쇼케이스."
/>
<meta name="twitter:image" content="./og.png" />
<meta name="theme-color" content="#f4f1e9" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/tokens.css?v=1" />
<link rel="stylesheet" href="./styles/site.css?v=1" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%232147e7'/%3E%3Cpath d='M6 9h20M8 16h16M10 23h12' stroke='%23fffdf8' stroke-width='3' stroke-linecap='square'/%3E%3C/svg%3E"
/>
<script>document.documentElement.classList.add("is-js");</script>
<script src="./scripts/site.js?v=1" defer></script>
</head>
<body>
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
<div class="truth-bar" role="note">
<p>쇼케이스용 가상 브랜드 · 가격과 매장 정보는 화면 동작 확인용 예시</p>
</div>
<header class="site-head" id="top">
<div class="wrap head-row">
<a class="brand" href="#top" aria-label="철판클럽 처음으로">
<span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span>
<span>철판클럽</span>
</a>
<nav class="head-nav" aria-label="주요 메뉴">
<a href="#menu">메뉴</a>
<a href="#builder">조합하기</a>
<a href="#stores">매장 찾기</a>
<a href="./franchise/">가맹 안내</a>
</nav>
<a class="head-action" href="#stores">가까운 매장 보기</a>
</div>
</header>
<main id="main">
<section class="hero" aria-labelledby="hero-heading">
<div class="hero-copy">
<p class="section-kicker">서울 도심 눌림 버거</p>
<h1 id="hero-heading">눌러 굽고,<br />바로 건넨다.</h1>
<p class="hero-lead">
얇게 눌러 가장자리는 바삭하게. 주문 뒤 조립하고 포장해, 퇴근길 한 상자로 건넵니다.
</p>
<div class="hero-actions">
<a class="button button-primary" href="#menu">오늘 메뉴 보기</a>
<a class="text-link" href="#builder">내 버거 조합하기 <span aria-hidden="true">→</span></a>
</div>
<dl class="hero-facts">
<div><dt>굽기</dt><dd>얇은 패티를 주문 뒤 눌러 굽기</dd></div>
<div><dt>받기</dt><dd>포장과 배달 가능 여부를 매장별 표시</dd></div>
</dl>
</div>
<figure class="hero-media image-shell">
<picture>
<source media="(max-width: 820px)" srcset="./assets/hero-handoff-mobile.webp" />
<img
src="./assets/hero-handoff.webp"
width="1600"
height="900"
alt="밝은 철판 주방에서 직원이 포장한 버거를 건네는 모습"
fetchpriority="high"
/>
</picture>
<span class="image-fallback" aria-hidden="true">버거 포장 장면</span>
<figcaption>
<span>철판에서 상자까지</span>
<span>생성 이미지 · 실제 매장 아님</span>
</figcaption>
</figure>
<aside class="hero-order" aria-label="오늘 고르기">
<span class="order-index">01</span>
<p>철판 더블</p>
<strong>예시 단품 8,900원</strong>
<button type="button" data-preset="double">이 조합으로 시작</button>
</aside>
</section>
<section class="service-strip" aria-label="이용 방식">
<div class="wrap service-row">
<p><span aria-hidden="true">●</span> 주문 뒤 조리</p>
<p>단품과 세트</p>
<p>포장 여부 매장별 확인</p>
<p>배달 여부 매장별 확인</p>
<p class="mono">GRIDDLE CLUB</p>
</div>
</section>
<section class="section menu-section" id="menu" aria-labelledby="menu-heading">
<div class="wrap">
<div class="section-head split-head">
<div>
<p class="section-kicker">오늘 고를 메뉴</p>
<h2 id="menu-heading">버거가 먼저 보이게.</h2>
</div>
<p class="section-note">
모든 가격은 조합기 동작을 보여 주기 위한 예시입니다. 실제 판매와 주문은 이루어지지 않습니다.
</p>
</div>
<div class="menu-toolbar" role="group" aria-label="메뉴 분류">
<button type="button" class="filter-button is-active" data-menu-filter="all" aria-pressed="true">전체</button>
<button type="button" class="filter-button" data-menu-filter="burger" aria-pressed="false">버거</button>
<button type="button" class="filter-button" data-menu-filter="side" aria-pressed="false">곁들임</button>
<button type="button" class="filter-button" data-menu-filter="drink" aria-pressed="false">음료</button>
</div>
<div class="menu-grid" id="menu-grid">
<article class="menu-item menu-item-featured" data-menu-category="burger">
<div class="menu-photo image-shell">
<img
src="./assets/signature-double.webp"
width="1400"
height="933"
alt="철판 패티 두 장과 치즈, 구운 양파를 넣은 버거"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">철판 더블 사진</span>
<span class="menu-number">01</span>
</div>
<div class="menu-body">
<div><p class="menu-state">대표 메뉴</p><h3>철판 더블</h3></div>
<p>얇은 패티 두 장, 치즈, 구운 양파, 피클, 집 소스.</p>
<div class="menu-foot">
<span><small>예시 단품</small> 8,900원</span>
<button type="button" data-preset="double">조합하기</button>
</div>
</div>
</article>
<article class="menu-item menu-item-graphic" data-menu-category="burger">
<div class="ingredient-stack" aria-hidden="true">
<span class="bun"></span><span class="pickle"></span><span class="cheese"></span><span class="patty"></span><span class="bun bottom"></span>
<b>02</b>
</div>
<div class="menu-body">
<div><p class="menu-state">가볍게</p><h3>철판 싱글</h3></div>
<p>패티 한 장, 치즈, 피클, 집 소스. 단품으로 빠르게.</p>
<div class="menu-foot">
<span><small>예시 단품</small> 6,900원</span>
<button type="button" data-preset="single">조합하기</button>
</div>
</div>
</article>
<article class="menu-item menu-item-blue" data-menu-category="burger">
<div class="menu-poster" aria-hidden="true"><span>03</span><i></i><i></i><i></i></div>
<div class="menu-body">
<div><p class="menu-state">매운 피클</p><h3>고추 피클 더블</h3></div>
<p>고추 피클과 매운 소스를 더한 더블. 맵기는 예시입니다.</p>
<div class="menu-foot">
<span><small>예시 단품</small> 9,400원</span>
<button type="button" data-preset="spicy">조합하기</button>
</div>
</div>
</article>
<article class="menu-item menu-item-small" data-menu-category="side">
<div class="menu-body">
<div><p class="menu-state">곁들임</p><h3>얇은 감자</h3></div>
<p>가늘게 튀겨 소금으로 마무리합니다.</p>
<div class="menu-foot"><span><small>예시 가격</small> 3,600원</span></div>
</div>
</article>
<article class="menu-item menu-item-small" data-menu-category="side">
<div class="menu-body">
<div><p class="menu-state">곁들임</p><h3>양파 한 컵</h3></div>
<p>철판에서 단맛을 낸 양파를 따로 담습니다.</p>
<div class="menu-foot"><span><small>예시 가격</small> 2,800원</span></div>
</div>
</article>
<article class="menu-item menu-item-small" data-menu-category="drink">
<div class="menu-body">
<div><p class="menu-state">음료</p><h3>탄산 한 잔</h3></div>
<p>매장에서 고른 음료 한 잔. 제품별 성분을 확인해 주세요.</p>
<div class="menu-foot"><span><small>예시 가격</small> 2,400원</span></div>
</div>
</article>
</div>
<div class="empty-state" id="menu-empty" hidden role="status">
<strong>이 분류에 준비된 예시 메뉴가 없어요.</strong>
<button type="button" data-show-all-menu>모든 메뉴 보기</button>
</div>
</div>
</section>
<section class="section builder-section" id="builder" aria-labelledby="builder-heading">
<div class="wrap">
<div class="section-head split-head">
<div>
<p class="section-kicker">버거 조합기</p>
<h2 id="builder-heading">상자에 담길 내용을<br />먼저 정하세요.</h2>
</div>
<p class="section-note">재료 선택과 예시 총액은 실제로 바뀝니다. 주문 전송 기능은 없습니다.</p>
</div>
<div class="builder-layout">
<form class="builder-form" id="burger-builder" novalidate>
<fieldset>
<legend><span>01</span> 맛 고르기</legend>
<div class="choice-row">
<label><input type="radio" name="flavor" value="classic" checked /><span><strong>기본</strong><small>집 소스와 피클</small></span></label>
<label><input type="radio" name="flavor" value="spicy" /><span><strong>매운 피클</strong><small>고추 피클과 매운 소스</small></span></label>
</div>
</fieldset>
<fieldset>
<legend><span>02</span> 크기와 치즈</legend>
<div class="choice-row choice-row-compact">
<label><input type="radio" name="patty" value="1" /><span>패티 한 장</span></label>
<label><input type="radio" name="patty" value="2" checked /><span>패티 두 장</span></label>
</div>
<label class="field-label" for="cheese">치즈</label>
<select id="cheese" name="cheese">
<option value="0">치즈 없음</option>
<option value="1" selected>치즈 한 장</option>
<option value="2">치즈 두 장</option>
</select>
</fieldset>
<fieldset>
<legend><span>03</span> 양파와 피클</legend>
<div class="select-grid">
<label>구운 양파
<select name="onion"><option value="none">없음</option><option value="regular" selected>기본</option><option value="extra">많이</option></select>
</label>
<label>피클
<select name="pickle"><option value="none">없음</option><option value="regular" selected>기본</option><option value="extra">많이</option></select>
</label>
<label>소스
<select name="sauce"><option value="none">없음</option><option value="regular" selected>기본</option><option value="side">따로</option></select>
</label>
</div>
</fieldset>
<fieldset>
<legend><span>04</span> 단품 또는 세트</legend>
<div class="choice-row choice-row-compact">
<label><input type="radio" name="serving" value="single" checked /><span>단품</span></label>
<label><input type="radio" name="serving" value="set" /><span>세트 더하기</span></label>
</div>
<div class="set-options" id="set-options">
<p id="set-heading">세트 구성</p>
<div class="select-grid">
<label>곁들임
<select name="side" disabled><option value="fries">얇은 감자</option><option value="onion">양파 한 컵</option></select>
</label>
<label>음료
<select name="drink" disabled><option value="cola">탄산 한 잔</option><option value="water">물 한 병</option></select>
</label>
</div>
</div>
</fieldset>
<fieldset>
<legend><span>05</span> 받을 예시 매장</legend>
<label class="field-label" for="builder-store">매장</label>
<select id="builder-store" name="store">
<option value="seongsu">성수 예시점 · 영업 중 예시</option>
<option value="yeonnam">연남 예시점 · 영업 종료 예시</option>
<option value="mullae">문래 예시점 · 오늘은 쉬어요</option>
</select>
<p class="field-help">매장과 영업 상태도 화면 동작용 예시입니다.</p>
</fieldset>
</form>
<aside class="builder-summary" aria-labelledby="summary-heading">
<div class="summary-photo image-shell">
<img
src="./assets/signature-double.webp"
width="1400"
height="933"
alt="조합 결과를 설명하는 철판 더블 버거"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">조합 버거 사진</span>
<span class="summary-stamp">화면 예시</span>
</div>
<div class="summary-body">
<p class="section-kicker">고른 내용</p>
<h3 id="summary-heading">철판 더블</h3>
<p id="summary-lines">패티 두 장 / 치즈 한 장 / 양파 기본 / 피클 기본 / 소스 기본</p>
<dl class="summary-list">
<div><dt>받기</dt><dd id="summary-serving">단품</dd></div>
<div><dt>예시 매장</dt><dd id="summary-store">성수 예시점</dd></div>
<div><dt>상태</dt><dd id="summary-store-state">영업 중 예시</dd></div>
</dl>
<div class="allergen-note" id="allergen-note">
<strong>알레르기 예시</strong>
<p id="summary-allergens">알, 우유, 대두, 밀, 쇠고기</p>
<small>같은 철판과 조리 도구를 함께 씁니다. 재료를 빼도 알레르기 안전을 보장하지 않습니다.</small>
</div>
<div class="summary-total"><span>화면 계산용 예시 총액</span><strong id="summary-total">8,900원</strong></div>
<p class="truth-inline">실제 주문이나 결제로 전송되지 않습니다.</p>
<button class="button button-primary button-full" type="button" id="save-builder">선택 내용 저장</button>
<a class="button button-ghost button-full" href="#stores" id="other-store-link">다른 매장 찾기</a>
<p class="save-status" id="save-status" role="status" aria-live="polite"></p>
</div>
</aside>
</div>
</div>
</section>
<section class="section process-section" id="process" aria-labelledby="process-heading">
<div class="wrap process-grid">
<div class="process-copy">
<p class="section-kicker">철판에서 상자까지</p>
<h2 id="process-heading">빠르다는 말 대신,<br />순서를 보여 줍니다.</h2>
<ol class="process-list">
<li><span>01</span><div><strong>누르기</strong><p>얇은 패티를 뜨거운 철판에 눌러 가장자리를 만듭니다.</p></div></li>
<li><span>02</span><div><strong>겹치기</strong><p>치즈, 구운 양파, 피클을 고른 순서대로 올립니다.</p></div></li>
<li><span>03</span><div><strong>건네기</strong><p>유산지와 상자에 담고 포장 상태를 확인합니다.</p></div></li>
</ol>
</div>
<figure class="process-media image-shell">
<img
src="./assets/griddle-press.webp"
width="1400"
height="933"
alt="조리사가 철판에서 패티를 눌러 굽고 양파를 익히는 모습"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">철판 조리 장면</span>
<figcaption>생성 이미지 · 실제 조리 시설 아님</figcaption>
</figure>
</div>
</section>
<section class="section stores-section" id="stores" aria-labelledby="stores-heading">
<div class="wrap">
<div class="section-head split-head">
<div><p class="section-kicker">매장 찾기</p><h2 id="stores-heading">지금 받을 수 있는지<br />먼저 확인하세요.</h2></div>
<p class="section-note">주소, 전화, 거리와 운영 상태를 꾸며내지 않습니다. 아래 세 곳은 상태 화면을 위한 예시 매장입니다.</p>
</div>
<div class="store-tools">
<div class="search-box">
<input id="store-search" type="search" placeholder="동네 또는 매장명 검색" autocomplete="off" aria-label="예시 매장 검색" />
<button type="button" id="clear-store-search" hidden>지우기</button>
</div>
<div class="region-filters" role="group" aria-label="지역 고르기">
<button type="button" class="is-active" data-region="all" aria-pressed="true">전체</button>
<button type="button" data-region="성동" aria-pressed="false">성동</button>
<button type="button" data-region="마포" aria-pressed="false">마포</button>
<button type="button" data-region="영등포" aria-pressed="false">영등포</button>
</div>
</div>
<div class="store-layout">
<div class="store-list" id="store-list">
<article class="store-card is-selected" data-store="seongsu" data-region="성동" data-search="성수 예시점 성동 성수동" tabindex="-1">
<div class="store-card-top"><span class="store-state state-open">영업 중 예시</span><span>예시 매장</span></div>
<h3>성수 예시점</h3>
<p>서울 성동구 성수동 일대 · 상세 주소 없음</p>
<dl><div><dt>운영</dt><dd>예시 11:00–21:30</dd></div><div><dt>마지막 주문</dt><dd>예시 21:00</dd></div></dl>
<ul aria-label="이용 방식"><li>포장 가능 예시</li><li>배달 가능 예시</li></ul>
<button type="button" data-select-store="seongsu">이 매장으로 조합 보기</button>
</article>
<article class="store-card" data-store="yeonnam" data-region="마포" data-search="연남 예시점 마포 연남동" tabindex="-1">
<div class="store-card-top"><span class="store-state state-closed">영업 종료 예시</span><span>예시 매장</span></div>
<h3>연남 예시점</h3>
<p>서울 마포구 연남동 일대 · 상세 주소 없음</p>
<dl><div><dt>운영</dt><dd>예시 11:30–20:30</dd></div><div><dt>마지막 주문</dt><dd>오늘 마감</dd></div></dl>
<ul aria-label="이용 방식"><li>포장 마감</li><li>배달 마감</li></ul>
<button type="button" data-select-store="yeonnam">종료 상태로 조합 보기</button>
</article>
<article class="store-card" data-store="mullae" data-region="영등포" data-search="문래 예시점 영등포 문래동" tabindex="-1">
<div class="store-card-top"><span class="store-state state-pause">오늘은 쉬어요</span><span>예시 매장</span></div>
<h3>문래 예시점</h3>
<p>서울 영등포구 문래동 일대 · 상세 주소 없음</p>
<dl><div><dt>운영</dt><dd>임시 휴무 예시</dd></div><div><dt>다음 확인</dt><dd>매장별 안내 필요</dd></div></dl>
<ul aria-label="이용 방식"><li>포장 불가</li><li>배달 불가</li></ul>
<button type="button" data-select-store="mullae">휴무 상태로 조합 보기</button>
</article>
</div>
<aside class="store-map" aria-label="예시 매장 분포 그림">
<div class="map-grid" aria-hidden="true"></div>
<span class="map-river" aria-hidden="true"></span>
<button type="button" class="map-pin pin-seongsu is-active" data-map-store="seongsu" aria-label="성수 예시점 고르기"><span>성수</span></button>
<button type="button" class="map-pin pin-yeonnam" data-map-store="yeonnam" aria-label="연남 예시점 고르기"><span>연남</span></button>
<button type="button" class="map-pin pin-mullae" data-map-store="mullae" aria-label="문래 예시점 고르기"><span>문래</span></button>
<p>실제 지도와 위치가 아닌 화면 설명용 도식</p>
</aside>
</div>
<div class="empty-state store-empty" id="store-empty" hidden role="status">
<strong>찾는 조건과 맞는 예시 매장이 없어요.</strong>
<p>검색어와 지역 조건을 지우고 다시 확인해 주세요.</p>
<button type="button" id="reset-store-search">검색 조건 지우기</button>
</div>
</div>
</section>
<section class="franchise-callout" aria-labelledby="franchise-heading">
<figure class="franchise-image image-shell">
<img
src="./assets/kitchen-flow.webp"
width="1400"
height="933"
alt="좁고 밝은 주방에서 두 직원이 철판 조리와 포장을 나누어 맡는 모습"
loading="lazy"
/>
<span class="image-fallback" aria-hidden="true">주방 운영 동선 장면</span>
</figure>
<div class="franchise-copy">
<p class="section-kicker">가맹을 생각한다면</p>
<h2 id="franchise-heading">매출 약속보다<br />운영 조건부터.</h2>
<p>상권, 점포, 면적, 조리 동선과 비용 미정 항목을 먼저 확인할 수 있게 분리했습니다.</p>
<a class="button button-light" href="./franchise/">가맹 운영 조건 보기 <span aria-hidden="true">→</span></a>
<small>실제 가맹 모집과 상담이 아닌 쇼케이스용 흐름입니다.</small>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-grid">
<div><a class="brand brand-foot" href="#top"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span>철판클럽</span></a><p>눌러 굽고, 바로 건넨다.</p></div>
<div><strong>살펴보기</strong><a href="#menu">메뉴</a><a href="#builder">조합기</a><a href="#stores">예시 매장</a><a href="./franchise/">가맹 안내</a></div>
<div><strong>정보 안내</strong><p>알레르기 정보도 예시입니다.</p><p>원산지와 영양 정보는 실제 제품 자료가 아닙니다.</p></div>
<div class="foot-truth"><strong>쇼케이스 고지</strong><p>가상 브랜드이며 실제 주문, 결제, 매장 방문, 가맹 문의를 제공하지 않습니다.</p><p class="mono">DESIGN SHOWCASE · 2026</p></div>
</div>
</footer>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

View file

@ -0,0 +1,300 @@
(() => {
"use strict";
const fixture = new URLSearchParams(window.location.search).get("fixture") || "";
const form = document.querySelector("#franchise-form");
const formSteps = [...document.querySelectorAll("[data-step]")];
const indicators = [...document.querySelectorAll("[data-step-indicator]")];
const indicatorList = document.querySelector(".step-indicator");
const areaField = document.querySelector("#area-field");
const areaInput = document.querySelector("#exclusive-area");
const areaOutput = document.querySelector("#area-conversion");
const successPanel = document.querySelector("#inquiry-success");
const submitState = document.querySelector("#submit-state");
const submitButton = document.querySelector("#submit-inquiry");
let currentStep = 1;
const markBrokenImage = (image) => {
const shell = image.closest(".image-shell");
if (shell) shell.classList.add("is-broken");
};
document.querySelectorAll(".image-shell img").forEach((image) => {
image.addEventListener("error", () => markBrokenImage(image));
if (image.complete && image.naturalWidth === 0) markBrokenImage(image);
});
if (!form) return;
const getRadioValue = (name) => form.querySelector(`input[name="${name}"]:checked`)?.value || "";
const getSelectValue = (name) => {
const field = form.elements.namedItem(name);
return field instanceof HTMLSelectElement ? field.value : "";
};
const getInputValue = (name) => {
const field = form.elements.namedItem(name);
return field instanceof HTMLInputElement ? field.value.trim() : "";
};
const setStep = (step) => {
currentStep = step;
formSteps.forEach((section) => section.classList.toggle("is-current", Number(section.dataset.step) === step));
indicators.forEach((item) => {
const active = Number(item.dataset.stepIndicator) === step;
item.classList.toggle("is-current", active);
if (active) item.setAttribute("aria-current", "step");
else item.removeAttribute("aria-current");
});
const heading = form.querySelector(`[data-step="${step}"] h3`);
if (heading instanceof HTMLElement) {
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
}
};
const clearFieldError = (fieldId, errorId) => {
const field = document.querySelector(`#${fieldId}`);
const error = document.querySelector(`#${errorId}`);
const wrapper = field?.closest(".form-field");
wrapper?.classList.remove("has-error");
field?.removeAttribute("aria-invalid");
field?.removeAttribute("aria-describedby");
if (error) error.textContent = "";
};
const setFieldError = (fieldId, errorId, message) => {
const field = document.querySelector(`#${fieldId}`);
const error = document.querySelector(`#${errorId}`);
const wrapper = field?.closest(".form-field");
wrapper?.classList.add("has-error");
field?.setAttribute("aria-invalid", "true");
field?.setAttribute("aria-describedby", errorId);
if (error) error.textContent = message;
return field;
};
const clearRadioError = (name, errorId) => {
const radios = [...form.querySelectorAll(`input[name="${name}"]`)];
const wrapper = radios[0]?.closest(".form-field");
wrapper?.classList.remove("has-error");
radios.forEach((radio) => {
radio.removeAttribute("aria-invalid");
radio.removeAttribute("aria-describedby");
});
const error = document.querySelector(`#${errorId}`);
if (error) error.textContent = "";
};
const setRadioError = (name, errorId, message) => {
const radios = [...form.querySelectorAll(`input[name="${name}"]`)];
const wrapper = radios[0]?.closest(".form-field");
wrapper?.classList.add("has-error");
radios.forEach((radio) => {
radio.setAttribute("aria-invalid", "true");
radio.setAttribute("aria-describedby", errorId);
});
const error = document.querySelector(`#${errorId}`);
if (error) error.textContent = message;
return radios[0];
};
const validateStepOne = () => {
const invalid = [];
clearFieldError("demo-name", "demo-name-error");
clearFieldError("region", "region-error");
clearRadioError("areaKind", "area-kind-error");
if (getInputValue("demoName").length < 2) {
invalid.push(setFieldError("demo-name", "demo-name-error", "예시 호칭을 두 글자 이상 입력해 주세요."));
}
if (!getSelectValue("region")) {
invalid.push(setFieldError("region", "region-error", "희망 지역을 골라 주세요."));
}
if (!getRadioValue("areaKind")) {
invalid.push(setRadioError("areaKind", "area-kind-error", "희망 상권을 골라 주세요."));
}
invalid[0]?.focus();
return invalid.length === 0;
};
const requiresArea = () => ["협의 중", "보유함"].includes(getRadioValue("storePossession"));
const updateAreaField = () => {
const required = requiresArea();
if (areaField) areaField.hidden = !required;
if (areaInput instanceof HTMLInputElement) {
areaInput.required = required;
if (!required) {
areaInput.value = "";
clearFieldError("exclusive-area", "exclusive-area-error");
if (areaOutput) areaOutput.textContent = "면적을 입력하면 평으로도 보여 드립니다.";
}
}
};
const updateAreaConversion = () => {
if (!(areaInput instanceof HTMLInputElement) || !areaOutput) return;
const squareMeters = Number(areaInput.value);
if (!Number.isFinite(squareMeters) || squareMeters <= 0) {
areaOutput.textContent = "면적을 입력하면 평으로도 보여 드립니다.";
return;
}
const pyeong = Math.round((squareMeters / 3.3058) * 10) / 10;
areaOutput.textContent = `${squareMeters.toLocaleString("ko-KR")}㎡ · 약 ${pyeong.toLocaleString("ko-KR")}평`;
};
const validateStepTwo = () => {
const invalid = [];
clearRadioError("storePossession", "store-possession-error");
clearFieldError("exclusive-area", "exclusive-area-error");
clearFieldError("opening-window", "opening-window-error");
clearRadioError("experience", "experience-error");
if (!getRadioValue("storePossession")) {
invalid.push(setRadioError("storePossession", "store-possession-error", "점포 상태를 골라 주세요."));
}
if (requiresArea()) {
const value = Number(getInputValue("exclusiveArea"));
if (!Number.isFinite(value) || value <= 0) {
invalid.push(setFieldError("exclusive-area", "exclusive-area-error", "면적은 0보다 큰 숫자로 입력해 주세요."));
}
}
if (!getSelectValue("openingWindow")) {
invalid.push(setFieldError("opening-window", "opening-window-error", "희망 시작 시기를 골라 주세요."));
}
if (!getRadioValue("experience")) {
invalid.push(setRadioError("experience", "experience-error", "운영 경험 여부를 골라 주세요."));
}
invalid[0]?.focus();
return invalid.length === 0;
};
const reviewValues = () => {
const squareMeters = Number(getInputValue("exclusiveArea"));
const areaLabel = requiresArea() && squareMeters > 0
? `${squareMeters.toLocaleString("ko-KR")}㎡ · 약 ${(Math.round((squareMeters / 3.3058) * 10) / 10).toLocaleString("ko-KR")}평`
: "해당 없음";
const values = {
demoName: getInputValue("demoName"),
region: getSelectValue("region"),
areaKind: getRadioValue("areaKind"),
storePossession: getRadioValue("storePossession"),
exclusiveArea: areaLabel,
openingWindow: getSelectValue("openingWindow"),
experience: getRadioValue("experience"),
};
Object.entries(values).forEach(([key, value]) => {
const target = document.querySelector(`[data-review="${key}"]`);
if (target) target.textContent = value || "—";
});
};
form.querySelectorAll("[data-next]").forEach((button) => {
button.addEventListener("click", () => {
if (currentStep === 1 && validateStepOne()) setStep(2);
else if (currentStep === 2 && validateStepTwo()) {
reviewValues();
setStep(3);
}
});
});
form.querySelectorAll("[data-back]").forEach((button) => {
button.addEventListener("click", () => setStep(Math.max(1, currentStep - 1)));
});
form.querySelectorAll("input[name='storePossession']").forEach((input) => {
input.addEventListener("change", updateAreaField);
});
areaInput?.addEventListener("input", updateAreaConversion);
form.addEventListener("input", (event) => {
const field = event.target;
if (!(field instanceof HTMLElement) || !field.id) return;
const errorId = `${field.id}-error`;
if (document.querySelector(`#${errorId}`)) clearFieldError(field.id, errorId);
});
form.addEventListener("change", (event) => {
const field = event.target;
if (!(field instanceof HTMLInputElement)) return;
if (field.type === "radio") {
const names = {
areaKind: "area-kind-error",
storePossession: "store-possession-error",
experience: "experience-error",
};
if (names[field.name]) clearRadioError(field.name, names[field.name]);
}
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const consent = document.querySelector("#demo-consent");
const consentError = document.querySelector("#demo-consent-error");
if (consent instanceof HTMLInputElement) {
consent.removeAttribute("aria-invalid");
consent.removeAttribute("aria-describedby");
}
if (consentError) consentError.textContent = "";
if (!(consent instanceof HTMLInputElement) || !consent.checked) {
consent?.setAttribute("aria-invalid", "true");
consent?.setAttribute("aria-describedby", "demo-consent-error");
if (consentError) consentError.textContent = "문의 체험 안내를 확인해 주세요.";
consent?.focus();
return;
}
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = true;
submitButton.textContent = "화면 상태 확인 중";
}
if (submitState) submitState.textContent = "";
window.setTimeout(() => {
if (fixture === "inquiry-fail") {
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = false;
submitButton.textContent = "다시 시도하기";
}
if (submitState) {
submitState.textContent = "지금은 문의 준비 상태를 바꾸지 못했어요. 입력 내용은 화면에 그대로 두었습니다.";
}
return;
}
form.hidden = true;
if (indicatorList) indicatorList.hidden = true;
if (successPanel) {
successPanel.hidden = false;
successPanel.focus();
}
if (submitButton instanceof HTMLButtonElement) {
submitButton.disabled = false;
submitButton.textContent = "문의 준비 마치기";
}
}, 450);
});
document.querySelector("#review-again")?.addEventListener("click", () => {
if (successPanel) successPanel.hidden = true;
form.hidden = false;
if (indicatorList) indicatorList.hidden = false;
setStep(3);
});
document.querySelector("#restart-form")?.addEventListener("click", () => {
form.reset();
form.hidden = false;
if (successPanel) successPanel.hidden = true;
if (indicatorList) indicatorList.hidden = false;
if (submitState) submitState.textContent = "";
document.querySelectorAll(".field-error").forEach((error) => { error.textContent = ""; });
document.querySelectorAll(".has-error").forEach((field) => field.classList.remove("has-error"));
updateAreaField();
setStep(1);
});
updateAreaField();
setStep(1);
})();

View file

@ -0,0 +1,261 @@
(() => {
"use strict";
const fixture = new URLSearchParams(window.location.search).get("fixture") || "";
const formatWon = (value) => `${new Intl.NumberFormat("ko-KR").format(value)}원`;
const markBrokenImage = (image) => {
const shell = image.closest(".image-shell");
if (shell) shell.classList.add("is-broken");
};
document.querySelectorAll(".image-shell img").forEach((image) => {
image.addEventListener("error", () => markBrokenImage(image));
if (image.complete && image.naturalWidth === 0) markBrokenImage(image);
});
const menuButtons = [...document.querySelectorAll("[data-menu-filter]")];
const menuItems = [...document.querySelectorAll("[data-menu-category]")];
const menuEmpty = document.querySelector("#menu-empty");
const setMenuFilter = (filter) => {
const forceMenuEmpty = new URLSearchParams(window.location.search).get("fixture") === "menu-empty";
let visibleCount = 0;
menuButtons.forEach((button) => {
const active = button.dataset.menuFilter === filter;
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", String(active));
});
menuItems.forEach((item) => {
const visible = !forceMenuEmpty && (filter === "all" || item.dataset.menuCategory === filter);
item.hidden = !visible;
if (visible) visibleCount += 1;
});
if (menuEmpty) menuEmpty.hidden = visibleCount > 0;
};
menuButtons.forEach((button) => {
button.addEventListener("click", () => setMenuFilter(button.dataset.menuFilter || "all"));
});
document.querySelector("[data-show-all-menu]")?.addEventListener("click", () => {
if (fixture === "menu-empty") {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.delete("fixture");
window.history.replaceState({}, "", nextUrl);
}
setMenuFilter("all");
});
if (fixture === "menu-empty") setMenuFilter("all");
const builder = document.querySelector("#burger-builder");
const builderSection = document.querySelector("#builder");
const setOptions = document.querySelector("#set-options");
const summaryHeading = document.querySelector("#summary-heading");
const summaryLines = document.querySelector("#summary-lines");
const summaryServing = document.querySelector("#summary-serving");
const summaryStore = document.querySelector("#summary-store");
const summaryStoreState = document.querySelector("#summary-store-state");
const summaryAllergens = document.querySelector("#summary-allergens");
const summaryTotal = document.querySelector("#summary-total");
const saveButton = document.querySelector("#save-builder");
const saveStatus = document.querySelector("#save-status");
const stores = {
seongsu: { label: "성수 예시점", state: "영업 중 예시", available: true },
yeonnam: { label: "연남 예시점", state: "오늘 영업 종료 예시", available: false },
mullae: { label: "문래 예시점", state: "오늘은 쉬어요", available: false },
};
const fieldValue = (name) => {
const field = builder?.elements.namedItem(name);
if (!field) return "";
if (field instanceof RadioNodeList) return field.value;
return field.value;
};
const setRadio = (name, value) => {
const radio = builder?.querySelector(`input[name="${name}"][value="${value}"]`);
if (radio instanceof HTMLInputElement) radio.checked = true;
};
const builderPrice = ({ flavor, patty, cheese, serving }) => {
const flavorBase = flavor === "spicy" ? 7400 : 6900;
const pattyAdd = Number(patty) === 2 ? 2000 : 0;
const cheeseAdd = Number(cheese) === 0 ? -400 : Number(cheese) === 2 ? 400 : 0;
const setAdd = serving === "set" ? 4100 : 0;
return flavorBase + pattyAdd + cheeseAdd + setAdd;
};
const updateBuilder = () => {
if (!builder) return;
const flavor = fieldValue("flavor");
const patty = fieldValue("patty");
const cheese = fieldValue("cheese");
const onion = fieldValue("onion");
const pickle = fieldValue("pickle");
const sauce = fieldValue("sauce");
const serving = fieldValue("serving");
const storeKey = fieldValue("store");
const store = stores[storeKey] || stores.seongsu;
const soldOut = fixture === "sold-out";
const setEnabled = serving === "set";
builder.querySelectorAll("select[name='side'], select[name='drink']").forEach((select) => {
select.disabled = !setEnabled;
});
setOptions?.classList.toggle("is-disabled", !setEnabled);
const names = {
onion: { none: "양파 없음", regular: "양파 기본", extra: "양파 많이" },
pickle: { none: "피클 없음", regular: "피클 기본", extra: "피클 많이" },
sauce: { none: "소스 없음", regular: "소스 기본", side: "소스 따로" },
};
const pattyLabel = Number(patty) === 2 ? "더블" : "싱글";
const flavorLabel = flavor === "spicy" ? "고추 피클" : "철판";
const cheeseLabel = Number(cheese) === 0 ? "치즈 없음" : `치즈 ${cheese === "2" ? "두" : "한"} 장`;
const productName = `${flavorLabel} ${pattyLabel}`;
const servingLabel = serving === "set" ? "세트 · 얇은 감자와 음료" : "단품";
if (summaryHeading) summaryHeading.textContent = productName;
if (summaryLines) {
summaryLines.textContent = `패티 ${patty === "2" ? "두" : "한"} 장 / ${cheeseLabel} / ${names.onion[onion]} / ${names.pickle[pickle]} / ${names.sauce[sauce]}`;
}
if (summaryServing) summaryServing.textContent = servingLabel;
if (summaryStore) summaryStore.textContent = store.label;
if (summaryStoreState) summaryStoreState.textContent = soldOut ? "오늘 품절 예시" : store.state;
const allergens = ["알", "대두", "밀", "쇠고기"];
if (Number(cheese) > 0) allergens.splice(1, 0, "우유");
if (flavor === "spicy") allergens.push("토마토");
if (summaryAllergens) summaryAllergens.textContent = allergens.join(", ");
if (summaryTotal) summaryTotal.textContent = formatWon(builderPrice({ flavor, patty, cheese, serving }));
if (saveButton) {
saveButton.disabled = soldOut || !store.available;
if (soldOut) saveButton.textContent = "오늘 품절 예시";
else if (!store.available) saveButton.textContent = "다른 매장을 골라 주세요";
else saveButton.textContent = "선택 내용 저장";
}
if (saveStatus) {
saveStatus.textContent = "";
saveStatus.classList.remove("is-error");
}
};
builder?.addEventListener("change", updateBuilder);
const applyPreset = (preset) => {
if (!builder) return;
if (preset === "single") {
setRadio("flavor", "classic");
setRadio("patty", "1");
} else if (preset === "spicy") {
setRadio("flavor", "spicy");
setRadio("patty", "2");
} else {
setRadio("flavor", "classic");
setRadio("patty", "2");
}
updateBuilder();
builderSection?.scrollIntoView({ behavior: "smooth", block: "start" });
window.setTimeout(() => builder.querySelector("input:checked")?.focus({ preventScroll: true }), 350);
};
document.querySelectorAll("[data-preset]").forEach((button) => {
button.addEventListener("click", () => applyPreset(button.dataset.preset || "double"));
});
saveButton?.addEventListener("click", () => {
if (!builder || !saveStatus) return;
const snapshot = Object.fromEntries(new FormData(builder).entries());
try {
if (fixture === "save-fail") throw new Error("fixture");
sessionStorage.setItem("griddle-club-demo-selection", JSON.stringify(snapshot));
saveStatus.classList.remove("is-error");
saveStatus.textContent = "이 기기의 현재 탭에 선택 내용을 남겼어요. 실제 주문으로 전송되지 않습니다.";
} catch {
saveStatus.classList.add("is-error");
saveStatus.textContent = "선택 내용을 남기지 못했어요. 고른 내용은 화면에 그대로 두었습니다.";
}
});
const storeCards = [...document.querySelectorAll("[data-store]")];
const mapPins = [...document.querySelectorAll("[data-map-store]")];
const storeSearch = document.querySelector("#store-search");
const clearStoreSearch = document.querySelector("#clear-store-search");
const storeEmpty = document.querySelector("#store-empty");
const regionButtons = [...document.querySelectorAll("[data-region]")];
let activeRegion = "all";
const selectStore = (storeKey, shouldMove = false) => {
const select = builder?.elements.namedItem("store");
if (select instanceof HTMLSelectElement) select.value = storeKey;
storeCards.forEach((card) => card.classList.toggle("is-selected", card.dataset.store === storeKey));
mapPins.forEach((pin) => pin.classList.toggle("is-active", pin.dataset.mapStore === storeKey));
updateBuilder();
if (shouldMove) builderSection?.scrollIntoView({ behavior: "smooth", block: "start" });
};
document.querySelectorAll("[data-select-store]").forEach((button) => {
button.addEventListener("click", () => selectStore(button.dataset.selectStore, true));
});
mapPins.forEach((button) => {
button.addEventListener("click", () => selectStore(button.dataset.mapStore, false));
});
const filterStores = () => {
const query = storeSearch instanceof HTMLInputElement ? storeSearch.value.trim().toLocaleLowerCase("ko-KR") : "";
let visibleCount = 0;
storeCards.forEach((card) => {
const regionMatch = activeRegion === "all" || card.dataset.region === activeRegion;
const queryMatch = !query || (card.dataset.search || "").toLocaleLowerCase("ko-KR").includes(query);
const visible = fixture !== "store-none" && regionMatch && queryMatch;
card.hidden = !visible;
if (visible) visibleCount += 1;
const pin = mapPins.find((item) => item.dataset.mapStore === card.dataset.store);
if (pin) pin.hidden = !visible;
});
if (storeEmpty) storeEmpty.hidden = visibleCount > 0;
if (clearStoreSearch) clearStoreSearch.hidden = !query;
};
storeSearch?.addEventListener("input", filterStores);
clearStoreSearch?.addEventListener("click", () => {
if (storeSearch instanceof HTMLInputElement) {
storeSearch.value = "";
storeSearch.focus();
}
filterStores();
});
regionButtons.forEach((button) => {
button.addEventListener("click", () => {
activeRegion = button.dataset.region || "all";
regionButtons.forEach((item) => {
const active = item === button;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", String(active));
});
filterStores();
});
});
document.querySelector("#reset-store-search")?.addEventListener("click", () => {
activeRegion = "all";
if (storeSearch instanceof HTMLInputElement) storeSearch.value = "";
regionButtons.forEach((button) => {
const active = button.dataset.region === "all";
button.classList.toggle("is-active", active);
button.setAttribute("aria-pressed", String(active));
});
filterStores();
storeSearch?.focus();
});
updateBuilder();
filterStores();
})();

View file

@ -0,0 +1,979 @@
.franchise-page {
background: var(--paper-bright);
}
.franchise-hero {
min-height: min(820px, calc(100svh - var(--header-height) - 30px));
display: grid;
grid-template-columns: minmax(360px, 0.92fr) minmax(0, 1.08fr);
background: var(--ink);
color: white;
}
.franchise-hero-copy {
align-self: center;
padding: clamp(76px, 10vw, 140px) var(--gutter);
padding-left: max(var(--gutter), calc((100vw - var(--wrap)) / 2));
}
.franchise-hero-copy .section-kicker {
color: #8ea5ff;
}
.franchise-hero h1 {
max-width: 700px;
margin: 22px 0 0;
font-size: clamp(48px, 5.6vw, 80px);
font-weight: 900;
letter-spacing: -0.085em;
line-height: 0.98;
}
.franchise-hero-copy > p:not(.section-kicker) {
max-width: 550px;
margin-top: 30px;
color: #c9c4bc;
font-size: clamp(16px, 1.5vw, 20px);
line-height: 1.75;
}
.light-link {
border-color: white;
}
.franchise-hero .hero-actions > a {
min-height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0;
line-height: 1.2;
}
.franchise-hero .hero-actions .light-link {
padding: 0 2px;
border-bottom: 0;
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 6px;
}
.franchise-truth {
max-width: 560px;
margin-top: 52px;
padding: 18px 20px;
border-left: 3px solid #8ea5ff;
background: #2b2926;
}
.franchise-truth strong {
font-size: 12px;
}
.franchise-truth p {
margin-top: 5px;
color: #aaa59d;
font-size: 11px;
}
.franchise-hero-media {
min-height: 680px;
overflow: hidden;
background: var(--steel);
}
.franchise-hero-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.franchise-hero-media figcaption {
position: absolute;
right: 16px;
bottom: 16px;
padding: 7px 10px;
background: var(--paper-bright);
color: var(--ink);
font-size: 10px;
font-weight: 700;
}
.fact-rail {
border-bottom: 1px solid var(--line);
background: var(--paper);
}
.fact-rail-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.fact-rail p {
min-height: 92px;
display: flex;
align-items: center;
gap: 16px;
padding: 18px 28px;
border-right: 1px solid var(--line);
font-size: 13px;
font-weight: 750;
}
.fact-rail p:first-child {
padding-left: 0;
}
.fact-rail p:last-child {
border-right: 0;
}
.fact-rail span,
.fit-number {
color: var(--blue);
font-family: var(--font-mono);
font-size: 11px;
}
.model-section {
background: var(--paper-bright);
}
.flow-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--ink);
}
.flow-board article {
min-height: 340px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: clamp(22px, 3vw, 34px);
border-right: 1px solid var(--ink);
}
.flow-board article:last-child {
border-right: 0;
background: var(--blue);
color: white;
}
.flow-board article > span {
margin-bottom: auto;
color: var(--blue);
font-family: var(--font-mono);
font-size: 11px;
}
.flow-board article:last-child > span {
color: #c8d3ff;
}
.flow-board h3 {
font-size: 23px;
letter-spacing: -0.045em;
}
.flow-board p {
margin-top: 8px;
color: var(--ink-soft);
font-size: 13px;
}
.flow-board article:last-child p {
color: #dce4ff;
}
.operator-fit {
display: grid;
grid-template-columns: 0.8fr 1.2fr;
gap: 70px;
margin-top: clamp(64px, 9vw, 110px);
padding-top: 42px;
border-top: 1px solid var(--ink);
}
.operator-fit h3 {
margin-top: 13px;
font-size: clamp(34px, 4.2vw, 54px);
letter-spacing: -0.065em;
line-height: 1.12;
}
.operator-fit ul {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--line);
}
.operator-fit li {
display: grid;
grid-template-columns: 130px 1fr;
gap: 20px;
padding: 19px 0;
border-bottom: 1px solid var(--line);
font-size: 13px;
}
.operator-fit span {
color: var(--ink-soft);
}
.site-fit-section {
background: var(--steel);
}
.fit-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-top: 1px solid var(--ink);
border-left: 1px solid var(--ink);
}
.fit-grid article {
min-height: 300px;
padding: clamp(22px, 3vw, 34px);
border-right: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
background: var(--paper-bright);
}
.fit-grid .fit-main {
grid-column: span 2;
grid-row: span 2;
min-height: 600px;
display: flex;
flex-direction: column;
background: var(--ink);
color: white;
}
.fit-grid h3 {
margin-top: 46px;
font-size: clamp(28px, 3.5vw, 48px);
letter-spacing: -0.06em;
}
.fit-grid article > p:not(.fit-number) {
margin-top: 14px;
color: var(--ink-soft);
font-size: 13px;
}
.fit-grid .fit-main > p:not(.fit-number) {
max-width: 490px;
color: #d4cec5;
font-size: 16px;
}
.fit-main .fit-number {
color: #9db0ff;
}
.fit-main ul {
margin: auto 0 0;
padding: 24px 0 0;
border-top: 1px solid #625f5a;
list-style: none;
}
.fit-main li {
padding: 8px 0;
color: #e8e3db;
font-size: 13px;
}
.franchise-process {
background: var(--paper-bright);
}
.franchise-timeline {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--ink);
}
.franchise-timeline li {
display: grid;
grid-template-columns: 56px 1fr auto;
align-items: center;
gap: 28px;
min-height: 124px;
padding: 22px 0;
border-bottom: 1px solid var(--line);
}
.franchise-timeline li > span {
color: var(--blue);
font-family: var(--font-mono);
font-size: 12px;
}
.franchise-timeline h3 {
font-size: 24px;
letter-spacing: -0.045em;
}
.franchise-timeline p {
margin-top: 3px;
color: var(--ink-soft);
font-size: 13px;
}
.franchise-timeline small {
padding: 6px 9px;
border: 1px solid var(--line);
font-size: 10px;
font-weight: 700;
}
.cost-section {
background: var(--ink);
color: white;
}
.cost-section .section-kicker {
color: #8ea5ff;
}
.cost-head {
display: grid;
grid-template-columns: 1.25fr 0.75fr;
align-items: end;
gap: 50px;
margin-bottom: 64px;
}
.cost-head h2 {
margin-top: 14px;
}
.unknown-cost {
padding: 22px;
border: 1px solid #625f5a;
background: #2a2825;
}
.unknown-cost span {
color: #aaa59d;
font-size: 10px;
}
.unknown-cost strong {
display: block;
margin-top: 3px;
color: #8ea5ff;
font-size: 25px;
}
.unknown-cost p {
margin-top: 7px;
color: #bdb7af;
font-size: 12px;
}
.cost-table-wrap {
overflow-x: auto;
border-top: 1px solid #827d75;
}
.cost-table {
width: 100%;
min-width: 780px;
border-collapse: collapse;
font-size: 12px;
}
.cost-table th,
.cost-table td {
padding: 17px 14px;
border-bottom: 1px solid #4f4c47;
text-align: left;
}
.cost-table thead th {
color: #9f9a92;
font-size: 10px;
}
.cost-table tbody th {
color: white;
font-weight: 750;
}
.cost-table td {
color: #c4beb6;
}
.cost-table td:nth-child(3) {
color: #8ea5ff;
font-family: var(--font-mono);
font-weight: 600;
}
.cost-table tfoot th,
.cost-table tfoot td {
border-bottom: 0;
background: var(--blue);
color: white;
font-weight: 750;
}
.cost-warning {
margin-top: 18px;
color: #aaa59d;
font-size: 11px;
}
.inquiry-section {
background: var(--paper);
}
.inquiry-layout {
display: grid;
grid-template-columns: minmax(280px, 0.7fr) minmax(480px, 1.3fr);
gap: clamp(44px, 8vw, 110px);
align-items: start;
}
.inquiry-intro {
position: sticky;
top: calc(var(--header-height) + 32px);
}
.inquiry-intro h2 {
margin-top: 14px;
}
.inquiry-intro > p:not(.section-kicker) {
max-width: 460px;
margin-top: 26px;
color: var(--ink-soft);
font-size: 15px;
}
.privacy-note {
margin-top: 32px;
padding: 18px;
border-left: 3px solid var(--danger);
background: #fff0eb;
}
.privacy-note strong {
font-size: 12px;
}
.privacy-note p {
margin-top: 5px;
color: #733c33;
font-size: 11px;
}
.inquiry-panel {
border: 1px solid var(--line-strong);
background: var(--paper-bright);
box-shadow: var(--shadow);
}
.step-indicator {
display: grid;
grid-template-columns: repeat(3, 1fr);
margin: 0;
padding: 0;
border-bottom: 1px solid var(--line);
list-style: none;
}
.step-indicator li {
min-height: 76px;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
border-right: 1px solid var(--line);
color: var(--ink-faint);
}
.step-indicator li:last-child {
border-right: 0;
}
.step-indicator li.is-current {
background: var(--blue);
color: white;
}
.step-indicator span {
font-family: var(--font-mono);
font-size: 12px;
}
.step-indicator small {
font-size: 11px;
font-weight: 750;
}
.form-step,
.inquiry-success {
padding: clamp(26px, 5vw, 50px);
}
.is-js .form-step:not(.is-current) {
display: none;
}
.step-count {
color: var(--blue);
font-family: var(--font-mono);
font-size: 11px;
}
.form-step h3,
.inquiry-success h3 {
margin-top: 8px;
font-size: clamp(29px, 4vw, 43px);
font-weight: 850;
letter-spacing: -0.06em;
line-height: 1.2;
}
.form-field {
margin-top: 30px;
}
.form-field > label,
.form-field > legend {
display: block;
margin-bottom: 9px;
font-size: 13px;
font-weight: 750;
}
.form-field input[type="text"],
.form-field input[type="number"],
.form-field select {
width: 100%;
min-height: 52px;
padding: 11px 13px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
background: white;
}
.form-field > small,
.form-field output {
display: block;
margin-top: 7px;
color: var(--ink-faint);
font-size: 10px;
}
.field-error {
min-height: 20px;
margin-top: 5px;
color: var(--danger);
font-size: 11px;
font-weight: 700;
}
.form-field.has-error input,
.form-field.has-error select,
.form-field.has-error .radio-grid {
border-color: var(--danger);
}
.radio-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.radio-grid-two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.radio-grid label {
position: relative;
}
.radio-grid input {
position: absolute;
z-index: 1;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.radio-grid span {
min-height: 48px;
display: grid;
place-items: center;
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 700;
text-align: center;
}
.radio-grid input:checked + span {
border-color: var(--blue);
background: var(--blue);
color: white;
}
.radio-grid input:focus-visible + span {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
.unit-input {
display: flex;
align-items: center;
border: 1px solid var(--line-strong);
background: white;
}
.unit-input input {
border: 0 !important;
}
.unit-input span {
padding-right: 14px;
font-size: 12px;
font-weight: 750;
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 36px;
}
.split-actions {
justify-content: space-between;
gap: 12px;
}
.form-actions .button-ghost {
margin-top: 0;
background: white;
}
.review-list {
margin-top: 30px;
border-top: 1px solid var(--ink);
}
.review-list div {
display: grid;
grid-template-columns: 130px 1fr;
gap: 20px;
padding: 14px 0;
border-bottom: 1px solid var(--line);
font-size: 12px;
}
.review-list dt {
color: var(--ink-faint);
}
.review-list dd {
font-weight: 750;
}
.consent-check {
position: relative;
min-height: 44px;
display: grid;
grid-template-columns: 20px 1fr;
align-items: start;
gap: 10px;
margin-top: 24px;
font-size: 12px;
font-weight: 650;
}
.consent-check input {
position: absolute;
z-index: 1;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.consent-check span {
position: relative;
grid-column: 2;
}
.consent-check span::before {
content: "";
position: absolute;
top: 2px;
left: -30px;
width: 18px;
height: 18px;
border: 1px solid var(--line-strong);
background: white;
}
.consent-check input:checked + span::before {
border-color: var(--blue);
background: var(--blue);
box-shadow: inset 0 0 0 4px white;
}
.consent-check input:focus-visible + span {
outline: 3px solid var(--focus);
outline-offset: 5px;
}
.submit-state {
min-height: 24px;
margin-top: 12px;
color: var(--danger);
font-size: 11px;
font-weight: 700;
}
.inquiry-success {
min-height: 580px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
.success-mark {
width: 58px;
height: 58px;
display: grid;
place-items: center;
margin-bottom: 26px;
background: var(--blue);
color: white;
font-size: 28px;
}
.inquiry-success > p:not(.section-kicker) {
max-width: 460px;
margin-top: 15px;
color: var(--ink-soft);
font-size: 14px;
}
.success-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 30px;
}
.success-actions .button {
margin-top: 0;
}
.franchise-close {
padding-block: var(--section);
background: var(--blue);
color: white;
}
.close-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
gap: 44px;
}
.franchise-close .section-kicker {
color: #c8d3ff;
}
.franchise-close h2 {
max-width: 850px;
margin-top: 14px;
font-size: clamp(42px, 6vw, 78px);
letter-spacing: -0.075em;
line-height: 1.06;
}
.franchise-foot-grid {
grid-template-columns: 1.2fr 0.8fr 1.4fr;
}
@media (max-width: 960px) {
.franchise-hero {
grid-template-columns: 0.95fr 1.05fr;
}
.franchise-hero-copy {
padding-left: var(--gutter);
}
.flow-board {
grid-template-columns: repeat(2, 1fr);
}
.flow-board article:nth-child(2) {
border-right: 0;
}
.flow-board article:nth-child(-n + 2) {
border-bottom: 1px solid var(--ink);
}
.fit-grid {
grid-template-columns: repeat(2, 1fr);
}
.cost-head,
.inquiry-layout {
grid-template-columns: 1fr;
}
.inquiry-intro {
position: static;
}
.franchise-foot-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.franchise-hero {
grid-template-columns: 1fr;
}
.franchise-hero-copy {
padding: 72px var(--gutter) 52px;
}
.franchise-hero h1 {
font-size: clamp(40px, 13.5vw, 60px);
}
.franchise-hero-media {
min-height: 560px;
}
.fact-rail-grid {
grid-template-columns: 1fr;
}
.fact-rail p,
.fact-rail p:first-child {
min-height: 64px;
padding: 14px 0;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.operator-fit {
grid-template-columns: 1fr;
gap: 34px;
}
.fit-grid .fit-main {
min-height: 520px;
}
.franchise-timeline li {
grid-template-columns: 42px 1fr;
}
.franchise-timeline small {
grid-column: 2;
justify-self: start;
}
.close-row {
grid-template-columns: 1fr;
align-items: start;
}
.franchise-foot-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 540px) {
.franchise-hero-media {
min-height: 460px;
}
.flow-board,
.fit-grid {
display: block;
border-left: 1px solid var(--ink);
}
.flow-board article,
.flow-board article:nth-child(2),
.fit-grid article {
border-right: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
}
.flow-board article,
.flow-board article:nth-child(2) {
min-height: 170px;
justify-content: flex-start;
}
.flow-board article > span {
margin-bottom: 30px;
}
.fit-grid .fit-main {
min-height: 500px;
}
.operator-fit li {
grid-template-columns: 1fr;
gap: 4px;
}
.step-indicator li {
min-height: 64px;
justify-content: center;
padding: 9px;
}
.step-indicator small {
display: inline;
font-size: 10px;
}
.radio-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-actions,
.split-actions,
.success-actions {
flex-direction: column-reverse;
}
.form-actions .button,
.success-actions .button {
width: 100%;
}
.review-list div {
grid-template-columns: 1fr;
gap: 3px;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,43 @@
:root {
color-scheme: light;
--paper: #f4f1e9;
--paper-bright: #fffdf8;
--ink: #211f1c;
--ink-soft: #56514a;
--ink-faint: #625d56;
--line: #c9c2b7;
--line-strong: #8a8379;
--blue: #2147e7;
--blue-dark: #1533ac;
--blue-pale: #dce4ff;
--steel: #d9dcda;
--cheese: #ffc642;
--danger: #b33124;
--success: #17683b;
--focus: #2147e7;
--font-sans: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
--wrap: 1180px;
--gutter: clamp(18px, 4vw, 52px);
--section: clamp(76px, 10vw, 144px);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--space-8: 72px;
--radius-sm: 4px;
--radius: 10px;
--radius-lg: 18px;
--shadow: 0 18px 50px rgb(33 31 28 / 10%);
--header-height: 78px;
}
@media (max-width: 720px) {
:root {
--header-height: 64px;
--section: 72px;
}
}

View file

@ -0,0 +1,294 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f3f4f3">
<meta name="description" content="가상 공인중개 쇼케이스의 매물 사실, 표시 항목, AI 분석 근거, 변경 이력, 게시 가능 여부를 한 화면에서 검수하는 상세 관리자 운영 데모입니다.">
<meta name="robots" content="noindex, nofollow">
<meta property="og:title" content="한남자리 관리 데모 — 매물 검수">
<meta property="og:description" content="예시 매물의 표시 항목, AI 근거와 게시 방어선을 살피는 운영 화면">
<meta property="og:image" content="../og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="한남자리 관리 데모 — 매물 검수">
<meta name="twitter:image" content="../og.png">
<title>한남자리 관리 데모 — 매물 검수</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23141413'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial' font-size='19' font-weight='700' fill='%23c6a15b'%3EHJ%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="../styles/tokens.css?v=20260829c">
<link rel="stylesheet" href="../styles/admin.css?v=20260829c">
<script src="../scripts/admin.js?v=20260829c" defer></script>
</head>
<body class="admin-body">
<a class="skip-link" href="#admin-main">본문 바로가기</a>
<div class="demo-strip" role="region" aria-label="쇼케이스 고지">쇼케이스 · 가상 브랜드 · 모든 사무소, 매물, 고객과 기록은 합성 예시입니다</div>
<div class="admin-shell">
<aside class="admin-sidebar" data-sidebar aria-label="관리 메뉴">
<div class="sidebar-brand-shell">
<a href="../" aria-label="한남자리 공개 쇼케이스로 이동"><span class="sidebar-mark">HJ</span><span><strong class="sidebar-brand">한남자리</strong><small>운영 화면 데모</small></span></a>
<button type="button" class="sidebar-close" data-close-sidebar aria-label="관리 메뉴 닫기">×</button>
</div>
<nav class="sidebar-nav" aria-label="관리 화면 목록">
<p>운영</p>
<a href="#queue" class="is-current" aria-current="page"><span aria-hidden="true">⌁</span>검수 대기열 <small>6</small></a>
<a href="#queue"><span aria-hidden="true">▤</span>전체 매물</a>
<a href="#inspector-inquiries"><span aria-hidden="true">◌</span>문의와 고객</a>
<a href="#inspector-ai"><span aria-hidden="true">✦</span>AI 분석</a>
<a href="#inspector-inquiries"><span aria-hidden="true">□</span>일정</a>
<p>관리</p>
<a href="#office-gate"><span aria-hidden="true">◇</span>사무소와 법정 정보 <small class="nav-alert">5</small></a>
<a href="#roles"><span aria-hidden="true">◎</span>팀과 권한</a>
<a href="#inspector-history"><span aria-hidden="true">↺</span>감사 로그</a>
<a href="#settings"><span aria-hidden="true">⚙</span>설정</a>
</nav>
<div class="sidebar-foot">
<span class="status-badge status-demo">데모 환경</span>
<p>실제 광고 게시와 외부 연락은 연결되지 않습니다.</p>
<a href="../">공개 화면 보기 ↗</a>
</div>
</aside>
<div class="sidebar-backdrop" data-sidebar-backdrop hidden></div>
<main class="admin-main" id="admin-main">
<header class="admin-topbar">
<button type="button" class="mobile-sidebar-button" aria-expanded="false" data-open-sidebar><span aria-hidden="true">☰</span><span class="sr-only">관리 메뉴 열기</span></button>
<div class="breadcrumb"><span>운영</span><span aria-hidden="true">/</span><strong>매물 검수</strong></div>
<div class="topbar-actions">
<button type="button" class="icon-button" aria-label="명령 메뉴 열기" data-command-button><span aria-hidden="true">⌕</span><kbd>Ctrl K</kbd></button>
<button type="button" class="icon-button" aria-label="알림 보기"><span aria-hidden="true">○</span><i aria-hidden="true"></i></button>
<button type="button" class="account-button" aria-label="예시 계정 메뉴"><span>김</span><span><strong>김유진</strong><small>공인중개사 · 예시</small></span></button>
</div>
</header>
<div class="admin-content">
<header class="page-heading">
<div>
<div class="heading-badges"><span class="status-badge status-demo">쇼케이스 · 가상 브랜드</span><span>자료 기준 2026.08.29</span></div>
<h1>매물 검수 대기열</h1>
<p>게시 전에 매물 사실, 표시 항목, AI 근거와 변경 이력을 한 자리에서 확인합니다.</p>
</div>
<div class="heading-actions">
<button type="button" class="button button-ghost" data-open-preview>쇼케이스 미리보기</button>
<button type="button" class="button button-primary" data-new-draft data-inspector-trigger>새 예시 초안</button>
</div>
</header>
<section class="system-banner" role="status" aria-live="polite" hidden data-system-banner>
<div><strong data-banner-title>운영 상태</strong><p data-banner-copy></p></div>
<button type="button" data-dismiss-banner>닫기</button>
</section>
<section class="queue-toolbar" aria-label="대기열 도구">
<label class="search-field"><span class="sr-only">매물 검색</span><span aria-hidden="true">⌕</span><input type="search" placeholder="매물명, 거래 형태, 담당 검색" data-queue-search></label>
<label><span class="sr-only">상태 필터</span><select data-status-filter><option value="all">모든 상태</option><option value="review">검수 필요</option><option value="visible">쇼케이스 공개</option><option value="paused">보류</option><option value="contracted">계약 완료</option><option value="draft">초안</option></select></label>
<label><span class="sr-only">정렬 기준</span><select data-sort-select><option value="updated-desc">최근 수정순</option><option value="updated-asc">오래된 수정순</option><option value="title-asc">매물명순</option><option value="status-asc">상태순</option></select></label>
<label class="state-simulator"><span>상태 시연</span><select data-state-simulator><option value="normal">정상</option><option value="loading">불러오는 중</option><option value="empty">빈 대기열</option><option value="error">불러오기 실패</option><option value="save-error">저장 실패</option><option value="stale">자료 오래됨</option><option value="conflict">버전 충돌</option><option value="permission">권한 제한</option></select></label>
</section>
<div class="admin-workbench">
<section class="queue-panel" id="queue" aria-labelledby="queue-title" aria-busy="false" data-queue-panel>
<header class="panel-heading">
<div><h2 id="queue-title">현재 대기열</h2><span data-result-count>6개 예시</span></div>
<div><span class="queue-legend"><i class="legend-block" aria-hidden="true"></i>차단</span><span class="queue-legend"><i class="legend-warn" aria-hidden="true"></i>확인</span></div>
</header>
<div class="table-scroll">
<table class="queue-table">
<caption class="sr-only">쇼케이스용 예시 매물 검수 대기열</caption>
<thead>
<tr><th scope="col">매물</th><th scope="col">거래</th><th scope="col">예시 조건</th><th scope="col">마지막 확인</th><th scope="col">표시 검수</th><th scope="col">AI 검토</th><th scope="col">담당</th></tr>
</thead>
<tbody data-queue-body>
<tr class="is-selected" data-queue-row data-id="DEMO-HJ-101" data-status="review" data-updated="2026-08-29T10:42:00+09:00" data-title="한남 생활권 정원 주거" aria-selected="true">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="한남 생활권 정원 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb"><img src="../assets/hannam-evening-640.webp" width="80" height="54" alt="서울 언덕 생활권의 주거 외관을 표현한 연출 이미지"></span><span><strong>한남 생활권 정원 주거</strong><small>DEMO-HJ-101 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>연립주택</small></td><td><strong>38억 원</strong><small>전용 118.42㎡</small></td><td><span>2026.08.27</span><small>2일 전 · 모의</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>2개 차단</span></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>검토 전</span></td><td><span class="assignee"><i>김</i>김유진</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-102" data-status="visible" data-updated="2026-08-29T09:18:00+09:00" data-title="한강 가까운 복층 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="한강 가까운 복층 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-plan"><i></i></span><span><strong>한강 가까운 복층 주거</strong><small>DEMO-HJ-102 · 예시 매물</small></span></span></td>
<td><span>전세</span><small>공동주택</small></td><td><strong>전세 22억</strong><small>전용 142.80㎡</small></td><td><span>2026.08.26</span><small>3일 전 · 모의</small></td><td><span class="cell-status is-pass"><i aria-hidden="true"></i>데모 통과</span></td><td><span class="cell-status is-pass"><i aria-hidden="true"></i>수정 승인</span></td><td><span class="assignee"><i>박</i>박소연</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-103" data-status="paused" data-updated="2026-08-28T17:25:00+09:00" data-title="조용한 골목의 테라스 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="조용한 골목의 테라스 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-stale"><i></i></span><span><strong>조용한 골목의 테라스 주거</strong><small>DEMO-HJ-103 · 예시 매물</small></span></span></td>
<td><span>월세</span><small>다세대주택</small></td><td><strong>5억 · 월 1,200만</strong><small>전용 140.84㎡</small></td><td><span>2026.08.11</span><small>18일 전 · 오래됨</small></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>다시 확인</span></td><td><span class="cell-status is-stale"><i aria-hidden="true"></i>오래됨</span></td><td><span class="assignee"><i>김</i>김유진</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-104" data-status="contracted" data-updated="2026-08-28T12:40:00+09:00" data-title="언덕 위 작은 정원 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="언덕 위 작은 정원 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-closed"><i></i></span><span><strong>언덕 위 작은 정원 주거</strong><small>DEMO-HJ-104 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>단독주택</small></td><td><strong>상담 종료</strong><small>공개 목록 제외</small></td><td><span>2026.08.28</span><small>1일 전 · 모의</small></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>게시 안 함</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>종료</span></td><td><span class="assignee"><i>박</i>박소연</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-105" data-status="draft" data-updated="2026-08-27T15:05:00+09:00" data-title="남산 가까운 가족 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="남산 가까운 가족 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-draft"><i></i></span><span><strong>남산 가까운 가족 주거</strong><small>DEMO-HJ-105 · 예시 매물</small></span></span></td>
<td><span>전세</span><small>공동주택</small></td><td><strong>조건 작성 중</strong><small>면적 입력됨</small></td><td><span>확인 전</span><small>모의 서류 없음</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>5개 차단</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>실행 전</span></td><td><span class="assignee"><i>최</i>최민서</span></td>
</tr>
<tr data-queue-row data-id="DEMO-HJ-106" data-status="review" data-updated="2026-08-26T11:20:00+09:00" data-title="독서당길 안쪽 복층 주거" aria-selected="false">
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="독서당길 안쪽 복층 주거 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-review"><i></i></span><span><strong>독서당길 안쪽 복층 주거</strong><small>DEMO-HJ-106 · 예시 매물</small></span></span></td>
<td><span>매매</span><small>공동주택</small></td><td><strong>41억 원</strong><small>전용 151.20㎡</small></td><td><span>2026.08.25</span><small>4일 전 · 모의</small></td><td><span class="cell-status is-warn"><i aria-hidden="true"></i>동의 대기</span></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>실행 실패</span></td><td><span class="assignee"><i>최</i>최민서</span></td>
</tr>
</tbody>
</table>
</div>
<div class="queue-loading" hidden data-queue-loading role="status">
<span></span><span></span><span></span><p>대기열을 불러오는 중입니다.</p>
</div>
<div class="queue-empty" hidden data-queue-empty>
<span aria-hidden="true">○</span><strong data-empty-title>현재 조건에 맞는 예시가 없습니다.</strong><p data-empty-copy>필터를 초기화하면 전체 대기열을 다시 볼 수 있습니다.</p><button type="button" class="button button-ghost" data-reset-filter>필터 초기화</button>
</div>
</section>
<div class="inspector-backdrop" data-inspector-backdrop hidden></div>
<aside class="inspector is-open" data-inspector aria-labelledby="inspector-title">
<header class="inspector-header">
<div><span class="status-badge status-warn" data-inspector-status>검수 필요</span><small data-inspector-id>DEMO-HJ-101 · 예시 매물</small><h2 id="inspector-title" data-inspector-title>한남 생활권 정원 주거</h2></div>
<button type="button" class="inspector-close" data-close-inspector aria-label="조사관과 선택 행 닫기">×</button>
</header>
<div class="inspector-media">
<img src="../assets/hannam-interior-720.webp" width="720" height="480" alt="큰 창과 목재 수납이 있는 거실의 연출 이미지" data-inspector-image>
<span>연출 이미지 · 실제 매물 사진 아님</span>
</div>
<div class="channel-gates" id="office-gate">
<button type="button" class="channel-gate is-pass" data-open-preview><span><i aria-hidden="true">✓</i><strong>쇼케이스</strong></span><small>미리보기 가능</small></button>
<button type="button" class="channel-gate is-blocked" data-block-publish><span><i aria-hidden="true">!</i><strong>실제 광고</strong></span><small>영구 차단 · 7개 이유</small></button>
</div>
<div class="inspector-tabs" role="tablist" aria-label="선택 매물 검수 항목">
<button type="button" role="tab" aria-selected="true" aria-controls="inspector-basic" id="tab-basic" data-tab="basic">기본</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-legal" id="tab-legal" tabindex="-1" data-tab="legal">표시·광고 <span>2</span></button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-docs" id="tab-docs" tabindex="-1" data-tab="docs">서류</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-ai" id="tab-ai" tabindex="-1" data-tab="ai">AI</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-inquiries" id="tab-inquiries" tabindex="-1" data-tab="inquiries">문의·일정</button>
<button type="button" role="tab" aria-selected="false" aria-controls="inspector-history" id="tab-history" tabindex="-1" data-tab="history">변경 이력</button>
</div>
<div class="inspector-scroll">
<section class="tab-panel" role="tabpanel" id="inspector-basic" aria-labelledby="tab-basic" data-panel="basic">
<div class="panel-section-heading"><div><span>기본 정보</span><h3>예시 거래 조건</h3></div><button type="button" class="text-button" data-simulate-save>수정 시연</button></div>
<dl class="data-grid" data-basic-grid>
<div><dt>거래 형태</dt><dd>매매</dd></div><div><dt>예시 가격</dt><dd>38억 원</dd></div><div><dt>유형</dt><dd>연립주택</dd></div><div><dt>전용 면적</dt><dd>118.42㎡ · 35.8평</dd></div><div><dt>방과 욕실</dt><dd>방 3 · 욕실 2</dd></div><div><dt>입주 가능</dt><dd>2026.12.20 이후 협의</dd></div>
</dl>
<div class="inline-notice"><span aria-hidden="true">i</span><p><strong>가상 데이터</strong> 가격과 면적은 쇼케이스 동작 검증용이며 실제 거래 조건이 아닙니다.</p></div>
<div class="panel-section-heading"><div><span>담당과 상태</span><h3>현재 업무 흐름</h3></div></div>
<ol class="state-path">
<li class="is-done"><span></span><div><strong>초안</strong><small>2026.08.27 · 예시</small></div></li><li class="is-done"><span></span><div><strong>접수 확인</strong><small>2026.08.28 · 예시</small></div></li><li class="is-current"><span></span><div><strong>검수 필요</strong><small>현재 · 2개 차단</small></div></li><li><span></span><div><strong>게시 준비</strong><small>차단 해제 후</small></div></li>
</ol>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-legal" aria-labelledby="tab-legal" data-panel="legal" hidden>
<div class="panel-section-heading"><div><span>이중 게시 게이트</span><h3>채널마다 다른 통과 조건</h3></div><button type="button" class="text-button" data-focus-missing>첫 누락으로 이동</button></div>
<article class="gate-detail is-pass">
<header><span aria-hidden="true">✓</span><div><strong>쇼케이스 미리보기</strong><small>6 / 6 통과</small></div></header>
<ul><li>상시 데모 표지</li><li>개별 예시 매물 배지</li><li>생성 이미지 출처</li><li>실제 연락 기능 비활성</li><li>정확한 주소와 등록번호 없음</li><li>AI 데모와 사람 검토 전 표시</li></ul>
</article>
<article class="gate-detail is-blocked" id="real-ad-gate">
<header><span aria-hidden="true">!</span><div><strong>실제 광고</strong><small>영구 차단 · 가상 브랜드</small></div></header>
<p>실제 사무소 등록정보와 중개의뢰 관계가 없으므로 이 데모에서 통과시킬 수 없습니다.</p>
<button type="button" class="button button-ghost button-small" data-block-publish>차단 이유 7개 보기</button>
</article>
<div class="check-list" data-legal-checks>
<div class="check-row is-pass"><span aria-hidden="true">✓</span><div><strong>소재지 · 면적 · 가격 · 유형 · 거래 형태</strong><small>쇼케이스 fixture에 구조화됨</small></div><button type="button">보기</button></div>
<div class="check-row is-pass"><span aria-hidden="true">✓</span><div><strong>총층수 · 사용승인일 · 방 · 욕실</strong><small>모의 서류 기준 · 실제 증빙 아님</small></div><button type="button">보기</button></div>
<div class="check-row is-blocked" id="missing-orientation" tabindex="-1"><span aria-hidden="true">!</span><div><strong>방향 판단 근거 미확인</strong><small>남동향 값은 있으나 현장 또는 실제 서류 근거 없음</small></div><button type="button" data-jump-field="missing-orientation">수정 위치</button></div>
<div class="check-row is-blocked"><span aria-hidden="true">!</span><div><strong>실제 중개의뢰 관계 없음</strong><small>가상 매물이므로 실제 광고 불가</small></div><button type="button" data-block-publish>이유</button></div>
<div class="check-row is-warn"><span aria-hidden="true">·</span><div><strong>관리비 포함·별도 항목</strong><small>모의 기준월과 항목을 표시함</small></div><button type="button">보기</button></div>
</div>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-docs" aria-labelledby="tab-docs" data-panel="docs" hidden>
<div class="panel-section-heading"><div><span>모의 서류</span><h3>증빙과 예시를 구분</h3></div></div>
<div class="document-list">
<article><span class="doc-icon" aria-hidden="true">▧</span><div><strong>건축물대장 형식 예시</strong><small>2026.08.27 추가 · 실제 공문서 아님</small></div><span class="status-badge status-demo">모의</span></article>
<article><span class="doc-icon" aria-hidden="true">▧</span><div><strong>의뢰 조건 메모</strong><small>2026.08.27 추가 · 가상 입력</small></div><span class="status-badge status-demo">모의</span></article>
<article class="is-missing"><span class="doc-icon" aria-hidden="true">×</span><div><strong>실제 등기사항</strong><small>실주소와 실제 권리 자료를 연결하지 않음</small></div><span class="status-badge status-blocked">없음</span></article>
<article class="is-missing"><span class="doc-icon" aria-hidden="true">×</span><div><strong>현장 확인 기록</strong><small>소음, 일조, 누수와 마감 상태 확인 안 됨</small></div><span class="status-badge status-warn">대기</span></article>
</div>
<div class="media-provenance"><img src="../assets/hannam-evening-640.webp" width="640" height="360" alt="서울 언덕길의 석재 주거 건물 연출 이미지"><div><span class="status-badge status-demo">생성 이미지</span><strong>실제 매물 증빙으로 사용 불가</strong><p>외관 분위기 전달용 연출 자산입니다. 위치, 조망, 일조와 건물 상태를 증명하지 않습니다.</p></div></div>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-ai" aria-labelledby="tab-ai" data-panel="ai" hidden>
<div class="panel-section-heading"><div><span>조건 비교 규칙 demo-0.3</span><h3>AI 참고 분석 데모</h3></div><span class="status-badge status-warn">사람 검토 전</span></div>
<dl class="run-meta"><div><dt>실행 번호</dt><dd>AIRUN-DEMO-0042</dd></div><div><dt>입력 버전</dt><dd>고객 3 · 매물 7</dd></div><div><dt>실행 방식</dt><dd>결정적 데모</dd></div><div><dt>공개 가능</dt><dd>아니요</dd></div></dl>
<div class="ai-summary"><span>현재 판단</span><strong>조건부 적합</strong><div><small>필수 충족 4 / 5</small><small>충돌 1</small><small>확인 필요 2</small></div></div>
<div class="claim-review">
<article class="is-fact"><header><span>사실</span><strong>예산 상한보다 2억 원 낮음</strong></header><p>가상 의뢰인 가격 입력 · 2026.08.27</p><footer><span>근거 강도 높음</span><button type="button" data-claim-action="approve">확인</button></footer></article>
<article class="is-fact"><header><span>사실</span><strong>전용면적이 희망 범위 안</strong></header><p>쇼케이스용 모의 서류 · 실제 공문서 아님</p><footer><span>근거 강도 높음</span><button type="button" data-claim-action="approve">확인</button></footer></article>
<article class="is-unknown"><header><span>확인 불가</span><strong>광화문 이동 시간</strong></header><p>현재 교통 자료가 연결되지 않아 추정하지 않음</p><footer><span>빈칸 유지</span><button type="button" data-claim-action="keep">유지</button></footer></article>
<article class="is-edited"><header><span>수정 필요</span><strong><s>조용한 주거 환경입니다.</s></strong></header><p>현장 근거가 없어 “소음 수준은 현장 확인이 필요합니다”로 바꿉니다.</p><footer><span>지원되지 않은 표현</span><button type="button" data-claim-action="edit">수정 승인</button></footer></article>
</div>
<button type="button" class="button button-primary inspector-wide-action" data-review-ai>사람 검토 완료 시연</button>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-inquiries" aria-labelledby="tab-inquiries" data-panel="inquiries" hidden>
<div class="panel-section-heading"><div><span>고객 조건 3판</span><h3>김○○ · 예시 고객</h3></div><span class="status-badge status-demo">연락처 마스킹</span></div>
<dl class="data-grid customer-grid"><div><dt>거래 형태</dt><dd>매매</dd></div><div><dt>예산 상한</dt><dd>40억 원 · 예시</dd></div><div><dt>전용 면적</dt><dd>110~130㎡</dd></div><div><dt>주차</dt><dd>2대 필수</dd></div><div><dt>입주 희망</dt><dd>2026.12.15까지</dd></div><div><dt>AI 비교 동의</dt><dd>예시 동의 있음</dd></div></dl>
<div class="consent-note"><span aria-hidden="true">i</span><p>상담 동의와 AI 비교 동의는 예시입니다. 홍보 수신은 거부 상태로 두었습니다.</p></div>
<div class="panel-section-heading"><div><span>일정</span><h3>전화 상담 · 예시</h3></div></div>
<article class="appointment-card"><time datetime="2026-08-29T15:30:00+09:00"><strong>29</strong><span>8월 · 토</span></time><div><strong>오후 3:30~4:00</strong><p>맞춤 조건과 권리 확인 순서 · 외부 알림 없음</p><small>한국 표준시 · 데모 일정</small></div><button type="button" data-reschedule>시간 변경 시연</button></article>
</section>
<section class="tab-panel" role="tabpanel" id="inspector-history" aria-labelledby="tab-history" data-panel="history" hidden>
<div class="panel-section-heading"><div><span>수정 불가 기록</span><h3>변경 이력 8건</h3></div><button type="button" class="text-button" data-simulate-log-failure>실패 상태 보기</button></div>
<ol class="audit-list">
<li><span class="audit-dot is-blocked"></span><div><header><strong>실제 광고 게시 차단</strong><time datetime="2026-08-29T10:42:18+09:00">오늘 10:42</time></header><p>사무소 등록정보와 실제 중개의뢰 관계가 없음</p><small>김유진 · 예시 계정 · 버전 7</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>AI 주장 수정 후 승인</strong><time datetime="2026-08-29T10:31:00+09:00">오늘 10:31</time></header><p>“조용함”을 “현장 확인 필요”로 변경</p><small>김유진 · 예시 계정 · AIRUN-DEMO-0041</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>AI 분석 완료</strong><time datetime="2026-08-29T10:20:00+09:00">오늘 10:20</time></header><p>조건 비교 규칙 demo-0.3 · 입력 버전 고객 3 / 매물 7</p><small>데모 분석기</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>쇼케이스 미리보기 공개</strong><time datetime="2026-08-29T09:54:00+09:00">오늘 09:54</time></header><p>데모 고지와 이미지 출처 6개 항목 통과</p><small>박소연 · 예시 계정</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>모의 서류 추가</strong><time datetime="2026-08-28T17:22:00+09:00">어제 17:22</time></header><p>건축물대장 형식 예시 · 실제 공문서 아님</p><small>김유진 · 예시 계정</small></div></li>
<li><span class="audit-dot"></span><div><header><strong>예시 가격 변경</strong><time datetime="2026-08-28T16:40:00+09:00">어제 16:40</time></header><p>40억 원 → 38억 원 · 가상 의뢰 조건 수정</p><small>김유진 · 예시 계정 · 버전 6 → 7</small></div></li>
<li><span class="audit-dot is-warn"></span><div><header><strong>오래된 자료로 자동 보류</strong><time datetime="2026-08-28T08:00:00+09:00">어제 08:00</time></header><p>DEMO-HJ-103 · 내부 운영 기준일 경과</p><small>데모 일정 작업</small></div></li>
<li><span class="audit-dot is-blocked"></span><div><header><strong>권한 없는 게시 시도 거부</strong><time datetime="2026-08-27T15:16:00+09:00">8월 27일</time></header><p>중개보조 예시 계정은 법정 검수 승인과 게시를 할 수 없음</p><small>권한 검사 · 요청 DEMO-77B</small></div></li>
</ol>
</section>
</div>
<footer class="inspector-actions">
<button type="button" class="button button-ghost" data-focus-missing>누락 항목 보기</button>
<button type="button" class="button button-primary" data-block-publish>실제 광고 게시</button>
</footer>
</aside>
</div>
<section class="role-proof" id="roles" aria-labelledby="roles-title">
<header><div><span>역할에 따라 달라지는 행동</span><h2 id="roles-title">권한 방어선</h2></div><p>버튼을 숨기는 데서 끝내지 않고 게시 시점에 역할, 게이트와 객체 버전을 다시 확인하는 모델입니다.</p></header>
<div class="role-grid"><article><strong>사무소 관리자</strong><p>사무소 연결, 역할, 모든 게시 관리</p><small>감사 로그 수정 불가</small></article><article><strong>공인중개사</strong><p>법정 검수, AI 승인, 게시와 중단</p><small>역할 관리자 변경 불가</small></article><article><strong>중개보조 계정</strong><p>초안, 문의 정리, 일정 등록</p><small>검수 승인과 게시 불가</small></article><article><strong>콘텐츠 편집자</strong><p>설명, 이미지, 미리보기</p><small>가격, 법정 필드와 고객 정보 수정 불가</small></article></div>
</section>
<section class="settings-proof" id="settings" aria-labelledby="settings-title"><h2 id="settings-title">데모 설정 원칙</h2><p>실제 연락, 저장 서버, 광고 채널과 등록정보 연결을 비활성 상태로 유지합니다.</p></section>
</div>
</main>
</div>
<dialog class="admin-dialog" data-block-dialog aria-labelledby="block-dialog-title">
<form method="dialog" class="admin-dialog-shell">
<header><div><span class="dialog-symbol is-blocked" aria-hidden="true">!</span><div><small>PUBLIC_REAL_AD</small><h2 id="block-dialog-title">실제 광고를 게시할 수 없습니다.</h2></div></div><button type="button" data-close-dialog aria-label="게시 차단 안내 닫기">×</button></header>
<div class="admin-dialog-content">
<p>이 화면은 가상 사무소와 합성 예시 매물로 만든 쇼케이스입니다. 아래 항목은 더미값으로 채워 통과시키지 않습니다.</p>
<ol><li><strong>실제 사무소 명칭</strong><span>연결 안 됨</span></li><li><strong>실제 사무소 소재지</strong><span>연결 안 됨</span></li><li><strong>실제 연락처</strong><span>연결 안 됨</span></li><li><strong>실제 등록번호</strong><span>연결 안 됨</span></li><li><strong>개업공인중개사 성명</strong><span>연결 안 됨</span></li><li><strong>실제 중개의뢰 관계</strong><span>없음</span></li><li><strong>실제 거래 가능 상태</strong><span>확인 불가</span></li></ol>
<div class="inline-notice"><span aria-hidden="true">i</span><p>쇼케이스 미리보기 채널은 데모 고지와 생성 이미지 출처를 갖춰 별도로 열 수 있습니다.</p></div>
</div>
<footer><button type="submit" class="button button-primary" value="close">확인</button></footer>
</form>
</dialog>
<dialog class="admin-dialog preview-dialog" data-preview-dialog aria-labelledby="preview-dialog-title">
<form method="dialog" class="admin-dialog-shell">
<header><div><span class="dialog-symbol is-pass" aria-hidden="true">✓</span><div><small>SHOWCASE</small><h2 id="preview-dialog-title">미리보기 채널은 열 수 있습니다.</h2></div></div><button type="button" data-close-dialog aria-label="쇼케이스 미리보기 안내 닫기">×</button></header>
<div class="admin-dialog-content"><p>상시 데모 고지, 개별 예시 배지, 생성 이미지 출처, 연락 기능 비활성, 실제 주소와 등록번호 없음, AI 데모 표시를 확인했습니다.</p><a class="button button-primary" href="../#listing-detail">공개 상세 보기</a></div>
</form>
</dialog>
<noscript>
<section class="admin-noscript"><strong>읽기 전용 화면입니다.</strong><p>자바스크립트가 없어 필터, 정렬, 선택, 탭과 대화상자를 쓸 수 없습니다. 기본 예시 대기열과 선택 매물의 정보는 계속 읽을 수 있습니다.</p></section>
</noscript>
<div class="live-region" role="status" aria-live="polite" hidden data-admin-live></div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,225 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f3f0e8">
<meta name="description" content="서울 한남 생활권의 집을 예산, 면적, 입주, 주차 조건으로 비교하고 AI 브리핑 뒤 공인중개사가 사실을 다시 확인하는 스마트 중개 디자인 쇼케이스입니다.">
<meta property="og:title" content="한남자리 — 내 기준에 맞는 집부터">
<meta property="og:description" content="조건을 정리하고 후보를 비교한 뒤 공인중개사가 확인하는 개인 주거 브리핑 데스크">
<meta property="og:image" content="./og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="한남자리 — 내 기준에 맞는 집부터">
<meta name="twitter:description" content="서울의 집을 내 조건으로 대조하는 개인 주거 브리핑 데스크">
<meta name="twitter:image" content="./og.png">
<title>한남자리 — 내 기준에 맞는 집부터</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='32' fill='%23121311'/%3E%3Ctext x='32' y='39' text-anchor='middle' font-family='Arial' font-size='19' font-weight='700' fill='%23f3f0e8'%3EHJ%3C/text%3E%3C/svg%3E">
<link rel="preload" href="./assets/hannam-evening-1600.webp" as="image" type="image/webp" media="(min-width: 961px)">
<link rel="stylesheet" href="./styles/tokens.css?v=20260829d">
<link rel="stylesheet" href="./styles/landing.css?v=20260829d">
<script src="./scripts/landing.js?v=20260829d" defer></script>
</head>
<body>
<a class="skip-link" href="#main-content">본문 바로가기</a>
<div class="demo-strip" role="region" aria-label="쇼케이스 고지">디자인 쇼케이스 · 가상 브랜드 · 상담은 접수되지 않습니다</div>
<header class="site-header" data-site-header>
<a class="brand-link" href="#top" aria-label="한남자리 처음으로">
<span class="brand-mark" aria-hidden="true">HJ</span>
<span class="brand-lockup"><strong class="brand">한남자리</strong><small>개인 주거 브리핑 데스크</small></span>
</a>
<button class="menu-button" type="button" aria-expanded="false" aria-controls="site-nav" data-menu-button>
<span class="sr-only">메뉴 열기</span><i aria-hidden="true"></i><i aria-hidden="true"></i>
</button>
<nav class="site-nav" id="site-nav" aria-label="주요 메뉴" data-site-nav>
<a href="#briefing">맞춤 브리핑</a>
<a href="#homes">세 집 비교</a>
<a href="#dossier">선택 서류철</a>
<a href="#handoff">중개사 인계</a>
<a class="nav-admin" href="./admin/">관리 화면</a>
</nav>
</header>
<main id="main-content">
<section class="brief-hero" id="top" aria-labelledby="hero-title">
<div class="masthead">
<p class="eyebrow">한남 생활권 맞춤 중개</p>
<h1 id="hero-title">한남에서, 내 기준에 맞는 집부터.</h1>
<p class="hero-copy">예산과 면적, 입주 계획을 먼저 정리합니다. AI가 후보를 비교하고 공인중개사가 다시 확인합니다.</p>
<div class="hero-actions">
<button class="button button-primary" type="button" data-focus-condition>내 조건 분석하기</button>
<a class="text-link" href="#homes">매물 보기 <span aria-hidden="true">→</span></a>
</div>
<dl class="masthead-note">
<div><dt>대조 기준</dt><dd>가격 · 면적 · 입주 · 주차</dd></div>
<div><dt>마지막 판단</dt><dd>공인중개사 확인</dd></div>
</dl>
</div>
<figure class="hero-scene" data-image-frame>
<figcaption>연출 이미지</figcaption>
<picture>
<source media="(max-width: 640px)" srcset="./assets/hannam-evening-mobile.webp">
<source media="(max-width: 960px)" srcset="./assets/hannam-evening-960.webp">
<img src="./assets/hannam-evening-1600.webp" width="1600" height="1000" alt="서울 언덕 생활권의 현대 주거를 표현한 이미지" data-fallback-image>
</picture>
<div class="image-fallback" hidden data-image-fallback role="img" aria-label="주거 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div>
</figure>
<form class="brief-dock" data-condition-form aria-describedby="condition-note">
<div class="dock-heading"><span>내 기준</span><strong>먼저 다섯 가지만 정리해 보세요.</strong></div>
<label class="dock-field"><span>거래 형태</span><select name="trade" data-trade-input><option value="sale">매매</option><option value="jeonse">전세</option><option value="monthly">월세</option></select></label>
<label class="dock-field"><span>예산 상한</span><span class="input-suffix"><input name="budget" type="number" min="1" max="100" step="1" value="40" inputmode="decimal"><i>억 원</i></span></label>
<label class="dock-field"><span>최소 전용면적</span><span class="input-suffix"><input name="minArea" type="number" min="30" max="300" step="1" value="110" inputmode="decimal"><i>㎡</i></span></label>
<label class="dock-field"><span>입주 희망일</span><input name="moveIn" type="date" value="2026-12-20"></label>
<label class="dock-field"><span>필요 주차</span><select name="parking"><option value="0">상관없음</option><option value="1">1대 이상</option><option value="2" selected>2대 이상</option><option value="3">3대 이상</option></select></label>
<div class="dock-actions"><button class="button button-primary" type="submit">분석 결과 보기</button><button class="save-button" type="button" data-save-condition>이 브라우저에 저장</button></div>
<p class="condition-note" id="condition-note">입력은 전송되지 않으며, 저장을 선택하면 이 브라우저에만 보관됩니다.</p>
</form>
</section>
<section class="briefing-section" id="briefing" aria-labelledby="briefing-title">
<header class="section-heading briefing-heading">
<p class="eyebrow">내 조건 브리핑</p>
<h2 id="briefing-title">맞는 이유와<br>조정할 지점을 나눕니다.</h2>
<p>화면에 입력한 값과 후보 정보를 이 브라우저에서 대조합니다.</p>
</header>
<div class="briefing-sheet" data-analysis-result aria-live="polite" aria-atomic="false">
<header><span>예시 AI 브리핑 · 모의 조건 비교 · 공인중개사 확인 전</span><strong data-analysis-home>아직 분석하지 않았습니다</strong></header>
<div class="briefing-empty" data-analysis-empty>
<span aria-hidden="true">01</span>
<div><h3>내 조건을 먼저 알려 주세요.</h3><p>분석을 실행하면 세 후보 가운데 조건과 가까운 집을 고르고, 이유를 세 묶음으로 정리합니다.</p></div>
<button class="button button-light" type="button" data-focus-condition>조건 입력으로 이동</button>
</div>
<div class="briefing-groups" data-analysis-groups hidden>
<section><span class="group-number">01</span><h3>조건과 맞는 점</h3><ul data-fit-list></ul></section>
<section><span class="group-number">02</span><h3>조정이 필요한 점</h3><ul data-adjust-list></ul></section>
<section><span class="group-number">03</span><h3>중개사와 확인할 점</h3><ul data-check-list></ul></section>
</div>
</div>
</section>
<section class="compare-section" id="homes" aria-labelledby="compare-title">
<header class="section-heading compare-heading">
<div><p class="eyebrow">예시 매물 3곳</p><h2 id="compare-title">서울의 집을<br>한 장씩 대조합니다.</h2></div>
<p>후보를 바꾸면 가운데 장면과 오른쪽 다섯 조건이 함께 바뀝니다.</p>
<div class="unit-switches" role="group" aria-label="표시 단위">
<div role="group" aria-label="면적 단위"><button type="button" aria-pressed="true" data-area-unit="m2">㎡</button><button type="button" aria-pressed="false" data-area-unit="pyeong">평</button></div>
<div role="group" aria-label="가격 표시"><button type="button" aria-pressed="true" data-price-unit="total">총액</button><button type="button" aria-pressed="false" data-price-unit="unit">면적 단가</button></div>
</div>
</header>
<div class="compare-desk">
<div class="candidate-rail" data-candidate-rail role="group" aria-label="비교할 집 선택">
<div class="candidate-option is-selected"><button type="button" class="candidate-button" aria-label="정원형 연립주택 선택" aria-pressed="true" data-select-listing="home-1"></button><div class="candidate-content" aria-hidden="true"><span>01</span><strong>정원형 연립주택</strong><small>매매 · 118.42㎡</small></div></div>
<div class="candidate-option"><button type="button" class="candidate-button" aria-label="복층형 공동주택 선택" aria-pressed="false" data-select-listing="home-2"></button><div class="candidate-content" aria-hidden="true"><span>02</span><strong>복층형 공동주택</strong><small>전세 · 142.80㎡</small></div></div>
<div class="candidate-option"><button type="button" class="candidate-button" aria-label="테라스형 다세대주택 선택" aria-pressed="false" data-select-listing="home-3"></button><div class="candidate-content" aria-hidden="true"><span>03</span><strong>테라스형 다세대주택</strong><small>월세 · 140.84㎡</small></div></div>
</div>
<figure class="candidate-scene" data-image-frame>
<figcaption>연출 이미지</figcaption>
<img src="./assets/hannam-interior-1200.webp" width="1200" height="800" loading="lazy" alt="큰 창과 목재 수납이 있는 거실을 표현한 이미지" data-compare-image data-fallback-image>
<div class="scene-caption"><span data-compare-index>01</span><div><strong data-compare-name>정원형 연립주택</strong><small data-compare-life>한강진역 북쪽 생활권</small></div></div>
<div class="image-fallback" hidden data-image-fallback role="img" aria-label="후보 주거 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div>
</figure>
<dl class="condition-matrix" data-condition-matrix>
<div><dt>거래</dt><dd data-matrix-trade>매매</dd><dd class="matrix-note" data-matrix-trade-note>분석 후 거래 조건 대조</dd></div>
<div><dt>가격</dt><dd data-matrix-price>38억 원</dd><dd class="matrix-note" data-matrix-price-note>분석 후 예산과 대조</dd></div>
<div><dt>전용면적</dt><dd data-matrix-area>118.42㎡</dd><dd class="matrix-note" data-matrix-area-note>분석 후 면적과 대조</dd></div>
<div><dt>주차</dt><dd data-matrix-parking>2대</dd><dd class="matrix-note" data-matrix-parking-note>분석 후 주차와 대조</dd></div>
<div><dt>입주</dt><dd data-matrix-move>2026.12.20 이후</dd><dd class="matrix-note" data-matrix-move-note>분석 후 입주일과 대조</dd></div>
</dl>
</div>
</section>
<section class="dossier-section" id="dossier" aria-labelledby="dossier-title">
<header class="section-heading dossier-heading">
<p class="eyebrow">선택한 예시 집 검토 서류철</p>
<h2 id="dossier-title" data-dossier-title>정원형 연립주택</h2>
<p><strong data-dossier-life>한강진역 북쪽 생활권</strong><span>거리·소요 시간 미제공</span></p>
</header>
<div class="dossier-shell">
<div class="dossier-tabs" role="tablist" aria-label="선택한 집 검토 항목">
<button type="button" role="tab" aria-selected="true" aria-controls="dossier-basic" id="dossier-tab-basic" data-dossier-tab="basic">기본</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-price" id="dossier-tab-price" tabindex="-1" data-dossier-tab="price">가격</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-rights" id="dossier-tab-rights" tabindex="-1" data-dossier-tab="rights">권리</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-location" id="dossier-tab-location" tabindex="-1" data-dossier-tab="location">입지 확인</button>
<button type="button" role="tab" aria-selected="false" aria-controls="dossier-facility" id="dossier-tab-facility" tabindex="-1" data-dossier-tab="facility">시설·관리</button>
</div>
<div class="dossier-pages">
<section class="dossier-panel" role="tabpanel" id="dossier-basic" aria-labelledby="dossier-tab-basic" data-dossier-panel="basic">
<div class="image-filmstrip">
<figure data-image-frame><figcaption>연출 이미지</figcaption><img src="./assets/hannam-evening-960.webp" width="960" height="540" loading="lazy" alt="서울 언덕 생활권의 주거 외관을 표현한 이미지" data-fallback-image><div class="image-fallback" hidden data-image-fallback role="img" aria-label="외관 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div></figure>
<figure data-image-frame><figcaption>연출 이미지</figcaption><img src="./assets/hannam-interior-720.webp" width="720" height="480" loading="lazy" alt="큰 창과 목재 수납이 있는 거실을 표현한 이미지" data-fallback-image><div class="image-fallback" hidden data-image-fallback role="img" aria-label="실내 연출 이미지를 불러오지 못했습니다"><span>이미지를 불러오지 못했습니다</span></div></figure>
</div>
<dl class="spec-strip" data-spec-strip>
<div><dt>거래</dt><dd>매매</dd></div><div><dt>가격</dt><dd>38억 원</dd></div><div><dt>전용</dt><dd>118.42㎡</dd></div><div><dt>주차</dt><dd>2대</dd></div><div><dt>입주</dt><dd>2026.12.20 이후</dd></div>
</dl>
<div class="decision-columns">
<section><h3>내 조건과 맞는 점</h3><ul data-dossier-fit><li>브리핑을 실행하면 입력 조건과의 관계를 표시합니다.</li></ul></section>
<section><h3>확인하고 결정할 점</h3><ul data-dossier-risk><li>등기와 권리 관계를 확인해야 합니다.</li><li>소음과 마감 상태는 현장에서 살펴야 합니다.</li><li>입주 가능일은 중개사와 다시 맞춰야 합니다.</li></ul></section>
</div>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-price" aria-labelledby="dossier-tab-price" data-dossier-panel="price" hidden>
<div class="page-heading"><span>가격 메모</span><h3>입력한 예산과 집의 가격을 따로 봅니다.</h3></div>
<dl class="page-ledger"><div><dt>입력한 예산 상한</dt><dd data-page-budget>40억 원</dd></div><div><dt>집의 거래 조건</dt><dd data-page-price>38억 원</dd></div><div><dt>기준</dt><dd>화면에 표시된 매물 정보</dd></div></dl>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-rights" aria-labelledby="dossier-tab-rights" data-dossier-panel="rights" hidden>
<div class="page-heading"><span>권리 확인</span><h3>계약 전 공인중개사가 다시 볼 항목입니다.</h3></div>
<ul class="review-list"><li><span>01</span><div><strong>등기와 소유 관계</strong><p>서류를 통해 현재 권리 상태를 확인합니다.</p></div></li><li><span>02</span><div><strong>계약 조건과 특약</strong><p>거래 형태에 맞는 보호 조건을 검토합니다.</p></div></li><li><span>03</span><div><strong>현재 거래 가능 상태</strong><p>상담 시점에 다시 확인합니다.</p></div></li></ul>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-location" aria-labelledby="dossier-tab-location" data-dossier-panel="location" hidden>
<div class="page-heading"><span>입지 확인</span><h3 data-location-heading>한강진역 북쪽 생활권을 직접 살핍니다.</h3></div>
<div class="location-memo"><p>역과 생활 편의 시설의 이용 방식은 현장에서 확인합니다.</p><p>출퇴근 경로는 실제 출발지와 시간대에 맞춰 다시 봅니다.</p><p>이미지의 배경은 조망이나 채광 판단에 사용하지 않습니다.</p></div>
</section>
<section class="dossier-panel" role="tabpanel" id="dossier-facility" aria-labelledby="dossier-tab-facility" data-dossier-panel="facility" hidden>
<div class="page-heading"><span>시설·관리</span><h3>생활 비용과 이용 조건을 함께 봅니다.</h3></div>
<dl class="page-ledger"><div><dt>관리비</dt><dd data-page-fee>월 48만 원</dd></div><div><dt>포함 항목</dt><dd data-page-included>공용 관리, 청소, 승강기</dd></div><div><dt>별도 항목</dt><dd data-page-excluded>전기, 가스, 수도</dd></div><div><dt>방과 욕실</dt><dd data-page-rooms>방 3 · 욕실 2</dd></div></dl>
</section>
</div>
</div>
</section>
<section class="handoff-section" id="handoff" aria-labelledby="handoff-title">
<header><p class="eyebrow">사람에게 넘기는 마감</p><h2 id="handoff-title">AI가 좁히고,<br>공인중개사가 확인합니다.</h2></header>
<ol class="handoff-steps">
<li><span>01</span><div><strong>조건 정리</strong><p>가격, 면적, 입주, 주차를 한 장에 모읍니다.</p></div></li>
<li><span>02</span><div><strong>후보 비교</strong><p>서로 다른 집의 차이를 같은 기준으로 대조합니다.</p></div></li>
<li><span>03</span><div><strong>권리·현장 확인</strong><p>서류와 현장에서 확인한 뒤 최종 판단합니다.</p></div></li>
</ol>
<button class="button button-light handoff-cta" type="button" data-open-consult>상담 흐름 미리보기</button>
</section>
</main>
<footer class="site-footer">
<div><strong>한남자리</strong><span>개인 주거 브리핑 데스크</span></div>
<p>한남자리는 가상 브랜드이며 등록 공인중개사무소나 거래 서비스를 제공하지 않습니다.</p>
<nav aria-label="보조 메뉴"><a href="#top">처음으로</a><a href="./admin/">관리 화면</a></nav>
</footer>
<dialog class="consult-dialog" data-consult-dialog aria-labelledby="consult-title">
<form method="dialog" class="dialog-shell" data-consult-form>
<header><div><span>상담 순서 살펴보기</span><h2 id="consult-title">중개사에게 무엇을 넘길까요?</h2></div><button class="dialog-close" type="button" aria-label="상담 흐름 닫기" data-close-consult>×</button></header>
<p class="dialog-privacy">연락처를 받거나 입력 내용을 저장하지 않습니다.</p>
<div class="dialog-content" data-consult-content>
<p><strong data-consult-home>정원형 연립주택</strong>을 기준으로 준비할 항목을 골라 보세요.</p>
<label><input type="checkbox" name="agenda" value="rights" checked> 권리 관계와 계약 조건</label>
<label><input type="checkbox" name="agenda" value="visit" checked> 현장 방문 때 볼 항목</label>
<label><input type="checkbox" name="agenda" value="move"> 입주 가능일 조율</label>
</div>
<footer><button class="button button-primary" type="button" data-finish-consult>다음 순서 보기</button></footer>
</form>
</dialog>
<div class="live-region" role="status" aria-live="polite" data-live-region hidden></div>
<noscript><div class="no-script"><strong>자바스크립트 없이 보는 중입니다.</strong><p>집 정보는 읽을 수 있지만 조건 비교, 후보 변경과 상담 미리보기는 작동하지 않습니다.</p></div></noscript>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View file

@ -0,0 +1,608 @@
(() => {
"use strict";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const listingDetails = {
"DEMO-HJ-101": {
title: "한남 생활권 정원 주거",
status: "검수 필요",
statusClass: "status-warn",
trade: "매매",
type: "연립주택",
price: "38억 원",
area: "118.42㎡ · 35.8평",
rooms: "방 3 · 욕실 2",
moveIn: "2026.12.20 이후 협의",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "2개 차단",
ai: "사람 검토 전"
},
"DEMO-HJ-102": {
title: "한강 가까운 복층 주거",
status: "쇼케이스 공개",
statusClass: "status-pass",
trade: "전세",
type: "공동주택",
price: "전세 22억 원",
area: "142.80㎡ · 43.2평",
rooms: "방 4 · 욕실 2",
moveIn: "2026.11.30 이후 협의",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "데모 통과",
ai: "수정 후 승인"
},
"DEMO-HJ-103": {
title: "조용한 골목의 테라스 주거",
status: "자료 오래됨",
statusClass: "status-warn",
trade: "월세",
type: "다세대주택",
price: "보증금 5억 · 월 1,200만",
area: "140.84㎡ · 42.6평",
rooms: "방 3 · 욕실 2",
moveIn: "입주 시점 다시 확인",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "다시 확인",
ai: "입력 변경으로 오래됨"
},
"DEMO-HJ-104": {
title: "언덕 위 작은 정원 주거",
status: "계약 완료",
statusClass: "status-demo",
trade: "매매",
type: "단독주택",
price: "상담 종료",
area: "예시 공개 종료",
rooms: "공개 목록 제외",
moveIn: "해당 없음",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "게시 안 함",
ai: "종료"
},
"DEMO-HJ-105": {
title: "남산 가까운 가족 주거",
status: "초안",
statusClass: "status-demo",
trade: "전세",
type: "공동주택",
price: "조건 작성 중",
area: "132.00㎡ · 39.9평",
rooms: "입력 전",
moveIn: "입력 전",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "5개 차단",
ai: "실행 전"
},
"DEMO-HJ-106": {
title: "독서당길 안쪽 복층 주거",
status: "검수 필요",
statusClass: "status-warn",
trade: "매매",
type: "공동주택",
price: "41억 원",
area: "151.20㎡ · 45.7평",
rooms: "방 4 · 욕실 3",
moveIn: "2027.01 이후 협의",
image: "../assets/hannam-evening-640.webp",
imageAlt: "서울 언덕길의 석재 주거 건물 연출 이미지",
blockers: "동의 대기",
ai: "직전 실행 실패"
}
};
const state = {
selectedId: "DEMO-HJ-101",
mode: "normal",
toastTimer: null,
draftCreated: false
};
const queuePanel = $("[data-queue-panel]");
const queueBody = $("[data-queue-body]");
const inspector = $("[data-inspector]");
const workbench = $(".admin-workbench");
const inspectorBackdrop = $("[data-inspector-backdrop]");
const sidebar = $("[data-sidebar]");
const sidebarBackdrop = $("[data-sidebar-backdrop]");
const announce = (message) => {
const region = $("[data-admin-live]");
if (!region) return;
region.textContent = message;
region.hidden = false;
window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
region.hidden = true;
region.textContent = "";
}, 3600);
};
const showBanner = (title, copy, kind = "warn") => {
const banner = $("[data-system-banner]");
if (!banner) return;
banner.classList.remove("is-error", "is-info");
if (kind === "error") banner.classList.add("is-error");
if (kind === "info") banner.classList.add("is-info");
$("[data-banner-title]", banner).textContent = title;
$("[data-banner-copy]", banner).textContent = copy;
banner.hidden = false;
};
const hideBanner = () => {
const banner = $("[data-system-banner]");
if (banner) banner.hidden = true;
};
const getRows = () => $$('[data-queue-row]', queueBody);
const isCompactInspector = () => window.matchMedia("(max-width: 76rem)").matches;
const updateInspectorBackdrop = () => {
if (!inspectorBackdrop) return;
inspectorBackdrop.hidden = !(isCompactInspector() && inspector?.classList.contains("is-open"));
};
const openInspector = (id, focus = false) => {
const detail = listingDetails[id] || listingDetails["DEMO-HJ-101"];
state.selectedId = id;
getRows().forEach((row) => {
const active = row.dataset.id === id;
row.classList.toggle("is-selected", active);
row.setAttribute("aria-selected", String(active));
});
if (!inspector) return;
inspector.classList.add("is-open");
workbench?.classList.remove("is-inspector-closed");
$("[data-inspector-id]", inspector).textContent = `${id} · 예시 매물`;
$("[data-inspector-title]", inspector).textContent = detail.title;
const status = $("[data-inspector-status]", inspector);
status.textContent = detail.status;
status.className = `status-badge ${detail.statusClass}`;
const image = $("[data-inspector-image]", inspector);
if (image) {
image.hidden = false;
image.src = detail.image;
image.alt = detail.imageAlt;
image.parentElement?.classList.remove("is-image-error");
}
const basicGrid = $("[data-basic-grid]", inspector);
if (basicGrid) {
const pairs = [
["거래 형태", detail.trade],
["예시 가격", detail.price],
["유형", detail.type],
["전용 면적", detail.area],
["방과 욕실", detail.rooms],
["입주 가능", detail.moveIn]
];
basicGrid.replaceChildren(...pairs.map(([label, value]) => {
const wrapper = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
wrapper.append(term, description);
return wrapper;
}));
}
updateInspectorBackdrop();
if (focus) $("[data-close-inspector]", inspector)?.focus();
announce(`${detail.title} 조사관을 열었습니다. ${detail.status} 상태입니다.`);
};
const closeInspector = ({ restoreFocus = false } = {}) => {
if (!inspector?.classList.contains("is-open")) return;
const previousId = state.selectedId;
inspector.classList.remove("is-open");
workbench?.classList.add("is-inspector-closed");
getRows().forEach((row) => {
row.classList.remove("is-selected");
row.setAttribute("aria-selected", "false");
});
if (inspectorBackdrop) inspectorBackdrop.hidden = true;
state.selectedId = null;
if (restoreFocus && previousId) {
$(`[data-queue-row][data-id="${previousId}"] [data-select-row]`)?.focus();
}
announce("조사관과 선택 행을 함께 닫았습니다.");
};
const activateTab = (name, focus = false) => {
$$('[role="tab"]', inspector).forEach((tab) => {
const active = tab.dataset.tab === name;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
if (active && focus) tab.focus();
});
$$('[data-panel]', inspector).forEach((panel) => {
panel.hidden = panel.dataset.panel !== name;
});
};
const focusMissing = () => {
if (!state.selectedId) openInspector("DEMO-HJ-101");
activateTab("legal");
window.requestAnimationFrame(() => {
const missing = $("#missing-orientation");
missing?.scrollIntoView({ block: "center", behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
missing?.focus();
announce("첫 누락 항목인 방향 판단 근거로 이동했습니다.");
});
};
const closeSidebar = () => {
sidebar?.classList.remove("is-open");
if (sidebarBackdrop) sidebarBackdrop.hidden = true;
$("[data-open-sidebar]")?.setAttribute("aria-expanded", "false");
};
const openSidebar = () => {
sidebar?.classList.add("is-open");
if (sidebarBackdrop) sidebarBackdrop.hidden = false;
$("[data-open-sidebar]")?.setAttribute("aria-expanded", "true");
$("[data-close-sidebar]")?.focus();
};
const updateResultCount = () => {
const visible = getRows().filter((row) => !row.classList.contains("is-hidden"));
const result = $("[data-result-count]");
if (result) result.textContent = `${visible.length}개 예시`;
const empty = $("[data-queue-empty]");
if (state.mode === "normal") empty.hidden = visible.length !== 0;
return visible.length;
};
const applyFilters = () => {
const search = ($("[data-queue-search]")?.value || "").trim().toLocaleLowerCase("ko-KR");
const status = $("[data-status-filter]")?.value || "all";
getRows().forEach((row) => {
const matchesSearch = !search || row.textContent.toLocaleLowerCase("ko-KR").includes(search);
const matchesStatus = status === "all" || row.dataset.status === status;
row.classList.toggle("is-hidden", !(matchesSearch && matchesStatus));
});
const count = updateResultCount();
const emptyTitle = $("[data-empty-title]");
const emptyCopy = $("[data-empty-copy]");
if (count === 0 && state.mode === "normal") {
emptyTitle.textContent = "현재 조건에 맞는 예시가 없습니다.";
emptyCopy.textContent = "검색어와 필터를 초기화하면 전체 대기열을 다시 볼 수 있습니다.";
}
};
const sortRows = () => {
const sort = $("[data-sort-select]")?.value || "updated-desc";
const rows = getRows();
const compare = {
"updated-desc": (a, b) => b.dataset.updated.localeCompare(a.dataset.updated),
"updated-asc": (a, b) => a.dataset.updated.localeCompare(b.dataset.updated),
"title-asc": (a, b) => a.dataset.title.localeCompare(b.dataset.title, "ko"),
"status-asc": (a, b) => a.dataset.status.localeCompare(b.dataset.status, "ko")
}[sort];
rows.sort(compare).forEach((row) => queueBody.append(row));
announce("대기열 정렬을 바꿨습니다.");
};
const resetModes = () => {
queuePanel?.setAttribute("aria-busy", "false");
const loading = $("[data-queue-loading]");
const empty = $("[data-queue-empty]");
if (loading) loading.hidden = true;
if (empty) empty.hidden = true;
hideBanner();
document.body.classList.remove("is-permission-limited", "is-stale-demo");
$$('[data-review-ai], [data-simulate-save], [data-new-draft]').forEach((button) => button.disabled = false);
applyFilters();
};
const simulateState = (mode) => {
state.mode = mode;
resetModes();
if (mode === "loading") {
queuePanel?.setAttribute("aria-busy", "true");
$("[data-queue-loading]").hidden = false;
announce("대기열을 불러오는 중입니다.");
return;
}
if (mode === "empty") {
const empty = $("[data-queue-empty]");
empty.hidden = false;
$("[data-empty-title]").textContent = "아직 접수된 예시 매물이 없습니다.";
$("[data-empty-copy]").textContent = "새 예시 초안을 만들거나 다른 상태를 시연할 수 있습니다.";
announce("빈 대기열 상태를 시연합니다.");
return;
}
if (mode === "error") {
showBanner("대기열 일부를 불러오지 못했습니다.", "마지막으로 확인한 예시 자료를 유지합니다. 재시도 전에는 게시 상태를 바꿀 수 없습니다.", "error");
announce("불러오기 실패 상태입니다. 마지막 확인 자료를 보여 줍니다.");
return;
}
if (mode === "save-error") {
showBanner("저장 경로를 확인할 수 없습니다.", "입력은 화면에 남지만 저장 성공으로 표시하지 않습니다. 수정 시연을 누르면 실패 처리를 확인할 수 있습니다.", "error");
announce("저장 실패 상태를 시연합니다.");
return;
}
if (mode === "stale") {
document.body.classList.add("is-stale-demo");
showBanner("선택 매물의 자료가 오래됐습니다.", "가격, 거래 가능 상태와 현장 확인을 다시 보기 전에는 게시 준비로 이동할 수 없습니다.");
if (!state.selectedId) openInspector("DEMO-HJ-103");
const status = $("[data-inspector-status]");
if (status) {
status.textContent = "자료 오래됨";
status.className = "status-badge status-warn";
}
announce("자료 오래됨 상태를 시연합니다.");
return;
}
if (mode === "conflict") {
showBanner("다른 예시 담당자가 먼저 수정했습니다.", "현재 버전 7과 서버 예시 버전 8의 차이를 확인한 뒤 다시 저장해야 합니다.", "error");
announce("버전 충돌 상태를 시연합니다.");
return;
}
if (mode === "permission") {
document.body.classList.add("is-permission-limited");
$$('[data-review-ai], [data-simulate-save], [data-new-draft]').forEach((button) => button.disabled = true);
showBanner("중개보조 예시 권한으로 보는 중입니다.", "초안과 일정은 정리할 수 있지만 법정 검수 승인, AI 최종 승인과 게시를 할 수 없습니다.", "info");
announce("권한 제한 상태를 시연합니다.");
return;
}
announce("정상 데모 상태로 돌아왔습니다.");
};
const openDialog = (dialog) => {
if (!dialog) return;
dialog.showModal();
document.body.classList.add("is-locked");
};
const closeDialog = (dialog) => {
if (dialog?.open) dialog.close();
};
const simulateSave = () => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 매물 정보를 수정할 수 없습니다.");
return;
}
if (state.mode === "conflict") {
showBanner("버전 충돌로 저장하지 않았습니다.", "서버 예시 버전 8을 다시 불러오거나 현재 수정을 별도 초안으로 남겨야 합니다.", "error");
announce("버전 충돌로 저장하지 않았습니다.");
return;
}
if (state.mode === "save-error") {
showBanner("저장에 실패했습니다.", "변경 내용은 화면에 남아 있습니다. 성공 기록과 감사 로그는 만들지 않았습니다.", "error");
announce("저장에 실패했습니다. 성공으로 처리하지 않았습니다.");
return;
}
announce("수정 시연을 마쳤습니다. 서버가 없는 데모라 실제 저장하지 않았습니다.");
};
const createDraft = () => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 새 초안을 만들 수 없습니다.");
return;
}
if (state.draftCreated) {
openInspector("DEMO-HJ-NEW", true);
return;
}
state.draftCreated = true;
listingDetails["DEMO-HJ-NEW"] = {
title: "제목 없는 새 예시 초안",
status: "초안",
statusClass: "status-demo",
trade: "미선택",
type: "미선택",
price: "입력 전",
area: "입력 전",
rooms: "입력 전",
moveIn: "입력 전",
image: "../assets/hannam-interior-720.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실의 연출 이미지",
blockers: "필수 입력 대기",
ai: "실행 전"
};
const row = document.createElement("tr");
row.dataset.queueRow = "";
row.dataset.id = "DEMO-HJ-NEW";
row.dataset.status = "draft";
row.dataset.updated = new Date().toISOString();
row.dataset.title = "제목 없는 새 예시 초안";
row.setAttribute("aria-selected", "false");
row.innerHTML = `
<td class="row-cell-select"><button type="button" class="row-select-overlay" data-select-row aria-label="제목 없는 새 예시 초안 조사관 열기"></button><span class="row-title" aria-hidden="true"><span class="row-thumb row-thumb-draft"><i></i></span><span><strong>제목 없는 새 예시 초안</strong><small>DEMO-HJ-NEW · 예시 매물</small></span></span></td>
<td><span>미선택</span><small>유형 미선택</small></td><td><strong>입력 전</strong><small>면적 입력 전</small></td><td><span>방금</span><small>데모 초안</small></td><td><span class="cell-status is-blocked"><i aria-hidden="true"></i>입력 대기</span></td><td><span class="cell-status is-muted"><i aria-hidden="true"></i>실행 전</span></td><td><span class="assignee"><i>김</i>김유진</span></td>`;
queueBody.prepend(row);
applyFilters();
openInspector("DEMO-HJ-NEW", true);
announce("새 예시 초안을 만들었습니다. 외부 서버에는 저장하지 않았습니다.");
};
const handleRowSelection = (button) => {
const row = button.closest("[data-queue-row]");
if (row) openInspector(row.dataset.id, true);
};
const initTabs = () => {
const tabs = $$('[role="tab"]', inspector);
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => activateTab(tab.dataset.tab));
tab.addEventListener("keydown", (event) => {
let targetIndex = null;
if (event.key === "ArrowRight") targetIndex = (index + 1) % tabs.length;
if (event.key === "ArrowLeft") targetIndex = (index - 1 + tabs.length) % tabs.length;
if (event.key === "Home") targetIndex = 0;
if (event.key === "End") targetIndex = tabs.length - 1;
if (targetIndex !== null) {
event.preventDefault();
activateTab(tabs[targetIndex].dataset.tab, true);
}
});
});
};
const initDialogs = () => {
const blockDialog = $("[data-block-dialog]");
const previewDialog = $("[data-preview-dialog]");
$$('[data-block-publish]').forEach((button) => button.addEventListener("click", () => openDialog(blockDialog)));
$$('[data-open-preview]').forEach((button) => button.addEventListener("click", () => openDialog(previewDialog)));
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
$$('dialog').forEach((dialog) => {
dialog.addEventListener("click", (event) => {
if (event.target === dialog) closeDialog(dialog);
});
dialog.addEventListener("close", () => {
document.body.classList.remove("is-locked");
closeInspector();
});
dialog.addEventListener("cancel", () => document.body.classList.remove("is-locked"));
});
};
const initImageFallback = () => {
$$('img').forEach((image) => {
const handleError = () => {
image.hidden = true;
image.parentElement?.classList.add("is-image-error");
image.parentElement?.setAttribute("data-image-error", "연출 이미지를 불러오지 못했습니다");
};
image.addEventListener("error", handleError);
if (image.complete && image.naturalWidth === 0) handleError();
});
};
const initGlobalActions = () => {
$("[data-close-inspector]")?.addEventListener("click", () => closeInspector({ restoreFocus: true }));
inspectorBackdrop?.addEventListener("click", () => closeInspector({ restoreFocus: true }));
$("[data-open-sidebar]")?.addEventListener("click", openSidebar);
$("[data-close-sidebar]")?.addEventListener("click", closeSidebar);
sidebarBackdrop?.addEventListener("click", closeSidebar);
$("[data-dismiss-banner]")?.addEventListener("click", hideBanner);
$("[data-reset-filter]")?.addEventListener("click", () => {
$("[data-queue-search]").value = "";
$("[data-status-filter]").value = "all";
state.mode = "normal";
$("[data-state-simulator]").value = "normal";
resetModes();
announce("검색과 필터를 초기화했습니다.");
});
$("[data-queue-search]")?.addEventListener("input", applyFilters);
$("[data-status-filter]")?.addEventListener("change", applyFilters);
$("[data-sort-select]")?.addEventListener("change", sortRows);
$("[data-state-simulator]")?.addEventListener("change", (event) => simulateState(event.target.value));
$$('[data-focus-missing]').forEach((button) => button.addEventListener("click", focusMissing));
$$('[data-simulate-save]').forEach((button) => button.addEventListener("click", simulateSave));
$("[data-new-draft]")?.addEventListener("click", createDraft);
queueBody?.addEventListener("click", (event) => {
const button = event.target.closest("[data-select-row]");
if (button) handleRowSelection(button);
});
$("[data-review-ai]")?.addEventListener("click", (event) => {
if (state.mode === "permission") {
announce("현재 예시 권한으로는 AI 결과를 승인할 수 없습니다.");
return;
}
event.currentTarget.textContent = "수정 후 승인됨";
event.currentTarget.disabled = true;
const badge = $("#inspector-ai .status-badge");
if (badge) {
badge.textContent = "수정 후 승인";
badge.className = "status-badge status-pass";
}
announce("AI 참고 분석을 수정 후 승인 상태로 바꿨습니다. 데모 외부에는 게시하지 않았습니다.");
});
$$('[data-claim-action]').forEach((button) => button.addEventListener("click", () => {
button.textContent = button.dataset.claimAction === "edit" ? "수정 반영" : "확인됨";
button.disabled = true;
announce("주장 검토 상태를 데모 안에서만 바꿨습니다.");
}));
$("[data-reschedule]")?.addEventListener("click", (event) => {
const card = event.currentTarget.closest(".appointment-card");
const time = $("div > strong", card);
time.textContent = time.textContent.includes("3:30") ? "오후 5:00~5:30" : "오후 3:30~4:00";
announce("예시 상담 시간을 바꿨습니다. 외부 알림은 보내지 않았습니다.");
});
$("[data-simulate-log-failure]")?.addEventListener("click", () => {
showBanner("감사 로그를 불러오지 못했습니다.", "변경 기록을 확인할 수 없는 동안 저장과 게시를 성공으로 처리하지 않습니다.", "error");
announce("감사 로그 실패 상태입니다. 게시를 중지합니다.");
});
$("[data-command-button]")?.addEventListener("click", () => {
$("[data-queue-search]")?.focus();
announce("매물 검색으로 이동했습니다.");
});
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "k") {
event.preventDefault();
$("[data-queue-search]")?.focus();
}
if (event.key === "Escape" && !$('dialog[open]')) {
if (sidebar?.classList.contains("is-open")) closeSidebar();
else closeInspector({ restoreFocus: true });
}
});
document.addEventListener("click", (event) => {
if (!inspector?.classList.contains("is-open") || $('dialog[open]')) return;
const path = event.composedPath();
const matchesPath = (selector) => path.some((node) => node instanceof Element && node.matches(selector));
const keptOpen = matchesPath("[data-inspector], [data-select-row], [data-inspector-trigger], [data-inspector-backdrop], [data-open-preview], [data-block-publish], [data-focus-missing]");
if (!keptOpen && !matchesPath("[data-open-sidebar], [data-sidebar]")) closeInspector();
});
window.matchMedia("(max-width: 76rem)").addEventListener("change", updateInspectorBackdrop);
window.matchMedia("(min-width: 50.01rem)").addEventListener("change", (event) => {
if (event.matches) closeSidebar();
});
$$('.sidebar-nav a[href^="#inspector-"]').forEach((link) => link.addEventListener("click", (event) => {
event.preventDefault();
if (!state.selectedId) openInspector("DEMO-HJ-101");
activateTab(link.getAttribute("href").replace("#inspector-", ""), true);
closeSidebar();
}));
};
const init = () => {
initTabs();
initDialogs();
initImageFallback();
initGlobalActions();
openInspector("DEMO-HJ-101");
updateResultCount();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();

View file

@ -0,0 +1,590 @@
(() => {
"use strict";
const homes = [
{
id: "home-1",
index: "01",
name: "정원형 연립주택",
trade: "sale",
tradeLabel: "매매",
priceEok: 38,
areaM2: 118.42,
areaPyeong: 35.8,
parking: 2,
moveDate: "2026-12-20",
moveLabel: "2026.12.20 이후 협의",
life: "한강진역 북쪽 생활권",
fee: "월 48만 원",
included: "공용 관리, 청소, 승강기",
excluded: "전기, 가스, 수도",
rooms: "방 3 · 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
},
{
id: "home-2",
index: "02",
name: "복층형 공동주택",
trade: "jeonse",
tradeLabel: "전세",
priceEok: 22,
areaM2: 142.8,
areaPyeong: 43.2,
parking: 2,
moveDate: "2027-01-15",
moveLabel: "2027.01.15 이후 협의",
life: "독서당길 남쪽 생활권",
fee: "월 56만 원",
included: "공용 관리, 보안, 승강기",
excluded: "전기, 가스, 수도, 냉난방",
rooms: "방 4 · 욕실 2",
image: "./assets/hannam-evening-1600.webp",
imageAlt: "서울 언덕 생활권의 현대 주거 외관을 표현한 이미지"
},
{
id: "home-3",
index: "03",
name: "테라스형 다세대주택",
trade: "monthly",
tradeLabel: "월세",
priceEok: 5,
monthlyManwon: 1200,
areaM2: 140.84,
areaPyeong: 42.6,
parking: 1,
moveDate: "2026-11-01",
moveLabel: "2026.11.01 이후 협의",
life: "이태원로 동쪽 생활권",
fee: "월 52만 원",
included: "공용 관리, 청소",
excluded: "전기, 가스, 수도, 승강기 점검",
rooms: "방 3 · 욕실 2",
image: "./assets/hannam-interior-1200.webp",
imageAlt: "큰 창과 목재 수납이 있는 거실을 표현한 이미지"
}
];
const STORAGE_KEY = "hannam-jari-conditions-v2";
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const state = {
selectedId: "home-1",
areaUnit: "m2",
priceUnit: "total",
analyzed: false,
conditions: null,
toastTimer: null,
consultationComplete: false
};
const form = $("[data-condition-form]");
const dialog = $("[data-consult-dialog]");
const formatNumber = (value, digits = 0) => new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
const getHome = (id = state.selectedId) => homes.find((home) => home.id === id) || homes[0];
const formatArea = (home) => state.areaUnit === "m2"
? `${formatNumber(home.areaM2, 2)}㎡`
: `${formatNumber(home.areaPyeong, 1)}평`;
const unitPriceText = (home) => {
const perPyeong = Math.round(((home.priceEok * 10000) / home.areaPyeong) / 10) * 10;
if (home.trade === "monthly") return `보증금 평당 약 ${formatNumber(perPyeong)}만 · 월 ${formatNumber(home.monthlyManwon)}만`;
return `평당 약 ${formatNumber(perPyeong)}만 원`;
};
const totalPriceText = (home) => home.trade === "monthly"
? `보증금 ${formatNumber(home.priceEok)}억 · 월 ${formatNumber(home.monthlyManwon)}만`
: `${formatNumber(home.priceEok)}억 원`;
const formatPrice = (home) => state.priceUnit === "total" ? totalPriceText(home) : unitPriceText(home);
const readConditions = () => {
const data = new FormData(form);
return {
trade: String(data.get("trade") || "sale"),
budget: Number(data.get("budget") || 0),
minArea: Number(data.get("minArea") || 0),
moveIn: String(data.get("moveIn") || ""),
parking: Number(data.get("parking") || 0)
};
};
const announce = (message) => {
const region = $("[data-live-region]");
if (!region) return;
region.textContent = message;
region.hidden = false;
window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
region.hidden = true;
region.textContent = "";
}, 3200);
};
const claim = (title, text, source) => ({ title, text, source });
const evaluateHome = (home, conditions) => {
const fit = [];
const adjust = [];
const check = [];
if (home.trade === conditions.trade) {
fit.push(claim("거래 형태", `${home.tradeLabel} 조건과 같습니다.`, "입력한 조건 · 매물 정보"));
} else {
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)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("월 납부 조건", `보증금은 상한 안쪽이지만 월 ${formatNumber(home.monthlyManwon)}만 원을 별도로 살펴야 합니다.`, "입력한 조건 · 매물 정보"));
}
} else if (home.priceEok <= conditions.budget) {
fit.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(conditions.budget - home.priceEok)}억 원 낮습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("가격 상한", `입력한 상한보다 ${formatNumber(home.priceEok - conditions.budget)}억 원 높습니다.`, "입력한 조건 · 매물 정보"));
}
if (home.areaM2 >= conditions.minArea) {
fit.push(claim("전용면적", `최소 면적보다 ${formatNumber(home.areaM2 - conditions.minArea, 2)}㎡ 넓습니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("전용면적", `최소 면적보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작습니다.`, "입력한 조건 · 매물 정보"));
}
if (home.parking >= conditions.parking) {
fit.push(claim("주차", `${home.parking}대로 필요한 수를 충족합니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("주차", `필요한 수보다 ${conditions.parking - home.parking}대 부족합니다.`, "입력한 조건 · 매물 정보"));
}
if (!conditions.moveIn) {
adjust.push(claim("입주 희망일", "날짜를 정하면 입주 가능 시점과 대조할 수 있습니다.", "입력한 조건"));
} else if (home.moveDate <= conditions.moveIn) {
fit.push(claim("입주 시점", `${home.moveLabel}으로 희망일 전후 협의가 가능합니다.`, "입력한 조건 · 매물 정보"));
} else {
adjust.push(claim("입주 시점", `${home.moveLabel}으로 입력한 희망일보다 늦습니다.`, "입력한 조건 · 매물 정보"));
}
check.push(claim("권리 관계", "등기와 계약 조건은 서류를 통해 다시 살펴야 합니다.", "공인중개사 확인"));
check.push(claim("현장 상태", "소음, 마감과 채광은 방문해서 확인해야 합니다.", "현장 확인"));
return { fit, adjust, check };
};
const tradeLabel = (trade) => ({ sale: "매매", jeonse: "전세", monthly: "월세" })[trade] || "미선택";
const scoreHome = (home, conditions) => {
let score = home.trade === conditions.trade ? 4 : 0;
if (home.trade === "monthly") score += home.priceEok <= conditions.budget ? 0.5 : 0;
else score += home.priceEok <= conditions.budget ? 1 : 0;
score += home.areaM2 >= conditions.minArea ? 1 : 0;
score += home.parking >= conditions.parking ? 1 : 0;
score += conditions.moveIn && home.moveDate <= conditions.moveIn ? 1 : 0;
return score;
};
const createClaimItem = ({ title, text, source }) => {
const item = document.createElement("li");
const strong = document.createElement("strong");
const span = document.createElement("span");
const small = document.createElement("small");
strong.textContent = title;
span.textContent = text;
small.textContent = source;
item.append(strong, span, small);
return item;
};
const fillClaimList = (selector, items, emptyText) => {
const list = $(selector);
if (!list) return;
const nextItems = items.length ? items : [claim(emptyText, "현재 입력에서 바로 조정할 항목이 없습니다.", "입력한 조건 · 매물 정보")];
list.replaceChildren(...nextItems.map(createClaimItem));
};
const renderBriefing = (home, conditions) => {
const result = evaluateHome(home, conditions);
$("[data-analysis-empty]").hidden = true;
$("[data-analysis-groups]").hidden = false;
$("[data-analysis-home]").textContent = `${home.name} 브리핑`;
fillClaimList("[data-fit-list]", result.fit, "바로 맞는 조건 없음");
fillClaimList("[data-adjust-list]", result.adjust, "바로 조정할 조건 없음");
fillClaimList("[data-check-list]", result.check, "추가 확인 항목 없음");
return result;
};
const resetBriefing = () => {
state.analyzed = false;
state.conditions = null;
$("[data-analysis-empty]").hidden = false;
$("[data-analysis-groups]").hidden = true;
$("[data-analysis-home]").textContent = "아직 분석하지 않았습니다";
};
const setMatrixNote = (selector, text, tone = "") => {
const node = $(selector);
if (!node) return;
node.textContent = text;
node.className = ["matrix-note", tone].filter(Boolean).join(" ");
};
const renderMatrix = (home, conditions = readConditions()) => {
$("[data-matrix-trade]").textContent = home.tradeLabel;
$("[data-matrix-price]").textContent = formatPrice(home);
$("[data-matrix-area]").textContent = formatArea(home);
$("[data-matrix-parking]").textContent = `${home.parking}대`;
$("[data-matrix-move]").textContent = home.moveLabel;
if (!state.analyzed) {
setMatrixNote("[data-matrix-trade-note]", "분석 후 거래 조건 대조");
setMatrixNote("[data-matrix-price-note]", "분석 후 예산과 대조");
setMatrixNote("[data-matrix-area-note]", "분석 후 면적과 대조");
setMatrixNote("[data-matrix-parking-note]", "분석 후 주차와 대조");
setMatrixNote("[data-matrix-move-note]", "분석 후 입주일과 대조");
return;
}
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)}억 높음`;
setMatrixNote("[data-matrix-price-note]", depositNote, "is-check");
} else {
const pricePass = home.priceEok <= conditions.budget;
setMatrixNote("[data-matrix-price-note]", pricePass ? `예산 ${formatNumber(conditions.budget)}억 안쪽` : `예산보다 ${formatNumber(home.priceEok - conditions.budget)}억 높음`, pricePass ? "" : "is-adjust");
}
const areaPass = home.areaM2 >= conditions.minArea;
setMatrixNote("[data-matrix-area-note]", areaPass ? `최소 ${formatNumber(conditions.minArea)}㎡ 충족` : `최소보다 ${formatNumber(conditions.minArea - home.areaM2, 2)}㎡ 작음`, areaPass ? "" : "is-adjust");
const parkingPass = home.parking >= conditions.parking;
setMatrixNote("[data-matrix-parking-note]", parkingPass ? `필요 ${conditions.parking}대 충족` : `${conditions.parking - home.parking}대 부족`, parkingPass ? "" : "is-adjust");
if (!conditions.moveIn) {
setMatrixNote("[data-matrix-move-note]", "희망일 입력 필요", "is-check");
} else {
const movePass = home.moveDate <= conditions.moveIn;
setMatrixNote("[data-matrix-move-note]", movePass ? "희망일 전후 협의 가능" : "희망일보다 늦음", movePass ? "" : "is-adjust");
}
};
const renderCandidateRail = () => {
$$('[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 content = button.nextElementSibling;
const small = content?.querySelector("small");
if (small) small.textContent = `${home.tradeLabel} · ${formatArea(home)}`;
});
};
const renderCompareScene = (home) => {
const image = $("[data-compare-image]");
if (image) {
image.hidden = false;
image.src = home.image;
image.alt = home.imageAlt;
image.parentElement?.classList.remove("is-image-error");
$("[data-image-fallback]", image.parentElement).hidden = true;
}
$("[data-compare-index]").textContent = home.index;
$("[data-compare-name]").textContent = home.name;
$("[data-compare-life]").textContent = home.life;
};
const setSimpleList = (selector, items) => {
const list = $(selector);
if (!list) return;
list.replaceChildren(...items.map((text) => {
const item = document.createElement("li");
item.textContent = text;
return item;
}));
};
const renderSpecs = (home) => {
const root = $("[data-spec-strip]");
if (!root) return;
const pairs = [
["거래", home.tradeLabel],
["가격", formatPrice(home)],
["전용", formatArea(home)],
["주차", `${home.parking}대`],
["입주", home.moveLabel]
];
root.replaceChildren(...pairs.map(([label, value]) => {
const wrapper = document.createElement("div");
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
wrapper.append(term, description);
return wrapper;
}));
};
const renderDossier = (home, conditions = readConditions()) => {
$("[data-dossier-title]").textContent = home.name;
$("[data-dossier-life]").textContent = home.life;
const consultHome = $("[data-consult-home]");
if (consultHome) consultHome.textContent = home.name;
$("[data-location-heading]").textContent = `${home.life}을 직접 살핍니다.`;
$("[data-page-budget]").textContent = `${formatNumber(conditions.budget)}억 원`;
$("[data-page-price]").textContent = totalPriceText(home);
$("[data-page-fee]").textContent = home.fee;
$("[data-page-included]").textContent = home.included;
$("[data-page-excluded]").textContent = home.excluded;
$("[data-page-rooms]").textContent = home.rooms;
renderSpecs(home);
if (!state.analyzed) {
setSimpleList("[data-dossier-fit]", ["브리핑을 실행하면 입력 조건과의 관계를 표시합니다."]);
setSimpleList("[data-dossier-risk]", ["등기와 권리 관계를 확인해야 합니다.", "소음과 마감 상태는 현장에서 살펴야 합니다.", "입주 가능일은 중개사와 다시 맞춰야 합니다."]);
return;
}
const result = evaluateHome(home, conditions);
const fits = result.fit.length ? result.fit.map((item) => item.text) : ["현재 입력과 바로 맞는 항목은 없습니다."];
const risks = [...result.adjust, ...result.check].map((item) => item.text).slice(0, 4);
setSimpleList("[data-dossier-fit]", fits);
setSimpleList("[data-dossier-risk]", risks);
};
const renderSelection = () => {
const home = getHome();
const conditions = state.conditions || readConditions();
renderCandidateRail();
renderCompareScene(home);
renderMatrix(home, conditions);
renderDossier(home, conditions);
if (state.analyzed) renderBriefing(home, conditions);
};
const selectListing = (id, announceChange = true) => {
const home = getHome(id);
state.selectedId = home.id;
renderSelection();
if (announceChange) announce(`${home.name}을 선택했습니다. 비교표와 서류철을 바꿨습니다.`);
};
const runAnalysis = () => {
if (!form.reportValidity()) return;
const conditions = readConditions();
state.conditions = conditions;
state.analyzed = true;
const best = homes.reduce((current, home) => scoreHome(home, conditions) > scoreHome(current, conditions) ? home : current, homes[0]);
state.selectedId = best.id;
renderSelection();
$("#briefing")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
announce(`${best.name}을 가장 가까운 후보로 골라 세 묶음의 브리핑을 만들었습니다.`);
};
const markConditionsChanged = () => {
if (!state.analyzed) return;
resetBriefing();
renderSelection();
announce("조건이 바뀌어 이전 브리핑을 비웠습니다. 다시 분석해 주세요.");
};
const saveConditions = () => {
if (!form.reportValidity()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(readConditions()));
announce("현재 조건을 이 브라우저에 저장했습니다.");
} catch {
announce("브라우저 저장소를 사용할 수 없어 조건을 저장하지 않았습니다.");
}
};
const restoreConditions = () => {
try {
const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "null");
if (!saved || typeof saved !== "object") return;
if (["sale", "jeonse", "monthly"].includes(saved.trade)) form.elements.trade.value = saved.trade;
if (Number.isFinite(saved.budget)) form.elements.budget.value = String(saved.budget);
if (Number.isFinite(saved.minArea)) form.elements.minArea.value = String(saved.minArea);
if (typeof saved.moveIn === "string") form.elements.moveIn.value = saved.moveIn;
if (Number.isFinite(saved.parking)) form.elements.parking.value = String(saved.parking);
} catch {
window.localStorage.removeItem(STORAGE_KEY);
}
};
const focusConditions = () => {
form.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "center" });
window.setTimeout(() => form.elements.trade?.focus(), 180);
};
const activateDossierTab = (name, focus = false) => {
$$('[data-dossier-tab]').forEach((tab) => {
const active = tab.dataset.dossierTab === name;
tab.setAttribute("aria-selected", String(active));
tab.tabIndex = active ? 0 : -1;
if (active && focus) tab.focus();
});
$$('[data-dossier-panel]').forEach((panel) => {
panel.hidden = panel.dataset.dossierPanel !== name;
});
};
const initDossierTabs = () => {
const tabs = $$('[data-dossier-tab]');
tabs.forEach((tab, index) => {
tab.addEventListener("click", () => activateDossierTab(tab.dataset.dossierTab));
tab.addEventListener("keydown", (event) => {
let target = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") target = (index + 1) % tabs.length;
if (event.key === "ArrowLeft" || event.key === "ArrowUp") target = (index - 1 + tabs.length) % tabs.length;
if (event.key === "Home") target = 0;
if (event.key === "End") target = tabs.length - 1;
if (target !== null) {
event.preventDefault();
activateDossierTab(tabs[target].dataset.dossierTab, true);
}
});
});
};
const resetConsultation = () => {
state.consultationComplete = false;
const content = $("[data-consult-content]", dialog);
content.replaceChildren();
const copy = document.createElement("p");
const strong = document.createElement("strong");
strong.dataset.consultHome = "";
strong.textContent = getHome().name;
copy.append(strong, "을 기준으로 준비할 항목을 골라 보세요.");
content.append(copy);
[["rights", "권리 관계와 계약 조건", true], ["visit", "현장 방문 때 볼 항목", true], ["move", "입주 가능일 조율", false]].forEach(([value, label, checked]) => {
const wrapper = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.name = "agenda";
input.value = value;
input.checked = checked;
wrapper.append(input, ` ${label}`);
content.append(wrapper);
});
$("[data-finish-consult]", dialog).textContent = "다음 순서 보기";
};
const openConsultation = () => {
resetConsultation();
dialog.showModal();
document.body.classList.add("is-locked");
};
const closeConsultation = () => {
if (dialog.open) dialog.close();
};
const finishConsultation = () => {
if (state.consultationComplete) {
resetConsultation();
return;
}
state.consultationComplete = true;
const content = $("[data-consult-content]", dialog);
const selected = $$('input[name="agenda"]:checked', content).map((input) => input.closest("label")?.textContent.trim()).filter(Boolean);
content.replaceChildren();
const heading = document.createElement("p");
const strong = document.createElement("strong");
strong.textContent = "준비할 순서";
heading.append(strong);
const list = document.createElement("ol");
const items = selected.length ? selected : ["집의 기본 조건 다시 보기"];
items.forEach((text) => {
const item = document.createElement("li");
item.textContent = text;
list.append(item);
});
content.append(heading, list);
$("[data-finish-consult]", dialog).textContent = "처음부터 보기";
};
const initImageFallback = () => {
$$('[data-fallback-image]').forEach((image) => {
const handleError = () => {
image.hidden = true;
const frame = image.closest("[data-image-frame]");
frame?.classList.add("is-image-error");
const fallback = $("[data-image-fallback]", frame);
if (fallback) fallback.hidden = false;
};
image.addEventListener("error", handleError);
if (image.complete && image.naturalWidth === 0) handleError();
});
};
const initMenu = () => {
const button = $("[data-menu-button]");
const nav = $("[data-site-nav]");
const close = () => {
nav.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button?.addEventListener("click", () => {
const open = nav.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
});
$$("a", nav).forEach((link) => link.addEventListener("click", close));
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") close();
});
};
const init = () => {
restoreConditions();
initMenu();
initDossierTabs();
initImageFallback();
resetBriefing();
renderSelection();
form.addEventListener("submit", (event) => {
event.preventDefault();
runAnalysis();
});
form.addEventListener("input", markConditionsChanged);
form.addEventListener("change", markConditionsChanged);
$$('[data-save-condition]').forEach((button) => button.addEventListener("click", saveConditions));
$$('[data-focus-condition]').forEach((button) => button.addEventListener("click", focusConditions));
$$('[data-select-listing]').forEach((button) => button.addEventListener("click", () => selectListing(button.dataset.selectListing)));
$$('[data-area-unit]').forEach((button) => button.addEventListener("click", () => {
state.areaUnit = button.dataset.areaUnit;
$$('[data-area-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
renderSelection();
announce(`면적을 ${state.areaUnit === "m2" ? "제곱미터" : "평"}으로 표시합니다.`);
}));
$$('[data-price-unit]').forEach((button) => button.addEventListener("click", () => {
state.priceUnit = button.dataset.priceUnit;
$$('[data-price-unit]').forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
renderSelection();
announce(`가격을 ${state.priceUnit === "total" ? "총액" : "면적 단가"}으로 표시합니다.`);
}));
$$('[data-open-consult]').forEach((button) => button.addEventListener("click", openConsultation));
$("[data-close-consult]")?.addEventListener("click", closeConsultation);
$("[data-finish-consult]")?.addEventListener("click", finishConsultation);
dialog?.addEventListener("click", (event) => {
if (event.target === dialog) closeConsultation();
});
dialog?.addEventListener("close", () => {
document.body.classList.remove("is-locked");
resetConsultation();
});
dialog?.addEventListener("cancel", () => document.body.classList.remove("is-locked"));
};
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init, { once: true });
else init();
})();

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,312 @@
:root {
color-scheme: light;
--font-sans: "Pretendard Variable", Pretendard, SUIT, "Noto Sans KR", "Apple SD Gothic Neo", system-ui, sans-serif;
--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
--ink-950: #0a0a09;
--ink-900: #121210;
--ink-800: #24231f;
--ink-700: #3e3c35;
--ink-600: #5d5a50;
--ink-500: #69655d;
--paper-50: #f8f7f3;
--paper-100: #f2f0e9;
--paper-200: #e8e4da;
--paper-300: #d8d2c5;
--gold-dark: #6f5420;
--gold: #9b762d;
--gold-light: #c6a15b;
--blue: #345d8c;
--blue-pale: #e7eef6;
--green: #2e684d;
--green-pale: #e5f0e9;
--red: #9d3f34;
--red-pale: #f6e8e5;
--amber: #7f5d18;
--amber-pale: #f4ecd8;
--white: #ffffff;
--line-light: rgba(10, 10, 9, 0.14);
--line-dark: rgba(255, 255, 255, 0.18);
--focus: #2e6ba8;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
--space-32: 8rem;
--content: min(100% - 2rem, 88rem);
--reading: 44rem;
--header-height: 4.5rem;
--radius-sm: 0.25rem;
--radius-md: 0.75rem;
--radius-lg: 1.25rem;
--transition: 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.6;
letter-spacing: -0.015em;
color: var(--ink-950);
background: var(--paper-50);
text-rendering: optimizeLegibility;
}
body.is-locked {
overflow: hidden;
}
img,
svg {
display: block;
max-width: 100%;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
select,
input[type="range"],
input[type="checkbox"],
input[type="radio"] {
cursor: pointer;
}
button:disabled,
input:disabled,
select:disabled {
cursor: not-allowed;
}
a {
color: inherit;
}
:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
[hidden] {
display: none !important;
}
.skip-link {
position: fixed;
z-index: 200;
top: 0.75rem;
left: 0.75rem;
padding: 0.75rem 1rem;
color: var(--white);
background: var(--ink-950);
transform: translateY(-180%);
transition: transform var(--transition);
}
.skip-link:focus {
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.demo-strip {
min-height: 2rem;
padding: 0.35rem 1rem;
color: #f5ead5;
background: var(--ink-950);
border-bottom: 1px solid rgba(198, 161, 91, 0.4);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-align: center;
}
.button {
display: inline-flex;
min-height: 2.875rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.7rem 1.05rem;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-weight: 720;
line-height: 1.2;
text-decoration: none;
transition: color var(--transition), background-color var(--transition), border-color var(--transition), transform var(--transition);
}
.button:hover {
transform: translateY(-1px);
}
.button-primary {
color: var(--paper-50);
background: var(--ink-950);
border-color: var(--ink-950);
}
.button-primary:hover {
color: var(--ink-950);
background: var(--gold-light);
border-color: var(--gold-light);
}
.button-light {
color: var(--ink-950);
background: var(--paper-50);
border-color: var(--paper-50);
}
.button-outline {
color: inherit;
background: transparent;
border-color: currentColor;
}
.button-ghost {
color: inherit;
background: transparent;
border-color: var(--line-light);
}
.button-small {
min-height: 2.25rem;
padding: 0.45rem 0.7rem;
font-size: 0.8125rem;
}
.status-badge {
display: inline-flex;
min-height: 1.6rem;
align-items: center;
gap: 0.35rem;
width: max-content;
padding: 0.25rem 0.5rem;
border: 1px solid currentColor;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 760;
line-height: 1;
letter-spacing: 0.01em;
}
.status-badge::before {
width: 0.38rem;
height: 0.38rem;
border-radius: 50%;
background: currentColor;
content: "";
}
.status-demo {
color: var(--gold-dark);
background: #f7efdc;
}
.status-pass {
color: var(--green);
background: var(--green-pale);
}
.status-warn {
color: var(--amber);
background: var(--amber-pale);
}
.status-blocked {
color: var(--red);
background: var(--red-pale);
}
.field-label {
display: block;
margin-bottom: 0.45rem;
color: var(--ink-700);
font-size: 0.75rem;
font-weight: 760;
}
.text-input,
.select-input {
width: 100%;
min-height: 2.75rem;
padding: 0.65rem 0.75rem;
color: var(--ink-950);
background: var(--white);
border: 1px solid var(--line-light);
border-radius: var(--radius-sm);
}
.live-region {
position: fixed;
z-index: 300;
right: 1rem;
bottom: 1rem;
max-width: min(24rem, calc(100% - 2rem));
padding: 0.85rem 1rem;
color: var(--white);
background: var(--ink-950);
border-left: 3px solid var(--gold-light);
border-radius: var(--radius-sm);
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.22);
}
@media (min-width: 48rem) {
:root {
--content: min(100% - 4rem, 88rem);
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View file

@ -0,0 +1,447 @@
# 두 쇼케이스 방향 보드 — 인간 검증 게이트 1
> **최신 재설계 게이트 H04 (2026-08-29):** 사용자가 한남자리 공개 화면의 중복과 전체 구조를 RED로 판정했다. 아래 초기 공개 IA 9개 장은 더 이상 정본이 아니다. 관리자와 철판클럽 방향은 유지하되, 한남자리 공개는 `브리핑 시작 / 예시 AI 브리핑 / 세 집 비교 데스크 / 선택 집 검토 서류철 / 사람에게 넘기기` 5개 핵심 섹션으로 전면 압축한다. 임의 지도·조망·거리·시간, 공개 화면의 게시 채널과 법정 검수 원장, 01~06 장문 상세는 폐기한다. 진실성은 경고 반복이 아니라 보이는 입력·비교 로직·출처·행동의 일치와 범위별 한 번의 가까운 라벨로 증명한다.
> 상태: 국내 방향으로 교정한 게이트 1.1 승인 완료. 2026-08-29 첫 방향은 해외 레퍼런스 비중이 지나치다는 판정을 받아 폐기했다.
> 사용자가 모든 중간 게이트를 자율 승인한다고 명시했다. 이후 게이트는 생략하지 않고 실행 증거로 자동 판정하며, 실패 항목은 수정 후 재검사한다.
## 사용자 피드백 1 — 국내 감각으로 근본 교정
판정:
- Sotheby's, Hennessy, Rolls-Royce, Teenage Engineering을 주축으로 둔 합성은 사용하지 않는다.
- 기능 일부가 한국형이어도 시각과 문장의 중심축이 해외 사례이면 `한국어를 얹은 해외 템플릿`이 된다.
- 한국성은 전통 문양, 붓글씨, 한지, 인장 같은 표면 장식으로 만들지 않는다.
- 단위, 용어, 정보 순서, 상담 행동, 주문 행동, 상업 사진만 남겨도 국내 서비스로 읽혀야 한다.
교정 원칙:
1. R1 주 레퍼런스는 국내 동종 업계의 실제 정보 구조와 모바일 전환으로 바꾼다.
2. R2 주 레퍼런스는 국내 금융, 리테일, 숙박, 외식 브랜드의 한글 밀도와 상업 사진으로 바꾼다.
3. 해외 레퍼런스는 특정 상호작용 한 가지의 지원 범위만 검증하는 보조 자료로 강등한다.
4. 기존 19개 쇼케이스가 이미 점유한 검정과 앰버, 한남 고급 거실, 아카이브 에디토리얼, 검정과 코발트 기술 화면을 반복하지 않는다.
5. 초기 생성 이미지 두 장은 배포하지 않는다. 국내 방향의 새 시안을 별도로 만들고 다시 승인받는다.
새 게이트 시안:
- 부동산: 생성 작업 `exec-137f9913-b133-4583-8e6f-d73a6b6e8bd0` — 초기 시안은 폐기하고 저장소에 포함하지 않음
- 햄버거: 생성 작업 `exec-7fae4537-c8f2-4e29-a848-8480b3606b7f` — 최종 파생본만 `public/work/griddle-club/assets/`에 보관
- 두 장 모두 1672×941, 약 1.78:1의 생성 원본이다.
- 현재는 게이트 시안이며, 배포 자산으로 복사하거나 압축하지 않는다.
## 목표와 고정 조건
### 부동산
- 한국에서 실제로 운영될 법한 고급 공인중개 홍보 사이트를 만든다.
- 검정과 금색을 사용하되 장식보다 정보와 사진이 고급감을 만든다.
- 생성 이미지를 실제 배경 자산으로 사용한다.
- 사용자 조건에 맞춘 AI 참고 분석을 실제 상호작용으로 보여준다.
- 공개 화면과 별도로 상세한 shadcn 계열 관리자 화면을 만든다.
- 허위 등록정보, 허위 거래 실적, 근거 없는 AI 정확도는 만들지 않는다.
### 햄버거
- 소비자 프로모션과 가맹 전환을 함께 다루는 프랜차이즈 사이트를 만든다.
- 흔한 빨강·노랑 복고 버거 문법과 다른 고유한 시각 체계를 만든다.
- 생성한 음식 사진을 핵심 자산으로 사용한다.
- 메뉴 탐색, 매장 탐색, 버거 조합, 가맹 문의가 실제로 작동해야 한다.
- 가격, 매장 수, 매출, 가맹비처럼 검증하지 않은 수치는 예시로 명시한다.
## 우선순위
1. 사용자 지시
2. `apps/site/design.md`
3. designpaca 0~6단계
4. design-taste-frontend의 마케팅 화면 원칙
5. high-end-visual-design의 타이포그래피와 여백 원칙
보조 스킬이 모든 카드에 장식을 반복하도록 요구하거나 운영 화면의 판독성을 낮추면 적용하지 않는다.
---
## 방향 A — 한남자리 최종 정본
- 공개 IA: `밝은 Hero와 다섯 조건 도크 → 예시 AI 브리핑 → 세 집 비교 데스크 → 다섯 탭 검토 서류철 → 사람 인계`.
- 의사결정 SSOT: 거래 형태·예산·최소 전용면적·입주 희망일·필요 주차. 월세는 보증금과 월 납부액을 분리한다.
- 공개 화면에서 지도·통근·일조·조망·임의 주소와 내부 게시 원장을 제거했다. 근거 없는 값은 계산하지 않고 중개사 확인 항목으로 남긴다.
- 시각 언어: 석재색 종이 면, 서울 청회색, 제한된 브론즈. 검정은 마지막 사람 인계 장면만 소유한다.
- 관리자는 밝고 밀도 높은 검수 대기열로 분리한다. 선택 전 표는 전체 폭, 선택 뒤에만 조사관 패널을 연다.
- R1은 국내 주거·중개 정보 순서, R2는 국내 리테일의 한국어 밀도와 사진 절제, R3는 키보드 가능한 탭과 조건 동기화만 가져왔다.
<details>
<summary>폐기된 초기 한남자리 방향 기록</summary>
아래 내용은 H04 인간 게이트 이전의 조사 이력이다. 최종 구현·게이트·배포의 요구사항으로 사용하지 않는다.
### 방향 A — 한남자리 공인중개사사무소
### 상태
- 작업명: `한남자리 공인중개사사무소`
- 영문 보조명: 사업자 정보 안에서만 `HANNAM JARI`
- 한 문장: **조건부터 맞춰 보는 한남의 집.**
- 성격 다이얼: 시각 7 / 움직임 4 / 정보 밀도 5
- 관리자 다이얼: 시각 4 / 움직임 3 / 정보 밀도 7
- 고지: 실제 업체가 아닌 쇼케이스용 가상 브랜드
- 기본 조판: Pretendard 또는 SUIT 계열 산세리프 한 패밀리
- 화면 면적 목표: 따뜻한 회백색 60% / 검정 35% / 금색 5%
- 첫 화면: 거래 조건 DOM 40% / 서울 외관 이미지 60%의 분할
### R1 · 같은 업종의 구조
주 레퍼런스: https://www.homeside.co.kr/
- 1280×720 기준 Pretendard, 본문 16px, 첫 제목 64/73.6px, 섹션 제목 38/51.3px를 확인했다.
- `가치 제안 → 고객 고민 → 신뢰 근거 → 전담 상담·AI 분석·위험 점검 → 과정 → 비용 → FAQ → 상담` 흐름을 가져온다.
- 국내 고객이 기대하는 `내 조건 이해 → 근거 분석 → 계약 위험 확인 → 입주까지 동행` 순서를 유지한다.
- 원본의 후기, 수상, 가격, 실적 수치는 가져오지 않는다.
국내 고급 주거와 매물 항목 보조:
- https://www.acro.co.kr/
- https://www.the-h.co.kr/Front/Main.aspx
- https://www.luxurynhouse.com/
- 아크로와 디에이치는 서울 고급 주거의 사진·한국어 카피·단지 정보 위계만 참고한다.
- 럭셔리앤하우스에서는 지역, 매물 유형, 매매·전세·월세, 확인날짜, 가격대, 지점, 카카오 상담 항목만 가져온다.
- 영문 Didot, 다단 메뉴, 노랑·검정 도구막대, 검증하지 않은 추천 수치는 버린다.
### R2 · 다른 업종의 톤
주 레퍼런스: https://www.tamburins.com/kr/
- 풀블리드 상업 사진, Pretendard, 30px 좌우 인셋, 작은 한국어 제목의 절제를 참고한다.
- 따뜻한 황토와 금빛은 UI 선과 테두리가 아니라 서울 주거 사진의 목재, 조명, 석재가 소유하게 한다.
- 초현실 모델과 향수 캠페인 연출은 가져오지 않는다.
지역과 무채색 조판 보조: https://www.leeumhoam.org/leeum
- 한남동의 국내 문화기관이 사용하는 한국어 우선 내비게이션과 무채색 위계를 참고한다.
- 현재 전시 그래픽과 미술관식 긴 여백은 복제하지 않는다.
- 금색은 어두운 면에서 `#C6A15B`, 밝은 면의 글자와 선에서는 접근 가능한 갈금 `#6F5420`처럼 역할을 분리한다.
### R3 · 구체 상호작용
주 레퍼런스: https://www.disco.re/
1. 국내 지도와 목록 교차 강조
- 지도 마커에는 가격과 면적을 함께 표시한다.
- 지도와 목록의 hover, focus, 선택 상태가 양방향으로 연결된다.
- 평과 제곱미터, 총액과 단가 전환 위치를 고정한다.
- 지도 없이도 같은 정보를 읽는 목록 보기를 제공한다.
2. 국내 매물 상세 고정 앵커와 정보 아코디언
- 사진과 개요 / 가격 근거 / 권리와 위험 / 입지와 교통 / 시설과 관리 / 담당 중개사
- hash, `scroll-margin-top`, 키보드 포커스를 지원한다.
- 스크롤 가로채기는 쓰지 않는다.
3. 조건 조정형 AI 참고 분석
- 예산, 통근, 일조, 주차 조건을 바꾸면 추천 이유와 주의점이 갱신된다.
- 결과는 실제 HTML에 있고 생성 이미지에 가짜 UI를 구워 넣지 않는다.
### 공개 사이트 정보 구조
1. 첫 화면 — 한국어 가치 제안, 거래 조건, 전화·방문 예약·조건 저장
2. 맞춤 분석 — 긍정, 위험, 확인 불가, 조건 조정, 사람 검토
3. 확인된 대표 매물 — 거래 형태, 평·제곱미터, 마지막 확인일
4. 지도와 목록 탐색 — 평·제곱미터, 총액·단가 전환
5. 한남 생활권 — 실거래, 교통, 생활, 건축 맥락
6. 매물 상세 — 가격 근거, 권리와 위험, 시설과 관리
7. 공인중개사와 사무소 — 등록정보와 비공개 상담
8. 매도·임대 의뢰와 상담 과정
9. 법정 고지와 소비자 보호 안내
### AI 신뢰 구조
- 제목은 `AI 참고 분석 — 공인중개사 검토 전`로 고정한다.
- 분석 시각, 데이터 기준일, 범위, 제외 범위를 노출한다.
- 사실, 추론, 확인 불가를 구분한다.
- 긍정 요인과 위험 요인을 같은 위계로 제공한다.
- 출처와 갱신 시점을 각 요인 가까이에 둔다.
- 근거가 약하면 정밀 점수 대신 높음, 중간, 낮음과 이유를 쓴다.
- 조건 수정, 정보 정정, 중개사 검토 요청을 결과와 함께 제공한다.
- 자동 공개를 금지하고 사람의 승인 상태를 기록한다.
참고:
- https://www.microsoft.com/en-us/haxtoolkit/guideline/make-clear-why-the-system-did-what-it-did/
- https://pair.withgoogle.com/guidebook-v2/chapter/explainability-trust/
- https://airc.nist.gov/airmf-resources/airmf/5-sec-core/
### 법정 정보와 게시 방어선
근거:
- https://www.law.go.kr/LSW/lsInfoP.do?lsId=001654
- https://www.law.go.kr/admRulLsInfoP.do?admRulSeq=2100000250356
구현 원칙:
- 등록 상호, 주소, 전화번호, 등록번호, 책임 공인중개사 성명을 빈 예시값으로 꾸미지 않는다.
- 소재지, 면적, 가격, 거래 형태, 용도와 유형, 층수, 입주일, 방과 욕실, 사용승인일, 주차, 관리비, 방향, 마지막 확인일을 게시 검수 항목으로 둔다.
- 필수 항목 누락, 오래된 확인일, 계약과 만료 상태에서는 게시를 차단한다.
- 사진, 설명, 가격, 상태 변경 이력을 보관하는 화면을 만든다.
### shadcn 계열 관리자 구조
참고:
- https://ui.shadcn.com/blocks?category=dashboard
- https://ui.shadcn.com/docs/components/base/sidebar
- https://ui.shadcn.com/docs/components/base/data-table
- https://ui.shadcn.com/docs/components/base/chart
주 구조:
- 일반적인 `KPI 카드 3개 + 차트 + 표`를 첫 화면으로 쓰지 않는다.
- 첫 화면은 밀도 높은 `매물 검수 대기열 + 고정 우측 조사관 패널`로 시작한다.
- 선택 행의 사진, 법정 표시, AI 근거, 게시 방어선, 변경 이력을 우측에서 같은 맥락으로 검토한다.
- shadcn의 Sidebar, Data Table, Tabs, Sheet, Dialog, Command, Calendar 구조를 쓰되 운영 목적에 맞게 재조립한다.
내비게이션:
- 개요
- 매물: 전체 / 초안 / 검수 필요 / 게시 중 / 보류 / 계약 / 만료
- 문의와 고객
- AI 분석
- 일정
- 지역 리포트와 콘텐츠
- 사무소와 법정 정보
- 팀과 권한
- 감사 로그와 설정
국내 업무 필드:
- 광고 가능 여부
- 확인설명서
- 건축물대장
- 등기 확인
- 의뢰인 동의
- 실거래 기준일
- 공동중개
- 게시 만료
- 현재 매물·보류·계약·만료
필수 작동:
- 사이드바 보기 전환
- 표 정렬, 필터, 검색, 선택
- 빠른 검토 Sheet와 전체 상세 보기
- 기본 / 법정 표시 / 사진과 영상 / AI 분석 / 문의와 일정 / 변경 이력 탭
- 게시, 게시 중단, 계약 완료, 삭제 확인 대화상자
- 누락 필드가 있는 게시 차단과 해당 필드 이동
- 고객 조건과 비교 매물 집합을 보여주는 AI 검토
- 모델과 데이터 버전, 재실행 차이, 검토자, 승인 시각, 실패 로그
- 닫기, 바깥 클릭, Escape가 팝업과 활성 상태를 함께 해제
- 모바일 공개 화면에는 `전화 상담 / 방문 예약 / 조건 저장`을 하단 고정 행동으로 제공
시각 원칙:
- 운영 화면은 밝고 중립적으로 유지한다.
- 검정과 금색은 브랜드 식별과 선택된 핵심 항목에만 제한한다.
- 상태는 색만으로 구분하지 않고 아이콘과 텍스트를 함께 쓴다.
### 회색조 위계
1. 현재 거래 조건과 서울 생활권 외관
2. 맞춤 분석의 근거와 위험
3. 확인된 매물 스펙과 상담 행동
색을 제거해도 이 순서가 유지되어야 한다.
### 생성 이미지 시안
- 생성 작업: `exec-137f9913-b133-4583-8e6f-d73a6b6e8bd0`; 최종 파생본은 `public/work/hannam-jari/assets/`에 보관
- 역할: 서울 생활권과 가상 고급 주택 외관을 보여주는 데스크톱 Hero 시안.
- 특징: 경사지 골목, 중저층 주거지, 공동주택 군집, 회색 석재, 검정 알루미늄, 현관의 따뜻한 빛.
- 제한: 실제 한남의 주소나 실매물 증빙으로 주장하지 않는다. 모바일은 별도 4:5 원본을 만든다.
- 상세 기준: `showcase-duo-image-bible-2026-08-29.md`
### 안 할 것
- 모든 카드의 금색 테두리와 금색 그라디언트
- 대리석, 금박 bevel, 먼지 입자, 장식용 3D 도시
- 가격과 법정 정보를 숨긴 사진 전시장
- 근거 없는 정확도, 거래 건수, 고객 수, 자산 규모
- 출처와 정정 절차가 없는 안전, 범죄, 학군 점수
- 자동 회전 캐러셀, 스크롤 가로채기, 데이터 화면 parallax
- AI 결과 자동 게시
- 관리자 전체를 검정과 금색으로 덮는 구성
- 영문 세리프, 작은 대문자 눈썹 문구, 300 굵기의 시적인 대제목
- 실제 공간보다 먼저 나오는 해외식 희소성 서사
</details>
---
## 방향 B — 철판클럽
### 상태
- 작업명: `철판클럽`
- 영문 보조명: 제품 정보 아래에서만 `GRIDDLE CLUB`
- 한 문장: **눌러 굽고, 바로 건넨다.**
- 확장 콘셉트: **퇴근길, 한 상자에 담아 가는 서울의 눌림 버거.**
- 성격 다이얼: 시각 8 / 움직임 6 / 정보 밀도 4
- 고지: 실제 업체가 아닌 쇼케이스용 가상 브랜드
- 기본 표면: 따뜻한 회백색 `#F4F1E9`
- 본문: 그릴에 가까운 흑갈색 `#211F1C`
- 강조: 코발트 `#2147E7`, 화면 면적 약 8~10%
- 스테인리스, 치즈색, 불빛은 사진이 소유하고 UI 그라디언트로 흉내내지 않는다.
### R1 · 같은 업종의 구조
주 레퍼런스: https://downtowner.co.kr/
- 1280×720 기준 본문 폭 1235px, 설명문 폭 603px, 본문 16/32px, 식별색 `#172A88`을 확인했다.
- `조리 장면 → 브랜드 한 문장 → 대표 메뉴 → 포장 → 협업 → 매장` 흐름을 가져온다.
- 버거뿐 아니라 포장과 매장 경험도 제품처럼 다루는 국내 브랜드 문법을 참고한다.
- 긴 영상 Hero와 7150px의 사진 교대는 복제하지 않고 6~8개 섹션으로 압축한다.
소비자와 창업 경로 분리 보조:
- https://www.momstouch.co.kr/
- 음식과 매장 사진으로 방문 목적을 나누는 근거만 가져오고, 일반 고객에게 중간 선택 화면을 강요하지 않는다.
- 공개 홈은 소비자 80 / 가맹 20 위계를 유지하고 가맹은 별도 경로로 보낸다.
가맹 정보 구조 보조:
- https://www.shinsegaefood.com/nobrandburger/franchise/nbbFranchise.sf
- 브랜드 이유 / 개설 안내 / 투자비 / 인테리어 / 자주 묻는 질문 / 상담 / 대출 흐름을 참고한다.
- 비용과 조건은 숨기고 연락처부터 받는 방식은 사용하지 않는다.
### R2 · 다른 업종의 톤
주 레퍼런스: https://www.29cm.co.kr/store/showcase
- 1280px에서 1169px 컨테이너, 첫 2열 573px, 일반 3열 375px, 20px 거터를 확인했다.
- 대표 제목 34/40px, 일반 제목 22/30px의 `사진 → 짧은 한국어 제목 → 설명 → 상태` 편집 리듬을 가져온다.
- Pretendard Variable 한 패밀리에서 굵기만 조절한다.
- `SHOWCASE` 같은 영문 배지는 버리고, 메뉴명은 한국어 12~18자를 우선한다.
포장 사진 보조: https://granhand.com/
- 크림 `#FDFBF5`, 짙은 갈색 `#322A24`, 열린 상자와 포장 재질을 함께 보여주는 방법만 참고한다.
- 작은 10~14px 글자와 향수 브랜드의 감성 카피는 가져오지 않는다.
### R3 · 구체 상호작용
주 레퍼런스: https://www.shinsegaefood.com/nobrandburger/index.sf
1. 국내 메뉴 분류 앵커
- 고정 분류 줄, 네이티브 앵커, `scroll-margin-top`, `IntersectionObserver`로 현재 분류를 갱신한다.
- `aria-current` 또는 `aria-selected`를 제공하고 모바일에서는 가로 스크롤 분류 줄을 쓴다.
- 감소 모션에서는 즉시 이동한다.
2. 국내 매장 탐색
- 지역 칩과 매장명·도로명 검색, 주소, 영업시간, 마지막 주문, 포장·배달 여부를 함께 보여준다.
- 지도와 동일한 매장 목록을 키보드로 읽을 수 있게 한다.
3. 버거 조합기
- 단품·세트, 패티, 치즈, 양파, 피클, 소스, 알레르기, 예시 총액이 실제로 갱신된다.
- 재료를 공중에 분해하지 않고 포장 라벨과 주문 요약에 선택이 누적된다.
- 생성 이미지에 가짜 조합기 UI를 구워 넣지 않는다.
4. 가맹 적합도와 문의 준비
- 자본 수치나 예상 수익을 꾸며내지 않는다.
- 운영 경험, 희망 지역, 일정, 문의 준비 상태를 단계적으로 정리한다.
### 공개 사이트 정보 구조
1. 첫 화면 — 대표 버거와 열린 포장, 메뉴와 가까운 매장 행동
2. 오늘 고를 메뉴 — 대표 2개를 크게, 일반 메뉴 3열, 단품·세트와 예시 가격
3. 버거 조합기 — 포장 라벨과 주문 요약 갱신
4. 철판에서 상자까지 — 굽기, 조립, 포장의 실제 순서
5. 포장해서 가기 — 상자, 유산지, 소스 컵, 영수증
6. 매장 찾기 — 지역·동네, 영업시간, 마지막 주문, 포장·배달 여부
7. 가맹 예고 — 매장 사진과 `운영 조건 먼저 보기`
8. 원산지, 알레르기, 영양, 예시 데이터 고지
### 가맹 전용 경로 `/franchise/`
1. 어떤 운영 방식인지
2. 적합한 운영자와 상권
3. 매장 크기와 형태
4. 개설 절차
5. 비용과 일정 표 — 검증 전에는 예시로 명시
6. 교육, 물류, 품질 지원
7. 자주 묻는 질문
8. 희망 지역, 점포 보유, 예상 면적, 상담 시간, 제출 서류를 받는 문의 폼
가맹 첫 화면은 버거 접사가 아니라 카운터, 주방 동선, 좌석, 포장대가 함께 보이는 운영 사진을 쓴다.
### 회색조 위계
1. 눌림면이 선명한 실제 버거
2. 대표 메뉴명과 메뉴 또는 매장 행동
3. 철판 방식과 재료 근거
코발트 색을 제거해도 음식이 첫 번째로 읽혀야 한다.
### 생성 이미지 시안
- 생성 작업: `exec-7fae4537-c8f2-4e29-a848-8480b3606b7f`; 최종 파생본은 `public/work/griddle-club/assets/`에 보관
- 역할: 직원이 포장된 버거를 건네는 국내 도심 매장의 데스크톱 Hero 시안.
- 특징: 밝은 스테인리스 카운터, 실제 손, 코발트 트레이와 집게판, 흰 유산지, 좁은 도심 매장.
- 제한: 메뉴 상세 제품 컷과 조합기 레이어로 재사용하지 않는다. 모바일은 별도 4:5 원본을 만든다.
- 상세 기준: `showcase-duo-image-bible-2026-08-29.md`
### 안 할 것
- 빨강과 노랑의 자동 적용
- 체커보드, 네온 간판, 1950년대 식당 장식의 무근거 혼합
- 제품보다 큰 브랜드 장식
- 같은 크기의 둥근 메뉴 카드 반복
- 읽을 수 없는 손글씨와 기울어진 스티커 남용
- 검증하지 않은 매장 수, 매출, 수익률, 가맹비, 후기
- 일반 고객과 가맹 희망자 CTA를 한 화면에서 같은 위계로 경쟁시키는 구성
- `1위`, `압도적`, `검증된`, `창업비 0원`을 출처 없이 반복하는 구성
- 매장 수, 매출, 판매량, 연예인 얼굴을 브랜드보다 크게 만드는 구성
- 떠 있는 전화, 카카오, 앱 설치, 창업 문의를 동시에 노출하는 구성
- 비용과 조건은 숨기고 연락처부터 요구하는 구성
---
## 인간 검증 게이트 1.1 — 승인 완료
2026-08-29 사용자가 모든 게이트의 자율 승인을 명시했다. 아래 다섯 항목을 국내형 구현 기준으로 잠근다.
1. 작업명: `한남자리`, `철판클럽`
2. 문장: `조건부터 맞춰 보는 한남의 집`, `눌러 굽고, 바로 건넨다`
3. 부동산: 회백색 60 / 검정 35 / 금색 5, 서울 외관과 거래 조건 40:60 분할
4. 햄버거: 따뜻한 회백색과 흑갈색, 코발트 8~10%, 밝은 도심 철판 주방과 포장 행동
5. 부동산 검수 대기열 관리자, 햄버거 소비자 홈과 가맹 경로 분리
승인으로 해제된 작업:
- 배포 자산 복사
- HTML, CSS, JavaScript 구현
- 쇼케이스 목록 등록
- 최종 디자인 토큰 확정
후속 게이트는 자동 승인이더라도 합격 증거가 있어야 닫는다. 캡처, 실제 브라우저 동작, 접근성, 진실성, 성능, 회귀 중 하나라도 실패하면 다음 단계로 넘기지 않는다.
## 다음 인간 검증 게이트의 합격 조건
### 게이트 2 · 부동산
- 1440px와 390px 실제 브라우저 캡처
- 공개 화면과 관리자 핵심 보기
- 다섯 조건 브리핑, 세 집 비교, 서류철 동기화, 관리자 게시 차단 증명
- 키보드, Escape, 바깥 클릭, 감소 모션 확인
- 허위 수치와 허위 등록정보 없음
### 게이트 3 · 햄버거
- 1440px와 390px 실제 브라우저 캡처
- 프로모션 홈과 가맹 경로
- 버거 조합기, 메뉴, 매장 목록, 가맹 문의 흐름 증명
- 음식 이미지 일관성, 키보드, 감소 모션 확인
- 가격과 사업 수치의 예시 고지 확인
### 최종 자동 게이트
- HTML 검증
- 스타일 검사
- JavaScript 문법 검사
- 한자 제거 검사
- 쇼케이스 전용 실제 브라우저 E2E
- 가로 넘침, 명도 대비, 320 / 390 / 768 / 1440 반응형
- 자산 MIME, 해시, 캐시 무효화
- 빌드와 기존 회귀 검사
- 캡처와 보고서 생성

View file

@ -0,0 +1,299 @@
# 두 쇼케이스 이미지 성경
> 상태: 인간 검증 게이트용 초안.
> 현재 생성 원본은 배포 자산이 아니며, 역할·연속성·크롭·성능 기준이 승인된 뒤 변환한다.
## 공통 원칙
- 생성 이미지는 물성, 공간, 촬영 분위기를 담당한다.
- 가격, 주소, 상태, 버튼, 차트, 지도, 분석 결과는 실제 HTML과 CSS가 담당한다.
- 생성한 장소와 건물을 실제 주소나 실매물의 증빙으로 주장하지 않는다.
- 모든 공개 이미지 가까이에 필요하면 `쇼케이스용 시연 이미지` 고지를 둔다.
- 원본 PNG는 보관하지만 공개 경로에서 직접 내려주지 않는다.
- 데스크톱과 모바일 Hero는 같은 가로 이미지를 강제 크롭하지 않고 별도 원본을 만든다.
- 이미지 속 로고, 상호, 주소, 가격, 번호, 안내 문구는 생성하지 않는다. 글자는 DOM 또는 검증한 SVG가 소유한다.
- 후속 이미지는 승인된 기준 이미지를 참조해 건물, 버거, 재료, 작업복, 조명, 카메라 연속성을 유지한다.
---
## 부동산 기준 이미지
### 생성 기록
- 생성 작업: `exec-137f9913-b133-4583-8e6f-d73a6b6e8bd0`
- 배포 파생본: `public/work/hannam-jari/assets/hannam-evening-1600.webp` · SHA-256 `10a8f321ee9fb439c1bbc375e714d32317780bd67a672e2dae67250ee2f123cc`
- 원본: 1672×941, 2,164,267 bytes, 약 1.78:1
- 판정: 조건부 승인
- 허용 역할: 공개 홈페이지의 데스크톱 외관 Hero
- 금지 역할: 실제 매물 증빙, 실제 주소 사진, 모든 매물의 공통 대표 이미지
### 생성 프롬프트
```text
Create a wide landscape 16:9 photorealistic commercial hero photograph for a fictional premium Korean real-estate brokerage website. The image must feel unmistakably like contemporary Seoul without using traditional motifs. Show a refined modern Korean high-end apartment or compact luxury villa exterior and its real urban living context on a Hannam-like hillside at early blue hour: dense but orderly Seoul mid-rise skyline in the distance, a believable sliver of river or city view, Korean curb and apartment entry proportions, dark stone and warm limestone, brushed bronze entrance detail, restrained native landscaping, one warm entry light. Compose the architecture and view mainly toward the right and center, with calmer darker breathing room toward the left for separate HTML copy, but do not put any text or interface inside the image. Natural architectural photography, 28mm lens, eye-level to slightly elevated, crisp but not HDR, believable materials and scale, quiet premium mood, black/charcoal and warm champagne light used by the scene rather than as graphic decoration. No interior living room, no Western mansion, no European villa, no American suburb, no hanok, no palace motifs, no marble-gold opulence, no fantasy skyline, no impossible landmarks, no logos, no signage, no readable text, no UI, no badges, no watermark, no oversaturated orange, no heavy bloom, no people posing.
```
### 국내성 단서
- 경사지 골목과 옹벽
- 고밀도 중저층 주거지와 멀리 보이는 공동주택 군집
- 회색 석재 외벽, 검정 알루미늄, 좁은 진입부
- 정리한 소나무와 낮은 상록수
- 금박 장식 대신 현관의 따뜻한 빛으로 만든 고급감
### 위험과 제한
- 먼 도시와 강은 실제 한남의 특정 지형으로 주장할 수 없다.
- 전경 도로의 차선, 배수, 보도 체계는 증빙 사진으로 쓰기에 부족하다.
- 현관 문과 손잡이 일부가 생성 이미지처럼 매끈하다.
- 상단 창과 검정 패널의 결합이 실제 시공 상세보다 지나치게 정돈됐다.
- 공동주택보다 단독 고급 빌라에 가깝다. 다른 유형의 매물에 재사용하지 않는다.
### 크롭 규칙
데스크톱:
- 이미지는 밝은 Hero의 독립 표면으로 사용하고 본문을 사진 위에 겹치지 않는다.
- 넓은 크롭에서는 건물 입구, 경사지 골목, 서울의 중저층 밀도를 함께 남긴다.
- 어두운 오버레이로 사진을 고급스럽게 보이게 만드는 방식은 쓰지 않는다.
- 실제 캡처에서 건물의 진입 맥락과 주거 스케일이 모두 읽히는지 확인한다.
모바일:
- 원본 중앙 크롭은 불합격이다.
- 4:5 전용 이미지에서 건물과 현관을 상단, 경사 골목과 도시를 하단에 둔다.
- 제목과 다섯 거래 조건은 이미지 밖 문서 객체에 두며, 첫 844px 안에서 건물과 입구가 함께 보여야 한다.
### 고정 재료
- 무광 회색 석회석 또는 화강석 외벽
- 무광 검정 알루미늄 프레임
- 따뜻한 세로 오크 현관 슬랫
- 투명 유리와 짙은 현무암 계열 옹벽
- 전정한 소나무 2~3그루와 낮은 상록수
- 금속 금장, 대리석 무늬, 샹들리에, 유광 황동 장식 금지
### 고정 조명
- 맑거나 옅게 흐린 서울 블루아워
- 외부 약 4300~5000K, 현관 약 2700K
- 따뜻함은 현관 내부에서만 나온다.
- 사진의 황색 면적은 약 12% 이하로 제한한다.
- 비, 안개, 렌즈 플레어, 과한 별빛, 전체 주황색 보정 금지
### 고정 카메라
- Hero: 풀프레임 환산 28~35mm, 눈높이 1.5~1.7m
- 건축 수직선 교정, 2점 투시
- 실내와 시설 상세: 50mm
- 재료 접사: 85mm
- 초광각, 과한 상향 촬영, 드론형 과장 원근 금지
### 연속성 고정물
- 같은 회색 석재 패턴
- 검정 캐노피와 세로 오크 현관
- 현관 오른쪽의 석재 벽
- 입구 주변 소나무 수형
- 경사지와 도시 방향
### 필요한 추가 자산
1. `estate-hero-exterior-mobile` — 같은 건물의 4:5 세로 Hero
2. `estate-exterior-day` — 같은 외관의 흐린 낮, 3:2
3. `estate-entry-detail` — 출입구와 공용부, 4:3
4. `estate-view-city` — 테라스에서 본 생활권 조망, 3:2
5. `estate-parking-proof` — 주차 진입과 폭, 4:3
6. `estate-storage-detail` — 수납 또는 창고, 4:3
7. `estate-neighborhood-walk` — 경사지 생활권과 상업시설, 3:2
8. `estate-room-neutral` — 화려한 트래버틴이 없는 실제 거주형 거실, 4:3
9. `estate-consultation` — 얼굴이 주인공이 아닌 국내 상담, 3:2
10. 관리자 매물 썸네일 6종 — 외관, 거실, 주차 범주가 구분되어야 함
AI 분석과 관리자 화면은 이미지로 만들지 않고 실제 DOM으로 구현한다.
---
## 햄버거 기준 이미지
### 생성 기록
- 생성 작업: `exec-7fae4537-c8f2-4e29-a848-8480b3606b7f`
- 배포 파생본: `public/work/griddle-club/assets/hero-handoff.webp` · SHA-256 `a4375cf93f4e99b264d55d10d75d56661a07d6ca0a9e9600ce670e5202b3046f`
- 원본: 1672×941, 2,040,309 bytes, 약 1.78:1
- 판정: 조건부 승인
- 허용 역할: 공개 홈페이지의 데스크톱 매장 현장 Hero
- 금지 역할: 메뉴 상세 제품 컷, 조합기 재료 레이어, 모든 메뉴의 공통 대표 이미지
### 생성 프롬프트
```text
Create a wide landscape 16:9 photorealistic commercial food hero photograph for a fictional contemporary Korean smashburger franchise called 철판클럽, but include absolutely no words or logos in the image. The visual world is a bright compact Seoul fast-casual takeaway kitchen, not an American diner. Show one appetizing double smashburger with a toasted sesame bun, two thin lace-edged beef patties, two melted cheese slices, griddled onion, pickles, and pale house sauce on a large electric-cobalt thin tray paper over brushed stainless steel. Include a real worker's clean hand in a plain dark apron naturally sliding or wrapping the burger, a small stainless condiment cup, a blank order slip held by a blue clip, and subtle narrow urban takeaway-counter depth. Use bright direct flash mixed with clean overhead fluorescent daylight, accurate food texture, cream paper, silver steel, charcoal ink-like shadows, and a large cobalt material plane. Keep the burger toward center-right and leave usable breathing room at left for separate HTML copy. Energetic, immediate, Korean city lunch-service realism. No red-yellow fast-food palette, no checkerboard, no neon sign, no 1950s diner, no rustic wood, no dark moody bar, no floating ingredients, no plastic-looking food, no excessive steam, no fake lettering, no Korean or English text, no logo, no UI, no watermark, no extra fingers, no distorted hand, no duplicate burger.
```
### 국내성 단서
- 좁고 밝은 스테인리스 카운터와 도심 창가
- 검정 반소매 작업복과 앞치마
- 코발트 트레이, 집게판, 흰 포장지
- 제품 진열보다 주문을 바로 건네는 매장 행동
- 체커보드, 네온, 가죽 부스가 없는 밝은 국내 패스트캐주얼 환경
### 위험과 제한
- 엄지와 포장지 경계가 약간 매끄럽게 융합됐다.
- 위쪽 양파, 소스, 치즈의 경계가 약하다.
- 번의 반구와 깨 배열이 상품 렌더처럼 지나치게 균일하다.
- 두 패티 질감이 복제처럼 비슷하다.
- 창밖의 흐린 파란 표식을 실제 간판이나 주소로 해석하지 않는다.
- 텍스트를 올릴 깨끗한 여백은 없다.
### 크롭 규칙
데스크톱:
- `크림색 DOM 정보 38~42% + 이미지 58~62%` 분할을 사용한다.
- 원본 `x 35~100%` 크롭으로 손과 버거를 함께 보존한다.
- `object-position: 72% 50%`를 시작점으로 실제 캡처에서 조정한다.
- 버거 핵심 구역은 약 `x 54~91%`, `y 36~78%`다.
- 음식 위에는 가격, 버튼, 배지를 올리지 않는다.
모바일:
- 단순 `object-fit: cover`는 불합격이다.
- 같은 직원, 트레이, 버거를 유지한 4:5 전용 장면을 만든다.
- 버거 중심을 약 `x 52%`, `y 57%`에 두고 손 전체와 트레이 앞선을 포함한다.
- 카피와 가격은 사진 아래 별도 DOM에 둔다.
### 대표 버거 고정 구성
- 깨가 듬성한 부드러운 브리오슈 번
- 바삭한 가장자리의 소고기 패티 정확히 2장
- 노란 체더 정확히 2장, 각 패티 사이에 한 장씩
- 얇게 구운 양파
- 골이 있는 피클 3조각
- 연한 크림색 하우스 소스
- 양상추, 토마토, 베이컨은 대표 버거에서 제외
- 패티 지름, 번 높이, 포장지 접힘 방향 고정
### 고정 재료와 작업복
- 코발트 `#2147E7` 폴리프로필렌 트레이
- 코발트 금속 집게
- 무코팅 흰 유산지
- 브러시드 304 스테인리스 카운터
- 소형 스테인리스 소스 컵
- 검정 면 반소매와 검정 앞치마
- 장신구, 문신, 브랜드 로고 없음
- 맨손은 포장지만 잡고 음식에 직접 닿지 않음
### 고정 조명
- 4800~5200K 상업 주방 천장등과 창문 주광
- 밝고 중성적인 색온도
- 번과 치즈에만 얇은 윤기
- 주황색 텅스텐, 과한 증기, 불꽃, 검은 비네트, 플라스틱 광택 금지
### 고정 카메라
- Hero: 35mm, 카운터 높이 약 90cm, 손 건네기 시점
- 메뉴 제품 컷: 50mm, 약 15도 낮은 측면
- 재료와 패티 접사: 85mm
- 매장 전경: 28mm, 수직선 교정
- 배경만 얕은 심도를 쓰고 버거 앞뒤는 선명하게 유지
### 필요한 추가 자산
1. `burger-hero-handoff-mobile` — 동일 장면의 4:5
2. `burger-signature-double` — 대표 더블 단독 제품 컷, 4:3
3. `burger-single` — 패티 1장 변형, 4:3
4. `burger-onion-detail` — 철판 양파 조리 접사, 3:2
5. `burger-patty-press` — 눌러 굽는 조리 과정, 3:2
6. `burger-wrap-handoff` — 포장 완료 장면, 3:2
7. `burger-ingredient-flatlay` — 실제 재료 근거, 4:3
8. `burger-store-interior` — 좁고 밝은 국내 도심 매장, 3:2
9. `burger-store-exterior` — 빈 간판 면을 둔 저녁 외관, 3:2
10. `burger-team-training` — 조리와 응대 교육, 얼굴 비중 낮게, 3:2
11. `burger-franchise-counter` — 가맹 페이지용 운영 동선, 3:2
12. 매장 카드용 외관 4종 — 서로 다른 입지, 같은 코발트 사인 체계
13. 조합기용 버거 기본 컷과 토핑 상태 5~7종
14. 공유 카드용 정면 버거 컷, 1:1
조합기는 UI가 들어간 이미지 한 장을 쓰지 않는다. 같은 카메라와 버거 위치를 유지한 상태 이미지 5~7종을 실제 DOM 선택에 따라 교체한다. 투명 레이어 방식은 좌표가 완전히 고정된 경우에만 사용한다.
---
## 게이트 1.1 승인 뒤 생성한 자산
### 부동산 거주형 실내
- 생성 작업: `exec-001f3044-ca57-4f8a-9a12-da141f580833`
- 배포 파생본: `public/work/hannam-jari/assets/hannam-interior-1200.webp` · SHA-256 `13e2d3b3cb85b42f91576e29958fd541c6aa288054efbaaf586ebb660867aba8`
- 원본: 1536×1024, 2,216,184 bytes
- 역할: 공개 매물 카드와 상세의 보조 연출 사진
- 유지 단서: 검정 창호, 회백색 마감, 낮은 모듈 소파, 오크 수납, 서울형 고밀도 조망
- 제한: 창밖의 건물과 지형은 실제 한남 조망이 아니며 채광, 소음, 조망의 증빙으로 쓰지 않는다.
- 판정: 조건부 승인. 이미지 가까이에 `연출 이미지 · 실제 매물 사진 아님`을 둔다.
### 부동산 모바일 외관
- 생성 작업: `exec-f705b6de-e573-483a-9162-dad639ac4d5b`
- 배포 파생본: `public/work/hannam-jari/assets/hannam-evening-mobile.webp` · SHA-256 `47246751f9d1d1036da68e5aa2efe5b4abf55c5eb585b8125a5624a745ca76ad`
- 원본: 1122×1402, 2,142,301 bytes
- 역할: 390px 첫 화면 전용 외관 사진
- 유지 단서: 경사지 골목, 회색 석재, 검정 창호, 세로 현관, 정리한 소나무, 서울형 중저층과 공동주택
- 제한: 가로 Hero와 동일 건물의 완전한 기록 사진이 아니라 같은 디자인 언어로 만든 연출 해석이다. 두 사진을 한 실매물의 시점 증빙처럼 묶지 않는다.
- 판정: 조건부 승인. 모바일에서 별도 `source`로만 사용하고 실제 위치를 주장하지 않는다.
### 철판클럽 대표 제품
- 생성 작업: `exec-06854e2c-037b-4799-9699-1f2cf8a7e956`
- 배포 파생본: `public/work/griddle-club/assets/signature-double.webp` · SHA-256 `8505dfd5da82a0a36dbfd6a2a3c9895ab0db3b09f9f7949fc71dbe325c95e172`
- 원본: 1448×1086, 2,068,902 bytes
- 역할: 대표 더블 메뉴와 조합기의 기본 제품 사진
- 유지 단서: 코발트 트레이, 흰 유산지, 스테인리스 주방, 더블 패티와 치즈
- 제한: 피클과 소스처럼 사진으로 정확히 셀 수 없는 구성은 조리 명세의 증거로 쓰지 않는다. 실제 구성과 알레르기 정보는 문서 객체가 소유한다.
- 판정: 조건부 승인. 가격, 순위, 판매량과 알레르기 주장을 이미지에 연결하지 않는다.
### 철판클럽 모바일 건네기
- 생성 작업: `exec-c6f40a2f-422b-4f06-9d43-7ad1e2753fdd`
- 배포 파생본: `public/work/griddle-club/assets/hero-handoff-mobile.webp` · SHA-256 `a7d98504cb156bffd00c8174fa1434b0da99ef7fbcb0af3618a382d02d32bfdf`
- 원본: 1122×1402, 1,984,985 bytes
- 역할: 390px 첫 화면 전용 건네기 사진
- 유지 단서: 국내 도심 상가 창, 밝은 스테인리스 주방, 검정 작업복, 코발트 트레이, 흰 유산지
- 제한: 직원은 가상 장면의 조연이며 실제 가맹점이나 실제 종사자로 표현하지 않는다. 얼굴이 제목보다 먼저 지배하지 않게 모바일 크롭을 검증한다.
- 판정: 조건부 승인. 사진 밖 문서 객체에 브랜드, 메뉴와 행동을 둔다.
네 자산 모두 생성 문자와 인터페이스는 보이지 않았지만, 압축 뒤 실제 캡처에서 작은 배경 표식과 손·재료 경계를 다시 육안 검사한다.
## 해상도와 전송 예산
| 역할 | 배포 크기 | 형식과 품질 | 목표 | 하드 상한 |
|---|---:|---|---:|---:|
| 데스크톱 Hero | 1600×900 | WebP q80~82 | 180~240KB | 320KB |
| 모바일 Hero | 960×1200 또는 900×1600 | WebP q80 | 140~190KB | 240KB |
| 본문 와이드 | 최대 1200×800 | WebP q78~82 | 120~180KB | 240KB |
| 4:3 카드 | 최대 720×540 | WebP q76~80 | 65~100KB | 130KB |
| 작은 썸네일 | 최대 480×360 | WebP q74~78 | 35~60KB | 80KB |
| 투명 조합기 레이어 | 최대 720×720 | alpha WebP q82~86 | 70~130KB | 170KB |
| OG 이미지 | 1200×630 | JPEG, WebP 또는 PNG | 200~350KB | 600KB |
추가 규칙:
- Hero만 `fetchpriority="high"`를 사용한다.
- 나머지 이미지는 지연 로딩한다.
- 모든 이미지에 실제 `width`, `height`, `srcset`, `sizes`를 둔다.
- 첫 화면 이미지 전송량 목표는 데스크톱 350KB, 모바일 260KB 이하다.
- 초기 이미지와 지연 이미지를 분리해 계측한다.
- 자산 변환 뒤 MIME, 파일 크기, 픽셀 크기, 해시를 보고서에 기록한다.
## 최종 이미지 게이트
- 데스크톱과 모바일 전용 크롭이 각각 주인공을 보존하는가
- 실제 장소나 실제 매물로 오인될 표현이 없는가
- 생성 문자, 로고, 사람의 손, 재료 경계 오류가 없는가
- 같은 건물과 대표 버거의 고정물이 모든 자산에서 유지되는가
- DOM 텍스트가 이미지의 중요한 정보와 겹치지 않는가
- 감소 모션과 이미지 실패에서도 핵심 정보와 행동이 남는가
- 성능 목표와 하드 상한을 모두 통과하는가

View file

@ -0,0 +1,216 @@
# 두 쇼케이스 구현 청사진
> **최종 정본 (H04 반영, 2026-08-29):** 이 문서는 구현과 사용자 게이트를 모두 통과한 최종 정보 구조, 진실성 계약, 화면 상태, 토큰, 성능 예산과 자동 게이트를 기록한다. 초기 9장 구성과 지도·통근 중심 설계는 폐기됐다.
## 1. 공통 계약
- `한남자리`와 `철판클럽`은 디자인 검증용 가상 브랜드다.
- 실제 사무소, 실제 매물, 실제 매장, 실제 가격, 실제 주문, 실제 가맹 문의로 오인될 표현을 만들지 않는다.
- 고지는 푸터 한 곳에 숨기지 않는다. 같은 진실 범위의 가장 가까운 의미 부모가 한 번 소유하고, 제한이 달라지는 카드·가격·매장 상태·제출 행동에서만 새로 표시한다.
- 생성 이미지는 물성, 공간, 음식과 현장 분위기만 소유한다. 가격, 상태, 주소, 분석, 버튼과 표지는 실제 문서 객체가 소유한다.
- 정적 데모의 계산은 실시간 인공지능이나 서버 전송으로 표현하지 않는다.
- 화면이 실패해도 입력값과 확인된 사실은 남기고, 성공 상태를 가장하지 않는다.
- 공개 경로와 운영 경로는 같은 브랜드 토큰을 공유하되 표현, 움직임, 정보 밀도 다이얼은 따로 둔다.
## 2. 경로와 소유권
| 제품 | 경로 | 주 임무 | 표현 / 움직임 / 밀도 |
|---|---|---|---|
| 한남자리 공개 | `/work/hannam-jari/` | 다섯 조건 입력, 예시 브리핑, 세 집 비교, 서류철, 사람 인계 | 7 / 4 / 5 |
| 한남자리 관리자 | `/work/hannam-jari/admin/` | 표시 검수, 게시 방어선, 사람 검토, 감사 | 4 / 3 / 8 |
| 철판클럽 공개 | `/work/griddle-club/` | 메뉴, 조합, 매장, 포장 행동 | 8 / 6 / 4 |
| 철판클럽 가맹 | `/work/griddle-club/franchise/` | 운영 조건, 비용 미정, 문의 준비 | 5 / 3 / 6 |
공유 저장소 수정 지점:
- `apps/site/src/data/work.ts`: 두 쇼케이스와 하위 경로 등록
- `apps/site/gate.config.json`: 네 경로 디자인 게이트 등록
- `apps/site/tools/showcase-duo-e2e.mjs`: 결정적 기능과 실패 상태 검증
- `apps/site/tools/design-gate.mjs`: 네 경로의 정적 계약과 실제 렌더를 320, 375, 390, 768, 1440에서 감사
- `apps/site/tools/capture-showcase-duo.mjs`: 본 화면과 핵심 보조 화면 캡처
- `apps/site/tools/verify.mjs`: 재귀 정적 검사, 실렌더 감사와 전용 시나리오 포함
## 3. 한남자리 공개 화면
### 밝은 Hero와 조건 도크
- 제목은 `한남에서, 내 기준에 맞는 집부터.`다.
- 서울 생활권의 생성 외관 이미지를 밝은 지면 위에 두고, 검정은 마지막 사람 인계 장면에서만 사용한다.
- 의사결정 SSOT는 거래 형태·예산·최소 전용면적·입주 희망일·필요 주차 다섯 필드뿐이다.
- 월세는 보증금과 월 납부액을 분리한다. 화면에 없는 통근·일조·좌표·조망은 추천 근거로 쓰지 않는다.
- 입력 전송은 없고, 명시적으로 저장할 때만 다섯 필드를 현재 브라우저에 보관한다.
### 예시 AI 브리핑과 세 집 비교
- 분석 전에는 중립 상태를 유지한다. 실행 뒤 `조건과 맞는 점 / 조정이 필요한 점 / 중개사와 확인할 점` 세 묶음을 만든다.
- 세 후보의 가격·면적·입주·주차를 동일한 입력과 비교하며, 선택 후보를 비교표·서류철·상담 인계에 동기화한다.
- 모든 후보와 생성 사진의 가상·연출 범위는 가장 가까운 공통 부모에서 표시한다.
### 탭형 검토 서류철과 사람 인계
- 서류철은 기본·가격·권리·입지 확인·시설과 관리 다섯 탭이며 클릭과 방향키·Home 키를 지원한다.
- 권리, 실제 이동 시간, 현장 상태, 입주 가능일처럼 근거가 없는 항목은 확인 필요로 남긴다.
- 마지막 장면은 `AI가 좁히고, 공인중개사가 확인합니다.`라는 역할 분리를 보여준다.
- 상담 흐름은 외부 전송 없는 모의 대화상자이며 닫기·바깥 클릭·Escape 뒤 선택 상태와 초점을 함께 복구한다.
## 4. 한남자리 관리자
### 첫 화면 구조
- 일반적인 지표 카드 세 장과 차트로 시작하지 않는다.
- 검수 대기열이 화면의 중심이고 우측 조사관이 선택 행의 전체 맥락을 가진다.
- 표 열은 상태, 매물, 거래, 가격, 마지막 확인, 표시 검수, 분석 검토, 담당이다.
- 검색, 상태 필터, 정렬, 키보드 행 선택과 빈 결과 초기화를 제공한다.
### 이중 게시 게이트
`쇼케이스 미리보기`는 다음 조건을 모두 통과할 때만 가능하다.
- 상시 데모 표지
- 개별 매물 예시 표지
- 생성 이미지 출처와 증빙 불가 표지
- 실제 연락 기능 비활성
- 실제 주소와 등록번호 없음
- 결정적 예시 분석 표지
`실제 광고`는 다음이 모두 실제 자료가 아니므로 모든 fixture에서 차단한다.
- 실제 등록 사무소 정보
- 실제 중개의뢰 관계
- 광고 동의
- 거래 가능 상태
- 실제 서류와 미디어 권리
- 공인중개사 최종 승인
가상 데이터로 실제 광고 게이트를 통과시키는 성공 상태는 구현하지 않는다.
### 조사관 탭
1. 기본
2. 표시·광고
3. 서류
4. AI 분석
5. 문의·일정
6. 변경 이력
표시 검수는 `통과`, `누락`, `충돌`, `오래됨`, `해당 없음`을 구분하고 법령, 행정 기준, 내부 운영 기준을 한 범주로 섞지 않는다. 누락 항목은 해당 필드로 이동한다.
### 운영 상태
- 대기열 없음과 필터 결과 없음은 서로 다른 상태다.
- 권한상 볼 수 없음, 선택 행 계약 완료, 분석 실패·취소·오래됨을 제공한다.
- 저장 실패는 낙관적 성공을 되돌리고 입력을 보존한다.
- 버전 충돌은 다른 담당자의 버전과 현재 편집본을 덮어쓰지 않고 비교한다.
- 실제 게시 요청은 중복 클릭, 권한, 객체 버전, 규칙 묶음, 감사 로그 쓰기 중 하나라도 실패하면 닫힌 상태를 유지한다.
- 조사관과 대화상자는 닫기, 바깥 클릭, Escape가 패널과 선택 행 강조를 함께 해제한다.
## 5. 철판클럽 공개 화면
### 첫 화면과 메뉴
- 제목은 `눌러 굽고, 바로 건넨다.`이다.
- 일반 고객 행동인 메뉴와 매장 찾기가 가맹보다 높은 위계를 가진다.
- 대표 두 메뉴는 큰 사진과 한국어 이름으로, 나머지는 세 열 편집 리듬으로 보여준다.
- 모든 가격에 `화면 계산용 예시`를 가까이 붙인다.
- 검증하지 않은 인기, 판매량, 순위 배지를 쓰지 않고 `대표`만 쓴다.
- 품절, 매장 미제공, 준비 중을 색만이 아니라 문구와 행동 제한으로 표시한다.
### 조합기
- 단품·세트, 패티, 치즈, 양파, 피클, 소스, 곁들임, 음료와 받을 예시 매장을 선택한다.
- 세트의 곁들임이나 음료가 비면 완료 상태가 되지 않는다.
- 매장별 품절 재료가 있으면 조합은 보존하고 다른 매장 행동을 제공한다.
- 매장이 닫혀도 조합을 지우지 않는다.
- 가격 자료가 없으면 합계를 만들지 않는다.
- 성공 행동은 주문이 아니라 `선택 내용 저장`이다.
- 저장 결과는 현재 기기의 화면 상태이며 서버 전송으로 말하지 않는다.
### 알레르기와 매장
- 재료를 빼면 예시 알레르기 표시는 다시 계산한다.
- 같은 철판과 도구의 교차 접촉 경고는 어떤 선택에서도 남긴다.
- 매장은 `예시 매장`으로 표시하고 동네 범위만 제공한다.
- 실제 도로명 주소, 전화번호, 거리와 위치 권한을 만들지 않는다.
- 영업 중, 마지막 주문 임박, 종료, 임시 휴무, 확인 필요를 각각 다른 행동으로 연결한다.
## 6. 철판클럽 가맹 화면
- 첫 사진은 버거 접사보다 조리, 포장, 응대 동선이 보이는 운영 장면이다.
- 브랜드 이유, 운영 방식, 희망 상권, 점포 상태, 개설 순서, 비용 상태, 교육과 품질, 문의 준비 순서로 읽힌다.
- 검증하지 않은 최소 면적, 가맹비, 평균 매출, 회수 기간과 예상 수익을 만들지 않는다.
- 비용 항목은 금액이 아닌 `미정`과 미정 이유를 보여주고 합계를 만들지 않는다.
- 면적 입력은 ㎡와 평을 함께 보여주지만 적합 판정으로 표현하지 않는다.
- 문의는 실제 이름과 연락처를 받지 않는 체험이다.
- 성공 상태는 `문의 준비가 끝났어요`이며 실제 접수나 담당자 연락을 주장하지 않는다.
- 실패 상태는 입력을 보존하고 다시 시도 행동을 제공한다.
## 7. 토큰과 조판
### 한남자리 공개
- 바탕 `#EEECE5`
- 검정 `#0F0F0E`
- 어두운 면 금색 `#C6A15B`
- 밝은 면 갈금 `#6F5420`
- 금색 점유율 5% 이하, 선택·검토·확인 표시에만 사용
- Pretendard 또는 SUIT 계열 한 패밀리
### 한남자리 관리자
- 밝은 중립 바탕, 흰 패널, zinc 계열 경계
- 브랜드 검정과 갈금은 선택 행, 현재 탭, 방어선 상태에만 사용
- 표 본문 13~14px, 일반 본문 15~16px, 지나치게 작은 보조문 금지
- radius는 작은 제어 6~8px, 패널 10~12px 이내
### 철판클럽
- 바탕 `#F4F1E9`
- 글자 `#211F1C`
- 코발트 `#2147E7`
- 코발트 점유율 8~10%, 행동과 포장 재료에 집중
- 스테인리스, 치즈와 불빛은 사진만 소유
- Pretendard Variable 한 패밀리
## 8. 반응형과 접근성
- 320, 390, 768, 1440에서 가로 넘침이 없어야 한다.
- 모바일 주 행동은 최소 높이 44px이다.
- 첫 제목은 320px에서도 두 줄 이내를 목표로 하되 한국어 어절을 강제로 쪼개지 않는다.
- 고정 머리글과 하단 행동은 안전 영역을 포함한다.
- 가로 표는 명시적 스크롤 용기와 표 설명을 가진다.
- 현재 선택, 품절, 차단, 경고는 색과 함께 텍스트·아이콘·속성으로 표현한다.
- 상태 변경은 `aria-live`, 폼 전체 실패는 `role=alert`로 알린다.
- 감소 모션에서는 자동 이동, 부드러운 스크롤과 장식 변환을 끈다.
- 자바스크립트가 없어도 제목, 핵심 사실, 예시 고지와 경로 설명은 남는다.
## 9. 이미지와 성능 예산
- 데스크톱 Hero 목표 180~240KB, 하드 상한 320KB
- 모바일 Hero 목표 140~190KB, 하드 상한 240KB
- 카드 목표 65~100KB, 하드 상한 130KB
- 첫 화면 동일 출처 자산은 데스크톱 350KB, 모바일 260KB 이하를 목표로 한다.
- Hero만 높은 우선순위를 사용하고 나머지는 지연 로딩한다.
- 모든 이미지에 고정 크기, 반응형 크기 힌트, 대체 문구를 둔다.
- 생성 이미지 픽셀의 글자와 로고는 자동 한자 검사로 잡히지 않으므로 별도 육안 감사한다.
## 10. 자동 게이트 계약
정적 게이트:
- 모든 하위 HTML 재귀 검증
- 모든 하위 CSS 재귀 스타일 검사
- 자바스크립트 문법 검사
- 한자 금지 검사
- 상대 경로, CSS 변수, 한글 조판 무결성 검사
실제 브라우저 게이트:
- 네 경로 × 네 폭의 구조와 오버플로 감사
- 한남자리 조건 분석, 단위 전환, 지도·목록 동기화, 상담 데모
- 한남자리 관리자 검색·정렬·필터·탭·게시 차단·대화상자 해제
- 철판클럽 메뉴, 조합기, 품절·영업 종료, 알레르기, 저장 성공·실패
- 철판클럽 가맹 면적 환산, 비용 미정, 검토 복귀, demo 성공·실패
- 키보드 순서, 접근성 자동 검사, 감소 모션, 자바스크립트 없음
- 깨진 이미지, 콘솔 오류, 네트워크 실패, 누적 배치 이동과 최초 주요 표시 시간
게이트의 자동 승인은 무조건 통과가 아니다. 검사 결과가 녹색일 때만 승인 기록을 남기고, 실패 시 관련 단계로 되돌아가 수정한다.

View file

@ -0,0 +1,150 @@
# 두 쇼케이스 학습 원장
> 목적: 두 디자인을 만들며 얻은 교훈을 증거와 함께 누적하고, 구현과 검증을 마친 뒤 10개 이상을 designpaca 규칙으로 승격한다.
> 현재 항목은 연구 단계의 가설이다. 구현이나 실제 브라우저 증거가 없는 항목은 최종 교훈으로 세지 않는다.
## 승격 기준
각 교훈은 다음을 모두 가져야 한다.
1. 이번 두 쇼케이스 중 어디에서 발견했는가
2. 어떤 화면, 코드, 사용자 동작, 검사 결과가 증거인가
3. 기존 designpaca 규칙과 충돌하는가
4. 충돌한다면 근본 규칙을 보존할지, 새 교훈에 맞게 근본 규칙을 고칠지
5. 규칙을 바꾼 뒤 어떤 검사로 회귀를 막을 것인가
## 연구 단계 가설
| 번호 | 관찰 | 후보 규칙 | 기존 규칙과 긴장 | 상태 |
|---:|---|---|---|---|
| 1 | 고급 마케팅 화면의 어두운 브랜드 톤을 운영 관리자까지 복제하면 상태와 경고가 약해진다. | 같은 제품이어도 마케팅 화면과 운영 화면의 시각 밀도 다이얼을 따로 정한다. | 하나의 토큰 체계와 화면별 판독성 사이의 긴장 | 구현 증거 대기 |
| 2 | 검정과 금색은 면적보다 역할을 제한할 때 더 고급스럽다. | 고급 팔레트는 강조색 점유율과 허용 역할을 함께 기록한다. | 팔레트 자유와 절제 사이의 긴장 | 구현 증거 대기 |
| 3 | AI 설명만 추가하면 신뢰가 생기지 않고 수정과 사람 검토가 함께 있어야 한다. | AI 결과 컴포넌트는 이유, 불확실성, 수정, 사람 검토를 하나의 게이트로 묶는다. | 디자인 범위와 제품 안전 범위 사이의 긴장 | 구현 증거 대기 |
| 4 | 같은 업종 구조, 다른 업종 톤, 구체 상호작용을 분리해 조사하면 복제 위험이 줄어든다. | R1, R2, R3마다 가져올 축과 버릴 축을 의무적으로 기록한다. | 기존 3슬롯 규칙의 구체화 | 조사 증거 있음, 구현 검증 대기 |
| 5 | 고객과 가맹 희망자는 같은 브랜드라도 성공 행동이 다르다. | 서로 다른 주 행동을 가진 사용자군은 한 화면의 동등 CTA가 아니라 경로 분리를 먼저 검토한다. | 단일 랜딩 압축과 정보 구조 분리 사이의 긴장 | 구현 증거 대기 |
| 6 | 생성 이미지 안에 가짜 UI를 넣으면 상호작용과 접근성을 증명할 수 없다. | 생성 이미지는 물성과 분위기를 소유하고 데이터와 제어는 HTML이 소유한다. | 강한 비주얼과 실제 제품성 사이의 긴장 | 국내 방향 시안 2장에 UI를 배제한 증거 있음, 구현 검증 대기 |
| 7 | 법정 공개 항목은 푸터 문구보다 관리자 게시 상태에 연결할 때 실효성이 생긴다. | 규제 필드는 고지 목록과 함께 게시 차단 상태 머신으로 모델링한다. | 시각 디자인 스킬의 범위를 운영 구조로 넓히는 문제 | 구현 증거 대기 |
| 8 | 생성 음식 사진은 한 장의 품질보다 여러 장의 재료와 포장 일관성이 더 어렵다. | 이미지 생성 전에 자산 역할, 재료 고정값, 조명, 카메라, 금지 목록을 이미지 성경으로 만든다. | 생성 속도와 일관성 관리 사이의 긴장 | 기준 이미지 오류와 크롭 감사 완료, 추가 자산 검증 대기 |
| 9 | 표현 강도는 사이트 전체에서 하나가 아니라 화면 임무별로 달라져야 한다. | 표현, 움직임, 정보 밀도 다이얼을 주요 화면군마다 기록한다. | 하나의 방향 보드와 화면별 변주 사이의 긴장 | 구현 증거 대기 |
| 10 | 인간 검증 게이트가 추상적인 감상 질문이면 다음 구현을 잠그지 못한다. | 게이트마다 승인할 이름, 문장, 색, 이미지, 정보 구조를 열거하고 승인 전 금지 행동을 기록한다. | 자율 구현 속도와 사용자 통제 사이의 긴장 | 게이트 응답 대기 |
| 11 | 프리미엄 화면에서 모든 카드를 장식하면 위계가 사라진다. | 장식이 필요한 단 하나의 지배 표면을 지정하고 나머지는 선, 여백, 타입으로 구분한다. | high-end 보조 규칙과 designpaca의 반복 억제 사이의 충돌 | 구현 증거 대기 |
| 12 | 지도나 시각화만 제공하면 동일 데이터를 키보드와 보조기술로 탐색하기 어렵다. | 지도와 시각화에는 동일 데이터의 목록 또는 표 보기를 필수 짝으로 둔다. | 몰입형 시각과 접근성 사이의 긴장 | 구현 증거 대기 |
| 13 | 국내 서비스를 만들면서 해외 사례를 시각 주축으로 두면 한국어를 얹어도 번역된 해외 템플릿처럼 읽힌다. | 지역 적합성은 장식이 아니라 단위, 용어, 정보 순서, 상담과 주문 행동, 상업 사진으로 검사한다. R1과 R2의 주 레퍼런스를 목표 지역 사례로 우선한다. | 세계적 레퍼런스 품질과 지역 사용자 맥락 사이의 긴장 | 사용자 피드백 증거 있음, 구현 검증 대기 |
## 실제 사용자 피드백 1
- 피드백: `주 레퍼런스가 너무 서양스럽다. 국내의 디자인 감각에 맞아야 한다.`
- 확인된 원인: 부동산은 Sotheby's와 Hennessy, 햄버거는 Teenage Engineering이 시각 중심축이었다.
- 저장소 충돌: 검정과 앰버, 한남 고급 거실, 에디토리얼 아카이브, 검정과 코발트 기술 화면이 기존 쇼케이스에 이미 존재한다.
- 수정 행동: 국내 부동산, 프롭테크, 주거 브랜드, 햄버거, 프랜차이즈, 국내 이종 업종을 병렬 재조사한다.
- 이미지 행동: 실내 중심 부동산과 어두운 해외 푸드 사진을 폐기하고 서울 공동주택 외관과 밝은 도심 철판 주방 시안을 재생성한다.
- 최종 판정 조건: 전통 장식을 모두 지워도 국내 단위, 용어, 흐름, 행동만으로 한국 서비스임을 식별할 수 있어야 한다.
## 구현 중 기록 형식
각 후보 아래에 다음 증거를 추가한다.
- 화면과 상태:
- 관련 파일:
- 실제 브라우저 동작:
- 실패 또는 수정 전 상태:
- 수정 후 검사:
- 기존 규칙과의 조정:
- 최종 승격 여부:
## 구현 증거 누적
### 증거 A — 정적 검사의 재귀 범위
- 화면과 상태: 공개 홈뿐 아니라 `admin/`, `franchise/` 하위 페이지가 추가된 직후
- 관련 파일: `apps/site/tools/verify.mjs`, 신규 HTML 네 경로
- 수정 전 상태: 깊이가 고정된 HTML glob은 앞으로 더 깊어진 하위 페이지를 놓칠 수 있었다.
- 실제 검사: 재귀 glob으로 바꾸자 미완성 신규 화면의 ARIA 오용, label 다중 연결, figure 자식 순서 오류 8건을 바로 검출했다.
- 수정 후 검사: 기존 추적 HTML은 그대로 PASS, 신규 오류만 RED로 분리됐다.
- 후보 교훈: 페이지 수가 아니라 경로 깊이가 변하는 제품에서는 정적 게이트 입력 자체를 재귀·명시 목록으로 검증한다.
### 증거 B — 철판클럽 첫 실렌더
- 화면과 상태: 홈과 가맹, 320 / 390 / 768 / 1440 실제 Chrome 렌더
- 관련 파일: `public/work/griddle-club/styles/site.css`, `styles/franchise.css`
- 실패 또는 수정 전 상태: PASS 0 / FAIL 8. 모바일 지도 장식이 문서 폭을 421px와 497px로 밀었고, 768px 제품 사진은 934px까지 넘쳤다. 작은 코발트·보조 글자는 최저 2.09:1, 두 Hero 제목은 3줄이었다.
- 수정: 지도 장식의 확대·회전을 제거하고 같은 경계 안에서 자르기, 중간폭 이미지에 `min-width: 0`과 절대 채움, 보조색 역할 토큰 상향, 제목 최대 크기와 폭 조정.
- 수정 후 검사: 한 번 더 남은 10px 푸터 문구 3.89:1을 교정한 뒤 PASS 8 / FAIL 0.
- 후보 교훈: 장식의 transform도 실제 경계 밖 픽셀을 만들 수 있으며, 강조색 토큰은 큰 CTA뿐 아니라 가장 작은 실제 사용 크기에서 검증해야 한다.
### 증거 C — 모바일 전용 생성 이미지
- 화면과 상태: 두 공개 Hero의 390px 전용 사진
- 관련 파일: `showcase-duo-image-bible-2026-08-29.md`, 두 사이트의 모바일 WebP
- 실패 또는 수정 전 상태: 가로 원본의 중앙 강제 크롭은 부동산 현관·경사 골목과 버거의 손·트레이를 동시에 보존하지 못했다.
- 수정: 같은 물성과 카메라 세계관으로 1122×1402 세로 원본을 별도 생성하고 `<picture>`의 좁은 폭 source로 연결했다.
- 후보 교훈: 모바일 Hero가 별도 시선 경로를 요구하면 CSS 크롭을 미세 조정하는 대신 생성·촬영 원본 단계에서 구도를 분기한다.
### 증거 D — 자율 승인과 게이트
- 화면과 상태: 사용자가 모든 중간 게이트를 자율 승인한다고 명시한 뒤
- 관련 파일: 방향 보드, 구현 청사진, 자동 검사 리포트
- 실제 동작: 게이트 질문을 반복하지 않고 구현은 진행했지만, 철판클럽과 한남자리의 첫 실렌더가 RED일 때 다음 단계로 승인 처리하지 않고 즉시 수정 루프로 돌아갔다.
- 후보 교훈: 자율 승인은 검사 면제가 아니라 의사결정 대기만 제거한다. 합격 증거와 실패 시 복귀 조건은 그대로 유지한다.
### 증거 E — 구조 게이트 뒤의 시각 RED
- 화면과 상태: 철판클럽 홈 390×844, 구조 감사 PASS 8 / FAIL 0 직후의 실제 상태 캡처
- 관련 파일: `public/work/griddle-club/styles/site.css`, `tools/.gen/showcase-duo/burger-home-mobile-hero.png`, `burger-home-mobile-stores.png`
- 실패 또는 수정 전 상태: 모바일 `.store-map { position: static }` 때문에 절대 배치된 격자·강·핀의 기준이 BODY로 바뀌었다. 지도 장식이 Hero 전체를 덮고 실제 매장 구간에는 빈 회색판만 남았다. 데스크톱 사진 크롭은 첫 844px에서 직원 얼굴만 보여 버거를 폴드 아래로 밀었다.
- 수정: 지도 부모의 containing block을 `position: relative`로 복원하고 목록을 지도보다 먼저 배치했다. 모바일 전용 4:5 이미지를 절대 채움·하단 중심으로 배치해 첫 화면에 손과 버거가 함께 보이게 했다.
- 수정 후 검사: 390 실측에서 Hero 사진 y=511.7~873.2, 첫 화면에 제품 노출. 새 상태 캡처를 직접 열어 오버레이 누수와 빈 지도판이 사라졌음을 확인.
- 후보 교훈: 기하·오버플로 PASS는 시각 PASS가 아니다. absolute/fixed 자식은 containing block을 단언하고, 기본 화면과 핵심 상태의 실제 캡처를 열어 봐야 한다.
### 증거 F — 90/350/12 하드락 E2E의 첫 RED
- 화면과 상태: 공개·관리·가맹 네 URL, Chromium 16폭, WebKit 8폭, axe, JS 없음, 감소 모션, 사용자 여정
- 관련 파일: `tools/showcase-duo-e2e.mjs`, `tools/showcase-duo-e2e-report.md`
- 계약: 90/90 시나리오, 350/350 단언, 12/12 캡처의 개수·규격·10KB 이상·SHA-256·상호작용 전후 해시 차이를 잠갔다.
- 실제 RED: 한남자리 대비·SVG 중첩 인터랙션, 관리자 새 초안이 같은 클릭의 outside-click으로 즉시 닫힘, 철판클럽 비용표의 키보드 초점 누락, 빈 메뉴 복구가 0개에 머무는 결함을 분리했다.
- 후보 교훈: 대규모 E2E는 시나리오·단언·캡처 수를 모두 hard-lock해야 검사 삭제가 GREEN으로 위장되지 않는다. 화면 존재가 아니라 행동 뒤 기대 상태와 부작용 없음을 단언한다.
### 증거 G — 인간 게이트가 잡은 세 가지 계측 공백
- H01: 철판클럽 가맹 Hero의 채운 버튼과 텍스트 링크가 `align-items:center` 행 안에서도 시각 중심이 달랐다. 공용 버튼의 `margin-top:34px`와 한 줄 링크 hitbox가 원인. 둘을 48px hitbox로 통일한 뒤 1440/390 centerY 차이 0px.
- H02: 한남자리 상세 04·05·06이 전부 `왼쪽 큰 제목 + 오른쪽 선형 목록/표` 5:7 골격이었다. 카피가 달라도 같은 섹션을 세 번 복제한 인상. 장소=생활권 도식, 비용=원장, 검토=분기 흐름으로 역할별 토폴로지를 분리한다.
- H03: 관리자 인스펙터를 닫아도 2열 grid track가 남아 큰 빈 회색 면이 생겼다. 닫힘 상태에서 표가 작업대 전체 폭을 회수하고, 재선택 때 분할을 복원하도록 상태 레이아웃 계약을 바꾼다.
- 기존 규칙과의 조정: 일관성을 버리고 모든 섹션을 다르게 만드는 규칙으로 굽히지 않는다. **의미 역할이 다르면 읽는 동작도 달라야 한다**는 조건부 규칙으로 조화시킨다. `align-items`나 `display:none` 선언을 보는 대신 실제 hitbox 중심과 surface edge를 측정한다.
- 회귀 게이트: H01 hitbox ≥44px·centerY 차이 ≤2px. H03 닫힘 queue/workbench right edge 차이 ≤2px, 재선택 split 복원. H02는 전후 상태 캡처를 사람이 다시 판정한다.
### 증거 H — 진실성 문구가 제품 가치를 덮은 공개 화면
- 화면과 상태: 사용자가 한남자리 공개 페이지 전체를 보고 `중복되는 내용도 많고 이상하다`며 전면 재설계를 요청한 상태
- 관련 파일: `public/work/hannam-jari/index.html`, `scripts/landing.js`
- 수정 전 계수: 공개 HTML 한 파일에 `예시` 49회, `조건` 31회, `실제` 28회, `확인` 27회, `쇼케이스` 15회, `검토` 12회, `데모` 11회. 7개 상위 섹션 안에 상세 6장이 다시 들어가 사실상 13개 장을 읽게 했다.
- 구조 원인: 공개 고객의 결정에 필요한 조건·후보·현장 확인과, 관리자의 게시 채널·법정 검수·내부 출처 원장이 한 IA에 겹쳤다. 진실성 장치가 주장 가까이에 놓인 것이 아니라 모든 장의 주제가 됐다.
- 로직 원인: 화면에 없는 면적·엘리베이터 조건을 적합 수에 포함하고, 입력하지 않은 입주 충돌을 강제했으며, 분석 실행 전에도 완료 결과가 보였다. 월세는 보증금만으로 예산 적합을 판정했다. 카피 중복과 계산 SSOT 불일치가 함께 있었다.
- 수정 계약: 공개 페이지를 `Hero+조건 도크 / AI 브리핑 / 세 집 비교 / 선택 집 검토 서류철 / 사람 확인` 5개로 압축한다. 내부 게시 게이트와 개발자 용어는 관리자에만 둔다. 전역·분석·후보 부모·이미지·폼·대화상자·푸터마다 오해가 실제 생기는 경계 한 번만 남긴다.
- 기존 규칙과의 조정: `오해 지점 가까이에 진실 라벨`이라는 근본 규칙을 약화하지 않는다. 같은 경계를 자식 값마다 반복하지 않고, 가장 가까운 공통 부모가 의미 범위를 정확히 소유하면 한 번으로 묶는다. 정직함은 경고문 수가 아니라 입력·계산·출처·행동의 일치로 측정한다.
- 회귀 게이트 후보: 공개/관리자 콘텐츠 소유권 표, 반복 핵심어 계수, 분석 전 빈 상태, 보이는 입력 목록과 계산 조건 목록의 동일성, 월세 복합 비용의 미확정 처리, 실제 게시 상수의 공개 DOM 부재를 단언한다.
### 증거 I — 다른 고위험 업종으로 돌린 스킬 전향 테스트
- 화면과 상태: 새 규칙을 한국형 AI 피부 상담 쇼케이스와 임상의 관리자 브리프에 그대로 재적용
- 관련 파일: `packages/skill/SKILL.md`, `references/trustworthy-showcases.md`, `references/preflight.md`, `references/audit-gate.md`
- 첫 RED: 규제 행동을 일반화한 문장과 달리 코드 예제는 모든 행동을 같은 세 불리언으로 열었다. 사용자 자기보고 값을 도메인 사실로 읽을 여지가 있었고, 가장 가까운 공통 부모 라벨과 공개/관리 콘텐츠 소유권이 정본에 없었다.
- 근본 수정: 행동별 `requiredByAction`, 미등록 행동 기본 거절, 합성 자료의 실제 행동 차단을 코드 예제에 반영했다. 사실을 주장 문장 단위로 정의하고, 모델 출력 내용은 추론·버전과 시각만 시스템 상태로 분리했다. 공통 부모/예외 자식/보조기술 연결과 공개 해석/관리 내부 증거의 소유권을 단계·감사·통과 조건에 함께 넣었다.
- 조정 원칙: `오해 지점 가까이에 라벨`을 약화하지 않았다. 의미 경계가 명확한 가장 가까운 부모가 같은 범위를 한 번 소유하게 해 정확도를 높였고, 사용자 행동에 영향을 주는 제한은 공개에서 숨기지 않았다.
- 재검 결과: 독립 전향 테스트 전 항목 PASS, 새 P1·P2 없음. 저장소 공식 skill lint도 경고 0건으로 통과했다.
## 최종 산출물
- [x] 확정 교훈 **21개** — `packages/skill/references/trustworthy-showcases.md` §12
- [x] 교훈별 코드, 캡처, 검사 증거 — 이 원장의 증거 A~I와 `tools/showcase-duo-e2e-report.md`
- [x] `packages/skill`의 근본 규칙과 단계별 참고 문서 수정 — SKILL, preflight, audit-gate, layout, images, reference-method
- [x] 기존 규칙과의 조화 기록 — 사실성·접근성 우선, R2 다른 업종 유지, 예외는 페이지 계약으로 좁게 선언
- [x] skill 테스트, CLI 패키지 검증, 타입 검사 — 경고 0, core 22/22, CLI 9/9, 배포 점검 통과
- [x] 실제 설치본 동기화 — Codex·Claude·Cursor 등 사용자 대상 10곳 모두 v0.10.0 doctor 정상
## 최종 게이트 판정
- 사이트 전체 L0~L6: 17계층 전부 PASS, 배포 금지 항목 0개
- 새 듀오 실제 브라우저 E2E: 90/90 시나리오 · 350/350 단언 · 12/12 캡처
- 메인 디자인 게이트: 178개 PASS
- WebKit: 8개 PASS · 시각 회귀: 30개 PASS · 탐색/키보드: 14개 PASS
- 쇼케이스 갤러리: 21개 카드와 메타 수치 일치, 한남자리·철판클럽 카드/하위 경로 연결 완료

Binary file not shown.

After

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

View file

@ -46,6 +46,10 @@ import twentyozCorp from "../assets/shots/work-twentyoz-corp.png";
import twentyozCorp2 from "../assets/shots/work-twentyoz-corp-2.png"; import twentyozCorp2 from "../assets/shots/work-twentyoz-corp-2.png";
import saebyeokCheonggwa from "../assets/shots/work-saebyeok-cheonggwa.png"; import saebyeokCheonggwa from "../assets/shots/work-saebyeok-cheonggwa.png";
import saebyeokCheonggwa2 from "../assets/shots/work-saebyeok-cheonggwa-2.png"; import saebyeokCheonggwa2 from "../assets/shots/work-saebyeok-cheonggwa-2.png";
import hannamJari from "../assets/shots/work-hannam-jari.png";
import hannamJari2 from "../assets/shots/work-hannam-jari-2.png";
import griddleClub from "../assets/shots/work-griddle-club.png";
import griddleClub2 from "../assets/shots/work-griddle-club-2.png";
export interface WorkItem { export interface WorkItem {
slug: string; slug: string;
@ -392,4 +396,54 @@ export const WORK: WorkItem[] = [
alt: "새벽청과 — 새벽 도매시장 통로 사진 위에 품목·산지·등급·당도·낙찰가가 적힌 오늘의 시세판", alt: "새벽청과 — 새벽 도매시장 통로 사진 위에 품목·산지·등급·당도·낙찰가가 적힌 오늘의 시세판",
size: "sm", size: "sm",
}, },
/* ── 9행: 생활 서비스 쇼케이스 2종 (3 + 3) ── */
{
slug: "hannam-jari",
name: "한남자리",
kind: "맞춤 주거 브리핑 · 운영 화면",
concept: "집보다 기준을 먼저 — 다섯 조건을 AI가 비교하고 공인중개사가 다시 확인하는 개인 브리핑 데스크",
risk: "가상 매물을 실제 광고처럼 꾸미지 않습니다. 입력한 조건, AI의 추론, 중개사가 확인할 사실을 한 화면에서 분리합니다",
preset: "domestic-briefing-desk",
date: "2026-08",
metrics: [
"공개·관리자 2화면",
"조건 5개 단일 기준",
"후보 3건·서류철 5종",
"생성 이미지 범위 표시",
"브라우저 E2E 90/90",
],
href: "/work/hannam-jari/",
subLinks: [
{ label: "매물 검수 관리자 화면", href: "/work/hannam-jari/admin/" },
],
shot: hannamJari,
shotAlt: hannamJari2,
alt: "한남자리 — 밝은 석재색 브리핑 데스크와 서울 주거 외관 이미지, 다섯 가지 맞춤 조건 입력판",
size: "md",
},
{
slug: "griddle-club",
name: "철판클럽",
kind: "도심 버거 매장 · 가맹 안내",
concept: "눌러 굽고 바로 건넨다 — 주문 뒤 조립하는 서울형 철판 버거와 운영 조건을 함께 보여 주는 두 갈래 사이트",
risk: "음식 사진만으로 가맹을 약속하지 않습니다. 상권, 점포, 주방 동선과 아직 정하지 못한 비용을 상담 전에 먼저 펼칩니다",
preset: "seoul-counter-service",
date: "2026-08",
metrics: [
"브랜드·가맹 2화면",
"버거 조합기",
"비용 미정 항목 공개",
"생성 이미지 범위 표시",
"브라우저 E2E 90/90",
],
href: "/work/griddle-club/",
subLinks: [
{ label: "가맹 운영 조건 안내", href: "/work/griddle-club/franchise/" },
],
shot: griddleClub,
shotAlt: griddleClub2,
alt: "철판클럽 — 밝은 종이색 화면과 코발트색 강조, 철판에서 구운 버거를 포장하는 서울형 매장 장면",
size: "md",
},
]; ];

View file

@ -10,8 +10,10 @@ import { WORK } from "../data/work.ts";
카드는 필수 정보만 싣고, 상세 설계와 지표는 네이티브 <dialog> 모달로 열린다. 카드는 필수 정보만 싣고, 상세 설계와 지표는 네이티브 <dialog> 모달로 열린다.
*/ */
const title = "사례 아카이브 · designpaca"; const title = "사례 아카이브 · designpaca";
const showcaseCount = WORK.length;
const showcaseCountKo = showcaseCount === 21 ? "스물한" : `${showcaseCount}`;
const description = const description =
"템플릿을 거부한 자리에 남는 열다섯 개의 고유한 인터페이스. 물성과 타이포그래피, 60fps 인터랙티브 셰이더로 완성된 라이브 쇼케이스를 탐색하세요."; `템플릿을 거부한 자리에 남는 ${showcaseCountKo} 개의 고유한 인터페이스. 물성과 타이포그래피, 실제 상호작용으로 완성된 라이브 쇼케이스를 탐색하세요.`;
const ariaClose = "닫기"; const ariaClose = "닫기";
const ariaDetail = "자세히 보기"; const ariaDetail = "자세히 보기";
const siteLabel = "사이트 보기"; const siteLabel = "사이트 보기";
@ -25,18 +27,18 @@ const siteLabel = "사이트 보기";
<div class="hero-main-col"> <div class="hero-main-col">
<div class="hero-eyebrow-pill reveal"> <div class="hero-eyebrow-pill reveal">
<span class="pulse-dot" aria-hidden="true"></span> <span class="pulse-dot" aria-hidden="true"></span>
<span class="mono">PROVEN ARCHIVE · 15 LIVE SHOWCASES</span> <span class="mono">PROVEN ARCHIVE · {showcaseCount} LIVE SHOWCASES</span>
</div> </div>
<h1 id="sc-h" class="hero-title reveal"> <h1 id="sc-h" class="hero-title reveal">
타협 없는 태도가 빚어낸<br /> 타협 없는 태도가 빚어낸<br />
<span class="accent-text">살아 숨 쉬는 열다섯 개의 결</span> <span class="accent-text">살아 숨 쉬는 {showcaseCountKo} 개의 결</span>
</h1> </h1>
<p class="lead hero-lead reveal"> <p class="lead hero-lead reveal">
AI가 양산하는 흔한 템플릿과 얄팍한 클리셰를 걷어내고, AI가 양산하는 흔한 템플릿과 얄팍한 클리셰를 걷어내고,
<strong>고유한 물성과 타이포그래피, 60fps 인터랙티브 셰이더</strong>로 완성한 실사례 아카이브입니다. <strong>고유한 물성과 타이포그래피, 60fps 인터랙티브 셰이더</strong>로 완성한 실사례 아카이브입니다.
브라우저 위에서 실제로 작동하는 15가지 웹 공간을 직접 경험해 보세요. 브라우저 위에서 실제로 작동하는 {showcaseCount}가지 웹 공간을 직접 경험해 보세요.
</p> </p>
</div> </div>
@ -45,7 +47,7 @@ const siteLabel = "사이트 보기";
<div class="hero-metrics-box"> <div class="hero-metrics-box">
<div class="hero-metrics-grid"> <div class="hero-metrics-grid">
<div class="hero-metric-item"> <div class="hero-metric-item">
<span class="metric-val mono">15</span> <span class="metric-val mono">{showcaseCount}</span>
<span class="metric-label">라이브 쇼케이스</span> <span class="metric-label">라이브 쇼케이스</span>
</div> </div>
<div class="hero-metric-item"> <div class="hero-metric-item">
@ -266,7 +268,7 @@ const siteLabel = "사이트 보기";
designpaca designpaca
</a> </a>
<p class="footer-tagline muted"> <p class="footer-tagline muted">
AI 웹 디자인 파이프라인 · 15개 라이브 쇼케이스 아카이브 AI 웹 디자인 파이프라인 · {showcaseCount}개 라이브 쇼케이스 아카이브
</p> </p>
</div> </div>

View file

@ -0,0 +1,194 @@
// 한남자리·철판클럽 전용 캡처.
//
// 이 스크립트가 덮어쓰는 파일은 아래 여섯 개뿐이다.
// src/assets/shots/work-hannam-jari.png
// src/assets/shots/work-hannam-jari-2.png
// src/assets/shots/work-griddle-club.png
// src/assets/shots/work-griddle-club-2.png
// public/work/hannam-jari/og.png
// public/work/griddle-club/og.png
//
// 공개 자산과 같은 HTTP 경로로 상대 링크를 검증하고, 폰트·이미지가 준비되지 않으면
// 불완전한 화면을 저장하지 않고 실패한다.
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import url from "node:url";
import puppeteer from "puppeteer-core";
const here = path.dirname(url.fileURLToPath(import.meta.url));
const root = path.resolve(here, "..");
const publicRoot = path.join(root, "public");
const shotsRoot = path.join(root, "src", "assets", "shots");
const chrome = [
process.env.CHROME_PATH,
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
].find((candidate) => candidate && fs.existsSync(candidate));
if (!chrome) {
throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH or install Chrome/Chromium.");
}
const MIME = new Map([
[".avif", "image/avif"],
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".ico", "image/x-icon"],
[".jpeg", "image/jpeg"],
[".jpg", "image/jpeg"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".webmanifest", "application/manifest+json; charset=utf-8"],
[".webp", "image/webp"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
const jobs = [
{
label: "한남자리 공개",
route: "/work/hannam-jari/",
output: path.join(shotsRoot, "work-hannam-jari.png"),
},
{
label: "한남자리 관리자",
route: "/work/hannam-jari/admin/",
output: path.join(shotsRoot, "work-hannam-jari-2.png"),
},
{
label: "철판클럽 공개",
route: "/work/griddle-club/",
output: path.join(shotsRoot, "work-griddle-club.png"),
},
{
label: "철판클럽 가맹",
route: "/work/griddle-club/franchise/",
output: path.join(shotsRoot, "work-griddle-club-2.png"),
},
];
const ogJobs = [
{
label: "한남자리 OG",
route: "/work/hannam-jari/",
output: path.join(publicRoot, "work", "hannam-jari", "og.png"),
},
{
label: "철판클럽 OG",
route: "/work/griddle-club/",
output: path.join(publicRoot, "work", "griddle-club", "og.png"),
},
];
function inside(rootDirectory, candidate) {
const relative = path.relative(rootDirectory, candidate);
return relative === "" || (!relative.startsWith(".." + path.sep) && relative !== ".." && !path.isAbsolute(relative));
}
const server = http.createServer((request, response) => {
try {
const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
const candidate = path.resolve(publicRoot, `.${pathname}`);
if (!inside(publicRoot, candidate)) {
response.writeHead(403).end("forbidden");
return;
}
const file = fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()
? path.join(candidate, "index.html")
: candidate;
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
response.writeHead(404).end("not found");
return;
}
response.setHeader("Content-Type", MIME.get(path.extname(file).toLowerCase()) || "application/octet-stream");
response.setHeader("Cache-Control", "no-store");
fs.createReadStream(file).pipe(response);
} catch {
response.writeHead(400).end("bad request");
}
});
await fs.promises.mkdir(shotsRoot, { recursive: true });
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
const origin = `http://127.0.0.1:${address.port}`;
let browser;
async function waitForReady(page) {
await Promise.race([
page.evaluate(() => document.fonts?.ready || Promise.resolve()),
new Promise((_, reject) => setTimeout(() => reject(new Error("Font readiness timed out")), 10000)),
]);
await page.evaluate(async () => {
const images = [...document.images];
for (const image of images) image.loading = "eager";
await Promise.allSettled(images.map((image) => image.decode()));
});
await new Promise((resolve) => setTimeout(resolve, 900));
const broken = await page.evaluate(() => [...document.images]
.filter((image) => image.complete && image.naturalWidth === 0)
.map((image) => image.currentSrc || image.src));
if (broken.length) throw new Error(`Broken images: ${broken.join(", ")}`);
await page.addStyleTag({
content: "*{animation:none!important;transition:none!important;caret-color:transparent!important}html{scroll-behavior:auto!important}::-webkit-scrollbar{display:none!important}",
});
await page.evaluate(() => window.scrollTo(0, 0));
}
async function capture(job, viewport) {
const page = await browser.newPage();
const failures = [];
page.on("pageerror", (error) => failures.push(`pageerror ${error.message}`));
page.on("response", (response) => {
if (response.url().startsWith(origin) && response.status() >= 400) {
failures.push(`${response.status()} ${response.url()}`);
}
});
try {
await page.setCacheEnabled(false);
await page.setViewport(viewport);
const response = await page.goto(origin + job.route, { waitUntil: "networkidle0", timeout: 30000 });
if (!response?.ok()) throw new Error(`${job.route} returned ${response?.status() ?? "no response"}`);
await waitForReady(page);
if (failures.length) throw new Error(failures.join(" · "));
await fs.promises.mkdir(path.dirname(job.output), { recursive: true });
await page.screenshot({ path: job.output, type: "png", fullPage: false });
const size = Math.round((await fs.promises.stat(job.output)).size / 1024);
console.log(`CAPTURE ${job.label} → ${path.relative(root, job.output)} (${viewport.width}×${viewport.height}, ${size}KB)`);
} finally {
await page.close();
}
}
try {
console.log("OVERWRITE SCOPE");
for (const job of [...jobs, ...ogJobs]) console.log(`- ${path.relative(root, job.output)}`);
browser = await puppeteer.launch({
executablePath: chrome,
headless: "new",
args: [
"--no-sandbox",
"--use-angle=swiftshader",
"--enable-unsafe-swiftshader",
"--force-device-scale-factor=1",
],
});
for (const job of jobs) await capture(job, { width: 1440, height: 900, deviceScaleFactor: 1 });
for (const job of ogJobs) await capture(job, { width: 1200, height: 630, deviceScaleFactor: 1 });
} finally {
if (browser) await browser.close();
await new Promise((resolve) => server.close(resolve));
}
console.log("쇼케이스 듀오 카드 4장과 OG 2장 캡처 완료");

View file

@ -26,6 +26,7 @@ const DEFAULT_CFG = {
viewAttribute: "data-view", viewAttribute: "data-view",
views: ["main"], views: ["main"],
h1: "h1", h1: "h1",
h1MaxLines: 1,
brand: ".brand", brand: ".brand",
}, },
], ],
@ -103,49 +104,105 @@ const cssFilesOf = (p) => {
return out; return out;
}; };
// 페이지가 공유 CSS·동적 JS·지역 토큰을 함께 쓰면 HTML 한 장과 파일명 규칙만으로
// 소유권을 추측할 수 없다. staticContract 는 검사 자체를 끄지 않고, 어떤 CSS를
// 어떤 문서 코퍼스로 대조하며 어느 파일이 토큰 권위인지 명시한다.
const contractFilesOf = (p, key, fallback) => {
const specs = p.staticContract?.[key];
if (!Array.isArray(specs)) return fallback;
return specs.map((file) => path.resolve(process.cwd(), file));
};
const scriptFilesOf = (p) => {
const out = [];
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<script[^>]+src=["']([^"']+)["'][^>]*>/g)) {
const src = tag[1];
if (/^(?:https?:|data:)/.test(src)) continue;
const file = path.resolve(pageDir(p), src.split("?")[0]);
if (fs.existsSync(file) && !out.includes(file)) out.push(file);
}
return out;
};
const selectorDocumentsOf = (p) => contractFilesOf(
p,
"selectorDocuments",
[path.resolve(process.cwd(), p.path), ...scriptFilesOf(p)],
);
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
if (CFG.checks.deadCss) { if (CFG.checks.deadCss) {
const ignore = CFG.deadCssIgnore || []; const ignore = CFG.deadCssIgnore || [];
for (const p of CFG.pages) { for (const p of CFG.pages) {
let html = ""; const files = contractFilesOf(p, "selectorFiles", cssFilesOf(p));
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } const documents = selectorDocumentsOf(p);
catch { skip(`${p.name} 죽은 선택자`, "HTML 읽기 실패"); continue; } const missing = missingFiles([...files, ...documents]);
const files = cssFilesOf(p); if (missing.length) {
fail(`${p.name} 죽은 선택자 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; } if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
const corpus = documents.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const dead = new Set(); const dead = new Set();
for (const f of files) { for (const f of files) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) { for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
const cls = m[1]; const cls = m[1];
if (ignore.some((pfx) => cls.startsWith(pfx))) continue; if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
if (!html.includes(cls)) dead.add(cls); if (!corpus.includes(cls)) dead.add(cls);
} }
} }
dead.size dead.size
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체") ? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS 전 클래스 사용 중(무시 ${ignore.length}접두사)`); : pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS × ${documents.length}개 HTML·JS 문서(무시 ${ignore.length}접두사)`);
} }
} else skip("죽은 선택자", "checks.deadCss=false"); } else skip("죽은 선택자", "checks.deadCss=false");
if (CFG.checks.cssHygiene) { if (CFG.checks.cssHygiene) {
for (const p of CFG.pages) { for (const p of CFG.pages) {
const files = cssFilesOf(p); const files = contractFilesOf(p, "hygieneFiles", cssFilesOf(p));
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; } if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
// 토큰 파일 = 파일명에 tokens 가 있는 것. 이 안의 값이 프로젝트의 단일 원본이다 const inferredTokens = cssFilesOf(p).filter((f) => /tokens/i.test(path.basename(f)));
const tokenFiles = files.filter((f) => /tokens/i.test(path.basename(f))); const tokenFiles = contractFilesOf(p, "tokenFiles", inferredTokens);
const others = files.filter((f) => !tokenFiles.includes(f)); const missing = missingFiles([...files, ...tokenFiles]);
const hexes = []; const radii = new Set(); if (missing.length) {
for (const f of others) { fail(`${p.name} 토큰 위생 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
const tokenNames = new Set();
for (const f of new Set([...tokenFiles, ...files])) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) hexes.push(`${path.basename(f)}:${m[0]}`); for (const m of src.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) tokenNames.add(m[1]);
for (const m of src.matchAll(/(?<![\w.-])rgba?\(/g)) hexes.push(`${path.basename(f)}:${m[0]}`); }
const colors = []; const colorKinds = new Set(); const radii = new Set();
for (const f of files) {
// 어느 파일에 있든 custom property 선언은 지역 토큰이다. 선언값을 다시
// 하드코딩으로 세면 :root 토큰을 만든 페이지가 오히려 실패한다.
const src = fs.readFileSync(f, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/--[A-Za-z0-9_-]+\s*:\s*([^;{}]+);/g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|lab|lch)\([^)]*\)/g)) {
colors.push(`${path.basename(f)}:${m[0]}`);
colorKinds.add(m[0].toLowerCase().replace(/\s+/g, " "));
}
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim()); for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
} }
const budgets = p.staticContract?.budgets || {};
const hasColorBudget = Number.isFinite(budgets.colorLiterals);
const hasRadiusBudget = Number.isFinite(budgets.radiusLiterals);
const colorLimit = hasColorBudget ? budgets.colorLiterals : (tokenFiles.length ? 0 : null);
const radiusLimit = hasRadiusBudget ? budgets.radiusLiterals : null;
const bad = []; const bad = [];
if (tokenFiles.length && hexes.length) bad.push(`토큰 밖 색 리터럴 ${hexes.length}건(토큰 파일이 있는데 상수색)`); if (colorLimit !== null && colors.length > colorLimit) bad.push(`색 리터럴 ${colors.length}건 > 계약 ${colorLimit}건`);
if (radii.size >= CFG.thresholds.radiusLiterals) bad.push(`radius 리터럴 ${radii.size}종([${[...radii].join(", ")}])`); if (radiusLimit !== null ? radii.size > radiusLimit : radii.size >= CFG.thresholds.radiusLiterals) {
bad.push(`radius 리터럴 ${radii.size}종 > 계약 ${radiusLimit ?? `<${CFG.thresholds.radiusLiterals}`}종([${[...radii].join(", ")}])`);
}
bad.length bad.length
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (hexes.length ? " — 예: " + hexes.slice(0, 4).join(" ") : "")) ? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (colors.length ? " — 예: " + colors.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${hexes.length}·radius ${radii.size} 리터럴(토큰 ${tokenFiles.length}파일)`); : pass(`${p.name} 토큰 위생`, `색 ${colors.length}${colorLimit === null ? "" : `/${colorLimit}`}건(${colorKinds.size}종)·radius ${radii.size}${radiusLimit === null ? "" : `/${radiusLimit}`}종·토큰 ${tokenFiles.length}파일/${tokenNames.size}개`);
} }
} else skip("토큰 위생", "checks.cssHygiene=false"); } else skip("토큰 위생", "checks.cssHygiene=false");
@ -240,7 +297,7 @@ for (const p of CFG.pages) {
if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg)) if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg))
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`); m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
} }
if (meta.h1Count > 1) m.push(`h1 ${meta.h1Count}개(유일해야)`); if (meta.h1Count !== 1) m.push(`h1 ${meta.h1Count}개(정확히 하나여야)`);
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`); if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패"); if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증 // 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증
@ -269,10 +326,14 @@ for (const p of CFG.pages) {
} }
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/")); overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
// h1 1줄 // h1 줄 수 — 앱 화면은 기본 1줄, 마케팅 헤드라인은 페이지별로 최대 2줄까지 명시할 수 있다.
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`); const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음")); const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
const h1Rule = h1MaxLines === 1 ? "h1 1줄" : `h1 최대 ${h1MaxLines}줄`;
h1l >= 1 && h1l <= h1MaxLines
? pass(`${p.name}/${v} ${h1Rule}`)
: (h1l > h1MaxLines ? fail(`${p.name}/${v} h1 ${h1l}줄(최대 ${h1MaxLines}줄)`) : skip(`${p.name}/${v} h1`, "요소 없음"));
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상 // 세로 쌓임(수축) — 짧은 라벨 3줄 이상
if (CFG.checks.stack) { if (CFG.checks.stack) {
@ -372,6 +433,7 @@ for (const p of CFG.pages) {
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ── // ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
if (CFG.checks.scaleMatrix) { if (CFG.checks.scaleMatrix) {
const bad = []; const bad = [];
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
for (const w of [375, 390]) { for (const w of [375, 390]) {
for (const s of CFG.fontScales) { for (const s of CFG.fontScales) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true }); await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
@ -379,10 +441,10 @@ for (const p of CFG.pages) {
await page.evaluate(UTILS); await page.evaluate(UTILS);
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` }); await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`); const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
if (l > 1) bad.push(`${w}@${s}x:${l}줄`); if (l > h1MaxLines) bad.push(`${w}@${s}x:${l}줄(최대 ${h1MaxLines}줄)`);
} }
} }
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`); bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `최대 ${h1MaxLines}줄 · scales ${CFG.fontScales.join("/")}`);
// 브랜드 1줄 // 브랜드 1줄
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" }); await page.goto(href, { waitUntil: "networkidle0" });

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,23 @@
# 검증 리포트 — 2026-08-26 09:13 # 검증 리포트 — 2026-08-29 12:16
게이트: **전 계층 통과 — 배포 가능** · 총 195.3초 게이트: **전 계층 통과 — 배포 가능** · 총 416.9초
| 계층 | 결과 | 항목 | 소요 | | 계층 | 결과 | 항목 | 소요 |
|---|---|---|---| |---|---|---|---|
| L0-자산/트웬티온스 3D | PASS | PASS — | 0.3s | | L0-자산/트웬티온스 3D | PASS | PASS — | 0.8s |
| L0-한자 금지/배포 텍스트 | PASS | PASS 1 | 0.1s | | L0-한자 금지/배포 텍스트 | PASS | PASS 1 | 0.2s |
| L0-정적/stylelint | PASS | PASS — | 2.4s | | L0-정적/stylelint | PASS | PASS — | 14.8s |
| L0-정식/html-validate | PASS | PASS — | 1.9s | | L0-정식/html-validate | PASS | PASS — | 9.0s |
| L0-무결성/변수·경로·조판 | PASS | PASS — | 0.1s | | L0-무결성/변수·경로·조판 | PASS | PASS — | 0.9s |
| L1-단위/계산로직 | PASS | PASS 40 | 0.1s | | L1-단위/계산로직 | PASS | PASS 40 | 0.8s |
| L2-기능/감사 | PASS | PASS 72 | 10.0s | | L2-기능/감사 | PASS | PASS 72 | 16.0s |
| L2-불변식/렌더 | PASS | PASS 10 | 36.9s | | L2-불변식/렌더 | PASS | PASS 10 | 23.8s |
| L2-디자인 게이트/메인 | PASS | PASS 130 | 37.9s | | L2-디자인 게이트/메인 | PASS | PASS 178 | 70.9s |
| L2-디자인 게이트/제품 | PASS | PASS 15 | 7.2s | | L2-디자인 게이트/제품 | PASS | PASS 15 | 8.9s |
| L3-시각회귀/diff | PASS | PASS 30 | 11.8s | | L3-시각회귀/diff | PASS | PASS 30 | 17.9s |
| L4-WebKit | PASS | PASS 8 | 3.8s | | L4-WebKit | PASS | PASS 8 | 9.0s |
| L5-탐색/키보드 | PASS | PASS 14 | 6.3s | | L5-탐색/키보드 | PASS | PASS 14 | 10.1s |
| L6-시나리오/E2E | PASS | PASS — | 43.6s | | L6-시나리오/E2E | PASS | PASS — | 50.5s |
| L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 10.9s | | L6-시나리오/최근 쇼케이스 | PASS | PASS 115 | 29.3s |
| L6-시나리오/트웬티온스 | PASS | PASS 148 | 22.0s | | L6-시나리오/트웬티온스 | PASS | PASS 148 | 29.9s |
| L6-시나리오/쇼케이스 듀오 | PASS | PASS — | 124.1s |

View file

@ -21,11 +21,11 @@ const steps = [
}, },
{ {
layer: "L0-정적/stylelint", layer: "L0-정적/stylelint",
cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }), cmd: () => spawnSync(npx, ["stylelint", "public/work/**/*.css"], { cwd: root, encoding: "utf8", shell: true }),
}, },
{ {
layer: "L0-정식/html-validate", layer: "L0-정식/html-validate",
cmd: () => spawnSync(npx, ["html-validate", "public/work/*/index.html", "public/work/*/*/index.html", "public/work/twentyoz-corp/products.html"], { cwd: root, encoding: "utf8", shell: true }), cmd: () => spawnSync(npx, ["html-validate", "public/work/**/*.html"], { cwd: root, encoding: "utf8", shell: true }),
}, },
{ {
// 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도 // 미정의 CSS 변수·끊긴 상대경로·한글 조판 누락·슬롭 지문은 stylelint 도 html-validate 도
@ -44,6 +44,7 @@ const steps = [
{ layer: "L6-시나리오/E2E", cmd: () => spawnSync(process.execPath, ["tools/e2e.mjs"], { cwd: root, encoding: "utf8" }) }, { layer: "L6-시나리오/E2E", cmd: () => spawnSync(process.execPath, ["tools/e2e.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L6-시나리오/최근 쇼케이스", cmd: () => spawnSync(process.execPath, ["tools/recent-showcases-e2e.mjs"], { cwd: root, encoding: "utf8" }) }, { layer: "L6-시나리오/최근 쇼케이스", cmd: () => spawnSync(process.execPath, ["tools/recent-showcases-e2e.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L6-시나리오/트웬티온스", cmd: () => spawnSync(process.execPath, ["tools/twentyoz-e2e.mjs"], { cwd: root, encoding: "utf8" }) }, { layer: "L6-시나리오/트웬티온스", cmd: () => spawnSync(process.execPath, ["tools/twentyoz-e2e.mjs"], { cwd: root, encoding: "utf8" }) },
{ layer: "L6-시나리오/쇼케이스 듀오", cmd: () => spawnSync(process.execPath, ["tools/showcase-duo-e2e.mjs", "--root=public"], { cwd: root, encoding: "utf8" }) },
]; ];
const report = []; const report = [];

View file

@ -1,5 +1,11 @@
# designpaca # designpaca
## 0.10.0
### Minor Changes
- Bundle the upgraded designpaca skill with target-market reference boundaries, trustworthy fictional and AI showcase contracts, state-based visual E2E guidance, and page-scoped static ownership gates.
## 0.9.2 ## 0.9.2
### Patch Changes ### Patch Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "designpaca", "name": "designpaca",
"version": "0.9.2", "version": "0.10.0",
"description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다", "description": "웹 디자인 파이프라인 스킬 — Claude Code · Codex · Cursor 에 한 줄로 설치한다",
"keywords": [ "keywords": [
"design", "design",

View file

@ -1,5 +1,7 @@
# @designpaca/core # @designpaca/core
## 0.10.0
## 0.9.2 ## 0.9.2
### Patch Changes ### Patch Changes

View file

@ -1,6 +1,6 @@
{ {
"name": "@designpaca/core", "name": "@designpaca/core",
"version": "0.9.2", "version": "0.10.0",
"private": true, "private": true,
"description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지", "description": "designpaca 설치 엔진 — 타깃 어댑터, 매니페스트, 드리프트 감지",
"type": "module", "type": "module",

View file

@ -1,5 +1,18 @@
# CHANGELOG # CHANGELOG
## 0.10.0
### Minor Changes
- Add target-market reference boundaries; trustworthy fictional/AI showcase contracts with provenance, sensitive-data, generated-evidence, and fail-closed regulated-action rules; state-based visual E2E guidance; human-gate layout invariants; page-scoped h1 line-count gates; and page-scoped static CSS/JS ownership with non-regression hygiene budgets.
- 목표 시장을 R1/R2/R3의 경계 조건으로 잠그고, 같은 문화권의 다른 업종 R2를 우선해 서구 레퍼런스의 무의식적 전이를 막는다.
- `trustworthy-showcases.md`를 추가해 가상·예시·AI·규제 쇼케이스의 진실 상태·출처 계층, 표면 다이얼, 모바일 이미지 바이블, 고위험 데이터 경계, 규제 행동의 실패 폐쇄, 상태별 시각 E2E를 표준화한다.
- 인간 시각 게이트에서 발견한 동일 행 액션 중심, 의미별 섹션 토폴로지, 조건부 데스크톱 패널의 트랙 회수를 레이아웃·E2E 계약으로 승격한다.
- 같은 진실 범위의 공통 부모 라벨, 공개 해석/관리 내부 증거 소유권, 보이는 입력과 판정 의존성 SSOT를 추가해 경고 반복과 맞춤 분석 모순을 막는다.
- 범용 디자인 게이트에 페이지별 `h1MaxLines` 계약과 h1 정확히 1개 검사를 추가한다. 기본 1줄은 유지하며 마케팅 페이지의 의도적 2줄만 좁게 선언할 수 있다.
- 범용 디자인 게이트에 페이지별 `staticContract`를 추가해 selector CSS/HTML·JS 문서 코퍼스, 토큰 권위 파일, 색·radius 비회귀 예산을 명시하고 공유 CSS·동적 클래스·지역 토큰 오탐을 막는다.
## 0.9.2 ## 0.9.2
### Patch Changes ### Patch Changes

View file

@ -15,6 +15,8 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬.
**쓰지 않는다**: 대시보드의 데이터 밀도 설계(→ 데이터 시각화 스킬) · 순수 백엔드 · 이미 확립된 디자인 시스템을 따라야만 하는 작업(그 시스템을 따르는 게 맞다) · "일단 돌아가게만" 요청 **쓰지 않는다**: 대시보드의 데이터 밀도 설계(→ 데이터 시각화 스킬) · 순수 백엔드 · 이미 확립된 디자인 시스템을 따라야만 하는 작업(그 시스템을 따르는 게 맞다) · "일단 돌아가게만" 요청
관리자 화면을 함께 만들 때 designpaca가 맡는 것은 **브랜드 표면·정보 위계·상호작용 상태·진실성/규제 게이트**다. 지표 정의, 차트 해석, 대규모 표의 열 우선순위와 밀도 최적화는 데이터 시각화·도메인 스킬의 근거를 추가로 받아야 한다. `관리자 밀도 3` 다이얼은 이 경계를 없애는 허가가 아니다.
## 우선순위 ## 우선순위
충돌하면 위가 이긴다. **이 스킬의 기본값은 맨 아래다.** 충돌하면 위가 이긴다. **이 스킬의 기본값은 맨 아래다.**
@ -200,6 +202,8 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬.
R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된다. SaaS 구조 + SaaS 톤 = 또 하나의 SaaS 슬롭이다. R1과 R2를 같은 업종에서 고르면 결과는 그 업종의 평균이 된다. SaaS 구조 + SaaS 톤 = 또 하나의 SaaS 슬롭이다.
**목표 시장이 다른 레퍼런스는 자동으로 중립이 되지 않는다.** 단위·용어·문의 방식·사진 조도·신뢰 표지는 문화권의 일부다. 시장 적합성이 중요한 브리프는 R1을 같은 목표 시장에서 우선 찾고, R2는 **같은 문화권의 다른 업종**을 우선한다. 다른 문화권의 R3는 구현 기법 하나만 가져온다. 상세 계약은 `reference-method.md`와, 가상·AI·규제 쇼케이스면 `trustworthy-showcases.md`를 따른다.
- 어디서 찾는가 → `references/galleries.md` (브리프별 라우팅 표) - 어디서 찾는가 → `references/galleries.md` (브리프별 라우팅 표)
- 어떻게 뜯어보는가 → `references/reference-method.md` (6축 해체 프레임워크, WebFetch 템플릿) - 어떻게 뜯어보는가 → `references/reference-method.md` (6축 해체 프레임워크, WebFetch 템플릿)
@ -222,6 +226,7 @@ headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다
- **한 문장 컨셉** — 이 사이트가 주는 인상을 한 문장으로. ("고급 잡지의 여백", "계기판처럼 정확한", "밤의 스튜디오") - **한 문장 컨셉** — 이 사이트가 주는 인상을 한 문장으로. ("고급 잡지의 여백", "계기판처럼 정확한", "밤의 스튜디오")
- **미학 프리셋** — 아래에서 고르거나, 브리프가 요구하면 새로 정의한다 - **미학 프리셋** — 아래에서 고르거나, 브리프가 요구하면 새로 정의한다
- **감수할 리스크 하나** — 정당화할 수 있는 과감한 선택 하나. 없으면 그 디자인은 안전하고 잊힌다 - **감수할 리스크 하나** — 정당화할 수 있는 과감한 선택 하나. 없으면 그 디자인은 안전하고 잊힌다
- **표면 다이얼** — 랜딩·관리자·가맹처럼 과업이 다른 화면이 함께 있으면 각 표면의 표현성·정보 밀도·모션·증거 노출을 0~3으로 따로 적는다. 공통 토큰은 공유하되 과업 밀도까지 같게 만들지 않는다
| 프리셋 | 한 줄 | 언제 | | 프리셋 | 한 줄 | 언제 |
|---|---|---| |---|---|---|
@ -263,6 +268,7 @@ headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다
자리를 만들기 전에 **무엇을 실을지** 정한다. 나중에 채우면 비율이 안 맞아 레이아웃을 다시 짠다. 자리를 만들기 전에 **무엇을 실을지** 정한다. 나중에 채우면 비율이 안 맞아 레이아웃을 다시 짠다.
사용자가 준 사진이 있으면 무조건 그것이고, 없으면 **codex 가 설치돼 있는지 확인해 생성**한다. 사용자가 준 사진이 있으면 무조건 그것이고, 없으면 **codex 가 설치돼 있는지 확인해 생성**한다.
생성했으면 `design.md` 에 생성물이라고 적는다 — 나중에 실제 사진으로 바꿀 사람이 알아야 한다. 생성했으면 `design.md` 에 생성물이라고 적는다 — 나중에 실제 사진으로 바꿀 사람이 알아야 한다.
히어로·증거 컷·모바일 히어로를 각각 슬롯으로 적은 **이미지 바이블**을 먼저 만든다. 데스크톱 크롭에서 핵심 피사체가 폴드 아래로 밀리면 모바일은 별도 원본을 만든다.
싣기 전에 반드시 줄인다(실측: 10.1MB → 603KB). 싣기 전에 반드시 줄인다(실측: 10.1MB → 603KB).
**4-1. 레이아웃과 타이포그래피** → `references/layout.md` (+ 폰트 적용은 `references/typography.md` §5, §6) **4-1. 레이아웃과 타이포그래피** → `references/layout.md` (+ 폰트 적용은 `references/typography.md` §5, §6)
@ -283,6 +289,9 @@ headed 브라우저가 있으면 스크린샷과 실측값을 바로 받는다
- **키보드 단축키** — 콘솔의 손가락 문법. 숫자키 뷰 전환, `/` 검색 포커스. `kbd` 물리 키 칩으로 안내하고, 입력 요소에 포커스가 있을 때는 무력화한다 - **키보드 단축키** — 콘솔의 손가락 문법. 숫자키 뷰 전환, `/` 검색 포커스. `kbd` 물리 키 칩으로 안내하고, 입력 요소에 포커스가 있을 때는 무력화한다
- **본연의 도메인 동작** — 학적부는 기록, 시간표는 격자, 주문은 원장. 그 도메인이 종이에서 하던 핵심 동작 하나가 빠져 있으면 그게 곧 '데모 티'다 - **본연의 도메인 동작** — 학적부는 기록, 시간표는 격자, 주문은 원장. 그 도메인이 종이에서 하던 핵심 동작 하나가 빠져 있으면 그게 곧 '데모 티'다
**4-6. 진실 계약과 표면 분리** → `references/trustworthy-showcases.md` (가상 브랜드·예시 데이터·AI·규제 주제·고객/관리자 복수 경로일 때만)
사실·예시·추론·미정을 구현 전에 나누고, 각 값의 출처를 사용자 진술·검증 출처·시스템 상태·합성 픽스처 중 하나로 추적한다. 오해가 생기는 주장·가격·행동 가까이에 라벨을 두되 같은 진실 범위는 가장 가까운 명확한 공통 부모가 한 번 소유한다. 보이는 입력과 판정 의존성은 같은 SSOT에서 만들고, 성공 행동이 다른 청중은 URL과 IA를 나눈다. AI는 출처가 붙은 입력·검증 사실·추론·불확실·수정·사람 검토를 함께 보여 주고, 진단·추천·예약·게시·내보내기처럼 위험한 규제 행동은 행동별 검증이 없으면 실패 폐쇄형으로 막는다.
**실험 경로** → `references/experimental-canvas.md` **실험 경로** → `references/experimental-canvas.md`
HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는다. 기본값은 쓰지 않는 것이다. HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는다. 기본값은 쓰지 않는 것이다.
@ -304,8 +313,9 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
3. **이펙트 전부 끄기** — CSS 필터·WebGL·애니메이션을 끈 상태에서 페이지가 여전히 읽히는가 3. **이펙트 전부 끄기** — CSS 필터·WebGL·애니메이션을 끈 상태에서 페이지가 여전히 읽히는가
4. **게이트 실행(폐쇄 루프)** — `tools/design-gate.mjs --init` 으로 설정을 만들고 돌린다. 오버플로·h1·수축·대비·SEO/meta·죽은 선택자·토큰 위생·스케일×폭까지 전 뷰 × 전 폭. L1(단위)·L5(탐색)·L6(시나리오 E2E)는 프로젝트 `tools/` 에 보강해 `npm run verify` 체인으로. **게이트 통과 없이 완료 선언 금지.** 4. **게이트 실행(폐쇄 루프)** — `tools/design-gate.mjs --init` 으로 설정을 만들고 돌린다. 오버플로·h1·수축·대비·SEO/meta·죽은 선택자·토큰 위생·스케일×폭까지 전 뷰 × 전 폭. L1(단위)·L5(탐색)·L6(시나리오 E2E)는 프로젝트 `tools/` 에 보강해 `npm run verify` 체인으로. **게이트 통과 없이 완료 선언 금지.**
5. **상태 완결성 스윕** — 입력·파괴·열림이 있는 화면은 실제로 조작해 본다: 수치 입력에 범위 밖 값을 넣고, 파괴적 행동을 되돌려보고, 열린 메뉴를 Esc 로 닫는다. 기준은 `references/preflight.md` §4-1. 통과 못 하면 4단계로 5. **상태 완결성 스윕** — 입력·파괴·열림이 있는 화면은 실제로 조작해 본다: 수치 입력에 범위 밖 값을 넣고, 파괴적 행동을 되돌려보고, 열린 메뉴를 Esc 로 닫는다. 기준은 `references/preflight.md` §4-1. 통과 못 하면 4단계로
6. **상태별 시각 E2E** — URL별 390·1440 기본 화면과 결과·오류·모달 같은 핵심 상태를 캡처한다. 하니스는 개수·규격·0바이트·중복 해시를 단언하고, 캡처를 실제로 열어 목표 시장 적합성·시선 위계·이미지 크롭·반복을 눈으로 판정한다. 구조 PASS를 시각 PASS로 바꾸어 말하지 않는다
> 게이트가 잡은 실제 사고 목록과 6계층 설계 근거는 `references/audit-gate.md` 다. 자기 검사 스크립트를 매번 새로 쓰지 마라 — 이 게이트를 심고 확장해라. > 게이트가 잡은 실제 사고 목록과 7계층 설계 근거는 `references/audit-gate.md` 다. 자기 검사 스크립트를 매번 새로 쓰지 마라 — 이 게이트를 심고 확장해라.
#### 하드 게이트 — 전부 "아니오"여야 한다 #### 하드 게이트 — 전부 "아니오"여야 한다
@ -376,6 +386,7 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
| `references/tokens.md` | 3단계 — 토큰을 정할 때 | | `references/tokens.md` | 3단계 — 토큰을 정할 때 |
| `references/typography.md` | 3단계 — 폰트를 고를 때 / 4-1 — 적용할 때 | | `references/typography.md` | 3단계 — 폰트를 고를 때 / 4-1 — 적용할 때 |
| `references/images.md` | **4-0 — 사진을 구하고 최적화할 때** | | `references/images.md` | **4-0 — 사진을 구하고 최적화할 때** |
| `references/trustworthy-showcases.md` | **1·2·4·5단계 — 가상 브랜드·예시 데이터·AI·규제 주제·고객/관리자 복수 경로** |
| `references/layout.md` | 4-1 — 그리드와 타이포 | | `references/layout.md` | 4-1 — 그리드와 타이포 |
| `references/svg-filters.md` | 4-2 — 재질을 만들 때 | | `references/svg-filters.md` | 4-2 — 재질을 만들 때 |
| `references/three.md` | 4-3 — 입체가 필요할 때 | | `references/three.md` | 4-3 — 입체가 필요할 때 |
@ -385,7 +396,7 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는
| `references/preflight.md` | 5단계 — 감사 | | `references/preflight.md` | 5단계 — 감사 |
| `references/antipatterns.md` | 3·5단계 — 한글 조판 / 슬롭 검출 | | `references/antipatterns.md` | 3·5단계 — 한글 조판 / 슬롭 검출 |
| `references/audit-gate.md` | **5단계 — 게이트(폐쇄 루프) 설계·설치·하니스 규칙** | | `references/audit-gate.md` | **5단계 — 게이트(폐쇄 루프) 설계·설치·하니스 규칙** |
| `tools/design-gate.mjs` | **5단계 — 범용 게이트 실행기(SEO/meta·대비·수축·시각·WebKit 옵션)** | | `tools/design-gate.mjs` | **5단계 — 범용 게이트 실행기(SEO/meta·대비·수축·페이지별 h1 최대 줄 계약·시각·WebKit 옵션)** |
## 작업 중 지켜야 할 것 ## 작업 중 지켜야 할 것

View file

@ -1,6 +1,6 @@
{ {
"name": "@designpaca/skill", "name": "@designpaca/skill",
"version": "0.9.2", "version": "0.10.0",
"private": true, "private": true,
"description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서", "description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서",
"scripts": { "scripts": {

View file

@ -32,6 +32,9 @@
| 탭 전환 스크롤 잔류 — 짧은 뷰에서 클램프되어 헤더 반쯤 잘림, 뷰마다 크롬 위치 제각각 | 뷰 전환 `scrollTo(0,0)` 단언(M5) + mobile-app-ux.md | | 탭 전환 스크롤 잔류 — 짧은 뷰에서 클램프되어 헤더 반쯤 잘림, 뷰마다 크롬 위치 제각각 | 뷰 전환 `scrollTo(0,0)` 단언(M5) + mobile-app-ux.md |
| 크럼을 헤더로 내보내 빈약한 헤더 — 계측 전부 통과했지만 사용자에게 '깨져 보인다' | 헤더 sticky·브랜드+액션 구조 단언 + **시각 무게 평가(눈)** — mobile-app-ux.md | | 크럼을 헤더로 내보내 빈약한 헤더 — 계측 전부 통과했지만 사용자에게 '깨져 보인다' | 헤더 sticky·브랜드+액션 구조 단언 + **시각 무게 평가(눈)** — mobile-app-ux.md |
| 왼쪽 인셋 리듬 불일치(제목 16 vs 앱바·카드 24) — 오버플로 검사는 오른쪽만 보므로 통과 | M6 왼쪽 인셋 스위트(텍스트 시작점 ≥12px·같은 축 ±10px) + preflight §0-E | | 왼쪽 인셋 리듬 불일치(제목 16 vs 앱바·카드 24) — 오버플로 검사는 오른쪽만 보므로 통과 | M6 왼쪽 인셋 스위트(텍스트 시작점 ≥12px·같은 축 ±10px) + preflight §0-E |
| 모바일에서 절대 배치 지도 자식의 부모를 `position: static`으로 바꿈 — offsetParent가 BODY가 되어 격자·핀이 히어로 전체를 덮음. 기하 게이트 8/8 통과 | **상태별 실제 캡처를 열어 보는 시각 E2E** + absolute/fixed 자식의 containing block 단언(하니스 규칙 19) |
| 새 관리자·가맹 서브페이지가 생겼지만 `public/work/*/index.html`만 검사 — 새 HTML 오류 8건이 검증 밖 | 정적 입력을 `public/work/**/*.html`·`**/*.css`로 재귀화 + 검사 대상 파일 수 단언(하니스 규칙 20) |
| 마케팅 h1의 의도적 2줄 때문에 범용 1줄 규칙을 꺼 버릴 위험 | 기본 1줄 유지 + 페이지별 `h1MaxLines` 계약. 375·390 × 글꼴 확대 매트릭스도 같은 상한 적용 |
## 7계층 ## 7계층
@ -42,7 +45,7 @@
| L0 정적 | stylelint + html-validate | 문법·구문 위반을 브라우저 켜기 전에 | | L0 정적 | stylelint + html-validate | 문법·구문 위반을 브라우저 켜기 전에 |
| L1 단위 | 순수 계산 로직 추출(calc.js) + 경계값 테스트 | 프로젝트별 작성 — 0명/만석/초과/빈배열. **입력 검증·신청 가능 같은 '판정'(가능/불가+사유)도 여기 둔다** — UI 는 판정을 문장으로 번역만 | | L1 단위 | 순수 계산 로직 추출(calc.js) + 경계값 테스트 | 프로젝트별 작성 — 0명/만석/초과/빈배열. **입력 검증·신청 가능 같은 '판정'(가능/불가+사유)도 여기 둔다** — UI 는 판정을 문장으로 번역만 |
| L2 불변식 | `design-gate.mjs` — 오버플로·h1·수축·대비·SEO/meta·죽은 선택자·토큰 위생·theme-color 정합 | 전 뷰 × 전 폭 | | L2 불변식 | `design-gate.mjs` — 오버플로·h1·수축·대비·SEO/meta·죽은 선택자·토큰 위생·theme-color 정합 | 전 뷰 × 전 폭 |
| L3 시각 회귀 | 기준 화면 pixelmatch diff (≥0.1% 실패) | 갱신은 검증된 배포 후 `--update-baseline` | | L3 시각 회귀 | 기준 화면 pixelmatch diff (≥0.1% 실패) + 핵심 상태 캡처 | 갱신은 검증된 배포 후 `--update-baseline`. 캡처 존재와 사람의 시각 판정은 별개다 |
| L4 WebKit | Playwright webkit | 사파리 엔진 렌더 차이 | | L4 WebKit | Playwright webkit | 사파리 엔진 렌더 차이 |
| L5 탐색 | 키보드 완전 통과 + 사용자 여정 차터 | 프로젝트별 작성 — 마우스 금지 | | L5 탐색 | 키보드 완전 통과 + 사용자 여정 차터 | 프로젝트별 작성 — 마우스 금지 |
| L6 시나리오 E2E | 실사용자 여정 재현 — 페르소나의 하루·장기 과업 포함 | 프로젝트별 작성. **기대값 단언 필수**(하니스 규칙 8), 백 키·시트·양방향 흐름은 `mobile-app-ux.md` 패턴 기준. 실측: RED 1,742 단언 중 318 실패가 신규 기능에 정확히 집중 — 시나리오를 먼저 쓰면 미구현이 수치로 드러난다 | | L6 시나리오 E2E | 실사용자 여정 재현 — 페르소나의 하루·장기 과업 포함 | 프로젝트별 작성. **기대값 단언 필수**(하니스 규칙 8), 백 키·시트·양방향 흐름은 `mobile-app-ux.md` 패턴 기준. 실측: RED 1,742 단언 중 318 실패가 신규 기능에 정확히 집중 — 시나리오를 먼저 쓰면 미구현이 수치로 드러난다 |
@ -89,6 +92,17 @@ L1·L5 는 프로젝트 안에 `tools/unit/*.test.mjs`, `tools/exploratory.mjs`
15. **시각 기준 갱신은 의도 변경 리뷰를 동반한다** — "달라졌으니 갱신"으로 결함이 기준에 박힌다(실측: 왼쪽 인셋 결함 화면이 기준 갱신으로 '정상'이 되어 재발을 숨겼다). 갱신 전에 무엇이 의도적으로 바뀌었는지 한 줄씩 나열하고, 사용자 보고가 있던 화면은 수리 후 재촬영한다. 15. **시각 기준 갱신은 의도 변경 리뷰를 동반한다** — "달라졌으니 갱신"으로 결함이 기준에 박힌다(실측: 왼쪽 인셋 결함 화면이 기준 갱신으로 '정상'이 되어 재발을 숨겼다). 갱신 전에 무엇이 의도적으로 바뀌었는지 한 줄씩 나열하고, 사용자 보고가 있던 화면은 수리 후 재촬영한다.
16. **사용자 보고 스크린샷은 비전 요약이 아니라 픽셀 판정으로 해부한다** — 비전 모델의 픽셀 추정("~0px", "~16px")은 부정확하다. pngjs 등으로 화면을 가로 밴드로 나눠 각 밴드의 텍스트 시작 x를 직접 재면(보임 행만, 왼쪽 테두리 아티팩트 제외) "무엇이 어디서 어긋났는지"가 수치로 나온다. 실측: '제목이 가장자리에 붙는다'는 보고가 앱바 24px·제목 16px의 리듬 불일치로 확정됐다. 16. **사용자 보고 스크린샷은 비전 요약이 아니라 픽셀 판정으로 해부한다** — 비전 모델의 픽셀 추정("~0px", "~16px")은 부정확하다. pngjs 등으로 화면을 가로 밴드로 나눠 각 밴드의 텍스트 시작 x를 직접 재면(보임 행만, 왼쪽 테두리 아티팩트 제외) "무엇이 어디서 어긋났는지"가 수치로 나온다. 실측: '제목이 가장자리에 붙는다'는 보고가 앱바 24px·제목 16px의 리듬 불일치로 확정됐다.
17. **새 검사는 수정 전 코드로 RED 재현 후 GREEN** — 검사 자체의 측정 하자(오탐)도 이렇게 잡는다. 실측: 왼쪽 인셋 검사 초안이 ①`display:none` 요소의 rect=0, ②전폭 컨테이너의 보더박스(텍스트가 아님)로 38개 오탐 — RED 단계에서 발견해 교정했다. 17. **새 검사는 수정 전 코드로 RED 재현 후 GREEN** — 검사 자체의 측정 하자(오탐)도 이렇게 잡는다. 실측: 왼쪽 인셋 검사 초안이 ①`display:none` 요소의 rect=0, ②전폭 컨테이너의 보더박스(텍스트가 아님)로 38개 오탐 — RED 단계에서 발견해 교정했다.
18. **시각 E2E는 기본 화면만 찍지 않는다** — URL별 390·1440 기본 화면에 더해 결과가 바뀐 입력, 오류/검토, 모달/차단 상태를 찍는다. 하니스는 파일 개수·가로세로·0바이트·해시 중복을 단언하고, 에이전트는 파일을 실제로 열어 시장 적합성·위계·크롭·반복을 판정한다. 스크린샷을 저장했다는 사실만으로 PASS하지 않는다.
19. **absolute/fixed 자식은 containing block을 잰다** — 모바일 미디어쿼리에서 부모의 `position`을 바꿨다면 각 절대 배치 자식의 `offsetParent` 또는 기준 rect가 의도한 컨테이너인지 단언한다. `overflow-x:hidden`으로 누수를 숨기지 않는다.
20. **정적 검사는 재귀 입력과 대상 수를 잠근다** — 서브페이지가 생기는 저장소에서 한 단계 glob은 금지한다. 실행 전에 발견한 HTML/CSS/JS 파일 수를 출력하고 0개 또는 이전 계약보다 적으면 실패시킨다.
21. **시나리오·단언·캡처 수를 hard-lock한다** — 대규모 E2E는 `EXPECTED_SCENARIOS`, `EXPECTED_ASSERTIONS`, `EXPECTED_CAPTURES`를 둔다. 리팩터링으로 검사가 삭제돼도 전체가 조용히 GREEN이 되지 않게 한다. 수를 채우기 위한 중복 단언은 금지하고 공통·정상·오류·부작용·접근성·시각 상태로 분류한다.
22. **사용자 승인과 검증 결과는 다른 상태다** — 사용자가 방향 게이트를 자율 승인했어도 기계·기능·시각 게이트는 면제되지 않는다. 승인됨/검증됨을 보고서에서 별도 표기한다.
23. **혼합 액션 행은 직접 자식 hitbox 중심을 비교한다** — 같은 액션 묶음·같은 행의 직접 자식인 채운 버튼과 텍스트 링크가 둘 다 44px 이상인지, `rect.top + rect.height / 2` 차이가 브라우저 CSS 픽셀 2px 이하인지 단언한다. 중첩 아이콘·배지나 모바일 세로 스택은 비교 대상이 아니다. `align-items:center` 선언 존재는 검사가 아니다.
24. **연속 섹션 토폴로지를 목록화한다** — 섹션별 `역할 / DOM 골격 / 회색조 실루엣`을 적는다. 역할이 다른 연속 3개가 같은 `제목+목록` 골격이면 시각 RED다. 반복 데이터처럼 같은 골격이 맞는 경우는 이유를 기록한다.
25. **조건부 패널의 닫힘은 트랙 회수까지 단언한다** — 데스크톱 분할 작업대는 `display:none`만 검사하지 않는다. 인스펙터가 닫히면 주 표면의 오른쪽 끝과 작업대 끝 차이가 CSS 픽셀 2px 이하인지, 다시 선택하면 분할 트랙이 복원되는지 잰다. 모바일 시트·전체 화면 패널은 트랙 대신 닫힘·배경 복귀·포커스 복원을 검증한다.
26. **진실 라벨은 개수가 아니라 의미 범위를 단언한다** — 같은 상태·출처·행동 가능성을 공유하는 자식은 제목과 경계가 분명한 가장 가까운 공통 부모가 한 번 소유하고 프로그램적으로 연결한다. 상태가 다른 자식만 예외 라벨을 둔다. 같은 공개 문구가 한 그룹의 모든 자식에 복제되면 범위 설계를 다시 본다.
27. **보이는 입력과 판정 의존성을 같은 SSOT로 잠근다** — 폼의 사용자 조건 이름 집합과 순수 판정 함수가 참조하는 사용자 조건 집합을 비교한다. 숨은 기본값을 사용자 선호로 세지 않고, 보이는 입력을 무시하지 않으며, 분석 전 완료 결과가 없고 복합 비용 일부로 적합을 확정하지 않는지 단언한다.
28. **공개와 관리 콘텐츠 소유권을 테스트한다** — 공개 DOM에는 내부 ID·원문 출처 원장·규제 채널 상수·감사 로그를 복제하지 않는다. 대신 사용자 행동을 막거나 바꾸는 제한과 해석 가능한 이유는 남긴다. 관리자는 그 요약을 재현하는 출처·검증 이벤트·판정 기록을 가진다.
## 리포트 양식 ## 리포트 양식

View file

@ -94,6 +94,22 @@ printf '%s' "\$imagegen $PROMPT" \
`Read` 로 열어 확인한 뒤에만 쓴다. 특히 **공간 사진**(작업실·매장)이 어색해지기 쉽다. `Read` 로 열어 확인한 뒤에만 쓴다. 특히 **공간 사진**(작업실·매장)이 어색해지기 쉽다.
주제가 안 맞으면 프롬프트를 **한 번에 한 가지만** 바꿔 다시 만든다. 주제가 안 맞으면 프롬프트를 **한 번에 한 가지만** 바꿔 다시 만든다.
### 여러 화면이면 이미지 바이블부터 만든다
히어로 한 장을 만든 뒤 카드·모바일·OG에 같은 파일을 억지로 자르지 않는다. 생성 전에 슬롯 계약을 쓴다.
| 슬롯 | 역할 | 피사체 | 비율 | 안전영역 | 진실 라벨 | 폴백 |
|---|---|---|---|---|---|---|
| 데스크톱 히어로 | 첫 인상 | 주체+행동 | 16:9 | 텍스트 반대쪽 55% | 필요 시 `연출 이미지` | 단색+DOM 카피 |
| 모바일 히어로 | 첫 화면 행동 | 핵심 주체 | 4:5 | 하단/중앙 | 동일 | 별도 정지컷 |
| 증거 컷 | 재질·동선 | 부분·공간 | 3:2 | 중앙 | 합성/예시 여부 | DOM 도식 |
**비율이 맞는 것과 의미가 맞는 것은 다르다.** 데스크톱 원본을 4:5로 잘랐을 때 얼굴만 남고 제품이 첫 화면 아래로 밀리면 모바일 전용 원본을 생성한다. 모바일 캡처 390×844에서 핵심 피사체가 실제로 보이는지 확인한다.
이미지는 재질·분위기·행동을 맡고, **가격·주소·지도·상태·AI 근거는 DOM이 맡는다.** 생성 이미지 안에 정보를 구우면 접근성·수정 가능성·사실 검증을 동시에 잃는다.
가상 매물·합성 매장처럼 실제 증거로 오인할 수 있는 사진은 파일 출처를 `design.md`에 적는 것만으로 부족하다. 사진과 같은 시각 그룹에 `연출 이미지 · 실제 사진 아님` 같은 라벨을 둔다. 상세 계약은 `trustworthy-showcases.md`를 따른다.
--- ---
## 3. 싣기 전에 반드시 줄인다 ## 3. 싣기 전에 반드시 줄인다

View file

@ -55,6 +55,43 @@
3. **시선을 한 번은 멈춰라** — 전부 같은 리듬으로 흐르면 아무것도 강조되지 않는다. 섹션 하나는 리듬을 깨야 한다 3. **시선을 한 번은 멈춰라** — 전부 같은 리듬으로 흐르면 아무것도 강조되지 않는다. 섹션 하나는 리듬을 깨야 한다
4. **스크롤은 보상이어야 한다** — 다음 화면에 무언가 새로운 것이 있어야 한다. 같은 카드 그리드가 세 번 반복되면 거기서 이탈한다 4. **스크롤은 보상이어야 한다** — 다음 화면에 무언가 새로운 것이 있어야 한다. 같은 카드 그리드가 세 번 반복되면 거기서 이탈한다
### 의미가 다르면 섹션 토폴로지도 달라야 한다
일관성은 같은 거시 그리드를 복제하는 것이 아니다. **연속한 섹션의 역할이 다른데 모두 `왼쪽 큰 제목 + 오른쪽 목록`이면 구조 슬롭**이다. 색·배경·카드 radius를 바꿔도 읽는 동작은 같다.
2단계에서 섹션 역할과 토폴로지를 나란히 적는다.
| 역할 | 맞는 토폴로지 예 |
|---|---|
| 장소·관계 | 지도/도식 + 접근 가능한 목록 쌍둥이 |
| 비용·사양 | 원장·명세서·비교표 |
| 절차·판정 | 흐름·분기·상태 전이 |
| 이미지·재질 | 풀블리드·필름스트립·캡션 |
| 입력·결과 | 폼 + 결과 인스펙터 |
**다르게 보이기 위해 억지로 바꾸지는 마라.** 같은 데이터 묶음을 반복하는 목록은 같은 구조가 맞다. 규칙은 `의미 역할이 다르면 읽는 동작도 달라야 한다`다. 연속 3개 섹션의 DOM 골격과 회색조 실루엣이 같다면, 그중 둘의 정보 구조부터 다시 설계한다.
### 같은 액션 행의 직접 자식은 클릭 박스 중심을 잰다
한 액션 묶음의 **같은 행에 놓인 직접 자식**인 채운 버튼과 텍스트 링크는 `align-items:center`만으로 시각 중심이 맞지 않을 수 있다. 중첩된 아이콘·배지까지 한꺼번에 비교하거나, 모바일에서 세로로 쌓인 액션에 이 규칙을 적용하면 오탐이다.
- 두 액션 모두 `inline-flex; align-items:center; min-height:44px`의 실제 hitbox를 가진다
- `getBoundingClientRect()`의 중심 y 차이가 브라우저 CSS 픽셀 기준 2px 이하여야 한다
- 텍스트 baseline이 아니라 클릭 가능한 박스 전체를 비교한다
- 보조 링크를 44px로 키우면서 빈 padding이 과해지면 액션을 세로로 쌓는다
### 패널을 숨기면 그리드 트랙도 회수한다
데스크톱 목록+인스펙터 분할 작업대에서 패널만 `display:none`으로 숨기고 2열 `grid-template-columns`를 유지하면 큰 빈 면이 남는다. 닫힌 상태는 가시성 변화가 아니라 **레이아웃 상태 변화**다. 모바일에서 인스펙터가 시트·전체 화면으로 열리는 구조에는 데스크톱 2px 트랙 계약을 그대로 적용하지 않고, 시트 닫힘·배경 복귀·포커스 복원을 별도로 잰다.
```css
.workbench { grid-template-columns: minmax(0, 1fr) minmax(20rem, .55fr); }
.workbench.is-inspector-closed { grid-template-columns: minmax(0, 1fr); }
.workbench.is-inspector-closed .inspector { display: none; }
```
닫힘 상태에서 목록 오른쪽 끝과 작업대 오른쪽 끝의 차이를 2px 이하로 재고, 행 선택 뒤 2열이 복원되는 것도 함께 단언한다.
--- ---
## 3. 타이포그래피 ## 3. 타이포그래피
@ -183,9 +220,12 @@ new Set([...document.querySelectorAll('.nav a')]
- [ ] CSS/HTML만으로 페이지가 완성됐다. JS를 꺼도 읽힌다 - [ ] CSS/HTML만으로 페이지가 완성됐다. JS를 꺼도 읽힌다
- [ ] 모든 값이 3단계 토큰에서 나온다. 하드코딩된 px/색이 없다 - [ ] 모든 값이 3단계 토큰에서 나온다. 하드코딩된 px/색이 없다
- [ ] 첫 화면에 메시지가 셋 이하다 - [ ] 첫 화면에 메시지가 셋 이하다
- [ ] 의미 역할이 다른 연속 섹션이 같은 거시 그리드를 3회 반복하지 않는다. 반복 데이터면 같은 구조를 유지한 이유가 적혀 있다
- [ ] 본문에 `--measure` 가 적용됐다 - [ ] 본문에 `--measure` 가 적용됐다
- [ ] 모바일 폭 **320px**(iPhone SE 세로)에서 가로 스크롤이 없다 - [ ] 모바일 폭 **320px**(iPhone SE 세로)에서 가로 스크롤이 없다
- [ ] 키보드 Tab 만으로 모든 인터랙티브 요소에 도달한다. 포커스 링이 보인다 - [ ] 키보드 Tab 만으로 모든 인터랙티브 요소에 도달한다. 포커스 링이 보인다
- [ ] 같은 행 액션 묶음의 직접 자식 hitbox가 44px 이상이고 중심 y 차이가 CSS 픽셀 2px 이하다
- [ ] 데스크톱 분할 패널을 숨긴 상태에서 빈 그리드 트랙이 남지 않고 주 표면이 전체 폭을 회수한다. 모바일 시트는 닫힘·배경·포커스 복원을 따로 검증한다
- [ ] 한글이 있다면 조판 규칙이 적용됐다 - [ ] 한글이 있다면 조판 규칙이 적용됐다
> 근거: research/references/02-methodology.md, 03-trends-2026.md, 04-ai-slop-signatures.md (조사일 2026-08-20) > 근거: research/references/02-methodology.md, 03-trends-2026.md, 04-ai-slop-signatures.md (조사일 2026-08-20)

View file

@ -163,6 +163,23 @@ canvas { display: none !important; }
--- ---
## 4-2. 진실 완결성 — 가상·예시·AI·규제 화면
가상 브랜드·예시 데이터·AI 분석·규제 주제를 실제 서비스처럼 보이게 만든 화면에만 돈다. 전역 데모 띠 하나로 끝내지 않는다. 상세 설계는 `trustworthy-showcases.md`.
- [ ] 모든 숫자·주소·상태·추천이 **사실 / 예시 / 추론 / 미정** 중 하나이고 `USER_SELF_REPORT / VERIFIED_SOURCE / SYSTEM_STATE / SYNTHETIC_FIXTURE` 출처 계층이 추적된다. 자기보고 값 자체를 검증 사실로 승격하지 않았다
- [ ] `예시 가격`, `거래 불가`, `실제 접수 아님`, `연출 이미지` 라벨이 오해가 생기는 범위 가까이에 있다. 같은 상태·출처·행동 가능성을 공유하는 자식은 가장 가까운 공통 부모가 한 번 소유하고, 다른 자식만 예외 라벨을 가진다
- [ ] 검증하지 않은 수익·가맹비·매장 수·등록번호·연락처를 만들지 않았다. 모르면 `미정`이다
- [ ] AI 결과에 입력 조건·출처가 붙은 입력/검증 사실·추론·불확실/누락·수정·사람 검토가 있고, 입력을 바꾸면 결과와 근거가 함께 바뀐다. 모델 출력 내용을 `SYSTEM_STATE` 사실로 두지 않았다
- [ ] 진단·개인화 추천·예약·전송·거래·게시·내보내기 등 실제 규제 행동은 행동별 필수 검증이 없을 때 **실패 폐쇄형**이며, 미등록 행동은 기본 거절되고 막힌 이유가 화면에 보인다
- [ ] 민감 입력과 파생값의 영속 저장·쿠키·분석 이벤트 포함·네트워크 전송 경계를 E2E로 검증했다
- [ ] 공개 화면은 사용자 해석·제한·다음 행동을, 관리자는 내부 원장·검증 이벤트·채널 판정·감사 로그를 소유한다. 사용자 행동에 영향을 주는 제한을 관리자에만 숨기지 않았다
- [ ] 지도·차트·도식의 같은 정보가 DOM 목록·표·설명으로도 제공되고 데이터 원천이 하나다
- [ ] 생성 이미지는 가격·주소·점수의 증거로 쓰이지 않으며, 모바일 390×844에서 핵심 피사체가 첫 화면의 의도와 맞는다
- [ ] URL별 390·1440 기본 화면과 핵심 입력/오류/모달 상태 캡처를 실제로 열어 보고 시각 PASS/RED를 따로 기록했다
---
## 5. 디자인 자체 ## 5. 디자인 자체
기계로 못 잡는 부분이다. **정직하게 답해라.** 기계로 못 잡는 부분이다. **정직하게 답해라.**
@ -186,6 +203,7 @@ canvas { display: none !important; }
- [ ] 반응형 체크리스트 통과 - [ ] 반응형 체크리스트 통과
- [ ] 콘텐츠 체크리스트 통과 - [ ] 콘텐츠 체크리스트 통과
- [ ] 상태 완결성(§4-1) — 입력 검증·되돌림·Esc 를 실제 조작으로 확인 - [ ] 상태 완결성(§4-1) — 입력 검증·되돌림·Esc 를 실제 조작으로 확인
- [ ] 해당하면 진실 완결성(§4-2) — 예시·AI·규제·생성 이미지의 오해 경로를 실제 상태로 확인
- [ ] 5장 여섯 질문에 정직하게 답했고, 실패 항목을 고쳤다 - [ ] 5장 여섯 질문에 정직하게 답했고, 실패 항목을 고쳤다
- [ ] `antipatterns.md` 자가 채점표 통과 - [ ] `antipatterns.md` 자가 채점표 통과

View file

@ -28,6 +28,21 @@
SaaS 구조 + SaaS 톤 = 또 하나의 SaaS 슬롭. SaaS 구조 + SaaS 톤 = 또 하나의 SaaS 슬롭.
SaaS 구조 + 독립 출판사 톤 = 차별화. SaaS 구조 + 독립 출판사 톤 = 차별화.
### 목표 시장은 네 번째 레퍼런스가 아니라 경계 조건이다
목표 시장을 적지 않으면 검색 결과 상위의 서구 사례가 무의식적으로 기본값이 된다. 먼저 한 줄로 잠근다.
```md
시장: 국가/도시/생활권 · 주 사용 기기 · 단위/통화 · 문의/구매 관습 · 피해야 할 외래 문법
```
- R1은 같은 업종·목적에 더해 **같은 목표 시장을 우선**한다. 구조에 단위·용어·연락 방식이 묻어 있기 때문이다.
- R2의 `다른 업종` 규칙은 유지한다. 목표 시장 감각이 중요하면 **같은 문화권의 다른 업종**을 먼저 찾는다.
- 다른 문화권 R3는 기법만 가져온다. 카피·색·상징·사진 연출까지 번지면 경계 조건 위반이다.
- 같은 시장 자료가 부족하면 빈칸을 숨기지 말고, 어떤 관습을 공식 문서·현지 서비스로 별도 검증했는지 적는다.
목표 시장 적합성은 국기·전통 문양 같은 표식이 아니다. 통화 단위, 면적 표기, 동네 탐색 축, 정보 순서, 촬영 조도, CTA 동사처럼 **사용자가 실제로 행동하는 문법**이다.
**충돌 해결 순위** **충돌 해결 순위**
1. 가독성·접근성이 언제나 이긴다. R2의 톤이 대비를 깨면 톤을 조정한다. 1. 가독성·접근성이 언제나 이긴다. R2의 톤이 대비를 깨면 톤을 조정한다.
2. R1 구조가 R3 모션을 이긴다. 모션 때문에 구조를 바꾸지 않는다. 2. R1 구조가 R3 모션을 이긴다. 모션 때문에 구조를 바꾸지 않는다.
@ -82,6 +97,7 @@ SaaS 구조 + 독립 출판사 톤 = 차별화.
## 3. 실전 절차 체크리스트 ## 3. 실전 절차 체크리스트
- [ ] **1. 브리프를 한 문장으로 압축한다.** `[대상]이 [행동]하게. 느낌은 [형용사 2개].` 이 문장 없이 수집을 시작하지 않는다. - [ ] **1. 브리프를 한 문장으로 압축한다.** `[대상]이 [행동]하게. 느낌은 [형용사 2개].` 이 문장 없이 수집을 시작하지 않는다.
- [ ] **1-b. 목표 시장 경계 조건을 쓴다.** 국가/생활권·기기·단위/통화·행동 관습·금지할 외래 문법. R1이 같은 시장인지 표시한다.
- [ ] **2. galleries.md 라우팅 표에서 R1/R2/R3 갤러리를 정한다.** R2가 R1과 다른 업종인지 확인한다. - [ ] **2. galleries.md 라우팅 표에서 R1/R2/R3 갤러리를 정한다.** R2가 R1과 다른 업종인지 확인한다.
- [ ] **3. 각 갤러리에서 원본 사이트 URL을 1개씩 확보한다.** 하위 페이지가 필요하면 **인덱스에서 `href`를 뽑아라. slug를 추측하지 마라** — 추측한 URL은 404가 난다. - [ ] **3. 각 갤러리에서 원본 사이트 URL을 1개씩 확보한다.** 하위 페이지가 필요하면 **인덱스에서 `href`를 뽑아라. slug를 추측하지 마라** — 추측한 URL은 404가 난다.
- [ ] **3-b. 그 URL이 정본인지 검증한다.** 사용자가 특정 제품·브랜드를 지목했고 후보 도메인이 여럿이면 필수다. **HTTP 200은 살아있다는 뜻이 아니다.** - [ ] **3-b. 그 URL이 정본인지 검증한다.** 사용자가 특정 제품·브랜드를 지목했고 후보 도메인이 여럿이면 필수다. **HTTP 200은 살아있다는 뜻이 아니다.**
@ -214,6 +230,12 @@ WebFetch는 마크다운으로 변환하면서 CSS를 버린다. **그리드·
## 브리프 ## 브리프
[대상]이 [행동]하게. 느낌은 [형용사 2개]. [대상]이 [행동]하게. 느낌은 [형용사 2개].
## 목표 시장
- 지역/생활권:
- 기기/단위/통화:
- 행동 관습:
- 금지할 외래 문법:
## R1 · 구조 — <이름> <원본 URL> ## R1 · 구조 — <이름> <원본 URL>
- 가져올 축: 그리드, 여백 리듬 - 가져올 축: 그리드, 여백 리듬
- 그리드: 최대폭 1200 / 비대칭 7:5 / 거터 32 - 그리드: 최대폭 1200 / 비대칭 7:5 / 거터 32
@ -244,6 +266,7 @@ WebFetch는 마크다운으로 변환하면서 CSS를 버린다. **그리드·
**검증 조건** — 하나라도 빠지면 되돌아간다. **검증 조건** — 하나라도 빠지면 되돌아간다.
- R1/R2/R3 각각 **원본 사이트 URL**이 있다 (갤러리 URL 아님) - R1/R2/R3 각각 **원본 사이트 URL**이 있다 (갤러리 URL 아님)
- 목표 시장 경계 조건이 있고 R1의 시장 일치 여부가 적혀 있다
- R2의 업종이 R1과 다르다 - R2의 업종이 R1과 다르다
- 각 슬롯에 **가져올 축**이 명시되어 있다 - 각 슬롯에 **가져올 축**이 명시되어 있다
- 각 슬롯에 **변형 1개와 그 이유**가 있다 - 각 슬롯에 **변형 1개와 그 이유**가 있다

View file

@ -0,0 +1,355 @@
# trustworthy-showcases — 실제처럼 보이되 거짓말하지 않는 쇼케이스
다음 중 하나라도 해당하면 **1·2·4·5단계**에서 읽는다. 1단계에서 시장과 위험 경계를 잠그고, 2단계에서 정보 구조를 나누고, 4단계에서 구현하며, 5단계에서 실제 상태를 검증한다.
- 가상 브랜드·가상 매물·예시 가격·예시 매장을 실제 서비스처럼 설계한다
- AI 분석·추천·점수·예측을 화면에 싣는다
- 부동산·금융·의료처럼 규제되거나 오해 비용이 큰 주제를 다룬다
- 고객용 랜딩과 관리자·가맹·운영 화면을 한 브랜드로 함께 만든다
- 생성 이미지를 실제 상품·공간·사람의 증거처럼 오인할 수 있다
목표는 경고문을 많이 붙이는 것이 아니다. **주장의 구조가 정직하고, 그 정직함이 디자인을 약하게 만들지 않게 하는 것**이다.
---
## 1. 기존 근본 규칙과의 충돌 해결
이 문서는 기존 규칙을 느슨하게 만들지 않는다. 충돌하면 다음 순서로 해석한다.
1. 접근성·사실성·사용자 안전이 언제나 이긴다.
2. 브리프와 목표 시장이 레퍼런스의 멋을 이긴다.
3. 하나의 브랜드 방향은 유지하되, 서로 다른 과업의 표면 밀도까지 같게 만들지는 않는다.
4. 게이트의 기본값은 유지한다. 예외가 필요하면 페이지 계약으로 좁게 선언하고 더 넓은 매트릭스로 검증한다.
5. 생성 이미지는 분위기와 재질을 맡고, 가격·상태·근거·판정은 DOM이 맡는다.
예: 마케팅 h1에 2줄이 필요하면 범용 1줄 규칙을 삭제하지 않는다. 그 페이지만 `h1MaxLines: 2`로 선언하고 375·390px × 글꼴 확대에서도 2줄 이하인지 잰다.
---
## 2. 목표 시장을 먼저 잠근다
`R1/R2/R3`보다 먼저 **시장 계약** 한 줄을 쓴다.
```md
시장: 한국 / 서울 생활권 / 모바일 우선
행동 관습: 원·만원·억, ㎡와 평 병기, 동네·역·도보 기준, 국내형 문의 순서
금지: 서구 럭셔리 부동산 문법, 미국 다이너 문법, 번역투 카피, 이국화 장식
```
- R1은 같은 업종·목적이면서 가능하면 **같은 목표 시장**에서 고른다. 구조에는 단위·용어·연락 방식·신뢰 표지가 들어 있기 때문이다.
- R2는 다른 업종이어야 한다는 근본 규칙을 유지한다. 다만 목표 시장의 미감이 중요하면 **같은 문화권의 다른 업종**을 우선한다.
- R3가 다른 문화권에서 왔다면 가져올 것은 구현 기법 하나뿐이다. 색·카피·상징까지 따라오면 시장 계약을 어긴다.
- 국내화는 태극 문양이나 한옥 장식을 붙이는 일이 아니다. **단위·정보 순서·검색 축·사진의 조도·문의 단계**를 현지 관습에 맞추는 일이다.
---
## 3. 한 방향, 여러 표면
고객용 랜딩·관리자·가맹 페이지가 함께 있을 때 전부 같은 표현 강도로 만들면 둘 중 하나가 망가진다. 공통 토큰을 공유하고 표면 다이얼을 따로 적는다.
| 다이얼 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 표현성 | 문서 | 조용한 제품 | 캠페인 | 실험적 |
| 정보 밀도 | 넉넉함 | 표준 | 조밀함 | 운영 콘솔 |
| 모션 | 없음 | 피드백만 | 절제된 전환 | 브랜드 연출 |
| 증거 노출 | 요약 | 근거 링크 | 근거 병기 | 감사 기록 |
예시:
```md
공통: Pretendard, 8px 리듬, 먹색, 강조색 1개, 직설형 카피
고객 랜딩: 표현 2 / 밀도 1 / 모션 1 / 증거 2
관리자: 표현 0 / 밀도 3 / 모션 0 / 증거 3
```
이렇게 해야 랜딩은 설득하고 관리자는 판정한다. 프리셋을 두 개 쓰는 것이 아니라 **같은 브랜드가 과업에 맞춰 목소리 크기를 바꾸는 것**이다.
---
## 4. 먼저 진실 계약을 만든다
화면을 그리기 전에 모든 주장과 행동을 네 종류로 분류한다. 이 **진실 상태**와 값이 어디서 왔는지 나타내는 **출처 계층**을 섞지 않는다.
| 종류 | 예 | 화면 규칙 |
|---|---|---|
| 사실 | 검증 가능한 문장: `사용자가 40억을 입력했다`, `공식 원문에 전용 118㎡로 적혔다` | 무엇이 사실인지 주어·출처·기준일을 가까이에 둔다 |
| 예시 | 가상 매물, 예시 가격, 합성 매장 | 주장·가격·카드·행동 근처에 `예시`를 붙인다 |
| 추론 | AI 추천, 위험 신호, 적합도 | 근거·불확실성·수정 경로를 함께 둔다 |
| 미정 | 가맹비, 수익, 실제 주소처럼 검증 안 된 값 | `미정`으로 남기고 숫자를 만들지 않는다 |
같은 값이라도 출처가 다르면 화면의 말투와 가능한 행동이 달라진다.
| 출처 계층 | 뜻 | 허용 표현 |
|---|---|---|
| `USER_SELF_REPORT` | 사용자가 직접 입력·진술했지만 외부 검증은 안 됨 | `입력한 조건`, `사용자 진술` |
| `VERIFIED_SOURCE` | 기준일과 원문을 확인한 외부·공식 출처 | `확인된 정보` + 출처·기준일 |
| `SYSTEM_STATE` | 현재 제품이 직접 계산·관측한 상태 | 계산 규칙·측정 시점 병기 |
| `SYNTHETIC_FIXTURE` | 쇼케이스 동작 확인용 합성 값 | `예시`, 실제 행동 차단 |
사용자 진술의 값 자체를 곧바로 도메인 사실로 승격하지 않는다. `사용자가 붉음을 선택했다`는 사실일 수 있지만 `피부가 실제로 붉다`는 임상 상태는 검증 전 미정이다. 합성 픽스처도 시스템 관측처럼 표현하지 않는다. 승격에는 출처·기준일·검증 이벤트가 필요하다. AI·모델 출력은 제품이 생성했다는 의미에서 시스템 상태일 수 있어도, 내용의 진실 상태는 여전히 **추론**이다.
### 4-a. 같은 진실 범위는 가장 가까운 공통 부모가 소유한다
`오해 지점 가까이에 라벨`은 같은 경고를 모든 자식에 복제하라는 뜻이 아니다.
- 그룹의 모든 자식이 같은 진실 상태·출처·행동 가능성을 공유하면, 제목과 시각 경계가 분명한 **가장 가까운 공통 부모**에 라벨을 한 번 둔다.
- 자식 하나라도 상태·출처·행동 가능성이 다르면 그 자식에 예외 라벨을 둔다.
- 공통 부모의 범위가 화면과 보조기술에서 명확하지 않으면 묶지 않는다. 폼은 `fieldset/legend`, 표는 `caption`, 카드 집합은 제목·설명과 `aria-describedby` 같은 프로그램적 연결을 쓴다.
- 전역 띠는 사이트 전체의 가상 브랜드 상태를 맡을 수 있지만, 특정 가격·이미지·제출 행동의 서로 다른 경계까지 대신하지 않는다.
구현 전에 진실 라벨의 `문구 / 소유 컨테이너 / 예외 자식`을 표로 만들고, 같은 문구의 반복 횟수가 아니라 의미 범위가 닫혔는지 감사한다.
전역 데모 띠 하나만으로는 부족하다. 사용자는 스크롤 중 그 띠를 보지 못한다. **라벨은 오해가 발생하는 지점에 붙인다.**
- 후보 전체가 같은 상태인 카드 집합: 부모 설명 `아래 세 후보는 예시 · 거래 불가`; 실제·예시가 섞이면 각 카드에 예외 라벨
- 생성 사진: 각 이미지 캡션 `연출 이미지 · 실제 증빙 아님`
- 가격 집합이 모두 같은 상태면 표 `caption`이나 열 제목 `예시 가격`; 상태가 다른 값만 개별 표시
- 매장 상태: `화면 동작 확인용 예시`
- 폼: `실제 접수되지 않음 · 개인정보 입력 금지`
라벨은 작은 회색 각주로 숨기지 않는다. 본문 대비를 지키고 해당 주장과 같은 시각 그룹에 둔다.
---
## 5. 성공 행동이 다르면 경로를 나눈다
한 페이지에서 소비자와 운영자, 구매자와 중개 관리자를 동시에 설득하지 않는다.
- 소비자 성공: 메뉴·가격·가까운 매장·조합 확인
- 가맹 성공: 운영 조건·상권·점포·문의 검토
- 고객 성공: 조건 입력·근거 확인·사람 상담
- 관리자 성공: 검수·보류 사유·게시 채널 판정
성공 행동이 다르면 URL과 IA를 나눈다. 공통 헤더에서 경로는 연결하되, 각 페이지의 첫 화면 메시지와 CTA는 하나의 성공 행동만 말한다. 별도 경로는 중복이 아니라 **우선순위 충돌을 제거하는 구조**다.
### 5-a. 공개 해석과 내부 검수의 소유권을 나눈다
URL을 나눠도 관리자 언어를 공개 페이지에 복제하면 경로 분리가 끝난 것이 아니다.
| 공개 화면이 소유 | 관리자 화면이 소유 |
|---|---|
| 사용자가 결정을 해석하는 데 필요한 근거·한계·다음 행동 | 내부 ID·원문 출처 원장·검증 이벤트·모델/규칙 버전 |
| 사용자 행동을 막거나 바꾸는 제한과 그 이유 | 규제 채널 판정 상세·역할 권한·감사 로그 |
| 정정·사람 확인 경로 | 승인·보류·변경 이력과 운영 실패 로그 |
내부 상수와 감사 원장은 관리자에 두되, 제한이 사용자의 행동을 막는다는 사실과 사용자가 이해해야 할 이유까지 숨기지 않는다. 공개는 **해석 가능한 요약**, 관리자는 **재현 가능한 증거**를 소유한다. 구현 전에 각 콘텐츠 블록에 `public / admin / both-summary` 소유자를 표시한다.
### 5-b. 고위험 데모의 데이터 경계
의료·금융·법률·주거 상담처럼 입력 자체가 민감할 수 있는 쇼케이스는 폼이 예뻐 보이는 것보다 **수집하지 않는 증거**가 먼저다.
- 과업 시연에 필요한 최소 필드만 둔다. 이름·연락처·정확한 주소·증상 원문처럼 없어도 되는 자유 입력은 빼거나 로컬 예시 선택지로 바꾼다.
- 합성 픽스처가 아닌 **민감 입력과 그 파생값**은 기본적으로 새로고침 후 남기지 않는다. 해당 값의 영속 저장·쿠키 기록·분석 이벤트 포함·네트워크 전송이 0회인지 E2E로 단언한다. 정적 이미지·폰트 요청과 일반 페이지 조회 자체는 이 0회 계약의 대상이 아니다.
- `실제 접수되지 않음` 문구만으로는 부족하다. 제출 성공 UI가 있어도 실제 요청·저장 부작용이 없어야 GREEN이다.
- 실제 운영 연결이 생기면 동의·보관 기간·삭제·전송 대상을 별도 제품 계약으로 설계한다. 쇼케이스 계약을 조용히 확장하지 않는다.
---
## 6. 이미지 바이블은 슬롯 단위로 만든다
이미지 한 장을 만든 뒤 여기저기 크롭하지 않는다. 생성 전에 슬롯 표를 만든다.
| 슬롯 | 역할 | 피사체 | 비율 | 구도 안전영역 | 진실 라벨 | 폴백 |
|---|---|---|---|---|---|---|
| 데스크톱 히어로 | 첫 인상 | 손+제품 | 16:9 | 우측 55% | 연출 이미지 | 단색+제품명 |
| 모바일 히어로 | 첫 화면 행동 | 제품+손 | 4:5 | 하단 중심 | 연출 이미지 | 같은 톤 정지컷 |
| 증거 컷 | 재질·동선 | 공간 일부 | 3:2 | 중앙 | 합성 예시 | DOM 도식 |
**모바일 전용 생성은 데스크톱 크롭의 사치 버전이 아니다.** 첫 화면에서 보여야 할 주체가 달라지면 별도 원본이 필요하다. 얼굴이 먼저 나오고 제품이 폴드 아래로 밀리면 비율이 맞아도 실패다.
- 같은 톤 접두사를 공유해 색온도·광원·재질을 잠근다.
- 장면별로 피사체·행동·카메라 높이만 바꾼다.
- 음식은 손가락·포장 접힘·재료 층, 공간은 문·난간·가구 접합부를 확대해 본다.
- 이미지 안에 가격·주소·지도·점수·차트를 굽지 않는다. 그 정보는 DOM으로 만든다.
- 이미지 파일이 존재한다는 것과 채택 가능하다는 것은 다르다. 원본을 눈으로 보고 조건부 채택 사유를 기록한다.
고위험 업종은 공통 이미지 규칙 위에 **도메인 금지 오버레이**를 한 장 더 만든다.
| 도메인 | 생성 이미지로 만들지 않는 증거 |
|---|---|
| 의료·미용 | 전후 비교, 병변·치료 효과, 가짜 의료진·자격·기관 로고 |
| 금융·투자 | 수익 인증, 잔고·체결 내역, 보장 수익 그래프 |
| 부동산 | 실제 매물 인증, 권리 서류, 조망·주소·중개 자격 증명 |
| 음식·가맹 | 실제 매출표, 실재 점포 운영 인증, 검증하지 않은 원산지 표식 |
생성 이미지는 분위기·재질·사용 장면까지만 맡긴다. 자격·효과·소유·수익을 증명하는 장면은 DOM 라벨로도 합법화되지 않는다.
---
## 7. AI는 답이 아니라 검토 가능한 기록이다
AI 화면에는 최소 다섯 블록이 필요하다.
1. **입력 조건** — 사용자가 무엇을 말했는지
2. **출처가 붙은 입력·검증 사실** — 사용자가 무엇을 진술했는지와 외부·공식 자료로 무엇을 확인했는지 분리
3. **추론** — 사실에서 어떻게 결론으로 갔는지
4. **불확실·누락** — 모르는 것과 데이터 기준일
5. **사람 검토·수정** — 틀린 조건을 고치고 담당자에게 넘기는 경로
단일 점수와 반짝이는 링만 보여 주지 않는다. 점수는 정밀해 보이지만 근거가 없으면 장식이다. 추천 카드마다 `왜`, `무엇이 부족`, `다음 확인`을 붙인다.
모델 출력은 제품 내부에서 생성됐다는 이유만으로 `SYSTEM_STATE`의 관측 사실이 되지 않는다. 출력 내용은 `추론`, 실행·모델 버전·생성 시각만 `SYSTEM_STATE`다.
AI 결과를 바꿀 수 있는 입력을 하나 이상 제공하고, E2E에서 입력을 바꿨을 때 근거·결과·불확실성도 함께 바뀌는지 단언한다.
### 7-a. 보이는 입력과 판정 의존성은 하나의 계약이다
맞춤 분석이 참조하는 조건을 UI와 코드가 따로 관리하지 않는다. 구현 전에 `입력 필드 / 출처 / 판정 사용처 / 출력 주장` 표를 만들고 하나의 스키마·데이터 객체에서 폼과 판정을 함께 만든다.
- 사용자가 입력하지 않은 숨은 기본값을 `희망 조건`처럼 결과에 쓰지 않는다.
- 화면에 받은 조건을 판정에서 무시한 채 맞춤 결과라고 부르지 않는다.
- 분석 실행 전에는 완료 수치·추천·충돌을 보여 주지 않는다. `아직 분석하지 않음` 상태가 정직한 초기값이다.
- 보증금+월 납부액처럼 단일 축으로 비교할 수 없는 복합 비용은 일부 값만으로 `예산 안쪽`이라 판정하지 않고 `별도 확인`으로 남긴다.
- E2E에서 보이는 입력 이름 집합과 판정 함수의 사용자 조건 의존성 집합이 일치하는지, 입력 변경 뒤 근거·결과·불확실성이 함께 변하는지 단언한다.
---
## 8. 규제된 행동은 실패 폐쇄형으로
게시만 규제되는 것이 아니다. 진단·개인화 추천·예약·상담 전송·거래·문서 내보내기처럼 실제 사람이나 시장에 영향을 주는 행동을 쇼케이스 성공 상태와 같은 버튼으로 다루지 않는다. 행동·채널별 판정 함수를 둔다.
```js
// 예시 구조다. 실제 필수 요건은 목표 시장·업종의 공식 기준으로 다시 잠근다.
const requiredByAction = {
DIAGNOSE: ["requiredConsent", "verifiedClinicalData", "licensedReviewer", "humanReview"],
BOOK_APPOINTMENT: ["requiredConsent", "verifiedProvider", "availableSlot"],
EXPORT_RECORD: ["requiredConsent", "verifiedRecord", "accessControl", "auditLog"],
PUBLISH_LISTING: ["verifiedBroker", "verifiedAddress", "sourceFiles", "humanReview"],
};
function regulatedActionDecision(record, action) {
if (action === "SHOWCASE_PREVIEW") {
return record.syntheticLabelled
? { allowed: true, reason: "예시 범위가 명확한 미리보기" }
: { allowed: false, reason: "예시 범위 표지 누락" };
}
const required = requiredByAction[action];
if (!required) return { allowed: false, reason: "등록되지 않은 행동" };
if (record.syntheticFixture) return { allowed: false, reason: "합성 자료로 실제 행동 불가" };
const missing = required.filter((field) => !record[field]);
if (missing.length) return { allowed: false, reason: `필수 검증 누락: ${missing.join(", ")}` };
return { allowed: true, reason: "필수 검증 완료" };
}
```
- 화면에서 비활성 버튼만 두지 말고 **왜 막혔는지**를 말한다.
- 알 수 없는 행동은 기본 거절한다. 행동마다 필수 검증 목록이 다르며, 한 묶음의 불리언으로 모든 규제 행동을 열지 않는다.
- 관리자 검수 큐에는 `사실 / 추론 / 미정 / 위험` 상태와 출처 계층을 분리한다.
- 보류·거절은 사유를 필수로 받고, 닫기·Esc·포커스 복귀를 검증한다.
- 가상 데이터만 있는 쇼케이스라면 실제 진단·추천·예약·전송·거래·게시·내보내기 경로가 끝까지 막힌 상태가 정상 GREEN이다.
---
## 9. 시각화에는 읽을 수 있는 쌍둥이를 둔다
지도·차트·도식은 시선을 모으지만 사실을 완전히 전달하지 못한다. 같은 정보를 DOM 목록·표·설명으로도 제공한다.
- 지도 핀 ↔ 매장 목록
- 적합도 그래프 ↔ 조건별 근거 목록
- 재료 도식 ↔ 알레르기·구성 텍스트
- 운영 흐름 그림 ↔ 단계별 제목·설명
이미지나 SVG가 실패해도 과업을 끝낼 수 있어야 한다. 두 표현의 값이 어긋나지 않도록 데이터 원천은 하나로 둔다.
---
## 10. 시각 E2E는 캡처가 아니라 계약이다
정적·기하 게이트와 눈 검사는 역할이 다르다.
1. HTML/CSS/JS 정적 검사
2. 320·390·768·1024·1440 기하·대비·터치·h1 계약
3. 사용자 여정 E2E와 기대값 단언
4. 기본 화면 + 중요한 상호작용 상태 캡처
5. 캡처를 실제로 열어 목표 시장 적합성·시선 위계·크롭·반복을 판정
최소 캡처 계약:
- 각 URL의 390px·1440px 기본 화면
- 결과가 바뀌는 입력 상태 1개
- 관리자 보류·게시 차단 상태 1개
- 폼 검토 또는 오류 상태 1개
폭을 숫자 목록으로만 쓰지 말고 각 폭이 잡아야 하는 실패를 적는다.
| 폭 | 검증 목적 |
|---|---|
| 320 | 긴 한국어 라벨·최소 터치 영역·긴급 수축 스트레스 |
| 375 + 글꼴 확대 | 페이지별 `h1MaxLines`·한국어 어절 줄바꿈·보조 행동 수축 |
| 390 | 실제 모바일 첫 화면의 주체·CTA·고정 요소 |
| 768 | 카드 재배치와 중간 폭 공백·절대 배치 기준 |
| 1024 | 태블릿·관리자 분할면의 트랙 회수와 표 오버플로 |
| 1440 | 데스크톱 시선 위계·콘텐츠 최대폭·과도한 빈 면 |
하니스는 캡처의 **개수·가로세로·0바이트 여부·해시 중복**을 단언한다. 모델은 그 뒤에 `국내 감각인가`, `제품이 첫 화면에 보이는가`, `운영 화면이 과장됐는가` 같은 지점을 평가한다.
실측 사고: 기하 게이트 8/8을 통과한 모바일 페이지에서 `position: static`으로 바뀐 지도 부모 때문에 절대 배치 자식의 기준이 `BODY`가 됐다. 격자와 핀이 히어로 전체를 덮고 실제 지도는 빈 판이 됐다. **시각 캡처를 열지 않았다면 GREEN으로 오판했다.**
---
## 11. 시나리오·단언 수를 잠근다
대규모 E2E가 중간 리팩터링에서 조용히 줄어드는 것을 막는다.
```js
const EXPECTED_SCENARIOS = 90;
const EXPECTED_ASSERTIONS = 350;
if (scenarios.length !== EXPECTED_SCENARIOS) throw new Error("scenario count drift");
if (assertionCount < EXPECTED_ASSERTIONS) throw new Error("assertion count drift");
```
숫자를 채우려고 같은 검사를 복제하지 않는다. 공통·고객·관리자·오류·접근성·시각 상태로 분류하고, 각 시나리오는 `행동 → 기대 DOM/상태 → 부작용 없음`을 단언한다.
정적 파일도 재귀 glob으로 잡는다. 새 서브페이지가 생겼는데 `public/work/*/index.html`만 검사하면 관리자·가맹 화면이 검증 밖으로 빠진다.
---
## 12. 구현과 사용자 게이트에서 검증된 교훈 21개
| # | 교훈 | 스킬에 반영하는 규칙 |
|---|---|---|
| 1 | 서구 레퍼런스는 국내 행동 관습을 자동으로 주지 않는다 | R1을 목표 시장에서 우선 탐색 |
| 2 | 국내 감각은 장식보다 단위·용어·정보 순서에서 생긴다 | 시장 계약을 R1/R2/R3보다 먼저 작성 |
| 3 | 다른 업종 R2는 유지해야 카테고리 평균을 피한다 | 같은 문화권의 다른 업종을 우선 |
| 4 | 랜딩과 관리자는 같은 표현 밀도를 쓰면 둘 다 약해진다 | 표면 다이얼 4종을 별도 선언 |
| 5 | 강조색은 비율만으로 통제되지 않는다 | 허용 역할과 최대 면적을 함께 적음 |
| 6 | 생성 이미지는 데이터 증거가 아니다 | 이미지=재질, DOM=가격·상태·근거 |
| 7 | 데스크톱 크롭은 모바일 핵심 피사체를 보장하지 않는다 | 의미가 바뀌면 모바일 원본 별도 생성 |
| 8 | 성공 행동이 다른 두 청중은 한 히어로에서 경쟁한다 | URL·IA를 분리하고 공통 브랜드만 공유 |
| 9 | AI 점수만 있으면 신뢰가 아니라 정밀함의 연출이다 | 사실·추론·불확실·수정·사람 검토를 병기 |
| 10 | 가상 데이터로 실제 규제 행동이 성공하면 버그다 | 행동별 요구 조건·미등록 기본 거절·합성 자료 차단 |
| 11 | 지도·도식만으로는 접근성과 사실 확인이 닫히지 않는다 | DOM 목록·표 쌍둥이를 의무화 |
| 12 | 전역 데모 띠는 스크롤 후 오해를 막지 못한다 | 주장·가격·행동 가까이에 진실 라벨 |
| 13 | 자동 승인은 검증 면제가 아니다 | 사용자 게이트 상태와 기계/시각 게이트를 분리 |
| 14 | 구조 PASS는 시각 PASS가 아니다 | 상태 캡처를 열어 직접 판정하고 재촬영 |
| 15 | 테스트 수는 리팩터링 중 조용히 줄어든다 | 시나리오·단언·캡처 수를 hard-lock |
| 16 | 채운 버튼과 텍스트 링크는 `align-items:center`여도 시각 중심이 어긋난다 | 실제 hitbox 44px·중심 y 차이 ≤2px를 단언 |
| 17 | 역할이 다른 섹션도 같은 5:7 제목+목록을 반복하면 구조가 복제돼 보인다 | 장소=도식, 비용=원장, 판정=흐름처럼 의미에 맞는 토폴로지를 배정 |
| 18 | 인스펙터만 숨기고 그리드 트랙을 남기면 빈 면이 고장처럼 보인다 | 닫힘 상태에서 주 표면이 전체 폭을 회수하고 재선택 때 분할을 복원 |
| 19 | 같은 `예시` 경고를 모든 자식에 반복하면 가치와 경고가 함께 묻힌다 | 같은 진실 범위는 가장 가까운 명확한 공통 부모가 한 번 소유 |
| 20 | 공개 페이지에 내부 ID·게시 채널·감사 원장을 복제하면 제품 설명서가 된다 | 공개는 해석·제한·다음 행동, 관리자는 재현 가능한 내부 증거를 소유 |
| 21 | 보이는 입력과 계산 조건이 다르면 맞춤 분석은 임의 결과가 된다 | 입력 필드·판정 의존성·출력 주장을 한 SSOT 표로 잠그고 초기 미실행 상태를 검증 |
---
## 13. 통과 조건
- [ ] 시장 계약과 금지 문법이 적혀 있다
- [ ] R1은 목표 시장의 정보 관습을 설명하고, R2는 다른 업종이다
- [ ] 각 표면의 표현·밀도·모션·증거 다이얼이 적혀 있다
- [ ] 모든 숫자·주소·상태·AI 결과가 사실/예시/추론/미정 중 하나이고 출처 계층이 추적된다
- [ ] 진실 라벨이 전역 공지뿐 아니라 오해 지점 가까이에 있고, 같은 범위는 명확한 공통 부모가 한 번 소유하며 다른 자식만 예외 표시된다
- [ ] 데스크톱·모바일 이미지 슬롯과 폴백이 따로 정의됐다
- [ ] 고위험 업종의 생성 이미지 금지 증거와 민감 입력·저장·전송 경계가 검증됐다
- [ ] 보이는 입력과 판정 의존성이 한 SSOT이고, 분석 전 완료 결과가 없으며 복합 비용 일부로 적합을 확정하지 않는다
- [ ] AI 결과에 출처가 붙은 입력/검증 사실·추론·불확실성과 수정·사람 검토 경로가 있다
- [ ] 규제 행동은 필수 검증이 없을 때 실패 폐쇄형이다
- [ ] 공개는 사용자 해석·제한·다음 행동을, 관리자는 재현 가능한 내부 증거를 소유한다
- [ ] 지도·차트·도식의 DOM 쌍둥이가 있다
- [ ] URL별 390·1440 기본 캡처와 핵심 상태 캡처를 실제로 열어 봤다
- [ ] E2E 시나리오·단언·캡처 수가 잠겨 있고 재귀 정적 검사가 새 서브페이지를 포함한다
하나라도 빠지면 4단계로 돌아간다. 정직함을 각주로 미루지 말고 구조로 해결한다.

View file

@ -26,6 +26,7 @@ const DEFAULT_CFG = {
viewAttribute: "data-view", viewAttribute: "data-view",
views: ["main"], views: ["main"],
h1: "h1", h1: "h1",
h1MaxLines: 1,
brand: ".brand", brand: ".brand",
}, },
], ],
@ -103,49 +104,105 @@ const cssFilesOf = (p) => {
return out; return out;
}; };
// 페이지가 공유 CSS·동적 JS·지역 토큰을 함께 쓰면 HTML 한 장과 파일명 규칙만으로
// 소유권을 추측할 수 없다. staticContract 는 검사 자체를 끄지 않고, 어떤 CSS를
// 어떤 문서 코퍼스로 대조하며 어느 파일이 토큰 권위인지 명시한다.
const contractFilesOf = (p, key, fallback) => {
const specs = p.staticContract?.[key];
if (!Array.isArray(specs)) return fallback;
return specs.map((file) => path.resolve(process.cwd(), file));
};
const scriptFilesOf = (p) => {
const out = [];
let html = "";
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } catch { return out; }
for (const tag of html.matchAll(/<script[^>]+src=["']([^"']+)["'][^>]*>/g)) {
const src = tag[1];
if (/^(?:https?:|data:)/.test(src)) continue;
const file = path.resolve(pageDir(p), src.split("?")[0]);
if (fs.existsSync(file) && !out.includes(file)) out.push(file);
}
return out;
};
const selectorDocumentsOf = (p) => contractFilesOf(
p,
"selectorDocuments",
[path.resolve(process.cwd(), p.path), ...scriptFilesOf(p)],
);
const missingFiles = (files) => files.filter((file) => !fs.existsSync(file));
if (CFG.checks.deadCss) { if (CFG.checks.deadCss) {
const ignore = CFG.deadCssIgnore || []; const ignore = CFG.deadCssIgnore || [];
for (const p of CFG.pages) { for (const p of CFG.pages) {
let html = ""; const files = contractFilesOf(p, "selectorFiles", cssFilesOf(p));
try { html = fs.readFileSync(path.resolve(process.cwd(), p.path), "utf8"); } const documents = selectorDocumentsOf(p);
catch { skip(`${p.name} 죽은 선택자`, "HTML 읽기 실패"); continue; } const missing = missingFiles([...files, ...documents]);
const files = cssFilesOf(p); if (missing.length) {
fail(`${p.name} 죽은 선택자 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; } if (!files.length) { skip(`${p.name} 죽은 선택자`, "로컬 CSS 없음"); continue; }
const corpus = documents.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const dead = new Set(); const dead = new Set();
for (const f of files) { for (const f of files) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) { for (const m of src.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)) {
const cls = m[1]; const cls = m[1];
if (ignore.some((pfx) => cls.startsWith(pfx))) continue; if (ignore.some((pfx) => cls.startsWith(pfx))) continue;
if (!html.includes(cls)) dead.add(cls); if (!corpus.includes(cls)) dead.add(cls);
} }
} }
dead.size dead.size
? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체") ? fail(`${p.name} 죽은 선택자 ${dead.size}종`, [...dead].slice(0, 8).map((c) => "." + c).join(" ") + (dead.size > 8 ? " …" : "") + " — 제거는 HTML·CSS·JS 삼위일체")
: pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS 전 클래스 사용 중(무시 ${ignore.length}접두사)`); : pass(`${p.name} 죽은 선택자`, `${files.length}개 CSS × ${documents.length}개 HTML·JS 문서(무시 ${ignore.length}접두사)`);
} }
} else skip("죽은 선택자", "checks.deadCss=false"); } else skip("죽은 선택자", "checks.deadCss=false");
if (CFG.checks.cssHygiene) { if (CFG.checks.cssHygiene) {
for (const p of CFG.pages) { for (const p of CFG.pages) {
const files = cssFilesOf(p); const files = contractFilesOf(p, "hygieneFiles", cssFilesOf(p));
if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; } if (!files.length) { skip(`${p.name} 토큰 위생`, "로컬 CSS 없음"); continue; }
// 토큰 파일 = 파일명에 tokens 가 있는 것. 이 안의 값이 프로젝트의 단일 원본이다 const inferredTokens = cssFilesOf(p).filter((f) => /tokens/i.test(path.basename(f)));
const tokenFiles = files.filter((f) => /tokens/i.test(path.basename(f))); const tokenFiles = contractFilesOf(p, "tokenFiles", inferredTokens);
const others = files.filter((f) => !tokenFiles.includes(f)); const missing = missingFiles([...files, ...tokenFiles]);
const hexes = []; const radii = new Set(); if (missing.length) {
for (const f of others) { fail(`${p.name} 토큰 위생 계약`, `없는 파일 ${missing.map((file) => path.relative(process.cwd(), file)).join(", ")}`);
continue;
}
const tokenNames = new Set();
for (const f of new Set([...tokenFiles, ...files])) {
const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); const src = fs.readFileSync(f, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) hexes.push(`${path.basename(f)}:${m[0]}`); for (const m of src.matchAll(/(--[A-Za-z0-9_-]+)\s*:/g)) tokenNames.add(m[1]);
for (const m of src.matchAll(/(?<![\w.-])rgba?\(/g)) hexes.push(`${path.basename(f)}:${m[0]}`); }
const colors = []; const colorKinds = new Set(); const radii = new Set();
for (const f of files) {
// 어느 파일에 있든 custom property 선언은 지역 토큰이다. 선언값을 다시
// 하드코딩으로 세면 :root 토큰을 만든 페이지가 오히려 실패한다.
const src = fs.readFileSync(f, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/--[A-Za-z0-9_-]+\s*:\s*([^;{}]+);/g, "");
for (const m of src.matchAll(/#[0-9a-fA-F]{3,8}\b|(?:rgb|rgba|hsl|hsla|oklch|lab|lch)\([^)]*\)/g)) {
colors.push(`${path.basename(f)}:${m[0]}`);
colorKinds.add(m[0].toLowerCase().replace(/\s+/g, " "));
}
for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim()); for (const m of src.matchAll(/border-radius:\s*([^;]+);/g)) if (!m[1].includes("var(")) radii.add(m[1].trim());
} }
const budgets = p.staticContract?.budgets || {};
const hasColorBudget = Number.isFinite(budgets.colorLiterals);
const hasRadiusBudget = Number.isFinite(budgets.radiusLiterals);
const colorLimit = hasColorBudget ? budgets.colorLiterals : (tokenFiles.length ? 0 : null);
const radiusLimit = hasRadiusBudget ? budgets.radiusLiterals : null;
const bad = []; const bad = [];
if (tokenFiles.length && hexes.length) bad.push(`토큰 밖 색 리터럴 ${hexes.length}건(토큰 파일이 있는데 상수색)`); if (colorLimit !== null && colors.length > colorLimit) bad.push(`색 리터럴 ${colors.length}건 > 계약 ${colorLimit}건`);
if (radii.size >= CFG.thresholds.radiusLiterals) bad.push(`radius 리터럴 ${radii.size}종([${[...radii].join(", ")}])`); if (radiusLimit !== null ? radii.size > radiusLimit : radii.size >= CFG.thresholds.radiusLiterals) {
bad.push(`radius 리터럴 ${radii.size}종 > 계약 ${radiusLimit ?? `<${CFG.thresholds.radiusLiterals}`}종([${[...radii].join(", ")}])`);
}
bad.length bad.length
? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (hexes.length ? " — 예: " + hexes.slice(0, 4).join(" ") : "")) ? fail(`${p.name} 토큰 위생`, bad.join(" · ") + (colors.length ? " — 예: " + colors.slice(0, 4).join(" ") : ""))
: pass(`${p.name} 토큰 위생`, `색 ${hexes.length}·radius ${radii.size} 리터럴(토큰 ${tokenFiles.length}파일)`); : pass(`${p.name} 토큰 위생`, `색 ${colors.length}${colorLimit === null ? "" : `/${colorLimit}`}건(${colorKinds.size}종)·radius ${radii.size}${radiusLimit === null ? "" : `/${radiusLimit}`}종·토큰 ${tokenFiles.length}파일/${tokenNames.size}개`);
} }
} else skip("토큰 위생", "checks.cssHygiene=false"); } else skip("토큰 위생", "checks.cssHygiene=false");
@ -170,6 +227,12 @@ const UTILS = `
for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t); for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t);
return ls.length; return ls.length;
}; };
window.__textLines = (sel) => {
const el = typeof sel === "string" ? document.querySelector(sel) : sel;
if (!el) return -1;
const text = [...el.childNodes].find((node) => node.nodeType === 3 && node.textContent.trim());
return window.__lines(text || el);
};
`; `;
const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] }); const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] });
@ -234,7 +297,7 @@ for (const p of CFG.pages) {
if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg)) if (meta.themeColor.replace(/\s+/g, "").toLowerCase() !== hexOf(meta.bodyBg))
m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`); m.push(`theme-color ${meta.themeColor} ≠ 실제 배경 ${meta.bodyBg}(낡은 토큰 의심)`);
} }
if (meta.h1Count > 1) m.push(`h1 ${meta.h1Count}개(유일해야)`); if (meta.h1Count !== 1) m.push(`h1 ${meta.h1Count}개(정확히 하나여야)`);
if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`); if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`);
if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패"); if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패");
// 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증 // 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증
@ -263,10 +326,14 @@ for (const p of CFG.pages) {
} }
overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/")); overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/"));
// h1 1줄 // h1 줄 수 — 앱 화면은 기본 1줄, 마케팅 헤드라인은 페이지별로 최대 2줄까지 명시할 수 있다.
await page.setViewport({ width: 1440, height: 900 }); await page.setViewport({ width: 1440, height: 900 });
const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`); const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음")); const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
const h1Rule = h1MaxLines === 1 ? "h1 1줄" : `h1 최대 ${h1MaxLines}줄`;
h1l >= 1 && h1l <= h1MaxLines
? pass(`${p.name}/${v} ${h1Rule}`)
: (h1l > h1MaxLines ? fail(`${p.name}/${v} h1 ${h1l}줄(최대 ${h1MaxLines}줄)`) : skip(`${p.name}/${v} h1`, "요소 없음"));
// 세로 쌓임(수축) — 짧은 라벨 3줄 이상 // 세로 쌓임(수축) — 짧은 라벨 3줄 이상
if (CFG.checks.stack) { if (CFG.checks.stack) {
@ -366,6 +433,7 @@ for (const p of CFG.pages) {
// ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ── // ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ──
if (CFG.checks.scaleMatrix) { if (CFG.checks.scaleMatrix) {
const bad = []; const bad = [];
const h1MaxLines = Number.isInteger(p.h1MaxLines) && p.h1MaxLines > 0 ? p.h1MaxLines : 1;
for (const w of [375, 390]) { for (const w of [375, 390]) {
for (const s of CFG.fontScales) { for (const s of CFG.fontScales) {
await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true }); await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true });
@ -373,10 +441,10 @@ for (const p of CFG.pages) {
await page.evaluate(UTILS); await page.evaluate(UTILS);
await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` }); await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` });
const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`); const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`);
if (l > 1) bad.push(`${w}@${s}x:${l}줄`); if (l > h1MaxLines) bad.push(`${w}@${s}x:${l}줄(최대 ${h1MaxLines}줄)`);
} }
} }
bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`); bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `최대 ${h1MaxLines}줄 · scales ${CFG.fontScales.join("/")}`);
// 브랜드 1줄 // 브랜드 1줄
await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true });
await page.goto(href, { waitUntil: "networkidle0" }); await page.goto(href, { waitUntil: "networkidle0" });
@ -386,7 +454,7 @@ for (const p of CFG.pages) {
const bl = await page.evaluate((sel) => { const bl = await page.evaluate((sel) => {
const els = [...document.querySelectorAll(sel)]; const els = [...document.querySelectorAll(sel)];
const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0]; const el = els.find((e) => e.offsetParent !== null || getComputedStyle(e).position === "fixed") || els[0];
return window.__lines(el); return window.__textLines(el);
}, p.brand); }, p.brand);
bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`); bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`);
} }