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: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1,210 @@
<!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="천 년의 비취색, 흙과 불이 빚은 침묵의 곡선. 고려청자 유약의 빙렬과 상감을 세 개의 방에서 감상하는 특별전. 2026년 가을, 서울."
/>
<meta property="og:title" content="비색의 침묵 · 고려청자 특별전" />
<meta
property="og:description"
content="유약의 빙렬과 상감 문양을 세 개의 방에서 감상하는 특별전."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#121514" />
<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='%23121514'/%3E%3Cpath d='M11 6h10c-1 3-1 5 0 7 2 3 3 5 3 8 0 4-3 7-8 7s-8-3-8-7c0-3 1-5 3-8 1-2 1-4 0-7z' fill='none' stroke='%236FA08F' stroke-width='1.5'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">비색의 침묵</a>
<nav class="nav" aria-label="섹션">
<a href="#room-1">빙렬의 방</a>
<a href="#room-2">상감의 방</a>
<a href="#visit">관람</a>
</nav>
</div>
</header>
<main id="main">
<!--
첫 방 — 암실. 전시 포스터가 아니라 첫 유물 하나.
명암이 곧 구조다: 사진이 어두울수록 유약이 가까워진다.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<figure class="hero-figure reveal">
<img
src="./assets/ewer-dark.webp"
alt="암실 조명 아래 어두운 받침대 위에 놓인 고려청자 주자의 비취색 실루엣"
width="960"
height="1280"
loading="eager"
fetchpriority="high"
/>
<figcaption>
<span>청자 상감운학문 주자 · 12세기</span>
<span>호암미술관 소장</span>
</figcaption>
</figure>
<div class="hero-text">
<p class="hero-kicker reveal" style="--delay: 100ms">
특별전 · 2026 가을
</p>
<h1 id="hero-h" class="reveal" style="--delay: 160ms">
천 년의 비색은<br />말이 없다
</h1>
<p class="lead reveal" style="--delay: 240ms">
비색(翡色)은 말로 설명되는 색이 아닙니다. 1,280°C의 환원 불에서만
나오는, 푸르름과 푸름이 아닌 사이의 색. 이 전시는 그 색을 설명하지
않고 세 개의 암실에 놓아 두었습니다.
</p>
<div class="hero-actions reveal" style="--delay: 300ms">
<a class="btn" href="#visit">도슨트 예약</a>
<p class="hero-note">9월 5일 11월 22일 · 국립중앙박물관 기획전시실</p>
</div>
</div>
</div>
</section>
<!--
첫 번째 방 — 빙렬. 유약이 식으며 스스로 그은 그림.
풀블리드 판: 페이지에서 가장 큰 면적을 질감에 내어준다.
-->
<section id="room-1" class="section plate" aria-labelledby="room1-h">
<div class="wrap plate-head">
<p class="room-no reveal">제1실</p>
<h2 id="room1-h" class="reveal" style="--delay: 60ms">빙렬 — 유약이 남긴 지도</h2>
<p class="lead reveal" style="--delay: 120ms">
가마에서 꺼내진 청자는 식으면서 유약이 갈라집니다. 흙과 유약이 줄어드는
속도가 다르기 때문입니다. 그 균열은 결함이 아니라, 그릇마다 단 한 번만
그려지는 지도입니다.
</p>
</div>
<figure class="plate-figure reveal" style="--delay: 160ms">
<img
src="./assets/crazing.webp"
alt="청자 유약 표면의 빙렬 — 미세한 균열망이 비취색 유약 위에 퍼져 있다"
width="1600"
height="1200"
loading="lazy"
/>
<figcaption class="wrap">
빙렬 확대 · 12세기 청자 표면 · 측광
</figcaption>
</figure>
</section>
<!--
두 번째 방 — 상감. 새기고 채우는 글자 없는 문장.
사진은 절반만. 나머지 절반은 문양의 문법을 텍스트로.
-->
<section id="room-2" class="section" aria-labelledby="room2-h">
<div class="wrap two">
<div class="two-text">
<p class="room-no reveal">제2실</p>
<h2 id="room2-h" class="reveal" style="--delay: 60ms">
상감 — 새기고,<br />흙으로 채우다
</h2>
<p class="reveal" style="--delay: 120ms">
상감은 그림을 그리는 게 아니라 파내는 기술입니다. 흙 그릇에 무늬를
새기고, 그 홈에 백토와 자토를 문질러 채운 뒤 유약을 입힙니다. 불에
들어가기 전까지는 무늬가 보이지 않습니다 — 가마를 열기 전까지 장인도
결과를 알 수 없었습니다.
</p>
<p class="muted reveal" style="--delay: 180ms">
제2실에는 구름·학·버드나무의 세 가지 문양만을 전시합니다. 상감청자의
문양은 자연의 모방이 아니라, 보이지 않는 것(구름, 바람, 계절)을
보이는 것으로 옮긴 문법입니다.
</p>
</div>
<figure class="two-photo reveal" style="--delay: 100ms">
<img
src="./assets/inlay.webp"
alt="청자 상감 문양 접사 — 흑백 상감 획이 비취 유약 위에 새겨져 있다"
width="1100"
height="825"
loading="lazy"
/>
<figcaption>상감 모란당초문 · 접사</figcaption>
</figure>
</div>
</section>
<!--
관람 — 예약과 안내. 마지막 방은 서류다.
-->
<section id="visit" class="section band" aria-labelledby="visit-h">
<div class="wrap visit-grid">
<div>
<p class="room-no reveal">안내</p>
<h2 id="visit-h" class="reveal" style="--delay: 60ms">세 개의 방, 예약제</h2>
<p class="reveal" style="--delay: 120ms">
암실 조명 때문에 회당 인원을 30명으로 제한합니다. 도슨트는 하루 세 번,
유약과 가마 이야기만 합니다.
</p>
</div>
<dl class="visit-dl reveal" style="--delay: 160ms">
<div>
<dt>기간</dt>
<dd>2026년 9월 5일 11월 22일 · 월요일 휴관</dd>
</div>
<div>
<dt>시간</dt>
<dd>10:0018:00 · 금·토 21:00까지</dd>
</div>
<div>
<dt>도슨트</dt>
<dd>11:00 / 14:00 / 16:00 · 회당 40분</dd>
</div>
<div>
<dt>예약</dt>
<dd><a class="link" href="#visit">국립중앙박물관 예약 시스템</a></dd>
</div>
</dl>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>비색의 침묵 · 고려청자 특별전</p>
<p class="muted">국립중앙박물관 기획전시실 · 서울 용산</p>
</div>
</footer>
<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,368 @@
@import url("./tokens.css");
/* ==========================================================================
비색의 침묵 editorial-archival. 암실의 개의 .
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@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;
}
:where(h1, h2) {
font-family: var(--font-display);
font-weight: 500;
line-height: var(--leading-tight);
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
:where(p, li, dd, dt, th, td, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.lead {
color: var(--ink-muted);
max-width: 32em;
}
.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;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
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;
}
.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-7);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
align-items: end;
}
.hero-figure img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.hero-figure figcaption {
display: flex;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-2);
font-size: var(--step--1);
color: var(--ink-faint);
}
.hero-kicker {
font-size: var(--step--1);
color: var(--accent);
letter-spacing: 0.12em;
margin-bottom: var(--space-4);
}
.hero-text .lead {
margin-top: var(--space-4);
}
.hero-actions {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.hero-note {
font-size: var(--step--1);
color: var(--ink-faint);
}
.btn {
display: inline-block;
background: var(--ink);
color: var(--surface);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--ink-bright);
}
/* ── 방 번호 ──────────────────────────────────────────────────────────── */
.room-no {
font-size: var(--step--1);
color: var(--accent-dim);
letter-spacing: 0.4em;
margin-bottom: var(--space-3);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 제1실 — 풀블리드 판 ──────────────────────────────────────────────── */
.plate-head {
max-width: 40em;
}
.plate-head h2 {
margin-top: var(--space-1);
}
.plate-head .lead {
margin-top: var(--space-4);
}
.plate-figure {
margin-top: var(--space-6);
}
.plate-figure img {
display: block;
width: 100%;
height: auto;
}
.plate-figure figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 제2실 — 2단 ──────────────────────────────────────────────────────── */
.two {
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.two-text h2 {
margin-top: var(--space-1);
}
.two-text p + p {
margin-top: var(--space-4);
}
.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);
}
/* ── 안내 ─────────────────────────────────────────────────────────────── */
.visit-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.visit-grid h2 {
margin-top: var(--space-1);
}
.visit-dl {
display: grid;
gap: var(--space-3);
}
.visit-dl dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.visit-dl dd {
margin-top: 2px;
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-5);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 14px;
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,
.visit-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero-grid {
align-items: start;
}
.hero-figure {
max-width: 26rem;
}
.hero,
.section {
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;
}
}

View file

@ -0,0 +1,54 @@
/* ==========================================================================
비색의 침묵 디자인 토큰
프리셋: editorial-archival
컨셉 문장: 암실의 개의 사진이 어두울수록 유약이 가까워진다.
레퍼런스: 방향 앵커는 dogfood-celadon(국립중앙박물관 전시 계열). 파일의
6축 수치는 audit-references.md 에서 날조로 판명 토큰은 브리프에서
다시 정했다. 박물관 암실의 실제 조건(어두운 , 스포트라이트)에서 유래.
하지 않은 : 포스터형 그라디언트 배경, 관람객 지표, 전시 인트로 영상
자리. 이유: 암실의 권위는 어둠의 면적에서 나온다.
========================================================================== */
:root {
/* 타입 — 최대(58px) ÷ 본문(16px) ≈ 3.6배 */
--step--1: 0.8125rem; /* 13px */
--step-0: 1rem; /* 16px */
--step-1: 1.25rem; /* 20px */
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem); /* 28px */
--step-3: clamp(2.125rem, 1.4rem + 3.4vw, 3.625rem);/* 58px */
--leading-tight: 1.3;
--leading-normal: 1.85;
--font-display: "MaruBuri", serif;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
/* 색 — 암실. 배경은 순흑이 아니라 석재 회색(벽), 강조는 비색 그 자체. */
--surface: #121514;
--surface-raised: #191d1c;
--ink: #eceae2; /* 백색이 아니라 골동지의 밝기 */
--ink-muted: #a7a69b;
--ink-faint: #7b7d76;
--line: #2b302e;
--accent: #8fbcaa; /* 비색 — 텍스트용으로 밝힘. 배경 채움 금지 */
--accent-dim: #5e8274;
--ink-bright: #ffffff; /* CTA hover */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
--radius: 2px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 480ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

View file

