- 핵심 규칙 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 실패 시 배포 금지 체인
5
apps/site/public/_headers
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# 워크 페이지(HTML·CSS)는 즉시 갱신이 보여야 한다 — 데모 갤러리의 생명.
|
||||
# HTML 은 CF Pages 기본이 max-age=0 이지만 CSS 가 4시간 캐시되어
|
||||
# 새 HTML + 옛 CSS 조합이 사용자에게 그려진 적이 있다(2026-08-22 실측).
|
||||
/work/*
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
BIN
apps/site/public/work/baekje-celadon/assets/crazing.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
apps/site/public/work/baekje-celadon/assets/ewer-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/site/public/work/baekje-celadon/assets/inlay.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
210
apps/site/public/work/baekje-celadon/index.html
Normal 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:00–18: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>
|
||||
368
apps/site/public/work/baekje-celadon/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
54
apps/site/public/work/baekje-celadon/styles/tokens.css
Normal 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);
|
||||
}
|
||||
30
apps/site/public/work/dure-enrollment/calc.js
Normal 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);
|
||||
771
apps/site/public/work/dure-enrollment/index.html
Normal 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/9–3/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:00–18: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:00–18: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>
|
||||
BIN
apps/site/public/work/dure-enrollment/og.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
567
apps/site/public/work/dure-enrollment/styles/main.css
Normal 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); }
|
||||
55
apps/site/public/work/dure-enrollment/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/font-foundry/assets/inkstone.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/site/public/work/font-foundry/assets/metaltype.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
289
apps/site/public/work/font-foundry/index.html
Normal 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>
|
||||
420
apps/site/public/work/font-foundry/styles/main.css
Normal 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);
|
||||
}
|
||||
}
|
||||
53
apps/site/public/work/font-foundry/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/form-architecture/assets/interior-bw.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/site/public/work/form-architecture/assets/model-bw.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/site/public/work/form-architecture/assets/pavilion-bw.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
303
apps/site/public/work/form-architecture/index.html
Normal 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">© 2014–2026</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>
|
||||
497
apps/site/public/work/form-architecture/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
52
apps/site/public/work/form-architecture/styles/tokens.css
Normal 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);
|
||||
}
|
||||
57
apps/site/public/work/gaon-lms/calc.js
Normal 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);
|
||||
1316
apps/site/public/work/gaon-lms/index.html
Normal file
BIN
apps/site/public/work/gaon-lms/og.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
807
apps/site/public/work/gaon-lms/styles/main.css
Normal 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; }
|
||||
}
|
||||
65
apps/site/public/work/gaon-lms/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/hyang-incense/assets/agarwood.webp
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
apps/site/public/work/hyang-incense/assets/smoke.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
255
apps/site/public/work/hyang-incense/index.html
Normal 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>
|
||||
447
apps/site/public/work/hyang-incense/styles/main.css
Normal 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);
|
||||
}
|
||||
}
|
||||
53
apps/site/public/work/hyang-incense/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/lumina-optics/assets/lens-section.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/site/public/work/lumina-optics/assets/prism-bench.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
427
apps/site/public/work/lumina-optics/index.html
Normal 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>
|
||||
477
apps/site/public/work/lumina-optics/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
57
apps/site/public/work/lumina-optics/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/muwol-coffee/assets/beans-hero.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/site/public/work/muwol-coffee/assets/pourover.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
333
apps/site/public/work/muwol-coffee/index.html
Normal 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>
|
||||
560
apps/site/public/work/muwol-coffee/styles/main.css
Normal 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);
|
||||
}
|
||||
}
|
||||
67
apps/site/public/work/muwol-coffee/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/orbit-aerospace/assets/channel-macro.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/site/public/work/orbit-aerospace/assets/static-fire.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
322
apps/site/public/work/orbit-aerospace/index.html
Normal 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>
|
||||
455
apps/site/public/work/orbit-aerospace/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
55
apps/site/public/work/orbit-aerospace/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/overtone-sound/assets/installation.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
257
apps/site/public/work/overtone-sound/index.html
Normal 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 · 220–940Hz</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">© 2019–2026 · 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>
|
||||
392
apps/site/public/work/overtone-sound/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
54
apps/site/public/work/overtone-sound/styles/tokens.css
Normal 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);
|
||||
}
|
||||
317
apps/site/public/work/pulsegate/index.html
Normal 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 & 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 & 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 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>
|
||||
543
apps/site/public/work/pulsegate/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
63
apps/site/public/work/pulsegate/styles/tokens.css
Normal 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);
|
||||
}
|
||||
BIN
apps/site/public/work/synapse-bci/assets/chip-macro.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
apps/site/public/work/synapse-bci/assets/thread-array.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
331
apps/site/public/work/synapse-bci/index.html
Normal 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>
|
||||
433
apps/site/public/work/synapse-bci/styles/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
54
apps/site/public/work/synapse-bci/styles/tokens.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||