Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

265 changed files with 423 additions and 20823 deletions

1
.gitignore vendored
View file

@ -10,4 +10,3 @@ apps/site/dist/
# 리서치 원본 자막(용량) — 요약 문서는 커밋한다 # 리서치 원본 자막(용량) — 요약 문서는 커밋한다
research/youtube/*.srt research/youtube/*.srt
research/youtube/*.vtt research/youtube/*.vtt
apps/site/tools/.gen/

View file

@ -1,9 +0,0 @@
{
"extends": ["html-validate:standard"],
"rules": {
"require-sri": "off",
"no-inline-style": "off",
"doctype-style": "off",
"void-style": "off"
}
}

View file

@ -1,13 +0,0 @@
{
"rules": {
"color-no-invalid-hex": true,
"function-calc-no-unspaced-operator": true,
"string-no-newline": true,
"unit-no-unknown": true,
"property-no-unknown": true,
"declaration-block-no-duplicate-properties": true,
"no-duplicate-selectors": true,
"no-empty-source": true,
"no-unknown-animations": true
}
}

View file

@ -489,304 +489,3 @@ Codex CLI 의 내장 `image_gen`(gpt-image-2)을 ChatGPT 구독 인증으로 호
--- ---
`2026-08-20` · 2판. 0~6단계 전체 경로 재주행. `2026-08-20` · 2판. 0~6단계 전체 경로 재주행.
`2026-08-21` · 쇼케이스 5종 확장 (총 6개 프리셋 완성).
- 무월 (muwol-coffee): quiet-commerce · 수치표 및 거래 원장 · 앰버 1색
- Pulsegate (pulsegate): dark-instrument · 28개 엣지 레이턴시 계기판 · 코발트 상태점
- 스튜디오 폼 (form-architecture): swiss-minimal · 12열 그리드 & 흑백 단면도 선화 · 무채색 98%
- 오버톤 사운드랩 (overtone-sound): anti-grid · 비대칭 FFT 파형 레이어 & 모노 타이포 · 일렉트릭 앰버 1색
- 한성활자연구소 (font-foundry): editorial-archival · 인터랙티브 조판 시험대 & 한지톤 파치먼트 · 인주 레드 1색
- 전체 쇼케이스 모자이크 그리드 6칸(1-lg, 2-md, 3-sm) 완성, 고해상도 스크린샷 10종 캡처 완료.
---
## 2026-08-21 · 쇼케이스 10종 전면 재주행 (목요일의 화원 제외)
0~6단계를 워크 단위로 다시 돌았다. **각 워크가 서로 다른 가상 클라이언트**이므로
레퍼런스·컨셉·토큰을 페이지별로 새로 정의했다(국소 아님 — 토큰 체계 재정의 = 전체 경로).
### 감사에서 발견된 것 (재주행의 이유)
- 예전 10개 페이지는 전부 "계기판 흉내"였다: 영어 대문자 eyebrow, 티커 마퀴,
발광 상태점, 세로 GNB 레일, 카드+FIG 캡션, Math.random 가짜 샌드박스.
목요일의 화원만이 실제 편집 디자인이었다.
- **dogfood 리서치 5종(aerospace·celadon·incense·optics·synapse)의 "실측값"이
날조였다** — `research/audit-references.md` 참고. spacex는 순흑+D-DIN,
museum.go.kr는 라이트 테스트 등 전부 불일치. 이번 판에서 그 수치를 토큰 근거로
쓰지 않았고 각 페이지 tokens.css 머리글에 그 사실을 남겼다.
새로 실측한 리서치: dogfood-architecture(John Pawson 724px·동일 크기·1px 선),
dogfood-overtone(carstennicolai 1152px·압축 스케일·obys 스태거),
dogfood-foundry(klim 스페시멘 서사·레드 점묘).
### 페이지별 한 줄 (전부 tokens.css 머리글에 상세 기록)
| 워크 | 프리셋 | 이번 판의 결정 |
|---|---|---|
| 무월 | quiet-commerce | 우측 레일·eyebrow 6개 폐기. 가격 첫 화면(R1), 텍스트가 상품(R2), CTA=최암반전 블록 |
| Pulsegate | dark-instrument | 코발트는 잉크로만(배경 금지). 정적 24h 프레임, CSS scroll-draw 배선도, 단일 가격 행 |
| 향연 | quiet-luxury | 한지 SVG 그레인, 세로쓰기 캡션, 사진 없는 상품 목록, 본문 17px·1.9행간 |
| 비색의 침묵 | editorial-archival | 암실 3개의 방, 빙렬 풀블리드 판, 비색 액센트 1색 |
| 스튜디오 폼 | swiss-minimal | 무채색 100%·radius 0, 텍스트 인덱스(Pawson), 스티키 평면도 scroll-draw |
| 한성활자 | editorial-archival | 22rem 글리프+인주각, Lorem 0회, 시험대 슬라이더 3종(네이티브 input+output) |
| 오버톤 | anti-grid | 절차적 유사-FFT 캔버스(오디오 디코드 회피), 스태거 2열, 압축 스케일, 마퀴 1회 |
| Lumina | refraction/WebGL | three.js 커스텀 셰이더 프리즘(IOR·분산 유니폼), 본문은 캔버스와 세로 분리 |
| 궤도 | technical-schematic | 손그린 엔진 단면도(SVG <use> 미러), 모든 수치에 측정조건, 보류 시험까지 공개 |
| Synapse | dark-instrument | 래스터는 SIM 명시, 미측정값 '—', 윤리 섹션을 콘텐츠로 |
### 프리플라이트 (기계 검증)
- 정적 grep: 슬롭 지문 0건, `100vh` 0건, scroll 리스너 0건, uppercase 0건.
토큰 밖 hex 7곳 → 전부 토큰화(각 페이지 `--*-bright`/`--accent-hover` 추가).
- puppeteer DOM 감사 **10페이지 × 3뷰포트(1440/390/320) = 30/30 통과**:
가로스크롤 0, 깨진 이미지 0, 버튼·내비 줄바꿈 0, 텍스트×사진 겹침 0, 콘솔 에러 0.
- 걸려서 고친 것: pulsegate 320px 가로스크롤(인라인 code의 nowrap 최소폭 →
`display:block`+`min-width:0`), lumina 텍스트-캔버스 겹침(절대배치 폐기,
세로 분리 — "3D 위에 본문 금지" 원칙을 스스로 위반했었다).
- **의도적 이탈 1건**: 폼·pulsegate의 `stroke-dashoffset` 스크롤 드로잉은
게이트 8(transform/opacity 외)에서 벗어난다. 선을 긋는 유일한 저비용 수단이며
레이아웃을 강제하지 않는 로컬 리페인트라 채택하고 여기에 기록한다.
### 이미지 파이프라인 (재사용 메모)
- `tools/gen-images.mjs` → codex 내장 image_gen(gpt-image-2), 프롬프트는 stdin.
**codex 배너가 stderr로 나오므로 세션 id 파싱은 e.stdout 포함 catch 필수.**
병렬 실행 시 세션 회수 경합 — `tools/collect-images.mjs`(세션 dir 생성시각순=JOBS순)으로 회수.
- 생성 22장 → webp(900~1600px, q82) 변환로 페이지당 28~220KB.
- 헤드리스 캡처에서 WebGL이 필요한 페이지는 `--disable-gpu` 금지,
`--use-angle=swiftshader` 사용(capture-shots.mjs `webgl: true`).
### 다음 실행이 해야 할 일
1. 비전 감사에서 남은 사소한 제안(차트 축 라벨 크기, 사진 해상도 아쉬움)은
기록만 남김 — 구조 결함 아님.
2. showcase 페이지 언어판(en) 설명문을 새 디자인에 맞게 다듬을 여지.
`2026-08-21` · 국소 — 모바일 메뉴 재작성 + 3건 오버플로 수정. 1 레퍼런스:
navbar.gallery → browserbase.com(항목 많은 사이트는 내비 마크업 자체를 복제해
분리한다; 우리는 5행이라 시트를 알약 전체 폭으로). `<details>` 결정 유지.
- 시트: 11rem 조각 상자 → 알약 전체 폭, 행 48px, mono 번호(0105)·repo·언어 행,
요약 아이콘 버거→X 교체, 44px 터치 대상, 등장 translateY+opacity 180ms.
- **발견한 숨은 버그**: 닫힌 `<details>` 자식에 제작자 `display`를 주면 UA 숨김을
덮어써 '보이지 않지만 레이아웃에 사는' 시트가 된다 — 320px +7px의 진짜 원인.
`.nav-mobile:not([open]) .nav-sheet { display: none }` 안전장치 추가.
- 행 클릭·바깥 클릭 닫힘 인라인 JS 몇 줄 추가(Esc·포커스는 여전히 브라우저 몫).
- ≤480px: 언어 버튼은 헤더에서 물러나고 메덥 행으로(320px 헤더 7px 초과 해소).
- Install 섹션: `@media`의 순수 `1fr` = minmax(auto,1fr) — nowrap 명령 min-content가
트랙을 밀어 390px +5px. `minmax(0,1fr)`로. `.t-path``overflow-wrap: anywhere`.
- 검증: / · /en/ · /showcase/ × 390/320 — 오버플로 0, 메뉴 열림/앵커 닫힘/바깥 닫힘
/행 48px/시트=알약 폭 전 통과.
`2026-08-21` · 국소 — 쇼케이스에서 내비 앵커가 죽는 버그. 내비 항목은 홈 섹션
앵커(#pipeline 등)인데 홈이 아닌 페이지에서는 그 페이지 안의 없는 앵커가 된다.
Base 레이아웃에서 홈 판별 후 `/#…` 로 접두사(언어판별). 홈은 그대로 `#…`.
`2026-08-21` · 국소 — 메뉴 시트 불투명화. .glass 의 잉크(white 4.5%)는 알약용
재질이지 시트용이 아니었다: 뒤 본문이 비쳐 겹쳐 보였다. 메뉴는 크롬 —
배경을 color-mix(surface 94%)로 채우고 rim/그림자만 유리 재질 유지.
`2026-08-21` · 국소 — 쇼케이스를 카드+모달 구조로 재구성(사용자 지시).
1 레퍼런스 tailwindcss.com/showcase(카드 해부: 이미지+이름+한줄종류+태그, 3열,
카드 전체 클릭). taste-skill 4종 병행 적용.
- 카드는 간단한 정보만: 스크린샷(호버 시 2번째 스크린샷 교체) + 이름 + 종류 +
프리셋 배지 + 컨셉 2줄 클램프. 리스크·지표는 카드에서 뺄샘 — 모달로.
- 그리드 비균일 유지: 첫 사례만 2열 폭(featured, 1440px에서 1100px), 나머지 3열(340px).
- 상세는 네이티브 <dialog>+showModal — Esc·포커스 트랩·inert 를 브라우저가 처리
(<details> 메뉴와 같은 결정론). 배경 탭·닫기 버튼은 인라인 JS 몇 줄.
- 모달은 불투명 판(유리 금지 — 메뉴 시트와 같은 교훈), ::backdrop 만 블러.
큰 CTA "사이트 보기" = btn-solid + 둥근 화살 칸(cmd-copy 아이콘 어휘), 새 탭.
- 카드 전체 클릭은 절대배치 <button> 한 개(카드당) — 텍스트는 비활성, 탭 순서=카드 순서.
- 검증 3뷰포트(1440/390/320) 통과: 모달 열림·포커스 내부·Esc/배경/버튼 닫힘·
CTA _blank·오버플로 0·페이지에러 0. 닫기 버튼은 밝은 스크린샷 위 불투명 44px.
`2026-08-21` · 국소 — 프로모션 카피 전면 재작성(사용자 지시: 광고다운 포장) +
레이아웃 미조정. taste-skill 4종 병행.
- **걸린 카피 결함**: "없습니다는 제약"·"못합니다는 말" 어문 붕괴 2건, 존댓말/평서체
혼용 전역, EN 프리플라이트 3번이 design.md에서 이미 정정된 옛 이야기(Chrome 이
선언을 버린다)를 그대로 싣던 것, KO 프리플라이트 6항목만 싣고 note 는 "열세 건"이라
주장하던 것, 미사용 showcase 블록이 옛 자리표 시절 노트("표의 값은 비어 있습니다")를
설명하던 것.
- 카피 방침(파일 머리 주석으로 명문화): 본문 존댓말 통일, 제목은 단형 종결,
새 "—" 금지, 재지 않은 값은 미측정로. 포장은 혜택 문장으로 하되 수치는 그대로.
- KO 프리플라이트 13항목 완전 수록(EN 기준 역번역). EN backdrop-filter 항목 정정.
- 히어로: 리드를 "무엇인지+어떻게 다른지"로 재작성, 2차 링크 "사례 보기(/showcase)" 추가.
- 레이아웃: h1 text-wrap:balance + 하한 3rem→2.6rem(h2 33px 대 41.6px 위계 유지,
320px 4줄→2줄). EN 320px만 3줄(라틴 장문의 자연 꺾임, 데스크톱 2줄 기준 충족).
- 감사: / · /en/ × 1440/390/320 — 오버플로 0, h1 줄수 기준 충족, 링크 렌더 확인,
.entry 13항목 렌더 확인.
`2026-08-22` · 국소 — 관리 앱 형태의 사례 2종 추가(사용자 지시: 메뉴가 작동하고
모달 팝업이 있는, 학습관리 대시보드·수강신청 같은 앱).
- **gaon-lms 가온 학적부**: 종이 학적부 톤(paper #f7f5ef, 흑연 사이드바 #24272b),
형광펜 밑줄 .hl(gradient 배경, 텍스트색 불변), 날인 .stamp. 뷰 3종(대시보드/수강생/
과제·성적) 사이드 전환 + aria-current 이동, 검색·상태칩 필터, 학생 상세 모달
(메모 추가 폼), 채점 모달(저장 시 표 상태 '채점 대기→채점 완료' 갱신). 모달은
네이티브 <dialog> — 쇼케이스 모달과 같은 결정론(Esc·포커스 트랩을 브라우저에).
- **dure-enrollment 두레대학 수강신청**: 남색 잉크 + 주홍 申 도장. 과목 10개,
시간표 블록 grid-row=(start9)×2+2(헤더 1행 오프셋), 겹침·정원 마감·학점 18
초과를 도장 누르기 전에 차단(openCourse 에서 warn + apply disabled), 결과는
인라인 status(aria-live)로. 취소는 블록 제거·학점 회수까지 되돌림.
- **320px 함정 재발**: 모바일 미디어쿼리의 .layout 트랙이 순수 1fr(=minmax(auto,1fr))
라 과목 행 min-content(426px)가 문서 전체를 밀었다(+122px). Install 섹션과 동일한
결함 — 축소 트랙도 minmax(0,1fr). 과목 행 안 .c-main/.course-list 의 암시적 auto
트랙도 함께 minmax(0,1fr)로(부제 '코드·시간·강의실'이 한 단어처럼 밀리지 않게).
- 시간표 .tt(최소폭 376px)는 .tt-scroll(overflow-x:auto)이 위젯 내부 가로 스크롤로
흡수 — 시간표의 표준 패턴이며 문서 오버플로가 아니다.
- -2 스크린샷을 '모달 열림 상태'로 캡처(2종 모두) — 카드 호버 교체 컷이 첫 화면과
동일한 나머지 사례와 달리, 모달이 핵심 기능인 이 두 사례는 교체 컷이 얘기해야 한다.
- 감사 25항목 통과: 뷰 전환/aria-current/검색 축소/칩 단일 선택/모달 open·닫힘(Esc·
버튼)/메모 추가(1→2)/채점 상태 갱신/겹침 차단(미시경제학↔생명과학, 월 14:0014:30)/
신청 블록 2개(월·수)/취소 회수/1440·390·320 오버플로 0.
- work.ts SSOT 등록: 항목 2개(size md, 6행 3+3). 지표의 JS 크기는 실측(7KB/10KB).
`2026-08-22` · 국소 — 관리 앱 2종에 화면 5개씩 추가(사용자 지시: 상세 페이지를 더
밀어붙이고 디자인도 개선). taste-skill 4종 병행. 연장 경로(토큰·컨셉 그대로).
- 1 국소 레퍼런스(뷰 내비 축): docs.astro.build 사이드바 실측 — 폭 300px, 행 40px,
링크 16px, 그룹 헤더로 246개 링크를 정리. 결론: 우리는 뷰 8개라 220px 폭·기존 행
밀도 유지, **그룹 헤더 3개(학적/수업/학사)** 만 도입. WebFetch 으로는 지오메트리를
얻을 수 없어 puppeteer 로 직접 실측(docs.stripe.com·docs.github.com 은 JS 렌더로
사이드바가 안 잡혀 두 번 실패 후 성공).
- **gaon-lms 3→8 화면**: 출석부(칸을 누르면 출석→지각→결석 순환, 주 전환, 반 전환,
휴학생은 ·), 학사 일정(2026-03 달력, 월요일 시작, 오늘=3/9 날인, 다가오는 일정),
상담 기록(memo 를 색인 카드로, 카드→기존 학생 모달 재사용), 자료실(배부 도장
토글 — 가짜 다운로드 링크 대신 상태만 기록), 학기 리포트(**명단에서 실제 계산**:
재원·평균 출석률·과제 제출률, 미채점은 '채점 중'으로 정직하게).
- **dure-enrollment 1→6 화면**: 앱바 아래 색인탭 내비(활성=남색 잉크 밑줄). 관심 과목
(모달 안 관심 남기기 → 여섯 화면이 같은 상태 공유), 신청 내역(여기서도 취소),
이수 현황(네 학기 기록 + 이번 학기 신청을 학점별 실시간 집계), 과목 총람(계열
소계 + 여석 임박 주홍), 공지·변경(게시판 행 문법). 빈 상태는 다음 행동 버튼과
함께(dashed 판 + '과목 찾기로 가기').
- 새 시각 언어: 장부 판(.sheet — 흰 종이에 먹줄 한 줄, dure 전 화면), 종이 그레인
(gaon body::after 고정 레이어, opacity 0.028, pointer-events:none), 표 행 hover,
:active scale 0.92-0.995 촉감 피드백.
- **320px 함정 3연타(같은 결함)**: gaon 모바일 .app 순수 1fr → 사이드 min-content
721px 가 트랙을 밀었다(+401px). 고침: .app 축소 트랙 minmax(0,1fr) + .side
min-width:0 + .side-nav max-width:100%(여덟 항목 가로 색인줄은 aside 안에서만
스크롤). 순수 1fr 은 이제 프로젝트 금지어 — 미디어쿼리 축소 트랙에도 minmax 를
붙인다.
- 감사 48항목 통과: 뷰 14개 전환·aria-current, 출석 순환·주·반 전환, 자료 필터·배부
토글, 달력 42칸·오늘·예정 7건, 상담 필터·카드→모달, 리포트 실계산(3명·91%·96%·
84.4점), 관심→목록 ★, 신청→내역·이수 +3 반영, 총람 소계, 겹침 차단 회귀, 내역에서
취소, 320·390·1440 오버플로 0.
- 스크린샷 재캡처(본 컷 + 모달 -2 컷), work.ts 지표 갱신(뷰 8/6종, 리포트 실계산,
색인탭, 빈 상태).
`2026-08-22` · 국소 — 모달 위치 결함 수정 + 표지 재질 개선(사용자 지시: 가운데도
안 있고 안 이쁘다). 두 앱의 `* { margin: 0 }` 리셋이 `<dialog>` UA 스타일의
`margin: auto`(가운데 정렬의 정체)를 지워 모달이 왼쪽 상단(left:0, top:10)에
붙어 있었다. 쇼케이스트 모달은 별도 스코프라 무사.
- 수정: .modal 에 `margin: auto` 명시(리셋 이긴 UA 값 되살리기). 앞으로 dialog 를
쓰는 페이지에서 `* { margin: 0 }` 리셋과 세트로 검사한다.
- 표지 재질: 안쪽 먹줄(inset box-shadow ring 5px+1px — 스크롤해도 테두리 유지),
잉크빛 그림자(0 32px 64px -28px), ::backdrop 2px 블러+50% 딤, 등장 모션
translateY(14px)+scale(0.985) 240ms, 닫기 버튼 hover 채움(흑연/남색).
- 검증: 프로덕션 margin 상하 261.656/261.672 대칭(완전 중앙), -2 컷 재캡처.
`2026-08-22` · 국소 — 모달 재구성(사용자 지시: 빈약하고 안 이쁘다). "작은 상자에
항목 몇 개"를 넘어 모달을 판(版)으로: 가온 학생 모달 760px 학적 카드(큰 숫자 3개,
**실제 ATT 데이터에서 뽑은 2주 출석 스트립 10칸**, 날짜열 상담 타임라인), 채점 모달
평균 실시간 계산(91.7→84.3, 형광펜 강조), 두레 강의계획 720px(큰 숫자 3개, **주간
자리 미니 격자** 918시 비례 레일, 평가 비중 **누적 막대 한 줄** — 같은 남색의 농도
3단). 모달 제목 step-3(28/30px)으로 상향. 검증: 세그먼트 63/189/378px=10/30/60%
정확, 비전 오탐(비율 안 맞는다는 지적)은 DOM 실측으로 뒤집음. 48항목 재감사 통과.
`2026-08-22` · 국소 — "변한 게 없다"는 보고의 원인은 캐시. HTML 은 엣지가 안
담아(max-age=0) 항상 최신이지만, 정적 CSS 는 Cloudflare 존의 브라우저 캐시 TTL
4시간이 헤더를 14400 으로 덮어쓴다(cf-cache-status REVALIDATED 실측). 새 HTML +
옛 CSS 조합이 그려졌던 것. 대응: (1) 모든 워크 페이지의 CSS 링크를 main.css?v=2
로 버전 걸기 — 캐시를 안 통하는 새 URL. (2) public/_headers 로 /work/* 에
max-age=0 지정(존 설정이 우선하면 효과는 제한적). **운칙: /work 페이지 CSS 를
고치면 반드시 ?v= 숫자를 올린다.** 근본 해법은 존의 Browser Cache TTL 을
'Respect Existing Headers' 로 바꾸는 것(대시보드 권한 필요).
`2026-08-22` · 국소 — "탈락" 통보에 대한 재작업: 모달을 레퍼런스 실측 없이 머릿속에서
그린 것이 원인(스킬 1단계 위반). radix-ui.com 다이얼로그를 puppeteer 로 실측해
문법을 기준 삼음: **폭 500px·패딩 25px·제목 17px·컨트롤 35px 통일·반지름 6px·
완벽 중앙·헤어라인 링+부드러운 큰 그림자·수직 리듬 15-20px**(shadcn/Stripe 데모는
리액트 이벤트가 안 잡혀 실패, Vercel 은 풀스크린이라 부적합, Framer 는 페이지
전환). 비전 조언도 같은 결론: 오스트ere한 원시 컴포넌트를 통째로 닮지 말고
구조 문법만 취하라.
- 적용(레퍼런스 문법 + 종이 물성): 폭 760→620/600px, 제목 28→22/20px, 결산 숫자
축소, 컨트롤 높이 통일(36-38px), 그림자 0 25px 50px -12px, 백드롭 딤 0.55,
워터마크 7rem→4.5rem·투명도 5%, 수직 리듬 균일화(gap 20px).
- 검증: 로컬 비전 평가 8.5-9/10(예약 사항=워터마크 가독성 → 투명도 하향으로 반영),
48항목 회귀 통과, 프로덕션 v=4 확인(중앙 offX=0, 제목 22px, 스트립 10칸).
- 교훈(design.md 규칙으로 승격): **모달·새 컴포넌트를 만들 때는 반드시 실측
레퍼런스의 수치를 먼저 적는다. 느낌으로 폭·제목 크기를 정하지 않는다.**
`2026-08-22` · 국소 — "변화량이 작다, 근본적으로 뜯어고쳐라" 통보: 스프레드(펼침
장부) 구조로 전면 재구성. 모달을 "작은 상자"가 아니라 **앱 껍데기의 축소판**으로 —
왼쪽 250px 표지판(가온=흑연 #24272b, 두레=남색 #1e3355)에 도장·이름·결산 행·물마크,
오른쪽 종이 내용판(스크롤은 여기만). Linear/Notion 피크 다이얼로그의 분할 문법.
- 채점 모달은 단순 중앙판 유지(내용이 짧다). 모바일(≤680px)에서 표지판은 가로 띠로
접히고 통계는 물러난다.
- **"안 바뀐다"의 두 번째 범인 발견**: 로컬 3000번 서버가 옛 dist 를 서빙 중이었다
(netstat 에 LAN 다른 기기의 ESTABLISHED 확인 — 사용자가 옛 빌드를 보고 있었다).
서버를 public(소스 실시간)으로 재가동. 로컬 검증은 서빙 디렉터리부터 확인한다.
- 검증: 비전 9/10 두 건("Linear/Notion 피크와 대등"), 48항목 회귀 통과, 프로덕션
v=5(mcover 7곳) 확인.
## 2026-08-22 — 관리 앱 2종: shadcn 프로덕션 콘솔 문법(v6→v7)
- **왼쪽 상단 모달 버그 3범인** 재확인: `* { margin: 0 }` 리셋이 `dialog`의 UA `margin: auto`(가운데 정렬)를 지운다. `.modal { margin: auto }` 없으면 모달은 (0,0)에 붙는다.
- **overflow-x: auto의 반대축 함정**: 한 축만 auto/scroll로 지정하면 다른 축의 visible은 계산값 auto가 된다. 여기에 `margin-bottom: -1px`+보더 트릭이 더해지면 콘텐츠가 1px 넘어 **세로 스크롤이 생긴다**(Windows 비오버레이 스크롤바는 바로 보인다). 규칙: `overflow-x: auto`를 쓰면 `overflow-y: hidden`을 명시하고, 음수 마진+보더 트릭 대신 border-box 높이로 해결한다. 탭 스트립은 `scrollbar-width: none`까지(스와이프는 유지).
- **찌꺼기 규칙 감사**: 그리드 구조를 바꿀 때 이전 구조용 선택자(`.panel:last-child { grid-column: 1/-1 }` 등)가 남아 새 배치를 엉망으로 만든다. 레이아웃 규칙을 고치면 같은 선택자 범위의 형제 규칙을 전부 읽는다.
- **빈칸은 데이터로 메운다**: 대시보드 2열 그리드에서 우측 2행이 비었던 문제 — 패널을 `grid-row: span 2`로 늘리고 차트 아래 요일별 결산 표(출석/지각/결석/출석률+합계)를 넣어 해결. 장식이 아니라 프로덕션 정보(카드 부정보, 진행 중 배지, 미응시 경고)로 밀도를 올린다.
- 캐시 운칙 준수: CSS 고칠 때마다 `main.css?v=N` 증가 — 현재 v7.
## 2026-08-22 — LMS 필수 요소 조사 반영(v9)
- 웹 검색으로 LMS 필수 기능을 조사해 가온에 없던 다섯 가지를 추가했다: **알림 센터**(탑바 종 + 드롭다운, 읽음 처리, 바깥 클릭 닫힘), **공지사항 뷰**(핀 고정 · 카테고리 칩 · 읽은 사람 수 · 아코디언 본문), **수업 진도**(과목별 차시 진행 바), **평가 유형**(과제/퀴즈/시험 칩 + 자동 채점 상태), **출결 사유·학부모 알림톡 요약**(병결/무단/보강/발송 수). 근거: 학원 LMS는 출결 알림(알림톡)·학부모 소통·보강이 일반 LMS와 구별되는 핵심 기능이라는 조사(웹헤즈, 출결톡 등).
- 폰트 라이선스 기록(typography.md §7 의무화): 두 앱 모두 본문 Pretendard Variable(SIL OFL, 웹 임베딩·서브셋 가능, 동적 서브셋 CDN) / 모노 JetBrains Mono(OFL). 폴백은 -apple-system·system-ui, 수치 위치엔 tabular-nums 적용.
- heredoc 잘림 재발: 긴 HTML 패치는 bash heredoc 대신 .mjs 패치 파일(Write 도구)로 쓴 뒤 실행한다 — 이미 두 번째 사고다.
## 2026-08-22 — 전 뷰 프로덕션 밀도(v10)
- "대시보드만 고쳤다"는 지적이 맞았다 — 뷰별 요약 스트립(statline) 컴포넌트를 만들어 아홉 뷰 전부에 같은 문법을 적용했다. **부분 개선은 완성이 아니다. 뷰 하나를 고치면 전 뷰를 같은 기준으로 훑는다.**
- 뷰별 추가: 수강생(학년·보호자 열 + 실계산 요약 + 모달 보호자·알림톡 이력), 출석부(반·주 기준 실계산 요약 + 일별 합계 tfoot — 반 전환 시 재계산), 과제·성적(채점 진행 요약 + 과목 필터), 자료실(크기·조회 수 + 요약), 학사 일정(유형색 배지 4종 + 범례 + D-day + 월 요약), 상담(요약 + 예정 상담 3건 + 알림톡 상태), 리포트(학기 요약 카드 4장 실계산), 공지(분류 필터).
- **감사 스크립트도 제품이다**: 검사 추가 시 셀렉터 다중 개수는 반드시 $$eval — $eval 은 첫 요소만 돌려줘 undefined.length 가 된다. 뷰 루프 순서가 바뀌면 '마지막 뷰' 전제 검사도 함께 바꾼다.
- 표에 열을 더하면 그 표가 .table-wrap 안에 있는지 다시 본다 — 자료실이 래퍼 밖이라 390px +104px 넘침이 재발했다.
## 2026-08-22 — 원론 조사 기반 전면 개정(v11): 타이포그래피·UX 방법론·한글 폰트
- 14개 웹검색·출처 110여 개를 정리한 research/design-principles-v11.md(105개 항목)를 기준으로 두 앱을 개정했다. 핵심 적용: **볼드 금지·수치 웨이트 원칙**(700 제거, UA strong/th 볼드를 600/500으로 재정의, font-synthesis-weight: none), **WCAG 대비**(보조 텍스트 --faint #a1a1aa#6e6e77, 2.6:1→5.05:1), **한글 자간 0 원칙**(h2·h3의 -0.02em 제거, 큰 제목만 -0.01em), **행길이 제한**(공지 본문 68ch), **터치 대상**(coarse 포인터에서 출석 칸 40px).
- **폰트 전략(각주 한 문장)**: 가온 = Noto Serif KR 600(학적부 서류의 명조 결, 제목·표지만) + Pretendard Variable 본문. 두레 = IBM Plex Sans KR 본문(대학·기술 문서의 중성 목소리) + Noto Serif KR 600(공문·신문 결, 마스트헤드·제목). 모두 SIL OFL 웹임베딩 가능. **정적 웨이트 폰트(Plex)에 650을 요청하면 700으로 올라간다** — 가변 폰트에만 500 대역 중간값을 쓴다.
- **대형 사고: CSS 닫는 중괄호 하나를 잃어 규칙 절반이 죽은 채 배포될 뻔**. 원인은 문자열 교체 패치가 닫는 } 를 삼킴. CSS 중첩 파싱 덕에 .tabs 하위 규칙은 살아 겉보기 정상이었고, .tabs 밖 규칙(표 헤더 등)만 죽어 UA th 볼드로 증상이 드러났다. **교훈: CSS를 고치면 중괄호 균형 검사를 감사에 넣는다** — 감사 도구에 brace-depth 체크 추가.
## 2026-08-22 — 모바일 눈 검수와 갸행 수습(v12/v13)
- "모바일은 눈으로 보라"는 지적이 맞았다 — 오버플로 수치만 보고 390px 실제 렌더를 본 적이 없었다. **390px 전 뷰 스크린샷 → 시각 검수 + Range.getClientRects 기반 줄바꿈 계측**을 병행했다.
- 갸행 탐지기의 함정 두 개: ① 같은 줄의 strong/본문 서로 다른 폰트 메트릭이 top 을 12px 차이내 별도 줄로 오탐 → **top 묶음에 ±5px 허용오차 + 높이 교차검증**. ② 비전 검수는 작은 글자에서 환각(안 끊긴 시간을 끊겼다고 보고, 안 늘어난 카드를 늘어났다고 보고) → **비전은 끊김 '지점' 평가에만 쓰고, 여부는 계측으로 확정**한다.
- 적은 수정: 라벨·본문 전역 `word-break: keep-all; overflow-wrap: anywhere; text-wrap: pretty`(한글은 어절에서만 끊고 외톨이 줄 방지), 마감 임박·예정 상담 행은 ≤680px 에서 세로 스택, 공지 행 ≤680px 1열(제목 전폭), tfoot 라벨 nowrap, 두레 과목행 부정보는 **세그먼트(코드·교수·시간·강의실)별 nowrap `<i>`** — 시간 값 "10:0011:30" 이 중간에 끊히는 것을 원천 차단.
- 검증 함정: 같은 세션에서 CSS 를 고치고 곧바로 찍으면 헤드리스가 옛 파일을 볼 수 있다 — **검증 직전 setCacheEnabled(false) 와 ?v= 범프를 같이** 걸고 '실제 로드된 링크 버전'을 계측값에 함께 찍는다.
- 두레에 가온과 같은 뷰 밀도 기준 적용(관심 과목: 요약 스트립+계열 필터+학점 초과 사전 경고, 신청 내역·이수 현황·총람·공지: 실계산 요약 스트립) — "고치면 전체를 같은 기준으로" 재적용.
## 2026-08-22 — 5라운드 정밀 검수(v13/v14)
- **R1 데스크톱 밀도**: 두레 관심(402px)·이수(435px) 등 절반빈 뷰 발견 → 관심 빈 상태에 여석 임박 추천 3건, 이수 표에 구분별 진행 바+총 이수율 72% 바, 신청 내역에 학점 게이지+규칙 안내, 가온 리포트에 진행 차시 열.
- **R2 극한 폭·상태**: 320px 전 뷰 오버플로 0, 모바일 모달 1열·닫기 접근·뷰포트 내, 실제 Tab 키 포커스 링 전 요소 2px solid (스크립트 focus 는 focus-visible 미적용이 올바름 — 키보드로 재검), 신청→게이지 실시간 갱신.
- **R3 플로우**: 빈 검색 안내·특수문자 무결·필터+검색 조합·출석 순환 재계산·채점 갱신·메모→상담 동기화·관심 담기→추천 숨김·17학점+2 시도 → 신청 버튼 disabled (학점 초과 차단).
- **R4 대비·타이포 전량 스캔**: 전 요소 fg/bg 블렌드 대비 계산 → 28건 미달 전부 --muted-foreground #71717a(4.4:1) 탓 → #64646e 로 교정(5.2:1+), 달력 '오늘' 숫자 배경 #059669#047857(5.4:1). 웨이트 5종(450·650·550 포함) → **400/500/600 3종**으로 정규화. tabular 누락 0.
- **R5 데이터 정합성**: 대시보드 "재원 26명"↔명단 12명, 오늘의 수업 8/8·6/9·9/9↔과목별 재학 3·3·2·3명 등 세계관 모순 발견 → 12명 기준으로 숫자 체계 전면 재정합(헤드라인·차트·결산표·마감·과제 표·알림·출결 칩·공지 읽은 사람).
- **교훈: 데모의 숫자도 하나의 세계관을 이룬다** — 한 화면의 통계를 고치면 다른 화면의 같은 사실도 함께 고친다. 대비 스캐너(배경 블렌드 계산)와 정합 스윕은 감사 도구에 남길 가치가 있다.
## 2026-08-22 — 모바일 개행 재보고: 기기 글꼴 확대 시나리오(v14/v15)
- "모바일에서 개행 문제가 여전히 보인다"는 재보고. 프로덕션 실측(390/375/360)에선 h1 이 모두 1줄 — 문제는 **기기 글꼴 확대(안드로이드 큰 글꼴 1.15~1.3×)** 재현 시에 나타났다: h1 이 2줄로 부서지며 외톨이 글자가 생기고, rem 기반 여백까지 커져 제목 칸이 좁아진다.
- **해법 — 제목은 뷰포트 단위**: `@media ≤680px { h1 { font-size: min(1.5rem, 6.4vw) } }`. vw 는 사용자 글꼴 설정을 무시하므로 제목 크기가 고정되고, 본문은 사용자 설정을 존중해 커진다. 제목은 디스플레이 요소이므로 이 예외는 정당하다. `text-wrap: balance` 를 h1(블록) 에 추가 — 예외시 균형 분할. flex 컨테이너(.n-t)의 balance 는 무의미해 제거.
- `-webkit-text-size-adjust: 100%` 방어 추가(iOS 자동 인플레이션 차단).
- **측정 교훈 3연타**: ① flex 컨테이너 안 아이콘과 텍스트의 top 차이가 '가짜 줄'로 집계된다 — 텍스트 노드만 분리 측정할 것. ② 비전은 전사 기반으로만 믿을 것(추측 보고가 2번 환각). ③ page.evaluate 클로저 참조 함정 세 번째 반복 — 측정 함수는 문자열로 페이지에 주입한다.
- 검증: 폰트 스케일 1.0/1.15/1.3/1.45× 에서 h1 1줄 유지(양 앱), 공지 제목은 확대 시 어절 경계 2줄(자연스러움), 감사 ALL PASS.
## 2026-08-22 — 모바일 브랜드 세로 쌓임 결함(v15)
- "학적부 타이틀이 세로로 나온다"는 보고 — 표준 390px 에서도 재현: 모바일 가로 스트립에서 `.brand` 가 flex-shrink 기본값(1)으로 side-nav 에 밀려 **54px·5줄**(글자 하나씩 세로)로 찌그러짐. 스트립 높이도 145px 로 부풀어 있었다.
- 수정: `@media ≤900px { .brand { flex: none; white-space: nowrap } }` — 브랜드는 고정 폭, 내비만 스크롤. 결과: 113px·1줄, 스트립 57px, 글꼴 확대 1.15/1.3× 에도 1줄 유지.
- **교훈: 텍스트가 '세로로 쌓인다'는 보고는 컨테이너가 1글자 폭으로 수축했다는 뜻** — flex/grid 아이템의 shrink 기본값을 의심한다. 방어 검사에 '모든 텍스트 요소의 최소 자식 폭 ≥ 2em' 같은 수축 탐지를 넣을 만하다.
## 2026-08-22 — QA 게이트 구축(v16) — 6계층과 기계적 배포 강제
- 업계 조사(시각회귀·품질게이트·테스트피라미드·크로스브라우저·axe 57%·QA체크리스트)를 대조해 계획 보강 후 전 계층 구축: **L0 정적**(stylelint·html-validate) → **L1 단위**(calc.js 추출, 브라우저 없이 30 경계 테스트) → **L2 불변식**(기능 감사 61 + h1·수축·대비·axe·콘텐츠 정합·스케일×폭 행렬) → **L3 시각 회귀**(기준 30화면 pixelmatch, 0.000%) → **L4 WebKit**(Playwright, 8항목) → **L5 탐색**(키보드 완전 통과 + 여정 3개).
- 게이트가 잡은 실제 결함: CSS 중복 셀렉터 2건, aria-label 오용 26건, dl 안 <p> 구문 위반, role="img" 라벨 소실(내 정리의 부작용까지), 두레 시간표 role="grid" 구문 위반, 두레 #q 포커스 링 — **전부 사용자 보고 이전에 자동으로 발견**.
- **배포는 이제 `npm run verify && build && deploy` 체인으로만** — verify.mjs 실패(exit 1)면 배포 불가. 리포트(tools/verify-report.md)는 수치와 함께 자동 생성.
- 하니스 교훈: ① 스코프 없는 선택자는 숨은 뷰의 같은 속성을 잡는다(대시보드 due-row도 data-assign 사용) — 선택자는 컨테이너 스코프 필수. ② 모달 닫힘 직후 포커스 강탈(비동기 복원) — 뷰 전환+타깃 포커스는 한 evaluate 로, 필요시 settle 후 재포커스. ③ UMD 가드는 module.exports 존재 검사가 아니라 module 존재 검사.
## 2026-08-22 — 스킬 내재화: 자율 검증 폐쇄 루프(v17)
- designpaca 스킬에 폐쇄 루프를 박았다: **핵심 규칙 6 "완료의 정의는 게이트 통과다"**, 5단계 기계 검사 4번째 "design-gate 실행 의무". references/audit-gate.md(사고-검사 매핑·SEO/meta 체크리스트·OS/브라우저 특성·하니스 규칙 7개) + tools/design-gate.mjs(범용 게이트, --init 스캐폴딩, 의존성 미비 SKIP) 동반.
- 도그푸드: 스킬 게이트가 두 앱에서 **og:image·twitter:card 부재, 뷰마다 h1(9·6개)** 를 잡음 → 탭 앱 표준 구조로 교정(브랜드=페이지 h1 sr-only, 뷰 제목=h2.v-h — 명시적 클래스, 선택자 추측 금지), OG 메타+og.png 추가, v17 배포(게이트 체인).
- **대형 사고와 자가 포착**: 패치 스크립트의 튜플 실수(`([f,t])[1]`)가 'h'→'1'·'c'→'o' 문자 치환을 일으켜 main.css 2개·invariants.mjs 가 손상 — **시각 회귀(13.9% diff)가 즉시 포착**, dist v16 빌드에서 복구. 교훈: ① 문자열 치환 패치는 사후 검증(변경 후 포함 여부 확인) 필수 — og 삽입도 조용히 실패한 뒤 재삽입했다. ② 패턴 매칭 대신 명시적 클래스(v-h)·명시적 앵커(</title>)를 쓴다. ③ 배포 아티팩트(dist)는 복구 원본이다.
- 기준 화면 갱신 1회(h1→h2 의도 변경, 1~2px 라인박스 시프트) — 방법론대로 '검증된 의도 변경'으로 기록 후 갱신.
## 2026-08-22 — 시간표 시간 라벨 깨짐 수리(v18)
- 사용자 보고 "왼쪽 타임 라벨 깨짐" — 계측으로 확정: 라벨 top 간격이 22·194·43·22 불규칙, 일부 라벨 폭 78px(요일 열에 배치). **원인: span-2 셀이 섞인 그리드 자동 배치(auto-placement)의 취약성** — 시간 라벨(1행)과 셀(2행 스팬)이 교차하며 희소 배치 커서가 열1의 빈 행을 잡아 라벨이 엉뚱한 행·열로 흩어졌다. 세션 초반 의심 목록에 있던 "--rows 미사용 문제"의 실체이기도 하다.
- 수리: buildAxis 를 **전 요소 명시적 격자 좌표**(corner 1/1, day 1/(d+1), hour (h-9)*2+2행/1열, cell 행 span2/열 d+1)로 재작성 — 자동 배치 의존 제거. 검증: 9라벨 전부 1열(40px 균일), 간격 Δ43~44px 등차(2행×21.6px 설계 정합). 시각 diff 0.38%(라벨 재배치 = 의도) 확인 후 기준 갱신, 게이트 통과, v18 배포.
- **교훈: 혼합 span 의 그리드는 자동 배치를 믿지 않는다** — 좌표를 명시하라. 격자 레이아웃 검사에는 '라벨 등간격(Δ 균일)' 불변식을 넣을 가치가 있다(스킬 하니스 규칙 후보).

View file

@ -1,90 +0,0 @@
# 게이트 리포트 — 2026-08-22 12:20
**전 항목 통과 — 배포 가능**
```
PASS L0 정적 — SKIP — checks.static=false
PASS gaon-lms SEO/meta — title 18자·desc 74자·OG✓·alt 0결
PASS gaon-lms/dashboard 오버플로 — 320/375/390/768/1440
PASS gaon-lms/dashboard h1 1줄
PASS gaon-lms/dashboard 수축 없음
PASS gaon-lms/dashboard 리듬·트랙
PASS gaon-lms/dashboard 대비 AA
PASS gaon-lms/students 오버플로 — 320/375/390/768/1440
PASS gaon-lms/students h1 — SKIP — 요소 없음
PASS gaon-lms/students 수축 없음
PASS gaon-lms/students 리듬·트랙
PASS gaon-lms/students 대비 AA
PASS gaon-lms/attendance 오버플로 — 320/375/390/768/1440
PASS gaon-lms/attendance h1 — SKIP — 요소 없음
PASS gaon-lms/attendance 수축 없음
PASS gaon-lms/attendance 리듬·트랙
PASS gaon-lms/attendance 대비 AA
PASS gaon-lms/grades 오버플로 — 320/375/390/768/1440
PASS gaon-lms/grades h1 — SKIP — 요소 없음
PASS gaon-lms/grades 수축 없음
PASS gaon-lms/grades 리듬·트랙
PASS gaon-lms/grades 대비 AA
PASS gaon-lms/files 오버플로 — 320/375/390/768/1440
PASS gaon-lms/files h1 — SKIP — 요소 없음
PASS gaon-lms/files 수축 없음
PASS gaon-lms/files 리듬·트랙
PASS gaon-lms/files 대비 AA
PASS gaon-lms/calendar 오버플로 — 320/375/390/768/1440
PASS gaon-lms/calendar h1 — SKIP — 요소 없음
PASS gaon-lms/calendar 수축 없음
PASS gaon-lms/calendar 리듬·트랙
PASS gaon-lms/calendar 대비 AA
PASS gaon-lms/counsel 오버플로 — 320/375/390/768/1440
PASS gaon-lms/counsel h1 — SKIP — 요소 없음
PASS gaon-lms/counsel 수축 없음
PASS gaon-lms/counsel 리듬·트랙
PASS gaon-lms/counsel 대비 AA
PASS gaon-lms/report 오버플로 — 320/375/390/768/1440
PASS gaon-lms/report h1 — SKIP — 요소 없음
PASS gaon-lms/report 수축 없음
PASS gaon-lms/report 리듬·트랙
PASS gaon-lms/report 대비 AA
PASS gaon-lms/notice 오버플로 — 320/375/390/768/1440
PASS gaon-lms/notice h1 — SKIP — 요소 없음
PASS gaon-lms/notice 수축 없음
PASS gaon-lms/notice 리듬·트랙
PASS gaon-lms/notice 대비 AA
PASS gaon-lms 스케일×폭 h1 — scales 1/1.3
PASS gaon-lms 브랜드 1줄
PASS dure-enrollment SEO/meta — title 15자·desc 79자·OG✓·alt 0결
PASS dure-enrollment/catalog 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/catalog h1 1줄
PASS dure-enrollment/catalog 수축 없음
PASS dure-enrollment/catalog 리듬·트랙
PASS dure-enrollment/catalog 대비 AA
PASS dure-enrollment/starred 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/starred h1 — SKIP — 요소 없음
PASS dure-enrollment/starred 수축 없음
PASS dure-enrollment/starred 리듬·트랙
PASS dure-enrollment/starred 대비 AA
PASS dure-enrollment/enrollments 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/enrollments h1 — SKIP — 요소 없음
PASS dure-enrollment/enrollments 수축 없음
PASS dure-enrollment/enrollments 리듬·트랙
PASS dure-enrollment/enrollments 대비 AA
PASS dure-enrollment/credits 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/credits h1 — SKIP — 요소 없음
PASS dure-enrollment/credits 수축 없음
PASS dure-enrollment/credits 리듬·트랙
PASS dure-enrollment/credits 대비 AA
PASS dure-enrollment/archive 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/archive h1 — SKIP — 요소 없음
PASS dure-enrollment/archive 수축 없음
PASS dure-enrollment/archive 리듬·트랙
PASS dure-enrollment/archive 대비 AA
PASS dure-enrollment/notices 오버플로 — 320/375/390/768/1440
PASS dure-enrollment/notices h1 — SKIP — 요소 없음
PASS dure-enrollment/notices 수축 없음
PASS dure-enrollment/notices 리듬·트랙
PASS dure-enrollment/notices 대비 AA
PASS dure-enrollment 스케일×폭 h1 — scales 1/1.3
PASS dure-enrollment 브랜드 1줄
PASS L3 시각 회귀 — SKIP — checks.visual=false
PASS L4 WebKit — SKIP — checks.webkit=false
```

View file

@ -1,65 +0,0 @@
{
"pages": [
{
"name": "gaon-lms",
"path": "public/work/gaon-lms/index.html",
"viewAttribute": "data-view",
"views": [
"dashboard",
"students",
"attendance",
"grades",
"files",
"calendar",
"counsel",
"report",
"notice"
],
"h1": "h2.v-h",
"brand": ".brand"
},
{
"name": "dure-enrollment",
"path": "public/work/dure-enrollment/index.html",
"viewAttribute": "data-view",
"views": [
"catalog",
"starred",
"enrollments",
"credits",
"archive",
"notices"
],
"h1": "h2.v-h",
"brand": ".brand"
}
],
"widths": [
320,
375,
390,
768,
1440
],
"fontScales": [
1,
1.3
],
"thresholds": {
"contrastNormal": 4.5,
"contrastLarge": 3,
"visualDiffPct": 0.1,
"stackLines": 3
},
"checks": {
"meta": true,
"contrast": true,
"stack": true,
"wrap": true,
"scaleMatrix": true,
"static": false,
"visual": false,
"webkit": false
},
"chromePath": null
}

File diff suppressed because it is too large Load diff

View file

@ -7,21 +7,11 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"typecheck": "echo \"(사이트는 build 로 검증한다)\"", "typecheck": "echo \"(사이트는 build 로 검증한다)\""
"verify": "node tools/verify.mjs"
}, },
"dependencies": { "dependencies": {
"astro": "^5.1.1", "astro": "^5.1.1",
"sharp": "^0.35.3", "sharp": "^0.35.3",
"three": "^0.185.1" "three": "^0.185.1"
},
"devDependencies": {
"axe-core": "^4.13.0",
"html-validate": "^10.11.3",
"pixelmatch": "^7.2.0",
"playwright": "^1.62.1",
"pngjs": "^7.0.0",
"puppeteer-core": "^25.8.0",
"stylelint": "^17.14.1"
} }
} }

View file

@ -1,5 +0,0 @@
# 워크 페이지(HTML·CSS)는 즉시 갱신이 보여야 한다 — 데모 갤러리의 생명.
# HTML 은 CF Pages 기본이 max-age=0 이지만 CSS 가 4시간 캐시되어
# 새 HTML + 옛 CSS 조합이 사용자에게 그려진 적이 있다(2026-08-22 실측).
/work/*
Cache-Control: public, max-age=0, must-revalidate

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View file

@ -1,210 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>비색의 침묵 · 고려청자 특별전</title>
<meta
name="description"
content="천 년의 비취색, 흙과 불이 빚은 침묵의 곡선. 고려청자 유약의 빙렬과 상감을 세 개의 방에서 감상하는 특별전. 2026년 가을, 서울."
/>
<meta property="og:title" content="비색의 침묵 · 고려청자 특별전" />
<meta
property="og:description"
content="유약의 빙렬과 상감 문양을 세 개의 방에서 감상하는 특별전."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#121514" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23121514'/%3E%3Cpath d='M11 6h10c-1 3-1 5 0 7 2 3 3 5 3 8 0 4-3 7-8 7s-8-3-8-7c0-3 1-5 3-8 1-2 1-4 0-7z' fill='none' stroke='%236FA08F' stroke-width='1.5'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">비색의 침묵</a>
<nav class="nav" aria-label="섹션">
<a href="#room-1">빙렬의 방</a>
<a href="#room-2">상감의 방</a>
<a href="#visit">관람</a>
</nav>
</div>
</header>
<main id="main">
<!--
첫 방 — 암실. 전시 포스터가 아니라 첫 유물 하나.
명암이 곧 구조다: 사진이 어두울수록 유약이 가까워진다.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<figure class="hero-figure reveal">
<img
src="./assets/ewer-dark.webp"
alt="암실 조명 아래 어두운 받침대 위에 놓인 고려청자 주자의 비취색 실루엣"
width="960"
height="1280"
loading="eager"
fetchpriority="high"
/>
<figcaption>
<span>청자 상감운학문 주자 · 12세기</span>
<span>호암미술관 소장</span>
</figcaption>
</figure>
<div class="hero-text">
<p class="hero-kicker reveal" style="--delay: 100ms">
특별전 · 2026 가을
</p>
<h1 id="hero-h" class="reveal" style="--delay: 160ms">
천 년의 비색은<br />말이 없다
</h1>
<p class="lead reveal" style="--delay: 240ms">
비색(翡色)은 말로 설명되는 색이 아닙니다. 1,280°C의 환원 불에서만
나오는, 푸르름과 푸름이 아닌 사이의 색. 이 전시는 그 색을 설명하지
않고 세 개의 암실에 놓아 두었습니다.
</p>
<div class="hero-actions reveal" style="--delay: 300ms">
<a class="btn" href="#visit">도슨트 예약</a>
<p class="hero-note">9월 5일 11월 22일 · 국립중앙박물관 기획전시실</p>
</div>
</div>
</div>
</section>
<!--
첫 번째 방 — 빙렬. 유약이 식으며 스스로 그은 그림.
풀블리드 판: 페이지에서 가장 큰 면적을 질감에 내어준다.
-->
<section id="room-1" class="section plate" aria-labelledby="room1-h">
<div class="wrap plate-head">
<p class="room-no reveal">제1실</p>
<h2 id="room1-h" class="reveal" style="--delay: 60ms">빙렬 — 유약이 남긴 지도</h2>
<p class="lead reveal" style="--delay: 120ms">
가마에서 꺼내진 청자는 식으면서 유약이 갈라집니다. 흙과 유약이 줄어드는
속도가 다르기 때문입니다. 그 균열은 결함이 아니라, 그릇마다 단 한 번만
그려지는 지도입니다.
</p>
</div>
<figure class="plate-figure reveal" style="--delay: 160ms">
<img
src="./assets/crazing.webp"
alt="청자 유약 표면의 빙렬 — 미세한 균열망이 비취색 유약 위에 퍼져 있다"
width="1600"
height="1200"
loading="lazy"
/>
<figcaption class="wrap">
빙렬 확대 · 12세기 청자 표면 · 측광
</figcaption>
</figure>
</section>
<!--
두 번째 방 — 상감. 새기고 채우는 글자 없는 문장.
사진은 절반만. 나머지 절반은 문양의 문법을 텍스트로.
-->
<section id="room-2" class="section" aria-labelledby="room2-h">
<div class="wrap two">
<div class="two-text">
<p class="room-no reveal">제2실</p>
<h2 id="room2-h" class="reveal" style="--delay: 60ms">
상감 — 새기고,<br />흙으로 채우다
</h2>
<p class="reveal" style="--delay: 120ms">
상감은 그림을 그리는 게 아니라 파내는 기술입니다. 흙 그릇에 무늬를
새기고, 그 홈에 백토와 자토를 문질러 채운 뒤 유약을 입힙니다. 불에
들어가기 전까지는 무늬가 보이지 않습니다 — 가마를 열기 전까지 장인도
결과를 알 수 없었습니다.
</p>
<p class="muted reveal" style="--delay: 180ms">
제2실에는 구름·학·버드나무의 세 가지 문양만을 전시합니다. 상감청자의
문양은 자연의 모방이 아니라, 보이지 않는 것(구름, 바람, 계절)을
보이는 것으로 옮긴 문법입니다.
</p>
</div>
<figure class="two-photo reveal" style="--delay: 100ms">
<img
src="./assets/inlay.webp"
alt="청자 상감 문양 접사 — 흑백 상감 획이 비취 유약 위에 새겨져 있다"
width="1100"
height="825"
loading="lazy"
/>
<figcaption>상감 모란당초문 · 접사</figcaption>
</figure>
</div>
</section>
<!--
관람 — 예약과 안내. 마지막 방은 서류다.
-->
<section id="visit" class="section band" aria-labelledby="visit-h">
<div class="wrap visit-grid">
<div>
<p class="room-no reveal">안내</p>
<h2 id="visit-h" class="reveal" style="--delay: 60ms">세 개의 방, 예약제</h2>
<p class="reveal" style="--delay: 120ms">
암실 조명 때문에 회당 인원을 30명으로 제한합니다. 도슨트는 하루 세 번,
유약과 가마 이야기만 합니다.
</p>
</div>
<dl class="visit-dl reveal" style="--delay: 160ms">
<div>
<dt>기간</dt>
<dd>2026년 9월 5일 11월 22일 · 월요일 휴관</dd>
</div>
<div>
<dt>시간</dt>
<dd>10:0018:00 · 금·토 21:00까지</dd>
</div>
<div>
<dt>도슨트</dt>
<dd>11:00 / 14:00 / 16:00 · 회당 40분</dd>
</div>
<div>
<dt>예약</dt>
<dd><a class="link" href="#visit">국립중앙박물관 예약 시스템</a></dd>
</div>
</dl>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>비색의 침묵 · 고려청자 특별전</p>
<p class="muted">국립중앙박물관 기획전시실 · 서울 용산</p>
</div>
</footer>
<script>
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,368 +0,0 @@
@import url("./tokens.css");
/* ==========================================================================
비색의 침묵 editorial-archival. 암실의 개의 .
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2) {
font-family: var(--font-display);
font-weight: 500;
line-height: var(--leading-tight);
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
:where(p, li, dd, dt, th, td, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.lead {
color: var(--ink-muted);
max-width: 32em;
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
.link {
color: var(--accent);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color var(--dur-quick) var(--ease-soft);
}
.link:hover {
border-color: var(--accent);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
padding-block: 2px;
border-bottom: 1px solid transparent;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--accent);
}
/* ── 첫 방(히어로) ────────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-7);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
align-items: end;
}
.hero-figure img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.hero-figure figcaption {
display: flex;
justify-content: space-between;
gap: var(--space-3);
margin-top: var(--space-2);
font-size: var(--step--1);
color: var(--ink-faint);
}
.hero-kicker {
font-size: var(--step--1);
color: var(--accent);
letter-spacing: 0.12em;
margin-bottom: var(--space-4);
}
.hero-text .lead {
margin-top: var(--space-4);
}
.hero-actions {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.hero-note {
font-size: var(--step--1);
color: var(--ink-faint);
}
.btn {
display: inline-block;
background: var(--ink);
color: var(--surface);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--ink-bright);
}
/* ── 방 번호 ──────────────────────────────────────────────────────────── */
.room-no {
font-size: var(--step--1);
color: var(--accent-dim);
letter-spacing: 0.4em;
margin-bottom: var(--space-3);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 제1실 — 풀블리드 판 ──────────────────────────────────────────────── */
.plate-head {
max-width: 40em;
}
.plate-head h2 {
margin-top: var(--space-1);
}
.plate-head .lead {
margin-top: var(--space-4);
}
.plate-figure {
margin-top: var(--space-6);
}
.plate-figure img {
display: block;
width: 100%;
height: auto;
}
.plate-figure figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 제2실 — 2단 ──────────────────────────────────────────────────────── */
.two {
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.two-text h2 {
margin-top: var(--space-1);
}
.two-text p + p {
margin-top: var(--space-4);
}
.two-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.two-photo figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 안내 ─────────────────────────────────────────────────────────────── */
.visit-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.visit-grid h2 {
margin-top: var(--space-1);
}
.visit-dl {
display: grid;
gap: var(--space-3);
}
.visit-dl dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.visit-dl dd {
margin-top: 2px;
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-5);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 14px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-grid,
.two,
.visit-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero-grid {
align-items: start;
}
.hero-figure {
max-width: 26rem;
}
.hero,
.section {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
}

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

View file

@ -1,289 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>한성활자연구소 · 본문용 한글 활자</title>
<meta
name="description"
content="획의 장력과 여백의 비례. 본문 조판을 위해 설계한 한글 활자 두 종과 조판 시험대. 1000 UPM, 측정 가능한 자간과 행간."
/>
<meta property="og:title" content="한성활자연구소 · 본문용 한글 활자" />
<meta
property="og:description"
content="본문 조판을 위해 설계한 한글 활자와 조판 시험대."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#F5F1E8" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F5F1E8'/%3E%3Crect x='8' y='8' width='16' height='16' fill='%23A63D2F'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">한성활자연구소</a>
<nav class="nav" aria-label="섹션">
<a href="#typefaces">활자</a>
<a href="#bench">시험대</a>
<a href="#case">활자 케이스</a>
<a href="#order">구입</a>
</nav>
</div>
</header>
<main id="main">
<!--
스페시멘 히어로 — 파운드리의 첫 화면은 설명이 아니라 글자다.
'한' 한 글자를 페이지에서 가장 크게, 인주각과 함께.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap">
<p class="hero-kicker reveal">본문용 한글 활자 · 1000 UPM · 2018년 창립</p>
<h1 id="hero-h" class="hero-glyph reveal" style="--delay: 60ms" lang="ko" aria-label="한"></h1>
<div class="hero-side">
<p class="hero-statement">
이 글자가 어디에 쓰이는지는 이 페이지 전체가 증명합니다. 우리는
제목용 활자를 만들지 않습니다 — 오래 읽어야 하는 본문의 활자만
만듭니다. 획의 장력, 자간의 균형, 행간의 숨을 그 단위(UPM)로
측정합니다.
</p>
<dl class="hero-facts reveal" style="--delay: 120ms">
<div>
<dt>서체</dt>
<dd>한성본문 · 한성제목</dd>
</div>
<div>
<dt>글자 수</dt>
<dd>11,172자 완성</dd>
</div>
<div>
<dt>단위</dt>
<dd>1000 UPM · 가변 웨이트 3종</dd>
</div>
</dl>
</div>
</div>
</section>
<!--
활자 두 종 — 스페시멘은 실제 문장으로. Lorem ipsum 은 활자시험의 반대다.
-->
<section id="typefaces" class="section" aria-labelledby="typefaces-h">
<div class="wrap">
<h2 id="typefaces-h">활자 두 종</h2>
<article class="typeface reveal">
<header class="typeface-head">
<h3>한성본문</h3>
<p class="muted">본문용 · 웨이트 400 · 16px에서 행간 1.8</p>
</header>
<p class="specimen" style="font-family: 'MaruBuri', serif">
아침 안개가 걷히기 전에 도랑물 소리가 먼저 들렸다. 글자는 종이 위에서
소리를 내지 않지만, 잘 짜인 활자는 소리 내어 읽게 만든다. 획이 닿는
각도와 글자가 서로 미는 힘을 천 번쯤 고쳐 썼다. 그 결과 이 문장이
밤늦게도 읽히는 것이다.
</p>
</article>
<article class="typeface reveal" style="--delay: 80ms">
<header class="typeface-head">
<h3>한성제목</h3>
<p class="muted">제목·표제용 · 웨이트 600 · 큰 짝에서 벌어짐 0</p>
</header>
<p class="specimen specimen-display" style="font-family: 'MaruBuri', serif">
활자는 붓의 속도를 기억한다
</p>
</article>
</div>
</section>
<!--
조판 시험대 — 이 페이지의 리스크이자 유일한 인터랙션.
슬라이더 세 개가 실제 본문에 즉시 반영된다. 라벨은 한국어, 값은 숫자로.
-->
<section id="bench" class="section band" aria-labelledby="bench-h">
<div class="wrap">
<h2 id="bench-h">조판 시험대</h2>
<p class="muted bench-lead">
자간과 행간, 글자 크기가 가독성을 어떻게 바꾸는지 직접 움직여 보세요.
아래 값은 권장값이 아니라 출발점입니다 — 용지 규격과 본문 길이에 따라
정답은 달라집니다.
</p>
<div class="bench">
<form class="bench-controls" aria-label="조판 설정">
<div class="control">
<label for="ctl-size">글자 크기 <output id="out-size" for="ctl-size">17</output></label>
<input type="range" id="ctl-size" min="13" max="26" step="1" value="17" />
</div>
<div class="control">
<label for="ctl-tracking">자간 <output id="out-tracking" for="ctl-tracking">0</output></label>
<input type="range" id="ctl-tracking" min="-5" max="10" step="1" value="0" />
</div>
<div class="control">
<label for="ctl-leading">행간 <output id="out-leading" for="ctl-leading">1.8</output></label>
<input type="range" id="ctl-leading" min="1.4" max="2.4" step="0.1" value="1.8" />
</div>
<button type="button" class="btn-reset" id="ctl-reset">권장값으로</button>
</form>
<div
class="bench-sample"
id="bench-sample"
style="font-family: 'MaruBuri', serif"
aria-live="polite"
>
활자의 역사는 글자를 찍는 기계의 역사가 아니라, 글자를 읽는 눈의
역사였다. 활자소의 식자공들은 하루에 만 글자를 짜면서 어느 글꼴이
눈을 덜 아프게 하는지 알고 있었다. 그 지식은 문서로 남지 않았고
손끝으로만 전해졌다. 이 시험대는 그 손끝의 판단을 누구나 해볼 수
있는 눈금으로 옮긴 것이다. 자간을 0에서 5로 옮겨 보라 — 문장이
늘어지는 것이 아니라 숨이 가빠지는 것을 느낄 것이다.
</div>
</div>
</div>
</section>
<!--
활자 케이스 — 연구소의 물성. 금속 활자와 인주.
-->
<section id="case" class="section" aria-labelledby="case-h">
<div class="wrap case-grid">
<figure class="case-photo reveal">
<img
src="./assets/metaltype.webp"
alt="나무 활자 케이스에 골라 담긴 금속 활자들 — 활자면이 위에서 빛을 받아 결이 보인다"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>연구소 2층 · 금속 활자 케이스</figcaption>
</figure>
<div class="case-text">
<h2 id="case-h">금속에서 시작해<br />화면으로 끝난다</h2>
<p>
한성본문의 자간 규칙은 금속 활자의 '옆면 여백'에서 왔습니다. 납활자는
글자 바로 옆에 살이 붙어 있어서, 찍히는 글자 사이는 언제나 일정했고
그 일정함이 읽는 리듬이었습니다. 화면용 폰트는 그 살을 지울 수
있지만, 우리는 지우지 않았습니다.
</p>
<figure class="case-photo-b reveal" style="--delay: 80ms">
<img
src="./assets/inkstone.webp"
alt="연구한 먹이 고인 연적과 옆에 놓인 인주 연판"
width="1100"
height="825"
loading="lazy"
/>
<figcaption>먹을 갈고 인주를 찍는 자리 — 시험 인쇄 전</figcaption>
</figure>
</div>
</div>
</section>
<!--
구입 — 라이선스는 조판 단위로. 조용한 두 줄.
-->
<section id="order" class="section" aria-labelledby="order-h">
<div class="wrap order-grid">
<div>
<h2 id="order-h">구입</h2>
<p class="muted">
웹·출판·임베드 세 가지 라이선스만 있습니다. 사용 수를 세지 않습니다 —
조판되는 단위(도서 권수, 사이트 도메인, 기기)로 셉니다.
</p>
</div>
<div class="order-rows">
<div class="order-row">
<div>
<h3></h3>
<p>도메인당 · 웨이트 전종 · 자체 호스팅 WOFF2</p>
</div>
<p class="order-price">140,000원</p>
</div>
<div class="order-row">
<div>
<h3>출판</h3>
<p>도서 권수당 · 인쇄+전자 동시</p>
</div>
<p class="order-price">220,000원</p>
</div>
</div>
<a class="btn" href="mailto:foundry@hanseong.kr">견적 문의</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>한성활자연구소 · 서울 마포</p>
<p class="muted">foundry@hanseong.kr</p>
</div>
</footer>
<script>
/* 시험대 — 슬라이더 세 개가 본문 스타일을 직접 쓴다. 프레임당 레이아웃 없음. */
const sample = document.getElementById("bench-sample");
const ctl = {
size: document.getElementById("ctl-size"),
tracking: document.getElementById("ctl-tracking"),
leading: document.getElementById("ctl-leading"),
};
const out = {
size: document.getElementById("out-size"),
tracking: document.getElementById("out-tracking"),
leading: document.getElementById("out-leading"),
};
const defaults = { size: 17, tracking: 0, leading: 1.8 };
function apply() {
const px = ctl.size.value + "px";
const em = ctl.tracking.value / 100 + "em";
const lh = ctl.leading.value;
sample.style.fontSize = px;
sample.style.letterSpacing = em;
sample.style.lineHeight = lh;
out.size.textContent = ctl.size.value;
out.tracking.textContent = ctl.tracking.value;
out.leading.textContent = Number(ctl.leading.value).toFixed(1);
}
for (const input of Object.values(ctl)) {
input.addEventListener("input", apply);
}
document.getElementById("ctl-reset").addEventListener("click", () => {
ctl.size.value = defaults.size;
ctl.tracking.value = defaults.tracking;
ctl.leading.value = defaults.leading;
apply();
});
apply();
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,420 +0,0 @@
@import url("./tokens.css");
/* ==========================================================================
한성활자연구소 editorial-archival. 글자가 상품.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2, h3) {
font-family: var(--font-display);
font-weight: 600;
line-height: var(--leading-tight);
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h2 {
font-size: var(--step-2);
}
h3 {
font-family: var(--font-body);
font-weight: 600;
font-size: var(--step-1);
}
:where(p, li, dd, dt, th, td, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 90%, transparent);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
padding-block: 2px;
border-bottom: 1px solid transparent;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--accent);
}
/* ── 스페시멘 히어로 ──────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-7) var(--space-8);
}
.hero-kicker {
font-size: var(--step--1);
color: var(--ink-muted);
letter-spacing: 0.06em;
}
.hero-glyph {
font-size: clamp(11rem, 24vw, 22rem);
line-height: 0.95;
margin-top: var(--space-4);
position: relative;
}
/* 인주각 — 글리프 옆에 찍히는 도장 */
.hero-glyph::after {
content: "";
position: absolute;
right: 0.12em;
bottom: 0.18em;
width: 0.11em;
height: 0.11em;
background: var(--accent);
}
.hero-side {
margin-top: var(--space-5);
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.hero-statement {
max-width: 32em;
}
.hero-facts {
display: grid;
gap: var(--space-3);
border-left: 1px solid var(--line);
padding-left: var(--space-5);
}
.hero-facts dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.hero-facts dd {
margin-top: 2px;
font-weight: 500;
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 활자 두 종 ───────────────────────────────────────────────────────── */
.typeface + .typeface {
margin-top: var(--space-6);
}
.typeface-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
flex-wrap: wrap;
border-top: 1px solid var(--line);
padding-top: var(--space-3);
}
.typeface-head .muted {
font-size: var(--step--1);
}
.specimen {
margin-top: var(--space-4);
font-size: 1.1875rem;
line-height: 1.9;
max-width: 46em;
}
.specimen-display {
font-size: clamp(1.75rem, 1.2rem + 3vw, 3.25rem);
line-height: 1.45;
max-width: 22em;
font-weight: 600;
}
/* ── 조판 시험대 ──────────────────────────────────────────────────────── */
.bench-lead {
max-width: 36em;
margin-top: var(--space-3);
}
.bench {
margin-top: var(--space-5);
display: grid;
grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.bench-controls {
display: grid;
gap: var(--space-4);
}
.control label {
display: flex;
justify-content: space-between;
font-size: var(--step--1);
color: var(--ink-muted);
}
.control output {
font-variant-numeric: tabular-nums;
color: var(--ink);
}
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.btn-reset {
justify-self: start;
background: none;
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--ink-muted);
font-size: var(--step--1);
padding: 0.4rem 0.9rem;
cursor: pointer;
transition: border-color var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn-reset:hover {
border-color: var(--accent);
color: var(--ink);
}
.bench-sample {
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: var(--space-5);
max-width: 46em;
}
/* ── 활자 케이스 ──────────────────────────────────────────────────────── */
.case-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: start;
}
.case-photo img,
.case-photo-b img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.case-photo figcaption,
.case-photo-b figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
.case-text h2 {
margin-bottom: var(--space-4);
}
.case-photo-b {
margin-top: var(--space-5);
max-width: 34rem;
}
/* ── 구입 ─────────────────────────────────────────────────────────────── */
.order-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr) auto;
gap: var(--space-5) var(--space-7);
align-items: start;
}
.order-grid .muted {
margin-top: var(--space-3);
max-width: 26em;
}
.order-rows {
border-top: 1px solid var(--line);
}
.order-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
padding-block: var(--space-3);
border-bottom: 1px solid var(--line);
}
.order-row p {
font-size: var(--step--1);
color: var(--ink-muted);
margin-top: 2px;
}
.order-price {
font-family: var(--font-display);
font-size: var(--step-1);
white-space: nowrap;
}
.btn {
display: inline-block;
background: var(--accent);
color: var(--accent-ink);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent-hover);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-5);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-side,
.bench,
.case-grid,
.order-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.case-photo {
max-width: 24rem;
}
.hero,
.section {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
.order-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View file

@ -1,303 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>스튜디오 폼 · 공간건축연구소</title>
<meta
name="description"
content="형태는 침묵하고 치수가 태도를 증명한다. 비례와 구조의 명료성으로 설계하는 건축연구소의 프로젝트 인덱스와 도면."
/>
<meta property="og:title" content="스튜디오 폼 · 공간건축연구소" />
<meta
property="og:description"
content="비례와 구조의 명료성으로 설계하는 건축연구소."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#FDFDFB" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='6' y='6' width='20' height='20' fill='none' stroke='%23111' stroke-width='2'/%3E%3Crect x='11' y='11' width='10' height='10' fill='%23111'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<!-- 4모서리 등록 표시(인쇄 판맞춤) — 이 페이지의 리스크. 넓은 화면에서만. -->
<div class="corner corner-tl" aria-hidden="true"></div>
<div class="corner corner-tr" aria-hidden="true"></div>
<div class="corner corner-bl" aria-hidden="true"></div>
<div class="corner corner-br" aria-hidden="true"></div>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">스튜디오 폼 <span class="wordmark-en">FORM</span></a>
<nav class="nav" aria-label="섹션">
<a href="#index">인덱스</a>
<a href="#drawing">도면</a>
<a href="#photo">사진</a>
<a href="#contact">연락</a>
</nav>
</div>
</header>
<main id="main">
<!--
서문 — Pawson의 교훈: 크기가 아니라 여백과 무게로 위계를 만든다.
큰 글자는 이 페이지에 하나만 있다.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap">
<p class="hero-meta reveal">
공간건축연구소 · 서울 · 2014년 설립
</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
형태는 침묵하고<br />치수가 태도를 증명한다
</h1>
<p class="hero-statement reveal" style="--delay: 120ms">
폼은 공간을 꾸미지 않습니다. 벽이 서는 이유, 창이 열리는 방향, 보가
건너뛰는 길이를 설계합니다. 아름다움은 그 결정들이 모였을 때의
부산물입니다 — 그래서 우리의 홍보는 도면과 치수로 합니다.
</p>
<dl class="hero-facts reveal" style="--delay: 180ms">
<div>
<dt>설립</dt>
<dd>2014년</dd>
</div>
<div>
<dt>구성</dt>
<dd>건축사 4 · 연구원 3</dd>
</div>
<div>
<dt>영역</dt>
<dd>주거 · 미술관 · 재생</dd>
</div>
</dl>
</div>
</section>
<!--
인덱스 — 이 페이지의 주인공. 사진 없는 역연도 목록.
R1(johnpawson.com): 프로젝트는 제목/위치/연도의 세 줄 텍스트다.
-->
<section id="index" class="section" aria-labelledby="index-h">
<div class="wrap">
<h2 id="index-h" class="index-h reveal">프로젝트 인덱스</h2>
<ol class="index">
<li class="index-row reveal">
<a href="#drawing">
<span class="idx-name">폼 하우스</span>
<span class="idx-loc">경기 가평</span>
<span class="idx-year">2025</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 40ms">
<a href="#drawing">
<span class="idx-name">백령도 해녀기념관</span>
<span class="idx-loc">인천 백령도</span>
<span class="idx-year">2024</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 80ms">
<a href="#drawing">
<span class="idx-name">은평 재생주거</span>
<span class="idx-loc">서울 은평</span>
<span class="idx-year">2024</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 120ms">
<a href="#drawing">
<span class="idx-name">제주 돌담 스튜디오</span>
<span class="idx-loc">제주 애월</span>
<span class="idx-year">2023</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 160ms">
<a href="#drawing">
<span class="idx-name">성수 인쇄공장 재생</span>
<span class="idx-loc">서울 성수</span>
<span class="idx-year">2022</span>
</a>
</li>
<li class="index-row reveal" style="--delay: 200ms">
<a href="#drawing">
<span class="idx-name">폼 오피스</span>
<span class="idx-loc">서울 종로</span>
<span class="idx-year">2019</span>
</a>
</li>
</ol>
</div>
</section>
<!--
도면 — 스티키 평면도가 섹션을 지나는 동안 그어진다.
R3: CSS scroll-driven + stroke-dashoffset. 폴백은 완성된 도면.
도면은 장식이 아니라 이 연구소의 언어 그 자체다.
-->
<section id="drawing" class="section drawing" aria-labelledby="drawing-h">
<div class="wrap drawing-grid">
<div class="drawing-stage reveal">
<svg
viewBox="0 0 520 640"
role="img"
aria-label="폼 하우스 1층 평면도. 남쪽으로 긴 거실과 북쪽 서비스 블록, 동쪽 마당으로 이루어져 있다."
>
<!-- 외곽 벽 (이중선) -->
<path
class="wall"
d="M 80 60 H 440 V 580 H 80 Z M 88 68 H 432 V 572 H 88 Z"
/>
<!-- 내부 벽 -->
<path
class="wall inner"
d="M 260 68 V 300 M 260 380 V 572 M 88 300 H 432 M 88 380 H 260"
/>
<!-- 문 개구부 표시 -->
<path class="door" d="M 260 340 h -36 M 260 340 a 36 36 0 0 1 36 -36" />
<!-- 기둥 -->
<path class="column" d="M 176 130 h 10 v 10 h -10 z M 356 130 h 10 v 10 h -10 z M 176 250 h 10 v 10 h -10 z M 356 250 h 10 v 10 h -10 z" />
<!-- 치수선 -->
<g class="dim">
<path d="M 80 610 H 440 M 80 602 v 16 M 440 602 v 16" />
<path d="M 50 60 V 580 M 42 60 h 16 M 42 580 h 16" />
<text x="260" y="632" text-anchor="middle">14,400</text>
<text x="30" y="325" text-anchor="middle" transform="rotate(-90 30 325)">9,600</text>
</g>
<!-- 방 이름 -->
<g class="room-label">
<text x="174" y="190">거실</text>
<text x="350" y="190" text-anchor="middle"></text>
<text x="174" y="460">주방·식당</text>
<text x="350" y="455">서비스</text>
<text x="350" y="490">현관</text>
</g>
<!-- 방위 -->
<g class="north">
<path d="M 470 90 v -40 M 470 50 l -8 14 M 470 50 l 8 14" />
<text x="470" y="110" text-anchor="middle">N</text>
</g>
</svg>
<p class="drawing-caption">폼 하우스 · 1층 평면도 · mm</p>
</div>
<div class="drawing-notes">
<h2 id="drawing-h">폼 하우스, 가평</h2>
<p>
대지 남쪽의 소나무군을 지우지 않기 위해 집은 길게 눕고, 마당은 집
안쪽으로 들어왔습니다. 구조는 9.6m 축 한 번으로 건너뛰어 거실에 기둥이
없습니다.
</p>
<p>
벽체는 단열 성능의 요구치에서 두께가 정해졌습니다 — 마감 재료를 먼저
고르고 두께를 맞추지 않았습니다. 창은 남향 긴면 하나, 동쪽 마당 면
하나. 그 두 개로 이 집의 하루가 결정됩니다.
</p>
<dl class="drawing-spec">
<div>
<dt>구조</dt>
<dd>RC 라멘 · 9.6m 스팬</dd>
</div>
<div>
<dt>면적</dt>
<dd>132.4 ㎡ (40.0평)</dd>
</div>
<div>
<dt>창개구</dt>
<dd>남측 14.4m · 동측 4.2m</dd>
</div>
<div>
<dt>줄공사</dt>
<dd>2025-04 2026-01</dd>
</div>
</dl>
</div>
</div>
</section>
<!--
사진 — 흑백 3점. 그리드는 엄격하게, 크기는 사진이 요구하는 대로.
-->
<section id="photo" class="section photo" aria-labelledby="photo-h">
<div class="wrap">
<h2 id="photo-h" class="reveal">사진</h2>
<div class="photo-grid">
<figure class="ph ph-a reveal">
<img
src="./assets/pavilion-bw.webp"
alt="긴 수평창이 낸 콘크리트 파빌리온에 벽 그림자가 지는 모습"
width="1440"
height="810"
loading="lazy"
/>
<figcaption>폼 하우스 · 남측 · 준공 후 첫 여름</figcaption>
</figure>
<figure class="ph ph-b reveal" style="--delay: 60ms">
<img
src="./assets/interior-bw.webp"
alt="천창으로 한 줄기 빛이 사선으로 들어오는 미장 마감 전시실"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>백령도 해녀기념관 · 제1전시실</figcaption>
</figure>
<figure class="ph ph-c reveal" style="--delay: 120ms">
<img
src="./assets/model-bw.webp"
alt="스터디 모델 — 직육면체를 쌓은 백색 석고 모형에 측광이 지는 모습"
width="1100"
height="825"
loading="lazy"
/>
<figcaption>스터디 모델 07 · 석고</figcaption>
</figure>
</div>
</div>
</section>
<!-- 연락 — 서류 같은 마무리 -->
<section id="contact" class="section contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">연락</h2>
<address>
서울 종로구 자하문로 00길 00, 3층<br />
<a class="link" href="mailto:studio@form.kr">studio@form.kr</a>
</address>
<p class="contact-note">
신규 프로젝트는 분기에 두 건만 받습니다. 대지와 예산의 조건을 적어
보내주시면, 가능 여부를 2주 안에 답합니다.
</p>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>스튜디오 폼 · 공간건축연구소</p>
<p class="foot-meta">© 20142026</p>
</div>
</footer>
<script>
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,497 +0,0 @@
@import url("./tokens.css");
/* ==========================================================================
스튜디오 swiss-minimal. 판맞춤 도면 묶음.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2, h3) {
font-weight: 600;
line-height: var(--leading-tight);
letter-spacing: -0.01em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
:where(p, li, dd, dt, th, td, address, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
a {
color: inherit;
}
.link {
text-decoration: none;
border-bottom: 1px solid var(--line-strong);
transition: border-color var(--dur-quick) var(--ease-soft);
}
.link:hover {
border-color: var(--ink);
}
:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 판맞춤 표시 — 넓은 화면에서만, 내용과 겹치지 않는 곳에 ──────────── */
.corner {
position: fixed;
width: 18px;
height: 18px;
z-index: 40;
pointer-events: none;
}
.corner-tl {
top: 10px;
left: 10px;
border-top: 1px solid var(--line-strong);
border-left: 1px solid var(--line-strong);
}
.corner-tr {
top: 10px;
right: 10px;
border-top: 1px solid var(--line-strong);
border-right: 1px solid var(--line-strong);
}
.corner-bl {
bottom: 10px;
left: 10px;
border-bottom: 1px solid var(--line-strong);
border-left: 1px solid var(--line-strong);
}
.corner-br {
bottom: 10px;
right: 10px;
border-bottom: 1px solid var(--line-strong);
border-right: 1px solid var(--line-strong);
}
@media (max-width: 900px) {
.corner {
display: none;
}
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 90%, transparent);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.wordmark-en {
color: var(--ink-faint);
font-weight: 400;
margin-left: var(--space-1);
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
border-bottom: 1px solid transparent;
padding-block: 2px;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--ink);
}
/* ── 서문 ─────────────────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-8) var(--space-7);
border-bottom: 1px solid var(--line);
}
.hero-meta {
font-size: var(--step--1);
color: var(--ink-muted);
margin-bottom: var(--space-5);
}
.hero-statement {
margin-top: var(--space-5);
max-width: 30em;
}
.hero-facts {
margin-top: var(--space-6);
display: flex;
flex-wrap: wrap;
gap: var(--space-4) var(--space-7);
border-top: 1px solid var(--line);
padding-top: var(--space-4);
}
.hero-facts dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.hero-facts dd {
font-weight: 500;
}
/* ── 인덱스 ───────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.index-h {
font-size: var(--step--1);
font-weight: 500;
color: var(--ink-muted);
letter-spacing: 0.2em;
}
.index {
list-style: none;
margin-top: var(--space-3);
padding: 0;
border-top: 1px solid var(--line-strong);
}
.index-row a {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: var(--space-4) var(--space-6);
align-items: baseline;
padding-block: var(--space-3);
border-bottom: 1px solid var(--line);
text-decoration: none;
transition: background var(--dur-quick) var(--ease-soft);
}
.index-row a:hover {
background: var(--ink);
color: var(--surface);
}
.idx-name {
font-size: var(--step-1);
font-weight: 500;
}
.idx-loc {
color: var(--ink-muted);
font-size: var(--step--1);
}
.index-row a:hover .idx-loc {
color: inherit;
}
.idx-year {
font-variant-numeric: tabular-nums;
color: var(--ink-faint);
}
.index-row a:hover .idx-year {
color: inherit;
}
/* ── 도면(스티키) ─────────────────────────────────────────────────────── */
.drawing {
background: var(--surface);
}
.drawing-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
}
.drawing-stage {
position: sticky;
top: calc(var(--space-5) + 56px);
align-self: start;
}
.drawing-stage svg {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
background: var(--surface-bright);
}
.wall {
fill: none;
stroke: var(--ink);
stroke-width: 2;
}
.wall.inner {
stroke-width: 1.25;
}
.door {
fill: none;
stroke: var(--ink-muted);
stroke-width: 1;
stroke-dasharray: none;
}
.column {
fill: var(--ink);
}
.dim path {
fill: none;
stroke: var(--line-strong);
stroke-width: 1;
}
.dim text {
font-size: 12px;
fill: var(--ink-faint);
font-variant-numeric: tabular-nums;
}
.room-label text {
font-size: 13px;
fill: var(--ink-muted);
}
.north path {
fill: none;
stroke: var(--ink);
stroke-width: 1.25;
}
.north text {
font-size: 12px;
fill: var(--ink-muted);
}
.drawing-caption {
margin-top: var(--space-2);
font-size: var(--step--1);
color: var(--ink-faint);
}
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.wall,
.wall.inner {
stroke-dasharray: 3400;
stroke-dashoffset: 3400;
animation: draw 1.4s linear both;
animation-timeline: --drawing;
animation-range: entry 10% contain 55%;
}
@keyframes draw {
to {
stroke-dashoffset: 0;
}
}
}
}
.drawing-notes h2 {
font-size: var(--step-2);
}
.drawing-notes p + p {
margin-top: var(--space-4);
}
.drawing-spec {
margin-top: var(--space-5);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: var(--space-3) var(--space-5);
border-top: 1px solid var(--line);
padding-top: var(--space-4);
}
.drawing-spec dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.drawing-spec dd {
margin-top: 2px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
/* ── 사진 ─────────────────────────────────────────────────────────────── */
.photo {
border-top: 1px solid var(--line);
}
.photo h2 {
font-size: var(--step--1);
font-weight: 500;
color: var(--ink-muted);
letter-spacing: 0.2em;
}
.photo-grid {
margin-top: var(--space-4);
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-4);
}
.ph {
display: block;
}
.ph img {
display: block;
width: 100%;
height: auto;
filter: grayscale(1); /* 사진 파일이 흑백이 아니어도 페이지는 흑백이다 */
}
.ph-a {
grid-column: 1 / -1;
}
.ph figcaption {
margin-top: var(--space-2);
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 연락 ─────────────────────────────────────────────────────────────── */
.contact {
border-top: 1px solid var(--line);
}
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.contact address {
font-style: normal;
color: var(--ink-muted);
}
.contact-note {
color: var(--ink-muted);
max-width: 26em;
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line-strong);
padding-block: var(--space-4);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
.foot-meta {
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 10px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.drawing-grid {
grid-template-columns: 1fr;
}
.drawing-stage {
position: static;
}
.contact-grid {
grid-template-columns: 1fr;
}
.hero,
.section {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
.index-row a {
grid-template-columns: 1fr auto;
}
.idx-loc {
grid-column: 1;
}
.idx-year {
grid-row: 1;
grid-column: 2;
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View file

@ -1,333 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>무월 (霧月) · 싱글오리진 원두 정기구독</title>
<meta
name="description"
content="원두마다 산미·단맛·바디를 같은 척도로 측정해 적고, 생두 매입가를 공개합니다. 격주 화요일 로스팅, 서울 서촌."
/>
<meta property="og:title" content="무월 (霧月) · 싱글오리진 원두 정기구독" />
<meta
property="og:description"
content="측정된 좌표로 원두를 고르는 격주 구독. 생두 매입 원장 공개."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#F4F4F0" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F4F4F0'/%3E%3Cpath d='M16 7a7 7 0 0 1 7 7h-7z' fill='%237A3E1D'/%3E%3Cpath d='M16 9a7 7 0 0 0-7 7h7z' fill='%23181B18'/%3E%3Ccircle cx='16' cy='16' r='9' fill='none' stroke='%23181B18' stroke-width='1.5'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">
무월 <span class="wordmark-han">霧月</span>
</a>
<nav class="nav" aria-label="섹션">
<a href="#scale">척도</a>
<a href="#ledger">원장</a>
<a href="#recipe">레시피</a>
<a href="#plan">구독</a>
</nav>
</div>
</header>
<main id="main">
<!--
히어로 — R1(coffeecollective.dk): 가격을 첫 화면에서 숨기지 않는다.
문구만으로 시작하는 대신 이번 주 로스 사진 한 장과 원두 둘(값 포함)을 같이 둔다.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<p class="hero-label reveal">싱글오리진 로스터리 · 서울 서촌</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
취향은 형용사가 아니라<br />측정된 좌표입니다
</h1>
<p class="lead reveal" style="--delay: 120ms">
과일 향이 난다는 말로는 추출 온도를 정할 수 없습니다. 무월은 원두마다
산미와 단맛, 바디를 같은 척도로 측정해 적고, 생두를 얼마에 들였는지까지
공개합니다. 마셔보기 전에 판단할 수 있어야 원두 고르는 즐거움이 시작됩니다.
</p>
<div class="hero-actions reveal" style="--delay: 180ms">
<a class="btn" href="#plan">격주 구독 시작하기</a>
<p class="hero-note">격주 화요일 로스팅 출고 · 언제든 해지</p>
</div>
</div>
<figure class="hero-photo reveal" style="--delay: 100ms">
<img
src="./assets/beans-hero.webp"
alt="따뜻한 회색 리넨 위 질도기 그릇에 담긴 갓 볶은 원두와 놋쇠 스쿱"
width="900"
height="1200"
loading="eager"
fetchpriority="high"
/>
<figcaption>이번 주 첫 로스 · 8월 19일</figcaption>
</figure>
<!-- 이번 주 원두 — 카드가 아니라 원장의 첫 두 줄 -->
<dl class="hero-beans reveal" style="--delay: 240ms">
<div class="bean-row">
<div>
<dt>에티오피아 구지 우라가</dt>
<dd class="bean-origin">워시드 · 해발 2,150m · 커핑 88.75</dd>
</div>
<dd class="bean-price"><span>100g</span> 8,500원</dd>
</div>
<div class="bean-row">
<div>
<dt>콜롬비아 엘 파라이소</dt>
<dd class="bean-origin">혐기발효 · 해발 1,930m · 커핑 91.20</dd>
</div>
<dd class="bean-price"><span>100g</span> 11,000원</dd>
</div>
</dl>
</div>
</section>
<!--
척도 — 향미 좌표계. 히어로의 계기판 노릇을 내려놓고
본문의 척도로 옮긴다. 눈금과 점만 남긴다.
-->
<section id="scale" class="section" aria-labelledby="scale-h">
<div class="wrap scale-grid">
<div class="scale-text">
<h2 id="scale-h">같은 척도로 잰<br />향미 좌표</h2>
<p>
같은 밝기로 볶아도 생두가 머금은 수분과 밀도에 따라 열은 다르게
들어갑니다. 그래서 무월은 색이 아니라 1차 팝핑 후 감률과 커핑 점수로
로트를 기록합니다.
</p>
<p class="muted">
좌표는 로트마다 다시 측정해 옮겨 적습니다. 지난 로트의 좌표와 지금이
같다고 말하지 않습니다.
</p>
<ul class="scale-legend" aria-label="좌표 범례">
<li><span class="key" aria-hidden="true"></span>우라가 G1 워시드</li>
<li><span class="key key-b" aria-hidden="true"></span>엘 파라이소 혐기발효</li>
<li><span class="key key-c" aria-hidden="true"></span>엘 소코로 마라카투라</li>
</ul>
</div>
<figure class="scale-plot reveal">
<svg
viewBox="0 0 520 340"
role="img"
aria-label="산미-바디 2차원 좌표. 우라가는 산미 높음, 엘 파라이소는 단맛 높음, 엘 소코로는 바디 높음."
>
<!-- 눈금 격자 -->
<g class="grid">
<line x1="56" y1="40" x2="56" y2="280" />
<line x1="56" y1="280" x2="500" y2="280" />
<line x1="56" y1="160" x2="500" y2="160" />
<line x1="278" y1="40" x2="278" y2="280" />
<line x1="167" y1="40" x2="167" y2="280" stroke-dasharray="2 6" />
<line x1="389" y1="40" x2="389" y2="280" stroke-dasharray="2 6" />
<line x1="56" y1="100" x2="500" y2="100" stroke-dasharray="2 6" />
<line x1="56" y1="220" x2="500" y2="220" stroke-dasharray="2 6" />
</g>
<!-- 축 이름 -->
<g class="axis-labels">
<text x="56" y="302">산미 →</text>
<text x="500" y="302" text-anchor="end">바디 →</text>
<text x="46" y="46" text-anchor="end">10.0</text>
<text x="46" y="286" text-anchor="end">0</text>
</g>
<!-- 로트 점 -->
<g class="points">
<circle cx="140" cy="62" r="5" />
<text x="140" y="46" text-anchor="middle">우라가 8.8</text>
<circle cx="252" cy="92" r="5" class="pt-b" />
<text x="252" y="76" text-anchor="middle">엘 파라이소 9.0</text>
<circle cx="430" cy="196" r="5" class="pt-c" />
<text x="430" y="180" text-anchor="middle">엘 소코로 8.6</text>
</g>
</svg>
<figcaption>8월 3주차 로트 실측 · 좌표는 주마다 다시 놓입니다</figcaption>
</figure>
</div>
</section>
<!--
원장 — 이 페이지의 신뢰 장치 (R1: 거래 내역 공개가 이 업종의 스펙).
밴드를 살짝 눌러 표가 페이지의 중심임을 알린다.
-->
<section id="ledger" class="section band" aria-labelledby="ledger-h">
<div class="wrap">
<div class="ledger-head">
<h2 id="ledger-h">생두 매입 원장</h2>
<p class="muted">
농장에 돌아가는 돈이 얼마인지 보여주는 일이 스페셜티의 조건이라
생각합니다. 1kg당 매입가와 가공 방식까지 로트 단위로 남깁니다.
</p>
</div>
<div class="table-wrap reveal">
<table>
<caption class="sr-only">
원두별 생두 매입 원장 — 원두명, 생산자, 가공 방식, 매입가, 커핑 점수
</caption>
<thead>
<tr>
<th scope="col">원두</th>
<th scope="col">생산자</th>
<th scope="col">가공</th>
<th scope="col" class="num">매입가 (FOB/kg)</th>
<th scope="col" class="num">커핑</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">에티오피아 구지 우라가</th>
<td>하르소 콜루 세척장</td>
<td>워시드 · 72시간 발효</td>
<td class="num">$14.20</td>
<td class="num">88.75</td>
</tr>
<tr>
<th scope="row">콜롬비아 엘 파라이소</th>
<td>디에고 사무엘 베르무데스</td>
<td>혐기발효 · 열충격 2회</td>
<td class="num">$26.50</td>
<td class="num">91.20</td>
</tr>
<tr>
<th scope="row">과테말라 엘 소코로</th>
<td>후안 디에고 데 라 세르다</td>
<td>전량 워시드</td>
<td class="num">$16.80</td>
<td class="num">89.00</td>
</tr>
</tbody>
</table>
</div>
<p class="ledger-note muted">
운임·통관·관세는 포함하지 않은 현지 출하가이며, 환율은 매입일 기준입니다.
</p>
</div>
</section>
<!--
레시피 — 사진과 사양. 사양은 목록의 주인공이 아니라 사진의 각주다.
-->
<section id="recipe" class="section" aria-labelledby="recipe-h">
<div class="wrap recipe-grid">
<figure class="recipe-photo reveal">
<img
src="./assets/pourover.webp"
alt="고핫물이 필터 백에 담긴 도자기 드리퍼로 얇은 물줄기로 들어가는 중"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>서촌 매장 · 오후 첫 잔</figcaption>
</figure>
<div class="recipe-text">
<h2 id="recipe-h">원두 18g, 물 288g</h2>
<p>
구독 패키지에는 원두 밀도에 맞춘 분쇄도와 주수 간격을 적은 카드 한 장이
들어갑니다. 드립의 변주는 그 다음 문제입니다.
</p>
<dl class="spec">
<div>
<dt>물 온도</dt>
<dd>93°C</dd>
</div>
<div>
<dt>분쇄도</dt>
<dd>코만단테 24딸</dd>
</div>
<div>
<dt>뜸 들이기</dt>
<dd>40초 · 물 40g</dd>
</div>
<div>
<dt>주수</dt>
<dd>3회 · 116g씩</dd>
</div>
</dl>
<p class="muted">
추출 시간은 2분 30초에서 3분 사이가 기준입니다. 그보다 빨리 흘러내리면
곱게 갈고, 늦으면 굵게 갑니다.
</p>
</div>
</div>
</section>
<!--
구독 — 회색조에서 가장 어두운 블록. 이 페이지의 유일한 강한 면.
(1단계 회색조 위계 계획의 실행: CTA가 1위 덩어리)
-->
<section id="plan" class="section plan" aria-labelledby="plan-h">
<div class="wrap plan-grid">
<div class="plan-text">
<h2 id="plan-h">격주, 서로 다른 좌표의<br />원두 두 종</h2>
<p>
2주마다 화요일 아침, 산미형 한 종과 밸런스형 한 종(각 100g)을 볶어
보냅니다. 원두는 시음 후 남는 양이 없어 다음 주에 같은 로트를 못 드릴 수
있습니다.
</p>
<ul class="plan-list">
<li>원두별 추출 카드 동봉 — 분쇄도·온도·주수</li>
<li>아로마 밸브 틴케이스 첫 회 증정</li>
<li>출장·휴가 주는 앱에서 건너뛰기</li>
</ul>
</div>
<div class="plan-box">
<p class="plan-price">
<span>28,000원</span><span class="plan-unit">회당 · 배송비 포함</span>
</p>
<a class="btn btn-inverse" href="#plan">격주 구독 시작하기</a>
<p class="plan-note">200g(100g×2) · 첫 회는 다음 화요일에 출고됩니다</p>
</div>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>무월 로스터리 · 서울 종로구 옥인길 18</p>
<p class="muted">화요일 로스팅 · 목요일부터 토요일까지 문을 엽니다</p>
<p class="muted">roastery@muwol.kr</p>
</div>
</footer>
<script>
/* 목요일의 화원과 같은 문법 — 한 번 보이면 유지, 스크롤 이벤트 없음 */
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,560 +0,0 @@
@import url("./tokens.css");
/* ==========================================================================
무월 quiet-commerce. 종이 원장의 문법.
레이아웃 재질 모션 순서로 읽힌다.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2) {
font-family: var(--font-display);
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.01em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
:where(p, li, dd, dt, th, td, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: 1.75;
text-wrap: pretty;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
.muted {
color: var(--ink-muted);
}
a {
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 88%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-family: var(--font-display);
font-size: var(--step-1);
font-weight: 600;
letter-spacing: 0.02em;
text-decoration: none;
}
.wordmark-han {
color: var(--ink-muted);
font-size: var(--step-0);
margin-left: var(--space-1);
font-weight: 400;
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
padding-block: 2px;
border-bottom: 1px solid transparent;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--accent);
}
/* ── 히어로 ───────────────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-7);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-7) var(--space-6);
align-items: start;
}
.hero-label {
font-size: var(--step--1);
color: var(--ink-muted);
letter-spacing: 0.04em;
margin-bottom: var(--space-4);
}
.lead {
max-width: 34em;
}
.hero-text .lead {
margin-top: var(--space-4);
}
.hero-actions {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.hero-note {
font-size: var(--step--1);
color: var(--ink-faint);
}
.btn {
display: inline-block;
background: var(--accent);
color: var(--accent-ink);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.5rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent-hover);
}
.btn-inverse {
background: var(--surface);
color: var(--ink);
}
.btn-inverse:hover {
background: var(--surface-bright);
}
.hero-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.hero-photo figcaption,
.recipe-photo figcaption,
.scale-plot figcaption {
font-size: var(--step--1);
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* 이번 주 원두 — 원장의 첫 두 줄 (카드 아님) */
.hero-beans {
margin-top: var(--space-6);
border-top: 1px solid var(--line);
}
.bean-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-4);
padding-block: var(--space-3);
border-bottom: 1px solid var(--line);
}
.bean-row dt {
font-family: var(--font-display);
font-weight: 500;
font-size: var(--step-1);
}
.bean-origin {
font-size: var(--step--1);
color: var(--ink-muted);
margin-top: 2px;
}
.bean-price {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.bean-price span {
color: var(--ink-faint);
margin-right: var(--space-2);
font-size: var(--step--1);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 척도(좌표 플롯) ──────────────────────────────────────────────────── */
.scale-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.scale-text p + p {
margin-top: var(--space-3);
}
.scale-legend {
list-style: none;
padding: 0;
margin-top: var(--space-4);
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.key {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ink);
margin-right: var(--space-1);
vertical-align: baseline;
}
.key-b {
background: var(--accent);
}
.key-c {
background: var(--ink-faint);
}
.scale-plot svg {
display: block;
width: 100%;
height: auto;
}
.scale-plot .grid line {
stroke: var(--line);
stroke-width: 1;
}
.scale-plot .grid line:first-child,
.scale-plot .grid line:nth-child(2) {
stroke: var(--ink-faint);
stroke-width: 1.25;
}
.axis-labels text {
font-family: var(--font-body);
font-size: 13px;
fill: var(--ink-faint);
}
.points circle {
fill: var(--ink);
}
.points .pt-b {
fill: var(--accent);
}
.points .pt-c {
fill: var(--ink-faint);
}
.points text {
font-family: var(--font-body);
font-size: 13px;
fill: var(--ink-muted);
}
/* ── 원장(밴드) ───────────────────────────────────────────────────────── */
.ledger-head {
max-width: 38em;
margin-bottom: var(--space-5);
}
.ledger-head p {
margin-top: var(--space-3);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--step-0);
}
thead th {
text-align: left;
font-weight: 500;
font-size: var(--step--1);
color: var(--ink-muted);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--ink-faint);
}
tbody th {
text-align: left;
font-weight: 500;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.ledger-note {
margin-top: var(--space-3);
font-size: var(--step--1);
}
/* ── 레시피 ───────────────────────────────────────────────────────────── */
.recipe-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.recipe-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.recipe-text p + p {
margin-top: var(--space-3);
}
.spec {
margin-top: var(--space-4);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
gap: var(--space-3) var(--space-5);
}
.spec dt {
font-size: var(--step--1);
color: var(--ink-faint);
}
.spec dd {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: var(--step-1);
margin-top: var(--space-1);
}
/* ── 구독(반전 블록) ──────────────────────────────────────────────────── */
.plan {
background: var(--surface-inverse);
color: var(--ink-inverse);
}
.plan-note {
color: var(--ink-inverse-muted);
}
.plan-grid {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.plan-text p {
margin-top: var(--space-4);
max-width: 34em;
}
.plan-list {
margin-top: var(--space-4);
padding: 0;
list-style: none;
}
.plan-list li {
padding-left: 1.25em;
position: relative;
color: var(--ink-inverse-muted);
}
.plan-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.72em;
width: 6px;
height: 1.5px;
background: var(--ink-inverse-muted);
}
.plan-list li + li {
margin-top: var(--space-2);
}
.plan-box {
border: 1px solid var(--line-inverse);
border-radius: var(--radius);
padding: var(--space-5);
display: grid;
gap: var(--space-4);
justify-items: start;
}
.plan-price span:first-child {
font-family: var(--font-display);
font-size: var(--step-2);
font-weight: 600;
}
.plan-unit {
display: block;
font-size: var(--step--1);
margin-top: var(--space-1);
color: var(--ink-inverse-muted);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-6);
font-size: var(--step--1);
}
.foot-inner {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-5);
justify-content: space-between;
}
/* ── 등장 모션 — IO 1회, 유지 ─────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-grid,
.scale-grid,
.recipe-grid,
.plan-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero-photo,
.recipe-photo {
max-width: 26rem;
}
.hero {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
.bean-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.plan-box {
padding: var(--space-4);
}
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View file

@ -1,322 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>궤도 우주공학 · 75kN 메탄 엔진 사양서</title>
<meta
name="description"
content="75kN급 메탄-액체산소 재생냉각 엔진의 단면도와 지상 연소 시험 기록. 사양은 전부 지상 시험 실측값이다."
/>
<meta property="og:title" content="궤도 우주공학 · 75kN 메탄 엔진" />
<meta property="og:description" content="재생냉각 엔진의 단면도와 연소 시험 기록." />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#081018" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23081018'/%3E%3Cpath d='M16 4l6 7v17h-12V11z' fill='none' stroke='%23C57A45' stroke-width='1.6'/%3E%3Cpath d='M13 11h6M12 18h8' stroke='%23C57A45'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">궤도 우주공학 <span class="wordmark-en">ORBIT</span></a>
<nav class="nav" aria-label="섹션">
<a href="#engine">엔진</a>
<a href="#spec">사양</a>
<a href="#fires">시험 기록</a>
<a href="#contact">문의</a>
</nav>
</div>
</header>
<main id="main">
<!--
히어로 — 문구 대신 도면. 엔진 단면도가 첫 화면의 주인공이다.
이 도면은 장식이 아니라 사양서의 1페이지다: 라벨은 실제 부품 이름.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<p class="hero-kicker reveal">75kN 메탄-액체산소 · 재생냉각 · 2024</p>
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
도면은 말하지 않는다.<br />측정만 한다
</h1>
<p class="lead reveal" style="--delay: 120ms">
이 페이지의 모든 수치는 지상 연소 시험에서 잰 값입니다. 홍보 문구에
쓸 수 없는 숫자는 사양서에도 넣지 않습니다. 추력, 비추력, 연소실
압력 — 어디서, 몇 초 동안 재었는지가 숫자와 함께 갑니다.
</p>
<a class="btn reveal" style="--delay: 180ms" href="#spec">사양 전문 보기</a>
</div>
<figure class="blueprint reveal" style="--delay: 100ms">
<svg
viewBox="0 0 460 560"
role="img"
aria-label="엔진 단면도: 상부 인젝터, 연소실, 중앙 스로트, 확장 노즐과 벽면의 재생냉각 유로"
>
<defs>
<g id="half">
<!-- 외벽: 연소실 → 스로트 → 노즐 -->
<path
class="bp-wall"
d="M 230 70
L 168 70
L 168 210
C 168 250 194 262 194 286
C 194 310 118 350 112 470
L 108 500"
/>
<!-- 내벽 -->
<path
class="bp-wall inner"
d="M 230 92
L 184 92
L 184 214
C 184 246 206 258 206 284
C 206 306 136 348 130 468
L 127 494"
/>
<!-- 재생냉각 유로 (내벽을 따라가는 가는 선 2줄) -->
<path
class="bp-cool"
d="M 176 100 L 176 212 C 176 248 198 262 198 286 C 198 308 126 350 121 468 L 118 492"
/>
<path
class="bp-cool"
d="M 170 100 L 170 210 C 170 244 192 260 192 288 C 192 312 120 352 114 470 L 111 496"
/>
<!-- 인젝터 매니폴드 -->
<rect class="bp-block" x="168" y="46" width="50" height="18" />
<path class="bp-wall thin" d="M 168 64 L 218 64" />
</g>
</defs>
<use href="#half" />
<use href="#half" transform="translate(460 0) scale(-1 1)" />
<!-- 중심선 -->
<path class="bp-axis" d="M 230 30 V 540" />
<!-- 유동 화살표 -->
<g class="bp-flow">
<path d="M 230 120 V 220" />
<path d="M 230 220 l -5 10 M 230 220 l 5 10" />
</g>
<!-- 라벨 -->
<g class="bp-label">
<path class="bp-lead" d="M 218 55 H 150" />
<text x="144" y="59" text-anchor="end">인젝터</text>
<path class="bp-lead" d="M 168 160 H 96" />
<text x="90" y="164" text-anchor="end">연소실</text>
<path class="bp-lead" d="M 200 275 H 78" />
<text x="72" y="279" text-anchor="end">스로트</text>
<path class="bp-lead" d="M 128 400 H 60" />
<text x="54" y="404" text-anchor="end">노즐</text>
<path class="bp-lead" d="M 186 180 H 120" />
<text x="114" y="196" text-anchor="end"></text>
<text x="242" y="150">재생냉각</text>
<text x="242" y="166">유로 (CH₄)</text>
</g>
<g class="bp-dim">
<path d="M 356 92 H 372 M 364 92 V 214" />
<text x="380" y="157"></text>
</g>
<g class="bp-dim">
<path d="M 60 500 H 96 M 78 500 V 520" />
<text x="60" y="542">Ø 460 — 노즐 출구</text>
</g>
</svg>
<figcaption>ORBIT-75 단면 · 재생냉각 벽면은 그림 2겹으로 단순화</figcaption>
</figure>
</div>
</section>
<!--
사양 — 표가 주인공. 전 항목 측정 조건이 붙는다(게이트 11의 해법).
-->
<section id="spec" class="section band" aria-labelledby="spec-h">
<div class="wrap">
<h2 id="spec-h" class="reveal">사양</h2>
<div class="table-wrap reveal" style="--delay: 80ms">
<table>
<caption class="sr-only">ORBIT-75 엔진 사양 — 항목, 값, 측정 조건</caption>
<thead>
<tr>
<th scope="col">항목</th>
<th scope="col" class="num"></th>
<th scope="col">측정 조건</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">추력 (해수면)</th>
<td class="num">75.4 kN</td>
<td>지상 시험 스탠드 · 정상 상태 30초 평균</td>
</tr>
<tr>
<th scope="row">비추력 Isp (진공)</th>
<td class="num">325.8 s</td>
<td>고도 모사 노즐 · 추진제 혼합비 3.4</td>
</tr>
<tr>
<th scope="row">연소실 압력</th>
<td class="num">12.5 MPa</td>
<td>피에조 계측 · 샘플링 1kHz</td>
</tr>
<tr>
<th scope="row">사이클</th>
<td class="num">다단연소</td>
<td>연료리치 프리버너 1기</td>
</tr>
<tr>
<th scope="row">냉각</th>
<td class="num">재생냉각</td>
<td>구리합금 채널 180개 · CH₄ 유량 92%</td>
</tr>
<tr>
<th scope="row">시동 신호→연소</th>
<td class="num">2.8 s</td>
<td>TEA-TEB 점화 · 12회 평균</td>
</tr>
</tbody>
</table>
</div>
<p class="measure-note reveal" style="--delay: 140ms">
모든 값은 나주 지상 시험장, 2026년 상반기 12회 시험의 평균입니다.
단일 최고값을 사양으로 적지 않습니다.
</p>
</div>
</section>
<!--
시험 기록 — 아카이브. 성공만 남기지 않는다: 3번의 보류가 있다.
-->
<section id="fires" class="section" aria-labelledby="fires-h">
<div class="wrap fires-grid">
<div class="fires-text">
<h2 id="fires-h">지상 연소 시험 기록</h2>
<p>
엔진은 도면으로 끝나지 않습니다. 우리는 보류된 시험도 지운 적이
없습니다 — 기록에서 실패가 사라지면 사양서는 광고가 됩니다.
</p>
</div>
<div class="table-wrap reveal">
<table>
<caption class="sr-only">지상 연소 시험 기록 — 번호, 날짜, 지속, 결과</caption>
<thead>
<tr>
<th scope="col">시험</th>
<th scope="col">날짜</th>
<th scope="col" class="num">지속</th>
<th scope="col">결과</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">SF-31</th><td>2026-07-18</td><td class="num">62 s</td><td>완료 · 노즐 온도 상한 내</td></tr>
<tr><th scope="row">SF-30</th><td>2026-06-27</td><td class="num">31 s</td><td>보류 · 프리버너 시동 지연</td></tr>
<tr><th scope="row">SF-29</th><td>2026-06-02</td><td class="num">60 s</td><td>완료 · 추력 편차 0.4%</td></tr>
<tr><th scope="row">SF-28</th><td>2026-05-11</td><td class="num">12 s</td><td>보류 · 냉각유로 압력 강하</td></tr>
<tr><th scope="row">SF-27</th><td>2026-04-20</td><td class="num">58 s</td><td>완료</td></tr>
</tbody>
</table>
</div>
</div>
</section>
<!--
시험 사진 — 풀블리드 판. 마하 다이아몬드가 보일 때만 쓴다.
-->
<section class="section plate" aria-label="지상 연소 시험 사진">
<figure class="plate-fig reveal">
<img
src="./assets/static-fire.webp"
alt="야간 지상 연소 시험 — 구리 노즐이 열로 붉게 빛나고 배기 기둥에 마하 다이아몬드가 서 있다"
width="1440"
height="810"
loading="lazy"
/>
<figcaption class="wrap">SF-29 · 60초 연소 · 나주 시험장 3번 스탠드</figcaption>
</figure>
</section>
<!--
재생냉각 — 구리 유로 접사 + 설명.
-->
<section class="section cooling" aria-labelledby="cool-h">
<div class="wrap two">
<figure class="two-photo reveal">
<img
src="./assets/channel-macro.webp"
alt="구리합금 블록의 단면에 밀링된 가느다란 재생냉각 유로들"
width="900"
height="1200"
loading="lazy"
/>
<figcaption>연소실 유로 접사 · 밀링 후 청소 전</figcaption>
</figure>
<div class="two-text">
<h2 id="cool-h">추진제가 벽을<br />먼저 식힙니다</h2>
<p>
연소실 벽은 3,200°C의 가스를 견디는 게 아닙니다 — 견디게 두지
않습니다. 메탄 연료가 점화 전에 벽 속 180개 유로를 지나가며 실온에서
320°C까지 데워지고, 그 사이 벽은 900°C 아래로 머뭅니다. 냉각에 쓴
추진제는 그대로 연소실로 들어가니 버리는 열이 없습니다.
</p>
<p class="muted">
유로 하나의 폭은 0.9mm입니다. 이 숫자가 이 엔진의 수명을 결정합니다 —
도면에서 가장 작은 치수가 가장 중요한 치수입니다.
</p>
</div>
</div>
</section>
<!-- 문의 -->
<section id="contact" class="section band contact" aria-labelledby="contact-h">
<div class="wrap contact-grid">
<h2 id="contact-h">문의</h2>
<p>
발사체 통합·위성 투입 협의, 그리고 엔진 시험 데이터 전문은 아래로
보내주세요. 기술 자료 요청에는 소속과 용도를 적어주세요.
</p>
<a class="btn" href="mailto:engine@orbit.kr">engine@orbit.kr</a>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>궤도 우주공학 · 나주 시험장 / 서울 사무실</p>
<p class="mono foot-meta">ORBIT AEROSPACE · EST. 2020</p>
</div>
</footer>
<script>
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,455 +0,0 @@
@import url("./tokens.css");
/* ==========================================================================
궤도 우주공학 technical-schematic. 사양서 1페이지.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
word-break: keep-all;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
:where(p, th, td, figcaption) {
word-break: keep-all;
overflow-wrap: break-word;
line-height: var(--leading-normal);
text-wrap: pretty;
}
.lead {
color: var(--ink-muted);
max-width: 34em;
}
.muted {
color: var(--ink-muted);
}
.mono {
font-family: var(--font-mono);
}
a {
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-size: var(--step-1);
font-weight: 600;
text-decoration: none;
}
.wordmark-en {
color: var(--ink-faint);
font-family: var(--font-mono);
font-size: var(--step--1);
font-weight: 400;
margin-left: var(--space-2);
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
padding-block: 2px;
border-bottom: 1px solid transparent;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--accent);
}
/* ── 히어로 + 단면도 ──────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-8) var(--space-7);
/* 제도용 모눈 — 히어로에만, 4% 밝기 */
background-image:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 48px 48px;
border-bottom: 1px solid var(--line);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.hero-kicker {
font-family: var(--font-mono);
font-size: var(--step--1);
color: var(--accent);
letter-spacing: 0.08em;
margin-bottom: var(--space-4);
}
.hero .lead {
margin-top: var(--space-4);
}
.hero .btn {
margin-top: var(--space-5);
}
.btn {
display: inline-block;
border: 1px solid var(--accent);
color: var(--accent);
text-decoration: none;
font-weight: 500;
padding: 0.7rem 1.4rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft),
color var(--dur-quick) var(--ease-soft);
}
.btn:hover {
background: var(--accent);
color: var(--surface);
}
/* 단면도 */
.blueprint {
background: color-mix(in srgb, var(--surface) 72%, transparent);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-4) var(--space-3) var(--space-3);
}
.blueprint svg {
display: block;
width: 100%;
height: auto;
max-height: 78vh;
}
.blueprint figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
text-align: center;
}
.bp-wall {
fill: none;
stroke: var(--ink);
stroke-width: 2;
}
.bp-wall.inner {
stroke-width: 1.25;
}
.bp-wall.thin {
stroke-width: 1;
}
.bp-cool {
fill: none;
stroke: var(--accent-stroke);
stroke-width: 0.9;
}
.bp-block {
fill: none;
stroke: var(--ink-muted);
stroke-width: 1.2;
}
.bp-axis {
stroke: var(--ink-faint);
stroke-width: 0.75;
stroke-dasharray: 10 4 2 4;
}
.bp-flow path {
fill: none;
stroke: var(--accent);
stroke-width: 1.2;
}
.bp-label text {
font-family: var(--font-body);
font-size: 13px;
fill: var(--ink-muted);
}
.bp-lead {
stroke: var(--ink-faint);
stroke-width: 0.8;
}
.bp-dim path {
fill: none;
stroke: var(--ink-faint);
stroke-width: 0.8;
}
.bp-dim text {
font-family: var(--font-mono);
font-size: 11px;
fill: var(--ink-faint);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
/* ── 표 ───────────────────────────────────────────────────────────────── */
.table-wrap {
margin-top: var(--space-4);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
text-align: left;
font-weight: 500;
font-size: var(--step--1);
color: var(--ink-faint);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--ink-faint);
}
tbody th {
font-weight: 500;
text-align: left;
white-space: nowrap;
}
tbody td,
tbody th {
padding: var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
}
tbody td:not(.num) {
color: var(--ink-muted);
font-size: var(--step--1);
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.measure-note {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 시험 기록 2단 ────────────────────────────────────────────────────── */
.fires-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-5) var(--space-7);
align-items: start;
}
.fires-text p {
margin-top: var(--space-3);
color: var(--ink-muted);
max-width: 26em;
}
.fires-grid .table-wrap {
margin-top: 0;
}
/* ── 시험 사진 판 ─────────────────────────────────────────────────────── */
.plate-fig img {
display: block;
width: 100%;
height: auto;
}
.plate-fig figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
/* ── 재생냉각 2단 ─────────────────────────────────────────────────────── */
.two {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
align-items: center;
}
.two-photo img {
display: block;
width: 100%;
height: auto;
border-radius: var(--radius);
}
.two-photo figcaption {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
margin-top: var(--space-2);
}
.two-text p + p {
margin-top: var(--space-4);
}
/* ── 문의 ─────────────────────────────────────────────────────────────── */
.contact-grid {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--space-4) var(--space-7);
align-items: center;
}
.contact-grid p {
color: var(--ink-muted);
max-width: 34em;
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-4);
font-size: var(--step--1);
color: var(--ink-muted);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
}
.foot-meta {
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-grid,
.fires-grid,
.two,
.contact-grid {
grid-template-columns: 1fr;
gap: var(--space-5);
}
.hero,
.section {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

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

View file

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

View file

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

View file

@ -1,317 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pulsegate · Edge Latency &amp; Synthetic Probe Engine</title>
<meta
name="description"
content="Synthetic HTTP/gRPC assertions every ten seconds from bare-metal edge POPs. No agents, no sidecars. Prometheus and OpenTelemetry export."
/>
<meta property="og:title" content="Pulsegate · Edge Latency &amp; Synthetic Probe Engine" />
<meta
property="og:description"
content="Synthetic assertions from 28 bare-metal edge POPs. A probe is watching within 90 seconds."
/>
<meta property="og:type" content="website" />
<meta name="theme-color" content="#0B0E12" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
/>
<link rel="stylesheet" href="./styles/main.css?v=2" />
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230B0E12'/%3E%3Cpath d='M6 17h6l3-8 4 14 3-6h4' fill='none' stroke='%236CA3EE' stroke-width='2'/%3E%3C/svg%3E"
/>
</head>
<body>
<a class="skip" href="#main">Skip to main content</a>
<header class="site-head">
<div class="wrap head-inner">
<a class="wordmark" href="#main">pulsegate<span class="wordmark-dot">.</span></a>
<nav class="nav" aria-label="Sections">
<a href="#wiring">Wiring</a>
<a href="#numbers">Numbers</a>
<a href="#pricing">Pricing</a>
</nav>
</div>
</header>
<main id="main">
<!--
Hero. Grayscale hierarchy per research: h1 (1) → primary CTA (2) → command (3).
Cobalt exists only as text and chart ink — never as a fill or a glow.
The instrument on the right is a static frame: no pulsing dots, no live pretence.
-->
<section class="hero" aria-labelledby="hero-h">
<div class="wrap hero-grid">
<div class="hero-text">
<h1 id="hero-h" class="reveal">
A probe is watching<br />within 90&nbsp;seconds
</h1>
<p class="lead reveal" style="--delay: 80ms">
Synthetic HTTP and gRPC assertions, executed every ten seconds from
bare-metal POPs in 28 transit centers. No agents on your fleet, no
sidecars to babysit — the first probe is running before you finish
reading this page.
</p>
<div class="hero-actions reveal" style="--delay: 140ms">
<a class="btn btn-primary" href="#pricing">Start with 3 free probes</a>
<a class="link-quiet" href="#wiring">See how it is wired</a>
</div>
<div class="command reveal" style="--delay: 200ms">
<span class="command-prompt" aria-hidden="true">$</span>
<code>curl -sL https://pulsegate.io/init | sh</code>
</div>
<p class="hero-note reveal" style="--delay: 240ms">
90 seconds: measured from <code>init</code> to first assertion on a
cold account, 2026-08-20.
</p>
</div>
<!-- Instrument: static 24h frame. Cobalt = one series only. -->
<figure class="instrument reveal" style="--delay: 160ms">
<figcaption class="instrument-head">
<span>EDGE LATENCY · P99 · LAST 24H</span>
<span>10s interval</span>
</figcaption>
<svg
viewBox="0 0 560 264"
role="img"
aria-label="24-hour p99 latency chart. Seoul stays under 16 ms, Tokyo under 34 ms, Frankfurt sits near 140 ms with one spike to 210 ms at 06:40 UTC."
>
<!-- hour grid -->
<g class="chart-grid">
<line x1="0" y1="8" x2="560" y2="8" />
<line x1="0" y1="70" x2="560" y2="70" />
<line x1="0" y1="132" x2="560" y2="132" />
<line x1="0" y1="194" x2="560" y2="194" />
<line x1="0" y1="256" x2="560" y2="256" class="chart-axis" />
<line x1="70" y1="0" x2="70" y2="264" />
<line x1="140" y1="0" x2="140" y2="264" />
<line x1="210" y1="0" x2="210" y2="264" />
<line x1="280" y1="0" x2="280" y2="264" />
<line x1="350" y1="0" x2="350" y2="264" />
<line x1="420" y1="0" x2="420" y2="264" />
<line x1="490" y1="0" x2="490" y2="264" />
</g>
<!-- series: FRA (faint, high) — 140ms band with 06:40 spike -->
<polyline
class="series series-faint"
points="0,96 35,94 70,97 105,92 140,95 175,93 205,88 210,74 215,60 220,74 225,90 260,95 280,93 315,96 350,94 385,97 420,93 455,96 490,94 525,97 560,95"
/>
<!-- series: NRT (muted, low-mid) -->
<polyline
class="series series-muted"
points="0,224 35,222 70,225 105,221 140,224 175,223 210,226 245,222 280,224 315,223 350,226 385,222 420,225 455,223 490,226 525,223 560,225"
/>
<!-- series: ICN (cobalt, lowest) -->
<polyline
class="series series-accent"
points="0,243 35,242 70,244 105,242 140,243 175,244 210,242 245,243 280,244 315,242 350,243 385,244 420,243 455,242 490,244 525,243 560,243"
/>
</svg>
<div class="instrument-rows" aria-label="POP readings, p99 over 24 hours">
<div><span>ICN-01 · Seoul</span><span class="num">14.2 ms</span></div>
<div><span>NRT-02 · Tokyo</span><span class="num">28.6 ms</span></div>
<div><span>SIN-01 · Singapore</span><span class="num">34.0 ms</span></div>
<div><span>SJC-04 · San Jose</span><span class="num">88.4 ms</span></div>
<div><span>LHR-02 · London</span><span class="num">126.8 ms</span></div>
<div><span>FRA-01 · Frankfurt</span><span class="num">142.1 ms</span></div>
</div>
</figure>
</div>
</section>
<!--
Wiring — the one scroll-motion detail (R3): a circuit trace that draws
itself as it enters the viewport. CSS animation-timeline, Chromium-gated;
every other browser (and reduced-motion) gets the fully drawn diagram.
Structure is the argument: probes → assertions → your telemetry.
-->
<section id="wiring" class="section" aria-labelledby="wiring-h">
<div class="wrap">
<h2 id="wiring-h" class="reveal">How it is wired</h2>
<p class="lead reveal" style="--delay: 80ms">
Probes run from physical tier-1 transit facilities, not shared cloud
VPCs, so what you measure is the network your users ride — not cloud
peering bias. One trace, three stations.
</p>
</div>
<div class="wrap wiring-diagram reveal" style="--delay: 120ms">
<svg viewBox="0 0 1120 220" role="img" aria-label="Wiring diagram: 28 POPs run assertions, the engine evaluates them, results export to Prometheus and OpenTelemetry.">
<!-- drawn trace: POPs → engine → export -->
<path
class="trace"
d="M 40 60 H 300 M 40 110 H 300 M 40 160 H 300 M 300 110 H 470 M 650 110 H 820 M 820 60 V 160 M 820 60 H 1010 M 820 160 H 1010"
/>
<!-- station 1: POPs -->
<g class="station">
<rect x="40" y="44" width="14" height="14" />
<rect x="40" y="94" width="14" height="14" />
<rect x="40" y="144" width="14" height="14" />
<text x="66" y="55">ICN · NRT · SIN …</text>
<text x="66" y="105">FRA · LHR · AMS …</text>
<text x="66" y="155">SJC · IAD · ORD …</text>
</g>
<!-- station 2: engine -->
<g class="station">
<rect x="470" y="88" width="180" height="44" />
<text x="478" y="106">assertion engine</text>
<text x="478" y="124" class="mono-note">http · grpc · tcp · dns</text>
</g>
<!-- station 3: export -->
<g class="station">
<rect x="1010" y="46" width="70" height="26" />
<rect x="1010" y="146" width="70" height="26" />
<text x="1006" y="38" text-anchor="end">Prometheus</text>
<text x="1006" y="192" text-anchor="end">OpenTelemetry</text>
</g>
<!-- flow labels -->
<g class="flow-label">
<text x="310" y="100">every 10 s</text>
<text x="660" y="100">p50 · p99 · jitter</text>
</g>
</svg>
</div>
<div class="wrap wiring-notes">
<div class="wiring-note reveal">
<h3>Bare-metal, not lambdas</h3>
<p>
Probes live in tier-1 transit facilities. The RTT you see is the
RTT your users get.
</p>
</div>
<div class="wiring-note reveal" style="--delay: 80ms">
<h3>Assertions, not pings</h3>
<p>
JSONPath body checks, gRPC status codes, TLS certificate expiry —
a check fails when your product fails, not when a packet does.
</p>
</div>
<div class="wiring-note reveal" style="--delay: 160ms">
<h3>Your telemetry, your tools</h3>
<p>
Export to the Prometheus and OpenTelemetry you already run. No
dashboard to live in, no lock-in to migrate off.
</p>
</div>
</div>
</section>
<!--
Numbers — the trust band. Every value carries its measurement condition.
No invented scale claims: these are the engine's own telemetry frame.
-->
<section id="numbers" class="section band" aria-labelledby="numbers-h">
<div class="wrap">
<h2 id="numbers-h" class="reveal">One frame, all 28 POPs</h2>
<p class="lead reveal" style="--delay: 80ms">
A single assertion — <code>GET /health</code> against a Seoul origin —
fanned out to every POP at the same instant.
</p>
<div class="table-wrap reveal" style="--delay: 120ms">
<table>
<caption class="sr-only">
Per-POP readings for one assertion: DNS, TLS, TTFB and p99 over 24 hours
</caption>
<thead>
<tr>
<th scope="col">POP</th>
<th scope="col">City</th>
<th scope="col" class="num">DNS</th>
<th scope="col" class="num">TLS</th>
<th scope="col" class="num">TTFB</th>
<th scope="col" class="num">p99 / 24h</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">ICN-01</th><td>Seoul</td><td class="num">1.8</td><td class="num">6.4</td><td class="num">14.2</td><td class="num">15.9</td></tr>
<tr><th scope="row">NRT-02</th><td>Tokyo</td><td class="num">2.1</td><td class="num">7.1</td><td class="num">28.6</td><td class="num">31.2</td></tr>
<tr><th scope="row">SIN-01</th><td>Singapore</td><td class="num">2.4</td><td class="num">8.2</td><td class="num">34.0</td><td class="num">37.5</td></tr>
<tr><th scope="row">SJC-04</th><td>San Jose</td><td class="num">3.0</td><td class="num">9.6</td><td class="num">88.4</td><td class="num">94.7</td></tr>
<tr><th scope="row">LHR-02</th><td>London</td><td class="num">4.0</td><td class="num">11.2</td><td class="num">126.8</td><td class="num">133.0</td></tr>
<tr><th scope="row">FRA-01</th><td>Frankfurt</td><td class="num">4.2</td><td class="num">11.8</td><td class="num">142.1</td><td class="num">151.8</td></tr>
<tr class="row-more"><td colspan="6">…22 more POPs in the console — same frame, same instant</td></tr>
</tbody>
</table>
</div>
<p class="measure-note reveal" style="--delay: 160ms">
Measured 2026-08-20 14:00 UTC · 10 s interval · n = 8,640 per POP ·
milliseconds, median of the hour shown for DNS/TLS/TTFB.
</p>
</div>
</section>
<!--
Pricing — two quiet rows, not three columns with a badge.
(research: the 3-column + "most popular" template is the highest-recurrence
slop pattern in this category — we don't run it)
-->
<section id="pricing" class="section" aria-labelledby="pricing-h">
<div class="wrap pricing-grid">
<div class="pricing-text">
<h2 id="pricing-h" class="reveal">Priced per probe,<br />not per seat</h2>
<p class="lead reveal" style="--delay: 80ms">
A probe costs the same whether three engineers or thirty look at
the console. Downgrade, pause, or delete from the CLI.
</p>
</div>
<div class="pricing-rows reveal" style="--delay: 120ms">
<div class="price-row">
<div>
<h3>Free</h3>
<p>3 probes · 60 s interval · 7-day retention</p>
</div>
<div class="price-val">
<span>$0</span>
<a class="btn btn-primary" href="#main">Create account</a>
</div>
</div>
<div class="price-row">
<div>
<h3>Pro</h3>
<p>Unlimited probes · 10 s interval · 13-month retention · OTel export</p>
</div>
<div class="price-val">
<span>$0.40 <em>per probe-month</em></span>
<a class="link-quiet" href="#main">Start free, upgrade inside</a>
</div>
</div>
</div>
</div>
</section>
</main>
<footer class="site-foot">
<div class="wrap foot-inner">
<p>pulsegate — distributed edge telemetry</p>
<p class="foot-meta">status: 28/28 POPs reporting · exporter protocol v2.4</p>
</div>
</footer>
<script>
/* Reveal only — same grammar as the rest of the studio's work.
No scroll listeners, no fake "live" loops: a static frame is honest. */
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,543 +0,0 @@
@import url("./tokens.css");
/* ==========================================================================
Pulsegate dark-instrument. 명도차로만 읽히는 계기판.
레이아웃 재질 모션 순서.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
background: var(--surface);
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--surface);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-0);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
:where(h1, h2, h3) {
font-weight: 500;
line-height: var(--leading-tight);
letter-spacing: -0.015em;
overflow-wrap: break-word;
text-wrap: balance;
}
h1 {
font-size: var(--step-3);
}
h2 {
font-size: var(--step-2);
}
h3 {
font-size: var(--step-1);
}
.lead {
color: var(--ink-muted);
max-width: 38em;
}
code {
font-family: var(--font-mono);
font-size: 0.9em;
}
a {
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
.skip {
position: absolute;
left: -9999px;
top: 0;
background: var(--ink);
color: var(--surface);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.wrap {
width: min(100% - 2 * var(--pad-inline), var(--container));
margin-inline: auto;
}
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
.site-head {
position: sticky;
top: 0;
z-index: 50;
background: color-mix(in srgb, var(--surface) 86%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.head-inner {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-3);
}
.wordmark {
font-family: var(--font-mono);
font-size: var(--step-1);
font-weight: 500;
text-decoration: none;
letter-spacing: -0.02em;
}
.wordmark-dot {
color: var(--accent);
}
.nav {
display: flex;
gap: clamp(var(--space-3), 2vw, var(--space-5));
font-size: var(--step--1);
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
padding-block: 2px;
border-bottom: 1px solid transparent;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.nav a:hover {
color: var(--ink);
border-color: var(--accent);
}
/* ── 히어로 ───────────────────────────────────────────────────────────── */
.hero {
padding-block: var(--space-8) var(--space-7);
}
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 6fr) minmax(0, 6fr);
gap: var(--space-6) var(--space-7);
align-items: start;
}
.hero-text .lead {
margin-top: var(--space-4);
}
.hero-actions {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.btn-primary {
display: inline-block;
background: var(--ink);
color: var(--accent-ink);
font-weight: 500;
text-decoration: none;
padding: 0.75rem 1.375rem;
border-radius: var(--radius);
transition: background var(--dur-quick) var(--ease-soft);
}
/* 히어로에서 가장 밝은 덩어리 = CTA. 색이 아니라 명도로 2위. */
.btn-primary:hover {
background: var(--ink-bright);
}
.link-quiet {
color: var(--ink-muted);
text-decoration: none;
border-bottom: 1px solid var(--line-strong);
padding-bottom: 1px;
transition: color var(--dur-quick) var(--ease-soft),
border-color var(--dur-quick) var(--ease-soft);
}
.link-quiet:hover {
color: var(--accent);
border-color: var(--accent);
}
.command {
margin-top: var(--space-5);
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--surface-frame);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-3) var(--space-4);
font-family: var(--font-mono);
font-size: var(--step--1);
max-width: 30rem;
}
.command-prompt {
color: var(--accent);
}
.command code {
display: block; /* 인라인이면 nowrap 최소폭이 그리드를 밀어낸다(320px 실패) */
min-width: 0;
color: var(--ink);
overflow-x: auto;
white-space: nowrap;
scrollbar-width: thin;
}
.hero-note {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--ink-faint);
max-width: 34rem;
}
/* ── 계기(정적 프레임) ─────────────────────────────────────────────────── */
.instrument {
border: 1px solid var(--line);
border-radius: var(--radius-frame);
background: var(--surface-raised);
padding: var(--space-4);
}
.instrument-head {
display: flex;
justify-content: space-between;
gap: var(--space-3);
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.08em;
color: var(--ink-faint);
margin-bottom: var(--space-3);
}
.instrument svg {
display: block;
width: 100%;
height: auto;
}
.chart-grid line {
stroke: var(--line);
stroke-width: 1;
}
.chart-grid .chart-axis {
stroke: var(--line-strong);
}
.series {
fill: none;
stroke-width: 1.25;
stroke-linejoin: round;
}
.series-faint {
stroke: var(--line-strong);
}
.series-muted {
stroke: var(--ink-faint);
}
.series-accent {
stroke: var(--accent-stroke);
}
.instrument-rows {
margin-top: var(--space-3);
border-top: 1px solid var(--line);
}
.instrument-rows > div {
display: flex;
justify-content: space-between;
gap: var(--space-3);
padding-block: 0.5rem;
border-bottom: 1px solid var(--line);
font-size: var(--step--1);
color: var(--ink-muted);
}
.num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--ink);
}
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
.section {
padding-block: var(--space-8);
}
.band {
background: var(--surface-raised);
border-block: 1px solid var(--line);
}
.section h2 + .lead {
margin-top: var(--space-3);
}
/* ── 배선 다이어그램 ──────────────────────────────────────────────────── */
.wiring-diagram {
margin-top: var(--space-6);
border: 1px solid var(--line);
border-radius: var(--radius-frame);
background: var(--surface-raised);
padding: var(--space-4) var(--space-3);
}
.wiring-diagram svg {
display: block;
width: 100%;
height: auto;
}
.trace {
fill: none;
stroke: var(--accent-stroke);
stroke-width: 1.5;
}
.station rect {
fill: none;
stroke: var(--line-strong);
stroke-width: 1;
}
.station text {
font-family: var(--font-body);
font-size: 15px;
fill: var(--ink-muted);
}
.station .mono-note {
font-family: var(--font-mono);
font-size: 12px;
fill: var(--ink-faint);
}
.flow-label text {
font-family: var(--font-mono);
font-size: 12px;
fill: var(--accent);
}
/* 스크롤 드로잉 — 이중 게이트. 폴백은 '이미 다 그어진 도면' */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.trace {
stroke-dasharray: 2400;
stroke-dashoffset: 2400;
animation: trace-draw 1.6s var(--ease-soft) both;
animation-timeline: view();
animation-range: entry 0% entry 60%;
}
@keyframes trace-draw {
to {
stroke-dashoffset: 0;
}
}
}
}
.wiring-notes {
margin-top: var(--space-5);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--space-5) var(--space-6);
}
.wiring-note h3 {
font-size: var(--step-0);
font-weight: 600;
}
.wiring-note p {
margin-top: var(--space-2);
color: var(--ink-muted);
font-size: var(--step--1);
}
/* ── 수치 밴드 ────────────────────────────────────────────────────────── */
.table-wrap {
margin-top: var(--space-5);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
text-align: left;
font-weight: 500;
font-size: var(--step--1);
color: var(--ink-faint);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line-strong);
}
tbody th {
font-weight: 500;
font-family: var(--font-mono);
font-size: var(--step--1);
}
tbody td,
tbody th {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--line);
vertical-align: baseline;
white-space: nowrap;
}
tbody td {
color: var(--ink-muted);
}
td.num {
color: var(--ink);
}
.row-more td {
color: var(--ink-faint);
font-size: var(--step--1);
padding-block: var(--space-3);
}
.measure-note {
margin-top: var(--space-3);
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 요금 ─────────────────────────────────────────────────────────────── */
.pricing-grid {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
gap: var(--space-6) var(--space-7);
align-items: start;
}
.pricing-text .lead {
margin-top: var(--space-3);
}
.pricing-rows {
border-top: 1px solid var(--line-strong);
}
.price-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding-block: var(--space-4);
border-bottom: 1px solid var(--line);
flex-wrap: wrap;
}
.price-row h3 {
font-weight: 600;
}
.price-row p {
margin-top: var(--space-1);
color: var(--ink-muted);
font-size: var(--step--1);
}
.price-val {
display: flex;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.price-val > span {
font-family: var(--font-mono);
font-size: var(--step-1);
font-variant-numeric: tabular-nums;
}
.price-val em {
font-style: normal;
font-size: var(--step--1);
color: var(--ink-faint);
}
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
.site-foot {
border-top: 1px solid var(--line);
padding-block: var(--space-5);
}
.foot-inner {
display: flex;
justify-content: space-between;
gap: var(--space-3) var(--space-5);
flex-wrap: wrap;
font-size: var(--step--1);
color: var(--ink-muted);
}
.foot-meta {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--ink-faint);
}
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
.reveal {
opacity: 0;
translate: 0 12px;
transition: opacity var(--dur-normal) var(--ease-soft),
translate var(--dur-normal) var(--ease-soft);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
translate: 0 0;
transition: none;
}
}
/* ── 반응형 ───────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.hero-grid,
.pricing-grid {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-5);
}
.hero {
padding-block: var(--space-6);
}
}
@media (max-width: 560px) {
.head-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-1);
}
.nav {
width: 100%;
justify-content: space-between;
}
.price-row {
align-items: flex-start;
flex-direction: column;
}
}

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

View file

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

View file

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

View file

@ -1,54 +0,0 @@
/* ==========================================================================
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);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

View file

@ -1,301 +0,0 @@
<!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 property="og:image" content="./assets/flower-01.webp" />
<meta name="theme-color" content="#f8f6f0" />
<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%3Ctext y='26' font-size='26'%3E🌿%3C/text%3E%3C/svg%3E" />
</head>
<body>
<a class="skip" href="#main">본문으로 건너뛰기</a>
<header class="site-head wrap">
<a class="wordmark" href="#main">
<span>목요일의</span> <span>화원</span>
</a>
<nav class="nav" aria-label="섹션">
<a href="#work">작업</a>
<a href="#thursday">목요일</a>
<a href="#subscribe">정기</a>
<a href="#visit">찾아오기</a>
</nav>
</header>
<main id="main">
<!--
히어로. 큰 사진 한 장을 깔지 않는다 — 그게 이 카테고리의 기본값이다.
크기도 비율도 다른 사진 넷을 공통 정렬선 없이 흩뿌리고,
그 사이 여백에 글자를 세로로 세운다. (R1: sarahwinward.com)
-->
<section class="hero" aria-labelledby="hero-h">
<div class="hero-inner wrap">
<div class="hero-text">
<p class="vertical hero-role reveal">플로럴 스튜디오 / 서울 서촌 / 2019</p>
<div class="hero-body">
<h1 id="hero-h" class="hero-h reveal">
그 주에 좋은 꽃만<br />들입니다
</h1>
<p class="lead hero-lead reveal" style="--delay: 120ms">
목요일 아침에 시장에서 그 주의 꽃을 고르고, 주문받은 만큼만 만듭니다.
남는 꽃이 없어서 시들 일도 없습니다.
</p>
<a class="btn hero-cta reveal" href="#contact" style="--delay: 200ms">
상담 신청하기
</a>
</div>
</div>
<div class="hero-photos">
<figure class="ph ph-a reveal" style="--delay: 80ms">
<img
src="./assets/flower-01.webp"
alt="흰 라넌큘러스와 작약, 유칼립투스로 묶은 꽃다발"
width="1086"
height="1448"
loading="eager"
fetchpriority="high"
/>
</figure>
<figure class="ph ph-b reveal" style="--delay: 260ms">
<img
src="./assets/flower-03.webp"
alt="꽃 한 송이의 꽃잎 결이 보이는 접사"
width="1086"
height="1448"
loading="lazy"
/>
</figure>
<figure class="ph ph-c reveal" style="--delay: 340ms">
<img
src="./assets/flower-02.webp"
alt="작업대 위에 놓인 꽃 줄기와 가위, 노끈"
width="1448"
height="1086"
loading="lazy"
/>
</figure>
<p class="vertical hero-tags reveal" style="--delay: 420ms">
주문 제작 / 정기 구독 / 공간 장식
</p>
</div>
</div>
</section>
<!-- 왜 목요일인가 — 이름이 곧 운영 방식이라는 것을 여기서 푼다 -->
<section id="thursday" class="section band" aria-labelledby="thursday-h">
<div class="wrap two">
<div class="two-text">
<h2 id="thursday-h">
일주일에 하루만 문을 엽니다
</h2>
<p>
수요일 밤에 경매가 끝나고, 목요일 새벽 시장에 그 주의 꽃이 나옵니다.
그날 좋은 것만 골라 그날 만듭니다.
</p>
<p>
나머지 엿새는 만들지 않습니다. 미리 사다 두면 파는 날까지 꽃이 기다려야
하고, 기다린 꽃은 이미 한 번 시든 꽃입니다.
</p>
<dl class="facts">
<div>
<dt>여는 날</dt>
<dd>매주 목요일 10:0019:00</dd>
</div>
<div>
<dt>주문 마감</dt>
<dd>수요일 18:00</dd>
</div>
<div>
<dt>그 주 제작 수</dt>
<dd>최대 20건</dd>
</div>
</dl>
</div>
<figure class="two-photo">
<img
src="./assets/flower-05.webp"
alt="꽃 줄기를 가위로 다듬는 손"
width="1086"
height="1448"
loading="lazy"
/>
<figcaption class="label">목요일 아침, 손질</figcaption>
</figure>
</div>
</section>
<!-- 작업 — 사진이 설득의 주체다. 크기 차이가 리듬이다 -->
<section id="work" class="section" aria-labelledby="work-h">
<div class="wrap">
<p class="label">작업</p>
<h2 id="work-h">같은 꽃을 두 번 쓰지 않습니다</h2>
<p class="lead">
주에 따라 들어오는 꽃이 다르니 결과도 매번 다릅니다. 지난 작업을 그대로
다시 만들어 달라는 요청은 받지 않습니다.
</p>
</div>
<div class="wrap gallery">
<figure class="g g-1">
<img
src="./assets/flower-04.webp"
alt="창가 자연광이 드는 작업실, 양철 양동이에 담긴 꽃들과 나무 선반"
width="1600"
height="900"
loading="lazy"
/>
<figcaption>작업실 · 서촌</figcaption>
</figure>
<figure class="g g-2">
<img
src="./assets/flower-01.webp"
alt="흰색과 크림색 위주로 묶은 꽃다발"
width="1086"
height="1448"
loading="lazy"
/>
<figcaption>화이트 온 화이트 · 6월</figcaption>
</figure>
<figure class="g g-3">
<img
src="./assets/flower-02.webp"
alt="꽃 줄기와 가위, 노끈이 놓인 작업대를 위에서 본 모습"
width="1448"
height="1086"
loading="lazy"
/>
<figcaption>손질 전 · 목요일 오전</figcaption>
</figure>
</div>
<div class="wrap">
<p class="muted work-note">
더 많은 작업은
<a class="link" href="https://instagram.com" rel="noopener">인스타그램</a>
주 단위로 올립니다.
</p>
</div>
</section>
<!-- 정기구독. 주 CTA(상담)와 경쟁하지 않게 아래쪽에 조용히 둔다 -->
<section id="subscribe" class="section band" aria-labelledby="sub-h">
<div class="wrap two two-reverse">
<div class="two-text">
<h2 id="sub-h">격주 목요일, 같은 자리에</h2>
<p>
사무실 응접실이나 매장 카운터처럼 꽃이 늘 있어야 하는 자리를 위한
것입니다. 격주로 그 주의 꽃을 가져다 놓고, 지난 것을 가져옵니다.
</p>
<p class="muted">
화병은 저희 것을 두고 씁니다. 자리와 조도를 먼저 보고 정하기 때문에
첫 회는 방문 상담으로 시작합니다.
</p>
<a class="btn btn-quiet" href="#contact">정기 상담 신청</a>
</div>
<figure class="two-photo">
<img
src="./assets/flower-03.webp"
alt="꽃잎의 결이 보이는 접사"
width="1086"
height="1448"
loading="lazy"
/>
<figcaption class="label">이번 주 · 라넌큘러스</figcaption>
</figure>
</div>
</section>
<!-- 문의 — 이 페이지의 유일한 주 CTA가 도착하는 곳 -->
<section id="contact" class="section contact" aria-labelledby="contact-h">
<div class="wrap contact-inner">
<p class="label">문의</p>
<h2 id="contact-h">무엇에 쓸 꽃인지부터 듣습니다</h2>
<p class="lead">
받는 분과 자리, 예산을 알려주시면 그 주에 가능한 것을 먼저 말씀드립니다.
가능하지 않은 주에는 그렇다고 말씀드립니다.
</p>
<ul class="contact-list">
<li>
<span class="label">전화</span>
<a class="link" href="tel:0212345678">02-1234-5678</a>
</li>
<li>
<span class="label">메일</span>
<a class="link" href="mailto:hello@thursday.flowers"
>hello@thursday.flowers</a
>
</li>
<li>
<span class="label">인스타그램</span>
<a class="link" href="https://instagram.com" rel="noopener"
>@thursday.flowers</a
>
</li>
</ul>
</div>
</section>
<!-- 찾아오기. 푸터를 따로 두지 않고 마지막 섹션이 그 역할을 한다 -->
<section id="visit" class="section visit" aria-labelledby="visit-h">
<div class="wrap visit-inner">
<div>
<h2 id="visit-h">서촌, 목요일에만</h2>
<address class="visit-address">
서울 종로구 자하문로 00길 00, 2층<br />
경복궁역 3번 출구에서 도보 8분
</address>
</div>
<p class="visit-note muted">
목요일 외에는 상주하지 않습니다. 다른 요일 방문은 미리 연락 주세요.
</p>
<p class="vertical visit-mark">목요일의 화원</p>
</div>
</section>
</main>
<script>
/*
등장 처리만 한다. 스크롤 이벤트는 쓰지 않는다 —
매 프레임 레이아웃을 강제하고 모바일에서 프레임이 무너진다.
한 번 보이면 유지한다: 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 아니다.
*/
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("is-in");
io.unobserve(e.target);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
</script>
</body>
</html>