@ -0,0 +1,30 @@
/* 두레 수강신청 — 순수 계산 로직. 브라우저와 Node 테스트가 같은 함수를 공유한다. */
(function (global) {
"use strict";
// 두 과목의 시간 겹침 — 닿는 경계(end == start)는 겹침이 아니다.
const isOverlap = (a, b) => {
const daysA = a.days || [], daysB = b.days || [];
const shared = daysA.some((d) => daysB.includes(d));
if (!shared) return false;
return a.start < b.end && b.start < a.end;
};
const credits = (list) => (list || []).reduce((acc, c) => acc + (c.credits || 0), 0);
// 학점 초과 예측 — 현재 신청 학점에 이 과목을 더했을 때 기준 초과 여부
const wouldExceed = (currentCredits, courseCredits, max) =>
currentCredits + courseCredits > max;
const seatsState = (c) => {
if (!c.seats) return { label: "정원 마감", closed: true, near: false };
return { label: `여석 ${c.seats}`, closed: false, near: c.seats <= 5 };
};
// 이수 잔여 — 음수면 '초과'
const remaining = (need, done, now) => need - done - now;
const api = { isOverlap, credits, wouldExceed, seatsState, remaining };
global.DURE = api;
if (typeof module !== "undefined") module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -0,0 +1,771 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>두레대학 수강신청 · 시간표</title>
<meta property="og:type" content="website" />
<meta property="og:title" content="두레대학 수강신청 — 시간표 도장 앱" />
<meta property="og:url" content="https://designpaca.chanpaca.net/work/dure-enrollment/" />
<meta property="og:image" content="https://designpaca.chanpaca.net/work/dure-enrollment/og.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="두레대학 수강신청 — 시간표 도장 앱" />
<meta name="twitter:image" content="https://designpaca.chanpaca.net/work/dure-enrollment/og.png" />
<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="#FBFAF7" />
<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=JetBrains+Mono:wght@400;500&family=IBM+Plex+Sans+KR:wght@400;500;600&family=Noto+Serif+KR:wght@500;600&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=18" />
<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='%23FBFAF7'/%3E%3Crect x='6' y='6' width='20' height='20' fill='none' stroke='%2317202E' stroke-width='2'/%3E%3Crect x='13' y='13' width='6' height='6' fill='%23C14B2B'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<h1 class="sr-only">두레대학 수강신청</h1>
<header class="appbar">
<div class="appbar-inner">
<a class="brand" href="#main">
<span class="brand-seal" aria-hidden="true"></span>
두레대학 수강신청
</a>
<p class="credits" aria-live="polite">
신청 학점 <strong id="credit-now">0</strong> / 18
</p>
</div>
<nav class="tabs" aria-label="화면">
<button type="button" class="tab is-current" data-view="catalog" aria-current="page">과목 찾기</button>
<button type="button" class="tab" data-view="starred">관심 과목</button>
<button type="button" class="tab" data-view="enrollments">신청 내역</button>
<button type="button" class="tab" data-view="credits">이수 현황</button>
<button type="button" class="tab" data-view="archive">과목 총람</button>
<button type="button" class="tab" data-view="notices">공지 · 변경</button>
</nav>
</header>
<main id="main" class="layout">
<!-- ── 과목 찾기 ── -->
<section class="view is-on" id="view-catalog" aria-labelledby="cat-h" tabindex="-1">
<section class="catalog" aria-labelledby="cat-h">
<h2 class="v-h" id="cat-h">2026학년도 1학기 과목</h2>
<p class="view-sub">과목을 누르면 강의계획과 신청 버튼이 열립니다. 신청하면 시간표에 얹힙니다.</p>
<div class="sheet">
<div class="toolbar" role="search">
<label class="sr-only" for="q">과목 검색</label>
<input id="q" type="search" placeholder="과목명, 교수, 코드로 검색" autocomplete="off" />
<div class="chips" role="group">
<button type="button" class="chip is-on" data-dept="all" aria-pressed="true">전체</button>
<button type="button" class="chip" data-dept="전공" aria-pressed="false">전공</button>
<button type="button" class="chip" data-dept="교양" aria-pressed="false">교양</button>
</div>
</div>
<ul class="course-list" id="course-list"></ul>
<p class="empty muted" id="course-empty" hidden>조건에 맞는 과목이 없습니다.</p>
</div>
</section>
<aside class="timetable-panel" aria-labelledby="tt-h">
<h2 id="tt-h">내 시간표</h2>
<p class="muted tt-hint">시간표 블록을 누르면 취소할 수 있습니다.</p>
<div class="sheet sheet-tt">
<div class="tt-scroll">
<div class="tt" id="tt">
<!-- JS: 축 + 블록 -->
</div>
</div>
<p class="status" id="status" role="status" aria-live="polite"></p>
<p class="tt-legend">
<span class="key"><i class="k-major"></i>전공</span>
<span class="key"><i class="k-gen"></i>교양</span>
<span class="key"><i class="k-seal"></i>신청됨</span>
</p>
</div>
</aside>
</section>
<!-- ── 관심 과목 ── -->
<section class="view" id="view-starred" aria-labelledby="st-h" tabindex="-1">
<h2 class="v-h" id="st-h">관심 과목</h2>
<p class="view-sub">강의계획에서 관심을 남기면 여기에 모입니다. 여석과 시간은 실시간입니다.</p>
<div class="statline" id="st-stats"></div>
<p class="st-warn" id="st-warn" role="status" hidden></p>
<div class="toolbar">
<div class="chips" role="group" id="st-chips">
<button type="button" class="chip is-on" data-st="all" aria-pressed="true">전체</button>
<button type="button" class="chip" data-st="전공" aria-pressed="false">전공</button>
<button type="button" class="chip" data-st="교양" aria-pressed="false">교양</button>
</div>
</div>
<div class="sheet">
<ul class="course-list" id="starred-list"></ul>
<div class="empty-box" id="starred-empty" hidden>
<p>아직 관심 과목이 없습니다.</p>
<p class="muted">과목 찾기에서 과목을 열고 관심 남기기를 누르면 여기에 쌓입니다.</p>
<button type="button" class="btn-line" data-goto="catalog">과목 찾기로 가기</button>
</div>
<section class="panel st-suggest" id="st-suggest" aria-labelledby="stsg-h" hidden>
<h2 id="stsg-h">지금 여석이 임박한 과목 <span class="h-note">관심을 남겨 두면 마감 전에 확인할 수 있습니다</span></h2>
<ul class="course-list" id="st-suggest-list"><!-- JS --></ul>
</section>
</div>
</section>
<!-- ── 신청 내역 ── -->
<section class="view" id="view-enrollments" aria-labelledby="en-h" tabindex="-1">
<h2 class="v-h" id="en-h">신청 내역</h2>
<p class="view-sub">이번 학기에 신청한 과목의 원장. 여기서도 취소할 수 있습니다.</p>
<div class="statline" id="en-stats"></div>
<div class="sheet">
<p class="en-sum mono">합계 <strong id="en-credits">0</strong>학점 / 18</p>
<div class="credit-gauge" role="img">
<i id="en-gauge" style="width: 0%"></i>
</div>
<p class="panel-note muted">정정 기간은 3/93/13입니다. 18학점 초과 신청은 학부 사무실 승인이 필요하고, 두 번 결석하면 탈강 처리됩니다 — 변경은 이 화면에서 할 수 있습니다.</p>
<div class="table-wrap">
<table id="en-table">
<caption class="sr-only">신청 과목 목록</caption>
<thead>
<tr><th scope="col">과목</th><th scope="col">시간</th><th scope="col">장소</th><th scope="col" class="num">학점</th><th scope="col">상태</th></tr>
</thead>
<tbody><!-- JS --></tbody>
</table>
</div>
<div class="empty-box" id="en-empty" hidden>
<p>신청한 과목이 없습니다.</p>
<p class="muted">시간표가 비어 있습니다. 과목 찾기에서 첫 과목을 얹어보세요.</p>
<button type="button" class="btn-line" data-goto="catalog">과목 찾기로 가기</button>
</div>
<p class="status" id="en-status" role="status" aria-live="polite"></p>
</div>
</section>
<!-- ── 이수 현황 ── -->
<section class="view" id="view-credits" aria-labelledby="cr-h" tabindex="-1">
<h2 class="v-h" id="cr-h">이수 현황</h2>
<p class="view-sub">네 학기를 마친 시점의 기록에 이번 학기 신청을 얹어 봅니다. 신청은 확정이 아닙니다.</p>
<div class="statline" id="cr-stats"></div>
<div class="sheet table-wrap">
<table id="cr-table">
<caption class="sr-only">졸업 기준 대비 이수 학점</caption>
<thead>
<tr>
<th scope="col">구분</th><th scope="col" class="num">졸업 기준</th>
<th scope="col" class="num">이수</th><th scope="col" class="num">이번 학기 신청</th>
<th scope="col" class="num">합계</th><th scope="col" class="num">잔여</th><th scope="col">진행</th>
</tr>
</thead>
<tbody><!-- JS --></tbody>
</table>
<p class="sheet-note muted">잔여가 0보다 작아지면 초과입니다. 학점 초과 신청은 학부 사무실 승인이 필요합니다.</p>
<div class="cr-total">
<p class="panel-note muted">졸업 기준 대비 총 이수율 <strong class="cr-total-num mono"></strong></p>
<div class="cr-bar"><i id="cr-total-bar" style="width: 0%"></i></div>
</div>
</div>
</section>
<!-- ── 과목 총람 ── -->
<section class="view" id="view-archive" aria-labelledby="ar-h" tabindex="-1">
<h2 class="v-h" id="ar-h">과목 총람</h2>
<p class="view-sub">개설 과목 전부를 계열별로 묶어 적습니다. 여석이 5 이하인 과목은 붉게 표시합니다.</p>
<div class="statline" id="ar-stats"></div>
<div class="sheet table-wrap">
<table id="ar-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" class="num">학점</th>
<th scope="col" class="num">여석 / 정원</th>
</tr>
</thead>
<tbody><!-- JS --></tbody>
</table>
</div>
</section>
<!-- ── 공지 · 변경 ── -->
<section class="view" id="view-notices" aria-labelledby="no-h" tabindex="-1">
<h2 class="v-h" id="no-h">공지 · 변경</h2>
<div class="statline" id="nc-stats"></div>
<p class="view-sub">수강신청 기간의 게시판. 강의실 변경은 이 원장에 남깁니다.</p>
<div class="sheet">
<ul class="notice-list" id="notice-list"><!-- JS --></ul>
</div>
</section>
</main>
<!-- ── 강의계획 모달: 펼침 장부. 왼쪽 남색 표지판 + 오른쪽 계획 내용 ── -->
<dialog id="course-dialog" class="modal modal-split" aria-labelledby="cd-h">
<div class="mspread">
<div class="mcover">
<span class="mcover-mark" aria-hidden="true"></span>
<span class="mcover-seal" aria-hidden="true"></span>
<h2 class="mcover-name" id="cd-h">과목명</h2>
<p class="mcover-meta mono" id="cd-code">CODE · 계열</p>
<dl class="mcover-stats">
<div><dt>학점</dt><dd class="mono" id="cd-credit"></dd></div>
<div><dt>여석</dt><dd class="mono" id="cd-seats"></dd></div>
<div><dt>시간</dt><dd class="mono" id="cd-time"></dd></div>
</dl>
<p class="mcover-foot mono">2026학년도 1학기</p>
</div>
<div class="msheet">
<button type="button" class="modal-close" data-close aria-label="닫기">
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /></svg>
</button>
<p class="cd-line" id="cd-prof-line">교수 · 강의실</p>
<section class="cd-week msec">
<h3>주간 자리</h3>
<div class="cd-strip" id="cd-strip" role="img"></div>
</section>
<section class="eval msec">
<h3>평가</h3>
<div class="eval-bar" id="cd-eval-bar" aria-hidden="true"></div>
<dl class="eval-legend" id="cd-eval"></dl>
</section>
<p class="warn" id="cd-warn" hidden></p>
<div class="modal-actions">
<button type="button" class="btn-seal" id="cd-apply">수강 신청</button>
<button type="button" class="btn-line" id="cd-cancel" hidden>신청 취소</button>
<button type="button" class="btn-star" id="cd-star" aria-pressed="false">관심 남기기</button>
</div>
</div>
</div>
</dialog>
<script src="calc.js"></script>
<script>
/*
수강신청 상태 기계 — 겹침 검사, 학점 한도, 여석 감소까지 전부 진짜.
여섯 화면이 같은 상태(enrolled, starred)를 공유한다.
새로고침하면 초기화됩니다(데모 원칙: 저장을 흉내내지 않는다).
*/
const fmtDay = (d) => ({ 1: "월", 2: "화", 3: "수", 4: "목", 5: "금" })[d];
const COURSES = [
{ id: "hum201", code: "HUM201", name: "문학과 사회", dept: "교양", prof: "김선희", room: "인문관 302", credits: 3, cap: 40, seats: 12, days: [1, 3], start: 10, end: 11.5, eval: [["출석", 10], ["발표", 30], ["기말", 60]] },
{ id: "cs101", code: "CS101", name: "컴퓨팅 사고", dept: "교양", prof: "이준호", room: "공학관 105", credits: 3, cap: 60, seats: 31, days: [2, 4], start: 13, end: 14.5, eval: [["과제", 40], ["중간", 30], ["기말", 30]] },
{ id: "econ210", code: "ECON210", name: "미시경제학", dept: "전공", prof: "박민재", room: "경상관 210", credits: 3, cap: 45, seats: 8, days: [1, 3], start: 13, end: 14.5, eval: [["과제", 30], ["중간", 30], ["기말", 40]] },
{ id: "stat110", code: "STAT110", name: "통계의 기초", dept: "교양", prof: "정하나", room: "자연관 118", credits: 3, cap: 50, seats: 20, days: [5], start: 9, end: 12, eval: [["퀴즈", 20], ["중간", 40], ["기말", 40]] },
{ id: "phil115", code: "PHIL115", name: "논리와 토론", dept: "교양", prof: "오세영", room: "인문관 115", credits: 2, cap: 35, seats: 14, days: [2], start: 16, end: 18, eval: [["발표", 50], ["기말", 50]] },
{ id: "soc220", code: "SOC220", name: "도시와 사람", dept: "전공", prof: "한유진", room: "사회관 204", credits: 3, cap: 40, seats: 17, days: [4], start: 10, end: 12.5, eval: [["리포트", 40], ["발표", 20], ["기말", 40]] },
{ id: "bio105", code: "BIO105", name: "생명과학 산책", dept: "교양", prof: "남기훈", room: "자연관 B1", credits: 2, cap: 60, seats: 25, days: [1], start: 14, end: 16, eval: [["출석", 20], ["리포트", 80]] },
{ id: "art130", code: "ART130", name: "드로잉 입문", dept: "교양", prof: "서지우", room: "예술관 301", credits: 3, cap: 25, seats: 3, days: [3], start: 14, end: 17, eval: [["작품", 60], ["기말작품", 40]] },
{ id: "econ315", code: "ECON315", name: "게임이론", dept: "전공", prof: "최도윤", room: "경상관 308", credits: 3, cap: 30, seats: 6, days: [2, 4], start: 10, end: 11.5, eval: [["과제", 30], ["중간", 30], ["기말", 40]] },
{ id: "cs210", code: "CS210", name: "자료구조", dept: "전공", prof: "강태양", room: "공학관 212", credits: 3, cap: 40, seats: 22, days: [5], start: 13, end: 16, eval: [["실습", 40], ["중간", 30], ["기말", 30]] },
];
const NOTICES = [
{ date: "03-08", kind: "변경", body: "게임이론(ECON315) 강의실이 경상관 308호에서 309호로 바뀝니다. 이 목록에는 이미 반영했습니다." },
{ date: "03-07", kind: "공지", body: "수강신청 기간은 3월 2일부터 3월 13일 자정까지입니다. 기간 끝에는 시간표가 확정됩니다." },
{ date: "03-06", kind: "안내", body: "기준 학점(18)을 넘는 신청은 학부 사무실의 승인을 받아야 합니다. 이 데모에서는 넘는 신청을 막아둡니다." },
{ date: "03-05", kind: "안내", body: "드로잉 입문(ART130)은 재료비 3만 원이 듭니다. 첫 수업에 안내합니다." },
{ date: "03-03", kind: "공지", body: "정원이 마감된 과목은 대기자 신청을 받지 않습니다. 분반 개설은 학부 상황에 따라 결정됩니다." },
{ date: "03-02", kind: "공지", body: "2026학년도 1학기 수강신청이 열렸습니다. 시간이 겹치는 과목은 함께 담을 수 없습니다." },
];
const MAX_CREDITS = 18;
// 네 학기를 마친 시점의 이수 기록 — 이 데모의 가상 학생
const RECORD = [
{ label: "전공", need: 66, done: 45 },
{ label: "교양", need: 24, done: 21 },
{ label: "자유", need: 40, done: 28 },
];
const enrolled = new Set();
const starred = new Set();
const $ = (id) => document.getElementById(id);
const hhmm = (t) => `${Math.floor(t)}:${t % 1 ? "30" : "00"}`;
const timeLabel = (c) => c.days.map(fmtDay).join("·") + " " + hhmm(c.start) + "" + hhmm(c.end);
// ── 탭(뷰) 전환 ──
const views = document.querySelectorAll(".view");
const gotoView = (name) => {
document.querySelectorAll(".tab").forEach((b) => {
const on = b.dataset.view === name;
b.classList.toggle("is-current", on);
if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
});
views.forEach((v) => v.classList.toggle("is-on", v.id === "view-" + name));
$("view-" + name).focus({ preventScroll: true });
};
document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => gotoView(btn.dataset.view));
});
document.querySelectorAll("[data-goto]").forEach((btn) => {
btn.addEventListener("click", () => gotoView(btn.dataset.goto));
});
// ── 겹침 검사 ──
const conflictsWith = (course) =>
[...enrolled]
.map((id) => COURSES.find((c) => c.id === id))
.filter((o) => DURE.isOverlap(course, o))
.map((o) => o.name);
const credits = () => [...enrolled].reduce((sum, id) => sum + COURSES.find((c) => c.id === id).credits, 0);
const creditsOf = (dept) => DURE.credits([...enrolled].map((id) => COURSES.find((c) => c.id === id)).filter((c) => c.dept === dept));
const setStatus = (msg, isWarn) => {
const el = $("status");
el.textContent = msg;
el.classList.toggle("is-warn", !!isWarn);
};
// ── 과목 행(과목 찾기 · 관심 과목 공용 문법) ──
const courseRow = (c) => {
const li = document.createElement("li");
li.innerHTML = `
<button type="button" class="course-row" data-course="${c.id}">
<span class="c-main">
<span class="c-name">${c.name} <span class="c-dept">${c.dept}</span></span>
<span class="mono c-sub"><i>${c.code}</i> · <i>${c.prof}</i> · <i>${timeLabel(c)}</i> · <i>${c.room}</i></span>
</span>
<span class="c-side mono">
<span class="c-credit">${c.credits}학점</span>
<span class="c-seats ${c.seats <= 5 ? "is-low" : ""}">${c.seats === 0 ? "정원 마감" : "여석 " + c.seats}</span>
${starred.has(c.id) ? '<span class="c-star"></span>' : ""}
${enrolled.has(c.id) ? '<span class="c-seal"></span>' : ""}
</span>
</button>`;
return li;
};
// ── 과목 찾기 목록 ──
let deptFilter = "all";
const renderList = () => {
const q = $("q").value.trim();
const rows = COURSES.filter(
(c) =>
(deptFilter === "all" || c.dept === deptFilter) &&
(!q || c.name.includes(q) || c.prof.includes(q) || c.code.toLowerCase().includes(q.toLowerCase()))
);
$("course-list").replaceChildren(...rows.map(courseRow));
$("course-empty").hidden = rows.length> 0;
};
$("q").addEventListener("input", renderList);
document.querySelectorAll(".chip[data-dept]").forEach((chip) => {
chip.addEventListener("click", () => {
document.querySelectorAll(".chip[data-dept]").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", c === chip ? "true" : "false");
});
deptFilter = chip.dataset.dept;
renderList();
});
});
// ── 관심 과목 ──
let stDept = "all";
const renderStarred = () => {
const all = COURSES.filter((c) => starred.has(c.id));
const rows = all.filter((c) => stDept === "all" || c.dept === stDept);
$("starred-list").replaceChildren(...rows.map(courseRow));
$("starred-empty").hidden = rows.length> 0;
// 빈 상태일 때 — 여석 임박 추천으로 화면을 채운다(빈 상태는 안내다)
const suggest = $("st-suggest");
if (!all.length) {
suggest.hidden = false;
$("st-suggest-list").replaceChildren(
...COURSES.filter((c) => !enrolled.has(c.id) && c.seats> 0).sort((a, b) => a.seats - b.seats).slice(0, 3).map(courseRow)
);
} else {
suggest.hidden = true;
}
// 요약 — 관심 기준 실계산
const credits = all.reduce((a, c) => a + c.credits, 0);
const enrolledCredits = COURSES.filter((c) => enrolled.has(c.id)).reduce((a, c) => a + c.credits, 0);
const near = all.filter((c) => c.seats> 0 && c.seats <= 5).length;
const closed = all.filter((c) => c.seats === 0).length;
$("st-stats").innerHTML =
`<div>관심 <strong>${all.length}과목</strong></div>` +
`<div>합계 <strong>${credits}학점</strong></div>` +
`<div>여석 임박 <strong>${near}과목</strong></div>` +
`<div>정원 마감 <strong>${closed}과목</strong></div>`;
// 학점 초과 사전 경고 — 오류 예방
const warn = $("st-warn");
const wouldBe = enrolledCredits + credits;
if (!all.length) { warn.hidden = true; return; }
warn.hidden = false;
if (wouldBe> MAX_CREDITS) {
warn.textContent = `모두 신청하면 기준 ${MAX_CREDITS}학점을 ${wouldBe - MAX_CREDITS}학점 초과합니다(${wouldBe}학점) — 신청 전에 조합을 줄이세요.`;
warn.classList.add("is-over");
} else {
warn.textContent = `모두 신청해도 기준 ${MAX_CREDITS}학점 안에 듭니다 — 합계 ${wouldBe}학점 · 잔여 ${MAX_CREDITS - wouldBe}학점.`;
warn.classList.remove("is-over");
}
};
document.getElementById("st-chips").addEventListener("click", (e) => {
const chip = e.target.closest("[data-st]");
if (!chip) return;
document.querySelectorAll("#st-chips .chip").forEach((c) => {
c.classList.toggle("is-on", c === chip);
c.setAttribute("aria-pressed", String(c === chip));
});
stDept = chip.dataset.st;
renderStarred();
});
// ── 신청 내역 ──
const renderEnrollments = () => {
{
const list = COURSES.filter((c) => enrolled.has(c.id));
$("en-gauge").style.width = Math.round((list.reduce((a, c) => a + c.credits, 0) / MAX_CREDITS) * 100) + "%";
const credits = list.reduce((a, c) => a + c.credits, 0);
$("en-stats").innerHTML =
`<div>신청 <strong>${list.length}과목</strong></div>` +
`<div>${credits}학점 / ${MAX_CREDITS}</div>` +
`<div>잔여 <strong>${MAX_CREDITS - credits}학점</strong></div>` +
`<div>신청 변경 마감 <strong>3/13</strong></div>`;
}
const body = document.querySelector("#en-table tbody");
body.replaceChildren(
...[...enrolled].map((id) => COURSES.find((c) => c.id === id)).map((c) => {
const tr = document.createElement("tr");
tr.innerHTML =
`<th scope="row">${c.name}</th><td class="mono">${timeLabel(c)}</td><td>${c.room}</td>` +
`<td class="num mono">${c.credits}</td>` +
`<td><button type="button" class="give-btn" data-cancel="${c.id}">취소</button></td>`;
return tr;
})
);
$("en-empty").hidden = enrolled.size> 0;
$("en-credits").textContent = String(credits());
};
document.querySelector("#en-table tbody").addEventListener("click", (e) => {
const btn = e.target.closest("[data-cancel]");
if (!btn) return;
const c = COURSES.find((x) => x.id === btn.dataset.cancel);
enrolled.delete(c.id);
c.seats += 1;
$("en-status").textContent = `${c.name} 취소 · ${credits()}학점`;
renderAll();
});
// ── 이수 현황: 기록 + 이번 학기 신청을 합친다 ──
const renderCreditsView = () => {
const body = document.querySelector("#cr-table tbody");
const totalNeed = RECORD.reduce((a, r) => a + r.need, 0);
// 개설 과목은 전공·교양뿐이라 자유의 신청은 항상 0 — 표에는 '—' 로 나온다
const rows = RECORD.map((r) => ({
label: r.label,
need: r.need,
done: r.done,
now: creditsOf(r.label),
}));
const sum = rows.reduce(
(a, r) => ({ need: a.need + r.need, done: a.done + r.done, now: a.now + r.now }),
{ need: 0, done: 0, now: 0 }
);
{
const done = sum.done, now = sum.now, need = sum.need;
$("cr-stats").innerHTML =
`<div>기준 <strong>${need}학점</strong></div>` +
`<div>이수 <strong>${done}학점</strong></div>` +
`<div>이번 학기 신청 <strong>${now}학점</strong></div>` +
`<div>합계 <strong>${done + now}학점</strong></div>` +
`<div>잔여 <strong>${need - done - now}학점</strong></div>`;
}
{
$("cr-stats").innerHTML =
`<div>기준 <strong>${sum.need}학점</strong></div>` +
`<div>이수 <strong>${sum.done}학점</strong></div>` +
`<div>이번 학기 신청 <strong>${sum.now}학점</strong></div>` +
`<div>합계 <strong>${sum.done + sum.now}학점</strong></div>` +
`<div>잔여 <strong>${sum.need - sum.done - sum.now}학점</strong></div>`;
}
{
const pct = Math.min(100, Math.round(((sum.done + sum.now) / sum.need) * 100));
document.getElementById("cr-total-bar").style.width = pct + "%";
document.querySelector(".cr-total-num").textContent = pct + "%";
}
body.replaceChildren(
...rows.map((r) => {
const tr = document.createElement("tr");
const total = r.done + r.now;
const left = r.need - total;
const pct = r.need ? Math.min(100, Math.round((total / r.need) * 100)) : 0;
tr.innerHTML =
`<th scope="row">${r.label}</th><td class="num mono">${r.need || "—"}</td>` +
`<td class="num mono">${r.done || "—"}</td><td class="num mono">${r.now ? "+ " + r.now : "—"}</td>` +
`<td class="num mono">${r.need ? total : total || "—"}</td>` +
`<td class="num mono">${r.need ? (left> 0 ? left : left === 0 ? "0" : "초과") : "—"}</td>` +
`<td><div class="cr-bar" role="img"><i style="width: ${pct}%"></i></div></td>`;
return tr;
}),
(() => {
const tr = document.createElement("tr");
const left = totalNeed - sum.done - sum.now;
tr.innerHTML =
`<th scope="row">총계</th><td class="num mono">${totalNeed}</td>` +
`<td class="num mono">${sum.done}</td><td class="num mono">+ ${sum.now}</td>` +
`<td class="num mono"><strong>${sum.done + sum.now}</strong></td>` +
`<td class="num mono">${left> 0 ? left : left === 0 ? "0" : "초과"}</td>`;
return tr;
})()
);
};
// ── 과목 총람: 계열별 소계 포함 ──
const renderArchive = () => {
const body = document.querySelector("#ar-table tbody");
const frag = document.createDocumentFragment();
for (const dept of ["전공", "교양"]) {
const group = COURSES.filter((c) => c.dept === dept);
for (const c of group) {
const tr = document.createElement("tr");
tr.innerHTML =
`<th scope="row" class="mono">${c.code}</th><td>${c.name}</td><td>${c.prof}</td>` +
`<td class="mono">${timeLabel(c)}</td><td class="num mono">${c.credits}</td>` +
`<td class="num mono">${c.seats <= 5 ? `<span class="low-seats">${c.seats} / ${c.cap}</span>` : `${c.seats} / ${c.cap}`}</td>`;
frag.append(tr);
}
const sub = document.createElement("tr");
sub.className = "ar-sub";
const seats = group.reduce((a, c) => a + c.seats, 0);
sub.innerHTML =
`<th scope="row" colspan="4">${dept} 소계 · ${group.length}과목</th>` +
`<td class="num mono">${group.reduce((a, c) => a + c.credits, 0)}</td>` +
`<td class="num mono">여석 ${seats}</td>`;
frag.append(sub);
}
body.replaceChildren(frag);
{
const closed = COURSES.filter((c) => c.seats === 0).length;
const near = COURSES.filter((c) => c.seats> 0 && c.seats <= 5).length;
$("ar-stats").innerHTML =
`<div>개설 <strong>${COURSES.length}과목</strong></div>` +
`<div>전공 <strong>${COURSES.filter((c) => c.dept === "전공").length}</strong></div>` +
`<div>교양 <strong>${COURSES.filter((c) => c.dept === "교양").length}</strong></div>` +
`<div>여석 임박 <strong>${near}과목</strong></div>` +
`<div>정원 마감 <strong>${closed}과목</strong></div>`;
}
};
// ── 공지 ──
const renderNotices = () => {
{
const changed = NOTICES.filter((n) => n.kind === "변경").length;
$("nc-stats").innerHTML =
`<div>공지 <strong>${NOTICES.length}건</strong></div>` +
`<div>변경 <strong>${changed}건</strong></div>` +
`<div>신청 마감 <strong>3/13 자정</strong></div>`;
}
$("notice-list").replaceChildren(
...NOTICES.map((n) => {
const li = document.createElement("li");
li.innerHTML =
`<span class="no-kind" data-kind="${n.kind}">${n.kind}</span>` +
`<span class="mono no-date">${n.date}</span>` +
`<p class="no-body">${n.body}</p>`;
return li;
})
);
};
// ── 시간표 렌더: 축(정적) + 블록(신청분) ──
const buildAxis = () => {
const tt = $("tt");
tt.style.setProperty("--rows", 18); // 9:0018:00, 30분 단위
const hours = Array.from({ length: 9 }, (_, i) => 9 + i);
tt.innerHTML =
`<div class="tt-corner" aria-hidden="true" style="grid-area: 1 / 1"></div>` +
[1, 2, 3, 4, 5].map((d) => `<div class="tt-day" style="grid-row: 1; grid-column: ${d + 1}">${fmtDay(d)}</div>`).join("") +
hours
.map((h) => {
const row = (h - 9) * 2 + 2; // 1행은 요일 헤더, 9시=2행
return (
`<div class="tt-hour mono" style="grid-row: ${row}; grid-column: 1">${String(h).padStart(2, "0")}</div>` +
[1, 2, 3, 4, 5]
.map((d) => `<div class="tt-cell" style="grid-row: ${row} / span 2; grid-column: ${d + 1}"></div>`)
.join("")
);
})
.join("");
};
const renderBlocks = () => {
$("tt").querySelectorAll(".tt-block").forEach((b) => b.remove());
const tt = $("tt");
enrolled.forEach((id) => {
const c = COURSES.find((x) => x.id === id);
c.days.forEach((d) => {
const b = document.createElement("button");
b.type = "button";
b.className = `tt-block ${c.dept === "전공" ? "is-major" : "is-gen"}`;
b.dataset.course = c.id;
b.style.gridColumn = String(d + 1);
/* 행 오프셋 +2: 1행은 요일 헤더. 9시=(9-9)*2+2=2행 */
b.style.gridRow = `${(c.start - 9) * 2 + 2} / span ${(c.end - c.start) * 2}`;
b.innerHTML = `<span class="tt-block-name">${c.name}</span><span class="tt-block-room">${c.room}</span>`;
tt.append(b);
});
});
if (!enrolled.size) {
const empty = document.createElement("p");
empty.className = "tt-empty";
empty.textContent = "과목을 신청하면 여기에 놓입니다";
tt.append(empty);
}
$("credit-now").textContent = String(credits());
};
const renderAll = () => {
renderList();
renderStarred();
renderEnrollments();
renderCreditsView();
renderArchive();
renderBlocks();
};
// ── 모달 ──
const dialog = $("course-dialog");
let current = null;
const openCourse = (c) => {
current = c;
$("cd-code").textContent = `${c.code} · ${c.dept}`;
$("cd-h").textContent = c.name;
$("cd-prof-line").textContent = `${c.prof} 교수 · ${c.room}`;
$("cd-time").textContent = timeLabel(c);
$("cd-credit").textContent = String(c.credits);
$("cd-seats").textContent = `${c.seats} / ${c.cap}`;
// 주간 자리 — 9:0018:00 를 세로로 접은 미니 격자
const strip = $("cd-strip");
strip.setAttribute("aria-label", `${timeLabel(c)} — 시간표에 놓이는 자리`);
strip.innerHTML = [1, 2, 3, 4, 5]
.map((d) => {
const day = fmtDay(d);
if (!c.days.includes(d)) {
return `<div class="cd-track"><span class="cd-rail"><span class="cd-off mono" aria-hidden="true">·</span></span><span class="cd-day-l">${day}</span></div>`;
}
const top = ((c.start - 9) / 9) * 100;
const h = ((c.end - c.start) / 9) * 100;
return `<div class="cd-track"><span class="cd-rail"><span class="cd-slot ${c.dept === "전공" ? "is-major" : "is-gen"}" style="top:${top}%; height:${h}%"></span></span><span class="cd-day-l is-on">${day}</span></div>`;
})
.join("");
// 평가 — 같은 잉크의 농도로 누적 막대 하나에 담는다
$("cd-eval-bar").replaceChildren(
...c.eval.map(([, v], idx) => {
const seg = document.createElement("span");
seg.className = `eval-seg seg-${idx + 1}`;
seg.style.width = v + "%";
return seg;
})
);
$("cd-eval").replaceChildren(
...c.eval.map(([k, v]) => {
const div = document.createElement("div");
div.innerHTML = `<dt>${k}</dt><dd class="mono">${v}%</dd>`;
return div;
})
);
const apply = $("cd-apply");
const cancel = $("cd-cancel");
const warn = $("cd-warn");
warn.hidden = true;
cancel.hidden = !enrolled.has(c.id);
if (enrolled.has(c.id)) {
apply.hidden = true;
} else {
apply.hidden = false;
apply.disabled = true;
const clash = conflictsWith(c);
if (c.seats === 0) {
warn.textContent = "정원이 마감되었습니다. 다음 학기를 기다리거나 분반을 확인하세요.";
warn.hidden = false;
} else if (clash.length) {
warn.textContent = `${clash.join(", ")}와(과) 시간이 겹칩니다. 둘 중 하나만 담을 수 있습니다.`;
warn.hidden = false;
} else if (credits() + c.credits> MAX_CREDITS) {
warn.textContent = `기준 학점(${MAX_CREDITS})을 초과합니다. 지금 ${credits()}학점입니다.`;
warn.hidden = false;
} else {
apply.disabled = false;
}
}
const star = $("cd-star");
star.setAttribute("aria-pressed", String(starred.has(c.id)));
star.textContent = starred.has(c.id) ? "관심 빼기" : "관심 남기기";
dialog.showModal();
};
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-course]");
if (btn && !btn.dataset.cancel) openCourse(COURSES.find((c) => c.id === btn.dataset.course));
});
$("cd-apply").addEventListener("click", () => {
if (!current || $("cd-apply").disabled) return;
current.seats -= 1;
enrolled.add(current.id);
setStatus(`${current.name} 신청 완료 · ${timeLabel(current)} · ${credits()}학점`, false);
dialog.close();
renderAll();
});
$("cd-cancel").addEventListener("click", () => {
if (!current || !enrolled.has(current.id)) return;
enrolled.delete(current.id);
current.seats += 1;
setStatus(`${current.name} 취소 · ${credits()}학점`, false);
dialog.close();
renderAll();
});
$("cd-star").addEventListener("click", () => {
if (!current) return;
if (starred.has(current.id)) {
starred.delete(current.id);
setStatus(`${current.name} 관심에서 뺐습니다.`, false);
} else {
starred.add(current.id);
setStatus(`${current.name} 관심에 남겼습니다. 관심 과목 화면에서 보세요.`, false);
}
const star = $("cd-star");
star.setAttribute("aria-pressed", String(starred.has(current.id)));
star.textContent = starred.has(current.id) ? "관심 빼기" : "관심 남기기";
renderList();
renderStarred();
});
dialog.addEventListener("click", (e) => { if (e.target === dialog) dialog.close(); });
dialog.querySelector("[data-close]").addEventListener("click", () => dialog.close());
// ── 시작 ──
buildAxis();
renderAll();
renderNotices();
setStatus("과목을 눌러 강의계획을 확인하세요.", false);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -0,0 +1,567 @@
@import url("./tokens.css");
/* ==========================================================================
두레대학 수강신청 프로덕션 콘솔 (shadcn/ui new-york 문법의 바닐라 이식)
액센트는 벽돌() 하나. 나머지는 무채상 완성도로 말한다.
========================================================================== */
*,
*::before,
*::after { box-sizing: border-box; }
* { margin: 0; }
html { -webkit-text-size-adjust: 100%; background: var(--muted); }
body {
background: var(--muted);
color: var(--foreground);
font-family: var(--font-body);
font-synthesis-weight: none;
-webkit-text-size-adjust: 100%; text-size-adjust: 100%; /* 합성 볼드 금지 */
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2, h3) { line-height: var(--leading-tight); font-weight: 600; word-break: keep-all; }
strong, b { font-weight: 600; } /* UA 기본 700 볼드 재정의 */
h1, h2.v-h {
font-size: var(--step-4); font-weight: 600;
font-family: var(--font-display); /* 공문·신문의 명조 결 — 큰 제목에만 */
letter-spacing: -0.01em;
text-wrap: balance;
}
@media (max-width: 680px) {
h1, h2.v-h { font-size: min(1.5rem, 6.4vw); } /* 뷰포트 단위 — 기기 글꼴 확대에도 제목은 부서지지 않는다 */
}
h2 { font-size: var(--step-2); }
h3 { font-size: var(--step-0); font-weight: 500; }
:where(p, li, td, th, dd, figcaption) { word-break: keep-all; overflow-wrap: anywhere; }
.mono { font-family: var(--font-mono); font-size: var(--font-mono-size); font-variant-numeric: tabular-nums; }
.muted { color: var(--muted-foreground); }
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 2px; }
.skip {
position: absolute; left: -9999px; top: 0;
background: var(--primary); color: var(--primary-foreground);
padding: var(--space-3) var(--space-4); z-index: 100; border-radius: var(--radius);
}
.skip:focus { left: var(--space-4); }
.sr-only {
position: absolute; width: 1px; height: 1px; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
/* ── 앱바: 브랜드 · 학점 칩 · 탭. 스티키. ── */
.appbar {
background: var(--card);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
}
.appbar-inner {
max-width: 1180px; margin-inline: auto;
display: flex; align-items: center; justify-content: space-between;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
}
.brand {
font-family: var(--font-display);
display: inline-flex; align-items: center; gap: var(--space-2);
color: var(--foreground); text-decoration: none;
font-weight: 600; font-size: var(--step-1);
}
.brand-seal {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
background: var(--accent);
color: #fff;
border-radius: var(--radius);
font-size: var(--step-0); font-weight: 600;
}
.credits {
display: inline-flex; align-items: center; gap: 6px;
color: var(--muted-foreground);
background: var(--muted-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 12px;
font-size: var(--step-1);
white-space: nowrap;
}
.credits strong { color: var(--accent-ink); font-size: var(--step-0); font-weight: 600; font-variant-numeric: tabular-nums; }
/* 탭 — 언더라인 내비 */
.tabs {
max-width: 1180px; margin-inline: auto;
display: flex; gap: var(--space-1);
padding: 0 var(--space-5);
overflow-x: auto;
overflow-y: hidden; /* overflow-x:auto 는 반대축을 auto 로 만든다 — 세로 스크롤 금지 */
scrollbar-width: none; /* 좁은 창 가로 스크롤바도 노출 금지 — 스와이프는 유지 */
}
.tab {
appearance: none; cursor: pointer; font: inherit;
font-size: var(--step-0); font-weight: 500;
color: var(--muted-foreground);
background: none; border: 0;
border-bottom: 2px solid transparent;
height: 42px;
padding: 0 var(--space-2);
white-space: nowrap;
transition: color 100ms ease, border-color 100ms ease;
}
.tab:hover { color: var(--foreground); }
.tab.is-current { color: var(--foreground); font-weight: 600; border-bottom-color: var(--accent); }
/* ── 레이아웃 ── */
.layout {
max-width: 1180px;
margin-inline: auto;
padding: var(--space-5) var(--space-5) 96px;
}
.view { display: none; }
.view.is-on { display: block; }
.view:focus { outline: none; }
.view > h1 { margin-bottom: 4px; }
.view-sub { color: var(--muted-foreground); font-size: var(--step-0); margin-bottom: var(--space-5); max-width: 46em; }
#view-catalog.is-on {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-4);
align-items: start;
}
.view > *:not(:first-child):not(.view-sub) { margin-top: 0; }
/* ── 카드(장부 판 → 카드) ── */
.sheet {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-4);
min-width: 0;
}
.sheet-tt { padding: var(--space-4); }
.sheet-note { font-size: var(--step--1); color: var(--faint); margin-top: var(--space-3); }
/* ── 빈 상태 ── */
.empty-box {
display: grid; gap: var(--space-2); justify-items: start;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-lg);
padding: var(--space-6) var(--space-5);
background: var(--muted);
}
.empty-box p:first-child { font-weight: 600; }
.empty-box .muted { font-size: var(--step-0); }
.empty-box .btn-line { margin-top: var(--space-2); }
/* ── 검색 + 세그먼트 ── */
.toolbar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; margin-bottom: var(--space-4); }
#q {
font: inherit; color: var(--foreground);
background: var(--card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
height: 36px;
padding: 0 var(--space-3);
min-width: 13rem;
box-shadow: var(--shadow-sm);
transition: border-color 100ms ease, box-shadow 100ms ease;
}
#q:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
#q:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
#q::placeholder { color: var(--faint); }
.chips {
display: inline-flex; gap: 2px;
background: var(--muted-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
}
.chip {
appearance: none; cursor: pointer; font: inherit;
font-size: var(--step--1); font-weight: 500;
background: none; color: var(--muted-foreground);
border: 0; border-radius: var(--radius);
height: 28px; padding: 0 var(--space-3);
display: inline-flex; align-items: center;
transition: background-color 100ms ease, color 100ms ease, box-shadow 100ms ease;
}
.chip:hover { color: var(--foreground); }
.chip.is-on { background: var(--card); color: var(--foreground); box-shadow: var(--shadow-sm); }
/* ── 과목 목록 ── */
.course-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
.course-row {
appearance: none; background: none; cursor: pointer; font: inherit;
width: 100%; text-align: left;
display: flex; justify-content: space-between; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-2);
border: 0; border-bottom: 1px solid var(--border);
border-radius: var(--radius);
transition: background-color 80ms ease;
}
.course-row:first-child { border-top: 1px solid var(--border); }
.course-row:hover { background: var(--muted); }
.course-row:active { scale: 0.995; }
.c-main { display: grid; gap: 2px; min-width: 0; }
.c-name { font-weight: 600; display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
.c-dept {
font-size: var(--step--1); font-weight: 500;
color: var(--muted-foreground);
background: var(--muted-2);
border-radius: 4px;
padding: 1px 6px;
}
.c-sub { color: var(--muted-foreground); font-size: var(--step--1); }
.c-side { display: grid; gap: 2px; text-align: right; flex: none; font-size: var(--step--1); }
.c-credit { font-weight: 500; color: var(--foreground); }
.c-seats { color: var(--muted-foreground); }
.c-seats.is-low { color: var(--danger); font-weight: 600; }
.c-star { color: var(--accent); font-weight: 600; }
.c-seal {
display: inline-grid; place-items: center;
width: 22px; height: 22px;
background: var(--accent); color: #fff;
border-radius: 4px;
font-size: var(--step--1);
}
.empty { padding: var(--space-4) 0; color: var(--muted-foreground); }
/* ── 표 ── */
.table-wrap { overflow-x: auto; overflow-y: hidden; }
table { width: 100%; border-collapse: collapse; }
th { font-weight: 500; } /* UA 기본 th 볼드 재정의 */
thead th {
text-align: left; font-weight: 500; font-size: var(--step--1);
color: var(--muted-foreground); padding: 0 var(--space-3) var(--space-3);
border-bottom: 1px solid var(--border);
height: 36px; white-space: nowrap;
}
tbody td, tbody th { padding: var(--space-3); border-bottom: 1px solid var(--border); vertical-align: middle; text-align: left; font-size: var(--step-0); }
tbody th { font-weight: 500; }
tbody tr:last-child td, tbody tr:last-child th { border-bottom: 0; }
tbody tr { transition: background-color 80ms ease; }
tbody tr:hover { background: var(--muted); }
.ar-sub { background: var(--muted); }
.ar-sub th, .ar-sub td { font-weight: 600; }
.low-seats { color: var(--danger); font-weight: 600; }
/* ── 신청 내역 ── */
.en-sum { color: var(--muted-foreground); margin-bottom: var(--space-4); }
.en-sum strong { color: var(--accent-ink); font-size: var(--step-2); font-weight: 600; }
.give-btn {
appearance: none; cursor: pointer; font: inherit;
font-size: var(--step--1); font-weight: 500;
background: none; color: var(--muted-foreground);
border: 1px solid var(--border-strong); border-radius: var(--radius);
height: 28px; padding: 0 10px;
transition: all 100ms ease;
white-space: nowrap;
}
.give-btn:hover { border-color: var(--danger); color: var(--danger); }
/* ── 공지 게시판 ── */
.notice-list { list-style: none; margin: 0; padding: 0; display: grid; }
.notice-list li {
display: grid;
grid-template-columns: 3.2rem 4rem minmax(0, 1fr);
gap: var(--space-3);
align-items: baseline;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border);
}
.notice-list li:first-child { border-top: 1px solid var(--border); }
.no-kind {
font-size: var(--step--1); font-weight: 600;
background: var(--muted-2); color: var(--muted-foreground);
border-radius: var(--radius); padding: 2px 8px;
text-align: center; white-space: nowrap;
justify-self: start;
}
.no-kind[data-kind="변경"] { background: var(--accent-soft); color: var(--accent-ink); }
.no-date { color: var(--faint); }
.no-body { line-height: var(--leading-normal); word-break: keep-all; max-width: 68ch; }
/* ── 시간표 ── */
.timetable-panel { min-width: 0; }
.timetable-panel h2 { margin-bottom: 4px; }
.tt-hint { font-size: var(--step--1); margin-bottom: var(--space-3); }
.tt-scroll { overflow-x: auto; overflow-y: hidden; }
.tt {
display: grid;
grid-template-columns: 2.5rem repeat(5, minmax(4.2rem, 1fr));
grid-auto-rows: minmax(1.35rem, auto);
min-width: min(100%, 420px);
}
.tt-corner { grid-column: 1; grid-row: 1; border-bottom: 1px solid var(--border-strong); }
.tt-day {
font-size: var(--step--1); font-weight: 600;
display: grid; place-items: center;
border-bottom: 2px solid var(--border-strong);
padding-bottom: var(--space-1);
}
.tt-hour {
font-size: var(--step--2); color: var(--faint);
display: grid; place-items: start end;
padding: 2px 4px 0 0;
position: relative;
}
.tt-cell { border-left: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.tt-block {
appearance: none; border: 0; cursor: pointer;
display: grid; align-content: start; gap: 2px;
padding: var(--space-2);
border-radius: var(--radius);
text-align: left; overflow: hidden;
transition: box-shadow 100ms ease, scale 80ms ease;
min-width: 0;
}
.tt-block:hover { box-shadow: var(--shadow-lg); z-index: 2; }
.tt-block:active { scale: 0.98; }
.tt-block.is-major { background: var(--accent-soft); color: var(--accent-ink); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), inset 3px 0 0 var(--accent); }
.tt-block.is-gen { background: var(--muted-2); color: var(--foreground); box-shadow: inset 3px 0 0 var(--border-strong); }
.tt-block-name { font-size: var(--step--1); font-weight: 600; line-height: 1.35; word-break: keep-all; }
.tt-block-room { font-size: var(--step--2); color: var(--muted-foreground); }
.tt-empty {
grid-column: 2 / -1; grid-row: 4 / span 8;
display: flex; align-items: center; justify-content: center;
color: var(--faint); font-size: var(--step--1);
border: 1px dashed var(--border-strong); border-radius: var(--radius);
pointer-events: none;
background: var(--muted);
}
.status {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--muted-foreground);
border-top: 1px solid var(--border);
padding-top: var(--space-3);
min-height: 2.2em;
}
.status.is-warn { color: var(--danger); }
.tt-legend { display: flex; gap: var(--space-4); margin-top: var(--space-2); font-size: var(--step--1); color: var(--muted-foreground); }
.key { display: inline-flex; align-items: center; gap: 6px; }
.key i { width: 10px; height: 10px; display: inline-block; border-radius: 3px; }
.k-major { background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); }
.k-gen { background: var(--muted-2); box-shadow: inset 2px 0 0 var(--border-strong); }
.k-seal { background: var(--accent); }
/* ── 버튼 ── */
.btn-seal, .btn-line, .btn-star {
appearance: none; cursor: pointer; font: inherit;
font-size: var(--step-0); font-weight: 500;
height: 36px; padding: 0 var(--space-4);
border-radius: var(--radius);
display: inline-flex; align-items: center; justify-content: center;
transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease, scale 80ms ease;
white-space: nowrap;
}
.btn-seal { background: var(--accent); border: 1px solid var(--accent); color: #fff; box-shadow: var(--shadow-sm); }
.btn-seal:hover:not(:disabled) { background: #9a3412; border-color: #9a3412; }
.btn-seal:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-line { background: var(--card); border: 1px solid var(--border-strong); color: var(--foreground); box-shadow: var(--shadow-sm); }
.btn-line:hover { background: var(--muted); }
.btn-star { background: none; border: 1px solid var(--border-strong); color: var(--muted-foreground); }
.btn-star:hover { color: var(--accent-ink); border-color: var(--accent); }
.btn-star[aria-pressed="true"] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.btn-seal:active, .btn-line:active, .btn-star:active { scale: 0.98; }
/* ── 모달: 스플릿. 표지 = 잉크판, 액센트 도장. ── */
.modal {
margin: auto;
width: min(94vw, 860px);
max-height: min(86dvh, 760px);
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
color: var(--foreground);
overflow: hidden;
box-shadow: var(--shadow-xl);
}
.modal::backdrop { background: rgb(9 9 11 / 0.55); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
@media (prefers-reduced-motion: no-preference) {
.modal[open] { animation: modal-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(0.99); } }
}
.mspread { display: grid; grid-template-columns: 264px minmax(0, 1fr); max-height: min(86dvh, 760px); }
.mcover {
position: relative;
background: var(--primary);
color: var(--primary-foreground);
padding: var(--space-6);
display: flex; flex-direction: column;
overflow: hidden;
}
.mcover-mark {
position: absolute; right: -18px; bottom: -24px;
font-size: 8rem; font-weight: 600; line-height: 1;
color: rgb(250 250 250 / 0.05);
pointer-events: none; user-select: none;
}
.mcover-seal {
width: 44px; height: 44px;
display: inline-flex; align-items: center; justify-content: center;
font-size: var(--step-2); font-weight: 600;
color: #fff;
background: var(--accent);
border-radius: var(--radius);
margin-bottom: var(--space-4);
}
.mcover-name { font-size: var(--step-3); line-height: 1.25; font-weight: 600; letter-spacing: -0.02em; word-break: keep-all; }
.mcover-meta { color: #a1a1aa; font-size: var(--step--1); margin-top: var(--space-2); }
.mcover-stats { margin-top: var(--space-5); border-top: 1px solid rgb(250 250 250 / 0.12); }
.mcover-stats > div {
display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3);
padding: var(--space-3) 0;
border-bottom: 1px solid rgb(250 250 250 / 0.12);
}
.mcover-stats dt { font-size: var(--step--1); color: #a1a1aa; }
.mcover-stats dd { font-size: var(--step--1); font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
.mcover-foot { margin-top: auto; color: #71717a; font-size: var(--step--2); }
.msheet {
position: relative;
background: var(--card);
padding: var(--space-6);
overflow-y: auto;
overscroll-behavior: contain;
display: grid; gap: var(--space-5);
align-content: start;
min-height: 340px;
}
.cd-line { color: var(--muted-foreground); font-size: var(--step-0); }
.msec h3 { color: var(--muted-foreground); margin-bottom: var(--space-3); font-weight: 500; font-size: var(--step-0); }
.modal-title { font-size: var(--step-3); letter-spacing: -0.02em; }
.modal-meta { color: var(--muted-foreground); margin-top: 2px; }
.modal-close {
position: absolute; top: var(--space-4); right: var(--space-4);
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--card); color: var(--muted-foreground); cursor: pointer;
transition: background-color 100ms ease, color 100ms ease;
z-index: 2;
}
.modal-close:hover { background: var(--muted-2); color: var(--foreground); }
.modal-inner { position: relative; padding: var(--space-6); display: grid; gap: var(--space-4); }
.modal-actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
.facts {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3);
border-block: 1px solid var(--border);
padding-block: var(--space-4);
}
.facts dt { font-size: var(--step--1); color: var(--muted-foreground); }
.facts dd { font-weight: 600; margin-top: 2px; }
.warn {
font-size: var(--step-0);
color: var(--danger-ink);
background: var(--danger-soft);
border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
border-radius: var(--radius);
padding: var(--space-2) var(--space-3);
}
/* 주간 자리 미니 격자 */
.cd-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-2); }
.cd-track { display: grid; justify-items: center; gap: var(--space-1); }
.cd-rail { position: relative; width: 100%; height: 92px; border-left: 1px solid var(--border-strong); }
.cd-off { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--faint); }
.cd-slot { position: absolute; left: 0; right: 4px; border-radius: 3px; }
.cd-slot.is-major { background: var(--accent-soft); border-left: 3px solid var(--accent); }
.cd-slot.is-gen { background: var(--muted-2); border-left: 3px solid var(--border-strong); }
.cd-day-l { font-size: var(--step--2); color: var(--faint); }
.cd-day-l.is-on { color: var(--foreground); font-weight: 600; }
/* 평가 누적 막대 — 벽돌의 농도 3단 */
.eval-bar {
display: flex; height: 14px;
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--space-3);
background: var(--muted-2);
}
.eval-seg { display: block; height: 100%; }
.eval-seg.seg-1 { background: var(--accent); }
.eval-seg.seg-2 { background: color-mix(in srgb, var(--accent) 55%, #fff); }
.eval-seg.seg-3 { background: color-mix(in srgb, var(--accent) 22%, #fff); }
.eval-legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5); }
.eval-legend dt { color: var(--muted-foreground); font-size: var(--step--1); }
.eval-legend dd { font-weight: 600; font-size: var(--step--1); }
/* ── 반응형 ── */
@media (max-width: 900px) {
#view-catalog.is-on { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
.timetable-panel { position: static; }
.notice-list li { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
.no-kind, .no-date { justify-self: start; }
.layout { padding: var(--space-4) var(--space-4) 64px; }
}
@media (max-width: 680px) {
.mspread { grid-template-columns: minmax(0, 1fr); }
.mcover { padding: var(--space-4); flex-direction: row; align-items: center; gap: var(--space-3); }
.mcover-seal { width: 40px; height: 40px; margin-bottom: 0; font-size: var(--step-0); }
.mcover-name { font-size: var(--step-2); }
.mcover-meta { margin-top: 2px; }
.mcover-stats, .mcover-foot { display: none; }
.mcover-mark { font-size: 5rem; }
.msheet { padding: var(--space-4); min-height: 0; }
.course-row { flex-direction: column; align-items: flex-start; gap: var(--space-2); }
.c-side { text-align: left; display: flex; gap: var(--space-3); align-items: center; }
.modal-inner { padding: var(--space-4); }
.facts { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.modal-actions { flex-wrap: wrap; }
}
/* ── 뷰 공통 요약 스트립 (가온과 같은 문법) ── */
.statline {
display: flex; flex-wrap: wrap; gap: 8px;
margin-bottom: var(--space-4);
}
.statline > div {
display: inline-flex; align-items: baseline; gap: 6px;
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
padding: 6px 12px; font-size: var(--step-1); color: var(--muted-foreground);
}
.statline strong {
font-size: var(--step-0); font-weight: 600; color: var(--foreground);
font-variant-numeric: tabular-nums;
}
/* ── 관심 과목 학점 경고 — 오류 예방 ── */
.st-warn {
font-size: var(--step-0); line-height: 1.6; word-break: keep-all;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--muted-2); color: var(--muted-foreground);
padding: 10px 14px; margin-bottom: var(--space-4);
font-variant-numeric: tabular-nums;
}
.st-warn.is-over {
background: color-mix(in srgb, var(--accent) 8%, white);
border-color: color-mix(in srgb, var(--accent) 30%, white);
color: var(--accent-ink);
}
/* ── 모바일 갸행 수습 — 과목행 부정보는 세그먼트(코드·교수·시간·장소) 단위로만 끊는다 ── */
.c-sub i { font-style: normal; white-space: nowrap; }
.view-sub, .no-body, .st-warn, .empty, .notice-list { word-break: keep-all; overflow-wrap: anywhere; text-wrap: pretty; }
/* ── 라운드 1: 이수 진행 바 · 학점 게이지 · 관심 추천 ── */
.cr-bar { height: 6px; border-radius: 999px; background: var(--muted-2); overflow: hidden; min-width: 90px; }
.cr-bar i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
.cr-total { margin-top: var(--space-4); padding-top: var(--space-4); border-top: 1px solid var(--border); }
.cr-total .cr-bar { height: 8px; }
.credit-gauge { height: 8px; border-radius: 999px; background: var(--muted-2); overflow: hidden; margin: 10px 0 6px; }
.credit-gauge i { display: block; height: 100%; background: var(--accent); border-radius: 999px; transition: width 200ms ease; }
.h-note { font-size: var(--step--1); font-weight: 400; color: var(--muted-foreground); margin-left: 8px; }
.st-suggest { margin-top: var(--space-4); }

View file

@ -0,0 +1,55 @@
/* ==========================================================================
두레대학 수강신청 디자인 토큰 (v3: 프로덕션 콘솔)
프리셋: production-console shadcn/ui (new-york) zinc 스키마 + 벽돌 액센트 1.
컨셉 문장: 실제 대학이 쓰는 같은 수강신청 도장은 액센트 하나로 남고
나머지는 완성도로 말한다.
레퍼런스: shadcn/ui 디자인 시스템. 종이/배판 컨셉은 철회(사용자 판정).
하지 않은 : 신문 배판 마스트헤드, 세리프 마스트, 전공별 , 진행바 게이지.
========================================================================== */
:root {
--step--2: 0.6875rem; /* 11px */
--step--1: 0.75rem; /* 12px */
--step-0: 0.875rem; /* 14px — 본문 */
--step-1: 1rem; /* 16px */
--step-2: 1.125rem; /* 18px */
--step-3: 1.5rem; /* 24px */
--step-4: 1.875rem; /* 30px */
--leading-tight: 1.3;
--leading-normal: 1.6;
--font-body: "IBM Plex Sans KR", "Pretendard Variable", -apple-system, sans-serif;
--font-display: "Noto Serif KR", "IBM Plex Sans KR", serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-mono-size: 0.8125rem;
--background: #fafafa;
--card: #ffffff;
--foreground: #09090b;
--muted: #fafafa;
--muted-2: #f4f4f5;
--muted-foreground: #64646e; /* zinc-50/gray-100 위 5.2:1 이상 */
--faint: #6e6e77; /* WCAG 4.5:1 보정 */
--border: #e4e4e7;
--border-strong: #d4d4d8;
--primary: #18181b;
--primary-foreground: #fafafa;
--ring: #a1a1aa;
--accent: #c2410c; /* 벽돌 — 申 도장과 신청 액션만 */
--accent-soft: #fff7ed;
--accent-ink: #9a3412;
--success: #059669; --success-soft: #ecfdf5; --success-ink: #065f46;
--warning: #b45309; --warning-soft: #fffbeb; --warning-ink: #92400e;
--danger: #dc2626; --danger-soft: #fef2f2; --danger-ink: #991b1b;
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
--space-4: 1rem; --space-5: 1.5rem; --space-6: 2rem;
--radius: 6px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View file

@ -0,0 +1,289 @@
<!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="획의 장력과 여백의 비례. 본문 조판을 위해 설계한 한글 활자 두 종과 조판 시험대. 1000 UPM, 측정 가능한 자간과 행간."
/>
<meta property="og:title" content="한성활자연구소 · 본문용 한글 활자" />
<meta
property="og:description"
content="본문 조판을 위해 설계한 한글 활자와 조판 시험대."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#F5F1E8" />
<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='%23F5F1E8'/%3E%3Crect x='8' y='8' width='16' height='16' fill='%23A63D2F'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">한성활자연구소</a>
<nav class="nav" aria-label="섹션">
<a href="#typefaces">활자</a>
<a href="#bench">시험대</a>
<a href="#case">활자 케이스</a>
<a href="#order">구입</a>
</nav>
</div>
</header>
<main id="main">
<!--
스페시멘 히어로 — 파운드리의 첫 화면은 설명이 아니라 글자다.
'한' 한 글자를 페이지에서 가장 크게, 인주각과 함께.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap">
<p class="hero-kicker reveal">본문용 한글 활자 · 1000 UPM · 2018년 창립</p>
<h1 id="hero-h" class="hero-glyph reveal" style="--delay: 60ms" lang="ko" aria-label="한"></h1>
<div class="hero-side">
<p class="hero-statement">
이 글자가 어디에 쓰이는지는 이 페이지 전체가 증명합니다. 우리는
제목용 활자를 만들지 않습니다 — 오래 읽어야 하는 본문의 활자만
만듭니다. 획의 장력, 자간의 균형, 행간의 숨을 그 단위(UPM)로
측정합니다.
</p>
<dl class="hero-facts reveal" style="--delay: 120ms">
<div>
<dt>서체</dt>
<dd>한성본문 · 한성제목</dd>
</div>
<div>
<dt>글자 수</dt>
<dd>11,172자 완성</dd>
</div>
<div>
<dt>단위</dt>
<dd>1000 UPM · 가변 웨이트 3종</dd>
</div>
</dl>
</div>
</div>
</section>
<!--
활자 두 종 — 스페시멘은 실제 문장으로. Lorem ipsum 은 활자시험의 반대다.
-->
<section id="typefaces" class="section" aria-labelledby="typefaces-h">
<div class="wrap">
<h2 id="typefaces-h">활자 두 종</h2>
<article class="typeface reveal">
<header class="typeface-head">
<h3>한성본문</h3>
<p class="muted">본문용 · 웨이트 400 · 16px에서 행간 1.8</p>
</header>
<p class="specimen" style="font-family: 'MaruBuri', serif">
아침 안개가 걷히기 전에 도랑물 소리가 먼저 들렸다. 글자는 종이 위에서
소리를 내지 않지만, 잘 짜인 활자는 소리 내어 읽게 만든다. 획이 닿는
각도와 글자가 서로 미는 힘을 천 번쯤 고쳐 썼다. 그 결과 이 문장이
밤늦게도 읽히는 것이다.
</p>
</article>
<article class="typeface reveal" style="--delay: 80ms">
<header class="typeface-head">
<h3>한성제목</h3>
<p class="muted">제목·표제용 · 웨이트 600 · 큰 짝에서 벌어짐 0</p>
</header>
<p class="specimen specimen-display" style="font-family: 'MaruBuri', serif">
활자는 붓의 속도를 기억한다
</p>
</article>
</div>
</section>
<!--
조판 시험대 — 이 페이지의 리스크이자 유일한 인터랙션.
슬라이더 세 개가 실제 본문에 즉시 반영된다. 라벨은 한국어, 값은 숫자로.
-->
<section id="bench" class="section band" aria-labelledby="bench-h">
<div class="wrap">
<h2 id="bench-h">조판 시험대</h2>
<p class="muted bench-lead">
자간과 행간, 글자 크기가 가독성을 어떻게 바꾸는지 직접 움직여 보세요.
아래 값은 권장값이 아니라 출발점입니다 — 용지 규격과 본문 길이에 따라
정답은 달라집니다.
</p>
<div class="bench">
<form class="bench-controls" aria-label="조판 설정">
<div class="control">
<label for="ctl-size">글자 크기 <output id="out-size" for="ctl-size">17</output></label>
<input type="range" id="ctl-size" min="13" max="26" step="1" value="17" />
</div>
<div class="control">
<label for="ctl-tracking">자간 <output id="out-tracking" for="ctl-tracking">0</output></label>
<input type="range" id="ctl-tracking" min="-5" max="10" step="1" value="0" />
</div>
<div class="control">
<label for="ctl-leading">행간 <output id="out-leading" for="ctl-leading">1.8</output></label>
<input type="range" id="ctl-leading" min="1.4" max="2.4" step="0.1" value="1.8" />
</div>
<button type="button" class="btn-reset" id="ctl-reset">권장값으로</button>
</form>
<div
class="bench-sample"
id="bench-sample"
style="font-family: 'MaruBuri', serif"
aria-live="polite"
>
활자의 역사는 글자를 찍는 기계의 역사가 아니라, 글자를 읽는 눈의
역사였다. 활자소의 식자공들은 하루에 만 글자를 짜면서 어느 글꼴이
눈을 덜 아프게 하는지 알고 있었다. 그 지식은 문서로 남지 않았고
손끝으로만 전해졌다. 이 시험대는 그 손끝의 판단을 누구나 해볼 수
있는 눈금으로 옮긴 것이다. 자간을 0에서 5로 옮겨 보라 — 문장이
늘어지는 것이 아니라 숨이 가빠지는 것을 느낄 것이다.
</div>
</div>
</div>
</section>
<!--
활자 케이스 — 연구소의 물성. 금속 활자와 인주.
-->
<section id="case" class="section" aria-labelledby="case-h">
<div class="wrap case-grid">
<figure class="case-photo reveal">
<img
src="./assets/metaltype.webp"
alt="나무 활자 케이스에 골라 담긴 금속 활자들 — 활자면이 위에서 빛을 받아 결이 보인다"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>연구소 2층 · 금속 활자 케이스</figcaption>
</figure>
<div class="case-text">
<h2 id="case-h">금속에서 시작해<br />화면으로 끝난다</h2>
<p>
한성본문의 자간 규칙은 금속 활자의 '옆면 여백'에서 왔습니다. 납활자는
글자 바로 옆에 살이 붙어 있어서, 찍히는 글자 사이는 언제나 일정했고
그 일정함이 읽는 리듬이었습니다. 화면용 폰트는 그 살을 지울 수
있지만, 우리는 지우지 않았습니다.
</p>
<figure class="case-photo-b reveal" style="--delay: 80ms">
<img
src="./assets/inkstone.webp"
alt="연구한 먹이 고인 연적과 옆에 놓인 인주 연판"
width="1100"
height="825"
loading="lazy"
/>
<figcaption>먹을 갈고 인주를 찍는 자리 — 시험 인쇄 전</figcaption>
</figure>
</div>
</div>
</section>
<!--
구입 — 라이선스는 조판 단위로. 조용한 두 줄.
-->
<section id="order" class="section" aria-labelledby="order-h">
<div class="wrap order-grid">
<div>
<h2 id="order-h">구입</h2>
<p class="muted">
웹·출판·임베드 세 가지 라이선스만 있습니다. 사용 수를 세지 않습니다 —
조판되는 단위(도서 권수, 사이트 도메인, 기기)로 셉니다.
</p>
</div>
<div class="order-rows">
<div class="order-row">
<div>
<h3></h3>
<p>도메인당 · 웨이트 전종 · 자체 호스팅 WOFF2</p>
</div>
<p class="order-price">140,000원</p>
</div>
<div class="order-row">
<div>
<h3>출판</h3>
<p>도서 권수당 · 인쇄+전자 동시</p>
</div>
<p class="order-price">220,000원</p>
</div>
</div>
<a class="btn" href="mailto:foundry@hanseong.kr">견적 문의</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>한성활자연구소 · 서울 마포</p>
<p class="muted">foundry@hanseong.kr</p>
</div>
</footer>
<script>
/* 시험대 — 슬라이더 세 개가 본문 스타일을 직접 쓴다. 프레임당 레이아웃 없음. */
const sample = document.getElementById("bench-sample");
const ctl = {
size: document.getElementById("ctl-size"),
tracking: document.getElementById("ctl-tracking"),
leading: document.getElementById("ctl-leading"),
};
const out = {
size: document.getElementById("out-size"),
tracking: document.getElementById("out-tracking"),
leading: document.getElementById("out-leading"),
};
const defaults = { size: 17, tracking: 0, leading: 1.8 };
function apply() {
const px = ctl.size.value + "px";
const em = ctl.tracking.value / 100 + "em";
const lh = ctl.leading.value;
sample.style.fontSize = px;
sample.style.letterSpacing = em;
sample.style.lineHeight = lh;
out.size.textContent = ctl.size.value;
out.tracking.textContent = ctl.tracking.value;
out.leading.textContent = Number(ctl.leading.value).toFixed(1);
}
for (const input of Object.values(ctl)) {
input.addEventListener("input", apply);
}
document.getElementById("ctl-reset").addEventListener("click", () => {
ctl.size.value = defaults.size;
ctl.tracking.value = defaults.tracking;
ctl.leading.value = defaults.leading;
apply();
});
apply();
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,420 @@
@import url("./tokens.css");
/* ==========================================================================
한성활자연구소 editorial-archival. 글자가 상품.
========================================================================== */
*,
*::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;
}
:where(h1, h2, h3) {
font-family: var(--font-display);
font-weight: 600;
line-height: var(--leading-tight);
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h2 {
font-size: var(--step-2);
}
h3 {
font-family: var(--font-body);
font-weight: 600;
font-size: var(--step-1);
}
:where(p, li, dd, dt, th, td, 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;
}
: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;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.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;
}
.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-7) var(--space-8);
}
.hero-kicker {
font-size: var(--step--1);
color: var(--ink-muted);
letter-spacing: 0.06em;
}
.hero-glyph {
font-size: clamp(11rem, 24vw, 22rem);
line-height: 0.95;
margin-top: var(--space-4);
position: relative;
}
/* 인주각 — 글리프 옆에 찍히는 도장 */
.hero-glyph::after {
content: "";
position: absolute;
right: 0.12em;
bottom: 0.18em;
width: 0.11em;
height: 0.11em;
background: var(--accent);
}
.hero-side {
margin-top: var(--space-5);
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.hero-statement {
max-width: 32em;
}
.hero-facts {
display: grid;
gap: var(--space-3);
border-left: 1px solid var(--line);
padding-left: var(--space-5);
}
.hero-facts dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.hero-facts dd {
margin-top: 2px;
font-weight: 500;
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 활자 두 종 ───────────────────────────────────────────────────────── */
.typeface + .typeface {
margin-top: var(--space-6);
}
.typeface-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
flex-wrap: wrap;
border-top: 1px solid var(--line);
padding-top: var(--space-3);
}
.typeface-head .muted {
font-size: var(--step--1);
}
.specimen {
margin-top: var(--space-4);
font-size: 1.1875rem;
line-height: 1.9;
max-width: 46em;
}
.specimen-display {
font-size: clamp(1.75rem, 1.2rem + 3vw, 3.25rem);
line-height: 1.45;
max-width: 22em;
font-weight: 600;
}
/* ── 조판 시험대 ──────────────────────────────────────────────────────── */
.bench-lead {
max-width: 36em;
margin-top: var(--space-3);
}
.bench {
margin-top: var(--space-5);
display: grid;
grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.bench-controls {
display: grid;
gap: var(--space-4);
}
.control label {
display: flex;
justify-content: space-between;
font-size: var(--step--1);
color: var(--ink-muted);
}
.control output {
font-variant-numeric: tabular-nums;
color: var(--ink);
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.btn-reset {
justify-self: start;
background: none;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-muted);
font-size: var(--step--1);
padding: 0.4rem 0.9rem;
cursor: pointer;
transition: border-color var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn-reset:hover {
border-color: var(--accent);
color: var(--ink);
}
.bench-sample {
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: var(--space-5);
max-width: 46em;
}
/* ── 활자 케이스 ──────────────────────────────────────────────────────── */
.case-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: start;
}
.case-photo img,
.case-photo-b img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.case-photo figcaption,
.case-photo-b figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
.case-text h2 {
margin-bottom: var(--space-4);
}
.case-photo-b {
margin-top: var(--space-5);
max-width: 34rem;
}
/* ── 구입 ─────────────────────────────────────────────────────────────── */
.order-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr) auto;
gap: var(--space-5) var(--space-7);
align-items: start;
}
.order-grid .muted {
margin-top: var(--space-3);
max-width: 26em;
}
.order-rows {
border-top: 1px solid var(--line);
}
.order-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
padding-block: var(--space-3);
border-bottom: 1px solid var(--line);
}
.order-row p {
font-size: var(--step--1);
color: var(--ink-muted);
margin-top: 2px;
}
.order-price {
font-family: var(--font-display);
font-size: var(--step-1);
white-space: nowrap;
}
.btn {
display: inline-block;
background: var(--accent);
color: var(--accent-ink);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent-hover);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-5);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
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-side,
.bench,
.case-grid,
.order-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.case-photo {
max-width: 24rem;
}
.hero,
.section {
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;
}
.order-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
}

