feat(skill): 자율 검증 폐쇄 루프 내재화 — 0.6.0
Some checks failed
ci / build (push) Failing after 5s

- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다
- 5단계 기계 검사 4번째: design-gate 실행 의무화
- references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙
- tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합)
- 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0)

feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18)

- 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판
- LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png)
- 시간표 자동배치 결함 수리(명시적 격자 좌표)
- QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
This commit is contained in:
Yun Chan 2026-08-22 21:22:05 +09:00
parent 78ddc8b61a
commit 72337b7ee0
226 changed files with 18212 additions and 380 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,255 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>향연 (享煙) · 자연 침향 아카이브</title>
<meta
name="description"
content="연기는 흩어지고 향기만 머뭅니다. 자연 침향목의 수지 함량과 숙성 연도를 기록으로 남기는 향 아카이브. 서울 삼청동."
/>
<meta property="og:title" content="향연 (享煙) · 자연 침향 아카이브" />
<meta property="og:description" content="수지 함량과 숙성 연도를 기록으로 남기는 침향 아카이브." />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#F6F2E9" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
/>
<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="./styles/main.css?v=2" />
<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='%23F6F2E9'/%3E%3Cpath d='M16 6c0 3 0 5 0 7m0 4c0 3 2 3 2 5m-2 2c0 2-2 3-2 5' fill='none' stroke='%237C3B24' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"
/>
</head>
<body>
<!-- 재질: 한지. 아주 옅은 섬유 그레인 — 사진이 아니라 종이 위에 있다는 감각 -->
<svg class="paper" aria-hidden="true">
<filter id="hanji">
<feTurbulence type="fractalNoise" baseFrequency="0.9 0.7" numOctaves="2" seed="7" stitchTiles="stitch" />
<feColorMatrix type="matrix" values="0 0 0 0 0.55 0 0 0 0 0.52 0 0 0 0 0.46 0 0 0 0.05 0" />
</filter>
<rect width="100%" height="100%" filter="url(#hanji)" />
</svg>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">향연 <span class="han">享煙</span></a>
<nav class="nav" aria-label="섹션">
<a href="#wood">향목</a>
<a href="#aging">숙성</a>
<a href="#collection">아카이브</a>
<a href="#visit">방문</a>
</nav>
</div>
</header>
<main id="main">
<!--
히어로 — 이 페이지의 리스크: 상품 사진 대신 연기 한 줄기.
여백이 주인공이고 사진은 그 여백을 가르는 흔적 하나다.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<p class="hero-kicker reveal">자연 침향 · 백단 · 서울 삼청동</p>
<h1 id="hero-h" class="reveal" style="--delay: 80ms">
연기는 흩어지고<br />향기만 머뭅니다
</h1>
<p class="lead reveal" style="--delay: 160ms">
향연은 향을 판매하기 전에 기록부터 남깁니다. 어느 해에 들인 나무인지,
수지가 몇이었는지, 연소에 몇 분이 걸렸는지. 향은 사라지는 것이니
남는 것은 문서뿐입니다.
</p>
<div class="hero-actions reveal" style="--delay: 220ms">
<a class="btn" href="#collection">아카이브 보기</a>
</div>
</div>
<figure class="hero-photo reveal" style="--delay: 120ms">
<img
src="./assets/smoke.webp"
alt="밝은 한지 위 검은 석 받침대에서 한 줄기 침향 연기가 곧게 피어오르는 모습"
width="900"
height="1200"
loading="eager"
fetchpriority="high"
/>
<figcaption class="vertical">침향 한 줄기 · 삼청동 아카이브룸</figcaption>
</figure>
</div>
</section>
<!--
향목 — 나무의 단면. 침향은 나무가 아니라 나무의 병(병해수지)이라는 것을
이 섹션에서만 설명한다.
-->
<section id="wood" class="section" aria-labelledby="wood-h">
<div class="wrap two">
<figure class="two-photo reveal">
<img
src="./assets/agarwood.webp"
alt="자연 침향 단면 — 짙은 수지 결과 하얀 목질이 함께 보이는 절편"
width="1100"
height="825"
loading="lazy"
/>
<figcaption>자연 침향 단면 · 수지와 목질</figcaption>
</figure>
<div class="two-text">
<h2 id="wood-h">병든 나무만이<br />이 향을 가집니다</h2>
<p>
침향은 나무의 향이 아니라 나무의 상처입니다. 상처 입은 나무가 스스로를
아무려기 위해 분비한 수지가 오랜 세월 굳은 것 — 그래서 심은 나무에서는
얻을 수 없고, 자연에서 쓰러진 나무에서만 길어 올립니다.
</p>
<p class="muted">
향연은 인공접종 목재를 다루지 않습니다. 매입 단계에서 수지 함량을
측정해 기록하고, 그 기록이 통과하지 못한 나무는 아카이브에 들어오지
않습니다.
</p>
</div>
</div>
</section>
<!--
숙성 — 아카이브의 뼈대. 연도가 곧 목록이다.
카드가 아니라 장부의 행: 연도 · 입목 · 수지 · 상태.
-->
<section id="aging" class="section band" aria-labelledby="aging-h">
<div class="wrap">
<div class="aging-head">
<h2 id="aging-h">숙성 장부</h2>
<p class="muted">
들여온 해와 수지 함량, 지금 상태입니다. 향은 재고가 아니라 연도입니다 —
같은 나무가 다시 들어오지 않습니다.
</p>
</div>
<div class="table-wrap reveal">
<table>
<caption class="sr-only">
숙성 장부 — 입목 연도, 산지, 수지 함량, 현재 상태
</caption>
<thead>
<tr>
<th scope="col">입목</th>
<th scope="col">산지</th>
<th scope="col" class="num">수지 함량</th>
<th scope="col">상태</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">2001년</th>
<td>베트남 카호아</td>
<td class="num">64.2%</td>
<td>열매 · 매진 임박</td>
</tr>
<tr>
<th scope="row">2012년</th>
<td>인도네시아 리아우</td>
<td class="num">51.8%</td>
<td>침향 절편 · 소량</td>
</tr>
<tr>
<th scope="row">2024년</th>
<td>캄보디아 프놈펜 인근</td>
<td class="num">47.3%</td>
<td>숙성 중 · 비매품</td>
</tr>
</tbody>
</table>
</div>
<p class="aging-note muted">
수지 함량: 에탄올 용출법 실측. 열매(초험)는 기증용으로만 남겨둡니다.
</p>
</div>
</section>
<!--
아카이브 — 세 개의 물건. 사진 없는 목록.
종이의 물성을 믿고 글자로만 판다(R2: 텍스트가 상품).
-->
<section id="collection" class="section" aria-labelledby="collection-h">
<div class="wrap">
<h2 id="collection-h">지금 만질 수 있는 것</h2>
<ol class="collection">
<li class="item reveal">
<div class="item-text">
<h3>자연 침향 절편</h3>
<p>2001년 입목 · 수지 64.2% · 10g</p>
</div>
<p class="item-price">180,000원</p>
</li>
<li class="item reveal" style="--delay: 80ms">
<div class="item-text">
<h3>백단 침 향</h3>
<p>인도 마이소르 노목 · 20g · 연소 45분</p>
</div>
<p class="item-price">42,000원</p>
</li>
<li class="item reveal" style="--delay: 160ms">
<div class="item-text">
<h3>침향 인엽</h3>
<p>리아우산 · 2012년 입목 · 3g</p>
</div>
<p class="item-price">96,000원</p>
</li>
</ol>
<p class="muted collection-note">
모든 물건은 삼청동 아카이브룸에서 직접 내어드립니다. 향을 맡아 보고
정하시면 됩니다 — 예약제이며 회당 한 분만 들어갑니다.
</p>
</div>
</section>
<!-- 방문 — 마지막 섹션이 푸터 역할 -->
<section id="visit" class="section visit" aria-labelledby="visit-h">
<div class="wrap visit-grid">
<div>
<h2 id="visit-h">삼청동, 예약제</h2>
<address>
서울 종로구 삼청로 00길 00<br />
경복궁역 5번 출구에서 도보 6분
</address>
</div>
<div class="visit-info">
<dl>
<div>
<dt>예약</dt>
<dd>화–토 · 11:00, 14:00, 16:00 회당 50분</dd>
</div>
<div>
<dt>문의</dt>
<dd><a class="link" href="mailto:archive@hyang.kr">archive@hyang.kr</a></dd>
</div>
</dl>
</div>
<p class="vertical visit-mark">향연 享煙</p>
</div>
</section>
</main>
<script>
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -0,0 +1,447 @@
@import url("./tokens.css");
/* ==========================================================================
향연 quiet-luxury. 한지 위의 장부.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
/* 한지 그레인 — 본문 뒤 전면, 알파 0.05. 이 페이지의 유일한 재질 효과 */
.paper {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
body > *:not(.paper) {
position: relative;
z-index: 2;
}
:where(h1, h2, h3) {
font-family: var(--font-display);
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.005em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
h3 {
font-size: var(--step-1);
}
:where(p, li, dd, dt, th, td, address, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
.link {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color var(--dur-quick) var(--ease-soft);
}
.link:hover {
border-color: var(--accent);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* 세로쓰기 — 이 스튜디오의 물성 어휘. 목요일의 화원과 같은 결, 다른 용도 */
.vertical {
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 0.3em;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 90%, transparent);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.wordmark .han {
color: var(--ink-muted);
font-weight: 400;
font-size: var(--step-0);
margin-left: var(--space-1);
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
padding-block: 2px;
border-bottom: 1px solid transparent;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--accent);
}
/* ── 히어로 ───────────────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-8) var(--space-7);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-7) var(--space-6);
align-items: start;
}
.hero-kicker {
font-size: var(--step--1);
color: var(--ink-muted);
letter-spacing: 0.14em;
margin-bottom: var(--space-5);
}
.lead {
max-width: 30em;
}
.hero-text .lead {
margin-top: var(--space-5);
}
.hero-actions {
margin-top: var(--space-6);
}
.btn {
display: inline-block;
border: 1px solid var(--ink);
color: var(--ink);
text-decoration: none;
padding: 0.8rem 2rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--ink);
color: var(--surface);
}
.hero-photo {
position: relative;
display: grid;
grid-template-columns: auto auto;
gap: var(--space-4);
justify-content: end;
align-items: start;
}
.hero-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.hero-photo figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
height: 14rem;
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 향목 (2단) ───────────────────────────────────────────────────────── */
.two {
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.two-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.two-photo figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
.two-text p + p {
margin-top: var(--space-4);
}
.two-text .muted {
margin-top: var(--space-4);
}
/* ── 숙성 장부 ────────────────────────────────────────────────────────── */
.aging-head {
max-width: 36em;
margin-bottom: var(--space-5);
}
.aging-head p {
margin-top: var(--space-3);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
text-align: left;
font-weight: 500;
font-size: var(--step--1);
color: var(--ink-muted);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--ink-faint);
}
tbody th {
font-family: var(--font-display);
font-weight: 500;
text-align: left;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
.num {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.aging-note {
margin-top: var(--space-3);
font-size: var(--step--1);
}
/* ── 아카이브 목록 ────────────────────────────────────────────────────── */
.collection {
list-style: none;
margin-top: var(--space-5);
padding: 0;
border-top: 1px solid var(--line);
}
.item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
padding-block: var(--space-4);
border-bottom: 1px solid var(--line);
}
.item h3 {
font-size: var(--step-1);
}
.item p {
font-size: var(--step--1);
color: var(--ink-muted);
margin-top: var(--space-1);
}
.item-price {
font-family: var(--font-display);
font-size: var(--step-1);
white-space: nowrap;
}
.collection-note {
margin-top: var(--space-4);
max-width: 34em;
}
/* ── 방문 ─────────────────────────────────────────────────────────────── */
.visit {
border-top: 1px solid var(--line);
}
.visit-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: var(--space-5) var(--space-7);
align-items: start;
}
.visit address {
font-style: normal;
margin-top: var(--space-3);
color: var(--ink-muted);
}
.visit-info dl {
display: grid;
gap: var(--space-3);
}
.visit-info dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.visit-info dd {
margin-top: 2px;
}
.visit-mark {
font-family: var(--font-display);
color: var(--ink-faint);
font-size: var(--step--1);
height: 11rem;
}
/* ── 등장 모션 — 다른 페이지보다 느리다 ──────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 16px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-grid,
.two {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero-photo {
max-width: 24rem;
grid-template-columns: 1fr auto;
justify-content: start;
}
.visit-grid {
grid-template-columns: 1fr;
}
.visit-mark {
display: none;
}
.hero {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
.item {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
}

View file

@ -0,0 +1,53 @@
/* ==========================================================================
향연 (享煙) 디자인 토큰
프리셋: quiet-luxury
컨셉 문장: 한지 위의 장부 여백이 주인공이고 연기 줄기가 흔적이다.
레퍼런스: 방향은 dogfood-incense(, 파일의 6축 수치는 audit-references.md
에서 날조로 판명 토큰 근거로 쓰지 않는다). 사이트 선택(정향·아카이브
계열의 절제) 방향 앵커로 쓰고, 값은 브리프에서 다시 정했다.
하지 않은 : 스티커형 상품 카드, 영어 eyebrow, 배경 음영 그라디언트,
숫자 카운트업. 이유: 기록물의 권위는 고요함에서 온다.
========================================================================== */
:root {
/* 타입 — 최대(60px) ÷ 본문(17px) ≈ 3.5배. 본문을 17px로 — 천천히 읽히는 페이지 */
--step--1: 0.8125rem; /* 13px — 캡션 */
--step-0: 1.0625rem; /* 17px — 본문 */
--step-1: 1.25rem; /* 20px — 소제목 */
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);/* 28px — 섹션 표제 */
--step-3: clamp(2.25rem, 1.4rem + 3.6vw, 3.75rem); /* 60px — 디스플레이 */
--leading-tight: 1.3;
--leading-normal: 1.9;
--font-display: "MaruBuri", serif;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
/* 색 — 한지 2단계 + 잉크 3단계 + 적갈 1. 정확한 유약색이 아니라 '건조한 종이' */
--surface: #f6f2e9;
--surface-raised: #efe9dc;
--ink: #2b2724;
--ink-muted: #6d655c;
--ink-faint: #9a9083;
--line: #ddd4c3;
--accent: #7c3b24; /* 적갈 — 잉크가 아니라 인주의 위험한 사촌 */
--accent-hover: #5f2c1a;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6.5rem;
--space-8: clamp(5rem, 3.4rem + 6vw, 10rem); /* 이 페이지의 여백은 다른 페이지보다 깊다 */
--radius: 2px;
--container: 1040px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 560ms; /* 느린 페이지 — 등장도 느리게 */
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}