View file

@ -1,608 +0,0 @@
@import url("./tokens.css");
/*
리셋 필요한 것만
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--paper);
color: var(--ink);
font-family: var(--font-body);
font-size: var(--step-body);
line-height: var(--leading-body);
font-weight: var(--weight-regular);
/* 종이 질감. 에디토리얼 프리셋의 기본 재질이고, 아주 약해야 한다.
세게 걸면 화면이 지저분해지고 사진의 디테일과 싸운다. */
background-image: url("#");
background-attachment: fixed;
}
img,
picture {
display: block;
max-width: 100%;
/* CLS 막으려고 HTML width/height 속성을 적어두면, height 속성이
그대로 계산된 높이가 되어 **aspect-ratio 무시된다.**
실측: aspect-ratio "3 / 4" 적용돼 있는데 높이는 원본 1448px 이었다.
height:auto 줘야 비율이 높이를 정한다. */
height: auto;
}
/* 한글 조판. 서구 레퍼런스에는 정보가 없어서 직접 정한다.
keep-all 없으면 어절 한가운데서 줄이 바뀐다. */
:where(p, li, h1, h2, h3, figcaption, dd, dt) {
word-break: keep-all;
overflow-wrap: anywhere;
text-wrap: pretty;
}
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(--paper);
padding: var(--space-3) var(--space-4);
z-index: 100;
}
.skip:focus {
left: var(--gutter);
top: var(--space-3);
}
/*
타이포그래피 크기 4종, 웨이트 2종. 이상 늘리지 않는다
*/
h1,
h2,
h3 {
font-family: var(--font-display);
font-weight: var(--weight-regular); /* 큰 글자일수록 가늘게 */
line-height: var(--leading-tight);
letter-spacing: var(--track-display);
text-wrap: balance;
}
h1 {
font-size: var(--step-display);
}
h2 {
font-size: clamp(1.6rem, 1.2rem + 1.4vw, 2.3rem);
letter-spacing: var(--track-lead);
}
h3 {
font-size: 1.15rem;
letter-spacing: var(--track-lead);
}
.lead {
font-size: var(--step-lead);
letter-spacing: var(--track-lead);
color: var(--ink-muted);
max-width: var(--measure);
}
/* 작은 라벨만 굵고 자간이 넓다. 크기 위계의 반대쪽 끝이다 */
.label {
font-size: var(--step-label);
font-weight: var(--weight-bold);
letter-spacing: var(--track-label);
color: var(--ink-muted);
text-transform: none; /* 한글에 uppercase 는 아무 효과가 없고 라틴만 튄다 */
}
.muted {
color: var(--ink-muted);
}
/* 제목 다음에 오는 것은 반드시 떨어진다.
h2 margin: 0 이고 p margin-top: 0 이라 그냥 두면 붙는다. */
h1 + *,
h2 + *,
h3 + * {
margin-top: var(--space-4);
}
/*
레이아웃
*/
.wrap {
max-width: var(--container);
margin-inline: auto;
padding-inline: var(--gutter);
}
.section {
padding-block: var(--space-8);
}
/* 세로로 세운 글자. R1 여백을 이렇게 쓴다
가로 공간을 먹지 않으면서 곳에 정보를 흘려 넣는다. */
.vertical {
writing-mode: vertical-rl;
font-size: var(--step-label);
font-weight: var(--weight-bold);
/* 가로쓰기 라벨의 자간(0.14em) 그대로 쓰면 된다.
세로쓰기에서 자간은 **글자 사이 세로 간격** 되어 낱글자가 흩어진다.
한글은 자소가 모여 글자라 특히 심하다. */
letter-spacing: 0.02em;
color: var(--ink-faint);
white-space: nowrap;
}
/* ── 링크: 밑줄이 글자를 침범하지 않게 ── */
.link {
color: var(--accent);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.25em;
transition: text-decoration-color 250ms var(--ease-out);
}
.link:hover {
text-decoration-color: transparent;
}
/* ── 버튼: 하나뿐인 주 CTA ── */
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-height: 3rem;
padding: 0 var(--space-5);
background: var(--ink);
color: var(--paper);
font-size: var(--step-body);
font-weight: var(--weight-regular);
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);
}
.btn:hover {
background: transparent;
color: var(--ink);
}
.btn-quiet {
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);
background: transparent;
}
/*
등장 페이드와 10px 상승뿐.
R1 스크롤 이동량이 0px 이었다. 하이엔드는 움직이지 않는다.
*/
@media (prefers-reduced-motion: no-preference) {
.reveal {
opacity: 0;
transform: translateY(var(--rise));
transition:
opacity var(--dur-slow) var(--ease-out),
transform var(--dur-slow) var(--ease-out);
transition-delay: var(--delay, 0ms);
}
.reveal.is-in {
opacity: 1;
transform: none;
}
}
/*
섹션별 레이아웃
*/
/* 헤더
워드마크 좌상단, 내비 우상단. R1 이렇게 놓는다.
고정하지 않는다 에디토리얼에서 떠다니는 바는 읽기를 방해한다. */
.site-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-5);
padding-top: var(--space-5);
}
/* 넓은 화면에서는 , 좁은 화면에서는 줄로 쓴다.
<br> display:none 으로 지우면 줄만 붙는 아니라 **공백까지 사라져**
"목요일의화원" 된다. span 개로 두고 display 바꾼다. */
.wordmark span {
display: block;
}
.wordmark {
font-family: var(--font-display);
font-size: 1.35rem;
line-height: 1.25;
letter-spacing: 0.06em;
text-decoration: none;
color: var(--ink);
}
.nav {
display: flex;
gap: var(--space-4);
font-size: var(--step-label);
font-weight: var(--weight-bold);
letter-spacing: var(--track-label);
padding-top: 0.4rem;
}
.nav a {
text-decoration: none;
color: var(--ink-muted);
transition: color 250ms var(--ease-out);
}
.nav a:hover {
color: var(--ink);
}
/* 히어로
리스크를 감수한 자리다. 사진 대신 셋을 흩뿌린다.
그리드를 12칸으로 두되 사진이 **서로 다른 칸에서 시작해 다른 칸에서 끝난다.**
R1 실측했더니 이미지 6개의 좌측 정렬선이 6개 전부 달랐다(117·173·188·433·588·948).
정렬선이 하나로 모이는 순간 레이아웃은 그냥 어긋난 그리드가 된다. */
.hero {
padding-block: var(--space-6) var(--space-8);
}
/* 12칸 그리드에 grid-row 손으로 배치했더니 높이가 콘텐츠에 따라
285/195/88/266px 제각각 잡히고 사이에 칸이 생겼다.
글과 사진을 덩어리로 나누고, **사진 안에서만** 흩뿌린다.
그래야 정렬선을 흩는 것과 공간이 생기는 것이 분리된다. */
.hero-inner {
display: grid;
/* 텍스트 칼럼이 좁으면 h1 3줄로 접힌다(카운트 규칙: 헤드라인 2줄 이하).
세로 라벨이 왼쪽에서 자리를 먹으므로 그만큼 준다. */
grid-template-columns: minmax(0, 1.06fr) minmax(0, 1fr);
gap: var(--space-5);
align-items: center;
}
.hero-text {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: var(--space-5);
align-items: start;
}
.hero-body > * + * {
margin-top: var(--space-5);
}
.hero-role {
align-self: start;
}
.hero-cta {
justify-self: start;
}
/* 사진 . 위치·크기·비율이 전부 다르고 서로 겹친다.
R1 실측에서 이미지 6개의 좌측 정렬선이 6개 전부 달랐다
정렬선이 하나로 모이면 레이아웃은 그냥 어긋난 그리드가 된다. */
.hero-photos {
position: relative;
/* 가장 아래 사진(.ph-c) 63% 에서 시작해 47%·4:3 이므로 0.99 에서 끝난다.
컨테이너를 그보다 길게 잡으면 히어로 아래에 띠가 남는다. */
aspect-ratio: 1 / 0.995;
}
.ph {
position: absolute;
margin: 0;
overflow: hidden;
}
.ph img {
width: 100%;
height: 100%;
object-fit: cover;
}
.ph-a {
left: 16%;
top: 0;
width: 60%;
aspect-ratio: 3 / 4;
}
.ph-b {
left: 0;
top: 52%;
width: 27%;
aspect-ratio: 3 / 4;
}
.ph-c {
right: 0;
top: 63%;
width: 47%;
aspect-ratio: 4 / 3;
}
.hero-tags {
position: absolute;
right: -1.4rem;
top: 4%;
}
/* ── 섹션 공통 ────────────────────────────────────── */
.band {
background: var(--paper-deep);
}
/* 이미지와 글의 2단. 같은 스플릿을 이상 쓰지 않는다
번째는 좌우를 뒤집어 리듬을 준다 */
.two {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.8fr);
gap: var(--space-7);
align-items: start;
}
.two-reverse .two-text {
order: 2;
}
.two-text > * + * {
margin-top: var(--space-4);
}
.two-text p {
max-width: var(--measure);
}
.two-photo {
margin: 0;
}
.two-photo img {
width: 100%;
aspect-ratio: 3 / 4;
object-fit: cover;
}
.two-photo figcaption {
margin-top: var(--space-3);
}
/* 숫자와 사실. 표가 아니라 조판으로 둔다 */
.facts {
margin-top: var(--space-6);
display: grid;
gap: var(--space-3);
}
.facts > div {
display: grid;
grid-template-columns: 7.5rem 1fr;
gap: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.facts dt {
font-size: var(--step-label);
font-weight: var(--weight-bold);
letter-spacing: var(--track-label);
color: var(--ink-muted);
padding-top: 0.35rem;
}
.facts dd {
margin: 0;
}
/* 작업 갤러리
균일한 3열 그리드를 쓰지 않는다. 셋의 크기와 세로 위치가 전부 다르다. */
.gallery {
margin-top: var(--space-7);
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: var(--space-5) var(--space-4);
align-items: start;
}
.g {
margin: 0;
}
.g img {
width: 100%;
object-fit: cover;
}
.g figcaption {
margin-top: var(--space-3);
font-size: var(--step-label);
font-weight: var(--weight-bold);
letter-spacing: var(--track-label);
color: var(--ink-muted);
}
.g-1 {
grid-column: 1 / 9;
}
.g-1 img {
aspect-ratio: 16 / 9;
}
.g-2 {
grid-column: 9 / 13;
margin-top: var(--space-7);
}
.g-2 img {
aspect-ratio: 3 / 4;
}
.g-3 {
grid-column: 3 / 10;
margin-top: var(--space-5);
}
.g-3 img {
aspect-ratio: 4 / 3;
}
.work-note {
margin-top: var(--space-6);
max-width: var(--measure);
}
/* ── 문의 ─────────────────────────────────────────── */
.contact-inner {
max-width: 46rem;
}
.contact-list {
list-style: none;
padding: 0;
margin-top: var(--space-6);
display: grid;
gap: var(--space-3);
}
.contact-list li {
display: grid;
grid-template-columns: 7rem 1fr;
gap: var(--space-3);
align-items: baseline;
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
/* ── 찾아오기 (푸터를 대신한다) ───────────────────── */
.visit {
padding-bottom: var(--space-7);
}
.visit-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 0.7fr) auto;
gap: var(--space-6);
align-items: end;
}
.visit-address {
margin-top: var(--space-4);
font-style: normal;
color: var(--ink-muted);
}
.visit-note {
max-width: 24ch;
}
.visit-mark {
color: var(--line);
font-family: var(--font-display);
font-size: 1rem;
letter-spacing: 0.2em;
}
/*
좁은 화면 흩뿌림은 넓은 화면의 사치다.
320px 에서 정렬선 여섯 개를 유지하면 그냥 부서진 페이지가 된다.
*/
@media (max-width: 900px) {
/* 히어로를 칸으로 내린다. 흩뿌림은 넓은 화면의 사치다
390px 에서 정렬선 셋을 유지하면 사진이 서로를 가린다. */
.hero-inner {
grid-template-columns: 1fr;
gap: var(--space-6);
}
/* 여기가 실제 사고 지점이었다. .hero-text `auto 1fr` 2칸인데
세로 라벨을 가로로 눕히자 문장이 auto 칸을 통째로 먹어
h1 칸이 짜부라졌다 390px 에서 헤드라인이 6줄, 320px 에서 11줄이 됐다. */
.hero-text {
grid-template-columns: 1fr;
gap: var(--space-4);
}
.hero-role {
writing-mode: horizontal-tb;
}
/* 히어로의 텍스트 요소는 넷을 넘지 않는다(카운트 규칙) */
.hero-tags {
display: none;
}
.hero-photos {
aspect-ratio: 1 / 0.9;
}
.visit-mark {
writing-mode: horizontal-tb;
}
.two,
.visit-inner {
grid-template-columns: 1fr;
gap: var(--space-6);
}
.two-reverse .two-text {
order: 0;
}
.gallery {
grid-template-columns: 1fr;
}
.g-1,
.g-2,
.g-3 {
grid-column: 1;
margin-top: 0;
}
}
@media (max-width: 560px) {
.facts > div,
.contact-list li {
grid-template-columns: 1fr;
gap: var(--space-1);
}
/* 워드마크와 내비를 줄에 나란히 두면 320px 에서 접힌다.
실측: 내비 링크가 (top 46/77) 걸리고 워드마크는 3줄이 됐다.
좁은 화면에서는 위아래로 쌓는다 접히는 것보다 쌓는 것이 낫다. */
.site-head {
flex-direction: column;
align-items: stretch;
gap: var(--space-3);
}
.wordmark {
font-size: 1.15rem;
}
/* 세로로 쌓았으니 폭에 여유가 있다. 워드마크를 한 줄로 편다 */
.wordmark span {
display: inline;
}
.nav {
flex-wrap: nowrap;
justify-content: space-between;
gap: var(--space-2);
letter-spacing: 0.04em;
padding-top: 0;
}
/* 버튼 라벨이 2줄로 접히면 하드 게이트 6 위반이다.
좌우 패딩을 줄이고 줄바꿈을 막는다. */
.btn {
padding-inline: var(--space-4);
white-space: nowrap;
}
/* 히어로 사진 셋을 세로로 세우면 화면이 너무 길어진다.
가장 작은 하나만 남기고 나머지는 갤러리에서 다시 보여준다. */
.hero-photos {
aspect-ratio: 3 / 4;
}
.ph-b {
display: none;
}
.ph-a {
left: 0;
width: 78%;
}
.ph-c {
top: 58%;
width: 62%;
}
}