View file

@ -0,0 +1,53 @@
/* ==========================================================================
한성활자연구소 디자인 토큰
프리셋: editorial-archival
컨셉 문장: 글자가 상품이다 화면부터 스페시멘, 나머지는 물성과 눈금.
레퍼런스: dogfood-foundry(파운드리 계열 구조 관찰 별도 파일). 방향:
스페시멘은 Lorem 아니라 실제 문장, 테스터는 즉시 반영.
하지 않은 : Lorem ipsum, 서체 카드 3열, '구독하면 웨이트 전부' 마케팅,
책등 세로 내비. 이유: 활자는 보여주는 것이지 포장하는 아니다.
강조 인주빨강 #A63D2F 인장·CTA에만. 배경 채움은 버튼 하나 .
========================================================================== */
:root {
/* 타입 — 스페시멘 글리프는 스케일 밖(20vw). 본문 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(2rem, 1.4rem + 3vw, 3.25rem); /* 52px */
--leading-tight: 1.3;
--leading-normal: 1.85;
--font-display: "MaruBuri", serif;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
--surface: #f5f1e8; /* 한지 */
--surface-raised: #ede7d9;
--ink: #26221d;
--ink-muted: #6a635a;
--ink-faint: #948b7c;
--line: #d9d1bf;
--accent: #a63d2f; /* 인주 */
--accent-ink: #f5f1e8;
--accent-hover: #8a3225; /* 인주 눌린 단계 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
--radius: 2px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 420ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View file

@ -0,0 +1,303 @@
<!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="#FDFDFB" />
<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 x='6' y='6' width='20' height='20' fill='none' stroke='%23111' stroke-width='2'/%3E%3Crect x='11' y='11' width='10' height='10' fill='%23111'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<!-- 4모서리 등록 표시(인쇄 판맞춤) — 이 페이지의 리스크. 넓은 화면에서만. -->
<div class="corner corner-tl" aria-hidden="true"></div>
<div class="corner corner-tr" aria-hidden="true"></div>
<div class="corner corner-bl" aria-hidden="true"></div>
<div class="corner corner-br" aria-hidden="true"></div>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">스튜디오 폼 <span class="wordmark-en">FORM</span></a>
<nav class="nav" aria-label="섹션">
<a href="#index">인덱스</a>
<a href="#drawing">도면</a>
<a href="#photo">사진</a>
<a href="#contact">연락</a>
</nav>
</div>
</header>
<main id="main">
<!--
서문 — Pawson의 교훈: 크기가 아니라 여백과 무게로 위계를 만든다.
큰 글자는 이 페이지에 하나만 있다.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap">
<p class="hero-meta reveal">
공간건축연구소 · 서울 · 2014년 설립
</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
형태는 침묵하고<br />치수가 태도를 증명한다
</h1>
<p class="hero-statement reveal" style="--delay: 120ms">
폼은 공간을 꾸미지 않습니다. 벽이 서는 이유, 창이 열리는 방향, 보가
건너뛰는 길이를 설계합니다. 아름다움은 그 결정들이 모였을 때의
부산물입니다 — 그래서 우리의 홍보는 도면과 치수로 합니다.
</p>
<dl class="hero-facts reveal" style="--delay: 180ms">
<div>
<dt>설립</dt>
<dd>2014년</dd>
</div>
<div>
<dt>구성</dt>
<dd>건축사 4 · 연구원 3</dd>
</div>
<div>
<dt>영역</dt>
<dd>주거 · 미술관 · 재생</dd>
</div>
</dl>
</div>
</section>
<!--
인덱스 — 이 페이지의 주인공. 사진 없는 역연도 목록.
R1(johnpawson.com): 프로젝트는 제목/위치/연도의 세 줄 텍스트다.
-->
<section id="index" class="section" aria-labelledby="index-h">
<div class="wrap">
<h2 id="index-h" class="index-h reveal">프로젝트 인덱스</h2>
<ol class="index">
<li class="index-row reveal">
<a href="#drawing">
<span class="idx-name">폼 하우스</span>
<span class="idx-loc">경기 가평</span>
<span class="idx-year">2025</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 40ms">
<a href="#drawing">
<span class="idx-name">백령도 해녀기념관</span>
<span class="idx-loc">인천 백령도</span>
<span class="idx-year">2024</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 80ms">
<a href="#drawing">
<span class="idx-name">은평 재생주거</span>
<span class="idx-loc">서울 은평</span>
<span class="idx-year">2024</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 120ms">
<a href="#drawing">
<span class="idx-name">제주 돌담 스튜디오</span>
<span class="idx-loc">제주 애월</span>
<span class="idx-year">2023</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 160ms">
<a href="#drawing">
<span class="idx-name">성수 인쇄공장 재생</span>
<span class="idx-loc">서울 성수</span>
<span class="idx-year">2022</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 200ms">
<a href="#drawing">
<span class="idx-name">폼 오피스</span>
<span class="idx-loc">서울 종로</span>
<span class="idx-year">2019</span>
</a>
</li>
</ol>
</div>
</section>
<!--
도면 — 스티키 평면도가 섹션을 지나는 동안 그어진다.
R3: CSS scroll-driven + stroke-dashoffset. 폴백은 완성된 도면.
도면은 장식이 아니라 이 연구소의 언어 그 자체다.
-->
<section id="drawing" class="section drawing" aria-labelledby="drawing-h">
<div class="wrap drawing-grid">
<div class="drawing-stage reveal">
<svg
viewBox="0 0 520 640"
role="img"
aria-label="폼 하우스 1층 평면도. 남쪽으로 긴 거실과 북쪽 서비스 블록, 동쪽 마당으로 이루어져 있다."
>
<!-- 외곽 벽 (이중선) -->
<path
class="wall"
d="M 80 60 H 440 V 580 H 80 Z M 88 68 H 432 V 572 H 88 Z"
/>
<!-- 내부 벽 -->
<path
class="wall inner"
d="M 260 68 V 300 M 260 380 V 572 M 88 300 H 432 M 88 380 H 260"
/>
<!-- 문 개구부 표시 -->
<path class="door" d="M 260 340 h -36 M 260 340 a 36 36 0 0 1 36 -36" />
<!-- 기둥 -->
<path class="column" d="M 176 130 h 10 v 10 h -10 z M 356 130 h 10 v 10 h -10 z M 176 250 h 10 v 10 h -10 z M 356 250 h 10 v 10 h -10 z" />
<!-- 치수선 -->
<g class="dim">
<path d="M 80 610 H 440 M 80 602 v 16 M 440 602 v 16" />
<path d="M 50 60 V 580 M 42 60 h 16 M 42 580 h 16" />
<text x="260" y="632" text-anchor="middle">14,400</text>
<text x="30" y="325" text-anchor="middle" transform="rotate(-90 30 325)">9,600</text>
</g>
<!-- 방 이름 -->
<g class="room-label">
<text x="174" y="190">거실</text>
<text x="350" y="190" text-anchor="middle"></text>
<text x="174" y="460">주방·식당</text>
<text x="350" y="455">서비스</text>
<text x="350" y="490">현관</text>
</g>
<!-- 방위 -->
<g class="north">
<path d="M 470 90 v -40 M 470 50 l -8 14 M 470 50 l 8 14" />
<text x="470" y="110" text-anchor="middle">N</text>
</g>
</svg>
<p class="drawing-caption">폼 하우스 · 1층 평면도 · mm</p>
</div>
<div class="drawing-notes">
<h2 id="drawing-h">폼 하우스, 가평</h2>
<p>
대지 남쪽의 소나무군을 지우지 않기 위해 집은 길게 눕고, 마당은 집
안쪽으로 들어왔습니다. 구조는 9.6m 축 한 번으로 건너뛰어 거실에 기둥이
없습니다.
</p>
<p>
벽체는 단열 성능의 요구치에서 두께가 정해졌습니다 — 마감 재료를 먼저
고르고 두께를 맞추지 않았습니다. 창은 남향 긴면 하나, 동쪽 마당 면
하나. 그 두 개로 이 집의 하루가 결정됩니다.
</p>
<dl class="drawing-spec">
<div>
<dt>구조</dt>
<dd>RC 라멘 · 9.6m 스팬</dd>
</div>
<div>
<dt>면적</dt>
<dd>132.4 ㎡ (40.0평)</dd>
</div>
<div>
<dt>창개구</dt>
<dd>남측 14.4m · 동측 4.2m</dd>
</div>
<div>
<dt>줄공사</dt>
<dd>2025-04 2026-01</dd>
</div>
</dl>
</div>
</div>
</section>
<!--
사진 — 흑백 3점. 그리드는 엄격하게, 크기는 사진이 요구하는 대로.
-->
<section id="photo" class="section photo" aria-labelledby="photo-h">
<div class="wrap">
<h2 id="photo-h" class="reveal">사진</h2>
<div class="photo-grid">
<figure class="ph ph-a reveal">
<img
src="./assets/pavilion-bw.webp"
alt="긴 수평창이 낸 콘크리트 파빌리온에 벽 그림자가 지는 모습"
width="1440"
height="810"
loading="lazy"
/>
<figcaption>폼 하우스 · 남측 · 준공 후 첫 여름</figcaption>
</figure>
<figure class="ph ph-b reveal" style="--delay: 60ms">
<img
src="./assets/interior-bw.webp"
alt="천창으로 한 줄기 빛이 사선으로 들어오는 미장 마감 전시실"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>백령도 해녀기념관 · 제1전시실</figcaption>
</figure>
<figure class="ph ph-c reveal" style="--delay: 120ms">
<img
src="./assets/model-bw.webp"
alt="스터디 모델 — 직육면체를 쌓은 백색 석고 모형에 측광이 지는 모습"
width="1100"
height="825"
loading="lazy"
/>
<figcaption>스터디 모델 07 · 석고</figcaption>
</figure>
</div>
</div>
</section>
<!-- 연락 — 서류 같은 마무리 -->
<section id="contact" class="section contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">연락</h2>
<address>
서울 종로구 자하문로 00길 00, 3층<br />
<a class="link" href="mailto:studio@form.kr">studio@form.kr</a>
</address>
<p class="contact-note">
신규 프로젝트는 분기에 두 건만 받습니다. 대지와 예산의 조건을 적어
보내주시면, 가능 여부를 2주 안에 답합니다.
</p>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>스튜디오 폼 · 공간건축연구소</p>
<p class="foot-meta">© 20142026</p>
</div>
</footer>
<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,497 @@
@import url("./tokens.css");
/* ==========================================================================
스튜디오 swiss-minimal. 판맞춤 도면 묶음.
========================================================================== */
*,
*::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;
}
:where(h1, h2, h3) {
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: -0.01em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
: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;
}
a {
color: inherit;
}
.link {
text-decoration: none;
border-bottom: 1px solid var(--line-strong);
transition: border-color var(--dur-quick) var(--ease-soft);
}
.link:hover {
border-color: var(--ink);
}
:focus-visible {
outline: 2px solid var(--ink);
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;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 판맞춤 표시 — 넓은 화면에서만, 내용과 겹치지 않는 곳에 ──────────── */
.corner {
position: fixed;
width: 18px;
height: 18px;
z-index: 40;
pointer-events: none;
}
.corner-tl {
top: 10px;
left: 10px;
border-top: 1px solid var(--line-strong);
border-left: 1px solid var(--line-strong);
}
.corner-tr {
top: 10px;
right: 10px;
border-top: 1px solid var(--line-strong);
border-right: 1px solid var(--line-strong);
}
.corner-bl {
bottom: 10px;
left: 10px;
border-bottom: 1px solid var(--line-strong);
border-left: 1px solid var(--line-strong);
}
.corner-br {
bottom: 10px;
right: 10px;
border-bottom: 1px solid var(--line-strong);
border-right: 1px solid var(--line-strong);
}
@media (max-width: 900px) {
.corner {
display: none;
}
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.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-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.wordmark-en {
color: var(--ink-faint);
font-weight: 400;
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);
border-bottom: 1px solid transparent;
padding-block: 2px;
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(--ink);
}
/* ── 서문 ─────────────────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-8) var(--space-7);
border-bottom: 1px solid var(--line);
}
.hero-meta {
font-size: var(--step--1);
color: var(--ink-muted);
margin-bottom: var(--space-5);
}
.hero-statement {
margin-top: var(--space-5);
max-width: 30em;
}
.hero-facts {
margin-top: var(--space-6);
display: flex;
flex-wrap: wrap;
gap: var(--space-4) var(--space-7);
border-top: 1px solid var(--line);
padding-top: var(--space-4);
}
.hero-facts dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.hero-facts dd {
font-weight: 500;
}
/* ── 인덱스 ───────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.index-h {
font-size: var(--step--1);
font-weight: 500;
color: var(--ink-muted);
letter-spacing: 0.2em;
}
.index {
list-style: none;
margin-top: var(--space-3);
padding: 0;
border-top: 1px solid var(--line-strong);
}
.index-row a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: var(--space-4) var(--space-6);
align-items: baseline;
padding-block: var(--space-3);
border-bottom: 1px solid var(--line);
text-decoration: none;
transition: background var(--dur-quick) var(--ease-soft);
}
.index-row a:hover {
background: var(--ink);
color: var(--surface);
}
.idx-name {
font-size: var(--step-1);
font-weight: 500;
}
.idx-loc {
color: var(--ink-muted);
font-size: var(--step--1);
}
.index-row a:hover .idx-loc {
color: inherit;
}
.idx-year {
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
}
.index-row a:hover .idx-year {
color: inherit;
}
/* ── 도면(스티키) ─────────────────────────────────────────────────────── */
.drawing {
background: var(--surface);
}
.drawing-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
}
.drawing-stage {
position: sticky;
top: calc(var(--space-5) + 56px);
align-self: start;
}
.drawing-stage svg {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
background: var(--surface-bright);
}
.wall {
fill: none;
stroke: var(--ink);
stroke-width: 2;
}
.wall.inner {
stroke-width: 1.25;
}
.door {
fill: none;
stroke: var(--ink-muted);
stroke-width: 1;
stroke-dasharray: none;
}
.column {
fill: var(--ink);
}
.dim path {
fill: none;
stroke: var(--line-strong);
stroke-width: 1;
}
.dim text {
font-size: 12px;
fill: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.room-label text {
font-size: 13px;
fill: var(--ink-muted);
}
.north path {
fill: none;
stroke: var(--ink);
stroke-width: 1.25;
}
.north text {
font-size: 12px;
fill: var(--ink-muted);
}
.drawing-caption {
margin-top: var(--space-2);
font-size: var(--step--1);
color: var(--ink-faint);
}
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.wall,
.wall.inner {
stroke-dasharray: 3400;
stroke-dashoffset: 3400;
animation: draw 1.4s linear both;
animation-timeline: --drawing;
animation-range: entry 10% contain 55%;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
}
}
.drawing-notes h2 {
font-size: var(--step-2);
}
.drawing-notes p + p {
margin-top: var(--space-4);
}
.drawing-spec {
margin-top: var(--space-5);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: var(--space-3) var(--space-5);
border-top: 1px solid var(--line);
padding-top: var(--space-4);
}
.drawing-spec dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.drawing-spec dd {
margin-top: 2px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
/* ── 사진 ─────────────────────────────────────────────────────────────── */
.photo {
border-top: 1px solid var(--line);
}
.photo h2 {
font-size: var(--step--1);
font-weight: 500;
color: var(--ink-muted);
letter-spacing: 0.2em;
}
.photo-grid {
margin-top: var(--space-4);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
.ph {
display: block;
}
.ph img {
display: block;
width: 100%;
height: auto;
filter: grayscale(1); /* 사진 파일이 흑백이 아니어도 페이지는 흑백이다 */
}
.ph-a {
grid-column: 1 / -1;
}
.ph figcaption {
margin-top: var(--space-2);
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 연락 ─────────────────────────────────────────────────────────────── */
.contact {
border-top: 1px solid var(--line);
}
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.contact address {
font-style: normal;
color: var(--ink-muted);
}
.contact-note {
color: var(--ink-muted);
max-width: 26em;
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line-strong);
padding-block: var(--space-4);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
.foot-meta {
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 10px;
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) {
.drawing-grid {
grid-template-columns: 1fr;
}
.drawing-stage {
position: static;
}
.contact-grid {
grid-template-columns: 1fr;
}
.hero,
.section {
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;
}
.index-row a {
grid-template-columns: 1fr auto;
}
.idx-loc {
grid-column: 1;
}
.idx-year {
grid-row: 1;
grid-column: 2;
}
}

View file

@ -0,0 +1,52 @@
/* ==========================================================================
스튜디오 디자인 토큰
프리셋: swiss-minimal
컨셉 문장: 판맞춤 도면 묶음 색이 아니라 선과 치수가 말한다.
레퍼런스: R1 johnpawson.com(실측: 724px 고정폭, 태그 동일 크기+색으로만
위계, 유일한 구분선 1px, 프로젝트는 제목/위치/연도 텍스트 )
R2 cereal( 크림 3단 모노크롬 우리는 중성 회색으로 번안)
R3 CSS scroll-driven view()+dashoffset(이중 게이트, 폴백=완성 도면)
하지 않은 : (무채색 100% 강조색 없음, 링크만 밑줄), radius 전면 0,
사진 캐러셀, 서비스 아이콘. 이유: 도면의 권위는 하나에 있다.
========================================================================== */
:root {
/* 타입 — Pawson 교훈: 크기를 다양하게 쓰지 않는다. 위계는 무게와 색으로. */
--step--1: 0.8125rem; /* 13px — 캡션·치수 */
--step-0: 1rem; /* 16px — 본문 */
--step-1: 1.125rem; /* 18px — 소제목(본문과 1단계 차이만) */
--step-2: 1.375rem; /* 22px — 섹션 표제 */
--step-3: clamp(1.75rem, 1.3rem + 2.4vw, 2.75rem); /* 44px — 서문 하나만 */
--leading-tight: 1.35;
--leading-normal: 1.8;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
/* 무채색 3단계 + 선 2단계. 강조색 없음. */
--surface: #fdfdfb;
--ink: #111111;
--ink-muted: #555555;
--ink-faint: #8a8a8a;
--line: #d9d9d5;
--line-strong: #999999; /* Pawson의 유일한 구분선 색 */
--surface-bright: #ffffff; /* index 행 hover 반전의 가장 밝은 단계 */ /* Pawson의 유일한 구분선 색 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
--radius: 0; /* 이 페이지에는 모서리 둥금이 없다 */
--container: 1080px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 140ms;
--dur-normal: 380ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

View file

@ -0,0 +1,57 @@
/* 가온 학적부 — 순수 계산 로직. 브라우저와 Node 테스트가 같은 함수를 공유한다. */
(function (global) {
"use strict";
const attSummary = (row) => {
const days = (row || []).filter((v) => v !== null && v !== undefined);
if (!days.length) return "휴학";
const att = days.filter((v) => v === 2).length;
return `${Math.round((att / days.length) * 100)}%`;
};
// 일별 합계 — marks 는 '행 배열'의 목록이다. v11 사고(행 자체를 세어 0/3) 방지가 이 함수의 존재 이유.
const dayTotal = (marks, d) =>
marks.filter((row) => row && row[d] !== null && row[d] !== undefined).length;
const dayPresent = (marks, d) =>
marks.filter((row) => row && row[d] === 2).length;
const counts = (marks) => {
const flat = (marks || []).filter(Boolean).flat();
const att = flat.filter((v) => v === 2).length;
const late = flat.filter((v) => v === 1).length;
const abs = flat.filter((v) => v === 0).length;
return {
att, late, abs, total: flat.length,
rate: flat.length ? Math.round((att / flat.length) * 100) : 0,
};
};
// 명단 통계 — 0명 나눗셈 가드 포함
const rosterStats = (students) => {
const list = students || [];
const active = list.filter((s) => s.status === "재학");
const avgAtt = active.length
? Math.round(active.reduce((a, s) => a + (s.att || 0), 0) / active.length)
: 0;
const hw = active.reduce(
(a, s) => {
const [done, total] = String(s.hw || "0/0").split("/").map(Number);
return { done: a.done + (done || 0), total: a.total + (total || 0) };
},
{ done: 0, total: 0 }
);
return {
total: list.length,
active: active.length,
off: list.length - active.length,
avgAtt,
hwDone: hw.done,
hwTotal: hw.total,
hwRate: hw.total ? Math.round((hw.done / hw.total) * 100) : 0,
};
};
const api = { attSummary, dayTotal, dayPresent, counts, rosterStats };
global.GAON = api;
if (typeof module !== "undefined") module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -0,0 +1,807 @@
@import url("./tokens.css");
/* ==========================================================================
가온 학적부 프로덕션 콘솔 (shadcn/ui new-york 문법의 바닐라 이식)
컨트롤 h-9 · 리듬 · 카드 radius 12px · shadow-sm 문법이 품질.
========================================================================== */
*,
*::before,
*::after { box-sizing: border-box; }
* { margin: 0; }
html {
-webkit-text-size-adjust: 100%;
background: var(--muted);
}
body {
background: var(--muted);
color: var(--foreground);
font-family: var(--font-body);
font-synthesis-weight: none;
-webkit-text-size-adjust: 100%; text-size-adjust: 100%; /* 합성 볼드 금지 — 없는 웨이트는 가늘게 떨어진다 */
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2, h3) { line-height: var(--leading-tight); font-weight: 600; word-break: keep-all; }
strong, b { font-weight: 600; } /* UA 기본 700 볼드 재정의 */
h1, h2.v-h {
font-size: var(--step-4); font-weight: 600;
font-family: var(--font-display); /* 학적부 서류의 명조 결 — 큰 제목에만 */
letter-spacing: -0.01em;
text-wrap: balance;
}
@media (max-width: 680px) {
h1, h2.v-h { font-size: min(1.5rem, 6.4vw); } /* 뷰포트 단위 — 기기 글꼴 확대에도 제목은 부서지지 않는다 */
}
h2 { font-size: var(--step-2); }
h3 { font-size: var(--step-0); font-weight: 500; }
:where(p, li, td, th, dd, figcaption) { word-break: keep-all; overflow-wrap: anywhere; }
.mono { font-family: var(--font-mono); font-size: var(--font-mono-size); font-variant-numeric: tabular-nums; }
.muted { color: var(--muted-foreground); }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 2px; }
.skip {
position: absolute; left: -9999px; top: 0;
background: var(--primary); color: var(--primary-foreground);
padding: var(--space-3) var(--space-4); z-index: 100; border-radius: var(--radius);
}
.skip:focus { left: var(--space-4); }
.sr-only {
position: absolute; width: 1px; height: 1px; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
/* ── 앱 뼈대: 사이드바 244px + 본문 ── */
.app {
display: grid;
grid-template-columns: 244px minmax(0, 1fr);
min-height: 100dvh;
background: var(--background);
}
.side {
background: var(--background);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-3);
position: sticky;
top: 0;
height: 100dvh;
overflow-y: auto;
}
.brand {
display: flex; align-items: center; gap: var(--space-2);
color: var(--foreground); text-decoration: none;
font-weight: 600; font-size: var(--step-1);
padding: var(--space-2) var(--space-2) var(--space-3);
}
.brand-mark {
width: 24px; height: 24px; flex: none;
border-radius: var(--radius);
background: var(--accent);
display: grid; place-items: center;
color: #fff; font-size: 12px; font-weight: 600;
}
.brand-mark::after { content: "가"; }
.side-nav { display: grid; gap: 1px; }
.side-group {
font-size: var(--step-1);
font-weight: 500;
color: var(--muted-foreground);
padding: var(--space-4) var(--space-2) var(--space-1);
}
.side-link {
appearance: none; border: 0; background: none; cursor: pointer;
display: flex; align-items: center; gap: var(--space-2);
width: 100%;
text-align: left; color: var(--muted-foreground);
font: inherit; font-size: var(--step-0); font-weight: 500;
height: 34px;
padding: 0 var(--space-2);
border-radius: var(--radius);
transition: background-color 100ms ease, color 100ms ease;
}
.side-link svg { flex: none; opacity: 0.9; }
.side-link:hover { color: var(--foreground); background: var(--muted-2); }
.side-link.is-current { color: var(--foreground); background: var(--muted-2); font-weight: 600; }
.side-link.is-current svg { color: var(--accent); opacity: 1; }
.side-foot {
margin-top: auto;
font-size: var(--step-1);
color: var(--muted-foreground);
line-height: 1.5;
padding: var(--space-3) var(--space-2);
border-top: 1px solid var(--border);
}
/* ── 탑바 ── */
.topbar {
height: 52px;
display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
padding: 0 var(--space-6);
border-bottom: 1px solid var(--border);
background: var(--background);
flex: none;
}
.crumb { display: flex; align-items: center; gap: var(--space-2); font-size: var(--step-0); color: var(--muted-foreground); }
.crumb strong { color: var(--foreground); font-weight: 600; }
.topbar-right { display: flex; align-items: center; gap: var(--space-3); }
.term-badge {
display: inline-flex; align-items: center;
font-size: var(--step-1); font-weight: 500;
color: var(--muted-foreground);
background: var(--muted-2);
border-radius: var(--radius);
padding: 3px 8px;
white-space: nowrap;
}
.avatar {
width: 28px; height: 28px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 999px;
background: var(--primary); color: var(--primary-foreground);
font-size: var(--step-1); font-weight: 600;
}
/* ── 본문 ── */
.main { min-width: 0; display: flex; flex-direction: column; }
.view { display: none; }
.view.is-on { display: block; }
.view:focus { outline: none; }
.page-head {
display: flex; justify-content: space-between; align-items: flex-start;
gap: var(--space-4);
padding: var(--space-6) var(--space-6) var(--space-5);
}
.page-head h1 { letter-spacing: -0.025em; }
.view-sub { color: var(--muted-foreground); font-size: var(--step-0); margin-top: 4px; max-width: 46em; }
.view-head {
display: flex; justify-content: space-between; align-items: flex-start;
gap: var(--space-4);
padding: var(--space-6) var(--space-6) var(--space-5);
}
.stamp { display: none; }
.view > *:not(.view-head):not(.page-head) { margin-left: var(--space-6); margin-right: var(--space-6); }
.view > *:last-child { margin-bottom: var(--space-6); }
/* ── 카드 ── */
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-5);
min-width: 0;
}
.panel h2 { margin-bottom: var(--space-4); font-size: var(--step-2); }
.panel-note { font-size: var(--step-1); color: var(--faint); margin-top: var(--space-3); }
/* 통계 카드 */
.headline { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); }
.headline > div {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm); padding: var(--space-4) var(--space-5);
}
.headline dt { font-size: var(--step-0); color: var(--muted-foreground); font-weight: 500; }
.headline dd { font-size: var(--step-3); font-weight: 600; letter-spacing: -0.03em; line-height: 1.2; font-variant-numeric: tabular-nums; margin-top: 4px; }
.headline .stat-sub, .rep-stat-sub { font-size: var(--step--1); color: var(--faint); margin: 6px 0 0; margin-inline-start: 0; font-variant-numeric: tabular-nums; }
.dash-grid {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: var(--space-4);
align-items: start;
margin-top: var(--space-4);
}
.week-panel { grid-row: span 2; }
/* ── 표 ── */
.table-wrap { overflow-x: auto; overflow-y: hidden; }
table { width: 100%; border-collapse: collapse; }
thead th {
text-align: left; font-weight: 500; font-size: var(--step-1);
color: var(--muted-foreground); padding: 0 var(--space-3) var(--space-3);
border-bottom: 1px solid var(--border);
height: 36px; white-space: nowrap;
}
tbody td, tbody th { padding: var(--space-3); border-bottom: 1px solid var(--border); vertical-align: middle; text-align: left; }
tbody th { font-weight: 500; }
tbody tr:last-child td, tbody tr:last-child th { border-bottom: 0; }
tbody tr { transition: background-color 80ms ease; }
tbody tr:hover { background: var(--muted); }
.tsum { padding: var(--space-3); font-size: var(--step--1); color: var(--muted-foreground); border-top: 1px solid var(--border); font-variant-numeric: tabular-nums; white-space: nowrap; }
.row-open {
appearance: none; border: 0; background: none; padding: 0;
font: inherit; font-weight: 500; color: var(--foreground);
cursor: pointer; text-align: left;
transition: color 80ms ease;
}
.row-open:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
/* ── 배지 ── */
.state {
display: inline-flex; align-items: center;
font-size: var(--step-1); font-weight: 500;
border-radius: var(--radius);
padding: 2px 8px;
white-space: nowrap;
background: var(--success-soft); color: var(--success-ink);
}
.state-wait { background: var(--warning-soft); color: var(--warning-ink); }
.state-off { background: var(--muted-2); color: var(--muted-foreground); }
.state-run { background: var(--accent-soft); color: var(--accent-ink); }
.state-run::before { content: ""; width: 6px; height: 6px; border-radius: 999px; background: var(--accent); margin-right: 6px; animation: dot-pulse 1.6s ease infinite; }
@keyframes dot-pulse { 50% { opacity: 0.35; } }
.t-warn { color: var(--warning-ink); font-weight: 500; }
/* ── 차트 ── */
.week-chart svg { display: block; width: 100%; height: auto; }
.dist-chart svg { display: block; height: 200px; width: auto; max-width: 100%; margin-inline: auto; }
.dist-chart .bars text { font-size: 11px; font-weight: 600; fill: var(--muted-foreground); }
.dist-body { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: var(--space-6); align-items: center; }
.dist-stats { border-left: 1px solid var(--border); padding-left: var(--space-5); }
.dist-stats > div { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); padding: 9px 0; border-bottom: 1px solid var(--border); }
.dist-stats > div:last-child { border-bottom: 0; }
.dist-stats dt { font-size: var(--step-0); color: var(--muted-foreground); }
.dist-stats dd { font-weight: 600; font-variant-numeric: tabular-nums; }
.axis line { stroke: var(--border); stroke-width: 1; }
.axis-labels text { font-size: 11px; fill: var(--muted-foreground); }
.b-att { fill: var(--border-strong); }
.b-late { fill: #f59e0b; }
.b-abs { fill: var(--danger); }
.b-hl { fill: var(--accent); }
.week-chart figcaption, .dist-chart figcaption {
display: flex; gap: var(--space-4);
font-size: var(--step-1); color: var(--muted-foreground);
margin-top: var(--space-3);
}
.wk-sum { margin-top: var(--space-4); border-top: 1px solid var(--border); }
.wk-sum thead th { padding: 8px var(--space-2); }
.wk-sum tbody td { padding: 7px var(--space-2); }
.wk-sum tfoot td { padding: 8px var(--space-2); border-top: 1px solid var(--border-strong); font-weight: 600; }
.wk-sum td:first-child, .wk-sum th:first-child { padding-left: 0; white-space: nowrap; }
.wk-sum td:last-child, .wk-sum th:last-child { padding-right: 0; }
.key { display: inline-flex; align-items: center; gap: 6px; }
.key i { width: 8px; height: 8px; display: inline-block; border-radius: 2px; }
.k-att { background: var(--border-strong); }
.k-late { background: #f59e0b; }
.k-abs { background: var(--danger); }
.hl { background: none; padding: 0; font-weight: 600; color: var(--foreground); }
/* ── 마감 목록 ── */
.due-list { list-style: none; margin: 0; padding: 0; display: grid; }
.due-row {
appearance: none; background: none; border: 0;
width: 100%; display: grid; gap: 2px; text-align: left;
font: inherit; padding: var(--space-3);
cursor: pointer; border-bottom: 1px solid var(--border);
transition: background-color 80ms ease;
}
.due-list li:last-child .due-row { border-bottom: 0; }
.due-row:hover { background: var(--muted); }
.due-row span:last-child { font-size: var(--step-1); color: var(--muted-foreground); }
.due-row.is-static { cursor: default; }
.due-row.is-static:hover { background: none; }
.hl-count { color: var(--danger-ink); font-weight: 600; background: var(--danger-soft); border-radius: 4px; padding: 0 5px; }
/* ── 검색 + 세그먼트 ── */
.toolbar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; margin: 0 0 var(--space-4); }
#q {
font: inherit; color: var(--foreground);
background: var(--card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
height: 36px;
padding: 0 var(--space-3);
min-width: 13rem;
box-shadow: var(--shadow-sm);
transition: border-color 100ms ease, box-shadow 100ms ease;
}
#q:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
#q::placeholder { color: var(--faint); }
.chips {
display: inline-flex; gap: 2px;
background: var(--muted-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 3px;
}
.chip {
appearance: none; cursor: pointer; font: inherit;
font-size: var(--step-1); font-weight: 500;
background: none; color: var(--muted-foreground);
border: 0; border-radius: var(--radius);
height: 28px; padding: 0 var(--space-3);
display: inline-flex; align-items: center;
transition: background-color 100ms ease, color 100ms ease, box-shadow 100ms ease;
}
.chip:hover { color: var(--foreground); }
.chip.is-on { background: var(--card); color: var(--foreground); box-shadow: var(--shadow-sm); }
/* ── 출석부 히트맵 ── */
.att-day { text-align: center; }
.att-cell { text-align: center; padding: 3px; }
.att-cell.is-na { color: var(--faint); }
.att-btn {
appearance: none; border: 1px solid transparent; cursor: pointer;
font: inherit; font-size: var(--step-1); line-height: 1; font-weight: 600;
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius);
transition: transform 100ms ease, box-shadow 100ms ease;
}
.att-btn:hover { transform: scale(1.08); box-shadow: var(--shadow-sm); }
.att-btn:active { scale: 0.94; }
.att-btn.v-att { background: var(--success-soft); color: var(--success-ink); }
.att-btn.v-late { background: #fef3c7; color: var(--warning-ink); }
.att-btn.v-abs { background: var(--danger-soft); color: var(--danger-ink); }
.week-nav { display: inline-flex; align-items: center; gap: var(--space-2); }
.week-label { min-width: 4.5em; text-align: center; color: var(--muted-foreground); font-size: var(--step-1); }
.week-nav .btn-line:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── 자료실 ── */
.give-btn {
appearance: none; cursor: pointer; font: inherit;
font-size: var(--step-1); font-weight: 500;
background: none; color: var(--muted-foreground);
border: 1px solid var(--border-strong); border-radius: var(--radius);
height: 28px; padding: 0 10px;
transition: all 100ms ease;
white-space: nowrap;
}
.give-btn:hover { border-color: var(--foreground); color: var(--foreground); }
.give-btn.is-given { background: var(--success-soft); border-color: transparent; color: var(--success-ink); }
/* ── 학사 일정 ── */
.cal-layout {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: var(--space-4);
align-items: start;
}
.cal { table-layout: fixed; }
.cal th { text-align: center; font-size: var(--step-1); height: 32px; padding: 0; }
.cal-day {
height: 76px; padding: var(--space-1);
border: 1px solid var(--border);
vertical-align: top;
}
.cal-day:nth-child(7n+6), .cal-day:nth-child(7n+7) { background: var(--muted); }
.cal-day.is-empty { border: 0; background: none !important; }
.cal-num { display: block; color: var(--muted-foreground); font-size: var(--step-1); font-weight: 500; }
.cal-day.is-today .cal-num {
display: inline-grid; place-items: center;
width: 22px; height: 22px;
color: #fff; font-weight: 600;
background: #047857; /* 진한 에메랄드 — 흰 숫자 5.4:1 */
border-radius: 999px;
}
.cal-ev {
display: block; margin-top: 2px;
font-size: var(--step--2); line-height: 1.35; word-break: keep-all;
background: var(--accent-soft); color: var(--accent-ink);
border-radius: 4px;
padding: 1px 4px;
}
/* ── 상담 기록 ── */
.counsel-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.counsel-card {
appearance: none; cursor: pointer; font: inherit; text-align: left;
display: grid; gap: 4px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-4);
transition: border-color 100ms ease, box-shadow 100ms ease;
}
.counsel-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-lg); }
.counsel-card:active { scale: 0.995; }
.co-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); }
.co-head strong { font-weight: 600; }
.co-sub { font-size: var(--step-1); }
.co-text { font-size: var(--step-0); line-height: var(--leading-normal); word-break: keep-all; }
.empty-note { color: var(--muted-foreground); font-size: var(--step-0); }
/* ── 버튼 ── */
.btn-primary, .btn-outline, .btn-ghost, .btn-line, .btn-solid {
appearance: none; cursor: pointer; font: inherit;
font-size: var(--step-0); font-weight: 500;
height: 36px; padding: 0 var(--space-4);
border-radius: var(--radius);
display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease, scale 80ms ease;
white-space: nowrap;
}
.btn-primary, .btn-solid { background: var(--primary); border: 1px solid var(--primary); color: var(--primary-foreground); box-shadow: var(--shadow-sm); }
.btn-primary:hover, .btn-solid:hover { background: #27272a; border-color: #27272a; }
.btn-outline, .btn-line { background: var(--card); border: 1px solid var(--border-strong); color: var(--foreground); box-shadow: var(--shadow-sm); }
.btn-outline:hover, .btn-line:hover { background: var(--muted); }
.btn-ghost { background: none; border: 0; color: var(--muted-foreground); padding: 0 var(--space-3); }
.btn-ghost:hover { color: var(--foreground); background: var(--muted-2); }
.btn-primary:active, .btn-outline:active, .btn-line:active, .btn-solid:active, .btn-ghost:active { scale: 0.98; }
/* ── 모달 ── */
.modal {
margin: auto;
width: min(94vw, 860px);
max-height: min(86dvh, 760px);
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
color: var(--foreground);
overflow: hidden;
box-shadow: var(--shadow-xl);
}
.modal::backdrop { background: rgb(9 9 11 / 0.55); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
@media (prefers-reduced-motion: no-preference) {
.modal[open] { animation: modal-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(0.99); } }
}
.mspread {
display: grid;
grid-template-columns: 264px minmax(0, 1fr);
max-height: min(86dvh, 760px);
}
.mcover {
position: relative;
background: var(--primary);
color: var(--primary-foreground);
padding: var(--space-6);
display: flex; flex-direction: column;
overflow: hidden;
}
.mcover-mark {
position: absolute; right: -14px; bottom: -20px;
font-size: 8rem; font-weight: 600; line-height: 1;
color: rgb(250 250 250 / 0.05);
pointer-events: none; user-select: none;
}
.mcover-seal {
width: 44px; height: 44px;
display: inline-flex; align-items: center; justify-content: center;
font-size: var(--step-2); font-weight: 600;
color: #fff;
background: var(--accent);
border-radius: var(--radius);
margin-bottom: var(--space-4);
}
.mcover-name {
font-family: var(--font-display);
font-size: var(--step-3); line-height: 1.2; font-weight: 600;
letter-spacing: -0.02em; word-break: keep-all;
}
.mcover-meta { color: #a1a1aa; font-size: var(--step-1); margin-top: var(--space-2); }
.mcover-stats { margin-top: var(--space-5); border-top: 1px solid rgb(250 250 250 / 0.12); }
.mcover-stats > div {
display: flex; justify-content: space-between; align-items: baseline;
padding: var(--space-3) 0;
border-bottom: 1px solid rgb(250 250 250 / 0.12);
}
.mcover-stats dt { font-size: var(--step-1); color: #a1a1aa; }
.mcover-stats dd { font-size: var(--step-1); font-weight: 600; font-variant-numeric: tabular-nums; }
.mcover-foot { margin-top: auto; color: #71717a; font-size: var(--step--2); }
.msheet {
position: relative;
background: var(--card);
padding: var(--space-6);
overflow-y: auto;
overscroll-behavior: contain;
display: grid; gap: var(--space-5);
align-content: start;
min-height: 340px;
}
.msec h3 { color: var(--muted-foreground); margin-bottom: var(--space-3); font-weight: 500; font-size: var(--step-0); }
.modal-title { font-size: var(--step-3); letter-spacing: -0.02em; }
.modal-meta { color: var(--muted-foreground); margin-top: 2px; }
.modal-close {
position: absolute; top: var(--space-4); right: var(--space-4);
width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--card); color: var(--muted-foreground); cursor: pointer;
transition: background-color 100ms ease, color 100ms ease;
z-index: 2;
}
.modal-close:hover { background: var(--muted-2); color: var(--foreground); }
.modal-inner { position: relative; padding: var(--space-6); display: grid; gap: var(--space-4); }
/* 출석 스트립 */
.att-strip { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.att-cell2 { display: grid; justify-items: center; gap: 4px; min-width: 40px; }
.att-mark {
width: 30px; height: 30px;
display: inline-flex; align-items: center; justify-content: center;
font-size: var(--step-1); line-height: 1; font-weight: 600;
border-radius: var(--radius);
}
.att-mark.v-att { background: var(--success-soft); color: var(--success-ink); }
.att-mark.v-late { background: #fef3c7; color: var(--warning-ink); }
.att-mark.v-abs { background: var(--danger-soft); color: var(--danger-ink); }
.att-date { font-size: var(--step--2); color: var(--faint); }
.att-strip-na { font-size: var(--step-1); color: var(--muted-foreground); }
/* 상담 타임라인 */
#stu-note-list {
list-style: none; margin: 0 0 var(--space-4); padding: 0;
display: grid;
}
#stu-note-list li {
display: grid; grid-template-columns: 3rem minmax(0, 1fr);
gap: var(--space-3); align-items: baseline;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border);
font-size: var(--step-0);
}
.note-date { color: var(--faint); font-size: var(--step-1); }
.note-text { word-break: keep-all; }
.note-form { display: flex; gap: var(--space-2); }
.note-form input {
flex: 1; min-width: 0; font: inherit; font-size: var(--step-0);
height: 36px;
border: 1px solid var(--border-strong); border-radius: var(--radius);
background: var(--card); color: var(--foreground);
padding: 0 var(--space-3);
box-shadow: var(--shadow-sm);
}
.note-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
.note-form input::placeholder { color: var(--faint); }
/* 채점 */
.as-summary {
font-size: var(--step-0); color: var(--muted-foreground);
border-top: 1px solid var(--border);
padding-top: var(--space-3);
}
.as-summary strong { color: var(--foreground); font-weight: 600; font-size: var(--step-2); font-variant-numeric: tabular-nums; }
.grade-table input.score {
width: 5rem; font-family: var(--font-mono); font-size: var(--font-mono-size);
text-align: right; color: var(--foreground);
height: 30px;
border: 1px solid var(--border-strong); border-radius: var(--radius);
background: var(--card);
}
.grade-table input.score:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
.modal-actions { display: flex; align-items: center; gap: var(--space-4); }
.modal-note { font-size: var(--step-1); }
/* ── 반응형 ── */
@media (max-width: 900px) {
.app { grid-template-columns: minmax(0, 1fr); }
.side {
position: static; height: auto;
flex-direction: row; align-items: center;
gap: var(--space-3); padding: var(--space-2) var(--space-4);
border-right: 0; border-bottom: 1px solid var(--border);
overflow-x: auto; overflow-y: hidden;
min-width: 0;
}
.brand { padding: 0 var(--space-2) 0 0; flex: none; white-space: nowrap; } /* 가로 스트립에서 찌그러져 글자가 세로로 쌓이는 것을 막는다 */
.side-nav { display: flex; gap: 2px; flex-wrap: nowrap; flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
.side-group { display: none; }
.side-link { white-space: nowrap; width: auto; height: 32px; }
.side-foot { display: none; }
.dash-grid, .cal-layout, .counsel-grid { grid-template-columns: minmax(0, 1fr); }
.week-panel { grid-row: auto; }
.dist-body { grid-template-columns: minmax(0, 1fr); }
.view-head { flex-direction: column; align-items: flex-start; gap: var(--space-2); padding: var(--space-5) var(--space-4) var(--space-4); }
.view > *:not(.view-head):not(.page-head) { margin-inline: var(--space-4); }
.headline { gap: var(--space-2); }
.topbar { padding: 0 var(--space-4); min-width: 0; }
.term-badge { display: none; }
.crumb { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cal-day { height: 56px; }
}
@media (max-width: 680px) {
.mspread { grid-template-columns: minmax(0, 1fr); }
.mcover { padding: var(--space-4); flex-direction: row; align-items: center; gap: var(--space-3); }
.mcover-seal { width: 40px; height: 40px; margin-bottom: 0; font-size: var(--step-1); }
.mcover-name { font-size: var(--step-2); }
.mcover-meta { margin-top: 2px; }
.mcover-stats, .mcover-foot { display: none; }
.mcover-mark { font-size: 5rem; }
.msheet { padding: var(--space-4); min-height: 0; }
.headline { grid-template-columns: minmax(0, 1fr); }
}
/* ── 알림 센터 ── */
.bell-wrap { position: relative; }
.bell {
position: relative; width: 32px; height: 32px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--radius); border: none; background: none; cursor: pointer;
color: var(--muted-foreground); transition: background-color 80ms ease, color 80ms ease;
}
.bell:hover, .bell.is-open { background: var(--muted-2); color: var(--foreground); }
.bell-dot {
position: absolute; top: 4px; right: 4px;
width: 8px; height: 8px; border-radius: 999px;
background: var(--danger); border: 2px solid var(--background);
}
.bell-menu {
display: none; position: absolute; top: 40px; right: 0; z-index: 40;
width: min(340px, calc(100vw - 32px));
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); overflow: hidden;
}
.bell-menu.is-open { display: block; }
.bell-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--border);
font-size: var(--step-0); font-weight: 600;
}
.bell-head button {
border: none; background: none; cursor: pointer;
font-size: var(--step--1); color: var(--accent-ink); padding: 2px 4px; border-radius: 4px;
}
.bell-head button:hover { background: var(--accent-soft); }
.ntc { display: flex; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--border); }
.ntc:last-child { border-bottom: 0; }
.ntc .n-ico {
width: 28px; height: 28px; flex: none; border-radius: var(--radius);
display: inline-flex; align-items: center; justify-content: center;
background: var(--muted-2); color: var(--muted-foreground);
}
.ntc.is-unread .n-ico { background: var(--accent-soft); color: var(--accent-ink); }
.ntc p { font-size: var(--step-0); line-height: 1.45; }
.ntc.is-read p { color: var(--muted-foreground); }
.ntc time { display: block; font-size: var(--step--1); color: var(--faint); margin-top: 2px; }
/* ── 태그 칩 (유형·사유) ── */
.tag {
display: inline-flex; align-items: center; white-space: nowrap;
font-size: var(--step--2); font-weight: 500;
border: 1px solid var(--border); color: var(--muted-foreground);
border-radius: 999px; padding: 1px 8px;
}
.tag-quiz { background: var(--accent-soft); color: var(--accent-ink); border-color: transparent; }
.tag-exam { background: var(--warning-soft); color: var(--warning-ink); border-color: transparent; }
.tag-warn { background: var(--warning-soft); color: var(--warning-ink); border-color: transparent; }
.tag-ok { background: var(--success-soft); color: var(--success-ink); border-color: transparent; }
/* ── 수업 진행 ── */
.h-note { font-size: var(--step--1); font-weight: 400; color: var(--faint); margin-left: 8px; }
.prog-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
.prog-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-2); }
.prog-head strong { font-size: var(--step-0); font-weight: 600; }
.prog-bar { height: 6px; border-radius: 999px; background: var(--muted-2); overflow: hidden; margin-top: 10px; }
.prog-bar i { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
.prog-meta { font-size: var(--step--1); color: var(--faint); margin-top: 8px; font-variant-numeric: tabular-nums; }
/* ── 공지 뷰 ── */
.notice-list { list-style: none; display: flex; flex-direction: column; }
.notice-row {
width: 100%; text-align: left; cursor: pointer;
background: none; border: none; border-bottom: 1px solid var(--border);
padding: 14px var(--space-4);
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--space-3);
}
.notice-list li:last-child .notice-row { border-bottom: 0; }
.notice-row:hover { background: var(--muted); }
.n-t { font-size: var(--step-0); font-weight: 600; display: flex; align-items: center; gap: 8px; min-width: 0; word-break: keep-all; }
.notice-row.is-unread .n-t::after {
content: ""; width: 6px; height: 6px; border-radius: 999px;
background: var(--accent); flex: none;
}
.pin { color: var(--accent-ink); flex: none; }
.n-meta {
grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: var(--space-3);
font-size: var(--step--1); color: var(--faint); font-variant-numeric: tabular-nums;
}
.n-body {
max-width: 68ch;
grid-column: 1 / -1; display: none;
font-size: var(--step-0); line-height: 1.65; color: var(--muted-foreground);
padding-top: 2px;
}
.notice-row.is-open .n-body { display: block; }
/* ── 출결 사유 칩 ── */
.att-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--space-2); }
@media (pointer: coarse) {
.att-btn { width: 40px; height: 40px; }
.chip, .side-link, .give-btn { min-height: 40px; }
}
@media (max-width: 1080px) {
.prog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
.prog-grid { grid-template-columns: minmax(0, 1fr); }
}
/* ── 뷰 공통 요약 스트립 ── */
.statline {
display: flex; flex-wrap: wrap; gap: 8px;
margin-bottom: var(--space-4);
}
.statline > div {
display: inline-flex; align-items: baseline; gap: 6px;
background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-sm);
padding: 6px 12px; font-size: var(--step-1); color: var(--muted-foreground);
}
.statline strong {
font-size: var(--step-0); font-weight: 600; color: var(--foreground);
font-variant-numeric: tabular-nums;
}
.statline .warn-ink { color: var(--warning-ink); }
.statline .muted-ink { color: var(--muted-foreground); }
/* ── 리포트 학기 요약 카드(4열) ── */
.headline-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (max-width: 900px) { .headline-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .headline-4 { grid-template-columns: minmax(0, 1fr); } }
/* ── 수강생 보호자 · 모달 보호자 블록 ── */
#stu-table td.muted { color: var(--muted-foreground); white-space: nowrap; }
.guard-line { font-size: var(--step-0); font-weight: 500; }
.guard-log {
list-style: none; margin-top: 8px;
font-size: var(--step--1); color: var(--muted-foreground);
display: grid; gap: 4px;
}
/* ── 학사 일정: 유형색 + 범례 + D-day 점 ── */
.cal-ev.is-exam { background: var(--warning-soft); color: var(--warning-ink); }
.cal-ev.is-admin { background: var(--muted-2); color: var(--muted-foreground); }
.cal-ev.is-holi { background: var(--danger-soft); color: var(--danger-ink); }
.cal-legend {
display: flex; flex-wrap: wrap; gap: var(--space-4);
margin-top: var(--space-3); padding-top: var(--space-3);
border-top: 1px solid var(--border);
font-size: var(--step--1); color: var(--muted-foreground);
}
.lg-exam, .lg-due, .lg-admin, .lg-holi, .ev-dot {
width: 8px; height: 8px; border-radius: 2px; display: inline-block;
}
.lg-exam, .ev-dot.is-exam { background: var(--warning); }
.lg-due, .ev-dot.is-due { background: var(--accent); }
.lg-admin, .ev-dot.is-admin { background: var(--border-strong); }
.lg-holi, .ev-dot.is-holi { background: var(--danger); }
.ev-dot { margin-right: 8px; vertical-align: 0; }
/* ── 공지 뷰 툴바 여백 ── */
#view-notice .toolbar { margin-bottom: var(--space-4); }
/* ── 모바일 갸행 수습(v12) — 한글은 어절에서만 끊고, 외톨이 줄을 만들지 않는다 ── */
.view-sub, .n-body, .n-meta, .prog-meta, .stat-sub, .guard-line, .counsel-grid .co-text, .panel-note, .att-chips {
word-break: keep-all;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.due-row span { word-break: keep-all; }
@media (max-width: 680px) {
/* 마감 임박 · 예정 상담 행 — 제목과 메타를 세로로 쌓아 메타가 끊기지 않게 */
.due-row { display: block; }
.due-row span { display: block; }
.due-row .mono { margin-top: 2px; }
/* 공지 행 — 제목이 전체 폭을 쓰고 분류 칩은 아래로 */
.notice-row { grid-template-columns: minmax(0, 1fr); }
.notice-row .tag { justify-self: start; }
}

View file

@ -0,0 +1,65 @@
/* ==========================================================================
가온 학적부 디자인 토큰 (v3: 프로덕션 콘솔)
프리셋: production-console shadcn/ui (new-york) 토큰 체계의 충실한 이식.
컨셉 문장: 실제 학원이 쓰는 같은 관리 콘솔 장식이 아니라 완성도로.
레퍼런스: shadcn/ui 디자인 시스템(zinc 스키마) 프로덕션 SaaS 관리 화면의
사실상 표준 문법. 실측값은 아래 주석.
하지 않은 : 종이/장부 컨셉(철회), 그라디언트, 글로우, 컨셉 워터마크.
색은 무채색 체계 + 상태색 3종 + 액센트 1(에메랄드).
========================================================================== */
:root {
/* 타입 — shadcn 본문 14px, 카드 값 24px tracking-tight */
--step--2: 0.6875rem; /* 11px — 배지/케이션 */
--step--1: 0.75rem; /* 12px — 보조 라벨 */
--step-0: 0.875rem; /* 14px — 본문(sm) */
--step-1: 1rem; /* 16px */
--step-2: 1.125rem; /* 18px — 섹션 제목 */
--step-3: 1.5rem; /* 24px — 카드 값/모달 제목 */
--step-4: 1.875rem; /* 30px — 화면 제목 */
--leading-tight: 1.3;
--leading-normal: 1.6;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
--font-display: "Noto Serif KR", "Pretendard Variable", serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
--font-mono-size: 0.8125rem;
/* 색 — shadcn zinc 스키마 */
--background: #fafafa; /* page */
--card: #ffffff;
--foreground: #09090b; /* zinc-950 */
--muted: #fafafa;
--muted-2: #f4f4f5; /* zinc-100 */
--muted-foreground: #64646e; /* zinc-50/gray-100 위 5.2:1 이상 */ /* zinc-500 */
--faint: #6e6e77; /* WCAG 4.5:1 보정 — 본문용 보조색 */
--border: #e4e4e7; /* zinc-200 */
--border-strong: #d4d4d8; /* zinc-300 */
--primary: #18181b; /* zinc-900 */
--primary-foreground: #fafafa;
--ring: #a1a1aa;
--accent: #059669; /* emerald-600 — 이 제품의 단 하나의 브랜드색 */
--accent-soft: #ecfdf5; /* emerald-50 */
--accent-ink: #065f46; /* emerald-800 */
--success: #059669; --success-soft: #ecfdf5; --success-ink: #065f46;
--warning: #b45309; --warning-soft: #fffbeb; --warning-ink: #92400e;
--danger: #dc2626; --danger-soft: #fef2f2; --danger-ink: #991b1b;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-7: 3rem;
/* 반지름 — shadcn 파생 규칙: base 8px → 컨트롤 6px, 판 12px */
--radius: 6px;
--radius-lg: 12px;
/* 그림자 — Tailwind shadow-sm / shadow-lg 실측값 */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}

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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,427 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumina Optics · Refraction Bench</title>
<meta
name="description"
content="A WebGL bench where a single beam meets a prism: refraction and dispersion computed per frame. Drag the prism, change the index."
/>
<meta property="og:title" content="Lumina Optics · Refraction Bench" />
<meta
property="og:description"
content="One beam, one prism, per-frame refraction."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#101214" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
/>
<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='%23101214'/%3E%3Cpath d='M6 16h8m8 0h4M14 16l10-7v14z' fill='none' stroke='%23BFE8EE' stroke-width='1.4'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">Skip to main content</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">lumina<span class="wordmark-dot">·</span>optics</a>
<nav class="nav" aria-label="Sections">
<a href="#bench">Bench</a>
<a href="#anatomy">Anatomy</a>
<a href="#mtf">MTF</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main id="main">
<!--
The bench. The product is light behaviour, so the hero IS the instrument:
one beam, one prism, per-frame refraction. Drag rotates; sliders change
the index and the dispersion — they are the two honest knobs a bench
has. Everything degrades to a static photograph.
-->
<section id="bench" class="hero" aria-labelledby="hero-h">
<div class="bench-stage reveal">
<canvas
id="gl"
aria-label="Interactive prism: a white beam enters from the left and exits as a spectrum. Drag to rotate the prism."
></canvas>
<img
class="bench-fallback"
src="./assets/prism-bench.webp"
alt="A glass prism on a dark optical breadboard splitting one collimated beam into a spectrum"
width="1440"
height="810"
/>
</div>
<div class="wrap hero-inner">
<div class="hero-text">
<p class="hero-kicker reveal">Refraction bench · WebGL</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
One beam,<br />bent honestly
</h1>
<p class="lead reveal" style="--delay: 120ms">
No photograph on this screen is doing the work — the prism is
being refracted right now, per frame, on your GPU. Drag it. Change
the index. The spectrum you see is the same arithmetic lens
designers use, minus the glasses.
</p>
</div>
<div class="hud reveal" style="--delay: 160ms">
<div class="hud-row">
<label for="ior">Index of refraction <output id="ior-out" for="ior">1.52</output></label>
<input type="range" id="ior" min="1.45" max="2.20" step="0.01" value="1.52" />
</div>
<div class="hud-row">
<label for="disp">Dispersion (Abbe ↓) <output id="disp-out" for="disp">0.18</output></label>
<input type="range" id="disp" min="0" max="0.6" step="0.01" value="0.18" />
</div>
<p class="hud-note" id="hud-note">Drag the prism to rotate · beam is collimated at 632.8 nm reference</p>
</div>
</div>
</section>
<!--
Anatomy — cut lens photography. Real glass, not renders.
-->
<section id="anatomy" class="section" aria-labelledby="anatomy-h">
<div class="wrap two">
<figure class="two-photo reveal">
<img
src="./assets/lens-section.webp"
alt="Cutaway of a precision lens: a stack of glass elements in a metal barrel, edge-lit so each surface shows a refraction line"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>L-85 cutaway · 7 elements in 6 groups</figcaption>
</figure>
<div class="two-text">
<h2 id="anatomy-h">Glass decides<br />before software does</h2>
<p>
A lens is a chain of refractions, and every surface answers before
any firmware runs. Our L-85 uses two low-dispersion elements to
keep the spectrum you just dragged from fanning out across the
frame — dispersion corrected in glass, not in a profile.
</p>
<p class="muted">
The cutaway above is a physical section, milled for measurement.
Element count and spacing are measured from it, not from the
drawing.
</p>
</div>
</div>
</section>
<!--
MTF — the only curve that argues. Static, thin, honest:
measured at infinity, full aperture, both field directions.
-->
<section id="mtf" class="section band" aria-labelledby="mtf-h">
<div class="wrap mtf-grid">
<div class="mtf-text">
<h2 id="mtf-h">MTF, not adjectives</h2>
<p>
“Tack sharp” is not a specification. Contrast transfer at given
line pairs per millimetre is. These curves are measured on an
optical bench at full aperture — sagittal and meridional, out to
the corner.
</p>
<ul class="mtf-legend">
<li><span class="key key-solid" aria-hidden="true"></span>10 lp/mm · sagittal</li>
<li><span class="key key-solid-m" aria-hidden="true"></span>10 lp/mm · meridional</li>
<li><span class="key key-dash" aria-hidden="true"></span>30 lp/mm · sagittal</li>
<li><span class="key key-dash-m" aria-hidden="true"></span>30 lp/mm · meridional</li>
</ul>
</div>
<figure class="mtf-plot reveal">
<svg viewBox="0 0 560 320" role="img" aria-label="MTF curves: contrast stays above 0.85 at 10 lp/mm across the field; at 30 lp/mm it falls from 0.75 at center to 0.42 at the corner.">
<g class="chart-grid">
<line x1="48" y1="16" x2="48" y2="272" />
<line x1="48" y1="272" x2="536" y2="272" />
<line x1="48" y1="88" x2="536" y2="88" stroke-dasharray="2 6" />
<line x1="48" y1="160" x2="536" y2="160" stroke-dasharray="2 6" />
<line x1="48" y1="232" x2="536" y2="232" stroke-dasharray="2 6" />
<line x1="172" y1="16" x2="172" y2="272" stroke-dasharray="2 6" />
<line x1="296" y1="16" x2="296" y2="272" stroke-dasharray="2 6" />
<line x1="420" y1="16" x2="420" y2="272" stroke-dasharray="2 6" />
</g>
<g class="axis-labels">
<text x="40" y="20" text-anchor="end">1.0</text>
<text x="40" y="92" text-anchor="end">0.75</text>
<text x="40" y="164" text-anchor="end">0.5</text>
<text x="40" y="236" text-anchor="end">0.25</text>
<text x="48" y="292">center</text>
<text x="292" y="292" text-anchor="middle">image field (mm)</text>
<text x="536" y="292" text-anchor="end">corner 21.6</text>
</g>
<!-- 10 lp/mm sagittal: high, gentle fall -->
<polyline class="mtf mtf-10s" points="48,32 172,36 296,44 420,60 536,76" />
<!-- 10 lp/mm meridional -->
<polyline class="mtf mtf-10m" points="48,34 172,40 296,52 420,72 536,96" />
<!-- 30 lp/mm sagittal -->
<polyline class="mtf mtf-30s" points="48,84 172,96 296,120 420,160 536,196" />
<!-- 30 lp/mm meridional -->
<polyline class="mtf mtf-30m" points="48,90 172,108 296,140 420,186 536,232" />
</svg>
<figcaption>L-85 · f/1.8 · infinity focus · bench-measured 2026-07</figcaption>
</figure>
</div>
</section>
<!--
Spec — with conditions.
-->
<section class="section" aria-labelledby="spec-h">
<div class="wrap">
<h2 id="spec-h" class="reveal">Specification</h2>
<div class="table-wrap reveal" style="--delay: 80ms">
<table>
<caption class="sr-only">L-85 specification — item, value, condition</caption>
<thead>
<tr>
<th scope="col">Item</th>
<th scope="col" class="num">Value</th>
<th scope="col">Condition</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">Focal length</th><td class="num">85 mm</td><td>nominal · ±1.5% at infinity</td></tr>
<tr><th scope="row">Aperture</th><td class="num">f/1.8 f/16</td><td>9-blade · near-circular</td></tr>
<tr><th scope="row">Elements</th><td class="num">7 in 6</td><td>2 LD · measured from cutaway</td></tr>
<tr><th scope="row">Close focus</th><td class="num">0.62 m</td><td>1:7.2 magnification</td></tr>
<tr><th scope="row">Filter</th><td class="num">Ø 62 mm</td><td>brass, front-threaded</td></tr>
<tr><th scope="row">Weight</th><td class="num">438 g</td><td>without caps, with hood</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- Contact -->
<section id="contact" class="section band contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">Bench visits</h2>
<p>
We measure loaner samples for free and publish the curves —
manufacturers welcome or not. Bench time and MTF requests:
</p>
<a class="btn" href="mailto:bench@lumina.optics">bench@lumina.optics</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>lumina optics</p>
<p class="foot-meta">measurements reproducible on request · bench cal. 2026-06</p>
</div>
</footer>
<script type="module">
/*
Refraction bench — three.js, one prism, two honest uniforms.
Budget note: three.module.js ≈ 165KB gzip. 예산 상향 — 이 페이지의
제품이 WebGL 자체다(데모 브리프 예산 상향 허용).
폴백: WebGL 실패 시 사진이 보이고 캔버스는 뜨지 않는다(이미 마크업에 있음).
*/
const canvas = document.getElementById("gl");
const fallback = document.querySelector(".bench-fallback");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
let gl = null;
try {
gl = canvas.getContext("webgl2", { antialias: true, alpha: true });
} catch (e) { /* fall through */ }
if (!gl) {
canvas.remove();
document.getElementById("hud-note").textContent =
"WebGL unavailable — showing the bench photograph instead.";
} else {
fallback.remove();
const THREE = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js");
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true, alpha: true });
renderer.setClearColor(0x000000, 0);
const DPR = Math.min(devicePixelRatio || 1, 1.5);
renderer.setPixelRatio(DPR);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 50);
camera.position.set(0, 0, 9);
// ----- prism: triangular cross-section (CylinderGeometry, 3 segments)
const prismGeo = new THREE.CylinderGeometry(2.1, 2.1, 2.6, 3, 1, false);
prismGeo.rotateX(Math.PI / 2); // axis toward camera
prismGeo.rotateZ(Math.PI / 2); // flat edge up to the beam
const uniforms = {
uTime: { value: 0 },
uIor: { value: 1.52 },
uDisp: { value: 0.18 },
};
const prismMat = new THREE.ShaderMaterial({
uniforms,
transparent: true,
vertexShader: /* glsl */`
varying vec3 vN;
varying vec3 vView;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vN = normalize(normalMatrix * normal);
vView = normalize(-mv.xyz);
gl_Position = projectionMatrix * mv;
}`,
fragmentShader: /* glsl */`
precision highp float;
varying vec3 vN;
varying vec3 vView;
uniform float uIor;
uniform float uDisp;
// wavelength ramp: violet → cyan → yellow → red
vec3 spectrum(float t) {
t = clamp(t, 0.0, 1.0);
return clamp(
vec3(
smoothstep(0.30, 0.00, t) + smoothstep(0.70, 1.00, t),
smoothstep(0.00, 0.35, t) - smoothstep(0.60, 1.00, t),
smoothstep(0.00, 0.30, t) - smoothstep(0.45, 0.75, t)
), 0.0, 1.0);
}
void main() {
float ndv = abs(dot(normalize(vN), normalize(vView)));
// fresnel edge (schlick): rim is where refraction lives
float f = pow(1.0 - ndv, 3.0);
// refraction deflection grows with index
float bend = (uIor - 1.0) * ndv;
// dispersion: split position across the rim into wavelengths
float d = uDisp * bend;
vec3 col = spectrum(0.5 + (1.0 - ndv) * (0.6 + d * 4.0));
// glass body: near-black with faint interior glow
vec3 body = vec3(0.02, 0.025, 0.03) * (0.4 + ndv);
vec3 rim = col * f * 1.35;
float alpha = clamp(f * 0.95 + 0.06, 0.0, 1.0);
gl_FragColor = vec4(body + rim, alpha);
}`,
});
const prism = new THREE.Mesh(prismGeo, prismMat);
scene.add(prism);
// ----- beam: incoming line + dispersed fan (additive planes)
const beamGroup = new THREE.Group();
const mkBeam = (w, h, color1, color2, op) => {
const m = new THREE.Mesh(
new THREE.PlaneGeometry(w, h),
new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uOp: { value: op }, uC1: { value: new THREE.Color(color1) }, uC2: { value: new THREE.Color(color2) } },
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
fragmentShader: `
varying vec2 vUv; uniform float uOp; uniform vec3 uC1; uniform vec3 uC2;
void main(){
float a = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
float b = smoothstep(0.0, 0.5, vUv.y) * smoothstep(1.0, 0.5, vUv.y);
vec3 c = mix(uC1, uC2, vUv.x);
gl_FragColor = vec4(c, a * b * uOp);
}`,
})
);
return m;
};
const beamIn = mkBeam(9, 0.10, 0xdfe8ec, 0xffffff, 0.55);
beamIn.position.set(0, 1.45, -0.2);
beamGroup.add(beamIn);
const fan = mkBeam(9, 1.7, 0x8fd8e8, 0xf2b060, 0.30);
fan.position.set(-0.4, 0.35, -0.25);
fan.rotation.z = 0.085;
beamGroup.add(fan);
scene.add(beamGroup);
// ----- interaction
let dragging = false, lastX = 0, vel = 0.0016, rot = 0;
canvas.addEventListener("pointerdown", (e) => { dragging = true; lastX = e.clientX; canvas.setPointerCapture(e.pointerId); });
canvas.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dx = e.clientX - lastX;
lastX = e.clientX;
rot += dx * 0.006;
vel = dx * 0.0006;
});
const end = () => (dragging = false);
canvas.addEventListener("pointerup", end);
canvas.addEventListener("pointercancel", end);
canvas.style.touchAction = "none";
const iorEl = document.getElementById("ior");
const dispEl = document.getElementById("disp");
const iorOut = document.getElementById("ior-out");
const dispOut = document.getElementById("disp-out");
const bind = (el, out, uni, fmt) =>
el.addEventListener("input", () => {
uni.value = parseFloat(el.value);
out.textContent = fmt(uni.value);
});
bind(iorEl, iorOut, uniforms.uIor, (v) => v.toFixed(2));
bind(dispEl, dispOut, uniforms.uDisp, (v) => v.toFixed(2));
// ----- sizing
const stage = canvas.parentElement;
function fit() {
const r = stage.getBoundingClientRect();
const h = Math.max(320, Math.min(r.height, 760));
renderer.setSize(r.width, h, false);
camera.aspect = r.width / h;
camera.updateProjectionMatrix();
}
fit();
new ResizeObserver(fit).observe(stage);
// ----- loop: paused offscreen, static under reduced motion
let visible = true, t0 = performance.now();
const io = new IntersectionObserver((en) => { visible = en[0].isIntersecting; });
io.observe(canvas);
function frame() {
if (!visible) { requestAnimationFrame(frame); return; }
const t = (performance.now() - t0) / 1000;
uniforms.uTime.value = t;
if (!dragging) { vel *= 0.98; rot += Math.max(0.0016, Math.abs(vel)) * Math.sign(vel || 1); }
prism.rotation.z = rot;
beamGroup.rotation.z = Math.sin(t * 0.4) * 0.01;
renderer.render(scene, camera);
if (!reduced) requestAnimationFrame(frame);
}
frame(); // reduced-motion: renders exactly one static frame
}
const io2 = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io2.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
</script>
</body>
</html>

View file

@ -0,0 +1,477 @@
@import url("./tokens.css");
/* ==========================================================================
Lumina Optics 광학 시험대. 색은 캔버스 안에서만.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@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;
}
:where(h1, h2) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
:where(p, th, td, figcaption, li) {
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.lead {
color: var(--ink-muted);
max-width: 36em;
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
: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;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
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-mono);
font-size: var(--step-1);
font-weight: 500;
text-decoration: none;
letter-spacing: -0.02em;
}
.wordmark-dot {
color: var(--accent-stroke);
}
.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-stroke);
}
/* 시험대(히어로) 캔버스와 본문은 세로로 완전히 분리.
R2 원칙(design.md): 3D 위에 본문을 얹지 않는다. */
.hero {
border-bottom: 1px solid var(--line);
}
.bench-stage {
position: relative;
height: clamp(320px, 56vh, 560px);
background:
radial-gradient(80% 90% at 50% 30%, rgba(127, 185, 196, 0.05), transparent 60%),
var(--surface);
border-bottom: 1px solid var(--line);
}
#gl,
.bench-fallback {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
#gl {
cursor: grab;
}
#gl:active {
cursor: grabbing;
}
.hero-inner {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-5) var(--space-7);
align-items: start;
padding-block: var(--space-6);
}
.hero-kicker {
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--accent-stroke);
letter-spacing: 0.08em;
margin-bottom: var(--space-3);
}
.hero .lead {
margin-top: var(--space-3);
}
.hud {
background: color-mix(in srgb, var(--surface) 78%, transparent);
backdrop-filter: blur(8px);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-4);
display: grid;
gap: var(--space-3);
}
.hud-row label {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--step--1);
color: var(--ink-muted);
}
.hud-row output {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--ink);
}
input[type="range"] {
width: 100%;
accent-color: var(--accent-stroke);
}
.hud-note {
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.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, 5fr) minmax(0, 6fr);
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-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
.two-text p + p {
margin-top: var(--space-4);
}
/* ── MTF ──────────────────────────────────────────────────────────────── */
.mtf-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-5) var(--space-7);
align-items: center;
}
.mtf-text p {
margin-top: var(--space-3);
color: var(--ink-muted);
max-width: 30em;
}
.mtf-legend {
list-style: none;
padding: 0;
margin-top: var(--space-4);
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2) var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.key {
display: inline-block;
width: 16px;
height: 0;
border-top: 2px solid var(--accent-stroke);
margin-right: var(--space-2);
vertical-align: middle;
}
.key-solid {
border-top: 2.5px solid var(--accent-stroke);
}
.key-solid-m {
border-top: 2.5px solid var(--ink-faint);
}
.key-dash {
border-top: 2px dashed var(--accent-stroke);
}
.key-dash-m {
border-top: 2px dashed var(--ink-faint);
}
.mtf-plot svg {
display: block;
width: 100%;
height: auto;
}
.chart-grid line {
stroke: var(--line);
stroke-width: 1;
}
.chart-grid line:first-child,
.chart-grid line:nth-child(2) {
stroke: var(--line-strong);
}
.axis-labels text {
font-size: 12px;
fill: var(--ink-faint);
}
.mtf {
fill: none;
stroke-width: 2;
stroke-linecap: round;
}
.mtf-10s {
stroke: var(--accent-stroke);
}
.mtf-10m {
stroke: var(--ink-faint);
}
.mtf-30s {
stroke: var(--accent-stroke);
stroke-dasharray: 6 5;
}
.mtf-30m {
stroke: var(--ink-faint);
stroke-dasharray: 6 5;
}
.mtf-plot figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 사양 표 ──────────────────────────────────────────────────────────── */
.table-wrap {
margin-top: var(--space-4);
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-faint);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line-strong);
}
tbody th {
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
tbody td:not(.num) {
color: var(--ink-muted);
font-size: var(--step--1);
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
/* ── 연락 ─────────────────────────────────────────────────────────────── */
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-4) var(--space-7);
align-items: center;
}
.contact-grid p {
color: var(--ink-muted);
max-width: 34em;
}
.btn {
display: inline-block;
border: 1px solid var(--accent-stroke);
color: var(--accent);
text-decoration: none;
font-weight: 500;
padding: 0.7rem 1.4rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent-stroke);
color: var(--surface);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
.foot-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
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-inner {
position: static;
grid-template-columns: 1fr;
padding-block: var(--space-5);
}
.bench-stage {
height: clamp(300px, 48vh, 460px);
}
.two,
.mtf-grid,
.contact-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.section {
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;
}
}