View file

@ -1,84 +0,0 @@
/*
목요일의 화원 디자인 토큰
전부 3단계에서 확정한 값이고, 근거는 design.md 있다.
값의 출처는 취향이 아니라 실측이다.
Aesop(#FFFEF2) · Sarah Winward(#F8F7F3) · Cereal(#F7F6EF) 곳이
전부 순백을 피하고 따뜻한 오프화이트를 쓴다. 글자도 순검정이 아니다.
*/
:root {
/*
강조색은 하나다. 그리고 **면이 아니라 **으로만 쓴다.
사진에는 이미 온갖 색이 들어 있어서, 화면에 색을 칠하면
사진과 싸운다. 링크·라벨·구분선 정도가 강조색이 쓰이는 전부다. */
--paper: #f8f6f0;
--paper-deep: #f1ede4; /* 섹션 하나만 살짝 눕힌다. 리듬용 */
--ink: #2a2723;
--ink-muted: #6b655c;
--ink-faint: #a09889;
--accent: #7c4a3f; /* 마른 장미. 꽃의 초록과 충돌하지 않는다 */
--line: #ddd7ca;
/* 타입
크기를 4종으로 묶는다. Aesop 3종(31/14/12), Cereal 2종(12/11)이었다.
종류를 늘리면 위계가 아니라 소음이 된다.
자간은 크기에 따라 **방향이 뒤집힌다**(Kinfolk 실측).
50px -0.5px(-1%) · 15px +0.15px(+1%)
글자는 조여서 덩어리로, 작은 글자는 풀어서 낱글자로 읽히게 한다. */
--font-display: "MaruBuri", "Nanum Myeongjo", Georgia, serif;
--font-body: "Pretendard", -apple-system, "Segoe UI", sans-serif;
/* 가장 줄이 "그 주에 좋은 꽃만"(한글 7 + 공백 2 7.7em)이다.
세로 라벨이 왼쪽에서 60px 먹으므로 텍스트 칼럼은 화면 폭의 절반보다 좁다.
1024px 에서 55.7px 잡히자 넘쳐 3줄이 됐다(카운트 규칙 위반).
기울기를 낮춰 구간에서 2줄을 유지한다. */
--step-display: clamp(2.2rem, 1.3rem + 2.9vw, 4.2rem);
--step-lead: clamp(1.05rem, 0.98rem + 0.3vw, 1.25rem);
--step-body: 1rem;
--step-label: 0.75rem;
--track-display: -0.02em;
--track-lead: -0.005em;
--track-body: 0;
--track-label: 0.14em;
/* 웨이트는 둘이면 충분하다. 굵기로 소리치지 않는다.
글자일수록 가늘게 Aesop 31px 400 이었고 12px 라벨이 700 이었다. */
--weight-regular: 400;
--weight-bold: 700;
--leading-tight: 1.2;
--leading-body: 1.85; /* 한글은 라틴보다 넉넉해야 한다. 받침 때문에 줄이 붙어 보인다 */
/* 한글 본문은 25~35자가 읽기 폭이다. 라틴 기준 65자를 그대로 쓰면 너무 넓다 */
--measure: 32ch;
/* 간격
여백이 콘텐츠다. 섹션 간격을 본문 간격의 5배 이상으로 벌린다. */
--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(6rem, 4rem + 8vw, 11rem); /* 섹션 사이 */
--gutter: clamp(1.25rem, 0.6rem + 2.6vw, 3rem);
--container: 1240px;
/* 모션
R1(sarahwinward.com) 스크롤 이동량을 쟀더니 **0px** 이었다.
하이엔드 플로럴은 움직이지 않는다. 등장만 조용히 처리한다. */
--dur-slow: 900ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--rise: 10px;
}
@media (prefers-contrast: more) {
:root {
--ink-muted: #4a453e;
--line: #b9b0a0;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 819 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Some files were not shown because too many files have changed in this diff Show more