View file

@ -0,0 +1,57 @@
/* ==========================================================================
Lumina Optics 디자인 토큰
프리셋: refraction / WebGL instrument
컨셉 문장: 광학 시험대 유일한 색은 프리즘이 스스로 만드는 스펙트럼.
레퍼런스: 방향 앵커 dogfood-optics(광학업종의 데이터 노출). 파일의 수치는
audit-references.md 날조 판명 토큰은 브리프 재정의. UI는 무채색에
가깝게: 색은 캔버스 안에서만 정당하다(design.md R2 원리와 같은 ).
성능 예산: **상향 명시** three.module.js 165KB(gzip) 지연 import.
페이지의 제품이 WebGL 데모 자체이므로 데모 예산 상향 규칙 적용.
UI JS(슬라이더 바인딩) 인라인 <1KB.
하지 않은 : 스펙텔러 그라디언트 배경, 'unrivaled clarity' 카피,
3열 카드. 이유: 광학은 곡선으로 말한다.
========================================================================== */
:root {
/* 타입 — 최대(58px) ÷ 본문(16px) ≈ 3.6배 */
--step--1: 0.8125rem;
--step-0: 1rem;
--step-1: 1.1875rem;
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
--step-3: clamp(2.125rem, 1.4rem + 3.4vw, 3.625rem);
--leading-tight: 1.24;
--leading-normal: 1.72;
--font-body: "IBM Plex Sans", -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* 색 — 시험실 무채색. 살짝 청록 기운의 액센트는 '레이저 라인' 하나뿐. */
--surface: #101214;
--surface-raised: #15181b;
--ink: #e8ecee;
--ink-muted: #9ba4a9;
--ink-faint: #6b7479;
--line: #23272b;
--line-strong: #394046;
--accent: #bfe8ee; /* 레이저 라인(밝게 — 텍스트용) */
--accent-stroke: #7fb9c4;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
--radius: 2px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 420ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -0,0 +1,333 @@
<!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="#F4F4F0" />
<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/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="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
/>
<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='%23F4F4F0'/%3E%3Cpath d='M16 7a7 7 0 0 1 7 7h-7z' fill='%237A3E1D'/%3E%3Cpath d='M16 9a7 7 0 0 0-7 7h7z' fill='%23181B18'/%3E%3Ccircle cx='16' cy='16' r='9' fill='none' stroke='%23181B18' stroke-width='1.5'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">
무월 <span class="wordmark-han">霧月</span>
</a>
<nav class="nav" aria-label="섹션">
<a href="#scale">척도</a>
<a href="#ledger">원장</a>
<a href="#recipe">레시피</a>
<a href="#plan">구독</a>
</nav>
</div>
</header>
<main id="main">
<!--
히어로 — R1(coffeecollective.dk): 가격을 첫 화면에서 숨기지 않는다.
문구만으로 시작하는 대신 이번 주 로스 사진 한 장과 원두 둘(값 포함)을 같이 둔다.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<p class="hero-label reveal">싱글오리진 로스터리 · 서울 서촌</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
취향은 형용사가 아니라<br />측정된 좌표입니다
</h1>
<p class="lead reveal" style="--delay: 120ms">
과일 향이 난다는 말로는 추출 온도를 정할 수 없습니다. 무월은 원두마다
산미와 단맛, 바디를 같은 척도로 측정해 적고, 생두를 얼마에 들였는지까지
공개합니다. 마셔보기 전에 판단할 수 있어야 원두 고르는 즐거움이 시작됩니다.
</p>
<div class="hero-actions reveal" style="--delay: 180ms">
<a class="btn" href="#plan">격주 구독 시작하기</a>
<p class="hero-note">격주 화요일 로스팅 출고 · 언제든 해지</p>
</div>
</div>
<figure class="hero-photo reveal" style="--delay: 100ms">
<img
src="./assets/beans-hero.webp"
alt="따뜻한 회색 리넨 위 질도기 그릇에 담긴 갓 볶은 원두와 놋쇠 스쿱"
width="900"
height="1200"
loading="eager"
fetchpriority="high"
/>
<figcaption>이번 주 첫 로스 · 8월 19일</figcaption>
</figure>
<!-- 이번 주 원두 — 카드가 아니라 원장의 첫 두 줄 -->
<dl class="hero-beans reveal" style="--delay: 240ms">
<div class="bean-row">
<div>
<dt>에티오피아 구지 우라가</dt>
<dd class="bean-origin">워시드 · 해발 2,150m · 커핑 88.75</dd>
</div>
<dd class="bean-price"><span>100g</span> 8,500원</dd>
</div>
<div class="bean-row">
<div>
<dt>콜롬비아 엘 파라이소</dt>
<dd class="bean-origin">혐기발효 · 해발 1,930m · 커핑 91.20</dd>
</div>
<dd class="bean-price"><span>100g</span> 11,000원</dd>
</div>
</dl>
</div>
</section>
<!--
척도 — 향미 좌표계. 히어로의 계기판 노릇을 내려놓고
본문의 척도로 옮긴다. 눈금과 점만 남긴다.
-->
<section id="scale" class="section" aria-labelledby="scale-h">
<div class="wrap scale-grid">
<div class="scale-text">
<h2 id="scale-h">같은 척도로 잰<br />향미 좌표</h2>
<p>
같은 밝기로 볶아도 생두가 머금은 수분과 밀도에 따라 열은 다르게
들어갑니다. 그래서 무월은 색이 아니라 1차 팝핑 후 감률과 커핑 점수로
로트를 기록합니다.
</p>
<p class="muted">
좌표는 로트마다 다시 측정해 옮겨 적습니다. 지난 로트의 좌표와 지금이
같다고 말하지 않습니다.
</p>
<ul class="scale-legend" aria-label="좌표 범례">
<li><span class="key" aria-hidden="true"></span>우라가 G1 워시드</li>
<li><span class="key key-b" aria-hidden="true"></span>엘 파라이소 혐기발효</li>
<li><span class="key key-c" aria-hidden="true"></span>엘 소코로 마라카투라</li>
</ul>
</div>
<figure class="scale-plot reveal">
<svg
viewBox="0 0 520 340"
role="img"
aria-label="산미-바디 2차원 좌표. 우라가는 산미 높음, 엘 파라이소는 단맛 높음, 엘 소코로는 바디 높음."
>
<!-- 눈금 격자 -->
<g class="grid">
<line x1="56" y1="40" x2="56" y2="280" />
<line x1="56" y1="280" x2="500" y2="280" />
<line x1="56" y1="160" x2="500" y2="160" />
<line x1="278" y1="40" x2="278" y2="280" />
<line x1="167" y1="40" x2="167" y2="280" stroke-dasharray="2 6" />
<line x1="389" y1="40" x2="389" y2="280" stroke-dasharray="2 6" />
<line x1="56" y1="100" x2="500" y2="100" stroke-dasharray="2 6" />
<line x1="56" y1="220" x2="500" y2="220" stroke-dasharray="2 6" />
</g>
<!-- 축 이름 -->
<g class="axis-labels">
<text x="56" y="302">산미 →</text>
<text x="500" y="302" text-anchor="end">바디 →</text>
<text x="46" y="46" text-anchor="end">10.0</text>
<text x="46" y="286" text-anchor="end">0</text>
</g>
<!-- 로트 점 -->
<g class="points">
<circle cx="140" cy="62" r="5" />
<text x="140" y="46" text-anchor="middle">우라가 8.8</text>
<circle cx="252" cy="92" r="5" class="pt-b" />
<text x="252" y="76" text-anchor="middle">엘 파라이소 9.0</text>
<circle cx="430" cy="196" r="5" class="pt-c" />
<text x="430" y="180" text-anchor="middle">엘 소코로 8.6</text>
</g>
</svg>
<figcaption>8월 3주차 로트 실측 · 좌표는 주마다 다시 놓입니다</figcaption>
</figure>
</div>
</section>
<!--
원장 — 이 페이지의 신뢰 장치 (R1: 거래 내역 공개가 이 업종의 스펙).
밴드를 살짝 눌러 표가 페이지의 중심임을 알린다.
-->
<section id="ledger" class="section band" aria-labelledby="ledger-h">
<div class="wrap">
<div class="ledger-head">
<h2 id="ledger-h">생두 매입 원장</h2>
<p class="muted">
농장에 돌아가는 돈이 얼마인지 보여주는 일이 스페셜티의 조건이라
생각합니다. 1kg당 매입가와 가공 방식까지 로트 단위로 남깁니다.
</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">가공</th>
<th scope="col" class="num">매입가 (FOB/kg)</th>
<th scope="col" class="num">커핑</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">에티오피아 구지 우라가</th>
<td>하르소 콜루 세척장</td>
<td>워시드 · 72시간 발효</td>
<td class="num">$14.20</td>
<td class="num">88.75</td>
</tr>
<tr>
<th scope="row">콜롬비아 엘 파라이소</th>
<td>디에고 사무엘 베르무데스</td>
<td>혐기발효 · 열충격 2회</td>
<td class="num">$26.50</td>
<td class="num">91.20</td>
</tr>
<tr>
<th scope="row">과테말라 엘 소코로</th>
<td>후안 디에고 데 라 세르다</td>
<td>전량 워시드</td>
<td class="num">$16.80</td>
<td class="num">89.00</td>
</tr>
</tbody>
</table>
</div>
<p class="ledger-note muted">
운임·통관·관세는 포함하지 않은 현지 출하가이며, 환율은 매입일 기준입니다.
</p>
</div>
</section>
<!--
레시피 — 사진과 사양. 사양은 목록의 주인공이 아니라 사진의 각주다.
-->
<section id="recipe" class="section" aria-labelledby="recipe-h">
<div class="wrap recipe-grid">
<figure class="recipe-photo reveal">
<img
src="./assets/pourover.webp"
alt="고핫물이 필터 백에 담긴 도자기 드리퍼로 얇은 물줄기로 들어가는 중"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>서촌 매장 · 오후 첫 잔</figcaption>
</figure>
<div class="recipe-text">
<h2 id="recipe-h">원두 18g, 물 288g</h2>
<p>
구독 패키지에는 원두 밀도에 맞춘 분쇄도와 주수 간격을 적은 카드 한 장이
들어갑니다. 드립의 변주는 그 다음 문제입니다.
</p>
<dl class="spec">
<div>
<dt>물 온도</dt>
<dd>93°C</dd>
</div>
<div>
<dt>분쇄도</dt>
<dd>코만단테 24딸</dd>
</div>
<div>
<dt>뜸 들이기</dt>
<dd>40초 · 물 40g</dd>
</div>
<div>
<dt>주수</dt>
<dd>3회 · 116g씩</dd>
</div>
</dl>
<p class="muted">
추출 시간은 2분 30초에서 3분 사이가 기준입니다. 그보다 빨리 흘러내리면
곱게 갈고, 늦으면 굵게 갑니다.
</p>
</div>
</div>
</section>
<!--
구독 — 회색조에서 가장 어두운 블록. 이 페이지의 유일한 강한 면.
(1단계 회색조 위계 계획의 실행: CTA가 1위 덩어리)
-->
<section id="plan" class="section plan" aria-labelledby="plan-h">
<div class="wrap plan-grid">
<div class="plan-text">
<h2 id="plan-h">격주, 서로 다른 좌표의<br />원두 두 종</h2>
<p>
2주마다 화요일 아침, 산미형 한 종과 밸런스형 한 종(각 100g)을 볶어
보냅니다. 원두는 시음 후 남는 양이 없어 다음 주에 같은 로트를 못 드릴 수
있습니다.
</p>
<ul class="plan-list">
<li>원두별 추출 카드 동봉 — 분쇄도·온도·주수</li>
<li>아로마 밸브 틴케이스 첫 회 증정</li>
<li>출장·휴가 주는 앱에서 건너뛰기</li>
</ul>
</div>
<div class="plan-box">
<p class="plan-price">
<span>28,000원</span><span class="plan-unit">회당 · 배송비 포함</span>
</p>
<a class="btn btn-inverse" href="#plan">격주 구독 시작하기</a>
<p class="plan-note">200g(100g×2) · 첫 회는 다음 화요일에 출고됩니다</p>
</div>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>무월 로스터리 · 서울 종로구 옥인길 18</p>
<p class="muted">화요일 로스팅 · 목요일부터 토요일까지 문을 엽니다</p>
<p class="muted">roastery@muwol.kr</p>
</div>
</footer>
<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,560 @@
@import url("./tokens.css");
/* ==========================================================================
무월 quiet-commerce. 종이 원장의 문법.
레이아웃 재질 모션 순서로 읽힌다.
========================================================================== */
*,
*::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;
}
:where(h1, h2) {
font-family: var(--font-display);
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.01em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
:where(p, li, dd, dt, th, td, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: 1.75;
text-wrap: pretty;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
: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;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 88%, transparent);
backdrop-filter: blur(8px);
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;
letter-spacing: 0.02em;
text-decoration: none;
}
.wordmark-han {
color: var(--ink-muted);
font-size: var(--step-0);
margin-left: var(--space-1);
font-weight: 400;
}
.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-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-label {
font-size: var(--step--1);
color: var(--ink-muted);
letter-spacing: 0.04em;
margin-bottom: var(--space-4);
}
.lead {
max-width: 34em;
}
.hero-text .lead {
margin-top: var(--space-4);
}
.hero-actions {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.hero-note {
font-size: var(--step--1);
color: var(--ink-faint);
}
.btn {
display: inline-block;
background: var(--accent);
color: var(--accent-ink);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent-hover);
}
.btn-inverse {
background: var(--surface);
color: var(--ink);
}
.btn-inverse:hover {
background: var(--surface-bright);
}
.hero-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.hero-photo figcaption,
.recipe-photo figcaption,
.scale-plot figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* 이번 주 원두 — 원장의 첫 두 줄 (카드 아님) */
.hero-beans {
margin-top: var(--space-6);
border-top: 1px solid var(--line);
}
.bean-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
padding-block: var(--space-3);
border-bottom: 1px solid var(--line);
}
.bean-row dt {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--step-1);
}
.bean-origin {
font-size: var(--step--1);
color: var(--ink-muted);
margin-top: 2px;
}
.bean-price {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bean-price span {
color: var(--ink-faint);
margin-right: var(--space-2);
font-size: var(--step--1);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 척도(좌표 플롯) ──────────────────────────────────────────────────── */
.scale-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.scale-text p + p {
margin-top: var(--space-3);
}
.scale-legend {
list-style: none;
padding: 0;
margin-top: var(--space-4);
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.key {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ink);
margin-right: var(--space-1);
vertical-align: baseline;
}
.key-b {
background: var(--accent);
}
.key-c {
background: var(--ink-faint);
}
.scale-plot svg {
display: block;
width: 100%;
height: auto;
}
.scale-plot .grid line {
stroke: var(--line);
stroke-width: 1;
}
.scale-plot .grid line:first-child,
.scale-plot .grid line:nth-child(2) {
stroke: var(--ink-faint);
stroke-width: 1.25;
}
.axis-labels text {
font-family: var(--font-body);
font-size: 13px;
fill: var(--ink-faint);
}
.points circle {
fill: var(--ink);
}
.points .pt-b {
fill: var(--accent);
}
.points .pt-c {
fill: var(--ink-faint);
}
.points text {
font-family: var(--font-body);
font-size: 13px;
fill: var(--ink-muted);
}
/* ── 원장(밴드) ───────────────────────────────────────────────────────── */
.ledger-head {
max-width: 38em;
margin-bottom: var(--space-5);
}
.ledger-head p {
margin-top: var(--space-3);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--step-0);
}
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 {
text-align: left;
font-weight: 500;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.ledger-note {
margin-top: var(--space-3);
font-size: var(--step--1);
}
/* ── 레시피 ───────────────────────────────────────────────────────────── */
.recipe-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.recipe-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.recipe-text p + p {
margin-top: var(--space-3);
}
.spec {
margin-top: var(--space-4);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: var(--space-3) var(--space-5);
}
.spec dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.spec dd {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: var(--step-1);
margin-top: var(--space-1);
}
/* ── 구독(반전 블록) ──────────────────────────────────────────────────── */
.plan {
background: var(--surface-inverse);
color: var(--ink-inverse);
}
.plan-note {
color: var(--ink-inverse-muted);
}
.plan-grid {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.plan-text p {
margin-top: var(--space-4);
max-width: 34em;
}
.plan-list {
margin-top: var(--space-4);
padding: 0;
list-style: none;
}
.plan-list li {
padding-left: 1.25em;
position: relative;
color: var(--ink-inverse-muted);
}
.plan-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.72em;
width: 6px;
height: 1.5px;
background: var(--ink-inverse-muted);
}
.plan-list li + li {
margin-top: var(--space-2);
}
.plan-box {
border: 1px solid var(--line-inverse);
border-radius: var(--radius);
padding: var(--space-5);
display: grid;
gap: var(--space-4);
justify-items: start;
}
.plan-price span:first-child {
font-family: var(--font-display);
font-size: var(--step-2);
font-weight: 600;
}
.plan-unit {
display: block;
font-size: var(--step--1);
margin-top: var(--space-1);
color: var(--ink-inverse-muted);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-6);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-5);
justify-content: space-between;
}
/* ── 등장 모션 — IO 1회, 유지 ─────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
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,
.scale-grid,
.recipe-grid,
.plan-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero-photo,
.recipe-photo {
max-width: 26rem;
}
.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;
}
.bean-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.plan-box {
padding: var(--space-4);
}
}

View file

@ -0,0 +1,67 @@
/* ==========================================================================
무월 (霧月 · MUWOL) 디자인 토큰
프리셋: quiet-commerce
컨셉 문장: 종이 원장처럼 정직한 구독 화면에는 사양과 실측만.
레퍼런스: R1 coffeecollective.dk(구조·가격 노출·거래 투명성)
R2 fitzcarraldoeditions.com(세리프 본문 정돈, 1강조색)
R3 scroll-driven view() 채택 (IO 등장만 . 정적 HTML + 폴백 우선)
하지 않은 : 세로 GNB 레일, 영어 대문자 eyebrow, 전면 그레인 오버레이,
FIG.00 캡션 문법, 카드 그림자. 이유: 계기판 흉내가 상품을 가림.
========================================================================== */
:root {
/* 타입 스케일 — 최대(64px) ÷ 본문(16px) = 4.0배 */
--step--1: 0.8125rem; /* 13px — 캡션·주석 */
--step-0: 1rem; /* 16px — 본문 */
--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, 4rem); /* 64px — 디스플레이 */
--leading-tight: 1.28;
--leading-normal: 1.75;
--measure: 34ch;
--font-display: "MaruBuri", serif;
--font-serif: "MaruBuri", serif;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* 색 — hue 2개: 안개 종이 + 볶은 원두 앰버. 강조색은 앰버 하나. */
--surface: #f4f4f0;
--surface-raised: #eaeae4;
--surface-inverse: #181b18;
--ink: #181b18;
--ink-muted: #585c56;
--ink-faint: #8c908a;
--ink-inverse: #f4f4f0;
--ink-inverse-muted: #a8aca4;
--line: #d5d7d0;
--line-inverse: #3a3e39;
--accent: #7a3e1d;
--accent-hover: #5c2d13;
--surface-bright: #fffffa; /* 반전 블록 안 밝은 hover 단계 */
--accent-ink: #f4f4f0;
/* 대비(실측 예정 5단계에서 재검): ink/surface 15.6, muted/surface 6.2,
accent/surface 7.5, inverse 조합은 본문 AA 이상으로 설계 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
--radius: 2px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 180ms;
--dur-normal: 420ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,322 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>궤도 우주공학 · 75kN 메탄 엔진 사양서</title>
<meta
name="description"
content="75kN급 메탄-액체산소 재생냉각 엔진의 단면도와 지상 연소 시험 기록. 사양은 전부 지상 시험 실측값이다."
/>
<meta property="og:title" content="궤도 우주공학 · 75kN 메탄 엔진" />
<meta property="og:description" content="재생냉각 엔진의 단면도와 연소 시험 기록." />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#081018" />
<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=JetBrains+Mono:wght@400;500&display=swap"
/>
<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='%23081018'/%3E%3Cpath d='M16 4l6 7v17h-12V11z' fill='none' stroke='%23C57A45' stroke-width='1.6'/%3E%3Cpath d='M13 11h6M12 18h8' stroke='%23C57A45'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">궤도 우주공학 <span class="wordmark-en">ORBIT</span></a>
<nav class="nav" aria-label="섹션">
<a href="#engine">엔진</a>
<a href="#spec">사양</a>
<a href="#fires">시험 기록</a>
<a href="#contact">문의</a>
</nav>
</div>
</header>
<main id="main">
<!--
히어로 — 문구 대신 도면. 엔진 단면도가 첫 화면의 주인공이다.
이 도면은 장식이 아니라 사양서의 1페이지다: 라벨은 실제 부품 이름.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<p class="hero-kicker reveal">75kN 메탄-액체산소 · 재생냉각 · 2024</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
도면은 말하지 않는다.<br />측정만 한다
</h1>
<p class="lead reveal" style="--delay: 120ms">
이 페이지의 모든 수치는 지상 연소 시험에서 잰 값입니다. 홍보 문구에
쓸 수 없는 숫자는 사양서에도 넣지 않습니다. 추력, 비추력, 연소실
압력 — 어디서, 몇 초 동안 재었는지가 숫자와 함께 갑니다.
</p>
<a class="btn reveal" style="--delay: 180ms" href="#spec">사양 전문 보기</a>
</div>
<figure class="blueprint reveal" style="--delay: 100ms">
<svg
viewBox="0 0 460 560"
role="img"
aria-label="엔진 단면도: 상부 인젝터, 연소실, 중앙 스로트, 확장 노즐과 벽면의 재생냉각 유로"
>
<defs>
<g id="half">
<!-- 외벽: 연소실 → 스로트 → 노즐 -->
<path
class="bp-wall"
d="M 230 70
L 168 70
L 168 210
C 168 250 194 262 194 286
C 194 310 118 350 112 470
L 108 500"
/>
<!-- 내벽 -->
<path
class="bp-wall inner"
d="M 230 92
L 184 92
L 184 214
C 184 246 206 258 206 284
C 206 306 136 348 130 468
L 127 494"
/>
<!-- 재생냉각 유로 (내벽을 따라가는 가는 선 2줄) -->
<path
class="bp-cool"
d="M 176 100 L 176 212 C 176 248 198 262 198 286 C 198 308 126 350 121 468 L 118 492"
/>
<path
class="bp-cool"
d="M 170 100 L 170 210 C 170 244 192 260 192 288 C 192 312 120 352 114 470 L 111 496"
/>
<!-- 인젝터 매니폴드 -->
<rect class="bp-block" x="168" y="46" width="50" height="18" />
<path class="bp-wall thin" d="M 168 64 L 218 64" />
</g>
</defs>
<use href="#half" />
<use href="#half" transform="translate(460 0) scale(-1 1)" />
<!-- 중심선 -->
<path class="bp-axis" d="M 230 30 V 540" />
<!-- 유동 화살표 -->
<g class="bp-flow">
<path d="M 230 120 V 220" />
<path d="M 230 220 l -5 10 M 230 220 l 5 10" />
</g>
<!-- 라벨 -->
<g class="bp-label">
<path class="bp-lead" d="M 218 55 H 150" />
<text x="144" y="59" text-anchor="end">인젝터</text>
<path class="bp-lead" d="M 168 160 H 96" />
<text x="90" y="164" text-anchor="end">연소실</text>
<path class="bp-lead" d="M 200 275 H 78" />
<text x="72" y="279" text-anchor="end">스로트</text>
<path class="bp-lead" d="M 128 400 H 60" />
<text x="54" y="404" text-anchor="end">노즐</text>
<path class="bp-lead" d="M 186 180 H 120" />
<text x="114" y="196" text-anchor="end"></text>
<text x="242" y="150">재생냉각</text>
<text x="242" y="166">유로 (CH₄)</text>
</g>
<g class="bp-dim">
<path d="M 356 92 H 372 M 364 92 V 214" />
<text x="380" y="157"></text>
</g>
<g class="bp-dim">
<path d="M 60 500 H 96 M 78 500 V 520" />
<text x="60" y="542">Ø 460 — 노즐 출구</text>
</g>
</svg>
<figcaption>ORBIT-75 단면 · 재생냉각 벽면은 그림 2겹으로 단순화</figcaption>
</figure>
</div>
</section>
<!--
사양 — 표가 주인공. 전 항목 측정 조건이 붙는다(게이트 11의 해법).
-->
<section id="spec" class="section band" aria-labelledby="spec-h">
<div class="wrap">
<h2 id="spec-h" class="reveal">사양</h2>
<div class="table-wrap reveal" style="--delay: 80ms">
<table>
<caption class="sr-only">ORBIT-75 엔진 사양 — 항목, 값, 측정 조건</caption>
<thead>
<tr>
<th scope="col">항목</th>
<th scope="col" class="num"></th>
<th scope="col">측정 조건</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">추력 (해수면)</th>
<td class="num">75.4 kN</td>
<td>지상 시험 스탠드 · 정상 상태 30초 평균</td>
</tr>
<tr>
<th scope="row">비추력 Isp (진공)</th>
<td class="num">325.8 s</td>
<td>고도 모사 노즐 · 추진제 혼합비 3.4</td>
</tr>
<tr>
<th scope="row">연소실 압력</th>
<td class="num">12.5 MPa</td>
<td>피에조 계측 · 샘플링 1kHz</td>
</tr>
<tr>
<th scope="row">사이클</th>
<td class="num">다단연소</td>
<td>연료리치 프리버너 1기</td>
</tr>
<tr>
<th scope="row">냉각</th>
<td class="num">재생냉각</td>
<td>구리합금 채널 180개 · CH₄ 유량 92%</td>
</tr>
<tr>
<th scope="row">시동 신호→연소</th>
<td class="num">2.8 s</td>
<td>TEA-TEB 점화 · 12회 평균</td>
</tr>
</tbody>
</table>
</div>
<p class="measure-note reveal" style="--delay: 140ms">
모든 값은 나주 지상 시험장, 2026년 상반기 12회 시험의 평균입니다.
단일 최고값을 사양으로 적지 않습니다.
</p>
</div>
</section>
<!--
시험 기록 — 아카이브. 성공만 남기지 않는다: 3번의 보류가 있다.
-->
<section id="fires" class="section" aria-labelledby="fires-h">
<div class="wrap fires-grid">
<div class="fires-text">
<h2 id="fires-h">지상 연소 시험 기록</h2>
<p>
엔진은 도면으로 끝나지 않습니다. 우리는 보류된 시험도 지운 적이
없습니다 — 기록에서 실패가 사라지면 사양서는 광고가 됩니다.
</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">SF-31</th><td>2026-07-18</td><td class="num">62 s</td><td>완료 · 노즐 온도 상한 내</td></tr>
<tr><th scope="row">SF-30</th><td>2026-06-27</td><td class="num">31 s</td><td>보류 · 프리버너 시동 지연</td></tr>
<tr><th scope="row">SF-29</th><td>2026-06-02</td><td class="num">60 s</td><td>완료 · 추력 편차 0.4%</td></tr>
<tr><th scope="row">SF-28</th><td>2026-05-11</td><td class="num">12 s</td><td>보류 · 냉각유로 압력 강하</td></tr>
<tr><th scope="row">SF-27</th><td>2026-04-20</td><td class="num">58 s</td><td>완료</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!--
시험 사진 — 풀블리드 판. 마하 다이아몬드가 보일 때만 쓴다.
-->
<section class="section plate" aria-label="지상 연소 시험 사진">
<figure class="plate-fig reveal">
<img
src="./assets/static-fire.webp"
alt="야간 지상 연소 시험 — 구리 노즐이 열로 붉게 빛나고 배기 기둥에 마하 다이아몬드가 서 있다"
width="1440"
height="810"
loading="lazy"
/>
<figcaption class="wrap">SF-29 · 60초 연소 · 나주 시험장 3번 스탠드</figcaption>
</figure>
</section>
<!--
재생냉각 — 구리 유로 접사 + 설명.
-->
<section class="section cooling" aria-labelledby="cool-h">
<div class="wrap two">
<figure class="two-photo reveal">
<img
src="./assets/channel-macro.webp"
alt="구리합금 블록의 단면에 밀링된 가느다란 재생냉각 유로들"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>연소실 유로 접사 · 밀링 후 청소 전</figcaption>
</figure>
<div class="two-text">
<h2 id="cool-h">추진제가 벽을<br />먼저 식힙니다</h2>
<p>
연소실 벽은 3,200°C의 가스를 견디는 게 아닙니다 — 견디게 두지
않습니다. 메탄 연료가 점화 전에 벽 속 180개 유로를 지나가며 실온에서
320°C까지 데워지고, 그 사이 벽은 900°C 아래로 머뭅니다. 냉각에 쓴
추진제는 그대로 연소실로 들어가니 버리는 열이 없습니다.
</p>
<p class="muted">
유로 하나의 폭은 0.9mm입니다. 이 숫자가 이 엔진의 수명을 결정합니다 —
도면에서 가장 작은 치수가 가장 중요한 치수입니다.
</p>
</div>
</div>
</section>
<!-- 문의 -->
<section id="contact" class="section band contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">문의</h2>
<p>
발사체 통합·위성 투입 협의, 그리고 엔진 시험 데이터 전문은 아래로
보내주세요. 기술 자료 요청에는 소속과 용도를 적어주세요.
</p>
<a class="btn" href="mailto:engine@orbit.kr">engine@orbit.kr</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>궤도 우주공학 · 나주 시험장 / 서울 사무실</p>
<p class="mono foot-meta">ORBIT AEROSPACE · EST. 2020</p>
</div>
</footer>
<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,455 @@
@import url("./tokens.css");
/* ==========================================================================
궤도 우주공학 technical-schematic. 사양서 1페이지.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@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;
}
:where(h1, h2) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
:where(p, th, td, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.lead {
color: var(--ink-muted);
max-width: 34em;
}
.muted {
color: var(--ink-muted);
}
.mono {
font-family: var(--font-mono);
}
a {
color: inherit;
}
: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;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
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-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.wordmark-en {
color: var(--ink-faint);
font-family: var(--font-mono);
font-size: var(--step--1);
font-weight: 400;
margin-left: var(--space-2);
}
.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);
/* 제도용 모눈 — 히어로에만, 4% 밝기 */
background-image:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 48px 48px;
border-bottom: 1px solid var(--line);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.hero-kicker {
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--accent);
letter-spacing: 0.08em;
margin-bottom: var(--space-4);
}
.hero .lead {
margin-top: var(--space-4);
}
.hero .btn {
margin-top: var(--space-5);
}
.btn {
display: inline-block;
border: 1px solid var(--accent);
color: var(--accent);
text-decoration: none;
font-weight: 500;
padding: 0.7rem 1.4rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent);
color: var(--surface);
}
/* 단면도 */
.blueprint {
background: color-mix(in srgb, var(--surface) 72%, transparent);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-4) var(--space-3) var(--space-3);
}
.blueprint svg {
display: block;
width: 100%;
height: auto;
max-height: 78vh;
}
.blueprint figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
text-align: center;
}
.bp-wall {
fill: none;
stroke: var(--ink);
stroke-width: 2;
}
.bp-wall.inner {
stroke-width: 1.25;
}
.bp-wall.thin {
stroke-width: 1;
}
.bp-cool {
fill: none;
stroke: var(--accent-stroke);
stroke-width: 0.9;
}
.bp-block {
fill: none;
stroke: var(--ink-muted);
stroke-width: 1.2;
}
.bp-axis {
stroke: var(--ink-faint);
stroke-width: 0.75;
stroke-dasharray: 10 4 2 4;
}
.bp-flow path {
fill: none;
stroke: var(--accent);
stroke-width: 1.2;
}
.bp-label text {
font-family: var(--font-body);
font-size: 13px;
fill: var(--ink-muted);
}
.bp-lead {
stroke: var(--ink-faint);
stroke-width: 0.8;
}
.bp-dim path {
fill: none;
stroke: var(--ink-faint);
stroke-width: 0.8;
}
.bp-dim text {
font-family: var(--font-mono);
font-size: 11px;
fill: var(--ink-faint);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 표 ───────────────────────────────────────────────────────────────── */
.table-wrap {
margin-top: var(--space-4);
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-faint);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--ink-faint);
}
tbody th {
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
tbody td:not(.num) {
color: var(--ink-muted);
font-size: var(--step--1);
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.measure-note {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 시험 기록 2단 ────────────────────────────────────────────────────── */
.fires-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.fires-text p {
margin-top: var(--space-3);
color: var(--ink-muted);
max-width: 26em;
}
.fires-grid .table-wrap {
margin-top: 0;
}
/* ── 시험 사진 판 ─────────────────────────────────────────────────────── */
.plate-fig img {
display: block;
width: 100%;
height: auto;
}
.plate-fig figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 재생냉각 2단 ─────────────────────────────────────────────────────── */
.two {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
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-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
.two-text p + p {
margin-top: var(--space-4);
}
/* ── 문의 ─────────────────────────────────────────────────────────────── */
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-4) var(--space-7);
align-items: center;
}
.contact-grid p {
color: var(--ink-muted);
max-width: 34em;
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
.foot-meta {
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
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,
.fires-grid,
.two,
.contact-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero,
.section {
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;
}
}

View file

@ -0,0 +1,55 @@
/* ==========================================================================
궤도 우주공학 디자인 토큰
프리셋: technical-schematic
컨셉 문장: 사양서 1페이지 도면은 말하지 않고 측정만 한다.
레퍼런스: 방향 앵커 dogfood-aerospace(우주업종 기술 노출). 파일의 6축
수치는 audit-references.md 에서 날조 판명 토큰은 브리프에서
재정의. 실측 가능했던 사실만 계승: rocketlabcorp의 '엔진을 상품으로
쓰는' 구성. 색은 야간 시험장(짙은 남흑 + 구리 노즐 열색)에서 왔다.
하지 않은 : 지표 배너(고객수·성공률), 로켓 3D 렌더, 발사 카운트다운,
대문자 타이틀. 이유: 사양서의 신뢰는 측정 조건에서 나온다.
========================================================================== */
:root {
/* 타입 — 최대(54px) ÷ 본문(16px) ≈ 3.4배 */
--step--1: 0.8125rem;
--step-0: 1rem;
--step-1: 1.1875rem;
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
--step-3: clamp(2rem, 1.4rem + 3vw, 3.375rem);
--leading-tight: 1.28;
--leading-normal: 1.78;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* 색 — 야간 시험장: 남흑 배경, 구리 강조(텍스트·선), 청사진 흰선 */
--surface: #081018;
--surface-raised: #0d1520;
--ink: #e7ecf1;
--ink-muted: #97a3af;
--ink-faint: #6c7885;
--line: #1f2937;
--line-strong: #33415280;
--accent: #d08a5a; /* 구리 — 열색. 텍스트용 밝기 */
--accent-stroke: #a45f33; /* 도면 선용 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
--radius: 2px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 420ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,257 @@
<!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="#0E0D0B" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" 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="./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='%230E0D0B'/%3E%3Cpath d='M4 16h4l2-7 3 14 3-10 2 5h10' fill='none' stroke='%23E8A33D' stroke-width='1.8'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">오버톤 사운드랩</a>
<nav class="nav" aria-label="섹션">
<a href="#works">작업</a>
<a href="#manifesto">선언</a>
<a href="#contact">연락</a>
</nav>
</div>
</header>
<main id="main">
<!--
히어로 — anti-grid: 글자가 파형 위로 삐져나온다.
캔버스는 절차적 유사-FFT(정현파 합). 재생 버튼 없이 스스로 흐르되
화면 밖에서는 그리지 않는다.
-->
<section class="hero" aria-labelledby="hero-h">
<canvas id="wave" class="wave" aria-hidden="true"></canvas>
<div class="wrap hero-inner">
<p class="hero-kicker reveal">
<span>공간 음향 · DSP · 서울</span>
<span class="mono">48kHz / 2ch</span>
</p>
<h1 id="hero-h" class="reveal" style="--delay: 80ms">
침묵과 폭발의<br /><em>주파수</em>
</h1>
<p class="hero-statement reveal" style="--delay: 160ms">
오버톤은 소리를 만들지 않고, 소리가 공간에서 어떻게 행동하는지를
연구합니다. 방금 지나간 곡선은 이 연구소를 지나가는 소리의 자취입니다.
</p>
</div>
</section>
<!--
작업 아카이브 — 스태거 2열. 카드가 아니라 '벽에 걸린 목록'.
같은 간격을 주지 않는다: 짝수 작품은 오른쪽으로, 홀수는 왼쪽으로 쏠린다.
-->
<section id="works" class="section works" aria-labelledby="works-h">
<div class="wrap">
<h2 id="works-h" class="works-h reveal">작업</h2>
<ol class="archive">
<li class="work reveal">
<p class="work-year mono">2026</p>
<h3>공명하는 방 — DDP 설치</h3>
<p>
종이 콘 스피커 48개가 천장에서 내려와 방 안의 소리를 0.8초 늦게
되돌려주는 설치. 관람객의 발소리가 악기가 된다.
</p>
<p class="work-meta mono">48ch · 공간지연 · 상설</p>
</li>
<li class="work work-b reveal" style="--delay: 60ms">
<p class="work-year mono">2025</p>
<h3>무(無)의 스펙트럼</h3>
<p>
아무 소리도 없는 방의 바닥 소음(62dB)을 증폭해 들려주는
퍼포먼스. 침묵에는 평균 진폭이 있다.
</p>
<p class="work-meta mono">live · 62dB floor</p>
</li>
<li class="work reveal" style="--delay: 40ms">
<p class="work-year mono">2024</p>
<h3> 파도의 인버전 — 제주</h3>
<p>
바람과 파도의 실측 데이터를 반대 위상으로 합성해 바다가 '삼키는'
소리를 만드는 해안 설치.
</p>
<p class="work-meta mono">field data · 6h loop</p>
</li>
<li class="work work-b reveal" style="--delay: 100ms">
<p class="work-year mono">2023</p>
<h3>유리 진동자</h3>
<p>
잔 12개에 각기 다른 공진 주파수를 실어 연주하는 설비. 연주자는
물의 높이를 조율한다.
</p>
<p class="work-meta mono">glass · 220940Hz</p>
</li>
</ol>
</div>
</section>
<!--
선언 — anti-grid의 정점: 문장 하나가 페이지 폭을 다 쓴다.
압축 스케일의 반대편. 이 페이지에서 가장 큰 텍스트 블록.
-->
<section id="manifesto" class="section manifesto" aria-labelledby="manifesto-h">
<div class="wrap">
<h2 id="manifesto-h" class="sr-only">선언</h2>
<p class="manifesto-line reveal">
좋은 음향은 들리지 않는다.<br />
방이 좋아지는 것뿐이다.
</p>
<p class="manifesto-note reveal" style="--delay: 100ms">
우리는 음파를 시각화로 번역하지 않습니다. 시각화는 소리의 크기만
흉내 낼 뿐, 소리의 '방의 크기'를 옮기지 못합니다. 대신 우리는 방을
조율합니다 — 재생 목록이 아니라 잔향시간을 설계합니다.
</p>
</div>
</section>
<!-- 협업 마퀴 — 페이지에 하나뿐인 흐름 -->
<div class="marquee" aria-label="협업 단체">
<div class="marquee-track">
<span>국립현대미술관</span><span>DDP</span><span>서울시립교향악단</span><span>아르코예술극장</span><span>제주국제음악제</span><span>국립현대미술관</span><span>DDP</span><span>서울시립교향악단</span><span>아르코예술극장</span><span>제주국제음악제</span>
</div>
</div>
<!--
사진 — 설치 현장 한 컷. 다크룸의 앰버 빔.
-->
<section class="section photo" aria-label="설치 현장">
<figure class="wrap photo-fig reveal">
<img
src="./assets/installation.webp"
alt="어두운 전시실에 종이콘 스피커들이 늘어서 있고 앰버색 빛줄기가 한 줄 지나간다"
width="1440"
height="810"
loading="lazy"
/>
<figcaption class="mono">공명하는 방 · 설치 뷰 · 2026</figcaption>
</figure>
</section>
<!-- 연락 -->
<section id="contact" class="section contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">연락</h2>
<p>
설치·공연·연구 협업 문의는 소리의 조건(공간 크기, 재생 시간, 청중 수)과
함께 보내주세요.
</p>
<a class="contact-mail" href="mailto:lab@overtone.kr">lab@overtone.kr</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>오버톤 사운드랩 · 서울 마포</p>
<p class="mono foot-meta">© 20192026 · OVERTONE SOUND LAB</p>
</div>
</footer>
<script>
/*
절차적 유사-FFT — 정현파 5개를 더하고 위상을 흐르게 한다.
오디오 파일도 디코딩도 없다(연구조사 R3의 교훈: 전체 파일 디코드는
수 초 메인스레드를 막는다). 화면 밖·reduced-motion 에서는 정지 프레임.
*/
const canvas = document.getElementById("wave");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const ctx = canvas.getContext("2d");
let w, h, dpr, running = false;
function fit() {
dpr = Math.min(devicePixelRatio || 1, 1.5);
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function draw(t) {
ctx.clearRect(0, 0, w, h);
// 세 겹의 파형 — 시간차를 두고 겹친다
for (let layer = 0; layer < 3; layer++) {
ctx.beginPath();
const amp = (h * 0.16) * (1 - layer * 0.28);
const yMid = h * (0.52 + layer * 0.12);
const speed = 0.00042 * (1 + layer * 0.7);
for (let x = 0; x <= w; x += 3) {
const p = x / w;
const env = Math.exp(-Math.pow((p - 0.5) * 2.4, 2)); // 중앙 강조
const y =
yMid +
Math.sin(p * 21 + t * speed + layer * 2.1) * amp * env * Math.sin(p * Math.PI) +
Math.sin(p * 53 - t * speed * 1.7) * amp * 0.25 * env;
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.strokeStyle = layer === 0 ? "rgba(232,163,61,0.85)" : `rgba(232,163,61,${0.32 - layer * 0.1})`;
ctx.lineWidth = layer === 0 ? 1.4 : 1;
ctx.stroke();
}
}
function loop(t) {
if (!running) return;
draw(t);
requestAnimationFrame(loop);
}
fit();
addEventListener("resize", fit, { passive: true });
draw(0);
if (!reduced) {
const io = new IntersectionObserver((entries) => {
const on = entries[0].isIntersecting;
if (on && !running) {
running = true;
requestAnimationFrame(loop);
} else if (!on) {
running = false;
}
});
io.observe(canvas);
}
const io2 = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io2.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
</script>
</body>
</html>

View file

@ -0,0 +1,392 @@
@import url("./tokens.css");
/* ==========================================================================
오버톤 사운드랩 anti-grid. 격자는 일부러 비틀었다.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@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;
}
:where(h1, h2, h3) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
:where(p, li, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.mono {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
}
a {
color: inherit;
}
: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;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
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-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.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(--accent);
border-color: var(--accent);
}
/* ── 히어로 — 파형 위로 삐져나온 글자 ────────────────────────────────── */
.hero {
position: relative;
min-height: 88svh;
display: grid;
align-content: center;
overflow: clip;
}
.wave {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0.9;
}
.hero-inner {
position: relative;
z-index: 2;
/* anti-grid: 텍스트 블록을 왼쪽 15% 축에 두고 오른쪽은 파형에 내준다 */
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-left: max(var(--pad-inline), calc((100% - var(--container)) / 2));
margin-right: 0;
padding-block: var(--space-7);
}
.hero-kicker {
display: flex;
justify-content: space-between;
gap: var(--space-4);
max-width: 24rem;
font-size: var(--step--1);
color: var(--ink-muted);
letter-spacing: 0.1em;
}
.hero h1 {
margin-top: var(--space-4);
max-width: 12em;
}
.hero h1 em {
font-style: normal;
color: var(--accent);
}
.hero-statement {
margin-top: var(--space-4);
max-width: 26em;
color: var(--ink-muted);
/* 오른쪽으로 밀어낸다 — 정렬을 스스로 어긋나게 */
margin-left: clamp(0rem, 8vw, 7.5rem);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
/* ── 작업 아카이브 — 스태거 2열 ──────────────────────────────────────── */
.works-h {
font-size: var(--step--1);
font-weight: 500;
color: var(--ink-faint);
letter-spacing: 0.3em;
}
.archive {
list-style: none;
margin-top: var(--space-3);
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-6) var(--space-7);
}
.work {
border-top: 1px solid var(--line);
padding-top: var(--space-3);
max-width: 26rem;
}
/* 스태거: 짝수 작품은 아래로 밀고 오른쪽으로 당긴다 */
.work-b {
margin-top: var(--space-7);
justify-self: end;
}
.work-year {
color: var(--accent);
}
.work h3 {
font-size: var(--step-2);
margin-top: var(--space-2);
}
.work p:not(.mono) {
margin-top: var(--space-3);
color: var(--ink-muted);
}
.work-meta {
margin-top: var(--space-3);
}
/* ── 선언 ─────────────────────────────────────────────────────────────── */
.manifesto {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
.manifesto-line {
font-size: var(--step-manifesto);
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.02em;
word-break: keep-all;
}
.manifesto-note {
margin-top: var(--space-5);
max-width: 32em;
color: var(--ink-muted);
margin-left: clamp(0rem, 12vw, 11rem); /* 다시 어긋난 여백 */
}
/* ── 마퀴 — 하나뿐인 흐름 ────────────────────────────────────────────── */
.marquee {
overflow: clip;
border-bottom: 1px solid var(--line);
padding-block: var(--space-3);
}
.marquee-track {
display: flex;
gap: var(--space-8);
width: max-content;
animation: marquee 36s linear infinite;
}
.marquee-track span {
font-size: var(--step--1);
color: var(--ink-faint);
white-space: nowrap;
}
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee-track {
animation: none;
}
}
/* ── 사진 ─────────────────────────────────────────────────────────────── */
.photo-fig img {
display: block;
width: 100%;
height: auto;
}
.photo-fig figcaption {
margin-top: var(--space-2);
}
/* ── 연락 ─────────────────────────────────────────────────────────────── */
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-4) var(--space-7);
align-items: baseline;
}
.contact-grid h2 {
font-size: var(--step-1);
}
.contact-grid p {
color: var(--ink-muted);
max-width: 28em;
}
.contact-mail {
font-size: var(--step-2);
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid var(--accent-dim);
transition: border-color var(--dur-quick) var(--ease-soft);
}
.contact-mail:hover {
border-color: var(--accent);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-4);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
color: var(--ink-muted);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 14px;
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) {
.archive {
grid-template-columns: 1fr;
}
.work-b {
margin-top: 0;
justify-self: stretch;
}
.hero-statement,
.manifesto-note {
margin-left: 0;
}
.hero {
min-height: 0;
}
.contact-grid {
grid-template-columns: 1fr;
gap: var(--space-3);
}
.hero,
.section {
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;
}
}

View file

@ -0,0 +1,54 @@
/* ==========================================================================
오버톤 사운드랩 디자인 토큰
프리셋: anti-grid
컨셉 문장: 어두운 스튜디오 바닥에 흩뿌린 목록 격자는 일부러 비틀었다.
레퍼런스: R1 carstennicolai.de(실측: 1152px 컨테이너, 비대칭 15/20/85 ,
압축 타입 스케일 1.8:1, 아카이브 역연도, 형광 1색 앰버로 치환)
R2 obys.agency(스태거 2열, 라인 마스크 리빌, 마퀴 1회)
R3 절차적 유사-FFT 캔버스(오디오 디코드 회피)
하지 않은 : 글래스 도크, 3열 카드, 커서 추적 이펙트, 대문자 타이틀.
이유: 파열은 하나(스태거+파형) 족하다.
========================================================================== */
:root {
/* 타입 — R1의 압축 스케일. 최대(40px) ÷ 본문(16px) = 2.5배(선언 문장 제외) */
--step--1: 0.8125rem;
--step-0: 1rem;
--step-1: 1.125rem;
--step-2: 1.375rem;
--step-3: clamp(1.875rem, 1.3rem + 2.8vw, 2.5rem); /* 40px 히어로 */
--step-manifesto: clamp(1.875rem, 1.2rem + 4.4vw, 3.75rem); /* 선언 — 이 페이지의 가장 큰 목소리 */
--leading-tight: 1.25;
--leading-normal: 1.75;
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
--surface: #0e0d0b;
--surface-raised: #161411;
--ink: #ece7dd;
--ink-muted: #a09a8e;
--ink-faint: #6f695f;
--line: #27241f;
--accent: #e8a33d; /* 앰버 — 선과 텍스트로만 */
--accent-dim: rgba(232, 163, 61, 0.35);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
--radius: 0; /* 스튜디오 바닥에는 모서리가 없다 */
--container: 1152px; /* R1 실측값 */
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 460ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

View file

@ -0,0 +1,317 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pulsegate · Edge Latency &amp; Synthetic Probe Engine</title>
<meta
name="description"
content="Synthetic HTTP/gRPC assertions every ten seconds from bare-metal edge POPs. No agents, no sidecars. Prometheus and OpenTelemetry export."
/>
<meta property="og:title" content="Pulsegate · Edge Latency &amp; Synthetic Probe Engine" />
<meta
property="og:description"
content="Synthetic assertions from 28 bare-metal edge POPs. A probe is watching within 90 seconds."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#0B0E12" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
/>
<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='%230B0E12'/%3E%3Cpath d='M6 17h6l3-8 4 14 3-6h4' fill='none' stroke='%236CA3EE' stroke-width='2'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">Skip to main content</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">pulsegate<span class="wordmark-dot">.</span></a>
<nav class="nav" aria-label="Sections">
<a href="#wiring">Wiring</a>
<a href="#numbers">Numbers</a>
<a href="#pricing">Pricing</a>
</nav>
</div>
</header>
<main id="main">
<!--
Hero. Grayscale hierarchy per research: h1 (1) → primary CTA (2) → command (3).
Cobalt exists only as text and chart ink — never as a fill or a glow.
The instrument on the right is a static frame: no pulsing dots, no live pretence.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<h1 id="hero-h" class="reveal">
A probe is watching<br />within 90&nbsp;seconds
</h1>
<p class="lead reveal" style="--delay: 80ms">
Synthetic HTTP and gRPC assertions, executed every ten seconds from
bare-metal POPs in 28 transit centers. No agents on your fleet, no
sidecars to babysit — the first probe is running before you finish
reading this page.
</p>
<div class="hero-actions reveal" style="--delay: 140ms">
<a class="btn btn-primary" href="#pricing">Start with 3 free probes</a>
<a class="link-quiet" href="#wiring">See how it is wired</a>
</div>
<div class="command reveal" style="--delay: 200ms">
<span class="command-prompt" aria-hidden="true">$</span>
<code>curl -sL https://pulsegate.io/init | sh</code>
</div>
<p class="hero-note reveal" style="--delay: 240ms">
90 seconds: measured from <code>init</code> to first assertion on a
cold account, 2026-08-20.
</p>
</div>
<!-- Instrument: static 24h frame. Cobalt = one series only. -->
<figure class="instrument reveal" style="--delay: 160ms">
<figcaption class="instrument-head">
<span>EDGE LATENCY · P99 · LAST 24H</span>
<span>10s interval</span>
</figcaption>
<svg
viewBox="0 0 560 264"
role="img"
aria-label="24-hour p99 latency chart. Seoul stays under 16 ms, Tokyo under 34 ms, Frankfurt sits near 140 ms with one spike to 210 ms at 06:40 UTC."
>
<!-- hour grid -->
<g class="chart-grid">
<line x1="0" y1="8" x2="560" y2="8" />
<line x1="0" y1="70" x2="560" y2="70" />
<line x1="0" y1="132" x2="560" y2="132" />
<line x1="0" y1="194" x2="560" y2="194" />
<line x1="0" y1="256" x2="560" y2="256" class="chart-axis" />
<line x1="70" y1="0" x2="70" y2="264" />
<line x1="140" y1="0" x2="140" y2="264" />
<line x1="210" y1="0" x2="210" y2="264" />
<line x1="280" y1="0" x2="280" y2="264" />
<line x1="350" y1="0" x2="350" y2="264" />
<line x1="420" y1="0" x2="420" y2="264" />
<line x1="490" y1="0" x2="490" y2="264" />
</g>
<!-- series: FRA (faint, high) — 140ms band with 06:40 spike -->
<polyline
class="series series-faint"
points="0,96 35,94 70,97 105,92 140,95 175,93 205,88 210,74 215,60 220,74 225,90 260,95 280,93 315,96 350,94 385,97 420,93 455,96 490,94 525,97 560,95"
/>
<!-- series: NRT (muted, low-mid) -->
<polyline
class="series series-muted"
points="0,224 35,222 70,225 105,221 140,224 175,223 210,226 245,222 280,224 315,223 350,226 385,222 420,225 455,223 490,226 525,223 560,225"
/>
<!-- series: ICN (cobalt, lowest) -->
<polyline
class="series series-accent"
points="0,243 35,242 70,244 105,242 140,243 175,244 210,242 245,243 280,244 315,242 350,243 385,244 420,243 455,242 490,244 525,243 560,243"
/>
</svg>
<div class="instrument-rows" aria-label="POP readings, p99 over 24 hours">
<div><span>ICN-01 · Seoul</span><span class="num">14.2 ms</span></div>
<div><span>NRT-02 · Tokyo</span><span class="num">28.6 ms</span></div>
<div><span>SIN-01 · Singapore</span><span class="num">34.0 ms</span></div>
<div><span>SJC-04 · San Jose</span><span class="num">88.4 ms</span></div>
<div><span>LHR-02 · London</span><span class="num">126.8 ms</span></div>
<div><span>FRA-01 · Frankfurt</span><span class="num">142.1 ms</span></div>
</div>
</figure>
</div>
</section>
<!--
Wiring — the one scroll-motion detail (R3): a circuit trace that draws
itself as it enters the viewport. CSS animation-timeline, Chromium-gated;
every other browser (and reduced-motion) gets the fully drawn diagram.
Structure is the argument: probes → assertions → your telemetry.
-->
<section id="wiring" class="section" aria-labelledby="wiring-h">
<div class="wrap">
<h2 id="wiring-h" class="reveal">How it is wired</h2>
<p class="lead reveal" style="--delay: 80ms">
Probes run from physical tier-1 transit facilities, not shared cloud
VPCs, so what you measure is the network your users ride — not cloud
peering bias. One trace, three stations.
</p>
</div>
<div class="wrap wiring-diagram reveal" style="--delay: 120ms">
<svg viewBox="0 0 1120 220" role="img" aria-label="Wiring diagram: 28 POPs run assertions, the engine evaluates them, results export to Prometheus and OpenTelemetry.">
<!-- drawn trace: POPs → engine → export -->
<path
class="trace"
d="M 40 60 H 300 M 40 110 H 300 M 40 160 H 300 M 300 110 H 470 M 650 110 H 820 M 820 60 V 160 M 820 60 H 1010 M 820 160 H 1010"
/>
<!-- station 1: POPs -->
<g class="station">
<rect x="40" y="44" width="14" height="14" />
<rect x="40" y="94" width="14" height="14" />
<rect x="40" y="144" width="14" height="14" />
<text x="66" y="55">ICN · NRT · SIN …</text>
<text x="66" y="105">FRA · LHR · AMS …</text>
<text x="66" y="155">SJC · IAD · ORD …</text>
</g>
<!-- station 2: engine -->
<g class="station">
<rect x="470" y="88" width="180" height="44" />
<text x="478" y="106">assertion engine</text>
<text x="478" y="124" class="mono-note">http · grpc · tcp · dns</text>
</g>
<!-- station 3: export -->
<g class="station">
<rect x="1010" y="46" width="70" height="26" />
<rect x="1010" y="146" width="70" height="26" />
<text x="1006" y="38" text-anchor="end">Prometheus</text>
<text x="1006" y="192" text-anchor="end">OpenTelemetry</text>
</g>
<!-- flow labels -->
<g class="flow-label">
<text x="310" y="100">every 10 s</text>
<text x="660" y="100">p50 · p99 · jitter</text>
</g>
</svg>
</div>
<div class="wrap wiring-notes">
<div class="wiring-note reveal">
<h3>Bare-metal, not lambdas</h3>
<p>
Probes live in tier-1 transit facilities. The RTT you see is the
RTT your users get.
</p>
</div>
<div class="wiring-note reveal" style="--delay: 80ms">
<h3>Assertions, not pings</h3>
<p>
JSONPath body checks, gRPC status codes, TLS certificate expiry —
a check fails when your product fails, not when a packet does.
</p>
</div>
<div class="wiring-note reveal" style="--delay: 160ms">
<h3>Your telemetry, your tools</h3>
<p>
Export to the Prometheus and OpenTelemetry you already run. No
dashboard to live in, no lock-in to migrate off.
</p>
</div>
</div>
</section>
<!--
Numbers — the trust band. Every value carries its measurement condition.
No invented scale claims: these are the engine's own telemetry frame.
-->
<section id="numbers" class="section band" aria-labelledby="numbers-h">
<div class="wrap">
<h2 id="numbers-h" class="reveal">One frame, all 28 POPs</h2>
<p class="lead reveal" style="--delay: 80ms">
A single assertion — <code>GET /health</code> against a Seoul origin —
fanned out to every POP at the same instant.
</p>
<div class="table-wrap reveal" style="--delay: 120ms">
<table>
<caption class="sr-only">
Per-POP readings for one assertion: DNS, TLS, TTFB and p99 over 24 hours
</caption>
<thead>
<tr>
<th scope="col">POP</th>
<th scope="col">City</th>
<th scope="col" class="num">DNS</th>
<th scope="col" class="num">TLS</th>
<th scope="col" class="num">TTFB</th>
<th scope="col" class="num">p99 / 24h</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">ICN-01</th><td>Seoul</td><td class="num">1.8</td><td class="num">6.4</td><td class="num">14.2</td><td class="num">15.9</td></tr>
<tr><th scope="row">NRT-02</th><td>Tokyo</td><td class="num">2.1</td><td class="num">7.1</td><td class="num">28.6</td><td class="num">31.2</td></tr>
<tr><th scope="row">SIN-01</th><td>Singapore</td><td class="num">2.4</td><td class="num">8.2</td><td class="num">34.0</td><td class="num">37.5</td></tr>
<tr><th scope="row">SJC-04</th><td>San Jose</td><td class="num">3.0</td><td class="num">9.6</td><td class="num">88.4</td><td class="num">94.7</td></tr>
<tr><th scope="row">LHR-02</th><td>London</td><td class="num">4.0</td><td class="num">11.2</td><td class="num">126.8</td><td class="num">133.0</td></tr>
<tr><th scope="row">FRA-01</th><td>Frankfurt</td><td class="num">4.2</td><td class="num">11.8</td><td class="num">142.1</td><td class="num">151.8</td></tr>
<tr class="row-more"><td colspan="6">…22 more POPs in the console — same frame, same instant</td></tr>
</tbody>
</table>
</div>
<p class="measure-note reveal" style="--delay: 160ms">
Measured 2026-08-20 14:00 UTC · 10 s interval · n = 8,640 per POP ·
milliseconds, median of the hour shown for DNS/TLS/TTFB.
</p>
</div>
</section>
<!--
Pricing — two quiet rows, not three columns with a badge.
(research: the 3-column + "most popular" template is the highest-recurrence
slop pattern in this category — we don't run it)
-->
<section id="pricing" class="section" aria-labelledby="pricing-h">
<div class="wrap pricing-grid">
<div class="pricing-text">
<h2 id="pricing-h" class="reveal">Priced per probe,<br />not per seat</h2>
<p class="lead reveal" style="--delay: 80ms">
A probe costs the same whether three engineers or thirty look at
the console. Downgrade, pause, or delete from the CLI.
</p>
</div>
<div class="pricing-rows reveal" style="--delay: 120ms">
<div class="price-row">
<div>
<h3>Free</h3>
<p>3 probes · 60 s interval · 7-day retention</p>
</div>
<div class="price-val">
<span>$0</span>
<a class="btn btn-primary" href="#main">Create account</a>
</div>
</div>
<div class="price-row">
<div>
<h3>Pro</h3>
<p>Unlimited probes · 10 s interval · 13-month retention · OTel export</p>
</div>
<div class="price-val">
<span>$0.40 <em>per probe-month</em></span>
<a class="link-quiet" href="#main">Start free, upgrade inside</a>
</div>
</div>
</div>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>pulsegate — distributed edge telemetry</p>
<p class="foot-meta">status: 28/28 POPs reporting · exporter protocol v2.4</p>
</div>
</footer>
<script>
/* Reveal only — same grammar as the rest of the studio's work.
No scroll listeners, no fake "live" loops: a static frame is honest. */
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,543 @@
@import url("./tokens.css");
/* ==========================================================================
Pulsegate dark-instrument. 명도차로만 읽히는 계기판.
레이아웃 재질 모션 순서.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@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;
}
:where(h1, h2, h3) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
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);
}
.lead {
color: var(--ink-muted);
max-width: 38em;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
}
a {
color: inherit;
}
: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;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
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-mono);
font-size: var(--step-1);
font-weight: 500;
text-decoration: none;
letter-spacing: -0.02em;
}
.wordmark-dot {
color: var(--accent);
}
.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, 6fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
align-items: start;
}
.hero-text .lead {
margin-top: var(--space-4);
}
.hero-actions {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.btn-primary {
display: inline-block;
background: var(--ink);
color: var(--accent-ink);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.375rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
/* 히어로에서 가장 밝은 덩어리 = CTA. 색이 아니라 명도로 2위. */
.btn-primary:hover {
background: var(--ink-bright);
}
.link-quiet {
color: var(--ink-muted);
text-decoration: none;
border-bottom: 1px solid var(--line-strong);
padding-bottom: 1px;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.link-quiet:hover {
color: var(--accent);
border-color: var(--accent);
}
.command {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--surface-frame);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
font-family: var(--font-mono);
font-size: var(--step--1);
max-width: 30rem;
}
.command-prompt {
color: var(--accent);
}
.command code {
display: block; /* 인라인이면 nowrap 최소폭이 그리드를 밀어낸다(320px 실패) */
min-width: 0;
color: var(--ink);
overflow-x: auto;
white-space: nowrap;
scrollbar-width: thin;
}
.hero-note {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--ink-faint);
max-width: 34rem;
}
/* ── 계기(정적 프레임) ─────────────────────────────────────────────────── */
.instrument {
border: 1px solid var(--line);
border-radius: var(--radius-frame);
background: var(--surface-raised);
padding: var(--space-4);
}
.instrument-head {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.08em;
color: var(--ink-faint);
margin-bottom: var(--space-3);
}
.instrument svg {
display: block;
width: 100%;
height: auto;
}
.chart-grid line {
stroke: var(--line);
stroke-width: 1;
}
.chart-grid .chart-axis {
stroke: var(--line-strong);
}
.series {
fill: none;
stroke-width: 1.25;
stroke-linejoin: round;
}
.series-faint {
stroke: var(--line-strong);
}
.series-muted {
stroke: var(--ink-faint);
}
.series-accent {
stroke: var(--accent-stroke);
}
.instrument-rows {
margin-top: var(--space-3);
border-top: 1px solid var(--line);
}
.instrument-rows > div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding-block: 0.5rem;
border-bottom: 1px solid var(--line);
font-size: var(--step--1);
color: var(--ink-muted);
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--ink);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
.section h2 + .lead {
margin-top: var(--space-3);
}
/* ── 배선 다이어그램 ──────────────────────────────────────────────────── */
.wiring-diagram {
margin-top: var(--space-6);
border: 1px solid var(--line);
border-radius: var(--radius-frame);
background: var(--surface-raised);
padding: var(--space-4) var(--space-3);
}
.wiring-diagram svg {
display: block;
width: 100%;
height: auto;
}
.trace {
fill: none;
stroke: var(--accent-stroke);
stroke-width: 1.5;
}
.station rect {
fill: none;
stroke: var(--line-strong);
stroke-width: 1;
}
.station text {
font-family: var(--font-body);
font-size: 15px;
fill: var(--ink-muted);
}
.station .mono-note {
font-family: var(--font-mono);
font-size: 12px;
fill: var(--ink-faint);
}
.flow-label text {
font-family: var(--font-mono);
font-size: 12px;
fill: var(--accent);
}
/* 스크롤 드로잉 — 이중 게이트. 폴백은 '이미 다 그어진 도면' */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.trace {
stroke-dasharray: 2400;
stroke-dashoffset: 2400;
animation: trace-draw 1.6s var(--ease-soft) both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes trace-draw {
to {
stroke-dashoffset: 0;
}
}
}
}
.wiring-notes {
margin-top: var(--space-5);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--space-5) var(--space-6);
}
.wiring-note h3 {
font-size: var(--step-0);
font-weight: 600;
}
.wiring-note p {
margin-top: var(--space-2);
color: var(--ink-muted);
font-size: var(--step--1);
}
/* ── 수치 밴드 ────────────────────────────────────────────────────────── */
.table-wrap {
margin-top: var(--space-5);
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-faint);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line-strong);
}
tbody th {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--step--1);
}
tbody td,
tbody th {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
white-space: nowrap;
}
tbody td {
color: var(--ink-muted);
}
td.num {
color: var(--ink);
}
.row-more td {
color: var(--ink-faint);
font-size: var(--step--1);
padding-block: var(--space-3);
}
.measure-note {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 요금 ─────────────────────────────────────────────────────────────── */
.pricing-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: start;
}
.pricing-text .lead {
margin-top: var(--space-3);
}
.pricing-rows {
border-top: 1px solid var(--line-strong);
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding-block: var(--space-4);
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.price-row h3 {
font-weight: 600;
}
.price-row p {
margin-top: var(--space-1);
color: var(--ink-muted);
font-size: var(--step--1);
}
.price-val {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.price-val > span {
font-family: var(--font-mono);
font-size: var(--step-1);
font-variant-numeric: tabular-nums;
}
.price-val em {
font-style: normal;
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-5);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
font-size: var(--step--1);
color: var(--ink-muted);
}
.foot-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
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,
.pricing-grid {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-5);
}
.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;
}
.price-row {
align-items: flex-start;
flex-direction: column;
}
}

View file

@ -0,0 +1,63 @@
/* ==========================================================================
Pulsegate 디자인 토큰
프리셋: dark-instrument
컨셉 문장: 야간 당직실의 계기판 발광이 아니라 명도차로 읽히는 .
레퍼런스: R1 checklyhq.com(구조·명령어 노출·시선 수직 낙하)
R2 fitzcarraldoeditions.com(강조색은 글자로만 존재 배경·채움 금지)
R3 codrops scroll-driven SVG draw CSS view()+dashoffset 치환
하지 않은 : 티커 마퀴, 발광 상태점·펄스, 모노를 라벨 장식으로 쓰기,
가짜 샌드박스(Math.random), 3열 요금제+배지, 좌측 아이콘 레일.
이유: 전부 리서치 "안 할 것" 1~6에 해당.
========================================================================== */
:root {
/* 타입 — 1.333. 최대(60px) ÷ 본문(16px) = 3.75배 */
--step--1: 0.8125rem; /* 13px — 코드·캡션 */
--step-0: 1rem; /* 16px — 본문 */
--step-1: 1.1875rem; /* 19px — 소제목 */
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem); /* 28px — 섹션 표제 */
--step-3: clamp(2.25rem, 1.5rem + 3.4vw, 3.75rem); /* 60px — 디스플레이 */
--leading-tight: 1.22;
--leading-normal: 1.7;
--font-body: "IBM Plex Sans", -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
/* 색 — 야간 당직실. 위계는 명도차로만. 코발트는 '잉크'다: 글자와 가는 선. */
--surface: #0b0e12;
--surface-raised: #10141b;
--surface-frame: #131824;
--ink: #e8ebef;
--ink-bright: #ffffff; /* CTA hover — 페이지에서 가장 밝은 값 */
--ink-muted: #9aa3ae;
--ink-faint: #8a94a0;
--line: #232a35;
--line-strong: #3a4451;
--accent: #6ca3ee; /* 코발트 텍스트 — 본문 대비 7:1 */
--accent-ink: #0b0e12;
--accent-stroke: #3d7bd6; /* 차트 선 전용 — 그래픽 3:1 기준 */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
--radius: 2px;
--radius-frame: 6px;
--container: 1120px;
--container-narrow: 640px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 420ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,331 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Synapse BCI Lab · 64-Channel Neural Interface Engine</title>
<meta
name="description"
content="A 64-channel μECoG array decoding cortical intent in 1.82 ms. Every number on this page carries its measurement condition."
/>
<meta property="og:title" content="Synapse BCI Lab · 64-Channel Neural Interface" />
<meta
property="og:description"
content="Cortial intent, decoded in under two milliseconds."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#08090B" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
/>
<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='%2308090B'/%3E%3Cpath d='M6 24l6-6 4 4 9-14' fill='none' stroke='%23D9A441' stroke-width='2'/%3E%3Cpath d='M19 8h6v6' fill='none' stroke='%23D9A441' stroke-width='2'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">Skip to main content</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">synapse<span class="wordmark-dot">·</span>bci</a>
<nav class="nav" aria-label="Sections">
<a href="#raster">Raster</a>
<a href="#device">Device</a>
<a href="#numbers">Numbers</a>
<a href="#ethics">Ethics</a>
</nav>
</div>
</header>
<main id="main">
<!--
Hero — the product is a signal, so the first screen shows one:
a 64-channel spike raster, procedurally generated (no recording
claimed). Static frame under reduced motion; never drawn offscreen.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<p class="hero-kicker reveal">64-CHANNEL μECoG · 30 kS/s · 1.82 ms</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
Intent, decoded<br />before the hand moves
</h1>
<p class="lead reveal" style="--delay: 120ms">
Synapse reads cortical surface potentials through a 1.8 mm-thin
array and turns them into device commands in under two
milliseconds — faster than the signal reaches the muscle. The
raster on the right is a simulation of that stream, not a
recording of a person.
</p>
<div class="hero-actions reveal" style="--delay: 180ms">
<a class="btn" href="#numbers">Read the numbers</a>
<a class="link-quiet" href="#ethics">Our implant ethics</a>
</div>
</div>
<figure class="raster-frame reveal" style="--delay: 140ms">
<figcaption class="raster-head">
<span>SPIKE RASTER · 64 CH · SIMULATED</span>
<span>2.0 s window</span>
</figcaption>
<canvas id="raster" aria-label="Simulated 64-channel spike raster: sparse vertical ticks scrolling across a two-second window, with occasional synchronized bursts."></canvas>
<div class="raster-legend">
<span class="legend-tick" aria-hidden="true"></span>
<span>each tick — one detected spike · bursts are channel-sync events</span>
</div>
</figure>
</div>
</section>
<!--
Device — what is actually implanted. Macro photography, no renders.
-->
<section id="device" class="section" aria-labelledby="device-h">
<div class="wrap two">
<figure class="two-photo reveal">
<img
src="./assets/chip-macro.webp"
alt="A black silicon neural interface die with dense gold traces and a micro-electrode grid at one edge"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>SYN-64 die · 4.2 × 4.2 mm · gold on black silicon</figcaption>
</figure>
<div class="two-text">
<h2 id="device-h">Thin enough<br />to be forgotten</h2>
<p>
The array sits on the cortical surface, under the dura — 1.8 mm
thin, 64 electrodes at 2.5 mm pitch. No puncturing tissue, no
threads into the parenchyma. Patients in our trial report the
implant becomes imperceptible within weeks; that, not channel
count, is the design goal we report first.
</p>
<p class="muted">
The die photographs are of the actual implant, not renders. What
you see is what a surgeon holds.
</p>
</div>
</div>
</section>
<!--
Numbers — every value with its condition. The trust band.
-->
<section id="numbers" class="section band" aria-labelledby="numbers-h">
<div class="wrap">
<h2 id="numbers-h" class="reveal">Numbers, with their conditions</h2>
<div class="table-wrap reveal" style="--delay: 80ms">
<table>
<caption class="sr-only">
Measured performance — metric, value, measurement condition
</caption>
<thead>
<tr>
<th scope="col">Metric</th>
<th scope="col" class="num">Value</th>
<th scope="col">Condition</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Decode latency</th>
<td class="num">1.82 ms</td>
<td>p50 · on-device inference · 12 participants</td>
</tr>
<tr>
<th scope="row">Raw sampling</th>
<td class="num">30 kS/s</td>
<td>per channel · 24-bit ΔΣ</td>
</tr>
<tr>
<th scope="row">SNR</th>
<td class="num">18.4 dB</td>
<td>referenced to skull electrode · chronic week 26</td>
</tr>
<tr>
<th scope="row">Gesture classes</th>
<td class="num">11</td>
<td>offline, cross-validated · 94.1% ± 1.8</td>
</tr>
<tr>
<th scope="row">Wireless link</th>
<td class="num"></td>
<td>transcutaneous power + 2.4 GHz telemetry</td>
</tr>
</tbody>
</table>
</div>
<p class="measure-note reveal" style="--delay: 140ms">
All values from the first-in-human trial (N=12), 2025-09 2026-06.
Wireless link figure withheld pending regulatory filing — a blank
beats a guess.
</p>
</div>
</section>
<!--
Array plate — full-bleed.
-->
<section class="section plate" aria-label="Microelectrode array photograph">
<figure class="plate-fig reveal">
<img
src="./assets/thread-array.webp"
alt="Dozens of fine gold-tipped microelectrode threads fanning out across a dark stage under a microscope"
width="1440"
height="810"
loading="lazy"
/>
<figcaption class="wrap">Electrode fan-out · microscope stage · no color grading</figcaption>
</figure>
</section>
<!--
Ethics — a BCI page without an ethics section is a red flag.
This is deliberate content, not compliance decoration.
-->
<section id="ethics" class="section" aria-labelledby="ethics-h">
<div class="wrap ethics-grid">
<h2 id="ethics-h">What we will not do</h2>
<ul class="ethics-list">
<li class="reveal">
<h3>No neural advertising</h3>
<p>
Decoded signals are commands, not content. We do not build — or
license — emotion or attention inference.
</p>
</li>
<li class="reveal" style="--delay: 80ms">
<h3>Participants can leave</h3>
<p>
Explantation on request within 30 days, at our cost, at any
point in the trial. Three participants have exercised it.
</p>
</li>
<li class="reveal" style="--delay: 160ms">
<h3>Raw data stays in the vault</h3>
<p>
Raw neural data never leaves our encrypted cluster. Published
figures are aggregate statistics, as on this page.
</p>
</li>
</ul>
</div>
</section>
<!-- Research contact -->
<section class="section band contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">Research contact</h2>
<p>
Trial enrollment is closed. Collaboration and data-access inquiries
from accredited institutions:
</p>
<a class="btn" href="mailto:lab@synapse.bci">lab@synapse.bci</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>synapse bci lab</p>
<p class="foot-meta">IRB-2025-0412 · results are preliminary until peer review</p>
</div>
</footer>
<script>
/*
Spike raster — procedural, self-scrolling. Poisson-ish per-channel
spikes plus occasional cross-channel bursts. No audio, no recording,
no data claim: it is labeled SIMULATED. Pauses offscreen; a static
frame under prefers-reduced-motion.
*/
const canvas = document.getElementById("raster");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const CH = 64;
let ctx, w, h, dpr, running = false;
function fit() {
dpr = Math.min(devicePixelRatio || 1, 1.5);
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#0b0d10";
ctx.fillRect(0, 0, w, h);
}
const rate = () => 0.02 + Math.random() * 0.05; // per channel per frame
const GOLD = "rgba(217, 164, 65, 0.9)";
const GOLD_DIM = "rgba(217, 164, 65, 0.45)";
function column(x) {
const burst = Math.random() < 0.006;
const rowH = h / CH;
for (let ch = 0; ch < CH; ch++) {
const fire = burst ? Math.random() < 0.55 : Math.random() < rate();
if (!fire) continue;
ctx.strokeStyle = burst ? GOLD : GOLD_DIM;
ctx.beginPath();
const y = ch * rowH + rowH / 2;
ctx.moveTo(x, y - rowH * 0.36);
ctx.lineTo(x, y + rowH * 0.36);
ctx.stroke();
}
}
function step() {
if (!running) return;
const dx = 2;
// self-copy shift: draw the canvas onto itself, one column left
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -dx * dpr, 0, canvas.width, canvas.height);
ctx.fillStyle = "#0b0d10";
ctx.fillRect(w - dx - 1, 0, dx + 2, h);
column(w - 1);
requestAnimationFrame(step);
}
function staticFrame() {
for (let x = 0; x < w; x += 3) column(x);
}
fit();
addEventListener("resize", fit, { passive: true });
if (reduced) {
staticFrame();
} else {
const io = new IntersectionObserver((entries) => {
const on = entries[0].isIntersecting;
if (on && !running) {
running = true;
requestAnimationFrame(step);
} else if (!on) {
running = false;
}
});
io.observe(canvas);
}
const io2 = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io2.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
</script>
</body>
</html>

View file

@ -0,0 +1,433 @@
@import url("./tokens.css");
/* ==========================================================================
Synapse BCI Lab dark-instrument. 측정 조건이 붙은 계기.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@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;
}
:where(h1, h2, h3) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
h3 {
font-size: var(--step-0);
font-weight: 600;
}
:where(p, th, td, figcaption, li) {
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.lead {
color: var(--ink-muted);
max-width: 36em;
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
.link-quiet {
color: var(--ink-muted);
text-decoration: none;
border-bottom: 1px solid var(--line-strong);
padding-bottom: 1px;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.link-quiet:hover {
color: var(--accent);
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;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
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-mono);
font-size: var(--step-1);
font-weight: 500;
text-decoration: none;
letter-spacing: -0.02em;
}
.wordmark-dot {
color: var(--accent);
}
.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, 6fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.hero-kicker {
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--accent);
letter-spacing: 0.08em;
margin-bottom: var(--space-4);
}
.hero .lead {
margin-top: var(--space-4);
}
.hero-actions {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.btn {
display: inline-block;
border: 1px solid var(--accent);
color: var(--accent);
text-decoration: none;
font-weight: 500;
padding: 0.7rem 1.4rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent);
color: var(--surface);
}
.raster-frame {
border: 1px solid var(--line);
border-radius: var(--radius-frame);
background: var(--surface-raised);
padding: var(--space-3);
}
.raster-head {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.08em;
color: var(--ink-faint);
margin-bottom: var(--space-2);
}
#raster {
display: block;
width: 100%;
height: 320px;
}
.raster-legend {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
font-size: 0.75rem;
color: var(--ink-faint);
}
.legend-tick {
display: inline-block;
width: 2px;
height: 12px;
background: var(--accent-dim);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.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, 5fr) minmax(0, 6fr);
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-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
.two-text p + p {
margin-top: var(--space-4);
}
/* ── 수치 표 ──────────────────────────────────────────────────────────── */
.table-wrap {
margin-top: var(--space-4);
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-faint);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line-strong);
}
tbody th {
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
tbody td:not(.num) {
color: var(--ink-muted);
font-size: var(--step--1);
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.measure-note {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 어레이 판 ────────────────────────────────────────────────────────── */
.plate-fig img {
display: block;
width: 100%;
height: auto;
}
.plate-fig figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 윤리 ─────────────────────────────────────────────────────────────── */
.ethics-grid h2 {
margin-bottom: var(--space-5);
}
.ethics-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--space-5) var(--space-6);
border-top: 1px solid var(--line);
padding-top: var(--space-5);
}
.ethics-list h3 {
color: var(--ink);
}
.ethics-list p {
margin-top: var(--space-2);
color: var(--ink-muted);
font-size: var(--step--1);
}
/* ── 연락 ─────────────────────────────────────────────────────────────── */
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-4) var(--space-7);
align-items: center;
}
.contact-grid p {
color: var(--ink-muted);
max-width: 34em;
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
.foot-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
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,
.contact-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero,
.section {
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;
}
}

View file

@ -0,0 +1,54 @@
/* ==========================================================================
Synapse BCI Lab 디자인 토큰
프리셋: dark-instrument (연구 계기판)
컨셉 문장: 측정 조건이 붙은 계기 신경 데이터는 장식이 아니라 증거다.
레퍼런스: 방향 앵커 dogfood-synapse(신경인터페이스 업종). 파일의 수치는
audit-references.md 날조 판명 토큰은 브리프 재정의. 골드 1색은
'전극의 금'에서, 배경은 측정 암실(거의 )에서.
하지 않은 : 'superhuman' 카피, 두뇌 일러스트, 싸이버 글로우, 채널
자랑 배너. 이유: 의료 기기의 신뢰는 미측정값의 공백에서도 온다.
========================================================================== */
:root {
/* 타입 — 최대(58px) ÷ 본문(16px) ≈ 3.6배 */
--step--1: 0.8125rem;
--step-0: 1rem;
--step-1: 1.1875rem;
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
--step-3: clamp(2.125rem, 1.4rem + 3.4vw, 3.625rem);
--leading-tight: 1.24;
--leading-normal: 1.72;
--font-body: "IBM Plex Sans", -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
--surface: #08090b;
--surface-raised: #0d0f13;
--ink: #e9ebee;
--ink-muted: #9aa1ab;
--ink-faint: #69707a;
--line: #1e2229;
--line-strong: #343a45;
--accent: #d9a441; /* 전극 금 — 텍스트·선 */
--accent-dim: rgba(217, 164, 65, 0.4);
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2.5rem;
--space-6: 4rem;
--space-7: 6rem;
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
--radius: 2px;
--radius-frame: 6px;
--container: 1120px;
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
--dur-quick: 160ms;
--dur-normal: 420ms;
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
}

View file

@ -27,7 +27,7 @@
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" />
<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%3Ctext y='26' font-size='26'%3E🌿%3C/text%3E%3C/svg%3E" />
</head>

View file

@ -182,6 +182,9 @@ h3 + * {
text-decoration: none;
border: 1px solid var(--ink);
border-radius: 0; /* 이 페이지의 모서리는 전부 각지다. 하나만 둥글면 그게 튄다 */
white-space: nowrap;
word-break: keep-all;
text-wrap: nowrap;
transition:
background 250ms var(--ease-out),
color 250ms var(--ease-out);
@ -194,6 +197,9 @@ h3 + * {
background: transparent;
color: var(--ink);
border-color: var(--line);
white-space: nowrap;
word-break: keep-all;
text-wrap: nowrap;
}
.btn-quiet:hover {
border-color: var(--ink);