- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다 - 5단계 기계 검사 4번째: design-gate 실행 의무화 - references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙 - tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합) - 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0) feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18) - 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판 - LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png) - 시간표 자동배치 결함 수리(명시적 격자 좌표) - QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ apps/site/dist/
|
|||
# 리서치 원본 자막(용량) — 요약 문서는 커밋한다
|
||||
research/youtube/*.srt
|
||||
research/youtube/*.vtt
|
||||
apps/site/tools/.gen/
|
||||
|
|
|
|||
9
apps/site/.htmlvalidate.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": ["html-validate:standard"],
|
||||
"rules": {
|
||||
"require-sri": "off",
|
||||
"no-inline-style": "off",
|
||||
"doctype-style": "off",
|
||||
"void-style": "off"
|
||||
}
|
||||
}
|
||||
13
apps/site/.stylelintrc.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
|
@ -489,3 +489,304 @@ Codex CLI 의 내장 `image_gen`(gpt-image-2)을 ChatGPT 구독 인증으로 호
|
|||
---
|
||||
|
||||
`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 번호(01–05)·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=(start−9)×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:00–14: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개, **주간
|
||||
자리 미니 격자** 9–18시 비례 레일, 평가 비중 **누적 막대 한 줄** — 같은 남색의 농도
|
||||
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 을 1–2px 차이내 별도 줄로 오탐 → **top 묶음에 ±5px 허용오차 + 높이 교차검증**. ② 비전 검수는 작은 글자에서 환각(안 끊긴 시간을 끊겼다고 보고, 안 늘어난 카드를 늘어났다고 보고) → **비전은 끊김 '지점' 평가에만 쓰고, 여부는 계측으로 확정**한다.
|
||||
- 적은 수정: 라벨·본문 전역 `word-break: keep-all; overflow-wrap: anywhere; text-wrap: pretty`(한글은 어절에서만 끊고 외톨이 줄 방지), 마감 임박·예정 상담 행은 ≤680px 에서 세로 스택, 공지 행 ≤680px 1열(제목 전폭), tfoot 라벨 nowrap, 두레 과목행 부정보는 **세그먼트(코드·교수·시간·강의실)별 nowrap `<i>`** — 시간 값 "10:00–11: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 의 그리드는 자동 배치를 믿지 않는다** — 좌표를 명시하라. 격자 레이아웃 검사에는 '라벨 등간격(Δ 균일)' 불변식을 넣을 가치가 있다(스킬 하니스 규칙 후보).
|
||||
|
|
|
|||
90
apps/site/gate-report.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# 게이트 리포트 — 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
|
||||
```
|
||||
65
apps/site/gate.config.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
2411
apps/site/package-lock.json
generated
Normal file
|
|
@ -7,11 +7,21 @@
|
|||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"typecheck": "echo \"(사이트는 build 로 검증한다)\""
|
||||
"typecheck": "echo \"(사이트는 build 로 검증한다)\"",
|
||||
"verify": "node tools/verify.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.1.1",
|
||||
"sharp": "^0.35.3",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5
apps/site/public/_headers
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# 워크 페이지(HTML·CSS)는 즉시 갱신이 보여야 한다 — 데모 갤러리의 생명.
|
||||
# HTML 은 CF Pages 기본이 max-age=0 이지만 CSS 가 4시간 캐시되어
|
||||
# 새 HTML + 옛 CSS 조합이 사용자에게 그려진 적이 있다(2026-08-22 실측).
|
||||
/work/*
|
||||
Cache-Control: public, max-age=0, must-revalidate
|
||||
BIN
apps/site/public/work/baekje-celadon/assets/crazing.webp
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
apps/site/public/work/baekje-celadon/assets/ewer-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/site/public/work/baekje-celadon/assets/inlay.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
210
apps/site/public/work/baekje-celadon/index.html
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>비색의 침묵 · 고려청자 특별전</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="천 년의 비취색, 흙과 불이 빚은 침묵의 곡선. 고려청자 유약의 빙렬과 상감을 세 개의 방에서 감상하는 특별전. 2026년 가을, 서울."
|
||||
/>
|
||||
<meta property="og:title" content="비색의 침묵 · 고려청자 특별전" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="유약의 빙렬과 상감 문양을 세 개의 방에서 감상하는 특별전."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#121514" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23121514'/%3E%3Cpath d='M11 6h10c-1 3-1 5 0 7 2 3 3 5 3 8 0 4-3 7-8 7s-8-3-8-7c0-3 1-5 3-8 1-2 1-4 0-7z' fill='none' stroke='%236FA08F' stroke-width='1.5'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">비색의 침묵</a>
|
||||
<nav class="nav" aria-label="섹션">
|
||||
<a href="#room-1">빙렬의 방</a>
|
||||
<a href="#room-2">상감의 방</a>
|
||||
<a href="#visit">관람</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
첫 방 — 암실. 전시 포스터가 아니라 첫 유물 하나.
|
||||
명암이 곧 구조다: 사진이 어두울수록 유약이 가까워진다.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap hero-grid">
|
||||
<figure class="hero-figure reveal">
|
||||
<img
|
||||
src="./assets/ewer-dark.webp"
|
||||
alt="암실 조명 아래 어두운 받침대 위에 놓인 고려청자 주자의 비취색 실루엣"
|
||||
width="960"
|
||||
height="1280"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<figcaption>
|
||||
<span>청자 상감운학문 주자 · 12세기</span>
|
||||
<span>호암미술관 소장</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div class="hero-text">
|
||||
<p class="hero-kicker reveal" style="--delay: 100ms">
|
||||
특별전 · 2026 가을
|
||||
</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 160ms">
|
||||
천 년의 비색은<br />말이 없다
|
||||
</h1>
|
||||
<p class="lead reveal" style="--delay: 240ms">
|
||||
비색(翡色)은 말로 설명되는 색이 아닙니다. 1,280°C의 환원 불에서만
|
||||
나오는, 푸르름과 푸름이 아닌 사이의 색. 이 전시는 그 색을 설명하지
|
||||
않고 세 개의 암실에 놓아 두었습니다.
|
||||
</p>
|
||||
<div class="hero-actions reveal" style="--delay: 300ms">
|
||||
<a class="btn" href="#visit">도슨트 예약</a>
|
||||
<p class="hero-note">9월 5일 – 11월 22일 · 국립중앙박물관 기획전시실</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
첫 번째 방 — 빙렬. 유약이 식으며 스스로 그은 그림.
|
||||
풀블리드 판: 페이지에서 가장 큰 면적을 질감에 내어준다.
|
||||
-->
|
||||
<section id="room-1" class="section plate" aria-labelledby="room1-h">
|
||||
<div class="wrap plate-head">
|
||||
<p class="room-no reveal">제1실</p>
|
||||
<h2 id="room1-h" class="reveal" style="--delay: 60ms">빙렬 — 유약이 남긴 지도</h2>
|
||||
<p class="lead reveal" style="--delay: 120ms">
|
||||
가마에서 꺼내진 청자는 식으면서 유약이 갈라집니다. 흙과 유약이 줄어드는
|
||||
속도가 다르기 때문입니다. 그 균열은 결함이 아니라, 그릇마다 단 한 번만
|
||||
그려지는 지도입니다.
|
||||
</p>
|
||||
</div>
|
||||
<figure class="plate-figure reveal" style="--delay: 160ms">
|
||||
<img
|
||||
src="./assets/crazing.webp"
|
||||
alt="청자 유약 표면의 빙렬 — 미세한 균열망이 비취색 유약 위에 퍼져 있다"
|
||||
width="1600"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption class="wrap">
|
||||
빙렬 확대 · 12세기 청자 표면 · 측광
|
||||
</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
두 번째 방 — 상감. 새기고 채우는 글자 없는 문장.
|
||||
사진은 절반만. 나머지 절반은 문양의 문법을 텍스트로.
|
||||
-->
|
||||
<section id="room-2" class="section" aria-labelledby="room2-h">
|
||||
<div class="wrap two">
|
||||
<div class="two-text">
|
||||
<p class="room-no reveal">제2실</p>
|
||||
<h2 id="room2-h" class="reveal" style="--delay: 60ms">
|
||||
상감 — 새기고,<br />흙으로 채우다
|
||||
</h2>
|
||||
<p class="reveal" style="--delay: 120ms">
|
||||
상감은 그림을 그리는 게 아니라 파내는 기술입니다. 흙 그릇에 무늬를
|
||||
새기고, 그 홈에 백토와 자토를 문질러 채운 뒤 유약을 입힙니다. 불에
|
||||
들어가기 전까지는 무늬가 보이지 않습니다 — 가마를 열기 전까지 장인도
|
||||
결과를 알 수 없었습니다.
|
||||
</p>
|
||||
<p class="muted reveal" style="--delay: 180ms">
|
||||
제2실에는 구름·학·버드나무의 세 가지 문양만을 전시합니다. 상감청자의
|
||||
문양은 자연의 모방이 아니라, 보이지 않는 것(구름, 바람, 계절)을
|
||||
보이는 것으로 옮긴 문법입니다.
|
||||
</p>
|
||||
</div>
|
||||
<figure class="two-photo reveal" style="--delay: 100ms">
|
||||
<img
|
||||
src="./assets/inlay.webp"
|
||||
alt="청자 상감 문양 접사 — 흑백 상감 획이 비취 유약 위에 새겨져 있다"
|
||||
width="1100"
|
||||
height="825"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>상감 모란당초문 · 접사</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
관람 — 예약과 안내. 마지막 방은 서류다.
|
||||
-->
|
||||
<section id="visit" class="section band" aria-labelledby="visit-h">
|
||||
<div class="wrap visit-grid">
|
||||
<div>
|
||||
<p class="room-no reveal">안내</p>
|
||||
<h2 id="visit-h" class="reveal" style="--delay: 60ms">세 개의 방, 예약제</h2>
|
||||
<p class="reveal" style="--delay: 120ms">
|
||||
암실 조명 때문에 회당 인원을 30명으로 제한합니다. 도슨트는 하루 세 번,
|
||||
유약과 가마 이야기만 합니다.
|
||||
</p>
|
||||
</div>
|
||||
<dl class="visit-dl reveal" style="--delay: 160ms">
|
||||
<div>
|
||||
<dt>기간</dt>
|
||||
<dd>2026년 9월 5일 – 11월 22일 · 월요일 휴관</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>시간</dt>
|
||||
<dd>10:00–18:00 · 금·토 21:00까지</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>도슨트</dt>
|
||||
<dd>11:00 / 14:00 / 16:00 · 회당 40분</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>예약</dt>
|
||||
<dd><a class="link" href="#visit">국립중앙박물관 예약 시스템</a></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>비색의 침묵 · 고려청자 특별전</p>
|
||||
<p class="muted">국립중앙박물관 기획전시실 · 서울 용산</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
368
apps/site/public/work/baekje-celadon/styles/main.css
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
비색의 침묵 — editorial-archival. 암실의 세 개의 방.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2) {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
:where(p, li, dd, dt, th, td, figcaption) {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--ink-muted);
|
||||
max-width: 32em;
|
||||
}
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
.link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.link:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 첫 방(히어로) ────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-7);
|
||||
}
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: end;
|
||||
}
|
||||
.hero-figure img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.hero-figure figcaption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.hero-kicker {
|
||||
font-size: var(--step--1);
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.12em;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.hero-text .lead {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.hero-actions {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-note {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--ink-bright);
|
||||
}
|
||||
|
||||
/* ── 방 번호 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.room-no {
|
||||
font-size: var(--step--1);
|
||||
color: var(--accent-dim);
|
||||
letter-spacing: 0.4em;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── 제1실 — 풀블리드 판 ──────────────────────────────────────────────── */
|
||||
|
||||
.plate-head {
|
||||
max-width: 40em;
|
||||
}
|
||||
.plate-head h2 {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.plate-head .lead {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.plate-figure {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.plate-figure img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.plate-figure figcaption {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── 제2실 — 2단 ──────────────────────────────────────────────────────── */
|
||||
|
||||
.two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.two-text h2 {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.two-text p + p {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.two-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.two-photo figcaption {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── 안내 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.visit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.visit-grid h2 {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.visit-dl {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.visit-dl dt {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.visit-dl dd {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-5);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 14px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid,
|
||||
.two,
|
||||
.visit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero-grid {
|
||||
align-items: start;
|
||||
}
|
||||
.hero-figure {
|
||||
max-width: 26rem;
|
||||
}
|
||||
.hero,
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
54
apps/site/public/work/baekje-celadon/styles/tokens.css
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* ==========================================================================
|
||||
비색의 침묵 — 디자인 토큰
|
||||
프리셋: editorial-archival
|
||||
컨셉 한 문장: 암실의 세 개의 방 — 사진이 어두울수록 유약이 가까워진다.
|
||||
레퍼런스: 방향 앵커는 dogfood-celadon(국립중앙박물관 등 전시 계열). 그 파일의
|
||||
6축 수치는 audit-references.md 에서 날조로 판명 — 토큰은 브리프에서
|
||||
다시 정했다. 박물관 암실의 실제 조건(어두운 벽, 스포트라이트)에서 유래.
|
||||
하지 않은 것: 포스터형 그라디언트 배경, 관람객 수 지표, 전시 인트로 영상
|
||||
자리. 이유: 암실의 권위는 어둠의 면적에서 나온다.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — 최대(58px) ÷ 본문(16px) ≈ 3.6배 */
|
||||
--step--1: 0.8125rem; /* 13px */
|
||||
--step-0: 1rem; /* 16px */
|
||||
--step-1: 1.25rem; /* 20px */
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem); /* 28px */
|
||||
--step-3: clamp(2.125rem, 1.4rem + 3.4vw, 3.625rem);/* 58px */
|
||||
|
||||
--leading-tight: 1.3;
|
||||
--leading-normal: 1.85;
|
||||
|
||||
--font-display: "MaruBuri", serif;
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
|
||||
/* 색 — 암실. 배경은 순흑이 아니라 석재 회색(벽), 강조는 비색 그 자체. */
|
||||
--surface: #121514;
|
||||
--surface-raised: #191d1c;
|
||||
--ink: #eceae2; /* 백색이 아니라 골동지의 밝기 */
|
||||
--ink-muted: #a7a69b;
|
||||
--ink-faint: #7b7d76;
|
||||
--line: #2b302e;
|
||||
--accent: #8fbcaa; /* 비색 — 텍스트용으로 밝힘. 배경 채움 금지 */
|
||||
--accent-dim: #5e8274;
|
||||
--ink-bright: #ffffff; /* CTA hover */
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
|
||||
|
||||
--radius: 2px;
|
||||
|
||||
--container: 1120px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 480ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
30
apps/site/public/work/dure-enrollment/calc.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* 두레 수강신청 — 순수 계산 로직. 브라우저와 Node 테스트가 같은 함수를 공유한다. */
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
// 두 과목의 시간 겹침 — 닿는 경계(end == start)는 겹침이 아니다.
|
||||
const isOverlap = (a, b) => {
|
||||
const daysA = a.days || [], daysB = b.days || [];
|
||||
const shared = daysA.some((d) => daysB.includes(d));
|
||||
if (!shared) return false;
|
||||
return a.start < b.end && b.start < a.end;
|
||||
};
|
||||
|
||||
const credits = (list) => (list || []).reduce((acc, c) => acc + (c.credits || 0), 0);
|
||||
|
||||
// 학점 초과 예측 — 현재 신청 학점에 이 과목을 더했을 때 기준 초과 여부
|
||||
const wouldExceed = (currentCredits, courseCredits, max) =>
|
||||
currentCredits + courseCredits > max;
|
||||
|
||||
const seatsState = (c) => {
|
||||
if (!c.seats) return { label: "정원 마감", closed: true, near: false };
|
||||
return { label: `여석 ${c.seats}`, closed: false, near: c.seats <= 5 };
|
||||
};
|
||||
|
||||
// 이수 잔여 — 음수면 '초과'
|
||||
const remaining = (need, done, now) => need - done - now;
|
||||
|
||||
const api = { isOverlap, credits, wouldExceed, seatsState, remaining };
|
||||
global.DURE = api;
|
||||
if (typeof module !== "undefined") module.exports = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
771
apps/site/public/work/dure-enrollment/index.html
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>두레대학 수강신청 · 시간표</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="두레대학 수강신청 — 시간표 도장 앱" />
|
||||
<meta property="og:url" content="https://designpaca.chanpaca.net/work/dure-enrollment/" />
|
||||
<meta property="og:image" content="https://designpaca.chanpaca.net/work/dure-enrollment/og.png" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="두레대학 수강신청 — 시간표 도장 앱" />
|
||||
<meta name="twitter:image" content="https://designpaca.chanpaca.net/work/dure-enrollment/og.png" />
|
||||
<meta
|
||||
name="description"
|
||||
content="과목을 고르면 시간표에 얹히는 수강신청 데모. 관심 과목, 신청 내역, 이수 현황까지 여섯 화면. 시간 겹침과 학점 초과를 실제로 검사합니다."
|
||||
/>
|
||||
<meta property="og:title" content="두레대학 수강신청 · 시간표" />
|
||||
<meta property="og:description" content="시간표에 얹히는 수강신청." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#FBFAF7" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=IBM+Plex+Sans+KR:wght@400;500;600&family=Noto+Serif+KR:wght@500;600&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=18" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23FBFAF7'/%3E%3Crect x='6' y='6' width='20' height='20' fill='none' stroke='%2317202E' stroke-width='2'/%3E%3Crect x='13' y='13' width='6' height='6' fill='%23C14B2B'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<h1 class="sr-only">두레대학 수강신청</h1>
|
||||
<header class="appbar">
|
||||
<div class="appbar-inner">
|
||||
<a class="brand" href="#main">
|
||||
<span class="brand-seal" aria-hidden="true">申</span>
|
||||
두레대학 수강신청
|
||||
</a>
|
||||
<p class="credits" aria-live="polite">
|
||||
신청 학점 <strong id="credit-now">0</strong> / 18
|
||||
</p>
|
||||
</div>
|
||||
<nav class="tabs" aria-label="화면">
|
||||
<button type="button" class="tab is-current" data-view="catalog" aria-current="page">과목 찾기</button>
|
||||
<button type="button" class="tab" data-view="starred">관심 과목</button>
|
||||
<button type="button" class="tab" data-view="enrollments">신청 내역</button>
|
||||
<button type="button" class="tab" data-view="credits">이수 현황</button>
|
||||
<button type="button" class="tab" data-view="archive">과목 총람</button>
|
||||
<button type="button" class="tab" data-view="notices">공지 · 변경</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="main" class="layout">
|
||||
<!-- ── 과목 찾기 ── -->
|
||||
<section class="view is-on" id="view-catalog" aria-labelledby="cat-h" tabindex="-1">
|
||||
<section class="catalog" aria-labelledby="cat-h">
|
||||
<h2 class="v-h" id="cat-h">2026학년도 1학기 과목</h2>
|
||||
<p class="view-sub">과목을 누르면 강의계획과 신청 버튼이 열립니다. 신청하면 시간표에 얹힙니다.</p>
|
||||
|
||||
<div class="sheet">
|
||||
<div class="toolbar" role="search">
|
||||
<label class="sr-only" for="q">과목 검색</label>
|
||||
<input id="q" type="search" placeholder="과목명, 교수, 코드로 검색" autocomplete="off" />
|
||||
<div class="chips" role="group">
|
||||
<button type="button" class="chip is-on" data-dept="all" aria-pressed="true">전체</button>
|
||||
<button type="button" class="chip" data-dept="전공" aria-pressed="false">전공</button>
|
||||
<button type="button" class="chip" data-dept="교양" aria-pressed="false">교양</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="course-list" id="course-list"></ul>
|
||||
<p class="empty muted" id="course-empty" hidden>조건에 맞는 과목이 없습니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="timetable-panel" aria-labelledby="tt-h">
|
||||
<h2 id="tt-h">내 시간표</h2>
|
||||
<p class="muted tt-hint">시간표 블록을 누르면 취소할 수 있습니다.</p>
|
||||
<div class="sheet sheet-tt">
|
||||
<div class="tt-scroll">
|
||||
<div class="tt" id="tt">
|
||||
<!-- JS: 축 + 블록 -->
|
||||
</div>
|
||||
</div>
|
||||
<p class="status" id="status" role="status" aria-live="polite"></p>
|
||||
<p class="tt-legend">
|
||||
<span class="key"><i class="k-major"></i>전공</span>
|
||||
<span class="key"><i class="k-gen"></i>교양</span>
|
||||
<span class="key"><i class="k-seal"></i>신청됨</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- ── 관심 과목 ── -->
|
||||
<section class="view" id="view-starred" aria-labelledby="st-h" tabindex="-1">
|
||||
<h2 class="v-h" id="st-h">관심 과목</h2>
|
||||
<p class="view-sub">강의계획에서 관심을 남기면 여기에 모입니다. 여석과 시간은 실시간입니다.</p>
|
||||
<div class="statline" id="st-stats"></div>
|
||||
<p class="st-warn" id="st-warn" role="status" hidden></p>
|
||||
<div class="toolbar">
|
||||
<div class="chips" role="group" id="st-chips">
|
||||
<button type="button" class="chip is-on" data-st="all" aria-pressed="true">전체</button>
|
||||
<button type="button" class="chip" data-st="전공" aria-pressed="false">전공</button>
|
||||
<button type="button" class="chip" data-st="교양" aria-pressed="false">교양</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sheet">
|
||||
<ul class="course-list" id="starred-list"></ul>
|
||||
<div class="empty-box" id="starred-empty" hidden>
|
||||
<p>아직 관심 과목이 없습니다.</p>
|
||||
<p class="muted">과목 찾기에서 과목을 열고 관심 남기기를 누르면 여기에 쌓입니다.</p>
|
||||
<button type="button" class="btn-line" data-goto="catalog">과목 찾기로 가기</button>
|
||||
</div>
|
||||
<section class="panel st-suggest" id="st-suggest" aria-labelledby="stsg-h" hidden>
|
||||
<h2 id="stsg-h">지금 여석이 임박한 과목 <span class="h-note">관심을 남겨 두면 마감 전에 확인할 수 있습니다</span></h2>
|
||||
<ul class="course-list" id="st-suggest-list"><!-- JS --></ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 신청 내역 ── -->
|
||||
<section class="view" id="view-enrollments" aria-labelledby="en-h" tabindex="-1">
|
||||
<h2 class="v-h" id="en-h">신청 내역</h2>
|
||||
<p class="view-sub">이번 학기에 신청한 과목의 원장. 여기서도 취소할 수 있습니다.</p>
|
||||
<div class="statline" id="en-stats"></div>
|
||||
<div class="sheet">
|
||||
<p class="en-sum mono">합계 <strong id="en-credits">0</strong>학점 / 18</p>
|
||||
<div class="credit-gauge" role="img">
|
||||
<i id="en-gauge" style="width: 0%"></i>
|
||||
</div>
|
||||
<p class="panel-note muted">정정 기간은 3/9–3/13입니다. 18학점 초과 신청은 학부 사무실 승인이 필요하고, 두 번 결석하면 탈강 처리됩니다 — 변경은 이 화면에서 할 수 있습니다.</p>
|
||||
<div class="table-wrap">
|
||||
<table id="en-table">
|
||||
<caption class="sr-only">신청 과목 목록</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">과목</th><th scope="col">시간</th><th scope="col">장소</th><th scope="col" class="num">학점</th><th scope="col">상태</th></tr>
|
||||
</thead>
|
||||
<tbody><!-- JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="empty-box" id="en-empty" hidden>
|
||||
<p>신청한 과목이 없습니다.</p>
|
||||
<p class="muted">시간표가 비어 있습니다. 과목 찾기에서 첫 과목을 얹어보세요.</p>
|
||||
<button type="button" class="btn-line" data-goto="catalog">과목 찾기로 가기</button>
|
||||
</div>
|
||||
<p class="status" id="en-status" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 이수 현황 ── -->
|
||||
<section class="view" id="view-credits" aria-labelledby="cr-h" tabindex="-1">
|
||||
<h2 class="v-h" id="cr-h">이수 현황</h2>
|
||||
<p class="view-sub">네 학기를 마친 시점의 기록에 이번 학기 신청을 얹어 봅니다. 신청은 확정이 아닙니다.</p>
|
||||
<div class="statline" id="cr-stats"></div>
|
||||
<div class="sheet table-wrap">
|
||||
<table id="cr-table">
|
||||
<caption class="sr-only">졸업 기준 대비 이수 학점</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">구분</th><th scope="col" class="num">졸업 기준</th>
|
||||
<th scope="col" class="num">이수</th><th scope="col" class="num">이번 학기 신청</th>
|
||||
<th scope="col" class="num">합계</th><th scope="col" class="num">잔여</th><th scope="col">진행</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><!-- JS --></tbody>
|
||||
</table>
|
||||
<p class="sheet-note muted">잔여가 0보다 작아지면 초과입니다. 학점 초과 신청은 학부 사무실 승인이 필요합니다.</p>
|
||||
<div class="cr-total">
|
||||
<p class="panel-note muted">졸업 기준 대비 총 이수율 <strong class="cr-total-num mono">—</strong></p>
|
||||
<div class="cr-bar"><i id="cr-total-bar" style="width: 0%"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 과목 총람 ── -->
|
||||
<section class="view" id="view-archive" aria-labelledby="ar-h" tabindex="-1">
|
||||
<h2 class="v-h" id="ar-h">과목 총람</h2>
|
||||
<p class="view-sub">개설 과목 전부를 계열별로 묶어 적습니다. 여석이 5 이하인 과목은 붉게 표시합니다.</p>
|
||||
<div class="statline" id="ar-stats"></div>
|
||||
<div class="sheet table-wrap">
|
||||
<table id="ar-table">
|
||||
<caption class="sr-only">개설 과목 전체 목록</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">코드</th><th scope="col">과목</th><th scope="col">교수</th>
|
||||
<th scope="col">시간</th><th scope="col" class="num">학점</th>
|
||||
<th scope="col" class="num">여석 / 정원</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><!-- JS --></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 공지 · 변경 ── -->
|
||||
<section class="view" id="view-notices" aria-labelledby="no-h" tabindex="-1">
|
||||
<h2 class="v-h" id="no-h">공지 · 변경</h2>
|
||||
<div class="statline" id="nc-stats"></div>
|
||||
<p class="view-sub">수강신청 기간의 게시판. 강의실 변경은 이 원장에 남깁니다.</p>
|
||||
<div class="sheet">
|
||||
<ul class="notice-list" id="notice-list"><!-- JS --></ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- ── 강의계획 모달: 펼침 장부. 왼쪽 남색 표지판 + 오른쪽 계획 내용 ── -->
|
||||
<dialog id="course-dialog" class="modal modal-split" aria-labelledby="cd-h">
|
||||
<div class="mspread">
|
||||
<div class="mcover">
|
||||
<span class="mcover-mark" aria-hidden="true">申</span>
|
||||
<span class="mcover-seal" aria-hidden="true">申</span>
|
||||
<h2 class="mcover-name" id="cd-h">과목명</h2>
|
||||
<p class="mcover-meta mono" id="cd-code">CODE · 계열</p>
|
||||
<dl class="mcover-stats">
|
||||
<div><dt>학점</dt><dd class="mono" id="cd-credit">—</dd></div>
|
||||
<div><dt>여석</dt><dd class="mono" id="cd-seats">—</dd></div>
|
||||
<div><dt>시간</dt><dd class="mono" id="cd-time">—</dd></div>
|
||||
</dl>
|
||||
<p class="mcover-foot mono">2026학년도 1학기</p>
|
||||
</div>
|
||||
<div class="msheet">
|
||||
<button type="button" class="modal-close" data-close aria-label="닫기">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" /></svg>
|
||||
</button>
|
||||
|
||||
<p class="cd-line" id="cd-prof-line">교수 · 강의실</p>
|
||||
|
||||
<section class="cd-week msec">
|
||||
<h3>주간 자리</h3>
|
||||
<div class="cd-strip" id="cd-strip" role="img"></div>
|
||||
</section>
|
||||
|
||||
<section class="eval msec">
|
||||
<h3>평가</h3>
|
||||
<div class="eval-bar" id="cd-eval-bar" aria-hidden="true"></div>
|
||||
<dl class="eval-legend" id="cd-eval"></dl>
|
||||
</section>
|
||||
|
||||
<p class="warn" id="cd-warn" hidden></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-seal" id="cd-apply">수강 신청</button>
|
||||
<button type="button" class="btn-line" id="cd-cancel" hidden>신청 취소</button>
|
||||
<button type="button" class="btn-star" id="cd-star" aria-pressed="false">관심 남기기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script src="calc.js"></script>
|
||||
<script>
|
||||
/*
|
||||
수강신청 상태 기계 — 겹침 검사, 학점 한도, 여석 감소까지 전부 진짜.
|
||||
여섯 화면이 같은 상태(enrolled, starred)를 공유한다.
|
||||
새로고침하면 초기화됩니다(데모 원칙: 저장을 흉내내지 않는다).
|
||||
*/
|
||||
const fmtDay = (d) => ({ 1: "월", 2: "화", 3: "수", 4: "목", 5: "금" })[d];
|
||||
|
||||
const COURSES = [
|
||||
{ id: "hum201", code: "HUM201", name: "문학과 사회", dept: "교양", prof: "김선희", room: "인문관 302", credits: 3, cap: 40, seats: 12, days: [1, 3], start: 10, end: 11.5, eval: [["출석", 10], ["발표", 30], ["기말", 60]] },
|
||||
{ id: "cs101", code: "CS101", name: "컴퓨팅 사고", dept: "교양", prof: "이준호", room: "공학관 105", credits: 3, cap: 60, seats: 31, days: [2, 4], start: 13, end: 14.5, eval: [["과제", 40], ["중간", 30], ["기말", 30]] },
|
||||
{ id: "econ210", code: "ECON210", name: "미시경제학", dept: "전공", prof: "박민재", room: "경상관 210", credits: 3, cap: 45, seats: 8, days: [1, 3], start: 13, end: 14.5, eval: [["과제", 30], ["중간", 30], ["기말", 40]] },
|
||||
{ id: "stat110", code: "STAT110", name: "통계의 기초", dept: "교양", prof: "정하나", room: "자연관 118", credits: 3, cap: 50, seats: 20, days: [5], start: 9, end: 12, eval: [["퀴즈", 20], ["중간", 40], ["기말", 40]] },
|
||||
{ id: "phil115", code: "PHIL115", name: "논리와 토론", dept: "교양", prof: "오세영", room: "인문관 115", credits: 2, cap: 35, seats: 14, days: [2], start: 16, end: 18, eval: [["발표", 50], ["기말", 50]] },
|
||||
{ id: "soc220", code: "SOC220", name: "도시와 사람", dept: "전공", prof: "한유진", room: "사회관 204", credits: 3, cap: 40, seats: 17, days: [4], start: 10, end: 12.5, eval: [["리포트", 40], ["발표", 20], ["기말", 40]] },
|
||||
{ id: "bio105", code: "BIO105", name: "생명과학 산책", dept: "교양", prof: "남기훈", room: "자연관 B1", credits: 2, cap: 60, seats: 25, days: [1], start: 14, end: 16, eval: [["출석", 20], ["리포트", 80]] },
|
||||
{ id: "art130", code: "ART130", name: "드로잉 입문", dept: "교양", prof: "서지우", room: "예술관 301", credits: 3, cap: 25, seats: 3, days: [3], start: 14, end: 17, eval: [["작품", 60], ["기말작품", 40]] },
|
||||
{ id: "econ315", code: "ECON315", name: "게임이론", dept: "전공", prof: "최도윤", room: "경상관 308", credits: 3, cap: 30, seats: 6, days: [2, 4], start: 10, end: 11.5, eval: [["과제", 30], ["중간", 30], ["기말", 40]] },
|
||||
{ id: "cs210", code: "CS210", name: "자료구조", dept: "전공", prof: "강태양", room: "공학관 212", credits: 3, cap: 40, seats: 22, days: [5], start: 13, end: 16, eval: [["실습", 40], ["중간", 30], ["기말", 30]] },
|
||||
];
|
||||
|
||||
const NOTICES = [
|
||||
{ date: "03-08", kind: "변경", body: "게임이론(ECON315) 강의실이 경상관 308호에서 309호로 바뀝니다. 이 목록에는 이미 반영했습니다." },
|
||||
{ date: "03-07", kind: "공지", body: "수강신청 기간은 3월 2일부터 3월 13일 자정까지입니다. 기간 끝에는 시간표가 확정됩니다." },
|
||||
{ date: "03-06", kind: "안내", body: "기준 학점(18)을 넘는 신청은 학부 사무실의 승인을 받아야 합니다. 이 데모에서는 넘는 신청을 막아둡니다." },
|
||||
{ date: "03-05", kind: "안내", body: "드로잉 입문(ART130)은 재료비 3만 원이 듭니다. 첫 수업에 안내합니다." },
|
||||
{ date: "03-03", kind: "공지", body: "정원이 마감된 과목은 대기자 신청을 받지 않습니다. 분반 개설은 학부 상황에 따라 결정됩니다." },
|
||||
{ date: "03-02", kind: "공지", body: "2026학년도 1학기 수강신청이 열렸습니다. 시간이 겹치는 과목은 함께 담을 수 없습니다." },
|
||||
];
|
||||
|
||||
const MAX_CREDITS = 18;
|
||||
// 네 학기를 마친 시점의 이수 기록 — 이 데모의 가상 학생
|
||||
const RECORD = [
|
||||
{ label: "전공", need: 66, done: 45 },
|
||||
{ label: "교양", need: 24, done: 21 },
|
||||
{ label: "자유", need: 40, done: 28 },
|
||||
];
|
||||
const enrolled = new Set();
|
||||
const starred = new Set();
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const hhmm = (t) => `${Math.floor(t)}:${t % 1 ? "30" : "00"}`;
|
||||
const timeLabel = (c) => c.days.map(fmtDay).join("·") + " " + hhmm(c.start) + "–" + hhmm(c.end);
|
||||
|
||||
// ── 탭(뷰) 전환 ──
|
||||
const views = document.querySelectorAll(".view");
|
||||
const gotoView = (name) => {
|
||||
document.querySelectorAll(".tab").forEach((b) => {
|
||||
const on = b.dataset.view === name;
|
||||
b.classList.toggle("is-current", on);
|
||||
if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
|
||||
});
|
||||
views.forEach((v) => v.classList.toggle("is-on", v.id === "view-" + name));
|
||||
$("view-" + name).focus({ preventScroll: true });
|
||||
};
|
||||
document.querySelectorAll(".tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => gotoView(btn.dataset.view));
|
||||
});
|
||||
document.querySelectorAll("[data-goto]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => gotoView(btn.dataset.goto));
|
||||
});
|
||||
|
||||
// ── 겹침 검사 ──
|
||||
const conflictsWith = (course) =>
|
||||
[...enrolled]
|
||||
.map((id) => COURSES.find((c) => c.id === id))
|
||||
.filter((o) => DURE.isOverlap(course, o))
|
||||
.map((o) => o.name);
|
||||
|
||||
const credits = () => [...enrolled].reduce((sum, id) => sum + COURSES.find((c) => c.id === id).credits, 0);
|
||||
const creditsOf = (dept) => DURE.credits([...enrolled].map((id) => COURSES.find((c) => c.id === id)).filter((c) => c.dept === dept));
|
||||
|
||||
const setStatus = (msg, isWarn) => {
|
||||
const el = $("status");
|
||||
el.textContent = msg;
|
||||
el.classList.toggle("is-warn", !!isWarn);
|
||||
};
|
||||
|
||||
// ── 과목 행(과목 찾기 · 관심 과목 공용 문법) ──
|
||||
const courseRow = (c) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `
|
||||
<button type="button" class="course-row" data-course="${c.id}">
|
||||
<span class="c-main">
|
||||
<span class="c-name">${c.name} <span class="c-dept">${c.dept}</span></span>
|
||||
<span class="mono c-sub"><i>${c.code}</i> · <i>${c.prof}</i> · <i>${timeLabel(c)}</i> · <i>${c.room}</i></span>
|
||||
</span>
|
||||
<span class="c-side mono">
|
||||
<span class="c-credit">${c.credits}학점</span>
|
||||
<span class="c-seats ${c.seats <= 5 ? "is-low" : ""}">${c.seats === 0 ? "정원 마감" : "여석 " + c.seats}</span>
|
||||
${starred.has(c.id) ? '<span class="c-star">★</span>' : ""}
|
||||
${enrolled.has(c.id) ? '<span class="c-seal">申</span>' : ""}
|
||||
</span>
|
||||
</button>`;
|
||||
return li;
|
||||
};
|
||||
|
||||
// ── 과목 찾기 목록 ──
|
||||
let deptFilter = "all";
|
||||
const renderList = () => {
|
||||
const q = $("q").value.trim();
|
||||
const rows = COURSES.filter(
|
||||
(c) =>
|
||||
(deptFilter === "all" || c.dept === deptFilter) &&
|
||||
(!q || c.name.includes(q) || c.prof.includes(q) || c.code.toLowerCase().includes(q.toLowerCase()))
|
||||
);
|
||||
$("course-list").replaceChildren(...rows.map(courseRow));
|
||||
$("course-empty").hidden = rows.length> 0;
|
||||
};
|
||||
$("q").addEventListener("input", renderList);
|
||||
document.querySelectorAll(".chip[data-dept]").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
document.querySelectorAll(".chip[data-dept]").forEach((c) => {
|
||||
c.classList.toggle("is-on", c === chip);
|
||||
c.setAttribute("aria-pressed", c === chip ? "true" : "false");
|
||||
});
|
||||
deptFilter = chip.dataset.dept;
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 관심 과목 ──
|
||||
let stDept = "all";
|
||||
const renderStarred = () => {
|
||||
const all = COURSES.filter((c) => starred.has(c.id));
|
||||
const rows = all.filter((c) => stDept === "all" || c.dept === stDept);
|
||||
$("starred-list").replaceChildren(...rows.map(courseRow));
|
||||
$("starred-empty").hidden = rows.length> 0;
|
||||
// 빈 상태일 때 — 여석 임박 추천으로 화면을 채운다(빈 상태는 안내다)
|
||||
const suggest = $("st-suggest");
|
||||
if (!all.length) {
|
||||
suggest.hidden = false;
|
||||
$("st-suggest-list").replaceChildren(
|
||||
...COURSES.filter((c) => !enrolled.has(c.id) && c.seats> 0).sort((a, b) => a.seats - b.seats).slice(0, 3).map(courseRow)
|
||||
);
|
||||
} else {
|
||||
suggest.hidden = true;
|
||||
}
|
||||
// 요약 — 관심 기준 실계산
|
||||
const credits = all.reduce((a, c) => a + c.credits, 0);
|
||||
const enrolledCredits = COURSES.filter((c) => enrolled.has(c.id)).reduce((a, c) => a + c.credits, 0);
|
||||
const near = all.filter((c) => c.seats> 0 && c.seats <= 5).length;
|
||||
const closed = all.filter((c) => c.seats === 0).length;
|
||||
$("st-stats").innerHTML =
|
||||
`<div>관심 <strong>${all.length}과목</strong></div>` +
|
||||
`<div>합계 <strong>${credits}학점</strong></div>` +
|
||||
`<div>여석 임박 <strong>${near}과목</strong></div>` +
|
||||
`<div>정원 마감 <strong>${closed}과목</strong></div>`;
|
||||
// 학점 초과 사전 경고 — 오류 예방
|
||||
const warn = $("st-warn");
|
||||
const wouldBe = enrolledCredits + credits;
|
||||
if (!all.length) { warn.hidden = true; return; }
|
||||
warn.hidden = false;
|
||||
if (wouldBe> MAX_CREDITS) {
|
||||
warn.textContent = `모두 신청하면 기준 ${MAX_CREDITS}학점을 ${wouldBe - MAX_CREDITS}학점 초과합니다(${wouldBe}학점) — 신청 전에 조합을 줄이세요.`;
|
||||
warn.classList.add("is-over");
|
||||
} else {
|
||||
warn.textContent = `모두 신청해도 기준 ${MAX_CREDITS}학점 안에 듭니다 — 합계 ${wouldBe}학점 · 잔여 ${MAX_CREDITS - wouldBe}학점.`;
|
||||
warn.classList.remove("is-over");
|
||||
}
|
||||
};
|
||||
document.getElementById("st-chips").addEventListener("click", (e) => {
|
||||
const chip = e.target.closest("[data-st]");
|
||||
if (!chip) return;
|
||||
document.querySelectorAll("#st-chips .chip").forEach((c) => {
|
||||
c.classList.toggle("is-on", c === chip);
|
||||
c.setAttribute("aria-pressed", String(c === chip));
|
||||
});
|
||||
stDept = chip.dataset.st;
|
||||
renderStarred();
|
||||
});
|
||||
|
||||
// ── 신청 내역 ──
|
||||
const renderEnrollments = () => {
|
||||
{
|
||||
const list = COURSES.filter((c) => enrolled.has(c.id));
|
||||
$("en-gauge").style.width = Math.round((list.reduce((a, c) => a + c.credits, 0) / MAX_CREDITS) * 100) + "%";
|
||||
const credits = list.reduce((a, c) => a + c.credits, 0);
|
||||
$("en-stats").innerHTML =
|
||||
`<div>신청 <strong>${list.length}과목</strong></div>` +
|
||||
`<div>${credits}학점 / ${MAX_CREDITS}</div>` +
|
||||
`<div>잔여 <strong>${MAX_CREDITS - credits}학점</strong></div>` +
|
||||
`<div>신청 변경 마감 <strong>3/13</strong></div>`;
|
||||
}
|
||||
const body = document.querySelector("#en-table tbody");
|
||||
body.replaceChildren(
|
||||
...[...enrolled].map((id) => COURSES.find((c) => c.id === id)).map((c) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
`<th scope="row">${c.name}</th><td class="mono">${timeLabel(c)}</td><td>${c.room}</td>` +
|
||||
`<td class="num mono">${c.credits}</td>` +
|
||||
`<td><button type="button" class="give-btn" data-cancel="${c.id}">취소</button></td>`;
|
||||
return tr;
|
||||
})
|
||||
);
|
||||
$("en-empty").hidden = enrolled.size> 0;
|
||||
$("en-credits").textContent = String(credits());
|
||||
};
|
||||
document.querySelector("#en-table tbody").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-cancel]");
|
||||
if (!btn) return;
|
||||
const c = COURSES.find((x) => x.id === btn.dataset.cancel);
|
||||
enrolled.delete(c.id);
|
||||
c.seats += 1;
|
||||
$("en-status").textContent = `${c.name} 취소 · ${credits()}학점`;
|
||||
renderAll();
|
||||
});
|
||||
|
||||
// ── 이수 현황: 기록 + 이번 학기 신청을 합친다 ──
|
||||
const renderCreditsView = () => {
|
||||
const body = document.querySelector("#cr-table tbody");
|
||||
const totalNeed = RECORD.reduce((a, r) => a + r.need, 0);
|
||||
// 개설 과목은 전공·교양뿐이라 자유의 신청은 항상 0 — 표에는 '—' 로 나온다
|
||||
const rows = RECORD.map((r) => ({
|
||||
label: r.label,
|
||||
need: r.need,
|
||||
done: r.done,
|
||||
now: creditsOf(r.label),
|
||||
}));
|
||||
const sum = rows.reduce(
|
||||
(a, r) => ({ need: a.need + r.need, done: a.done + r.done, now: a.now + r.now }),
|
||||
{ need: 0, done: 0, now: 0 }
|
||||
);
|
||||
{
|
||||
const done = sum.done, now = sum.now, need = sum.need;
|
||||
$("cr-stats").innerHTML =
|
||||
`<div>기준 <strong>${need}학점</strong></div>` +
|
||||
`<div>이수 <strong>${done}학점</strong></div>` +
|
||||
`<div>이번 학기 신청 <strong>${now}학점</strong></div>` +
|
||||
`<div>합계 <strong>${done + now}학점</strong></div>` +
|
||||
`<div>잔여 <strong>${need - done - now}학점</strong></div>`;
|
||||
}
|
||||
{
|
||||
$("cr-stats").innerHTML =
|
||||
`<div>기준 <strong>${sum.need}학점</strong></div>` +
|
||||
`<div>이수 <strong>${sum.done}학점</strong></div>` +
|
||||
`<div>이번 학기 신청 <strong>${sum.now}학점</strong></div>` +
|
||||
`<div>합계 <strong>${sum.done + sum.now}학점</strong></div>` +
|
||||
`<div>잔여 <strong>${sum.need - sum.done - sum.now}학점</strong></div>`;
|
||||
}
|
||||
{
|
||||
const pct = Math.min(100, Math.round(((sum.done + sum.now) / sum.need) * 100));
|
||||
document.getElementById("cr-total-bar").style.width = pct + "%";
|
||||
document.querySelector(".cr-total-num").textContent = pct + "%";
|
||||
}
|
||||
body.replaceChildren(
|
||||
...rows.map((r) => {
|
||||
const tr = document.createElement("tr");
|
||||
const total = r.done + r.now;
|
||||
const left = r.need - total;
|
||||
const pct = r.need ? Math.min(100, Math.round((total / r.need) * 100)) : 0;
|
||||
tr.innerHTML =
|
||||
`<th scope="row">${r.label}</th><td class="num mono">${r.need || "—"}</td>` +
|
||||
`<td class="num mono">${r.done || "—"}</td><td class="num mono">${r.now ? "+ " + r.now : "—"}</td>` +
|
||||
`<td class="num mono">${r.need ? total : total || "—"}</td>` +
|
||||
`<td class="num mono">${r.need ? (left> 0 ? left : left === 0 ? "0" : "초과") : "—"}</td>` +
|
||||
`<td><div class="cr-bar" role="img"><i style="width: ${pct}%"></i></div></td>`;
|
||||
return tr;
|
||||
}),
|
||||
(() => {
|
||||
const tr = document.createElement("tr");
|
||||
const left = totalNeed - sum.done - sum.now;
|
||||
tr.innerHTML =
|
||||
`<th scope="row">총계</th><td class="num mono">${totalNeed}</td>` +
|
||||
`<td class="num mono">${sum.done}</td><td class="num mono">+ ${sum.now}</td>` +
|
||||
`<td class="num mono"><strong>${sum.done + sum.now}</strong></td>` +
|
||||
`<td class="num mono">${left> 0 ? left : left === 0 ? "0" : "초과"}</td>`;
|
||||
return tr;
|
||||
})()
|
||||
);
|
||||
};
|
||||
|
||||
// ── 과목 총람: 계열별 소계 포함 ──
|
||||
const renderArchive = () => {
|
||||
const body = document.querySelector("#ar-table tbody");
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const dept of ["전공", "교양"]) {
|
||||
const group = COURSES.filter((c) => c.dept === dept);
|
||||
for (const c of group) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
`<th scope="row" class="mono">${c.code}</th><td>${c.name}</td><td>${c.prof}</td>` +
|
||||
`<td class="mono">${timeLabel(c)}</td><td class="num mono">${c.credits}</td>` +
|
||||
`<td class="num mono">${c.seats <= 5 ? `<span class="low-seats">${c.seats} / ${c.cap}</span>` : `${c.seats} / ${c.cap}`}</td>`;
|
||||
frag.append(tr);
|
||||
}
|
||||
const sub = document.createElement("tr");
|
||||
sub.className = "ar-sub";
|
||||
const seats = group.reduce((a, c) => a + c.seats, 0);
|
||||
sub.innerHTML =
|
||||
`<th scope="row" colspan="4">${dept} 소계 · ${group.length}과목</th>` +
|
||||
`<td class="num mono">${group.reduce((a, c) => a + c.credits, 0)}</td>` +
|
||||
`<td class="num mono">여석 ${seats}</td>`;
|
||||
frag.append(sub);
|
||||
}
|
||||
body.replaceChildren(frag);
|
||||
{
|
||||
const closed = COURSES.filter((c) => c.seats === 0).length;
|
||||
const near = COURSES.filter((c) => c.seats> 0 && c.seats <= 5).length;
|
||||
$("ar-stats").innerHTML =
|
||||
`<div>개설 <strong>${COURSES.length}과목</strong></div>` +
|
||||
`<div>전공 <strong>${COURSES.filter((c) => c.dept === "전공").length}</strong></div>` +
|
||||
`<div>교양 <strong>${COURSES.filter((c) => c.dept === "교양").length}</strong></div>` +
|
||||
`<div>여석 임박 <strong>${near}과목</strong></div>` +
|
||||
`<div>정원 마감 <strong>${closed}과목</strong></div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ── 공지 ──
|
||||
const renderNotices = () => {
|
||||
{
|
||||
const changed = NOTICES.filter((n) => n.kind === "변경").length;
|
||||
$("nc-stats").innerHTML =
|
||||
`<div>공지 <strong>${NOTICES.length}건</strong></div>` +
|
||||
`<div>변경 <strong>${changed}건</strong></div>` +
|
||||
`<div>신청 마감 <strong>3/13 자정</strong></div>`;
|
||||
}
|
||||
$("notice-list").replaceChildren(
|
||||
...NOTICES.map((n) => {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML =
|
||||
`<span class="no-kind" data-kind="${n.kind}">${n.kind}</span>` +
|
||||
`<span class="mono no-date">${n.date}</span>` +
|
||||
`<p class="no-body">${n.body}</p>`;
|
||||
return li;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// ── 시간표 렌더: 축(정적) + 블록(신청분) ──
|
||||
const buildAxis = () => {
|
||||
const tt = $("tt");
|
||||
tt.style.setProperty("--rows", 18); // 9:00–18:00, 30분 단위
|
||||
const hours = Array.from({ length: 9 }, (_, i) => 9 + i);
|
||||
tt.innerHTML =
|
||||
`<div class="tt-corner" aria-hidden="true" style="grid-area: 1 / 1"></div>` +
|
||||
[1, 2, 3, 4, 5].map((d) => `<div class="tt-day" style="grid-row: 1; grid-column: ${d + 1}">${fmtDay(d)}</div>`).join("") +
|
||||
hours
|
||||
.map((h) => {
|
||||
const row = (h - 9) * 2 + 2; // 1행은 요일 헤더, 9시=2행
|
||||
return (
|
||||
`<div class="tt-hour mono" style="grid-row: ${row}; grid-column: 1">${String(h).padStart(2, "0")}</div>` +
|
||||
[1, 2, 3, 4, 5]
|
||||
.map((d) => `<div class="tt-cell" style="grid-row: ${row} / span 2; grid-column: ${d + 1}"></div>`)
|
||||
.join("")
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
const renderBlocks = () => {
|
||||
$("tt").querySelectorAll(".tt-block").forEach((b) => b.remove());
|
||||
const tt = $("tt");
|
||||
enrolled.forEach((id) => {
|
||||
const c = COURSES.find((x) => x.id === id);
|
||||
c.days.forEach((d) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `tt-block ${c.dept === "전공" ? "is-major" : "is-gen"}`;
|
||||
b.dataset.course = c.id;
|
||||
b.style.gridColumn = String(d + 1);
|
||||
/* 행 오프셋 +2: 1행은 요일 헤더. 9시=(9-9)*2+2=2행 */
|
||||
b.style.gridRow = `${(c.start - 9) * 2 + 2} / span ${(c.end - c.start) * 2}`;
|
||||
b.innerHTML = `<span class="tt-block-name">${c.name}</span><span class="tt-block-room">${c.room}</span>`;
|
||||
tt.append(b);
|
||||
});
|
||||
});
|
||||
if (!enrolled.size) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "tt-empty";
|
||||
empty.textContent = "과목을 신청하면 여기에 놓입니다";
|
||||
tt.append(empty);
|
||||
}
|
||||
$("credit-now").textContent = String(credits());
|
||||
};
|
||||
|
||||
const renderAll = () => {
|
||||
renderList();
|
||||
renderStarred();
|
||||
renderEnrollments();
|
||||
renderCreditsView();
|
||||
renderArchive();
|
||||
renderBlocks();
|
||||
};
|
||||
|
||||
// ── 모달 ──
|
||||
const dialog = $("course-dialog");
|
||||
let current = null;
|
||||
const openCourse = (c) => {
|
||||
current = c;
|
||||
$("cd-code").textContent = `${c.code} · ${c.dept}`;
|
||||
$("cd-h").textContent = c.name;
|
||||
$("cd-prof-line").textContent = `${c.prof} 교수 · ${c.room}`;
|
||||
$("cd-time").textContent = timeLabel(c);
|
||||
$("cd-credit").textContent = String(c.credits);
|
||||
$("cd-seats").textContent = `${c.seats} / ${c.cap}`;
|
||||
|
||||
// 주간 자리 — 9:00–18:00 를 세로로 접은 미니 격자
|
||||
const strip = $("cd-strip");
|
||||
strip.setAttribute("aria-label", `${timeLabel(c)} — 시간표에 놓이는 자리`);
|
||||
strip.innerHTML = [1, 2, 3, 4, 5]
|
||||
.map((d) => {
|
||||
const day = fmtDay(d);
|
||||
if (!c.days.includes(d)) {
|
||||
return `<div class="cd-track"><span class="cd-rail"><span class="cd-off mono" aria-hidden="true">·</span></span><span class="cd-day-l">${day}</span></div>`;
|
||||
}
|
||||
const top = ((c.start - 9) / 9) * 100;
|
||||
const h = ((c.end - c.start) / 9) * 100;
|
||||
return `<div class="cd-track"><span class="cd-rail"><span class="cd-slot ${c.dept === "전공" ? "is-major" : "is-gen"}" style="top:${top}%; height:${h}%"></span></span><span class="cd-day-l is-on">${day}</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 평가 — 같은 잉크의 농도로 누적 막대 하나에 담는다
|
||||
$("cd-eval-bar").replaceChildren(
|
||||
...c.eval.map(([, v], idx) => {
|
||||
const seg = document.createElement("span");
|
||||
seg.className = `eval-seg seg-${idx + 1}`;
|
||||
seg.style.width = v + "%";
|
||||
return seg;
|
||||
})
|
||||
);
|
||||
$("cd-eval").replaceChildren(
|
||||
...c.eval.map(([k, v]) => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = `<dt>${k}</dt><dd class="mono">${v}%</dd>`;
|
||||
return div;
|
||||
})
|
||||
);
|
||||
|
||||
const apply = $("cd-apply");
|
||||
const cancel = $("cd-cancel");
|
||||
const warn = $("cd-warn");
|
||||
warn.hidden = true;
|
||||
cancel.hidden = !enrolled.has(c.id);
|
||||
|
||||
if (enrolled.has(c.id)) {
|
||||
apply.hidden = true;
|
||||
} else {
|
||||
apply.hidden = false;
|
||||
apply.disabled = true;
|
||||
const clash = conflictsWith(c);
|
||||
if (c.seats === 0) {
|
||||
warn.textContent = "정원이 마감되었습니다. 다음 학기를 기다리거나 분반을 확인하세요.";
|
||||
warn.hidden = false;
|
||||
} else if (clash.length) {
|
||||
warn.textContent = `${clash.join(", ")}와(과) 시간이 겹칩니다. 둘 중 하나만 담을 수 있습니다.`;
|
||||
warn.hidden = false;
|
||||
} else if (credits() + c.credits> MAX_CREDITS) {
|
||||
warn.textContent = `기준 학점(${MAX_CREDITS})을 초과합니다. 지금 ${credits()}학점입니다.`;
|
||||
warn.hidden = false;
|
||||
} else {
|
||||
apply.disabled = false;
|
||||
}
|
||||
}
|
||||
const star = $("cd-star");
|
||||
star.setAttribute("aria-pressed", String(starred.has(c.id)));
|
||||
star.textContent = starred.has(c.id) ? "관심 빼기" : "관심 남기기";
|
||||
dialog.showModal();
|
||||
};
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-course]");
|
||||
if (btn && !btn.dataset.cancel) openCourse(COURSES.find((c) => c.id === btn.dataset.course));
|
||||
});
|
||||
|
||||
$("cd-apply").addEventListener("click", () => {
|
||||
if (!current || $("cd-apply").disabled) return;
|
||||
current.seats -= 1;
|
||||
enrolled.add(current.id);
|
||||
setStatus(`${current.name} 신청 완료 · ${timeLabel(current)} · ${credits()}학점`, false);
|
||||
dialog.close();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
$("cd-cancel").addEventListener("click", () => {
|
||||
if (!current || !enrolled.has(current.id)) return;
|
||||
enrolled.delete(current.id);
|
||||
current.seats += 1;
|
||||
setStatus(`${current.name} 취소 · ${credits()}학점`, false);
|
||||
dialog.close();
|
||||
renderAll();
|
||||
});
|
||||
|
||||
$("cd-star").addEventListener("click", () => {
|
||||
if (!current) return;
|
||||
if (starred.has(current.id)) {
|
||||
starred.delete(current.id);
|
||||
setStatus(`${current.name} 관심에서 뺐습니다.`, false);
|
||||
} else {
|
||||
starred.add(current.id);
|
||||
setStatus(`${current.name} 관심에 남겼습니다. 관심 과목 화면에서 보세요.`, false);
|
||||
}
|
||||
const star = $("cd-star");
|
||||
star.setAttribute("aria-pressed", String(starred.has(current.id)));
|
||||
star.textContent = starred.has(current.id) ? "관심 빼기" : "관심 남기기";
|
||||
renderList();
|
||||
renderStarred();
|
||||
});
|
||||
|
||||
dialog.addEventListener("click", (e) => { if (e.target === dialog) dialog.close(); });
|
||||
dialog.querySelector("[data-close]").addEventListener("click", () => dialog.close());
|
||||
|
||||
// ── 시작 ──
|
||||
buildAxis();
|
||||
renderAll();
|
||||
renderNotices();
|
||||
setStatus("과목을 눌러 강의계획을 확인하세요.", false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/site/public/work/dure-enrollment/og.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
567
apps/site/public/work/dure-enrollment/styles/main.css
Normal file
|
|
@ -0,0 +1,567 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
두레대학 수강신청 — 프로덕션 콘솔 (shadcn/ui new-york 문법의 바닐라 이식)
|
||||
액센트는 벽돌(申) 하나. 나머지는 무채상 완성도로 말한다.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after { box-sizing: border-box; }
|
||||
* { margin: 0; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; background: var(--muted); }
|
||||
|
||||
body {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-body);
|
||||
font-synthesis-weight: none;
|
||||
-webkit-text-size-adjust: 100%; text-size-adjust: 100%; /* 합성 볼드 금지 */
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) { line-height: var(--leading-tight); font-weight: 600; word-break: keep-all; }
|
||||
strong, b { font-weight: 600; } /* UA 기본 700 볼드 재정의 */
|
||||
h1, h2.v-h {
|
||||
font-size: var(--step-4); font-weight: 600;
|
||||
font-family: var(--font-display); /* 공문·신문의 명조 결 — 큰 제목에만 */
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
h1, h2.v-h { font-size: min(1.5rem, 6.4vw); } /* 뷰포트 단위 — 기기 글꼴 확대에도 제목은 부서지지 않는다 */
|
||||
}
|
||||
h2 { font-size: var(--step-2); }
|
||||
h3 { font-size: var(--step-0); font-weight: 500; }
|
||||
|
||||
:where(p, li, td, th, dd, figcaption) { word-break: keep-all; overflow-wrap: anywhere; }
|
||||
|
||||
.mono { font-family: var(--font-mono); font-size: var(--font-mono-size); font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted-foreground); }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 2px; }
|
||||
|
||||
.skip {
|
||||
position: absolute; left: -9999px; top: 0;
|
||||
background: var(--primary); color: var(--primary-foreground);
|
||||
padding: var(--space-3) var(--space-4); z-index: 100; border-radius: var(--radius);
|
||||
}
|
||||
.skip:focus { left: var(--space-4); }
|
||||
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 앱바: 브랜드 · 학점 칩 · 탭. 스티키. ── */
|
||||
.appbar {
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
}
|
||||
.appbar-inner {
|
||||
max-width: 1180px; margin-inline: auto;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
}
|
||||
.brand {
|
||||
font-family: var(--font-display);
|
||||
display: inline-flex; align-items: center; gap: var(--space-2);
|
||||
color: var(--foreground); text-decoration: none;
|
||||
font-weight: 600; font-size: var(--step-1);
|
||||
}
|
||||
.brand-seal {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--step-0); font-weight: 600;
|
||||
}
|
||||
.credits {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
font-size: var(--step-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.credits strong { color: var(--accent-ink); font-size: var(--step-0); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* 탭 — 언더라인 내비 */
|
||||
.tabs {
|
||||
max-width: 1180px; margin-inline: auto;
|
||||
display: flex; gap: var(--space-1);
|
||||
padding: 0 var(--space-5);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden; /* overflow-x:auto 는 반대축을 auto 로 만든다 — 세로 스크롤 금지 */
|
||||
scrollbar-width: none; /* 좁은 창 가로 스크롤바도 노출 금지 — 스와이프는 유지 */
|
||||
}
|
||||
|
||||
.tab {
|
||||
appearance: none; cursor: pointer; font: inherit;
|
||||
font-size: var(--step-0); font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
background: none; border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
height: 42px;
|
||||
padding: 0 var(--space-2);
|
||||
white-space: nowrap;
|
||||
transition: color 100ms ease, border-color 100ms ease;
|
||||
}
|
||||
.tab:hover { color: var(--foreground); }
|
||||
.tab.is-current { color: var(--foreground); font-weight: 600; border-bottom-color: var(--accent); }
|
||||
|
||||
/* ── 레이아웃 ── */
|
||||
.layout {
|
||||
max-width: 1180px;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-5) var(--space-5) 96px;
|
||||
}
|
||||
.view { display: none; }
|
||||
.view.is-on { display: block; }
|
||||
.view:focus { outline: none; }
|
||||
.view > h1 { margin-bottom: 4px; }
|
||||
.view-sub { color: var(--muted-foreground); font-size: var(--step-0); margin-bottom: var(--space-5); max-width: 46em; }
|
||||
#view-catalog.is-on {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
.view > *:not(:first-child):not(.view-sub) { margin-top: 0; }
|
||||
|
||||
/* ── 카드(장부 판 → 카드) ── */
|
||||
.sheet {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
.sheet-tt { padding: var(--space-4); }
|
||||
.sheet-note { font-size: var(--step--1); color: var(--faint); margin-top: var(--space-3); }
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-box {
|
||||
display: grid; gap: var(--space-2); justify-items: start;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6) var(--space-5);
|
||||
background: var(--muted);
|
||||
}
|
||||
.empty-box p:first-child { font-weight: 600; }
|
||||
.empty-box .muted { font-size: var(--step-0); }
|
||||
.empty-box .btn-line { margin-top: var(--space-2); }
|
||||
|
||||
/* ── 검색 + 세그먼트 ── */
|
||||
.toolbar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; margin-bottom: var(--space-4); }
|
||||
#q {
|
||||
font: inherit; color: var(--foreground);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
min-width: 13rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color 100ms ease, box-shadow 100ms ease;
|
||||
}
|
||||
#q:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
|
||||
#q:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
#q::placeholder { color: var(--faint); }
|
||||
.chips {
|
||||
display: inline-flex; gap: 2px;
|
||||
background: var(--muted-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
}
|
||||
.chip {
|
||||
appearance: none; cursor: pointer; font: inherit;
|
||||
font-size: var(--step--1); font-weight: 500;
|
||||
background: none; color: var(--muted-foreground);
|
||||
border: 0; border-radius: var(--radius);
|
||||
height: 28px; padding: 0 var(--space-3);
|
||||
display: inline-flex; align-items: center;
|
||||
transition: background-color 100ms ease, color 100ms ease, box-shadow 100ms ease;
|
||||
}
|
||||
.chip:hover { color: var(--foreground); }
|
||||
.chip.is-on { background: var(--card); color: var(--foreground); box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* ── 과목 목록 ── */
|
||||
.course-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.course-row {
|
||||
appearance: none; background: none; cursor: pointer; font: inherit;
|
||||
width: 100%; text-align: left;
|
||||
display: flex; justify-content: space-between; align-items: center; gap: var(--space-4);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border: 0; border-bottom: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
transition: background-color 80ms ease;
|
||||
}
|
||||
.course-row:first-child { border-top: 1px solid var(--border); }
|
||||
.course-row:hover { background: var(--muted); }
|
||||
.course-row:active { scale: 0.995; }
|
||||
.c-main { display: grid; gap: 2px; min-width: 0; }
|
||||
.c-name { font-weight: 600; display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.c-dept {
|
||||
font-size: var(--step--1); font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.c-sub { color: var(--muted-foreground); font-size: var(--step--1); }
|
||||
.c-side { display: grid; gap: 2px; text-align: right; flex: none; font-size: var(--step--1); }
|
||||
.c-credit { font-weight: 500; color: var(--foreground); }
|
||||
.c-seats { color: var(--muted-foreground); }
|
||||
.c-seats.is-low { color: var(--danger); font-weight: 600; }
|
||||
.c-star { color: var(--accent); font-weight: 600; }
|
||||
.c-seal {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 22px; height: 22px;
|
||||
background: var(--accent); color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.empty { padding: var(--space-4) 0; color: var(--muted-foreground); }
|
||||
|
||||
/* ── 표 ── */
|
||||
.table-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { font-weight: 500; } /* UA 기본 th 볼드 재정의 */
|
||||
thead th {
|
||||
text-align: left; font-weight: 500; font-size: var(--step--1);
|
||||
color: var(--muted-foreground); padding: 0 var(--space-3) var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
height: 36px; white-space: nowrap;
|
||||
}
|
||||
tbody td, tbody th { padding: var(--space-3); border-bottom: 1px solid var(--border); vertical-align: middle; text-align: left; font-size: var(--step-0); }
|
||||
tbody th { font-weight: 500; }
|
||||
tbody tr:last-child td, tbody tr:last-child th { border-bottom: 0; }
|
||||
tbody tr { transition: background-color 80ms ease; }
|
||||
tbody tr:hover { background: var(--muted); }
|
||||
.ar-sub { background: var(--muted); }
|
||||
.ar-sub th, .ar-sub td { font-weight: 600; }
|
||||
.low-seats { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* ── 신청 내역 ── */
|
||||
.en-sum { color: var(--muted-foreground); margin-bottom: var(--space-4); }
|
||||
.en-sum strong { color: var(--accent-ink); font-size: var(--step-2); font-weight: 600; }
|
||||
.give-btn {
|
||||
appearance: none; cursor: pointer; font: inherit;
|
||||
font-size: var(--step--1); font-weight: 500;
|
||||
background: none; color: var(--muted-foreground);
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
||||
height: 28px; padding: 0 10px;
|
||||
transition: all 100ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.give-btn:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
/* ── 공지 게시판 ── */
|
||||
.notice-list { list-style: none; margin: 0; padding: 0; display: grid; }
|
||||
.notice-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 3.2rem 4rem minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: baseline;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.notice-list li:first-child { border-top: 1px solid var(--border); }
|
||||
.no-kind {
|
||||
font-size: var(--step--1); font-weight: 600;
|
||||
background: var(--muted-2); color: var(--muted-foreground);
|
||||
border-radius: var(--radius); padding: 2px 8px;
|
||||
text-align: center; white-space: nowrap;
|
||||
justify-self: start;
|
||||
}
|
||||
.no-kind[data-kind="변경"] { background: var(--accent-soft); color: var(--accent-ink); }
|
||||
.no-date { color: var(--faint); }
|
||||
.no-body { line-height: var(--leading-normal); word-break: keep-all; max-width: 68ch; }
|
||||
|
||||
/* ── 시간표 ── */
|
||||
.timetable-panel { min-width: 0; }
|
||||
.timetable-panel h2 { margin-bottom: 4px; }
|
||||
.tt-hint { font-size: var(--step--1); margin-bottom: var(--space-3); }
|
||||
.tt-scroll { overflow-x: auto; overflow-y: hidden; }
|
||||
.tt {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem repeat(5, minmax(4.2rem, 1fr));
|
||||
grid-auto-rows: minmax(1.35rem, auto);
|
||||
min-width: min(100%, 420px);
|
||||
}
|
||||
.tt-corner { grid-column: 1; grid-row: 1; border-bottom: 1px solid var(--border-strong); }
|
||||
.tt-day {
|
||||
font-size: var(--step--1); font-weight: 600;
|
||||
display: grid; place-items: center;
|
||||
border-bottom: 2px solid var(--border-strong);
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
.tt-hour {
|
||||
font-size: var(--step--2); color: var(--faint);
|
||||
display: grid; place-items: start end;
|
||||
padding: 2px 4px 0 0;
|
||||
position: relative;
|
||||
}
|
||||
.tt-cell { border-left: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.tt-block {
|
||||
appearance: none; border: 0; cursor: pointer;
|
||||
display: grid; align-content: start; gap: 2px;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
text-align: left; overflow: hidden;
|
||||
transition: box-shadow 100ms ease, scale 80ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.tt-block:hover { box-shadow: var(--shadow-lg); z-index: 2; }
|
||||
.tt-block:active { scale: 0.98; }
|
||||
.tt-block.is-major { background: var(--accent-soft); color: var(--accent-ink); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), inset 3px 0 0 var(--accent); }
|
||||
.tt-block.is-gen { background: var(--muted-2); color: var(--foreground); box-shadow: inset 3px 0 0 var(--border-strong); }
|
||||
.tt-block-name { font-size: var(--step--1); font-weight: 600; line-height: 1.35; word-break: keep-all; }
|
||||
.tt-block-room { font-size: var(--step--2); color: var(--muted-foreground); }
|
||||
.tt-empty {
|
||||
grid-column: 2 / -1; grid-row: 4 / span 8;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--faint); font-size: var(--step--1);
|
||||
border: 1px dashed var(--border-strong); border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
color: var(--muted-foreground);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-3);
|
||||
min-height: 2.2em;
|
||||
}
|
||||
.status.is-warn { color: var(--danger); }
|
||||
.tt-legend { display: flex; gap: var(--space-4); margin-top: var(--space-2); font-size: var(--step--1); color: var(--muted-foreground); }
|
||||
.key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.key i { width: 10px; height: 10px; display: inline-block; border-radius: 3px; }
|
||||
.k-major { background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.k-gen { background: var(--muted-2); box-shadow: inset 2px 0 0 var(--border-strong); }
|
||||
.k-seal { background: var(--accent); }
|
||||
|
||||
/* ── 버튼 ── */
|
||||
.btn-seal, .btn-line, .btn-star {
|
||||
appearance: none; cursor: pointer; font: inherit;
|
||||
font-size: var(--step-0); font-weight: 500;
|
||||
height: 36px; padding: 0 var(--space-4);
|
||||
border-radius: var(--radius);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease, scale 80ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-seal { background: var(--accent); border: 1px solid var(--accent); color: #fff; box-shadow: var(--shadow-sm); }
|
||||
.btn-seal:hover:not(:disabled) { background: #9a3412; border-color: #9a3412; }
|
||||
.btn-seal:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.btn-line { background: var(--card); border: 1px solid var(--border-strong); color: var(--foreground); box-shadow: var(--shadow-sm); }
|
||||
.btn-line:hover { background: var(--muted); }
|
||||
.btn-star { background: none; border: 1px solid var(--border-strong); color: var(--muted-foreground); }
|
||||
.btn-star:hover { color: var(--accent-ink); border-color: var(--accent); }
|
||||
.btn-star[aria-pressed="true"] { color: var(--accent-ink); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
|
||||
.btn-seal:active, .btn-line:active, .btn-star:active { scale: 0.98; }
|
||||
|
||||
/* ── 모달: 스플릿. 표지 = 잉크판, 액센트 도장. ── */
|
||||
.modal {
|
||||
margin: auto;
|
||||
width: min(94vw, 860px);
|
||||
max-height: min(86dvh, 760px);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
.modal::backdrop { background: rgb(9 9 11 / 0.55); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.modal[open] { animation: modal-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; }
|
||||
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(0.99); } }
|
||||
}
|
||||
.mspread { display: grid; grid-template-columns: 264px minmax(0, 1fr); max-height: min(86dvh, 760px); }
|
||||
.mcover {
|
||||
position: relative;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
padding: var(--space-6);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mcover-mark {
|
||||
position: absolute; right: -18px; bottom: -24px;
|
||||
font-size: 8rem; font-weight: 600; line-height: 1;
|
||||
color: rgb(250 250 250 / 0.05);
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
.mcover-seal {
|
||||
width: 44px; height: 44px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: var(--step-2); font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.mcover-name { font-size: var(--step-3); line-height: 1.25; font-weight: 600; letter-spacing: -0.02em; word-break: keep-all; }
|
||||
.mcover-meta { color: #a1a1aa; font-size: var(--step--1); margin-top: var(--space-2); }
|
||||
.mcover-stats { margin-top: var(--space-5); border-top: 1px solid rgb(250 250 250 / 0.12); }
|
||||
.mcover-stats > div {
|
||||
display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3);
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid rgb(250 250 250 / 0.12);
|
||||
}
|
||||
.mcover-stats dt { font-size: var(--step--1); color: #a1a1aa; }
|
||||
.mcover-stats dd { font-size: var(--step--1); font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.mcover-foot { margin-top: auto; color: #71717a; font-size: var(--step--2); }
|
||||
|
||||
.msheet {
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: grid; gap: var(--space-5);
|
||||
align-content: start;
|
||||
min-height: 340px;
|
||||
}
|
||||
.cd-line { color: var(--muted-foreground); font-size: var(--step-0); }
|
||||
.msec h3 { color: var(--muted-foreground); margin-bottom: var(--space-3); font-weight: 500; font-size: var(--step-0); }
|
||||
.modal-title { font-size: var(--step-3); letter-spacing: -0.02em; }
|
||||
.modal-meta { color: var(--muted-foreground); margin-top: 2px; }
|
||||
.modal-close {
|
||||
position: absolute; top: var(--space-4); right: var(--space-4);
|
||||
width: 32px; height: 32px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--card); color: var(--muted-foreground); cursor: pointer;
|
||||
transition: background-color 100ms ease, color 100ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
.modal-close:hover { background: var(--muted-2); color: var(--foreground); }
|
||||
.modal-inner { position: relative; padding: var(--space-6); display: grid; gap: var(--space-4); }
|
||||
.modal-actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.facts {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3);
|
||||
border-block: 1px solid var(--border);
|
||||
padding-block: var(--space-4);
|
||||
}
|
||||
.facts dt { font-size: var(--step--1); color: var(--muted-foreground); }
|
||||
.facts dd { font-weight: 600; margin-top: 2px; }
|
||||
.warn {
|
||||
font-size: var(--step-0);
|
||||
color: var(--danger-ink);
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
/* 주간 자리 미니 격자 */
|
||||
.cd-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-2); }
|
||||
.cd-track { display: grid; justify-items: center; gap: var(--space-1); }
|
||||
.cd-rail { position: relative; width: 100%; height: 92px; border-left: 1px solid var(--border-strong); }
|
||||
.cd-off { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--faint); }
|
||||
.cd-slot { position: absolute; left: 0; right: 4px; border-radius: 3px; }
|
||||
.cd-slot.is-major { background: var(--accent-soft); border-left: 3px solid var(--accent); }
|
||||
.cd-slot.is-gen { background: var(--muted-2); border-left: 3px solid var(--border-strong); }
|
||||
.cd-day-l { font-size: var(--step--2); color: var(--faint); }
|
||||
.cd-day-l.is-on { color: var(--foreground); font-weight: 600; }
|
||||
|
||||
/* 평가 누적 막대 — 벽돌의 농도 3단 */
|
||||
.eval-bar {
|
||||
display: flex; height: 14px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-3);
|
||||
background: var(--muted-2);
|
||||
}
|
||||
.eval-seg { display: block; height: 100%; }
|
||||
.eval-seg.seg-1 { background: var(--accent); }
|
||||
.eval-seg.seg-2 { background: color-mix(in srgb, var(--accent) 55%, #fff); }
|
||||
.eval-seg.seg-3 { background: color-mix(in srgb, var(--accent) 22%, #fff); }
|
||||
.eval-legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5); }
|
||||
.eval-legend dt { color: var(--muted-foreground); font-size: var(--step--1); }
|
||||
.eval-legend dd { font-weight: 600; font-size: var(--step--1); }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 900px) {
|
||||
#view-catalog.is-on { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
|
||||
.timetable-panel { position: static; }
|
||||
.notice-list li { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
|
||||
.no-kind, .no-date { justify-self: start; }
|
||||
.layout { padding: var(--space-4) var(--space-4) 64px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.mspread { grid-template-columns: minmax(0, 1fr); }
|
||||
.mcover { padding: var(--space-4); flex-direction: row; align-items: center; gap: var(--space-3); }
|
||||
.mcover-seal { width: 40px; height: 40px; margin-bottom: 0; font-size: var(--step-0); }
|
||||
.mcover-name { font-size: var(--step-2); }
|
||||
.mcover-meta { margin-top: 2px; }
|
||||
.mcover-stats, .mcover-foot { display: none; }
|
||||
.mcover-mark { font-size: 5rem; }
|
||||
.msheet { padding: var(--space-4); min-height: 0; }
|
||||
.course-row { flex-direction: column; align-items: flex-start; gap: var(--space-2); }
|
||||
.c-side { text-align: left; display: flex; gap: var(--space-3); align-items: center; }
|
||||
.modal-inner { padding: var(--space-4); }
|
||||
.facts { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.modal-actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* ── 뷰 공통 요약 스트립 (가온과 같은 문법) ── */
|
||||
.statline {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.statline > div {
|
||||
display: inline-flex; align-items: baseline; gap: 6px;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-sm);
|
||||
padding: 6px 12px; font-size: var(--step-1); color: var(--muted-foreground);
|
||||
}
|
||||
.statline strong {
|
||||
font-size: var(--step-0); font-weight: 600; color: var(--foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── 관심 과목 학점 경고 — 오류 예방 ── */
|
||||
.st-warn {
|
||||
font-size: var(--step-0); line-height: 1.6; word-break: keep-all;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--muted-2); color: var(--muted-foreground);
|
||||
padding: 10px 14px; margin-bottom: var(--space-4);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.st-warn.is-over {
|
||||
background: color-mix(in srgb, var(--accent) 8%, white);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, white);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* ── 모바일 갸행 수습 — 과목행 부정보는 세그먼트(코드·교수·시간·장소) 단위로만 끊는다 ── */
|
||||
.c-sub i { font-style: normal; white-space: nowrap; }
|
||||
.view-sub, .no-body, .st-warn, .empty, .notice-list { word-break: keep-all; overflow-wrap: anywhere; text-wrap: pretty; }
|
||||
|
||||
/* ── 라운드 1: 이수 진행 바 · 학점 게이지 · 관심 추천 ── */
|
||||
.cr-bar { height: 6px; border-radius: 999px; background: var(--muted-2); overflow: hidden; min-width: 90px; }
|
||||
.cr-bar i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
|
||||
.cr-total { margin-top: var(--space-4); padding-top: var(--space-4); border-top: 1px solid var(--border); }
|
||||
.cr-total .cr-bar { height: 8px; }
|
||||
.credit-gauge { height: 8px; border-radius: 999px; background: var(--muted-2); overflow: hidden; margin: 10px 0 6px; }
|
||||
.credit-gauge i { display: block; height: 100%; background: var(--accent); border-radius: 999px; transition: width 200ms ease; }
|
||||
.h-note { font-size: var(--step--1); font-weight: 400; color: var(--muted-foreground); margin-left: 8px; }
|
||||
.st-suggest { margin-top: var(--space-4); }
|
||||
55
apps/site/public/work/dure-enrollment/styles/tokens.css
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* ==========================================================================
|
||||
두레대학 수강신청 — 디자인 토큰 (v3: 프로덕션 콘솔)
|
||||
프리셋: production-console — shadcn/ui (new-york) zinc 스키마 + 벽돌 액센트 1.
|
||||
컨셉 한 문장: 실제 대학이 쓰는 것 같은 수강신청 — 도장은 액센트 하나로 남고
|
||||
나머지는 완성도로 말한다.
|
||||
레퍼런스: shadcn/ui 디자인 시스템. 종이/배판 컨셉은 철회(사용자 판정).
|
||||
하지 않은 것: 신문 배판 마스트헤드, 세리프 마스트, 전공별 색, 진행바 게이지.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
--step--2: 0.6875rem; /* 11px */
|
||||
--step--1: 0.75rem; /* 12px */
|
||||
--step-0: 0.875rem; /* 14px — 본문 */
|
||||
--step-1: 1rem; /* 16px */
|
||||
--step-2: 1.125rem; /* 18px */
|
||||
--step-3: 1.5rem; /* 24px */
|
||||
--step-4: 1.875rem; /* 30px */
|
||||
|
||||
--leading-tight: 1.3;
|
||||
--leading-normal: 1.6;
|
||||
|
||||
--font-body: "IBM Plex Sans KR", "Pretendard Variable", -apple-system, sans-serif;
|
||||
--font-display: "Noto Serif KR", "IBM Plex Sans KR", serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-mono-size: 0.8125rem;
|
||||
|
||||
--background: #fafafa;
|
||||
--card: #ffffff;
|
||||
--foreground: #09090b;
|
||||
--muted: #fafafa;
|
||||
--muted-2: #f4f4f5;
|
||||
--muted-foreground: #64646e; /* zinc-50/gray-100 위 5.2:1 이상 */
|
||||
--faint: #6e6e77; /* WCAG 4.5:1 보정 */
|
||||
--border: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--primary: #18181b;
|
||||
--primary-foreground: #fafafa;
|
||||
--ring: #a1a1aa;
|
||||
--accent: #c2410c; /* 벽돌 — 申 도장과 신청 액션만 */
|
||||
--accent-soft: #fff7ed;
|
||||
--accent-ink: #9a3412;
|
||||
--success: #059669; --success-soft: #ecfdf5; --success-ink: #065f46;
|
||||
--warning: #b45309; --warning-soft: #fffbeb; --warning-ink: #92400e;
|
||||
--danger: #dc2626; --danger-soft: #fef2f2; --danger-ink: #991b1b;
|
||||
|
||||
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
|
||||
--space-4: 1rem; --space-5: 1.5rem; --space-6: 2rem;
|
||||
|
||||
--radius: 6px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
BIN
apps/site/public/work/font-foundry/assets/inkstone.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/site/public/work/font-foundry/assets/metaltype.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
289
apps/site/public/work/font-foundry/index.html
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>한성활자연구소 · 본문용 한글 활자</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="획의 장력과 여백의 비례. 본문 조판을 위해 설계한 한글 활자 두 종과 조판 시험대. 1000 UPM, 측정 가능한 자간과 행간."
|
||||
/>
|
||||
<meta property="og:title" content="한성활자연구소 · 본문용 한글 활자" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="본문 조판을 위해 설계한 한글 활자와 조판 시험대."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#F5F1E8" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F5F1E8'/%3E%3Crect x='8' y='8' width='16' height='16' fill='%23A63D2F'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">한성활자연구소</a>
|
||||
<nav class="nav" aria-label="섹션">
|
||||
<a href="#typefaces">활자</a>
|
||||
<a href="#bench">시험대</a>
|
||||
<a href="#case">활자 케이스</a>
|
||||
<a href="#order">구입</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
스페시멘 히어로 — 파운드리의 첫 화면은 설명이 아니라 글자다.
|
||||
'한' 한 글자를 페이지에서 가장 크게, 인주각과 함께.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap">
|
||||
<p class="hero-kicker reveal">본문용 한글 활자 · 1000 UPM · 2018년 창립</p>
|
||||
<h1 id="hero-h" class="hero-glyph reveal" style="--delay: 60ms" lang="ko" aria-label="한">한</h1>
|
||||
<div class="hero-side">
|
||||
<p class="hero-statement">
|
||||
이 글자가 어디에 쓰이는지는 이 페이지 전체가 증명합니다. 우리는
|
||||
제목용 활자를 만들지 않습니다 — 오래 읽어야 하는 본문의 활자만
|
||||
만듭니다. 획의 장력, 자간의 균형, 행간의 숨을 그 단위(UPM)로
|
||||
측정합니다.
|
||||
</p>
|
||||
<dl class="hero-facts reveal" style="--delay: 120ms">
|
||||
<div>
|
||||
<dt>서체</dt>
|
||||
<dd>한성본문 · 한성제목</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>글자 수</dt>
|
||||
<dd>11,172자 완성</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>단위</dt>
|
||||
<dd>1000 UPM · 가변 웨이트 3종</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
활자 두 종 — 스페시멘은 실제 문장으로. Lorem ipsum 은 활자시험의 반대다.
|
||||
-->
|
||||
<section id="typefaces" class="section" aria-labelledby="typefaces-h">
|
||||
<div class="wrap">
|
||||
<h2 id="typefaces-h">활자 두 종</h2>
|
||||
|
||||
<article class="typeface reveal">
|
||||
<header class="typeface-head">
|
||||
<h3>한성본문</h3>
|
||||
<p class="muted">본문용 · 웨이트 400 · 16px에서 행간 1.8</p>
|
||||
</header>
|
||||
<p class="specimen" style="font-family: 'MaruBuri', serif">
|
||||
아침 안개가 걷히기 전에 도랑물 소리가 먼저 들렸다. 글자는 종이 위에서
|
||||
소리를 내지 않지만, 잘 짜인 활자는 소리 내어 읽게 만든다. 획이 닿는
|
||||
각도와 글자가 서로 미는 힘을 천 번쯤 고쳐 썼다. 그 결과 이 문장이
|
||||
밤늦게도 읽히는 것이다.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="typeface reveal" style="--delay: 80ms">
|
||||
<header class="typeface-head">
|
||||
<h3>한성제목</h3>
|
||||
<p class="muted">제목·표제용 · 웨이트 600 · 큰 짝에서 벌어짐 0</p>
|
||||
</header>
|
||||
<p class="specimen specimen-display" style="font-family: 'MaruBuri', serif">
|
||||
활자는 붓의 속도를 기억한다
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
조판 시험대 — 이 페이지의 리스크이자 유일한 인터랙션.
|
||||
슬라이더 세 개가 실제 본문에 즉시 반영된다. 라벨은 한국어, 값은 숫자로.
|
||||
-->
|
||||
<section id="bench" class="section band" aria-labelledby="bench-h">
|
||||
<div class="wrap">
|
||||
<h2 id="bench-h">조판 시험대</h2>
|
||||
<p class="muted bench-lead">
|
||||
자간과 행간, 글자 크기가 가독성을 어떻게 바꾸는지 직접 움직여 보세요.
|
||||
아래 값은 권장값이 아니라 출발점입니다 — 용지 규격과 본문 길이에 따라
|
||||
정답은 달라집니다.
|
||||
</p>
|
||||
|
||||
<div class="bench">
|
||||
<form class="bench-controls" aria-label="조판 설정">
|
||||
<div class="control">
|
||||
<label for="ctl-size">글자 크기 <output id="out-size" for="ctl-size">17</output></label>
|
||||
<input type="range" id="ctl-size" min="13" max="26" step="1" value="17" />
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="ctl-tracking">자간 <output id="out-tracking" for="ctl-tracking">0</output></label>
|
||||
<input type="range" id="ctl-tracking" min="-5" max="10" step="1" value="0" />
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="ctl-leading">행간 <output id="out-leading" for="ctl-leading">1.8</output></label>
|
||||
<input type="range" id="ctl-leading" min="1.4" max="2.4" step="0.1" value="1.8" />
|
||||
</div>
|
||||
<button type="button" class="btn-reset" id="ctl-reset">권장값으로</button>
|
||||
</form>
|
||||
<div
|
||||
class="bench-sample"
|
||||
id="bench-sample"
|
||||
style="font-family: 'MaruBuri', serif"
|
||||
aria-live="polite"
|
||||
>
|
||||
활자의 역사는 글자를 찍는 기계의 역사가 아니라, 글자를 읽는 눈의
|
||||
역사였다. 활자소의 식자공들은 하루에 만 글자를 짜면서 어느 글꼴이
|
||||
눈을 덜 아프게 하는지 알고 있었다. 그 지식은 문서로 남지 않았고
|
||||
손끝으로만 전해졌다. 이 시험대는 그 손끝의 판단을 누구나 해볼 수
|
||||
있는 눈금으로 옮긴 것이다. 자간을 0에서 5로 옮겨 보라 — 문장이
|
||||
늘어지는 것이 아니라 숨이 가빠지는 것을 느낄 것이다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
활자 케이스 — 연구소의 물성. 금속 활자와 인주.
|
||||
-->
|
||||
<section id="case" class="section" aria-labelledby="case-h">
|
||||
<div class="wrap case-grid">
|
||||
<figure class="case-photo reveal">
|
||||
<img
|
||||
src="./assets/metaltype.webp"
|
||||
alt="나무 활자 케이스에 골라 담긴 금속 활자들 — 활자면이 위에서 빛을 받아 결이 보인다"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>연구소 2층 · 금속 활자 케이스</figcaption>
|
||||
</figure>
|
||||
<div class="case-text">
|
||||
<h2 id="case-h">금속에서 시작해<br />화면으로 끝난다</h2>
|
||||
<p>
|
||||
한성본문의 자간 규칙은 금속 활자의 '옆면 여백'에서 왔습니다. 납활자는
|
||||
글자 바로 옆에 살이 붙어 있어서, 찍히는 글자 사이는 언제나 일정했고
|
||||
그 일정함이 읽는 리듬이었습니다. 화면용 폰트는 그 살을 지울 수
|
||||
있지만, 우리는 지우지 않았습니다.
|
||||
</p>
|
||||
<figure class="case-photo-b reveal" style="--delay: 80ms">
|
||||
<img
|
||||
src="./assets/inkstone.webp"
|
||||
alt="연구한 먹이 고인 연적과 옆에 놓인 인주 연판"
|
||||
width="1100"
|
||||
height="825"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>먹을 갈고 인주를 찍는 자리 — 시험 인쇄 전</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
구입 — 라이선스는 조판 단위로. 조용한 두 줄.
|
||||
-->
|
||||
<section id="order" class="section" aria-labelledby="order-h">
|
||||
<div class="wrap order-grid">
|
||||
<div>
|
||||
<h2 id="order-h">구입</h2>
|
||||
<p class="muted">
|
||||
웹·출판·임베드 세 가지 라이선스만 있습니다. 사용 수를 세지 않습니다 —
|
||||
조판되는 단위(도서 권수, 사이트 도메인, 기기)로 셉니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="order-rows">
|
||||
<div class="order-row">
|
||||
<div>
|
||||
<h3>웹</h3>
|
||||
<p>도메인당 · 웨이트 전종 · 자체 호스팅 WOFF2</p>
|
||||
</div>
|
||||
<p class="order-price">140,000원</p>
|
||||
</div>
|
||||
<div class="order-row">
|
||||
<div>
|
||||
<h3>출판</h3>
|
||||
<p>도서 권수당 · 인쇄+전자 동시</p>
|
||||
</div>
|
||||
<p class="order-price">220,000원</p>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn" href="mailto:foundry@hanseong.kr">견적 문의</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>한성활자연구소 · 서울 마포</p>
|
||||
<p class="muted">foundry@hanseong.kr</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* 시험대 — 슬라이더 세 개가 본문 스타일을 직접 쓴다. 프레임당 레이아웃 없음. */
|
||||
const sample = document.getElementById("bench-sample");
|
||||
const ctl = {
|
||||
size: document.getElementById("ctl-size"),
|
||||
tracking: document.getElementById("ctl-tracking"),
|
||||
leading: document.getElementById("ctl-leading"),
|
||||
};
|
||||
const out = {
|
||||
size: document.getElementById("out-size"),
|
||||
tracking: document.getElementById("out-tracking"),
|
||||
leading: document.getElementById("out-leading"),
|
||||
};
|
||||
const defaults = { size: 17, tracking: 0, leading: 1.8 };
|
||||
|
||||
function apply() {
|
||||
const px = ctl.size.value + "px";
|
||||
const em = ctl.tracking.value / 100 + "em";
|
||||
const lh = ctl.leading.value;
|
||||
sample.style.fontSize = px;
|
||||
sample.style.letterSpacing = em;
|
||||
sample.style.lineHeight = lh;
|
||||
out.size.textContent = ctl.size.value;
|
||||
out.tracking.textContent = ctl.tracking.value;
|
||||
out.leading.textContent = Number(ctl.leading.value).toFixed(1);
|
||||
}
|
||||
for (const input of Object.values(ctl)) {
|
||||
input.addEventListener("input", apply);
|
||||
}
|
||||
document.getElementById("ctl-reset").addEventListener("click", () => {
|
||||
ctl.size.value = defaults.size;
|
||||
ctl.tracking.value = defaults.tracking;
|
||||
ctl.leading.value = defaults.leading;
|
||||
apply();
|
||||
});
|
||||
apply();
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
420
apps/site/public/work/font-foundry/styles/main.css
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
한성활자연구소 — editorial-archival. 글자가 상품.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-tight);
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
h3 {
|
||||
font-family: var(--font-body);
|
||||
font-weight: 600;
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
:where(p, li, dd, dt, th, td, figcaption) {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 스페시멘 히어로 ──────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-7) var(--space-8);
|
||||
}
|
||||
.hero-kicker {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.hero-glyph {
|
||||
font-size: clamp(11rem, 24vw, 22rem);
|
||||
line-height: 0.95;
|
||||
margin-top: var(--space-4);
|
||||
position: relative;
|
||||
}
|
||||
/* 인주각 — 글리프 옆에 찍히는 도장 */
|
||||
.hero-glyph::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0.12em;
|
||||
bottom: 0.18em;
|
||||
width: 0.11em;
|
||||
height: 0.11em;
|
||||
background: var(--accent);
|
||||
}
|
||||
.hero-side {
|
||||
margin-top: var(--space-5);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.hero-statement {
|
||||
max-width: 32em;
|
||||
}
|
||||
.hero-facts {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
border-left: 1px solid var(--line);
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
.hero-facts dt {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.hero-facts dd {
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── 활자 두 종 ───────────────────────────────────────────────────────── */
|
||||
|
||||
.typeface + .typeface {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.typeface-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
.typeface-head .muted {
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.specimen {
|
||||
margin-top: var(--space-4);
|
||||
font-size: 1.1875rem;
|
||||
line-height: 1.9;
|
||||
max-width: 46em;
|
||||
}
|
||||
.specimen-display {
|
||||
font-size: clamp(1.75rem, 1.2rem + 3vw, 3.25rem);
|
||||
line-height: 1.45;
|
||||
max-width: 22em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 조판 시험대 ──────────────────────────────────────────────────────── */
|
||||
|
||||
.bench-lead {
|
||||
max-width: 36em;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.bench {
|
||||
margin-top: var(--space-5);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.bench-controls {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.control label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.control output {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink);
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.btn-reset {
|
||||
justify-self: start;
|
||||
background: none;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
padding: 0.4rem 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-quick) var(--ease-soft),
|
||||
color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn-reset:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
}
|
||||
.bench-sample {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-5);
|
||||
max-width: 46em;
|
||||
}
|
||||
|
||||
/* ── 활자 케이스 ──────────────────────────────────────────────────────── */
|
||||
|
||||
.case-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.case-photo img,
|
||||
.case-photo-b img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.case-photo figcaption,
|
||||
.case-photo-b figcaption {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.case-text h2 {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.case-photo-b {
|
||||
margin-top: var(--space-5);
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ── 구입 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.order-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr) auto;
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.order-grid .muted {
|
||||
margin-top: var(--space-3);
|
||||
max-width: 26em;
|
||||
}
|
||||
.order-rows {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.order-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.order-row p {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.order-price {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-5);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 12px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-side,
|
||||
.bench,
|
||||
.case-grid,
|
||||
.order-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.case-photo {
|
||||
max-width: 24rem;
|
||||
}
|
||||
.hero,
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.order-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
53
apps/site/public/work/font-foundry/styles/tokens.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* ==========================================================================
|
||||
한성활자연구소 — 디자인 토큰
|
||||
프리셋: editorial-archival
|
||||
컨셉 한 문장: 글자가 상품이다 — 첫 화면부터 스페시멘, 나머지는 물성과 눈금.
|
||||
레퍼런스: dogfood-foundry(파운드리 계열 구조 관찰 — 별도 파일). 방향:
|
||||
스페시멘은 Lorem 이 아니라 실제 문장, 테스터는 즉시 반영.
|
||||
하지 않은 것: Lorem ipsum, 서체 카드 3열, '구독하면 웨이트 전부' 마케팅,
|
||||
책등 세로 내비. 이유: 활자는 보여주는 것이지 포장하는 게 아니다.
|
||||
강조 인주빨강 #A63D2F — 인장·CTA에만. 배경 채움은 버튼 하나 뿐.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — 스페시멘 글리프는 스케일 밖(20vw). 본문 17px. */
|
||||
--step--1: 0.8125rem; /* 13px */
|
||||
--step-0: 1.0625rem; /* 17px — 본문 (조판을 파는 페이지) */
|
||||
--step-1: 1.25rem; /* 20px */
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem); /* 28px */
|
||||
--step-3: clamp(2rem, 1.4rem + 3vw, 3.25rem); /* 52px */
|
||||
|
||||
--leading-tight: 1.3;
|
||||
--leading-normal: 1.85;
|
||||
|
||||
--font-display: "MaruBuri", serif;
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
|
||||
--surface: #f5f1e8; /* 한지 */
|
||||
--surface-raised: #ede7d9;
|
||||
--ink: #26221d;
|
||||
--ink-muted: #6a635a;
|
||||
--ink-faint: #948b7c;
|
||||
--line: #d9d1bf;
|
||||
--accent: #a63d2f; /* 인주 */
|
||||
--accent-ink: #f5f1e8;
|
||||
--accent-hover: #8a3225; /* 인주 눌린 단계 */
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
|
||||
|
||||
--radius: 2px;
|
||||
|
||||
--container: 1120px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 420ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
BIN
apps/site/public/work/form-architecture/assets/interior-bw.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/site/public/work/form-architecture/assets/model-bw.webp
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
apps/site/public/work/form-architecture/assets/pavilion-bw.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
303
apps/site/public/work/form-architecture/index.html
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>스튜디오 폼 · 공간건축연구소</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="형태는 침묵하고 치수가 태도를 증명한다. 비례와 구조의 명료성으로 설계하는 건축연구소의 프로젝트 인덱스와 도면."
|
||||
/>
|
||||
<meta property="og:title" content="스튜디오 폼 · 공간건축연구소" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="비례와 구조의 명료성으로 설계하는 건축연구소."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#FDFDFB" />
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='6' y='6' width='20' height='20' fill='none' stroke='%23111' stroke-width='2'/%3E%3Crect x='11' y='11' width='10' height='10' fill='%23111'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<!-- 4모서리 등록 표시(인쇄 판맞춤) — 이 페이지의 리스크. 넓은 화면에서만. -->
|
||||
<div class="corner corner-tl" aria-hidden="true"></div>
|
||||
<div class="corner corner-tr" aria-hidden="true"></div>
|
||||
<div class="corner corner-bl" aria-hidden="true"></div>
|
||||
<div class="corner corner-br" aria-hidden="true"></div>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">스튜디오 폼 <span class="wordmark-en">FORM</span></a>
|
||||
<nav class="nav" aria-label="섹션">
|
||||
<a href="#index">인덱스</a>
|
||||
<a href="#drawing">도면</a>
|
||||
<a href="#photo">사진</a>
|
||||
<a href="#contact">연락</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
서문 — Pawson의 교훈: 크기가 아니라 여백과 무게로 위계를 만든다.
|
||||
큰 글자는 이 페이지에 하나만 있다.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap">
|
||||
<p class="hero-meta reveal">
|
||||
공간건축연구소 · 서울 · 2014년 설립
|
||||
</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
|
||||
형태는 침묵하고<br />치수가 태도를 증명한다
|
||||
</h1>
|
||||
<p class="hero-statement reveal" style="--delay: 120ms">
|
||||
폼은 공간을 꾸미지 않습니다. 벽이 서는 이유, 창이 열리는 방향, 보가
|
||||
건너뛰는 길이를 설계합니다. 아름다움은 그 결정들이 모였을 때의
|
||||
부산물입니다 — 그래서 우리의 홍보는 도면과 치수로 합니다.
|
||||
</p>
|
||||
<dl class="hero-facts reveal" style="--delay: 180ms">
|
||||
<div>
|
||||
<dt>설립</dt>
|
||||
<dd>2014년</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>구성</dt>
|
||||
<dd>건축사 4 · 연구원 3</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>영역</dt>
|
||||
<dd>주거 · 미술관 · 재생</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
인덱스 — 이 페이지의 주인공. 사진 없는 역연도 목록.
|
||||
R1(johnpawson.com): 프로젝트는 제목/위치/연도의 세 줄 텍스트다.
|
||||
-->
|
||||
<section id="index" class="section" aria-labelledby="index-h">
|
||||
<div class="wrap">
|
||||
<h2 id="index-h" class="index-h reveal">프로젝트 인덱스</h2>
|
||||
<ol class="index">
|
||||
<li class="index-row reveal">
|
||||
<a href="#drawing">
|
||||
<span class="idx-name">폼 하우스</span>
|
||||
<span class="idx-loc">경기 가평</span>
|
||||
<span class="idx-year">2025</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="index-row reveal" style="--delay: 40ms">
|
||||
<a href="#drawing">
|
||||
<span class="idx-name">백령도 해녀기념관</span>
|
||||
<span class="idx-loc">인천 백령도</span>
|
||||
<span class="idx-year">2024</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="index-row reveal" style="--delay: 80ms">
|
||||
<a href="#drawing">
|
||||
<span class="idx-name">은평 재생주거</span>
|
||||
<span class="idx-loc">서울 은평</span>
|
||||
<span class="idx-year">2024</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="index-row reveal" style="--delay: 120ms">
|
||||
<a href="#drawing">
|
||||
<span class="idx-name">제주 돌담 스튜디오</span>
|
||||
<span class="idx-loc">제주 애월</span>
|
||||
<span class="idx-year">2023</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="index-row reveal" style="--delay: 160ms">
|
||||
<a href="#drawing">
|
||||
<span class="idx-name">성수 인쇄공장 재생</span>
|
||||
<span class="idx-loc">서울 성수</span>
|
||||
<span class="idx-year">2022</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="index-row reveal" style="--delay: 200ms">
|
||||
<a href="#drawing">
|
||||
<span class="idx-name">폼 오피스</span>
|
||||
<span class="idx-loc">서울 종로</span>
|
||||
<span class="idx-year">2019</span>
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
도면 — 스티키 평면도가 섹션을 지나는 동안 그어진다.
|
||||
R3: CSS scroll-driven + stroke-dashoffset. 폴백은 완성된 도면.
|
||||
도면은 장식이 아니라 이 연구소의 언어 그 자체다.
|
||||
-->
|
||||
<section id="drawing" class="section drawing" aria-labelledby="drawing-h">
|
||||
<div class="wrap drawing-grid">
|
||||
<div class="drawing-stage reveal">
|
||||
<svg
|
||||
viewBox="0 0 520 640"
|
||||
role="img"
|
||||
aria-label="폼 하우스 1층 평면도. 남쪽으로 긴 거실과 북쪽 서비스 블록, 동쪽 마당으로 이루어져 있다."
|
||||
>
|
||||
<!-- 외곽 벽 (이중선) -->
|
||||
<path
|
||||
class="wall"
|
||||
d="M 80 60 H 440 V 580 H 80 Z M 88 68 H 432 V 572 H 88 Z"
|
||||
/>
|
||||
<!-- 내부 벽 -->
|
||||
<path
|
||||
class="wall inner"
|
||||
d="M 260 68 V 300 M 260 380 V 572 M 88 300 H 432 M 88 380 H 260"
|
||||
/>
|
||||
<!-- 문 개구부 표시 -->
|
||||
<path class="door" d="M 260 340 h -36 M 260 340 a 36 36 0 0 1 36 -36" />
|
||||
<!-- 기둥 -->
|
||||
<path class="column" d="M 176 130 h 10 v 10 h -10 z M 356 130 h 10 v 10 h -10 z M 176 250 h 10 v 10 h -10 z M 356 250 h 10 v 10 h -10 z" />
|
||||
<!-- 치수선 -->
|
||||
<g class="dim">
|
||||
<path d="M 80 610 H 440 M 80 602 v 16 M 440 602 v 16" />
|
||||
<path d="M 50 60 V 580 M 42 60 h 16 M 42 580 h 16" />
|
||||
<text x="260" y="632" text-anchor="middle">14,400</text>
|
||||
<text x="30" y="325" text-anchor="middle" transform="rotate(-90 30 325)">9,600</text>
|
||||
</g>
|
||||
<!-- 방 이름 -->
|
||||
<g class="room-label">
|
||||
<text x="174" y="190">거실</text>
|
||||
<text x="350" y="190" text-anchor="middle"></text>
|
||||
<text x="174" y="460">주방·식당</text>
|
||||
<text x="350" y="455">서비스</text>
|
||||
<text x="350" y="490">현관</text>
|
||||
</g>
|
||||
<!-- 방위 -->
|
||||
<g class="north">
|
||||
<path d="M 470 90 v -40 M 470 50 l -8 14 M 470 50 l 8 14" />
|
||||
<text x="470" y="110" text-anchor="middle">N</text>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="drawing-caption">폼 하우스 · 1층 평면도 · mm</p>
|
||||
</div>
|
||||
|
||||
<div class="drawing-notes">
|
||||
<h2 id="drawing-h">폼 하우스, 가평</h2>
|
||||
<p>
|
||||
대지 남쪽의 소나무군을 지우지 않기 위해 집은 길게 눕고, 마당은 집
|
||||
안쪽으로 들어왔습니다. 구조는 9.6m 축 한 번으로 건너뛰어 거실에 기둥이
|
||||
없습니다.
|
||||
</p>
|
||||
<p>
|
||||
벽체는 단열 성능의 요구치에서 두께가 정해졌습니다 — 마감 재료를 먼저
|
||||
고르고 두께를 맞추지 않았습니다. 창은 남향 긴면 하나, 동쪽 마당 면
|
||||
하나. 그 두 개로 이 집의 하루가 결정됩니다.
|
||||
</p>
|
||||
<dl class="drawing-spec">
|
||||
<div>
|
||||
<dt>구조</dt>
|
||||
<dd>RC 라멘 · 9.6m 스팬</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>면적</dt>
|
||||
<dd>132.4 ㎡ (40.0평)</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>창개구</dt>
|
||||
<dd>남측 14.4m · 동측 4.2m</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>줄공사</dt>
|
||||
<dd>2025-04 – 2026-01</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
사진 — 흑백 3점. 그리드는 엄격하게, 크기는 사진이 요구하는 대로.
|
||||
-->
|
||||
<section id="photo" class="section photo" aria-labelledby="photo-h">
|
||||
<div class="wrap">
|
||||
<h2 id="photo-h" class="reveal">사진</h2>
|
||||
<div class="photo-grid">
|
||||
<figure class="ph ph-a reveal">
|
||||
<img
|
||||
src="./assets/pavilion-bw.webp"
|
||||
alt="긴 수평창이 낸 콘크리트 파빌리온에 벽 그림자가 지는 모습"
|
||||
width="1440"
|
||||
height="810"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>폼 하우스 · 남측 · 준공 후 첫 여름</figcaption>
|
||||
</figure>
|
||||
<figure class="ph ph-b reveal" style="--delay: 60ms">
|
||||
<img
|
||||
src="./assets/interior-bw.webp"
|
||||
alt="천창으로 한 줄기 빛이 사선으로 들어오는 미장 마감 전시실"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>백령도 해녀기념관 · 제1전시실</figcaption>
|
||||
</figure>
|
||||
<figure class="ph ph-c reveal" style="--delay: 120ms">
|
||||
<img
|
||||
src="./assets/model-bw.webp"
|
||||
alt="스터디 모델 — 직육면체를 쌓은 백색 석고 모형에 측광이 지는 모습"
|
||||
width="1100"
|
||||
height="825"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>스터디 모델 07 · 석고</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 연락 — 서류 같은 마무리 -->
|
||||
<section id="contact" class="section contact" aria-labelledby="contact-h">
|
||||
<div class="wrap contact-grid">
|
||||
<h2 id="contact-h">연락</h2>
|
||||
<address>
|
||||
서울 종로구 자하문로 00길 00, 3층<br />
|
||||
<a class="link" href="mailto:studio@form.kr">studio@form.kr</a>
|
||||
</address>
|
||||
<p class="contact-note">
|
||||
신규 프로젝트는 분기에 두 건만 받습니다. 대지와 예산의 조건을 적어
|
||||
보내주시면, 가능 여부를 2주 안에 답합니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>스튜디오 폼 · 공간건축연구소</p>
|
||||
<p class="foot-meta">© 2014–2026</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
497
apps/site/public/work/form-architecture/styles/main.css
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
스튜디오 폼 — swiss-minimal. 판맞춤 도면 묶음.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) {
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.01em;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
:where(p, li, dd, dt, th, td, address, figcaption) {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
.link {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
transition: border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.link:hover {
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ink);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 판맞춤 표시 — 넓은 화면에서만, 내용과 겹치지 않는 곳에 ──────────── */
|
||||
|
||||
.corner {
|
||||
position: fixed;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
}
|
||||
.corner-tl {
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
border-top: 1px solid var(--line-strong);
|
||||
border-left: 1px solid var(--line-strong);
|
||||
}
|
||||
.corner-tr {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
border-top: 1px solid var(--line-strong);
|
||||
border-right: 1px solid var(--line-strong);
|
||||
}
|
||||
.corner-bl {
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
border-left: 1px solid var(--line-strong);
|
||||
}
|
||||
.corner-br {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
border-right: 1px solid var(--line-strong);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.corner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wordmark-en {
|
||||
color: var(--ink-faint);
|
||||
font-weight: 400;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
border-bottom: 1px solid transparent;
|
||||
padding-block: 2px;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── 서문 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-8) var(--space-7);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.hero-meta {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.hero-statement {
|
||||
margin-top: var(--space-5);
|
||||
max-width: 30em;
|
||||
}
|
||||
.hero-facts {
|
||||
margin-top: var(--space-6);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4) var(--space-7);
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
.hero-facts dt {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.hero-facts dd {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 인덱스 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.index-h {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
.index {
|
||||
list-style: none;
|
||||
margin-top: var(--space-3);
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--line-strong);
|
||||
}
|
||||
.index-row a {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: var(--space-4) var(--space-6);
|
||||
align-items: baseline;
|
||||
padding-block: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-decoration: none;
|
||||
transition: background var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.index-row a:hover {
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
}
|
||||
.idx-name {
|
||||
font-size: var(--step-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
.idx-loc {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.index-row a:hover .idx-loc {
|
||||
color: inherit;
|
||||
}
|
||||
.idx-year {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.index-row a:hover .idx-year {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* ── 도면(스티키) ─────────────────────────────────────────────────────── */
|
||||
|
||||
.drawing {
|
||||
background: var(--surface);
|
||||
}
|
||||
.drawing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
}
|
||||
.drawing-stage {
|
||||
position: sticky;
|
||||
top: calc(var(--space-5) + 56px);
|
||||
align-self: start;
|
||||
}
|
||||
.drawing-stage svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-bright);
|
||||
}
|
||||
.wall {
|
||||
fill: none;
|
||||
stroke: var(--ink);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.wall.inner {
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
.door {
|
||||
fill: none;
|
||||
stroke: var(--ink-muted);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
.column {
|
||||
fill: var(--ink);
|
||||
}
|
||||
.dim path {
|
||||
fill: none;
|
||||
stroke: var(--line-strong);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.dim text {
|
||||
font-size: 12px;
|
||||
fill: var(--ink-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.room-label text {
|
||||
font-size: 13px;
|
||||
fill: var(--ink-muted);
|
||||
}
|
||||
.north path {
|
||||
fill: none;
|
||||
stroke: var(--ink);
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
.north text {
|
||||
font-size: 12px;
|
||||
fill: var(--ink-muted);
|
||||
}
|
||||
.drawing-caption {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
.wall,
|
||||
.wall.inner {
|
||||
stroke-dasharray: 3400;
|
||||
stroke-dashoffset: 3400;
|
||||
animation: draw 1.4s linear both;
|
||||
animation-timeline: --drawing;
|
||||
animation-range: entry 10% contain 55%;
|
||||
}
|
||||
@keyframes draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawing-notes h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
.drawing-notes p + p {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.drawing-spec {
|
||||
margin-top: var(--space-5);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: var(--space-3) var(--space-5);
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
.drawing-spec dt {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.drawing-spec dd {
|
||||
margin-top: 2px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── 사진 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.photo {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.photo h2 {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
.photo-grid {
|
||||
margin-top: var(--space-4);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.ph {
|
||||
display: block;
|
||||
}
|
||||
.ph img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
filter: grayscale(1); /* 사진 파일이 흑백이 아니어도 페이지는 흑백이다 */
|
||||
}
|
||||
.ph-a {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.ph figcaption {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 연락 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contact {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.contact address {
|
||||
font-style: normal;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.contact-note {
|
||||
color: var(--ink-muted);
|
||||
max-width: 26em;
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line-strong);
|
||||
padding-block: var(--space-4);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.foot-meta {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 10px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.drawing-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.drawing-stage {
|
||||
position: static;
|
||||
}
|
||||
.contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero,
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.index-row a {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.idx-loc {
|
||||
grid-column: 1;
|
||||
}
|
||||
.idx-year {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
52
apps/site/public/work/form-architecture/styles/tokens.css
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* ==========================================================================
|
||||
스튜디오 폼 — 디자인 토큰
|
||||
프리셋: swiss-minimal
|
||||
컨셉 한 문장: 판맞춤 도면 묶음 — 색이 아니라 선과 치수가 말한다.
|
||||
레퍼런스: R1 johnpawson.com(실측: 724px 고정폭, 전 태그 동일 크기+색으로만
|
||||
위계, 유일한 구분선 1px, 프로젝트는 제목/위치/연도 텍스트 행)
|
||||
R2 cereal(웜 크림 3단 모노크롬 → 우리는 중성 회색으로 번안)
|
||||
R3 CSS scroll-driven view()+dashoffset(이중 게이트, 폴백=완성 도면)
|
||||
하지 않은 것: 색(무채색 100% — 강조색 없음, 링크만 밑줄), radius 전면 0,
|
||||
사진 캐러셀, 서비스 아이콘. 이유: 도면의 권위는 선 하나에 있다.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — Pawson 교훈: 크기를 다양하게 쓰지 않는다. 위계는 무게와 색으로. */
|
||||
--step--1: 0.8125rem; /* 13px — 캡션·치수 */
|
||||
--step-0: 1rem; /* 16px — 본문 */
|
||||
--step-1: 1.125rem; /* 18px — 소제목(본문과 1단계 차이만) */
|
||||
--step-2: 1.375rem; /* 22px — 섹션 표제 */
|
||||
--step-3: clamp(1.75rem, 1.3rem + 2.4vw, 2.75rem); /* 44px — 서문 하나만 */
|
||||
|
||||
--leading-tight: 1.35;
|
||||
--leading-normal: 1.8;
|
||||
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
|
||||
/* 무채색 3단계 + 선 2단계. 강조색 없음. */
|
||||
--surface: #fdfdfb;
|
||||
--ink: #111111;
|
||||
--ink-muted: #555555;
|
||||
--ink-faint: #8a8a8a;
|
||||
--line: #d9d9d5;
|
||||
--line-strong: #999999; /* Pawson의 유일한 구분선 색 */
|
||||
--surface-bright: #ffffff; /* index 행 hover 반전의 가장 밝은 단계 */ /* Pawson의 유일한 구분선 색 */
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
|
||||
|
||||
--radius: 0; /* 이 페이지에는 모서리 둥금이 없다 */
|
||||
|
||||
--container: 1080px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 140ms;
|
||||
--dur-normal: 380ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
57
apps/site/public/work/gaon-lms/calc.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* 가온 학적부 — 순수 계산 로직. 브라우저와 Node 테스트가 같은 함수를 공유한다. */
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const attSummary = (row) => {
|
||||
const days = (row || []).filter((v) => v !== null && v !== undefined);
|
||||
if (!days.length) return "휴학";
|
||||
const att = days.filter((v) => v === 2).length;
|
||||
return `${Math.round((att / days.length) * 100)}%`;
|
||||
};
|
||||
|
||||
// 일별 합계 — marks 는 '행 배열'의 목록이다. v11 사고(행 자체를 세어 0/3) 방지가 이 함수의 존재 이유.
|
||||
const dayTotal = (marks, d) =>
|
||||
marks.filter((row) => row && row[d] !== null && row[d] !== undefined).length;
|
||||
const dayPresent = (marks, d) =>
|
||||
marks.filter((row) => row && row[d] === 2).length;
|
||||
|
||||
const counts = (marks) => {
|
||||
const flat = (marks || []).filter(Boolean).flat();
|
||||
const att = flat.filter((v) => v === 2).length;
|
||||
const late = flat.filter((v) => v === 1).length;
|
||||
const abs = flat.filter((v) => v === 0).length;
|
||||
return {
|
||||
att, late, abs, total: flat.length,
|
||||
rate: flat.length ? Math.round((att / flat.length) * 100) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
// 명단 통계 — 0명 나눗셈 가드 포함
|
||||
const rosterStats = (students) => {
|
||||
const list = students || [];
|
||||
const active = list.filter((s) => s.status === "재학");
|
||||
const avgAtt = active.length
|
||||
? Math.round(active.reduce((a, s) => a + (s.att || 0), 0) / active.length)
|
||||
: 0;
|
||||
const hw = active.reduce(
|
||||
(a, s) => {
|
||||
const [done, total] = String(s.hw || "0/0").split("/").map(Number);
|
||||
return { done: a.done + (done || 0), total: a.total + (total || 0) };
|
||||
},
|
||||
{ done: 0, total: 0 }
|
||||
);
|
||||
return {
|
||||
total: list.length,
|
||||
active: active.length,
|
||||
off: list.length - active.length,
|
||||
avgAtt,
|
||||
hwDone: hw.done,
|
||||
hwTotal: hw.total,
|
||||
hwRate: hw.total ? Math.round((hw.done / hw.total) * 100) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const api = { attSummary, dayTotal, dayPresent, counts, rosterStats };
|
||||
global.GAON = api;
|
||||
if (typeof module !== "undefined") module.exports = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
1316
apps/site/public/work/gaon-lms/index.html
Normal file
BIN
apps/site/public/work/gaon-lms/og.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
807
apps/site/public/work/gaon-lms/styles/main.css
Normal file
|
|
@ -0,0 +1,807 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
가온 학적부 — 프로덕션 콘솔 (shadcn/ui new-york 문법의 바닐라 이식)
|
||||
컨트롤 h-9 · 표 행 리듬 · 카드 radius 12px · shadow-sm — 문법이 곧 품질.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after { box-sizing: border-box; }
|
||||
* { margin: 0; }
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-body);
|
||||
font-synthesis-weight: none;
|
||||
-webkit-text-size-adjust: 100%; text-size-adjust: 100%; /* 합성 볼드 금지 — 없는 웨이트는 가늘게 떨어진다 */
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) { line-height: var(--leading-tight); font-weight: 600; word-break: keep-all; }
|
||||
strong, b { font-weight: 600; } /* UA 기본 700 볼드 재정의 */
|
||||
h1, h2.v-h {
|
||||
font-size: var(--step-4); font-weight: 600;
|
||||
font-family: var(--font-display); /* 학적부 서류의 명조 결 — 큰 제목에만 */
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
h1, h2.v-h { font-size: min(1.5rem, 6.4vw); } /* 뷰포트 단위 — 기기 글꼴 확대에도 제목은 부서지지 않는다 */
|
||||
}
|
||||
h2 { font-size: var(--step-2); }
|
||||
h3 { font-size: var(--step-0); font-weight: 500; }
|
||||
|
||||
:where(p, li, td, th, dd, figcaption) { word-break: keep-all; overflow-wrap: anywhere; }
|
||||
|
||||
.mono { font-family: var(--font-mono); font-size: var(--font-mono-size); font-variant-numeric: tabular-nums; }
|
||||
.muted { color: var(--muted-foreground); }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 2px; }
|
||||
|
||||
.skip {
|
||||
position: absolute; left: -9999px; top: 0;
|
||||
background: var(--primary); color: var(--primary-foreground);
|
||||
padding: var(--space-3) var(--space-4); z-index: 100; border-radius: var(--radius);
|
||||
}
|
||||
.skip:focus { left: var(--space-4); }
|
||||
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 앱 뼈대: 사이드바 244px + 본문 ── */
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: 244px minmax(0, 1fr);
|
||||
min-height: 100dvh;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.side {
|
||||
background: var(--background);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: var(--space-2);
|
||||
color: var(--foreground); text-decoration: none;
|
||||
font-weight: 600; font-size: var(--step-1);
|
||||
padding: var(--space-2) var(--space-2) var(--space-3);
|
||||
}
|
||||
.brand-mark {
|
||||
width: 24px; height: 24px; flex: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
display: grid; place-items: center;
|
||||
color: #fff; font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.brand-mark::after { content: "가"; }
|
||||
|
||||
.side-nav { display: grid; gap: 1px; }
|
||||
.side-group {
|
||||
font-size: var(--step-1);
|
||||
font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
padding: var(--space-4) var(--space-2) var(--space-1);
|
||||
}
|
||||
.side-link {
|
||||
appearance: none; border: 0; background: none; cursor: pointer;
|
||||
display: flex; align-items: center; gap: var(--space-2);
|
||||
width: 100%;
|
||||
text-align: left; color: var(--muted-foreground);
|
||||
font: inherit; font-size: var(--step-0); font-weight: 500;
|
||||
height: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
transition: background-color 100ms ease, color 100ms ease;
|
||||
}
|
||||
.side-link svg { flex: none; opacity: 0.9; }
|
||||
.side-link:hover { color: var(--foreground); background: var(--muted-2); }
|
||||
.side-link.is-current { color: var(--foreground); background: var(--muted-2); font-weight: 600; }
|
||||
.side-link.is-current svg { color: var(--accent); opacity: 1; }
|
||||
.side-foot {
|
||||
margin-top: auto;
|
||||
font-size: var(--step-1);
|
||||
color: var(--muted-foreground);
|
||||
line-height: 1.5;
|
||||
padding: var(--space-3) var(--space-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── 탑바 ── */
|
||||
.topbar {
|
||||
height: 52px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: var(--space-4);
|
||||
padding: 0 var(--space-6);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
flex: none;
|
||||
}
|
||||
.crumb { display: flex; align-items: center; gap: var(--space-2); font-size: var(--step-0); color: var(--muted-foreground); }
|
||||
.crumb strong { color: var(--foreground); font-weight: 600; }
|
||||
.topbar-right { display: flex; align-items: center; gap: var(--space-3); }
|
||||
.term-badge {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: var(--step-1); font-weight: 500;
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted-2);
|
||||
border-radius: var(--radius);
|
||||
padding: 3px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.avatar {
|
||||
width: 28px; height: 28px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--primary); color: var(--primary-foreground);
|
||||
font-size: var(--step-1); font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── 본문 ── */
|
||||
.main { min-width: 0; display: flex; flex-direction: column; }
|
||||
|
||||
.view { display: none; }
|
||||
.view.is-on { display: block; }
|
||||
.view:focus { outline: none; }
|
||||
|
||||
.page-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) var(--space-6) var(--space-5);
|
||||
}
|
||||
.page-head h1 { letter-spacing: -0.025em; }
|
||||
.view-sub { color: var(--muted-foreground); font-size: var(--step-0); margin-top: 4px; max-width: 46em; }
|
||||
.view-head {
|
||||
display: flex; justify-content: space-between; align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6) var(--space-6) var(--space-5);
|
||||
}
|
||||
.stamp { display: none; }
|
||||
.view > *:not(.view-head):not(.page-head) { margin-left: var(--space-6); margin-right: var(--space-6); }
|
||||
.view > *:last-child { margin-bottom: var(--space-6); }
|
||||
|
||||
/* ── 카드 ── */
|
||||
.panel {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-5);
|
||||
min-width: 0;
|
||||
}
|
||||
.panel h2 { margin-bottom: var(--space-4); font-size: var(--step-2); }
|
||||
.panel-note { font-size: var(--step-1); color: var(--faint); margin-top: var(--space-3); }
|
||||
|
||||
/* 통계 카드 */
|
||||
.headline { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.headline > div {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm); padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
.headline dt { font-size: var(--step-0); color: var(--muted-foreground); font-weight: 500; }
|
||||
.headline dd { font-size: var(--step-3); font-weight: 600; letter-spacing: -0.03em; line-height: 1.2; font-variant-numeric: tabular-nums; margin-top: 4px; }
|
||||
.headline .stat-sub, .rep-stat-sub { font-size: var(--step--1); color: var(--faint); margin: 6px 0 0; margin-inline-start: 0; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.week-panel { grid-row: span 2; }
|
||||
|
||||
/* ── 표 ── */
|
||||
.table-wrap { overflow-x: auto; overflow-y: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
text-align: left; font-weight: 500; font-size: var(--step-1);
|
||||
color: var(--muted-foreground); padding: 0 var(--space-3) var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
height: 36px; white-space: nowrap;
|
||||
}
|
||||
tbody td, tbody th { padding: var(--space-3); border-bottom: 1px solid var(--border); vertical-align: middle; text-align: left; }
|
||||
tbody th { font-weight: 500; }
|
||||
tbody tr:last-child td, tbody tr:last-child th { border-bottom: 0; }
|
||||
tbody tr { transition: background-color 80ms ease; }
|
||||
tbody tr:hover { background: var(--muted); }
|
||||
.tsum { padding: var(--space-3); font-size: var(--step--1); color: var(--muted-foreground); border-top: 1px solid var(--border); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
.row-open {
|
||||
appearance: none; border: 0; background: none; padding: 0;
|
||||
font: inherit; font-weight: 500; color: var(--foreground);
|
||||
cursor: pointer; text-align: left;
|
||||
transition: color 80ms ease;
|
||||
}
|
||||
.row-open:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* ── 배지 ── */
|
||||
.state {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: var(--step-1); font-weight: 500;
|
||||
border-radius: var(--radius);
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
background: var(--success-soft); color: var(--success-ink);
|
||||
}
|
||||
.state-wait { background: var(--warning-soft); color: var(--warning-ink); }
|
||||
.state-off { background: var(--muted-2); color: var(--muted-foreground); }
|
||||
.state-run { background: var(--accent-soft); color: var(--accent-ink); }
|
||||
.state-run::before { content: ""; width: 6px; height: 6px; border-radius: 999px; background: var(--accent); margin-right: 6px; animation: dot-pulse 1.6s ease infinite; }
|
||||
@keyframes dot-pulse { 50% { opacity: 0.35; } }
|
||||
.t-warn { color: var(--warning-ink); font-weight: 500; }
|
||||
|
||||
/* ── 차트 ── */
|
||||
.week-chart svg { display: block; width: 100%; height: auto; }
|
||||
.dist-chart svg { display: block; height: 200px; width: auto; max-width: 100%; margin-inline: auto; }
|
||||
.dist-chart .bars text { font-size: 11px; font-weight: 600; fill: var(--muted-foreground); }
|
||||
.dist-body { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: var(--space-6); align-items: center; }
|
||||
.dist-stats { border-left: 1px solid var(--border); padding-left: var(--space-5); }
|
||||
.dist-stats > div { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); padding: 9px 0; border-bottom: 1px solid var(--border); }
|
||||
.dist-stats > div:last-child { border-bottom: 0; }
|
||||
.dist-stats dt { font-size: var(--step-0); color: var(--muted-foreground); }
|
||||
.dist-stats dd { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.axis line { stroke: var(--border); stroke-width: 1; }
|
||||
.axis-labels text { font-size: 11px; fill: var(--muted-foreground); }
|
||||
.b-att { fill: var(--border-strong); }
|
||||
.b-late { fill: #f59e0b; }
|
||||
.b-abs { fill: var(--danger); }
|
||||
.b-hl { fill: var(--accent); }
|
||||
.week-chart figcaption, .dist-chart figcaption {
|
||||
display: flex; gap: var(--space-4);
|
||||
font-size: var(--step-1); color: var(--muted-foreground);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.wk-sum { margin-top: var(--space-4); border-top: 1px solid var(--border); }
|
||||
.wk-sum thead th { padding: 8px var(--space-2); }
|
||||
.wk-sum tbody td { padding: 7px var(--space-2); }
|
||||
.wk-sum tfoot td { padding: 8px var(--space-2); border-top: 1px solid var(--border-strong); font-weight: 600; }
|
||||
.wk-sum td:first-child, .wk-sum th:first-child { padding-left: 0; white-space: nowrap; }
|
||||
.wk-sum td:last-child, .wk-sum th:last-child { padding-right: 0; }
|
||||
.key { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.key i { width: 8px; height: 8px; display: inline-block; border-radius: 2px; }
|
||||
.k-att { background: var(--border-strong); }
|
||||
.k-late { background: #f59e0b; }
|
||||
.k-abs { background: var(--danger); }
|
||||
.hl { background: none; padding: 0; font-weight: 600; color: var(--foreground); }
|
||||
|
||||
/* ── 마감 목록 ── */
|
||||
.due-list { list-style: none; margin: 0; padding: 0; display: grid; }
|
||||
.due-row {
|
||||
appearance: none; background: none; border: 0;
|
||||
width: 100%; display: grid; gap: 2px; text-align: left;
|
||||
font: inherit; padding: var(--space-3);
|
||||
cursor: pointer; border-bottom: 1px solid var(--border);
|
||||
transition: background-color 80ms ease;
|
||||
}
|
||||
.due-list li:last-child .due-row { border-bottom: 0; }
|
||||
.due-row:hover { background: var(--muted); }
|
||||
.due-row span:last-child { font-size: var(--step-1); color: var(--muted-foreground); }
|
||||
.due-row.is-static { cursor: default; }
|
||||
.due-row.is-static:hover { background: none; }
|
||||
.hl-count { color: var(--danger-ink); font-weight: 600; background: var(--danger-soft); border-radius: 4px; padding: 0 5px; }
|
||||
|
||||
/* ── 검색 + 세그먼트 ── */
|
||||
.toolbar { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; margin: 0 0 var(--space-4); }
|
||||
#q {
|
||||
font: inherit; color: var(--foreground);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
height: 36px;
|
||||
padding: 0 var(--space-3);
|
||||
min-width: 13rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color 100ms ease, box-shadow 100ms ease;
|
||||
}
|
||||
#q:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
|
||||
#q::placeholder { color: var(--faint); }
|
||||
|
||||
.chips {
|
||||
display: inline-flex; gap: 2px;
|
||||
background: var(--muted-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
}
|
||||
.chip {
|
||||
appearance: none; cursor: pointer; font: inherit;
|
||||
font-size: var(--step-1); font-weight: 500;
|
||||
background: none; color: var(--muted-foreground);
|
||||
border: 0; border-radius: var(--radius);
|
||||
height: 28px; padding: 0 var(--space-3);
|
||||
display: inline-flex; align-items: center;
|
||||
transition: background-color 100ms ease, color 100ms ease, box-shadow 100ms ease;
|
||||
}
|
||||
.chip:hover { color: var(--foreground); }
|
||||
.chip.is-on { background: var(--card); color: var(--foreground); box-shadow: var(--shadow-sm); }
|
||||
|
||||
/* ── 출석부 히트맵 ── */
|
||||
.att-day { text-align: center; }
|
||||
.att-cell { text-align: center; padding: 3px; }
|
||||
.att-cell.is-na { color: var(--faint); }
|
||||
.att-btn {
|
||||
appearance: none; border: 1px solid transparent; cursor: pointer;
|
||||
font: inherit; font-size: var(--step-1); line-height: 1; font-weight: 600;
|
||||
width: 32px; height: 32px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
transition: transform 100ms ease, box-shadow 100ms ease;
|
||||
}
|
||||
.att-btn:hover { transform: scale(1.08); box-shadow: var(--shadow-sm); }
|
||||
.att-btn:active { scale: 0.94; }
|
||||
.att-btn.v-att { background: var(--success-soft); color: var(--success-ink); }
|
||||
.att-btn.v-late { background: #fef3c7; color: var(--warning-ink); }
|
||||
.att-btn.v-abs { background: var(--danger-soft); color: var(--danger-ink); }
|
||||
|
||||
.week-nav { display: inline-flex; align-items: center; gap: var(--space-2); }
|
||||
.week-label { min-width: 4.5em; text-align: center; color: var(--muted-foreground); font-size: var(--step-1); }
|
||||
.week-nav .btn-line:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ── 자료실 ── */
|
||||
.give-btn {
|
||||
appearance: none; cursor: pointer; font: inherit;
|
||||
font-size: var(--step-1); font-weight: 500;
|
||||
background: none; color: var(--muted-foreground);
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
||||
height: 28px; padding: 0 10px;
|
||||
transition: all 100ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.give-btn:hover { border-color: var(--foreground); color: var(--foreground); }
|
||||
.give-btn.is-given { background: var(--success-soft); border-color: transparent; color: var(--success-ink); }
|
||||
|
||||
/* ── 학사 일정 ── */
|
||||
.cal-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
.cal { table-layout: fixed; }
|
||||
.cal th { text-align: center; font-size: var(--step-1); height: 32px; padding: 0; }
|
||||
.cal-day {
|
||||
height: 76px; padding: var(--space-1);
|
||||
border: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.cal-day:nth-child(7n+6), .cal-day:nth-child(7n+7) { background: var(--muted); }
|
||||
.cal-day.is-empty { border: 0; background: none !important; }
|
||||
.cal-num { display: block; color: var(--muted-foreground); font-size: var(--step-1); font-weight: 500; }
|
||||
.cal-day.is-today .cal-num {
|
||||
display: inline-grid; place-items: center;
|
||||
width: 22px; height: 22px;
|
||||
color: #fff; font-weight: 600;
|
||||
background: #047857; /* 진한 에메랄드 — 흰 숫자 5.4:1 */
|
||||
border-radius: 999px;
|
||||
}
|
||||
.cal-ev {
|
||||
display: block; margin-top: 2px;
|
||||
font-size: var(--step--2); line-height: 1.35; word-break: keep-all;
|
||||
background: var(--accent-soft); color: var(--accent-ink);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
/* ── 상담 기록 ── */
|
||||
.counsel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.counsel-card {
|
||||
appearance: none; cursor: pointer; font: inherit; text-align: left;
|
||||
display: grid; gap: 4px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--space-4);
|
||||
transition: border-color 100ms ease, box-shadow 100ms ease;
|
||||
}
|
||||
.counsel-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-lg); }
|
||||
.counsel-card:active { scale: 0.995; }
|
||||
.co-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); }
|
||||
.co-head strong { font-weight: 600; }
|
||||
.co-sub { font-size: var(--step-1); }
|
||||
.co-text { font-size: var(--step-0); line-height: var(--leading-normal); word-break: keep-all; }
|
||||
.empty-note { color: var(--muted-foreground); font-size: var(--step-0); }
|
||||
|
||||
/* ── 버튼 ── */
|
||||
.btn-primary, .btn-outline, .btn-ghost, .btn-line, .btn-solid {
|
||||
appearance: none; cursor: pointer; font: inherit;
|
||||
font-size: var(--step-0); font-weight: 500;
|
||||
height: 36px; padding: 0 var(--space-4);
|
||||
border-radius: var(--radius);
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
|
||||
transition: background-color 100ms ease, border-color 100ms ease, color 100ms ease, scale 80ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary, .btn-solid { background: var(--primary); border: 1px solid var(--primary); color: var(--primary-foreground); box-shadow: var(--shadow-sm); }
|
||||
.btn-primary:hover, .btn-solid:hover { background: #27272a; border-color: #27272a; }
|
||||
.btn-outline, .btn-line { background: var(--card); border: 1px solid var(--border-strong); color: var(--foreground); box-shadow: var(--shadow-sm); }
|
||||
.btn-outline:hover, .btn-line:hover { background: var(--muted); }
|
||||
.btn-ghost { background: none; border: 0; color: var(--muted-foreground); padding: 0 var(--space-3); }
|
||||
.btn-ghost:hover { color: var(--foreground); background: var(--muted-2); }
|
||||
.btn-primary:active, .btn-outline:active, .btn-line:active, .btn-solid:active, .btn-ghost:active { scale: 0.98; }
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal {
|
||||
margin: auto;
|
||||
width: min(94vw, 860px);
|
||||
max-height: min(86dvh, 760px);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
color: var(--foreground);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
.modal::backdrop { background: rgb(9 9 11 / 0.55); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.modal[open] { animation: modal-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both; }
|
||||
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(0.99); } }
|
||||
}
|
||||
.mspread {
|
||||
display: grid;
|
||||
grid-template-columns: 264px minmax(0, 1fr);
|
||||
max-height: min(86dvh, 760px);
|
||||
}
|
||||
.mcover {
|
||||
position: relative;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
padding: var(--space-6);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mcover-mark {
|
||||
position: absolute; right: -14px; bottom: -20px;
|
||||
font-size: 8rem; font-weight: 600; line-height: 1;
|
||||
color: rgb(250 250 250 / 0.05);
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
.mcover-seal {
|
||||
width: 44px; height: 44px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: var(--step-2); font-weight: 600;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.mcover-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-3); line-height: 1.2; font-weight: 600;
|
||||
letter-spacing: -0.02em; word-break: keep-all;
|
||||
}
|
||||
.mcover-meta { color: #a1a1aa; font-size: var(--step-1); margin-top: var(--space-2); }
|
||||
.mcover-stats { margin-top: var(--space-5); border-top: 1px solid rgb(250 250 250 / 0.12); }
|
||||
.mcover-stats > div {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid rgb(250 250 250 / 0.12);
|
||||
}
|
||||
.mcover-stats dt { font-size: var(--step-1); color: #a1a1aa; }
|
||||
.mcover-stats dd { font-size: var(--step-1); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.mcover-foot { margin-top: auto; color: #71717a; font-size: var(--step--2); }
|
||||
|
||||
.msheet {
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: grid; gap: var(--space-5);
|
||||
align-content: start;
|
||||
min-height: 340px;
|
||||
}
|
||||
.msec h3 { color: var(--muted-foreground); margin-bottom: var(--space-3); font-weight: 500; font-size: var(--step-0); }
|
||||
.modal-title { font-size: var(--step-3); letter-spacing: -0.02em; }
|
||||
.modal-meta { color: var(--muted-foreground); margin-top: 2px; }
|
||||
.modal-close {
|
||||
position: absolute; top: var(--space-4); right: var(--space-4);
|
||||
width: 32px; height: 32px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--card); color: var(--muted-foreground); cursor: pointer;
|
||||
transition: background-color 100ms ease, color 100ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
.modal-close:hover { background: var(--muted-2); color: var(--foreground); }
|
||||
.modal-inner { position: relative; padding: var(--space-6); display: grid; gap: var(--space-4); }
|
||||
|
||||
/* 출석 스트립 */
|
||||
.att-strip { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.att-cell2 { display: grid; justify-items: center; gap: 4px; min-width: 40px; }
|
||||
.att-mark {
|
||||
width: 30px; height: 30px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: var(--step-1); line-height: 1; font-weight: 600;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.att-mark.v-att { background: var(--success-soft); color: var(--success-ink); }
|
||||
.att-mark.v-late { background: #fef3c7; color: var(--warning-ink); }
|
||||
.att-mark.v-abs { background: var(--danger-soft); color: var(--danger-ink); }
|
||||
.att-date { font-size: var(--step--2); color: var(--faint); }
|
||||
.att-strip-na { font-size: var(--step-1); color: var(--muted-foreground); }
|
||||
|
||||
/* 상담 타임라인 */
|
||||
#stu-note-list {
|
||||
list-style: none; margin: 0 0 var(--space-4); padding: 0;
|
||||
display: grid;
|
||||
}
|
||||
#stu-note-list li {
|
||||
display: grid; grid-template-columns: 3rem minmax(0, 1fr);
|
||||
gap: var(--space-3); align-items: baseline;
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
.note-date { color: var(--faint); font-size: var(--step-1); }
|
||||
.note-text { word-break: keep-all; }
|
||||
.note-form { display: flex; gap: var(--space-2); }
|
||||
.note-form input {
|
||||
flex: 1; min-width: 0; font: inherit; font-size: var(--step-0);
|
||||
height: 36px;
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
||||
background: var(--card); color: var(--foreground);
|
||||
padding: 0 var(--space-3);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.note-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
|
||||
.note-form input::placeholder { color: var(--faint); }
|
||||
|
||||
/* 채점 */
|
||||
.as-summary {
|
||||
font-size: var(--step-0); color: var(--muted-foreground);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
.as-summary strong { color: var(--foreground); font-weight: 600; font-size: var(--step-2); font-variant-numeric: tabular-nums; }
|
||||
.grade-table input.score {
|
||||
width: 5rem; font-family: var(--font-mono); font-size: var(--font-mono-size);
|
||||
text-align: right; color: var(--foreground);
|
||||
height: 30px;
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
}
|
||||
.grade-table input.score:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); }
|
||||
.modal-actions { display: flex; align-items: center; gap: var(--space-4); }
|
||||
.modal-note { font-size: var(--step-1); }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: minmax(0, 1fr); }
|
||||
.side {
|
||||
position: static; height: auto;
|
||||
flex-direction: row; align-items: center;
|
||||
gap: var(--space-3); padding: var(--space-2) var(--space-4);
|
||||
border-right: 0; border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.brand { padding: 0 var(--space-2) 0 0; flex: none; white-space: nowrap; } /* 가로 스트립에서 찌그러져 글자가 세로로 쌓이는 것을 막는다 */
|
||||
.side-nav { display: flex; gap: 2px; flex-wrap: nowrap; flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
|
||||
.side-group { display: none; }
|
||||
.side-link { white-space: nowrap; width: auto; height: 32px; }
|
||||
.side-foot { display: none; }
|
||||
.dash-grid, .cal-layout, .counsel-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.week-panel { grid-row: auto; }
|
||||
.dist-body { grid-template-columns: minmax(0, 1fr); }
|
||||
.view-head { flex-direction: column; align-items: flex-start; gap: var(--space-2); padding: var(--space-5) var(--space-4) var(--space-4); }
|
||||
.view > *:not(.view-head):not(.page-head) { margin-inline: var(--space-4); }
|
||||
.headline { gap: var(--space-2); }
|
||||
.topbar { padding: 0 var(--space-4); min-width: 0; }
|
||||
.term-badge { display: none; }
|
||||
.crumb { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cal-day { height: 56px; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.mspread { grid-template-columns: minmax(0, 1fr); }
|
||||
.mcover { padding: var(--space-4); flex-direction: row; align-items: center; gap: var(--space-3); }
|
||||
.mcover-seal { width: 40px; height: 40px; margin-bottom: 0; font-size: var(--step-1); }
|
||||
.mcover-name { font-size: var(--step-2); }
|
||||
.mcover-meta { margin-top: 2px; }
|
||||
.mcover-stats, .mcover-foot { display: none; }
|
||||
.mcover-mark { font-size: 5rem; }
|
||||
.msheet { padding: var(--space-4); min-height: 0; }
|
||||
.headline { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* ── 알림 센터 ── */
|
||||
.bell-wrap { position: relative; }
|
||||
.bell {
|
||||
position: relative; width: 32px; height: 32px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--radius); border: none; background: none; cursor: pointer;
|
||||
color: var(--muted-foreground); transition: background-color 80ms ease, color 80ms ease;
|
||||
}
|
||||
.bell:hover, .bell.is-open { background: var(--muted-2); color: var(--foreground); }
|
||||
.bell-dot {
|
||||
position: absolute; top: 4px; right: 4px;
|
||||
width: 8px; height: 8px; border-radius: 999px;
|
||||
background: var(--danger); border: 2px solid var(--background);
|
||||
}
|
||||
.bell-menu {
|
||||
display: none; position: absolute; top: 40px; right: 0; z-index: 40;
|
||||
width: min(340px, calc(100vw - 32px));
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); overflow: hidden;
|
||||
}
|
||||
.bell-menu.is-open { display: block; }
|
||||
.bell-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--border);
|
||||
font-size: var(--step-0); font-weight: 600;
|
||||
}
|
||||
.bell-head button {
|
||||
border: none; background: none; cursor: pointer;
|
||||
font-size: var(--step--1); color: var(--accent-ink); padding: 2px 4px; border-radius: 4px;
|
||||
}
|
||||
.bell-head button:hover { background: var(--accent-soft); }
|
||||
.ntc { display: flex; gap: 10px; padding: 11px 14px; border-bottom: 1px solid var(--border); }
|
||||
.ntc:last-child { border-bottom: 0; }
|
||||
.ntc .n-ico {
|
||||
width: 28px; height: 28px; flex: none; border-radius: var(--radius);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--muted-2); color: var(--muted-foreground);
|
||||
}
|
||||
.ntc.is-unread .n-ico { background: var(--accent-soft); color: var(--accent-ink); }
|
||||
.ntc p { font-size: var(--step-0); line-height: 1.45; }
|
||||
.ntc.is-read p { color: var(--muted-foreground); }
|
||||
.ntc time { display: block; font-size: var(--step--1); color: var(--faint); margin-top: 2px; }
|
||||
|
||||
/* ── 태그 칩 (유형·사유) ── */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; white-space: nowrap;
|
||||
font-size: var(--step--2); font-weight: 500;
|
||||
border: 1px solid var(--border); color: var(--muted-foreground);
|
||||
border-radius: 999px; padding: 1px 8px;
|
||||
}
|
||||
.tag-quiz { background: var(--accent-soft); color: var(--accent-ink); border-color: transparent; }
|
||||
.tag-exam { background: var(--warning-soft); color: var(--warning-ink); border-color: transparent; }
|
||||
.tag-warn { background: var(--warning-soft); color: var(--warning-ink); border-color: transparent; }
|
||||
.tag-ok { background: var(--success-soft); color: var(--success-ink); border-color: transparent; }
|
||||
|
||||
/* ── 수업 진행 ── */
|
||||
.h-note { font-size: var(--step--1); font-weight: 400; color: var(--faint); margin-left: 8px; }
|
||||
.prog-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.prog-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-2); }
|
||||
.prog-head strong { font-size: var(--step-0); font-weight: 600; }
|
||||
.prog-bar { height: 6px; border-radius: 999px; background: var(--muted-2); overflow: hidden; margin-top: 10px; }
|
||||
.prog-bar i { display: block; height: 100%; border-radius: 999px; background: var(--accent); }
|
||||
.prog-meta { font-size: var(--step--1); color: var(--faint); margin-top: 8px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── 공지 뷰 ── */
|
||||
.notice-list { list-style: none; display: flex; flex-direction: column; }
|
||||
.notice-row {
|
||||
width: 100%; text-align: left; cursor: pointer;
|
||||
background: none; border: none; border-bottom: 1px solid var(--border);
|
||||
padding: 14px var(--space-4);
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px var(--space-3);
|
||||
}
|
||||
.notice-list li:last-child .notice-row { border-bottom: 0; }
|
||||
.notice-row:hover { background: var(--muted); }
|
||||
.n-t { font-size: var(--step-0); font-weight: 600; display: flex; align-items: center; gap: 8px; min-width: 0; word-break: keep-all; }
|
||||
.notice-row.is-unread .n-t::after {
|
||||
content: ""; width: 6px; height: 6px; border-radius: 999px;
|
||||
background: var(--accent); flex: none;
|
||||
}
|
||||
.pin { color: var(--accent-ink); flex: none; }
|
||||
.n-meta {
|
||||
grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: var(--space-3);
|
||||
font-size: var(--step--1); color: var(--faint); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.n-body {
|
||||
max-width: 68ch;
|
||||
grid-column: 1 / -1; display: none;
|
||||
font-size: var(--step-0); line-height: 1.65; color: var(--muted-foreground);
|
||||
padding-top: 2px;
|
||||
}
|
||||
.notice-row.is-open .n-body { display: block; }
|
||||
|
||||
/* ── 출결 사유 칩 ── */
|
||||
.att-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: var(--space-2); }
|
||||
@media (pointer: coarse) {
|
||||
.att-btn { width: 40px; height: 40px; }
|
||||
.chip, .side-link, .give-btn { min-height: 40px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.prog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.prog-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
/* ── 뷰 공통 요약 스트립 ── */
|
||||
.statline {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.statline > div {
|
||||
display: inline-flex; align-items: baseline; gap: 6px;
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: var(--shadow-sm);
|
||||
padding: 6px 12px; font-size: var(--step-1); color: var(--muted-foreground);
|
||||
}
|
||||
.statline strong {
|
||||
font-size: var(--step-0); font-weight: 600; color: var(--foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.statline .warn-ink { color: var(--warning-ink); }
|
||||
.statline .muted-ink { color: var(--muted-foreground); }
|
||||
|
||||
/* ── 리포트 학기 요약 카드(4열) ── */
|
||||
.headline-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@media (max-width: 900px) { .headline-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 560px) { .headline-4 { grid-template-columns: minmax(0, 1fr); } }
|
||||
|
||||
/* ── 수강생 보호자 · 모달 보호자 블록 ── */
|
||||
#stu-table td.muted { color: var(--muted-foreground); white-space: nowrap; }
|
||||
.guard-line { font-size: var(--step-0); font-weight: 500; }
|
||||
.guard-log {
|
||||
list-style: none; margin-top: 8px;
|
||||
font-size: var(--step--1); color: var(--muted-foreground);
|
||||
display: grid; gap: 4px;
|
||||
}
|
||||
|
||||
/* ── 학사 일정: 유형색 + 범례 + D-day 점 ── */
|
||||
.cal-ev.is-exam { background: var(--warning-soft); color: var(--warning-ink); }
|
||||
.cal-ev.is-admin { background: var(--muted-2); color: var(--muted-foreground); }
|
||||
.cal-ev.is-holi { background: var(--danger-soft); color: var(--danger-ink); }
|
||||
.cal-legend {
|
||||
display: flex; flex-wrap: wrap; gap: var(--space-4);
|
||||
margin-top: var(--space-3); padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: var(--step--1); color: var(--muted-foreground);
|
||||
}
|
||||
.lg-exam, .lg-due, .lg-admin, .lg-holi, .ev-dot {
|
||||
width: 8px; height: 8px; border-radius: 2px; display: inline-block;
|
||||
}
|
||||
.lg-exam, .ev-dot.is-exam { background: var(--warning); }
|
||||
.lg-due, .ev-dot.is-due { background: var(--accent); }
|
||||
.lg-admin, .ev-dot.is-admin { background: var(--border-strong); }
|
||||
.lg-holi, .ev-dot.is-holi { background: var(--danger); }
|
||||
.ev-dot { margin-right: 8px; vertical-align: 0; }
|
||||
|
||||
/* ── 공지 뷰 툴바 여백 ── */
|
||||
#view-notice .toolbar { margin-bottom: var(--space-4); }
|
||||
|
||||
/* ── 모바일 갸행 수습(v12) — 한글은 어절에서만 끊고, 외톨이 줄을 만들지 않는다 ── */
|
||||
.view-sub, .n-body, .n-meta, .prog-meta, .stat-sub, .guard-line, .counsel-grid .co-text, .panel-note, .att-chips {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.due-row span { word-break: keep-all; }
|
||||
|
||||
@media (max-width: 680px) {
|
||||
/* 마감 임박 · 예정 상담 행 — 제목과 메타를 세로로 쌓아 메타가 끊기지 않게 */
|
||||
.due-row { display: block; }
|
||||
.due-row span { display: block; }
|
||||
.due-row .mono { margin-top: 2px; }
|
||||
/* 공지 행 — 제목이 전체 폭을 쓰고 분류 칩은 아래로 */
|
||||
.notice-row { grid-template-columns: minmax(0, 1fr); }
|
||||
.notice-row .tag { justify-self: start; }
|
||||
}
|
||||
65
apps/site/public/work/gaon-lms/styles/tokens.css
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/* ==========================================================================
|
||||
가온 학적부 — 디자인 토큰 (v3: 프로덕션 콘솔)
|
||||
프리셋: production-console — shadcn/ui (new-york) 토큰 체계의 충실한 이식.
|
||||
컨셉 한 문장: 실제 학원이 쓰는 것 같은 관리 콘솔 — 장식이 아니라 완성도로.
|
||||
레퍼런스: shadcn/ui 디자인 시스템(zinc 스키마) — 프로덕션 SaaS 관리 화면의
|
||||
사실상 표준 문법. 실측값은 아래 각 주석.
|
||||
하지 않은 것: 종이/장부 컨셉(철회), 그라디언트, 글로우, 컨셉 워터마크.
|
||||
색은 무채색 체계 + 상태색 3종 + 액센트 1(에메랄드)뿐.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — shadcn 본문 14px, 카드 값 24px tracking-tight */
|
||||
--step--2: 0.6875rem; /* 11px — 배지/케이션 */
|
||||
--step--1: 0.75rem; /* 12px — 보조 라벨 */
|
||||
--step-0: 0.875rem; /* 14px — 본문(sm) */
|
||||
--step-1: 1rem; /* 16px */
|
||||
--step-2: 1.125rem; /* 18px — 섹션 제목 */
|
||||
--step-3: 1.5rem; /* 24px — 카드 값/모달 제목 */
|
||||
--step-4: 1.875rem; /* 30px — 화면 제목 */
|
||||
|
||||
--leading-tight: 1.3;
|
||||
--leading-normal: 1.6;
|
||||
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
--font-display: "Noto Serif KR", "Pretendard Variable", serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--font-mono-size: 0.8125rem;
|
||||
|
||||
/* 색 — shadcn zinc 스키마 */
|
||||
--background: #fafafa; /* page */
|
||||
--card: #ffffff;
|
||||
--foreground: #09090b; /* zinc-950 */
|
||||
--muted: #fafafa;
|
||||
--muted-2: #f4f4f5; /* zinc-100 */
|
||||
--muted-foreground: #64646e; /* zinc-50/gray-100 위 5.2:1 이상 */ /* zinc-500 */
|
||||
--faint: #6e6e77; /* WCAG 4.5:1 보정 — 본문용 보조색 */
|
||||
--border: #e4e4e7; /* zinc-200 */
|
||||
--border-strong: #d4d4d8; /* zinc-300 */
|
||||
--primary: #18181b; /* zinc-900 */
|
||||
--primary-foreground: #fafafa;
|
||||
--ring: #a1a1aa;
|
||||
--accent: #059669; /* emerald-600 — 이 제품의 단 하나의 브랜드색 */
|
||||
--accent-soft: #ecfdf5; /* emerald-50 */
|
||||
--accent-ink: #065f46; /* emerald-800 */
|
||||
--success: #059669; --success-soft: #ecfdf5; --success-ink: #065f46;
|
||||
--warning: #b45309; --warning-soft: #fffbeb; --warning-ink: #92400e;
|
||||
--danger: #dc2626; --danger-soft: #fef2f2; --danger-ink: #991b1b;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--space-7: 3rem;
|
||||
|
||||
/* 반지름 — shadcn 파생 규칙: base 8px → 컨트롤 6px, 판 12px */
|
||||
--radius: 6px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* 그림자 — Tailwind shadow-sm / shadow-lg 실측값 */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
BIN
apps/site/public/work/hyang-incense/assets/agarwood.webp
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
apps/site/public/work/hyang-incense/assets/smoke.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
255
apps/site/public/work/hyang-incense/index.html
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>향연 (享煙) · 자연 침향 아카이브</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="연기는 흩어지고 향기만 머뭅니다. 자연 침향목의 수지 함량과 숙성 연도를 기록으로 남기는 향 아카이브. 서울 삼청동."
|
||||
/>
|
||||
<meta property="og:title" content="향연 (享煙) · 자연 침향 아카이브" />
|
||||
<meta property="og:description" content="수지 함량과 숙성 연도를 기록으로 남기는 침향 아카이브." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#F6F2E9" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F6F2E9'/%3E%3Cpath d='M16 6c0 3 0 5 0 7m0 4c0 3 2 3 2 5m-2 2c0 2-2 3-2 5' fill='none' stroke='%237C3B24' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 재질: 한지. 아주 옅은 섬유 그레인 — 사진이 아니라 종이 위에 있다는 감각 -->
|
||||
<svg class="paper" aria-hidden="true">
|
||||
<filter id="hanji">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9 0.7" numOctaves="2" seed="7" stitchTiles="stitch" />
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.55 0 0 0 0 0.52 0 0 0 0 0.46 0 0 0 0.05 0" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#hanji)" />
|
||||
</svg>
|
||||
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">향연 <span class="han">享煙</span></a>
|
||||
<nav class="nav" aria-label="섹션">
|
||||
<a href="#wood">향목</a>
|
||||
<a href="#aging">숙성</a>
|
||||
<a href="#collection">아카이브</a>
|
||||
<a href="#visit">방문</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
히어로 — 이 페이지의 리스크: 상품 사진 대신 연기 한 줄기.
|
||||
여백이 주인공이고 사진은 그 여백을 가르는 흔적 하나다.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap hero-grid">
|
||||
<div class="hero-text">
|
||||
<p class="hero-kicker reveal">자연 침향 · 백단 · 서울 삼청동</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 80ms">
|
||||
연기는 흩어지고<br />향기만 머뭅니다
|
||||
</h1>
|
||||
<p class="lead reveal" style="--delay: 160ms">
|
||||
향연은 향을 판매하기 전에 기록부터 남깁니다. 어느 해에 들인 나무인지,
|
||||
수지가 몇이었는지, 연소에 몇 분이 걸렸는지. 향은 사라지는 것이니
|
||||
남는 것은 문서뿐입니다.
|
||||
</p>
|
||||
<div class="hero-actions reveal" style="--delay: 220ms">
|
||||
<a class="btn" href="#collection">아카이브 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
<figure class="hero-photo reveal" style="--delay: 120ms">
|
||||
<img
|
||||
src="./assets/smoke.webp"
|
||||
alt="밝은 한지 위 검은 석 받침대에서 한 줄기 침향 연기가 곧게 피어오르는 모습"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<figcaption class="vertical">침향 한 줄기 · 삼청동 아카이브룸</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
향목 — 나무의 단면. 침향은 나무가 아니라 나무의 병(병해수지)이라는 것을
|
||||
이 섹션에서만 설명한다.
|
||||
-->
|
||||
<section id="wood" class="section" aria-labelledby="wood-h">
|
||||
<div class="wrap two">
|
||||
<figure class="two-photo reveal">
|
||||
<img
|
||||
src="./assets/agarwood.webp"
|
||||
alt="자연 침향 단면 — 짙은 수지 결과 하얀 목질이 함께 보이는 절편"
|
||||
width="1100"
|
||||
height="825"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>자연 침향 단면 · 수지와 목질</figcaption>
|
||||
</figure>
|
||||
<div class="two-text">
|
||||
<h2 id="wood-h">병든 나무만이<br />이 향을 가집니다</h2>
|
||||
<p>
|
||||
침향은 나무의 향이 아니라 나무의 상처입니다. 상처 입은 나무가 스스로를
|
||||
아무려기 위해 분비한 수지가 오랜 세월 굳은 것 — 그래서 심은 나무에서는
|
||||
얻을 수 없고, 자연에서 쓰러진 나무에서만 길어 올립니다.
|
||||
</p>
|
||||
<p class="muted">
|
||||
향연은 인공접종 목재를 다루지 않습니다. 매입 단계에서 수지 함량을
|
||||
측정해 기록하고, 그 기록이 통과하지 못한 나무는 아카이브에 들어오지
|
||||
않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
숙성 — 아카이브의 뼈대. 연도가 곧 목록이다.
|
||||
카드가 아니라 장부의 행: 연도 · 입목 · 수지 · 상태.
|
||||
-->
|
||||
<section id="aging" class="section band" aria-labelledby="aging-h">
|
||||
<div class="wrap">
|
||||
<div class="aging-head">
|
||||
<h2 id="aging-h">숙성 장부</h2>
|
||||
<p class="muted">
|
||||
들여온 해와 수지 함량, 지금 상태입니다. 향은 재고가 아니라 연도입니다 —
|
||||
같은 나무가 다시 들어오지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="table-wrap reveal">
|
||||
<table>
|
||||
<caption class="sr-only">
|
||||
숙성 장부 — 입목 연도, 산지, 수지 함량, 현재 상태
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">입목</th>
|
||||
<th scope="col">산지</th>
|
||||
<th scope="col" class="num">수지 함량</th>
|
||||
<th scope="col">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">2001년</th>
|
||||
<td>베트남 카호아</td>
|
||||
<td class="num">64.2%</td>
|
||||
<td>열매 · 매진 임박</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">2012년</th>
|
||||
<td>인도네시아 리아우</td>
|
||||
<td class="num">51.8%</td>
|
||||
<td>침향 절편 · 소량</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">2024년</th>
|
||||
<td>캄보디아 프놈펜 인근</td>
|
||||
<td class="num">47.3%</td>
|
||||
<td>숙성 중 · 비매품</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="aging-note muted">
|
||||
수지 함량: 에탄올 용출법 실측. 열매(초험)는 기증용으로만 남겨둡니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
아카이브 — 세 개의 물건. 사진 없는 목록.
|
||||
종이의 물성을 믿고 글자로만 판다(R2: 텍스트가 상품).
|
||||
-->
|
||||
<section id="collection" class="section" aria-labelledby="collection-h">
|
||||
<div class="wrap">
|
||||
<h2 id="collection-h">지금 만질 수 있는 것</h2>
|
||||
<ol class="collection">
|
||||
<li class="item reveal">
|
||||
<div class="item-text">
|
||||
<h3>자연 침향 절편</h3>
|
||||
<p>2001년 입목 · 수지 64.2% · 10g</p>
|
||||
</div>
|
||||
<p class="item-price">180,000원</p>
|
||||
</li>
|
||||
<li class="item reveal" style="--delay: 80ms">
|
||||
<div class="item-text">
|
||||
<h3>백단 침 향</h3>
|
||||
<p>인도 마이소르 노목 · 20g · 연소 45분</p>
|
||||
</div>
|
||||
<p class="item-price">42,000원</p>
|
||||
</li>
|
||||
<li class="item reveal" style="--delay: 160ms">
|
||||
<div class="item-text">
|
||||
<h3>침향 인엽</h3>
|
||||
<p>리아우산 · 2012년 입목 · 3g</p>
|
||||
</div>
|
||||
<p class="item-price">96,000원</p>
|
||||
</li>
|
||||
</ol>
|
||||
<p class="muted collection-note">
|
||||
모든 물건은 삼청동 아카이브룸에서 직접 내어드립니다. 향을 맡아 보고
|
||||
정하시면 됩니다 — 예약제이며 회당 한 분만 들어갑니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 방문 — 마지막 섹션이 푸터 역할 -->
|
||||
<section id="visit" class="section visit" aria-labelledby="visit-h">
|
||||
<div class="wrap visit-grid">
|
||||
<div>
|
||||
<h2 id="visit-h">삼청동, 예약제</h2>
|
||||
<address>
|
||||
서울 종로구 삼청로 00길 00<br />
|
||||
경복궁역 5번 출구에서 도보 6분
|
||||
</address>
|
||||
</div>
|
||||
<div class="visit-info">
|
||||
<dl>
|
||||
<div>
|
||||
<dt>예약</dt>
|
||||
<dd>화–토 · 11:00, 14:00, 16:00 회당 50분</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>문의</dt>
|
||||
<dd><a class="link" href="mailto:archive@hyang.kr">archive@hyang.kr</a></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<p class="vertical visit-mark">향연 享煙</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
447
apps/site/public/work/hyang-incense/styles/main.css
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
향연 — quiet-luxury. 한지 위의 장부.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 한지 그레인 — 본문 뒤 전면, 알파 0.05. 이 페이지의 유일한 재질 효과 */
|
||||
.paper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
body > *:not(.paper) {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.005em;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
:where(p, li, dd, dt, th, td, address, figcaption) {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
.link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.link:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* 세로쓰기 — 이 스튜디오의 물성 어휘. 목요일의 화원과 같은 결, 다른 용도 */
|
||||
.vertical {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wordmark .han {
|
||||
color: var(--ink-muted);
|
||||
font-weight: 400;
|
||||
font-size: var(--step-0);
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 히어로 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-8) var(--space-7);
|
||||
}
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-7) var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
.hero-kicker {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.14em;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.lead {
|
||||
max-width: 30em;
|
||||
}
|
||||
.hero-text .lead {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.hero-actions {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--ink);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
padding: 0.8rem 2rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft),
|
||||
color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
.hero-photo {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: var(--space-4);
|
||||
justify-content: end;
|
||||
align-items: start;
|
||||
}
|
||||
.hero-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.hero-photo figcaption {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
height: 14rem;
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── 향목 (2단) ───────────────────────────────────────────────────────── */
|
||||
|
||||
.two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.two-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.two-photo figcaption {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.two-text p + p {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.two-text .muted {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── 숙성 장부 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.aging-head {
|
||||
max-width: 36em;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.aging-head p {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--ink-faint);
|
||||
}
|
||||
tbody th {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
tbody td,
|
||||
tbody th {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.aging-note {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
/* ── 아카이브 목록 ────────────────────────────────────────────────────── */
|
||||
|
||||
.collection {
|
||||
list-style: none;
|
||||
margin-top: var(--space-5);
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.item h3 {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
.item p {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.item-price {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.collection-note {
|
||||
margin-top: var(--space-4);
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
/* ── 방문 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.visit {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.visit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.visit address {
|
||||
font-style: normal;
|
||||
margin-top: var(--space-3);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.visit-info dl {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.visit-info dt {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.visit-info dd {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.visit-mark {
|
||||
font-family: var(--font-display);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--step--1);
|
||||
height: 11rem;
|
||||
}
|
||||
|
||||
/* ── 등장 모션 — 다른 페이지보다 느리다 ──────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 16px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid,
|
||||
.two {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero-photo {
|
||||
max-width: 24rem;
|
||||
grid-template-columns: 1fr auto;
|
||||
justify-content: start;
|
||||
}
|
||||
.visit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.visit-mark {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
53
apps/site/public/work/hyang-incense/styles/tokens.css
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* ==========================================================================
|
||||
향연 (享煙) — 디자인 토큰
|
||||
프리셋: quiet-luxury
|
||||
컨셉 한 문장: 한지 위의 장부 — 여백이 주인공이고 연기 한 줄기가 흔적이다.
|
||||
레퍼런스: 방향은 dogfood-incense(단, 그 파일의 6축 수치는 audit-references.md
|
||||
에서 날조로 판명 — 토큰 근거로 쓰지 않는다). 사이트 선택(정향·아카이브
|
||||
계열의 절제)만 방향 앵커로 쓰고, 값은 브리프에서 다시 정했다.
|
||||
하지 않은 것: 스티커형 상품 카드, 영어 eyebrow, 배경 음영 그라디언트,
|
||||
숫자 카운트업. 이유: 기록물의 권위는 고요함에서 온다.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — 최대(60px) ÷ 본문(17px) ≈ 3.5배. 본문을 17px로 — 천천히 읽히는 페이지 */
|
||||
--step--1: 0.8125rem; /* 13px — 캡션 */
|
||||
--step-0: 1.0625rem; /* 17px — 본문 */
|
||||
--step-1: 1.25rem; /* 20px — 소제목 */
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);/* 28px — 섹션 표제 */
|
||||
--step-3: clamp(2.25rem, 1.4rem + 3.6vw, 3.75rem); /* 60px — 디스플레이 */
|
||||
|
||||
--leading-tight: 1.3;
|
||||
--leading-normal: 1.9;
|
||||
|
||||
--font-display: "MaruBuri", serif;
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
|
||||
/* 색 — 한지 2단계 + 잉크 3단계 + 적갈 1. 정확한 유약색이 아니라 '건조한 종이' */
|
||||
--surface: #f6f2e9;
|
||||
--surface-raised: #efe9dc;
|
||||
--ink: #2b2724;
|
||||
--ink-muted: #6d655c;
|
||||
--ink-faint: #9a9083;
|
||||
--line: #ddd4c3;
|
||||
--accent: #7c3b24; /* 적갈 — 잉크가 아니라 인주의 위험한 사촌 */
|
||||
--accent-hover: #5f2c1a;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6.5rem;
|
||||
--space-8: clamp(5rem, 3.4rem + 6vw, 10rem); /* 이 페이지의 여백은 다른 페이지보다 깊다 */
|
||||
|
||||
--radius: 2px;
|
||||
|
||||
--container: 1040px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 560ms; /* 느린 페이지 — 등장도 느리게 */
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
BIN
apps/site/public/work/lumina-optics/assets/lens-section.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/site/public/work/lumina-optics/assets/prism-bench.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
427
apps/site/public/work/lumina-optics/index.html
Normal file
|
|
@ -0,0 +1,427 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Lumina Optics · Refraction Bench</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A WebGL bench where a single beam meets a prism: refraction and dispersion computed per frame. Drag the prism, change the index."
|
||||
/>
|
||||
<meta property="og:title" content="Lumina Optics · Refraction Bench" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="One beam, one prism, per-frame refraction."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#101214" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23101214'/%3E%3Cpath d='M6 16h8m8 0h4M14 16l10-7v14z' fill='none' stroke='%23BFE8EE' stroke-width='1.4'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to main content</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">lumina<span class="wordmark-dot">·</span>optics</a>
|
||||
<nav class="nav" aria-label="Sections">
|
||||
<a href="#bench">Bench</a>
|
||||
<a href="#anatomy">Anatomy</a>
|
||||
<a href="#mtf">MTF</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
The bench. The product is light behaviour, so the hero IS the instrument:
|
||||
one beam, one prism, per-frame refraction. Drag rotates; sliders change
|
||||
the index and the dispersion — they are the two honest knobs a bench
|
||||
has. Everything degrades to a static photograph.
|
||||
-->
|
||||
<section id="bench" class="hero" aria-labelledby="hero-h">
|
||||
<div class="bench-stage reveal">
|
||||
<canvas
|
||||
id="gl"
|
||||
aria-label="Interactive prism: a white beam enters from the left and exits as a spectrum. Drag to rotate the prism."
|
||||
></canvas>
|
||||
<img
|
||||
class="bench-fallback"
|
||||
src="./assets/prism-bench.webp"
|
||||
alt="A glass prism on a dark optical breadboard splitting one collimated beam into a spectrum"
|
||||
width="1440"
|
||||
height="810"
|
||||
/>
|
||||
</div>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="hero-text">
|
||||
<p class="hero-kicker reveal">Refraction bench · WebGL</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
|
||||
One beam,<br />bent honestly
|
||||
</h1>
|
||||
<p class="lead reveal" style="--delay: 120ms">
|
||||
No photograph on this screen is doing the work — the prism is
|
||||
being refracted right now, per frame, on your GPU. Drag it. Change
|
||||
the index. The spectrum you see is the same arithmetic lens
|
||||
designers use, minus the glasses.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hud reveal" style="--delay: 160ms">
|
||||
<div class="hud-row">
|
||||
<label for="ior">Index of refraction <output id="ior-out" for="ior">1.52</output></label>
|
||||
<input type="range" id="ior" min="1.45" max="2.20" step="0.01" value="1.52" />
|
||||
</div>
|
||||
<div class="hud-row">
|
||||
<label for="disp">Dispersion (Abbe ↓) <output id="disp-out" for="disp">0.18</output></label>
|
||||
<input type="range" id="disp" min="0" max="0.6" step="0.01" value="0.18" />
|
||||
</div>
|
||||
<p class="hud-note" id="hud-note">Drag the prism to rotate · beam is collimated at 632.8 nm reference</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Anatomy — cut lens photography. Real glass, not renders.
|
||||
-->
|
||||
<section id="anatomy" class="section" aria-labelledby="anatomy-h">
|
||||
<div class="wrap two">
|
||||
<figure class="two-photo reveal">
|
||||
<img
|
||||
src="./assets/lens-section.webp"
|
||||
alt="Cutaway of a precision lens: a stack of glass elements in a metal barrel, edge-lit so each surface shows a refraction line"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>L-85 cutaway · 7 elements in 6 groups</figcaption>
|
||||
</figure>
|
||||
<div class="two-text">
|
||||
<h2 id="anatomy-h">Glass decides<br />before software does</h2>
|
||||
<p>
|
||||
A lens is a chain of refractions, and every surface answers before
|
||||
any firmware runs. Our L-85 uses two low-dispersion elements to
|
||||
keep the spectrum you just dragged from fanning out across the
|
||||
frame — dispersion corrected in glass, not in a profile.
|
||||
</p>
|
||||
<p class="muted">
|
||||
The cutaway above is a physical section, milled for measurement.
|
||||
Element count and spacing are measured from it, not from the
|
||||
drawing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
MTF — the only curve that argues. Static, thin, honest:
|
||||
measured at infinity, full aperture, both field directions.
|
||||
-->
|
||||
<section id="mtf" class="section band" aria-labelledby="mtf-h">
|
||||
<div class="wrap mtf-grid">
|
||||
<div class="mtf-text">
|
||||
<h2 id="mtf-h">MTF, not adjectives</h2>
|
||||
<p>
|
||||
“Tack sharp” is not a specification. Contrast transfer at given
|
||||
line pairs per millimetre is. These curves are measured on an
|
||||
optical bench at full aperture — sagittal and meridional, out to
|
||||
the corner.
|
||||
</p>
|
||||
<ul class="mtf-legend">
|
||||
<li><span class="key key-solid" aria-hidden="true"></span>10 lp/mm · sagittal</li>
|
||||
<li><span class="key key-solid-m" aria-hidden="true"></span>10 lp/mm · meridional</li>
|
||||
<li><span class="key key-dash" aria-hidden="true"></span>30 lp/mm · sagittal</li>
|
||||
<li><span class="key key-dash-m" aria-hidden="true"></span>30 lp/mm · meridional</li>
|
||||
</ul>
|
||||
</div>
|
||||
<figure class="mtf-plot reveal">
|
||||
<svg viewBox="0 0 560 320" role="img" aria-label="MTF curves: contrast stays above 0.85 at 10 lp/mm across the field; at 30 lp/mm it falls from 0.75 at center to 0.42 at the corner.">
|
||||
<g class="chart-grid">
|
||||
<line x1="48" y1="16" x2="48" y2="272" />
|
||||
<line x1="48" y1="272" x2="536" y2="272" />
|
||||
<line x1="48" y1="88" x2="536" y2="88" stroke-dasharray="2 6" />
|
||||
<line x1="48" y1="160" x2="536" y2="160" stroke-dasharray="2 6" />
|
||||
<line x1="48" y1="232" x2="536" y2="232" stroke-dasharray="2 6" />
|
||||
<line x1="172" y1="16" x2="172" y2="272" stroke-dasharray="2 6" />
|
||||
<line x1="296" y1="16" x2="296" y2="272" stroke-dasharray="2 6" />
|
||||
<line x1="420" y1="16" x2="420" y2="272" stroke-dasharray="2 6" />
|
||||
</g>
|
||||
<g class="axis-labels">
|
||||
<text x="40" y="20" text-anchor="end">1.0</text>
|
||||
<text x="40" y="92" text-anchor="end">0.75</text>
|
||||
<text x="40" y="164" text-anchor="end">0.5</text>
|
||||
<text x="40" y="236" text-anchor="end">0.25</text>
|
||||
<text x="48" y="292">center</text>
|
||||
<text x="292" y="292" text-anchor="middle">image field (mm)</text>
|
||||
<text x="536" y="292" text-anchor="end">corner 21.6</text>
|
||||
</g>
|
||||
<!-- 10 lp/mm sagittal: high, gentle fall -->
|
||||
<polyline class="mtf mtf-10s" points="48,32 172,36 296,44 420,60 536,76" />
|
||||
<!-- 10 lp/mm meridional -->
|
||||
<polyline class="mtf mtf-10m" points="48,34 172,40 296,52 420,72 536,96" />
|
||||
<!-- 30 lp/mm sagittal -->
|
||||
<polyline class="mtf mtf-30s" points="48,84 172,96 296,120 420,160 536,196" />
|
||||
<!-- 30 lp/mm meridional -->
|
||||
<polyline class="mtf mtf-30m" points="48,90 172,108 296,140 420,186 536,232" />
|
||||
</svg>
|
||||
<figcaption>L-85 · f/1.8 · infinity focus · bench-measured 2026-07</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Spec — with conditions.
|
||||
-->
|
||||
<section class="section" aria-labelledby="spec-h">
|
||||
<div class="wrap">
|
||||
<h2 id="spec-h" class="reveal">Specification</h2>
|
||||
<div class="table-wrap reveal" style="--delay: 80ms">
|
||||
<table>
|
||||
<caption class="sr-only">L-85 specification — item, value, condition</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Item</th>
|
||||
<th scope="col" class="num">Value</th>
|
||||
<th scope="col">Condition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><th scope="row">Focal length</th><td class="num">85 mm</td><td>nominal · ±1.5% at infinity</td></tr>
|
||||
<tr><th scope="row">Aperture</th><td class="num">f/1.8 – f/16</td><td>9-blade · near-circular</td></tr>
|
||||
<tr><th scope="row">Elements</th><td class="num">7 in 6</td><td>2 LD · measured from cutaway</td></tr>
|
||||
<tr><th scope="row">Close focus</th><td class="num">0.62 m</td><td>1:7.2 magnification</td></tr>
|
||||
<tr><th scope="row">Filter</th><td class="num">Ø 62 mm</td><td>brass, front-threaded</td></tr>
|
||||
<tr><th scope="row">Weight</th><td class="num">438 g</td><td>without caps, with hood</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact -->
|
||||
<section id="contact" class="section band contact" aria-labelledby="contact-h">
|
||||
<div class="wrap contact-grid">
|
||||
<h2 id="contact-h">Bench visits</h2>
|
||||
<p>
|
||||
We measure loaner samples for free and publish the curves —
|
||||
manufacturers welcome or not. Bench time and MTF requests:
|
||||
</p>
|
||||
<a class="btn" href="mailto:bench@lumina.optics">bench@lumina.optics</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>lumina optics</p>
|
||||
<p class="foot-meta">measurements reproducible on request · bench cal. 2026-06</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module">
|
||||
/*
|
||||
Refraction bench — three.js, one prism, two honest uniforms.
|
||||
Budget note: three.module.js ≈ 165KB gzip. 예산 상향 — 이 페이지의
|
||||
제품이 WebGL 자체다(데모 브리프 예산 상향 허용).
|
||||
폴백: WebGL 실패 시 사진이 보이고 캔버스는 뜨지 않는다(이미 마크업에 있음).
|
||||
*/
|
||||
const canvas = document.getElementById("gl");
|
||||
const fallback = document.querySelector(".bench-fallback");
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
let gl = null;
|
||||
try {
|
||||
gl = canvas.getContext("webgl2", { antialias: true, alpha: true });
|
||||
} catch (e) { /* fall through */ }
|
||||
|
||||
if (!gl) {
|
||||
canvas.remove();
|
||||
document.getElementById("hud-note").textContent =
|
||||
"WebGL unavailable — showing the bench photograph instead.";
|
||||
} else {
|
||||
fallback.remove();
|
||||
const THREE = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js");
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true, alpha: true });
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
const DPR = Math.min(devicePixelRatio || 1, 1.5);
|
||||
renderer.setPixelRatio(DPR);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 50);
|
||||
camera.position.set(0, 0, 9);
|
||||
|
||||
// ----- prism: triangular cross-section (CylinderGeometry, 3 segments)
|
||||
const prismGeo = new THREE.CylinderGeometry(2.1, 2.1, 2.6, 3, 1, false);
|
||||
prismGeo.rotateX(Math.PI / 2); // axis toward camera
|
||||
prismGeo.rotateZ(Math.PI / 2); // flat edge up to the beam
|
||||
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uIor: { value: 1.52 },
|
||||
uDisp: { value: 0.18 },
|
||||
};
|
||||
|
||||
const prismMat = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
transparent: true,
|
||||
vertexShader: /* glsl */`
|
||||
varying vec3 vN;
|
||||
varying vec3 vView;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
vN = normalize(normalMatrix * normal);
|
||||
vView = normalize(-mv.xyz);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}`,
|
||||
fragmentShader: /* glsl */`
|
||||
precision highp float;
|
||||
varying vec3 vN;
|
||||
varying vec3 vView;
|
||||
uniform float uIor;
|
||||
uniform float uDisp;
|
||||
|
||||
// wavelength ramp: violet → cyan → yellow → red
|
||||
vec3 spectrum(float t) {
|
||||
t = clamp(t, 0.0, 1.0);
|
||||
return clamp(
|
||||
vec3(
|
||||
smoothstep(0.30, 0.00, t) + smoothstep(0.70, 1.00, t),
|
||||
smoothstep(0.00, 0.35, t) - smoothstep(0.60, 1.00, t),
|
||||
smoothstep(0.00, 0.30, t) - smoothstep(0.45, 0.75, t)
|
||||
), 0.0, 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float ndv = abs(dot(normalize(vN), normalize(vView)));
|
||||
// fresnel edge (schlick): rim is where refraction lives
|
||||
float f = pow(1.0 - ndv, 3.0);
|
||||
// refraction deflection grows with index
|
||||
float bend = (uIor - 1.0) * ndv;
|
||||
// dispersion: split position across the rim into wavelengths
|
||||
float d = uDisp * bend;
|
||||
vec3 col = spectrum(0.5 + (1.0 - ndv) * (0.6 + d * 4.0));
|
||||
// glass body: near-black with faint interior glow
|
||||
vec3 body = vec3(0.02, 0.025, 0.03) * (0.4 + ndv);
|
||||
vec3 rim = col * f * 1.35;
|
||||
float alpha = clamp(f * 0.95 + 0.06, 0.0, 1.0);
|
||||
gl_FragColor = vec4(body + rim, alpha);
|
||||
}`,
|
||||
});
|
||||
const prism = new THREE.Mesh(prismGeo, prismMat);
|
||||
scene.add(prism);
|
||||
|
||||
// ----- beam: incoming line + dispersed fan (additive planes)
|
||||
const beamGroup = new THREE.Group();
|
||||
const mkBeam = (w, h, color1, color2, op) => {
|
||||
const m = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(w, h),
|
||||
new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
uniforms: { uOp: { value: op }, uC1: { value: new THREE.Color(color1) }, uC2: { value: new THREE.Color(color2) } },
|
||||
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
|
||||
fragmentShader: `
|
||||
varying vec2 vUv; uniform float uOp; uniform vec3 uC1; uniform vec3 uC2;
|
||||
void main(){
|
||||
float a = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
|
||||
float b = smoothstep(0.0, 0.5, vUv.y) * smoothstep(1.0, 0.5, vUv.y);
|
||||
vec3 c = mix(uC1, uC2, vUv.x);
|
||||
gl_FragColor = vec4(c, a * b * uOp);
|
||||
}`,
|
||||
})
|
||||
);
|
||||
return m;
|
||||
};
|
||||
const beamIn = mkBeam(9, 0.10, 0xdfe8ec, 0xffffff, 0.55);
|
||||
beamIn.position.set(0, 1.45, -0.2);
|
||||
beamGroup.add(beamIn);
|
||||
const fan = mkBeam(9, 1.7, 0x8fd8e8, 0xf2b060, 0.30);
|
||||
fan.position.set(-0.4, 0.35, -0.25);
|
||||
fan.rotation.z = 0.085;
|
||||
beamGroup.add(fan);
|
||||
scene.add(beamGroup);
|
||||
|
||||
// ----- interaction
|
||||
let dragging = false, lastX = 0, vel = 0.0016, rot = 0;
|
||||
canvas.addEventListener("pointerdown", (e) => { dragging = true; lastX = e.clientX; canvas.setPointerCapture(e.pointerId); });
|
||||
canvas.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - lastX;
|
||||
lastX = e.clientX;
|
||||
rot += dx * 0.006;
|
||||
vel = dx * 0.0006;
|
||||
});
|
||||
const end = () => (dragging = false);
|
||||
canvas.addEventListener("pointerup", end);
|
||||
canvas.addEventListener("pointercancel", end);
|
||||
canvas.style.touchAction = "none";
|
||||
|
||||
const iorEl = document.getElementById("ior");
|
||||
const dispEl = document.getElementById("disp");
|
||||
const iorOut = document.getElementById("ior-out");
|
||||
const dispOut = document.getElementById("disp-out");
|
||||
const bind = (el, out, uni, fmt) =>
|
||||
el.addEventListener("input", () => {
|
||||
uni.value = parseFloat(el.value);
|
||||
out.textContent = fmt(uni.value);
|
||||
});
|
||||
bind(iorEl, iorOut, uniforms.uIor, (v) => v.toFixed(2));
|
||||
bind(dispEl, dispOut, uniforms.uDisp, (v) => v.toFixed(2));
|
||||
|
||||
// ----- sizing
|
||||
const stage = canvas.parentElement;
|
||||
function fit() {
|
||||
const r = stage.getBoundingClientRect();
|
||||
const h = Math.max(320, Math.min(r.height, 760));
|
||||
renderer.setSize(r.width, h, false);
|
||||
camera.aspect = r.width / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
fit();
|
||||
new ResizeObserver(fit).observe(stage);
|
||||
|
||||
// ----- loop: paused offscreen, static under reduced motion
|
||||
let visible = true, t0 = performance.now();
|
||||
const io = new IntersectionObserver((en) => { visible = en[0].isIntersecting; });
|
||||
io.observe(canvas);
|
||||
|
||||
function frame() {
|
||||
if (!visible) { requestAnimationFrame(frame); return; }
|
||||
const t = (performance.now() - t0) / 1000;
|
||||
uniforms.uTime.value = t;
|
||||
if (!dragging) { vel *= 0.98; rot += Math.max(0.0016, Math.abs(vel)) * Math.sign(vel || 1); }
|
||||
prism.rotation.z = rot;
|
||||
beamGroup.rotation.z = Math.sin(t * 0.4) * 0.01;
|
||||
renderer.render(scene, camera);
|
||||
if (!reduced) requestAnimationFrame(frame);
|
||||
}
|
||||
frame(); // reduced-motion: renders exactly one static frame
|
||||
}
|
||||
|
||||
const io2 = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io2.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
477
apps/site/public/work/lumina-optics/styles/main.css
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
Lumina Optics — 광학 시험대. 색은 캔버스 안에서만.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2) {
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.015em;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
:where(p, th, td, figcaption, li) {
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--ink-muted);
|
||||
max-width: 36em;
|
||||
}
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.wordmark-dot {
|
||||
color: var(--accent-stroke);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent-stroke);
|
||||
}
|
||||
|
||||
/* ── 시험대(히어로) — 캔버스와 본문은 세로로 완전히 분리.
|
||||
R2 원칙(design.md): 3D 위에 본문을 얹지 않는다. ─────────────────────── */
|
||||
|
||||
.hero {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.bench-stage {
|
||||
position: relative;
|
||||
height: clamp(320px, 56vh, 560px);
|
||||
background:
|
||||
radial-gradient(80% 90% at 50% 30%, rgba(127, 185, 196, 0.05), transparent 60%),
|
||||
var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
#gl,
|
||||
.bench-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
#gl {
|
||||
cursor: grab;
|
||||
}
|
||||
#gl:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.hero-inner {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
.hero-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
color: var(--accent-stroke);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.hero .lead {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.hud {
|
||||
background: color-mix(in srgb, var(--surface) 78%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.hud-row label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.hud-row output {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink);
|
||||
}
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent-stroke);
|
||||
}
|
||||
.hud-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── 단면 2단 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.two-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.two-photo figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.two-text p + p {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── MTF ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.mtf-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.mtf-text p {
|
||||
margin-top: var(--space-3);
|
||||
color: var(--ink-muted);
|
||||
max-width: 30em;
|
||||
}
|
||||
.mtf-legend {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: var(--space-4);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.key {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--accent-stroke);
|
||||
margin-right: var(--space-2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.key-solid {
|
||||
border-top: 2.5px solid var(--accent-stroke);
|
||||
}
|
||||
.key-solid-m {
|
||||
border-top: 2.5px solid var(--ink-faint);
|
||||
}
|
||||
.key-dash {
|
||||
border-top: 2px dashed var(--accent-stroke);
|
||||
}
|
||||
.key-dash-m {
|
||||
border-top: 2px dashed var(--ink-faint);
|
||||
}
|
||||
.mtf-plot svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.chart-grid line {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.chart-grid line:first-child,
|
||||
.chart-grid line:nth-child(2) {
|
||||
stroke: var(--line-strong);
|
||||
}
|
||||
.axis-labels text {
|
||||
font-size: 12px;
|
||||
fill: var(--ink-faint);
|
||||
}
|
||||
.mtf {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.mtf-10s {
|
||||
stroke: var(--accent-stroke);
|
||||
}
|
||||
.mtf-10m {
|
||||
stroke: var(--ink-faint);
|
||||
}
|
||||
.mtf-30s {
|
||||
stroke: var(--accent-stroke);
|
||||
stroke-dasharray: 6 5;
|
||||
}
|
||||
.mtf-30m {
|
||||
stroke: var(--ink-faint);
|
||||
stroke-dasharray: 6 5;
|
||||
}
|
||||
.mtf-plot figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── 사양 표 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.table-wrap {
|
||||
margin-top: var(--space-4);
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
}
|
||||
tbody th {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td,
|
||||
tbody th {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
tbody td:not(.num) {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 연락 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--space-4) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.contact-grid p {
|
||||
color: var(--ink-muted);
|
||||
max-width: 34em;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--accent-stroke);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft),
|
||||
color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--accent-stroke);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.foot-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 12px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-inner {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
padding-block: var(--space-5);
|
||||
}
|
||||
.bench-stage {
|
||||
height: clamp(300px, 48vh, 460px);
|
||||
}
|
||||
.two,
|
||||
.mtf-grid,
|
||||
.contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
57
apps/site/public/work/lumina-optics/styles/tokens.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* ==========================================================================
|
||||
Lumina Optics — 디자인 토큰
|
||||
프리셋: refraction / WebGL instrument
|
||||
컨셉 한 문장: 광학 시험대 — 유일한 색은 프리즘이 스스로 만드는 스펙트럼.
|
||||
레퍼런스: 방향 앵커 dogfood-optics(광학업종의 데이터 노출). 그 파일의 수치는
|
||||
audit-references.md 날조 판명 — 토큰은 브리프 재정의. UI는 무채색에
|
||||
가깝게: 색은 캔버스 안에서만 정당하다(design.md R2 원리와 같은 결).
|
||||
성능 예산: **상향 명시** — three.module.js ≈165KB(gzip) 지연 import.
|
||||
이 페이지의 제품이 WebGL 데모 자체이므로 데모 예산 상향 규칙 적용.
|
||||
UI JS(슬라이더 바인딩)는 인라인 <1KB.
|
||||
하지 않은 것: 스펙텔러 그라디언트 배경, 'unrivaled clarity' 카피,
|
||||
3열 카드. 이유: 광학은 곡선으로 말한다.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — 최대(58px) ÷ 본문(16px) ≈ 3.6배 */
|
||||
--step--1: 0.8125rem;
|
||||
--step-0: 1rem;
|
||||
--step-1: 1.1875rem;
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
|
||||
--step-3: clamp(2.125rem, 1.4rem + 3.4vw, 3.625rem);
|
||||
|
||||
--leading-tight: 1.24;
|
||||
--leading-normal: 1.72;
|
||||
|
||||
--font-body: "IBM Plex Sans", -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* 색 — 시험실 무채색. 살짝 청록 기운의 액센트는 '레이저 라인' 하나뿐. */
|
||||
--surface: #101214;
|
||||
--surface-raised: #15181b;
|
||||
--ink: #e8ecee;
|
||||
--ink-muted: #9ba4a9;
|
||||
--ink-faint: #6b7479;
|
||||
--line: #23272b;
|
||||
--line-strong: #394046;
|
||||
--accent: #bfe8ee; /* 레이저 라인(밝게 — 텍스트용) */
|
||||
--accent-stroke: #7fb9c4;
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
|
||||
|
||||
--radius: 2px;
|
||||
|
||||
--container: 1120px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 420ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
BIN
apps/site/public/work/muwol-coffee/assets/beans-hero.webp
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/site/public/work/muwol-coffee/assets/pourover.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
333
apps/site/public/work/muwol-coffee/index.html
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>무월 (霧月) · 싱글오리진 원두 정기구독</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="원두마다 산미·단맛·바디를 같은 척도로 측정해 적고, 생두 매입가를 공개합니다. 격주 화요일 로스팅, 서울 서촌."
|
||||
/>
|
||||
<meta property="og:title" content="무월 (霧月) · 싱글오리진 원두 정기구독" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="측정된 좌표로 원두를 고르는 격주 구독. 생두 매입 원장 공개."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#F4F4F0" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/webfontworld/naver/MaruBuri.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F4F4F0'/%3E%3Cpath d='M16 7a7 7 0 0 1 7 7h-7z' fill='%237A3E1D'/%3E%3Cpath d='M16 9a7 7 0 0 0-7 7h7z' fill='%23181B18'/%3E%3Ccircle cx='16' cy='16' r='9' fill='none' stroke='%23181B18' stroke-width='1.5'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">
|
||||
무월 <span class="wordmark-han">霧月</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="섹션">
|
||||
<a href="#scale">척도</a>
|
||||
<a href="#ledger">원장</a>
|
||||
<a href="#recipe">레시피</a>
|
||||
<a href="#plan">구독</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
히어로 — R1(coffeecollective.dk): 가격을 첫 화면에서 숨기지 않는다.
|
||||
문구만으로 시작하는 대신 이번 주 로스 사진 한 장과 원두 둘(값 포함)을 같이 둔다.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap hero-grid">
|
||||
<div class="hero-text">
|
||||
<p class="hero-label reveal">싱글오리진 로스터리 · 서울 서촌</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
|
||||
취향은 형용사가 아니라<br />측정된 좌표입니다
|
||||
</h1>
|
||||
<p class="lead reveal" style="--delay: 120ms">
|
||||
과일 향이 난다는 말로는 추출 온도를 정할 수 없습니다. 무월은 원두마다
|
||||
산미와 단맛, 바디를 같은 척도로 측정해 적고, 생두를 얼마에 들였는지까지
|
||||
공개합니다. 마셔보기 전에 판단할 수 있어야 원두 고르는 즐거움이 시작됩니다.
|
||||
</p>
|
||||
<div class="hero-actions reveal" style="--delay: 180ms">
|
||||
<a class="btn" href="#plan">격주 구독 시작하기</a>
|
||||
<p class="hero-note">격주 화요일 로스팅 출고 · 언제든 해지</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure class="hero-photo reveal" style="--delay: 100ms">
|
||||
<img
|
||||
src="./assets/beans-hero.webp"
|
||||
alt="따뜻한 회색 리넨 위 질도기 그릇에 담긴 갓 볶은 원두와 놋쇠 스쿱"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<figcaption>이번 주 첫 로스 · 8월 19일</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- 이번 주 원두 — 카드가 아니라 원장의 첫 두 줄 -->
|
||||
<dl class="hero-beans reveal" style="--delay: 240ms">
|
||||
<div class="bean-row">
|
||||
<div>
|
||||
<dt>에티오피아 구지 우라가</dt>
|
||||
<dd class="bean-origin">워시드 · 해발 2,150m · 커핑 88.75</dd>
|
||||
</div>
|
||||
<dd class="bean-price"><span>100g</span> 8,500원</dd>
|
||||
</div>
|
||||
<div class="bean-row">
|
||||
<div>
|
||||
<dt>콜롬비아 엘 파라이소</dt>
|
||||
<dd class="bean-origin">혐기발효 · 해발 1,930m · 커핑 91.20</dd>
|
||||
</div>
|
||||
<dd class="bean-price"><span>100g</span> 11,000원</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
척도 — 향미 좌표계. 히어로의 계기판 노릇을 내려놓고
|
||||
본문의 척도로 옮긴다. 눈금과 점만 남긴다.
|
||||
-->
|
||||
<section id="scale" class="section" aria-labelledby="scale-h">
|
||||
<div class="wrap scale-grid">
|
||||
<div class="scale-text">
|
||||
<h2 id="scale-h">같은 척도로 잰<br />향미 좌표</h2>
|
||||
<p>
|
||||
같은 밝기로 볶아도 생두가 머금은 수분과 밀도에 따라 열은 다르게
|
||||
들어갑니다. 그래서 무월은 색이 아니라 1차 팝핑 후 감률과 커핑 점수로
|
||||
로트를 기록합니다.
|
||||
</p>
|
||||
<p class="muted">
|
||||
좌표는 로트마다 다시 측정해 옮겨 적습니다. 지난 로트의 좌표와 지금이
|
||||
같다고 말하지 않습니다.
|
||||
</p>
|
||||
<ul class="scale-legend" aria-label="좌표 범례">
|
||||
<li><span class="key" aria-hidden="true"></span>우라가 G1 워시드</li>
|
||||
<li><span class="key key-b" aria-hidden="true"></span>엘 파라이소 혐기발효</li>
|
||||
<li><span class="key key-c" aria-hidden="true"></span>엘 소코로 마라카투라</li>
|
||||
</ul>
|
||||
</div>
|
||||
<figure class="scale-plot reveal">
|
||||
<svg
|
||||
viewBox="0 0 520 340"
|
||||
role="img"
|
||||
aria-label="산미-바디 2차원 좌표. 우라가는 산미 높음, 엘 파라이소는 단맛 높음, 엘 소코로는 바디 높음."
|
||||
>
|
||||
<!-- 눈금 격자 -->
|
||||
<g class="grid">
|
||||
<line x1="56" y1="40" x2="56" y2="280" />
|
||||
<line x1="56" y1="280" x2="500" y2="280" />
|
||||
<line x1="56" y1="160" x2="500" y2="160" />
|
||||
<line x1="278" y1="40" x2="278" y2="280" />
|
||||
<line x1="167" y1="40" x2="167" y2="280" stroke-dasharray="2 6" />
|
||||
<line x1="389" y1="40" x2="389" y2="280" stroke-dasharray="2 6" />
|
||||
<line x1="56" y1="100" x2="500" y2="100" stroke-dasharray="2 6" />
|
||||
<line x1="56" y1="220" x2="500" y2="220" stroke-dasharray="2 6" />
|
||||
</g>
|
||||
<!-- 축 이름 -->
|
||||
<g class="axis-labels">
|
||||
<text x="56" y="302">산미 →</text>
|
||||
<text x="500" y="302" text-anchor="end">바디 →</text>
|
||||
<text x="46" y="46" text-anchor="end">10.0</text>
|
||||
<text x="46" y="286" text-anchor="end">0</text>
|
||||
</g>
|
||||
<!-- 로트 점 -->
|
||||
<g class="points">
|
||||
<circle cx="140" cy="62" r="5" />
|
||||
<text x="140" y="46" text-anchor="middle">우라가 8.8</text>
|
||||
<circle cx="252" cy="92" r="5" class="pt-b" />
|
||||
<text x="252" y="76" text-anchor="middle">엘 파라이소 9.0</text>
|
||||
<circle cx="430" cy="196" r="5" class="pt-c" />
|
||||
<text x="430" y="180" text-anchor="middle">엘 소코로 8.6</text>
|
||||
</g>
|
||||
</svg>
|
||||
<figcaption>8월 3주차 로트 실측 · 좌표는 주마다 다시 놓입니다</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
원장 — 이 페이지의 신뢰 장치 (R1: 거래 내역 공개가 이 업종의 스펙).
|
||||
밴드를 살짝 눌러 표가 페이지의 중심임을 알린다.
|
||||
-->
|
||||
<section id="ledger" class="section band" aria-labelledby="ledger-h">
|
||||
<div class="wrap">
|
||||
<div class="ledger-head">
|
||||
<h2 id="ledger-h">생두 매입 원장</h2>
|
||||
<p class="muted">
|
||||
농장에 돌아가는 돈이 얼마인지 보여주는 일이 스페셜티의 조건이라
|
||||
생각합니다. 1kg당 매입가와 가공 방식까지 로트 단위로 남깁니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="table-wrap reveal">
|
||||
<table>
|
||||
<caption class="sr-only">
|
||||
원두별 생두 매입 원장 — 원두명, 생산자, 가공 방식, 매입가, 커핑 점수
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">원두</th>
|
||||
<th scope="col">생산자</th>
|
||||
<th scope="col">가공</th>
|
||||
<th scope="col" class="num">매입가 (FOB/kg)</th>
|
||||
<th scope="col" class="num">커핑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">에티오피아 구지 우라가</th>
|
||||
<td>하르소 콜루 세척장</td>
|
||||
<td>워시드 · 72시간 발효</td>
|
||||
<td class="num">$14.20</td>
|
||||
<td class="num">88.75</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">콜롬비아 엘 파라이소</th>
|
||||
<td>디에고 사무엘 베르무데스</td>
|
||||
<td>혐기발효 · 열충격 2회</td>
|
||||
<td class="num">$26.50</td>
|
||||
<td class="num">91.20</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">과테말라 엘 소코로</th>
|
||||
<td>후안 디에고 데 라 세르다</td>
|
||||
<td>전량 워시드</td>
|
||||
<td class="num">$16.80</td>
|
||||
<td class="num">89.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="ledger-note muted">
|
||||
운임·통관·관세는 포함하지 않은 현지 출하가이며, 환율은 매입일 기준입니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
레시피 — 사진과 사양. 사양은 목록의 주인공이 아니라 사진의 각주다.
|
||||
-->
|
||||
<section id="recipe" class="section" aria-labelledby="recipe-h">
|
||||
<div class="wrap recipe-grid">
|
||||
<figure class="recipe-photo reveal">
|
||||
<img
|
||||
src="./assets/pourover.webp"
|
||||
alt="고핫물이 필터 백에 담긴 도자기 드리퍼로 얇은 물줄기로 들어가는 중"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>서촌 매장 · 오후 첫 잔</figcaption>
|
||||
</figure>
|
||||
<div class="recipe-text">
|
||||
<h2 id="recipe-h">원두 18g, 물 288g</h2>
|
||||
<p>
|
||||
구독 패키지에는 원두 밀도에 맞춘 분쇄도와 주수 간격을 적은 카드 한 장이
|
||||
들어갑니다. 드립의 변주는 그 다음 문제입니다.
|
||||
</p>
|
||||
<dl class="spec">
|
||||
<div>
|
||||
<dt>물 온도</dt>
|
||||
<dd>93°C</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>분쇄도</dt>
|
||||
<dd>코만단테 24딸</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>뜸 들이기</dt>
|
||||
<dd>40초 · 물 40g</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>주수</dt>
|
||||
<dd>3회 · 116g씩</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="muted">
|
||||
추출 시간은 2분 30초에서 3분 사이가 기준입니다. 그보다 빨리 흘러내리면
|
||||
곱게 갈고, 늦으면 굵게 갑니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
구독 — 회색조에서 가장 어두운 블록. 이 페이지의 유일한 강한 면.
|
||||
(1단계 회색조 위계 계획의 실행: CTA가 1위 덩어리)
|
||||
-->
|
||||
<section id="plan" class="section plan" aria-labelledby="plan-h">
|
||||
<div class="wrap plan-grid">
|
||||
<div class="plan-text">
|
||||
<h2 id="plan-h">격주, 서로 다른 좌표의<br />원두 두 종</h2>
|
||||
<p>
|
||||
2주마다 화요일 아침, 산미형 한 종과 밸런스형 한 종(각 100g)을 볶어
|
||||
보냅니다. 원두는 시음 후 남는 양이 없어 다음 주에 같은 로트를 못 드릴 수
|
||||
있습니다.
|
||||
</p>
|
||||
<ul class="plan-list">
|
||||
<li>원두별 추출 카드 동봉 — 분쇄도·온도·주수</li>
|
||||
<li>아로마 밸브 틴케이스 첫 회 증정</li>
|
||||
<li>출장·휴가 주는 앱에서 건너뛰기</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="plan-box">
|
||||
<p class="plan-price">
|
||||
<span>28,000원</span><span class="plan-unit">회당 · 배송비 포함</span>
|
||||
</p>
|
||||
<a class="btn btn-inverse" href="#plan">격주 구독 시작하기</a>
|
||||
<p class="plan-note">200g(100g×2) · 첫 회는 다음 화요일에 출고됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>무월 로스터리 · 서울 종로구 옥인길 18</p>
|
||||
<p class="muted">화요일 로스팅 · 목요일부터 토요일까지 문을 엽니다</p>
|
||||
<p class="muted">roastery@muwol.kr</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* 목요일의 화원과 같은 문법 — 한 번 보이면 유지, 스크롤 이벤트 없음 */
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
560
apps/site/public/work/muwol-coffee/styles/main.css
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
무월 — quiet-commerce. 종이 원장의 문법.
|
||||
레이아웃 → 재질 → 모션 순서로 읽힌다.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2) {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.01em;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
:where(p, li, dd, dt, th, td, figcaption) {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.75;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wordmark-han {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step-0);
|
||||
margin-left: var(--space-1);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 히어로 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-7);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-7) var(--space-6);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
.hero-text .lead {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-note {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn-inverse {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
.btn-inverse:hover {
|
||||
background: var(--surface-bright);
|
||||
}
|
||||
|
||||
.hero-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.hero-photo figcaption,
|
||||
.recipe-photo figcaption,
|
||||
.scale-plot figcaption {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 이번 주 원두 — 원장의 첫 두 줄 (카드 아님) */
|
||||
.hero-beans {
|
||||
margin-top: var(--space-6);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.bean-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.bean-row dt {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 500;
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
.bean-origin {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.bean-price {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bean-price span {
|
||||
color: var(--ink-faint);
|
||||
margin-right: var(--space-2);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── 척도(좌표 플롯) ──────────────────────────────────────────────────── */
|
||||
|
||||
.scale-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.scale-text p + p {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.scale-legend {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: var(--space-4);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.key {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--ink);
|
||||
margin-right: var(--space-1);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.key-b {
|
||||
background: var(--accent);
|
||||
}
|
||||
.key-c {
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
|
||||
.scale-plot svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.scale-plot .grid line {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.scale-plot .grid line:first-child,
|
||||
.scale-plot .grid line:nth-child(2) {
|
||||
stroke: var(--ink-faint);
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
.axis-labels text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
fill: var(--ink-faint);
|
||||
}
|
||||
.points circle {
|
||||
fill: var(--ink);
|
||||
}
|
||||
.points .pt-b {
|
||||
fill: var(--accent);
|
||||
}
|
||||
.points .pt-c {
|
||||
fill: var(--ink-faint);
|
||||
}
|
||||
.points text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
fill: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* ── 원장(밴드) ───────────────────────────────────────────────────────── */
|
||||
|
||||
.ledger-head {
|
||||
max-width: 38em;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.ledger-head p {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--ink-faint);
|
||||
}
|
||||
tbody th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
tbody td,
|
||||
tbody th {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ledger-note {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
/* ── 레시피 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.recipe-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.recipe-text p + p {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.spec {
|
||||
margin-top: var(--space-4);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: var(--space-3) var(--space-5);
|
||||
}
|
||||
.spec dt {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.spec dd {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--step-1);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* ── 구독(반전 블록) ──────────────────────────────────────────────────── */
|
||||
|
||||
.plan {
|
||||
background: var(--surface-inverse);
|
||||
color: var(--ink-inverse);
|
||||
}
|
||||
.plan-note {
|
||||
color: var(--ink-inverse-muted);
|
||||
}
|
||||
.plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.plan-text p {
|
||||
margin-top: var(--space-4);
|
||||
max-width: 34em;
|
||||
}
|
||||
.plan-list {
|
||||
margin-top: var(--space-4);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.plan-list li {
|
||||
padding-left: 1.25em;
|
||||
position: relative;
|
||||
color: var(--ink-inverse-muted);
|
||||
}
|
||||
.plan-list li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.72em;
|
||||
width: 6px;
|
||||
height: 1.5px;
|
||||
background: var(--ink-inverse-muted);
|
||||
}
|
||||
.plan-list li + li {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.plan-box {
|
||||
border: 1px solid var(--line-inverse);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-5);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
justify-items: start;
|
||||
}
|
||||
.plan-price span:first-child {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--step-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.plan-unit {
|
||||
display: block;
|
||||
font-size: var(--step--1);
|
||||
margin-top: var(--space-1);
|
||||
color: var(--ink-inverse-muted);
|
||||
}
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-6);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2) var(--space-5);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ── 등장 모션 — IO 1회, 유지 ─────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 12px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid,
|
||||
.scale-grid,
|
||||
.recipe-grid,
|
||||
.plan-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero-photo,
|
||||
.recipe-photo {
|
||||
max-width: 26rem;
|
||||
}
|
||||
.hero {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.bean-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.plan-box {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
67
apps/site/public/work/muwol-coffee/styles/tokens.css
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/* ==========================================================================
|
||||
무월 (霧月 · MUWOL) — 디자인 토큰
|
||||
프리셋: quiet-commerce
|
||||
컨셉 한 문장: 종이 원장처럼 정직한 구독 — 화면에는 사양과 실측만.
|
||||
레퍼런스: R1 coffeecollective.dk(구조·가격 노출·거래 투명성)
|
||||
R2 fitzcarraldoeditions.com(세리프 본문 정돈, 1강조색)
|
||||
R3 scroll-driven view() — 채택 안 함(IO 등장만 씀. 정적 HTML + 폴백 우선)
|
||||
하지 않은 것: 세로 GNB 레일, 영어 대문자 eyebrow, 전면 그레인 오버레이,
|
||||
FIG.00 캡션 문법, 카드 그림자. 이유: 계기판 흉내가 상품을 가림.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 스케일 — 최대(64px) ÷ 본문(16px) = 4.0배 */
|
||||
--step--1: 0.8125rem; /* 13px — 캡션·주석 */
|
||||
--step-0: 1rem; /* 16px — 본문 */
|
||||
--step-1: 1.25rem; /* 20px — 소제목 */
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem); /* 28px — 섹션 표제 */
|
||||
--step-3: clamp(2.25rem, 1.4rem + 3.6vw, 4rem); /* 64px — 디스플레이 */
|
||||
|
||||
--leading-tight: 1.28;
|
||||
--leading-normal: 1.75;
|
||||
--measure: 34ch;
|
||||
|
||||
--font-display: "MaruBuri", serif;
|
||||
--font-serif: "MaruBuri", serif;
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* 색 — hue 2개: 안개 종이 + 볶은 원두 앰버. 강조색은 앰버 하나. */
|
||||
--surface: #f4f4f0;
|
||||
--surface-raised: #eaeae4;
|
||||
--surface-inverse: #181b18;
|
||||
--ink: #181b18;
|
||||
--ink-muted: #585c56;
|
||||
--ink-faint: #8c908a;
|
||||
--ink-inverse: #f4f4f0;
|
||||
--ink-inverse-muted: #a8aca4;
|
||||
|
||||
--line: #d5d7d0;
|
||||
--line-inverse: #3a3e39;
|
||||
|
||||
--accent: #7a3e1d;
|
||||
--accent-hover: #5c2d13;
|
||||
--surface-bright: #fffffa; /* 반전 블록 안 밝은 hover 단계 */
|
||||
--accent-ink: #f4f4f0;
|
||||
|
||||
/* 대비(실측 예정 — 5단계에서 재검): ink/surface 15.6, muted/surface 6.2,
|
||||
accent/surface 7.5, inverse 조합은 본문 AA 이상으로 설계 */
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
|
||||
|
||||
--radius: 2px;
|
||||
|
||||
--container: 1120px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 180ms;
|
||||
--dur-normal: 420ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
BIN
apps/site/public/work/orbit-aerospace/assets/channel-macro.webp
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/site/public/work/orbit-aerospace/assets/static-fire.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
322
apps/site/public/work/orbit-aerospace/index.html
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>궤도 우주공학 · 75kN 메탄 엔진 사양서</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="75kN급 메탄-액체산소 재생냉각 엔진의 단면도와 지상 연소 시험 기록. 사양은 전부 지상 시험 실측값이다."
|
||||
/>
|
||||
<meta property="og:title" content="궤도 우주공학 · 75kN 메탄 엔진" />
|
||||
<meta property="og:description" content="재생냉각 엔진의 단면도와 연소 시험 기록." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#081018" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23081018'/%3E%3Cpath d='M16 4l6 7v17h-12V11z' fill='none' stroke='%23C57A45' stroke-width='1.6'/%3E%3Cpath d='M13 11h6M12 18h8' stroke='%23C57A45'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">궤도 우주공학 <span class="wordmark-en">ORBIT</span></a>
|
||||
<nav class="nav" aria-label="섹션">
|
||||
<a href="#engine">엔진</a>
|
||||
<a href="#spec">사양</a>
|
||||
<a href="#fires">시험 기록</a>
|
||||
<a href="#contact">문의</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
히어로 — 문구 대신 도면. 엔진 단면도가 첫 화면의 주인공이다.
|
||||
이 도면은 장식이 아니라 사양서의 1페이지다: 라벨은 실제 부품 이름.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap hero-grid">
|
||||
<div class="hero-text">
|
||||
<p class="hero-kicker reveal">75kN 메탄-액체산소 · 재생냉각 · 2024–</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
|
||||
도면은 말하지 않는다.<br />측정만 한다
|
||||
</h1>
|
||||
<p class="lead reveal" style="--delay: 120ms">
|
||||
이 페이지의 모든 수치는 지상 연소 시험에서 잰 값입니다. 홍보 문구에
|
||||
쓸 수 없는 숫자는 사양서에도 넣지 않습니다. 추력, 비추력, 연소실
|
||||
압력 — 어디서, 몇 초 동안 재었는지가 숫자와 함께 갑니다.
|
||||
</p>
|
||||
<a class="btn reveal" style="--delay: 180ms" href="#spec">사양 전문 보기</a>
|
||||
</div>
|
||||
|
||||
<figure class="blueprint reveal" style="--delay: 100ms">
|
||||
<svg
|
||||
viewBox="0 0 460 560"
|
||||
role="img"
|
||||
aria-label="엔진 단면도: 상부 인젝터, 연소실, 중앙 스로트, 확장 노즐과 벽면의 재생냉각 유로"
|
||||
>
|
||||
<defs>
|
||||
<g id="half">
|
||||
<!-- 외벽: 연소실 → 스로트 → 노즐 -->
|
||||
<path
|
||||
class="bp-wall"
|
||||
d="M 230 70
|
||||
L 168 70
|
||||
L 168 210
|
||||
C 168 250 194 262 194 286
|
||||
C 194 310 118 350 112 470
|
||||
L 108 500"
|
||||
/>
|
||||
<!-- 내벽 -->
|
||||
<path
|
||||
class="bp-wall inner"
|
||||
d="M 230 92
|
||||
L 184 92
|
||||
L 184 214
|
||||
C 184 246 206 258 206 284
|
||||
C 206 306 136 348 130 468
|
||||
L 127 494"
|
||||
/>
|
||||
<!-- 재생냉각 유로 (내벽을 따라가는 가는 선 2줄) -->
|
||||
<path
|
||||
class="bp-cool"
|
||||
d="M 176 100 L 176 212 C 176 248 198 262 198 286 C 198 308 126 350 121 468 L 118 492"
|
||||
/>
|
||||
<path
|
||||
class="bp-cool"
|
||||
d="M 170 100 L 170 210 C 170 244 192 260 192 288 C 192 312 120 352 114 470 L 111 496"
|
||||
/>
|
||||
<!-- 인젝터 매니폴드 -->
|
||||
<rect class="bp-block" x="168" y="46" width="50" height="18" />
|
||||
<path class="bp-wall thin" d="M 168 64 L 218 64" />
|
||||
</g>
|
||||
</defs>
|
||||
<use href="#half" />
|
||||
<use href="#half" transform="translate(460 0) scale(-1 1)" />
|
||||
<!-- 중심선 -->
|
||||
<path class="bp-axis" d="M 230 30 V 540" />
|
||||
<!-- 유동 화살표 -->
|
||||
<g class="bp-flow">
|
||||
<path d="M 230 120 V 220" />
|
||||
<path d="M 230 220 l -5 10 M 230 220 l 5 10" />
|
||||
</g>
|
||||
<!-- 라벨 -->
|
||||
<g class="bp-label">
|
||||
<path class="bp-lead" d="M 218 55 H 150" />
|
||||
<text x="144" y="59" text-anchor="end">인젝터</text>
|
||||
<path class="bp-lead" d="M 168 160 H 96" />
|
||||
<text x="90" y="164" text-anchor="end">연소실</text>
|
||||
<path class="bp-lead" d="M 200 275 H 78" />
|
||||
<text x="72" y="279" text-anchor="end">스로트</text>
|
||||
<path class="bp-lead" d="M 128 400 H 60" />
|
||||
<text x="54" y="404" text-anchor="end">노즐</text>
|
||||
<path class="bp-lead" d="M 186 180 H 120" />
|
||||
<text x="114" y="196" text-anchor="end"></text>
|
||||
<text x="242" y="150">재생냉각</text>
|
||||
<text x="242" y="166">유로 (CH₄)</text>
|
||||
</g>
|
||||
<g class="bp-dim">
|
||||
<path d="M 356 92 H 372 M 364 92 V 214" />
|
||||
<text x="380" y="157"></text>
|
||||
</g>
|
||||
<g class="bp-dim">
|
||||
<path d="M 60 500 H 96 M 78 500 V 520" />
|
||||
<text x="60" y="542">Ø 460 — 노즐 출구</text>
|
||||
</g>
|
||||
</svg>
|
||||
<figcaption>ORBIT-75 단면 · 재생냉각 벽면은 그림 2겹으로 단순화</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
사양 — 표가 주인공. 전 항목 측정 조건이 붙는다(게이트 11의 해법).
|
||||
-->
|
||||
<section id="spec" class="section band" aria-labelledby="spec-h">
|
||||
<div class="wrap">
|
||||
<h2 id="spec-h" class="reveal">사양</h2>
|
||||
<div class="table-wrap reveal" style="--delay: 80ms">
|
||||
<table>
|
||||
<caption class="sr-only">ORBIT-75 엔진 사양 — 항목, 값, 측정 조건</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">항목</th>
|
||||
<th scope="col" class="num">값</th>
|
||||
<th scope="col">측정 조건</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">추력 (해수면)</th>
|
||||
<td class="num">75.4 kN</td>
|
||||
<td>지상 시험 스탠드 · 정상 상태 30초 평균</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">비추력 Isp (진공)</th>
|
||||
<td class="num">325.8 s</td>
|
||||
<td>고도 모사 노즐 · 추진제 혼합비 3.4</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">연소실 압력</th>
|
||||
<td class="num">12.5 MPa</td>
|
||||
<td>피에조 계측 · 샘플링 1kHz</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">사이클</th>
|
||||
<td class="num">다단연소</td>
|
||||
<td>연료리치 프리버너 1기</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">냉각</th>
|
||||
<td class="num">재생냉각</td>
|
||||
<td>구리합금 채널 180개 · CH₄ 유량 92%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">시동 신호→연소</th>
|
||||
<td class="num">2.8 s</td>
|
||||
<td>TEA-TEB 점화 · 12회 평균</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="measure-note reveal" style="--delay: 140ms">
|
||||
모든 값은 나주 지상 시험장, 2026년 상반기 12회 시험의 평균입니다.
|
||||
단일 최고값을 사양으로 적지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
시험 기록 — 아카이브. 성공만 남기지 않는다: 3번의 보류가 있다.
|
||||
-->
|
||||
<section id="fires" class="section" aria-labelledby="fires-h">
|
||||
<div class="wrap fires-grid">
|
||||
<div class="fires-text">
|
||||
<h2 id="fires-h">지상 연소 시험 기록</h2>
|
||||
<p>
|
||||
엔진은 도면으로 끝나지 않습니다. 우리는 보류된 시험도 지운 적이
|
||||
없습니다 — 기록에서 실패가 사라지면 사양서는 광고가 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="table-wrap reveal">
|
||||
<table>
|
||||
<caption class="sr-only">지상 연소 시험 기록 — 번호, 날짜, 지속, 결과</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">시험</th>
|
||||
<th scope="col">날짜</th>
|
||||
<th scope="col" class="num">지속</th>
|
||||
<th scope="col">결과</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><th scope="row">SF-31</th><td>2026-07-18</td><td class="num">62 s</td><td>완료 · 노즐 온도 상한 내</td></tr>
|
||||
<tr><th scope="row">SF-30</th><td>2026-06-27</td><td class="num">31 s</td><td>보류 · 프리버너 시동 지연</td></tr>
|
||||
<tr><th scope="row">SF-29</th><td>2026-06-02</td><td class="num">60 s</td><td>완료 · 추력 편차 0.4%</td></tr>
|
||||
<tr><th scope="row">SF-28</th><td>2026-05-11</td><td class="num">12 s</td><td>보류 · 냉각유로 압력 강하</td></tr>
|
||||
<tr><th scope="row">SF-27</th><td>2026-04-20</td><td class="num">58 s</td><td>완료</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
시험 사진 — 풀블리드 판. 마하 다이아몬드가 보일 때만 쓴다.
|
||||
-->
|
||||
<section class="section plate" aria-label="지상 연소 시험 사진">
|
||||
<figure class="plate-fig reveal">
|
||||
<img
|
||||
src="./assets/static-fire.webp"
|
||||
alt="야간 지상 연소 시험 — 구리 노즐이 열로 붉게 빛나고 배기 기둥에 마하 다이아몬드가 서 있다"
|
||||
width="1440"
|
||||
height="810"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption class="wrap">SF-29 · 60초 연소 · 나주 시험장 3번 스탠드</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
재생냉각 — 구리 유로 접사 + 설명.
|
||||
-->
|
||||
<section class="section cooling" aria-labelledby="cool-h">
|
||||
<div class="wrap two">
|
||||
<figure class="two-photo reveal">
|
||||
<img
|
||||
src="./assets/channel-macro.webp"
|
||||
alt="구리합금 블록의 단면에 밀링된 가느다란 재생냉각 유로들"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>연소실 유로 접사 · 밀링 후 청소 전</figcaption>
|
||||
</figure>
|
||||
<div class="two-text">
|
||||
<h2 id="cool-h">추진제가 벽을<br />먼저 식힙니다</h2>
|
||||
<p>
|
||||
연소실 벽은 3,200°C의 가스를 견디는 게 아닙니다 — 견디게 두지
|
||||
않습니다. 메탄 연료가 점화 전에 벽 속 180개 유로를 지나가며 실온에서
|
||||
320°C까지 데워지고, 그 사이 벽은 900°C 아래로 머뭅니다. 냉각에 쓴
|
||||
추진제는 그대로 연소실로 들어가니 버리는 열이 없습니다.
|
||||
</p>
|
||||
<p class="muted">
|
||||
유로 하나의 폭은 0.9mm입니다. 이 숫자가 이 엔진의 수명을 결정합니다 —
|
||||
도면에서 가장 작은 치수가 가장 중요한 치수입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 문의 -->
|
||||
<section id="contact" class="section band contact" aria-labelledby="contact-h">
|
||||
<div class="wrap contact-grid">
|
||||
<h2 id="contact-h">문의</h2>
|
||||
<p>
|
||||
발사체 통합·위성 투입 협의, 그리고 엔진 시험 데이터 전문은 아래로
|
||||
보내주세요. 기술 자료 요청에는 소속과 용도를 적어주세요.
|
||||
</p>
|
||||
<a class="btn" href="mailto:engine@orbit.kr">engine@orbit.kr</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>궤도 우주공학 · 나주 시험장 / 서울 사무실</p>
|
||||
<p class="mono foot-meta">ORBIT AEROSPACE · EST. 2020</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
455
apps/site/public/work/orbit-aerospace/styles/main.css
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
궤도 우주공학 — technical-schematic. 사양서 1페이지.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2) {
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.015em;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
:where(p, th, td, figcaption) {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--ink-muted);
|
||||
max-width: 34em;
|
||||
}
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.wordmark-en {
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
font-weight: 400;
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 히어로 + 단면도 ──────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-8) var(--space-7);
|
||||
/* 제도용 모눈 — 히어로에만, 4% 밝기 */
|
||||
background-image:
|
||||
linear-gradient(var(--line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--line) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.hero-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.hero .lead {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.hero .btn {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft),
|
||||
color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
/* 단면도 */
|
||||
.blueprint {
|
||||
background: color-mix(in srgb, var(--surface) 72%, transparent);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4) var(--space-3) var(--space-3);
|
||||
}
|
||||
.blueprint svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 78vh;
|
||||
}
|
||||
.blueprint figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
.bp-wall {
|
||||
fill: none;
|
||||
stroke: var(--ink);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.bp-wall.inner {
|
||||
stroke-width: 1.25;
|
||||
}
|
||||
.bp-wall.thin {
|
||||
stroke-width: 1;
|
||||
}
|
||||
.bp-cool {
|
||||
fill: none;
|
||||
stroke: var(--accent-stroke);
|
||||
stroke-width: 0.9;
|
||||
}
|
||||
.bp-block {
|
||||
fill: none;
|
||||
stroke: var(--ink-muted);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
.bp-axis {
|
||||
stroke: var(--ink-faint);
|
||||
stroke-width: 0.75;
|
||||
stroke-dasharray: 10 4 2 4;
|
||||
}
|
||||
.bp-flow path {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
.bp-label text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
fill: var(--ink-muted);
|
||||
}
|
||||
.bp-lead {
|
||||
stroke: var(--ink-faint);
|
||||
stroke-width: 0.8;
|
||||
}
|
||||
.bp-dim path {
|
||||
fill: none;
|
||||
stroke: var(--ink-faint);
|
||||
stroke-width: 0.8;
|
||||
}
|
||||
.bp-dim text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
fill: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── 표 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
.table-wrap {
|
||||
margin-top: var(--space-4);
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--ink-faint);
|
||||
}
|
||||
tbody th {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td,
|
||||
tbody th {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
tbody td:not(.num) {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.measure-note {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 시험 기록 2단 ────────────────────────────────────────────────────── */
|
||||
|
||||
.fires-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
||||
gap: var(--space-5) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.fires-text p {
|
||||
margin-top: var(--space-3);
|
||||
color: var(--ink-muted);
|
||||
max-width: 26em;
|
||||
}
|
||||
.fires-grid .table-wrap {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* ── 시험 사진 판 ─────────────────────────────────────────────────────── */
|
||||
|
||||
.plate-fig img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.plate-fig figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── 재생냉각 2단 ─────────────────────────────────────────────────────── */
|
||||
|
||||
.two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.two-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.two-photo figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.two-text p + p {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── 문의 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--space-4) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.contact-grid p {
|
||||
color: var(--ink-muted);
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.foot-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 12px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid,
|
||||
.fires-grid,
|
||||
.two,
|
||||
.contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero,
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
55
apps/site/public/work/orbit-aerospace/styles/tokens.css
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/* ==========================================================================
|
||||
궤도 우주공학 — 디자인 토큰
|
||||
프리셋: technical-schematic
|
||||
컨셉 한 문장: 사양서 1페이지 — 도면은 말하지 않고 측정만 한다.
|
||||
레퍼런스: 방향 앵커 dogfood-aerospace(우주업종 기술 노출). 그 파일의 6축
|
||||
수치는 audit-references.md 에서 날조 판명 — 토큰은 브리프에서
|
||||
재정의. 실측 가능했던 사실만 계승: rocketlabcorp의 '엔진을 상품으로
|
||||
쓰는' 구성. 색은 야간 시험장(짙은 남흑 + 구리 노즐 열색)에서 왔다.
|
||||
하지 않은 것: 지표 배너(고객수·성공률), 로켓 3D 렌더, 발사 카운트다운,
|
||||
대문자 타이틀. 이유: 사양서의 신뢰는 측정 조건에서 나온다.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — 최대(54px) ÷ 본문(16px) ≈ 3.4배 */
|
||||
--step--1: 0.8125rem;
|
||||
--step-0: 1rem;
|
||||
--step-1: 1.1875rem;
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
|
||||
--step-3: clamp(2rem, 1.4rem + 3vw, 3.375rem);
|
||||
|
||||
--leading-tight: 1.28;
|
||||
--leading-normal: 1.78;
|
||||
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* 색 — 야간 시험장: 남흑 배경, 구리 강조(텍스트·선), 청사진 흰선 */
|
||||
--surface: #081018;
|
||||
--surface-raised: #0d1520;
|
||||
--ink: #e7ecf1;
|
||||
--ink-muted: #97a3af;
|
||||
--ink-faint: #6c7885;
|
||||
--line: #1f2937;
|
||||
--line-strong: #33415280;
|
||||
--accent: #d08a5a; /* 구리 — 열색. 텍스트용 밝기 */
|
||||
--accent-stroke: #a45f33; /* 도면 선용 */
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
|
||||
|
||||
--radius: 2px;
|
||||
|
||||
--container: 1120px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 420ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
BIN
apps/site/public/work/overtone-sound/assets/installation.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
257
apps/site/public/work/overtone-sound/index.html
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>오버톤 사운드랩 · 공간 음향 연구소</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="침묵과 폭발의 주파수. 공간 음향과 생성 사운드스케이프를 만드는 연구소의 작업 아카이브."
|
||||
/>
|
||||
<meta property="og:title" content="오버톤 사운드랩" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="공간 음향과 생성 사운드스케이프 — 작업 아카이브."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#0E0D0B" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230E0D0B'/%3E%3Cpath d='M4 16h4l2-7 3 14 3-10 2 5h10' fill='none' stroke='%23E8A33D' stroke-width='1.8'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">오버톤 사운드랩</a>
|
||||
<nav class="nav" aria-label="섹션">
|
||||
<a href="#works">작업</a>
|
||||
<a href="#manifesto">선언</a>
|
||||
<a href="#contact">연락</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
히어로 — anti-grid: 글자가 파형 위로 삐져나온다.
|
||||
캔버스는 절차적 유사-FFT(정현파 합). 재생 버튼 없이 스스로 흐르되
|
||||
화면 밖에서는 그리지 않는다.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<canvas id="wave" class="wave" aria-hidden="true"></canvas>
|
||||
<div class="wrap hero-inner">
|
||||
<p class="hero-kicker reveal">
|
||||
<span>공간 음향 · DSP · 서울</span>
|
||||
<span class="mono">48kHz / 2ch</span>
|
||||
</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 80ms">
|
||||
침묵과 폭발의<br /><em>주파수</em>
|
||||
</h1>
|
||||
<p class="hero-statement reveal" style="--delay: 160ms">
|
||||
오버톤은 소리를 만들지 않고, 소리가 공간에서 어떻게 행동하는지를
|
||||
연구합니다. 방금 지나간 곡선은 이 연구소를 지나가는 소리의 자취입니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
작업 아카이브 — 스태거 2열. 카드가 아니라 '벽에 걸린 목록'.
|
||||
같은 간격을 주지 않는다: 짝수 작품은 오른쪽으로, 홀수는 왼쪽으로 쏠린다.
|
||||
-->
|
||||
<section id="works" class="section works" aria-labelledby="works-h">
|
||||
<div class="wrap">
|
||||
<h2 id="works-h" class="works-h reveal">작업</h2>
|
||||
<ol class="archive">
|
||||
<li class="work reveal">
|
||||
<p class="work-year mono">2026</p>
|
||||
<h3>공명하는 방 — DDP 설치</h3>
|
||||
<p>
|
||||
종이 콘 스피커 48개가 천장에서 내려와 방 안의 소리를 0.8초 늦게
|
||||
되돌려주는 설치. 관람객의 발소리가 악기가 된다.
|
||||
</p>
|
||||
<p class="work-meta mono">48ch · 공간지연 · 상설</p>
|
||||
</li>
|
||||
<li class="work work-b reveal" style="--delay: 60ms">
|
||||
<p class="work-year mono">2025</p>
|
||||
<h3>무(無)의 스펙트럼</h3>
|
||||
<p>
|
||||
아무 소리도 없는 방의 바닥 소음(−62dB)을 증폭해 들려주는
|
||||
퍼포먼스. 침묵에는 평균 진폭이 있다.
|
||||
</p>
|
||||
<p class="work-meta mono">live · −62dB floor</p>
|
||||
</li>
|
||||
<li class="work reveal" style="--delay: 40ms">
|
||||
<p class="work-year mono">2024</p>
|
||||
<h3> 파도의 인버전 — 제주</h3>
|
||||
<p>
|
||||
바람과 파도의 실측 데이터를 반대 위상으로 합성해 바다가 '삼키는'
|
||||
소리를 만드는 해안 설치.
|
||||
</p>
|
||||
<p class="work-meta mono">field data · 6h loop</p>
|
||||
</li>
|
||||
<li class="work work-b reveal" style="--delay: 100ms">
|
||||
<p class="work-year mono">2023</p>
|
||||
<h3>유리 진동자</h3>
|
||||
<p>
|
||||
잔 12개에 각기 다른 공진 주파수를 실어 연주하는 설비. 연주자는
|
||||
물의 높이를 조율한다.
|
||||
</p>
|
||||
<p class="work-meta mono">glass · 220–940Hz</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
선언 — anti-grid의 정점: 문장 하나가 페이지 폭을 다 쓴다.
|
||||
압축 스케일의 반대편. 이 페이지에서 가장 큰 텍스트 블록.
|
||||
-->
|
||||
<section id="manifesto" class="section manifesto" aria-labelledby="manifesto-h">
|
||||
<div class="wrap">
|
||||
<h2 id="manifesto-h" class="sr-only">선언</h2>
|
||||
<p class="manifesto-line reveal">
|
||||
좋은 음향은 들리지 않는다.<br />
|
||||
방이 좋아지는 것뿐이다.
|
||||
</p>
|
||||
<p class="manifesto-note reveal" style="--delay: 100ms">
|
||||
우리는 음파를 시각화로 번역하지 않습니다. 시각화는 소리의 크기만
|
||||
흉내 낼 뿐, 소리의 '방의 크기'를 옮기지 못합니다. 대신 우리는 방을
|
||||
조율합니다 — 재생 목록이 아니라 잔향시간을 설계합니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 협업 마퀴 — 페이지에 하나뿐인 흐름 -->
|
||||
<div class="marquee" aria-label="협업 단체">
|
||||
<div class="marquee-track">
|
||||
<span>국립현대미술관</span><span>DDP</span><span>서울시립교향악단</span><span>아르코예술극장</span><span>제주국제음악제</span><span>국립현대미술관</span><span>DDP</span><span>서울시립교향악단</span><span>아르코예술극장</span><span>제주국제음악제</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
사진 — 설치 현장 한 컷. 다크룸의 앰버 빔.
|
||||
-->
|
||||
<section class="section photo" aria-label="설치 현장">
|
||||
<figure class="wrap photo-fig reveal">
|
||||
<img
|
||||
src="./assets/installation.webp"
|
||||
alt="어두운 전시실에 종이콘 스피커들이 늘어서 있고 앰버색 빛줄기가 한 줄 지나간다"
|
||||
width="1440"
|
||||
height="810"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption class="mono">공명하는 방 · 설치 뷰 · 2026</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<!-- 연락 -->
|
||||
<section id="contact" class="section contact" aria-labelledby="contact-h">
|
||||
<div class="wrap contact-grid">
|
||||
<h2 id="contact-h">연락</h2>
|
||||
<p>
|
||||
설치·공연·연구 협업 문의는 소리의 조건(공간 크기, 재생 시간, 청중 수)과
|
||||
함께 보내주세요.
|
||||
</p>
|
||||
<a class="contact-mail" href="mailto:lab@overtone.kr">lab@overtone.kr</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>오버톤 사운드랩 · 서울 마포</p>
|
||||
<p class="mono foot-meta">© 2019–2026 · OVERTONE SOUND LAB</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/*
|
||||
절차적 유사-FFT — 정현파 5개를 더하고 위상을 흐르게 한다.
|
||||
오디오 파일도 디코딩도 없다(연구조사 R3의 교훈: 전체 파일 디코드는
|
||||
수 초 메인스레드를 막는다). 화면 밖·reduced-motion 에서는 정지 프레임.
|
||||
*/
|
||||
const canvas = document.getElementById("wave");
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const ctx = canvas.getContext("2d");
|
||||
let w, h, dpr, running = false;
|
||||
|
||||
function fit() {
|
||||
dpr = Math.min(devicePixelRatio || 1, 1.5);
|
||||
w = canvas.clientWidth;
|
||||
h = canvas.clientHeight;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
|
||||
function draw(t) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
// 세 겹의 파형 — 시간차를 두고 겹친다
|
||||
for (let layer = 0; layer < 3; layer++) {
|
||||
ctx.beginPath();
|
||||
const amp = (h * 0.16) * (1 - layer * 0.28);
|
||||
const yMid = h * (0.52 + layer * 0.12);
|
||||
const speed = 0.00042 * (1 + layer * 0.7);
|
||||
for (let x = 0; x <= w; x += 3) {
|
||||
const p = x / w;
|
||||
const env = Math.exp(-Math.pow((p - 0.5) * 2.4, 2)); // 중앙 강조
|
||||
const y =
|
||||
yMid +
|
||||
Math.sin(p * 21 + t * speed + layer * 2.1) * amp * env * Math.sin(p * Math.PI) +
|
||||
Math.sin(p * 53 - t * speed * 1.7) * amp * 0.25 * env;
|
||||
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = layer === 0 ? "rgba(232,163,61,0.85)" : `rgba(232,163,61,${0.32 - layer * 0.1})`;
|
||||
ctx.lineWidth = layer === 0 ? 1.4 : 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function loop(t) {
|
||||
if (!running) return;
|
||||
draw(t);
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
fit();
|
||||
addEventListener("resize", fit, { passive: true });
|
||||
draw(0);
|
||||
|
||||
if (!reduced) {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
const on = entries[0].isIntersecting;
|
||||
if (on && !running) {
|
||||
running = true;
|
||||
requestAnimationFrame(loop);
|
||||
} else if (!on) {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
}
|
||||
|
||||
const io2 = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io2.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
392
apps/site/public/work/overtone-sound/styles/main.css
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
오버톤 사운드랩 — anti-grid. 격자는 일부러 비틀었다.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) {
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.015em;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
:where(p, li, figcaption) {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-size: var(--step-1);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 히어로 — 파형 위로 삐져나온 글자 ────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 88svh;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
overflow: clip;
|
||||
}
|
||||
.wave {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
/* anti-grid: 텍스트 블록을 왼쪽 15% 축에 두고 오른쪽은 파형에 내준다 */
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-left: max(var(--pad-inline), calc((100% - var(--container)) / 2));
|
||||
margin-right: 0;
|
||||
padding-block: var(--space-7);
|
||||
}
|
||||
.hero-kicker {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
max-width: 24rem;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.hero h1 {
|
||||
margin-top: var(--space-4);
|
||||
max-width: 12em;
|
||||
}
|
||||
.hero h1 em {
|
||||
font-style: normal;
|
||||
color: var(--accent);
|
||||
}
|
||||
.hero-statement {
|
||||
margin-top: var(--space-4);
|
||||
max-width: 26em;
|
||||
color: var(--ink-muted);
|
||||
/* 오른쪽으로 밀어낸다 — 정렬을 스스로 어긋나게 */
|
||||
margin-left: clamp(0rem, 8vw, 7.5rem);
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
|
||||
/* ── 작업 아카이브 — 스태거 2열 ──────────────────────────────────────── */
|
||||
|
||||
.works-h {
|
||||
font-size: var(--step--1);
|
||||
font-weight: 500;
|
||||
color: var(--ink-faint);
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
.archive {
|
||||
list-style: none;
|
||||
margin-top: var(--space-3);
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-6) var(--space-7);
|
||||
}
|
||||
.work {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: var(--space-3);
|
||||
max-width: 26rem;
|
||||
}
|
||||
/* 스태거: 짝수 작품은 아래로 밀고 오른쪽으로 당긴다 */
|
||||
.work-b {
|
||||
margin-top: var(--space-7);
|
||||
justify-self: end;
|
||||
}
|
||||
.work-year {
|
||||
color: var(--accent);
|
||||
}
|
||||
.work h3 {
|
||||
font-size: var(--step-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.work p:not(.mono) {
|
||||
margin-top: var(--space-3);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.work-meta {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* ── 선언 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.manifesto {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
.manifesto-line {
|
||||
font-size: var(--step-manifesto);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.02em;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.manifesto-note {
|
||||
margin-top: var(--space-5);
|
||||
max-width: 32em;
|
||||
color: var(--ink-muted);
|
||||
margin-left: clamp(0rem, 12vw, 11rem); /* 다시 어긋난 여백 */
|
||||
}
|
||||
|
||||
/* ── 마퀴 — 하나뿐인 흐름 ────────────────────────────────────────────── */
|
||||
|
||||
.marquee {
|
||||
overflow: clip;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.marquee-track {
|
||||
display: flex;
|
||||
gap: var(--space-8);
|
||||
width: max-content;
|
||||
animation: marquee 36s linear infinite;
|
||||
}
|
||||
.marquee-track span {
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes marquee {
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marquee-track {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 사진 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.photo-fig img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.photo-fig figcaption {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── 연락 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--space-4) var(--space-7);
|
||||
align-items: baseline;
|
||||
}
|
||||
.contact-grid h2 {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
.contact-grid p {
|
||||
color: var(--ink-muted);
|
||||
max-width: 28em;
|
||||
}
|
||||
.contact-mail {
|
||||
font-size: var(--step-2);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--accent-dim);
|
||||
transition: border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.contact-mail:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-4);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 14px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 — 좁은 화면에서는 비틀림을 줄인다(읽힘이 위) ─────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.archive {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.work-b {
|
||||
margin-top: 0;
|
||||
justify-self: stretch;
|
||||
}
|
||||
.hero-statement,
|
||||
.manifesto-note {
|
||||
margin-left: 0;
|
||||
}
|
||||
.hero {
|
||||
min-height: 0;
|
||||
}
|
||||
.contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.hero,
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
54
apps/site/public/work/overtone-sound/styles/tokens.css
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* ==========================================================================
|
||||
오버톤 사운드랩 — 디자인 토큰
|
||||
프리셋: anti-grid
|
||||
컨셉 한 문장: 어두운 스튜디오 바닥에 흩뿌린 목록 — 격자는 일부러 비틀었다.
|
||||
레퍼런스: R1 carstennicolai.de(실측: 1152px 컨테이너, 비대칭 15/20/85 열,
|
||||
압축 타입 스케일 1.8:1, 아카이브 역연도, 형광 1색 → 앰버로 치환)
|
||||
R2 obys.agency(스태거 2열, 라인 마스크 리빌, 마퀴 1회)
|
||||
R3 절차적 유사-FFT 캔버스(오디오 디코드 회피)
|
||||
하지 않은 것: 글래스 도크, 3열 카드, 커서 추적 이펙트, 대문자 타이틀.
|
||||
이유: 파열은 하나(스태거+파형)로 족하다.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — R1의 압축 스케일. 최대(40px) ÷ 본문(16px) = 2.5배(선언 문장 제외) */
|
||||
--step--1: 0.8125rem;
|
||||
--step-0: 1rem;
|
||||
--step-1: 1.125rem;
|
||||
--step-2: 1.375rem;
|
||||
--step-3: clamp(1.875rem, 1.3rem + 2.8vw, 2.5rem); /* 40px 히어로 */
|
||||
--step-manifesto: clamp(1.875rem, 1.2rem + 4.4vw, 3.75rem); /* 선언 — 이 페이지의 가장 큰 목소리 */
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.75;
|
||||
|
||||
--font-body: "Pretendard Variable", Pretendard, -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
--surface: #0e0d0b;
|
||||
--surface-raised: #161411;
|
||||
--ink: #ece7dd;
|
||||
--ink-muted: #a09a8e;
|
||||
--ink-faint: #6f695f;
|
||||
--line: #27241f;
|
||||
--accent: #e8a33d; /* 앰버 — 선과 텍스트로만 */
|
||||
--accent-dim: rgba(232, 163, 61, 0.35);
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
|
||||
|
||||
--radius: 0; /* 스튜디오 바닥에는 모서리가 없다 */
|
||||
|
||||
--container: 1152px; /* R1 실측값 */
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 460ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
317
apps/site/public/work/pulsegate/index.html
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Pulsegate · Edge Latency & Synthetic Probe Engine</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Synthetic HTTP/gRPC assertions every ten seconds from bare-metal edge POPs. No agents, no sidecars. Prometheus and OpenTelemetry export."
|
||||
/>
|
||||
<meta property="og:title" content="Pulsegate · Edge Latency & Synthetic Probe Engine" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Synthetic assertions from 28 bare-metal edge POPs. A probe is watching within 90 seconds."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#0B0E12" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230B0E12'/%3E%3Cpath d='M6 17h6l3-8 4 14 3-6h4' fill='none' stroke='%236CA3EE' stroke-width='2'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to main content</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">pulsegate<span class="wordmark-dot">.</span></a>
|
||||
<nav class="nav" aria-label="Sections">
|
||||
<a href="#wiring">Wiring</a>
|
||||
<a href="#numbers">Numbers</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
Hero. Grayscale hierarchy per research: h1 (1) → primary CTA (2) → command (3).
|
||||
Cobalt exists only as text and chart ink — never as a fill or a glow.
|
||||
The instrument on the right is a static frame: no pulsing dots, no live pretence.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap hero-grid">
|
||||
<div class="hero-text">
|
||||
<h1 id="hero-h" class="reveal">
|
||||
A probe is watching<br />within 90 seconds
|
||||
</h1>
|
||||
<p class="lead reveal" style="--delay: 80ms">
|
||||
Synthetic HTTP and gRPC assertions, executed every ten seconds from
|
||||
bare-metal POPs in 28 transit centers. No agents on your fleet, no
|
||||
sidecars to babysit — the first probe is running before you finish
|
||||
reading this page.
|
||||
</p>
|
||||
<div class="hero-actions reveal" style="--delay: 140ms">
|
||||
<a class="btn btn-primary" href="#pricing">Start with 3 free probes</a>
|
||||
<a class="link-quiet" href="#wiring">See how it is wired</a>
|
||||
</div>
|
||||
<div class="command reveal" style="--delay: 200ms">
|
||||
<span class="command-prompt" aria-hidden="true">$</span>
|
||||
<code>curl -sL https://pulsegate.io/init | sh</code>
|
||||
</div>
|
||||
<p class="hero-note reveal" style="--delay: 240ms">
|
||||
90 seconds: measured from <code>init</code> to first assertion on a
|
||||
cold account, 2026-08-20.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Instrument: static 24h frame. Cobalt = one series only. -->
|
||||
<figure class="instrument reveal" style="--delay: 160ms">
|
||||
<figcaption class="instrument-head">
|
||||
<span>EDGE LATENCY · P99 · LAST 24H</span>
|
||||
<span>10s interval</span>
|
||||
</figcaption>
|
||||
<svg
|
||||
viewBox="0 0 560 264"
|
||||
role="img"
|
||||
aria-label="24-hour p99 latency chart. Seoul stays under 16 ms, Tokyo under 34 ms, Frankfurt sits near 140 ms with one spike to 210 ms at 06:40 UTC."
|
||||
>
|
||||
<!-- hour grid -->
|
||||
<g class="chart-grid">
|
||||
<line x1="0" y1="8" x2="560" y2="8" />
|
||||
<line x1="0" y1="70" x2="560" y2="70" />
|
||||
<line x1="0" y1="132" x2="560" y2="132" />
|
||||
<line x1="0" y1="194" x2="560" y2="194" />
|
||||
<line x1="0" y1="256" x2="560" y2="256" class="chart-axis" />
|
||||
<line x1="70" y1="0" x2="70" y2="264" />
|
||||
<line x1="140" y1="0" x2="140" y2="264" />
|
||||
<line x1="210" y1="0" x2="210" y2="264" />
|
||||
<line x1="280" y1="0" x2="280" y2="264" />
|
||||
<line x1="350" y1="0" x2="350" y2="264" />
|
||||
<line x1="420" y1="0" x2="420" y2="264" />
|
||||
<line x1="490" y1="0" x2="490" y2="264" />
|
||||
</g>
|
||||
<!-- series: FRA (faint, high) — 140ms band with 06:40 spike -->
|
||||
<polyline
|
||||
class="series series-faint"
|
||||
points="0,96 35,94 70,97 105,92 140,95 175,93 205,88 210,74 215,60 220,74 225,90 260,95 280,93 315,96 350,94 385,97 420,93 455,96 490,94 525,97 560,95"
|
||||
/>
|
||||
<!-- series: NRT (muted, low-mid) -->
|
||||
<polyline
|
||||
class="series series-muted"
|
||||
points="0,224 35,222 70,225 105,221 140,224 175,223 210,226 245,222 280,224 315,223 350,226 385,222 420,225 455,223 490,226 525,223 560,225"
|
||||
/>
|
||||
<!-- series: ICN (cobalt, lowest) -->
|
||||
<polyline
|
||||
class="series series-accent"
|
||||
points="0,243 35,242 70,244 105,242 140,243 175,244 210,242 245,243 280,244 315,242 350,243 385,244 420,243 455,242 490,244 525,243 560,243"
|
||||
/>
|
||||
</svg>
|
||||
<div class="instrument-rows" aria-label="POP readings, p99 over 24 hours">
|
||||
<div><span>ICN-01 · Seoul</span><span class="num">14.2 ms</span></div>
|
||||
<div><span>NRT-02 · Tokyo</span><span class="num">28.6 ms</span></div>
|
||||
<div><span>SIN-01 · Singapore</span><span class="num">34.0 ms</span></div>
|
||||
<div><span>SJC-04 · San Jose</span><span class="num">88.4 ms</span></div>
|
||||
<div><span>LHR-02 · London</span><span class="num">126.8 ms</span></div>
|
||||
<div><span>FRA-01 · Frankfurt</span><span class="num">142.1 ms</span></div>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Wiring — the one scroll-motion detail (R3): a circuit trace that draws
|
||||
itself as it enters the viewport. CSS animation-timeline, Chromium-gated;
|
||||
every other browser (and reduced-motion) gets the fully drawn diagram.
|
||||
Structure is the argument: probes → assertions → your telemetry.
|
||||
-->
|
||||
<section id="wiring" class="section" aria-labelledby="wiring-h">
|
||||
<div class="wrap">
|
||||
<h2 id="wiring-h" class="reveal">How it is wired</h2>
|
||||
<p class="lead reveal" style="--delay: 80ms">
|
||||
Probes run from physical tier-1 transit facilities, not shared cloud
|
||||
VPCs, so what you measure is the network your users ride — not cloud
|
||||
peering bias. One trace, three stations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="wrap wiring-diagram reveal" style="--delay: 120ms">
|
||||
<svg viewBox="0 0 1120 220" role="img" aria-label="Wiring diagram: 28 POPs run assertions, the engine evaluates them, results export to Prometheus and OpenTelemetry.">
|
||||
<!-- drawn trace: POPs → engine → export -->
|
||||
<path
|
||||
class="trace"
|
||||
d="M 40 60 H 300 M 40 110 H 300 M 40 160 H 300 M 300 110 H 470 M 650 110 H 820 M 820 60 V 160 M 820 60 H 1010 M 820 160 H 1010"
|
||||
/>
|
||||
<!-- station 1: POPs -->
|
||||
<g class="station">
|
||||
<rect x="40" y="44" width="14" height="14" />
|
||||
<rect x="40" y="94" width="14" height="14" />
|
||||
<rect x="40" y="144" width="14" height="14" />
|
||||
<text x="66" y="55">ICN · NRT · SIN …</text>
|
||||
<text x="66" y="105">FRA · LHR · AMS …</text>
|
||||
<text x="66" y="155">SJC · IAD · ORD …</text>
|
||||
</g>
|
||||
<!-- station 2: engine -->
|
||||
<g class="station">
|
||||
<rect x="470" y="88" width="180" height="44" />
|
||||
<text x="478" y="106">assertion engine</text>
|
||||
<text x="478" y="124" class="mono-note">http · grpc · tcp · dns</text>
|
||||
</g>
|
||||
<!-- station 3: export -->
|
||||
<g class="station">
|
||||
<rect x="1010" y="46" width="70" height="26" />
|
||||
<rect x="1010" y="146" width="70" height="26" />
|
||||
<text x="1006" y="38" text-anchor="end">Prometheus</text>
|
||||
<text x="1006" y="192" text-anchor="end">OpenTelemetry</text>
|
||||
</g>
|
||||
<!-- flow labels -->
|
||||
<g class="flow-label">
|
||||
<text x="310" y="100">every 10 s</text>
|
||||
<text x="660" y="100">p50 · p99 · jitter</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="wrap wiring-notes">
|
||||
<div class="wiring-note reveal">
|
||||
<h3>Bare-metal, not lambdas</h3>
|
||||
<p>
|
||||
Probes live in tier-1 transit facilities. The RTT you see is the
|
||||
RTT your users get.
|
||||
</p>
|
||||
</div>
|
||||
<div class="wiring-note reveal" style="--delay: 80ms">
|
||||
<h3>Assertions, not pings</h3>
|
||||
<p>
|
||||
JSONPath body checks, gRPC status codes, TLS certificate expiry —
|
||||
a check fails when your product fails, not when a packet does.
|
||||
</p>
|
||||
</div>
|
||||
<div class="wiring-note reveal" style="--delay: 160ms">
|
||||
<h3>Your telemetry, your tools</h3>
|
||||
<p>
|
||||
Export to the Prometheus and OpenTelemetry you already run. No
|
||||
dashboard to live in, no lock-in to migrate off.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Numbers — the trust band. Every value carries its measurement condition.
|
||||
No invented scale claims: these are the engine's own telemetry frame.
|
||||
-->
|
||||
<section id="numbers" class="section band" aria-labelledby="numbers-h">
|
||||
<div class="wrap">
|
||||
<h2 id="numbers-h" class="reveal">One frame, all 28 POPs</h2>
|
||||
<p class="lead reveal" style="--delay: 80ms">
|
||||
A single assertion — <code>GET /health</code> against a Seoul origin —
|
||||
fanned out to every POP at the same instant.
|
||||
</p>
|
||||
<div class="table-wrap reveal" style="--delay: 120ms">
|
||||
<table>
|
||||
<caption class="sr-only">
|
||||
Per-POP readings for one assertion: DNS, TLS, TTFB and p99 over 24 hours
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">POP</th>
|
||||
<th scope="col">City</th>
|
||||
<th scope="col" class="num">DNS</th>
|
||||
<th scope="col" class="num">TLS</th>
|
||||
<th scope="col" class="num">TTFB</th>
|
||||
<th scope="col" class="num">p99 / 24h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><th scope="row">ICN-01</th><td>Seoul</td><td class="num">1.8</td><td class="num">6.4</td><td class="num">14.2</td><td class="num">15.9</td></tr>
|
||||
<tr><th scope="row">NRT-02</th><td>Tokyo</td><td class="num">2.1</td><td class="num">7.1</td><td class="num">28.6</td><td class="num">31.2</td></tr>
|
||||
<tr><th scope="row">SIN-01</th><td>Singapore</td><td class="num">2.4</td><td class="num">8.2</td><td class="num">34.0</td><td class="num">37.5</td></tr>
|
||||
<tr><th scope="row">SJC-04</th><td>San Jose</td><td class="num">3.0</td><td class="num">9.6</td><td class="num">88.4</td><td class="num">94.7</td></tr>
|
||||
<tr><th scope="row">LHR-02</th><td>London</td><td class="num">4.0</td><td class="num">11.2</td><td class="num">126.8</td><td class="num">133.0</td></tr>
|
||||
<tr><th scope="row">FRA-01</th><td>Frankfurt</td><td class="num">4.2</td><td class="num">11.8</td><td class="num">142.1</td><td class="num">151.8</td></tr>
|
||||
<tr class="row-more"><td colspan="6">…22 more POPs in the console — same frame, same instant</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="measure-note reveal" style="--delay: 160ms">
|
||||
Measured 2026-08-20 14:00 UTC · 10 s interval · n = 8,640 per POP ·
|
||||
milliseconds, median of the hour shown for DNS/TLS/TTFB.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Pricing — two quiet rows, not three columns with a badge.
|
||||
(research: the 3-column + "most popular" template is the highest-recurrence
|
||||
slop pattern in this category — we don't run it)
|
||||
-->
|
||||
<section id="pricing" class="section" aria-labelledby="pricing-h">
|
||||
<div class="wrap pricing-grid">
|
||||
<div class="pricing-text">
|
||||
<h2 id="pricing-h" class="reveal">Priced per probe,<br />not per seat</h2>
|
||||
<p class="lead reveal" style="--delay: 80ms">
|
||||
A probe costs the same whether three engineers or thirty look at
|
||||
the console. Downgrade, pause, or delete from the CLI.
|
||||
</p>
|
||||
</div>
|
||||
<div class="pricing-rows reveal" style="--delay: 120ms">
|
||||
<div class="price-row">
|
||||
<div>
|
||||
<h3>Free</h3>
|
||||
<p>3 probes · 60 s interval · 7-day retention</p>
|
||||
</div>
|
||||
<div class="price-val">
|
||||
<span>$0</span>
|
||||
<a class="btn btn-primary" href="#main">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="price-row">
|
||||
<div>
|
||||
<h3>Pro</h3>
|
||||
<p>Unlimited probes · 10 s interval · 13-month retention · OTel export</p>
|
||||
</div>
|
||||
<div class="price-val">
|
||||
<span>$0.40 <em>per probe-month</em></span>
|
||||
<a class="link-quiet" href="#main">Start free, upgrade inside</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>pulsegate — distributed edge telemetry</p>
|
||||
<p class="foot-meta">status: 28/28 POPs reporting · exporter protocol v2.4</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* Reveal only — same grammar as the rest of the studio's work.
|
||||
No scroll listeners, no fake "live" loops: a static frame is honest. */
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
543
apps/site/public/work/pulsegate/styles/main.css
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
Pulsegate — dark-instrument. 명도차로만 읽히는 계기판.
|
||||
레이아웃 → 재질 → 모션 순서.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) {
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.015em;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--step-1);
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--ink-muted);
|
||||
max-width: 38em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.wordmark-dot {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 히어로 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-8) var(--space-7);
|
||||
}
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 6fr) minmax(0, 6fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.hero-text .lead {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.hero-actions {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--ink);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: 0.75rem 1.375rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
/* 히어로에서 가장 밝은 덩어리 = CTA. 색이 아니라 명도로 2위. */
|
||||
.btn-primary:hover {
|
||||
background: var(--ink-bright);
|
||||
}
|
||||
|
||||
.link-quiet {
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
padding-bottom: 1px;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.link-quiet:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.command {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: var(--surface-frame);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
max-width: 30rem;
|
||||
}
|
||||
.command-prompt {
|
||||
color: var(--accent);
|
||||
}
|
||||
.command code {
|
||||
display: block; /* 인라인이면 nowrap 최소폭이 그리드를 밀어낸다(320px 실패) */
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.hero-note {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* ── 계기(정적 프레임) ─────────────────────────────────────────────────── */
|
||||
|
||||
.instrument {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-frame);
|
||||
background: var(--surface-raised);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.instrument-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.instrument svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.chart-grid line {
|
||||
stroke: var(--line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.chart-grid .chart-axis {
|
||||
stroke: var(--line-strong);
|
||||
}
|
||||
.series {
|
||||
fill: none;
|
||||
stroke-width: 1.25;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.series-faint {
|
||||
stroke: var(--line-strong);
|
||||
}
|
||||
.series-muted {
|
||||
stroke: var(--ink-faint);
|
||||
}
|
||||
.series-accent {
|
||||
stroke: var(--accent-stroke);
|
||||
}
|
||||
.instrument-rows {
|
||||
margin-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.instrument-rows > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding-block: 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
.section h2 + .lead {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* ── 배선 다이어그램 ──────────────────────────────────────────────────── */
|
||||
|
||||
.wiring-diagram {
|
||||
margin-top: var(--space-6);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-frame);
|
||||
background: var(--surface-raised);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
}
|
||||
.wiring-diagram svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.trace {
|
||||
fill: none;
|
||||
stroke: var(--accent-stroke);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.station rect {
|
||||
fill: none;
|
||||
stroke: var(--line-strong);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.station text {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
fill: var(--ink-muted);
|
||||
}
|
||||
.station .mono-note {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
fill: var(--ink-faint);
|
||||
}
|
||||
.flow-label text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
/* 스크롤 드로잉 — 이중 게이트. 폴백은 '이미 다 그어진 도면' */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@supports (animation-timeline: view()) {
|
||||
.trace {
|
||||
stroke-dasharray: 2400;
|
||||
stroke-dashoffset: 2400;
|
||||
animation: trace-draw 1.6s var(--ease-soft) both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 60%;
|
||||
}
|
||||
@keyframes trace-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wiring-notes {
|
||||
margin-top: var(--space-5);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: var(--space-5) var(--space-6);
|
||||
}
|
||||
.wiring-note h3 {
|
||||
font-size: var(--step-0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wiring-note p {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
/* ── 수치 밴드 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.table-wrap {
|
||||
margin-top: var(--space-5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
}
|
||||
tbody th {
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
tbody td,
|
||||
tbody th {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: baseline;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
td.num {
|
||||
color: var(--ink);
|
||||
}
|
||||
.row-more td {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--step--1);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.measure-note {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 요금 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.pricing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: start;
|
||||
}
|
||||
.pricing-text .lead {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.pricing-rows {
|
||||
border-top: 1px solid var(--line-strong);
|
||||
}
|
||||
.price-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.price-row h3 {
|
||||
font-weight: 600;
|
||||
}
|
||||
.price-row p {
|
||||
margin-top: var(--space-1);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.price-val {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.price-val > span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.price-val em {
|
||||
font-style: normal;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-5);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.foot-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 12px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid,
|
||||
.pricing-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.price-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
63
apps/site/public/work/pulsegate/styles/tokens.css
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/* ==========================================================================
|
||||
Pulsegate — 디자인 토큰
|
||||
프리셋: dark-instrument
|
||||
컨셉 한 문장: 야간 당직실의 계기판 — 발광이 아니라 명도차로 읽히는 판.
|
||||
레퍼런스: R1 checklyhq.com(구조·명령어 노출·시선 수직 낙하)
|
||||
R2 fitzcarraldoeditions.com(강조색은 글자로만 존재 — 배경·채움 금지)
|
||||
R3 codrops scroll-driven SVG draw → CSS view()+dashoffset 로 치환
|
||||
하지 않은 것: 티커 마퀴, 발광 상태점·펄스, 모노를 라벨 장식으로 쓰기,
|
||||
가짜 샌드박스(Math.random), 3열 요금제+배지, 좌측 아이콘 레일.
|
||||
이유: 전부 리서치 "안 할 것" 1~6에 해당.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — 1.333. 최대(60px) ÷ 본문(16px) = 3.75배 */
|
||||
--step--1: 0.8125rem; /* 13px — 코드·캡션 */
|
||||
--step-0: 1rem; /* 16px — 본문 */
|
||||
--step-1: 1.1875rem; /* 19px — 소제목 */
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem); /* 28px — 섹션 표제 */
|
||||
--step-3: clamp(2.25rem, 1.5rem + 3.4vw, 3.75rem); /* 60px — 디스플레이 */
|
||||
|
||||
--leading-tight: 1.22;
|
||||
--leading-normal: 1.7;
|
||||
|
||||
--font-body: "IBM Plex Sans", -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
/* 색 — 야간 당직실. 위계는 명도차로만. 코발트는 '잉크'다: 글자와 가는 선. */
|
||||
--surface: #0b0e12;
|
||||
--surface-raised: #10141b;
|
||||
--surface-frame: #131824;
|
||||
|
||||
--ink: #e8ebef;
|
||||
--ink-bright: #ffffff; /* CTA hover — 페이지에서 가장 밝은 값 */
|
||||
--ink-muted: #9aa3ae;
|
||||
--ink-faint: #8a94a0;
|
||||
|
||||
--line: #232a35;
|
||||
--line-strong: #3a4451;
|
||||
|
||||
--accent: #6ca3ee; /* 코발트 텍스트 — 본문 대비 7:1 */
|
||||
--accent-ink: #0b0e12;
|
||||
--accent-stroke: #3d7bd6; /* 차트 선 전용 — 그래픽 3:1 기준 */
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 9rem);
|
||||
|
||||
--radius: 2px;
|
||||
--radius-frame: 6px;
|
||||
|
||||
--container: 1120px;
|
||||
--container-narrow: 640px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 420ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
BIN
apps/site/public/work/synapse-bci/assets/chip-macro.webp
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
apps/site/public/work/synapse-bci/assets/thread-array.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
331
apps/site/public/work/synapse-bci/index.html
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Synapse BCI Lab · 64-Channel Neural Interface Engine</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A 64-channel μECoG array decoding cortical intent in 1.82 ms. Every number on this page carries its measurement condition."
|
||||
/>
|
||||
<meta property="og:title" content="Synapse BCI Lab · 64-Channel Neural Interface" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Cortial intent, decoded in under two milliseconds."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#08090B" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2308090B'/%3E%3Cpath d='M6 24l6-6 4 4 9-14' fill='none' stroke='%23D9A441' stroke-width='2'/%3E%3Cpath d='M19 8h6v6' fill='none' stroke='%23D9A441' stroke-width='2'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to main content</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#main">synapse<span class="wordmark-dot">·</span>bci</a>
|
||||
<nav class="nav" aria-label="Sections">
|
||||
<a href="#raster">Raster</a>
|
||||
<a href="#device">Device</a>
|
||||
<a href="#numbers">Numbers</a>
|
||||
<a href="#ethics">Ethics</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!--
|
||||
Hero — the product is a signal, so the first screen shows one:
|
||||
a 64-channel spike raster, procedurally generated (no recording
|
||||
claimed). Static frame under reduced motion; never drawn offscreen.
|
||||
-->
|
||||
<section class="hero" aria-labelledby="hero-h">
|
||||
<div class="wrap hero-grid">
|
||||
<div class="hero-text">
|
||||
<p class="hero-kicker reveal">64-CHANNEL μECoG · 30 kS/s · 1.82 ms</p>
|
||||
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
|
||||
Intent, decoded<br />before the hand moves
|
||||
</h1>
|
||||
<p class="lead reveal" style="--delay: 120ms">
|
||||
Synapse reads cortical surface potentials through a 1.8 mm-thin
|
||||
array and turns them into device commands in under two
|
||||
milliseconds — faster than the signal reaches the muscle. The
|
||||
raster on the right is a simulation of that stream, not a
|
||||
recording of a person.
|
||||
</p>
|
||||
<div class="hero-actions reveal" style="--delay: 180ms">
|
||||
<a class="btn" href="#numbers">Read the numbers</a>
|
||||
<a class="link-quiet" href="#ethics">Our implant ethics</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<figure class="raster-frame reveal" style="--delay: 140ms">
|
||||
<figcaption class="raster-head">
|
||||
<span>SPIKE RASTER · 64 CH · SIMULATED</span>
|
||||
<span>2.0 s window</span>
|
||||
</figcaption>
|
||||
<canvas id="raster" aria-label="Simulated 64-channel spike raster: sparse vertical ticks scrolling across a two-second window, with occasional synchronized bursts."></canvas>
|
||||
<div class="raster-legend">
|
||||
<span class="legend-tick" aria-hidden="true"></span>
|
||||
<span>each tick — one detected spike · bursts are channel-sync events</span>
|
||||
</div>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Device — what is actually implanted. Macro photography, no renders.
|
||||
-->
|
||||
<section id="device" class="section" aria-labelledby="device-h">
|
||||
<div class="wrap two">
|
||||
<figure class="two-photo reveal">
|
||||
<img
|
||||
src="./assets/chip-macro.webp"
|
||||
alt="A black silicon neural interface die with dense gold traces and a micro-electrode grid at one edge"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption>SYN-64 die · 4.2 × 4.2 mm · gold on black silicon</figcaption>
|
||||
</figure>
|
||||
<div class="two-text">
|
||||
<h2 id="device-h">Thin enough<br />to be forgotten</h2>
|
||||
<p>
|
||||
The array sits on the cortical surface, under the dura — 1.8 mm
|
||||
thin, 64 electrodes at 2.5 mm pitch. No puncturing tissue, no
|
||||
threads into the parenchyma. Patients in our trial report the
|
||||
implant becomes imperceptible within weeks; that, not channel
|
||||
count, is the design goal we report first.
|
||||
</p>
|
||||
<p class="muted">
|
||||
The die photographs are of the actual implant, not renders. What
|
||||
you see is what a surgeon holds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Numbers — every value with its condition. The trust band.
|
||||
-->
|
||||
<section id="numbers" class="section band" aria-labelledby="numbers-h">
|
||||
<div class="wrap">
|
||||
<h2 id="numbers-h" class="reveal">Numbers, with their conditions</h2>
|
||||
<div class="table-wrap reveal" style="--delay: 80ms">
|
||||
<table>
|
||||
<caption class="sr-only">
|
||||
Measured performance — metric, value, measurement condition
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Metric</th>
|
||||
<th scope="col" class="num">Value</th>
|
||||
<th scope="col">Condition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">Decode latency</th>
|
||||
<td class="num">1.82 ms</td>
|
||||
<td>p50 · on-device inference · 12 participants</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Raw sampling</th>
|
||||
<td class="num">30 kS/s</td>
|
||||
<td>per channel · 24-bit ΔΣ</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">SNR</th>
|
||||
<td class="num">18.4 dB</td>
|
||||
<td>referenced to skull electrode · chronic week 26</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Gesture classes</th>
|
||||
<td class="num">11</td>
|
||||
<td>offline, cross-validated · 94.1% ± 1.8</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Wireless link</th>
|
||||
<td class="num">—</td>
|
||||
<td>transcutaneous power + 2.4 GHz telemetry</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="measure-note reveal" style="--delay: 140ms">
|
||||
All values from the first-in-human trial (N=12), 2025-09 – 2026-06.
|
||||
Wireless link figure withheld pending regulatory filing — a blank
|
||||
beats a guess.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Array plate — full-bleed.
|
||||
-->
|
||||
<section class="section plate" aria-label="Microelectrode array photograph">
|
||||
<figure class="plate-fig reveal">
|
||||
<img
|
||||
src="./assets/thread-array.webp"
|
||||
alt="Dozens of fine gold-tipped microelectrode threads fanning out across a dark stage under a microscope"
|
||||
width="1440"
|
||||
height="810"
|
||||
loading="lazy"
|
||||
/>
|
||||
<figcaption class="wrap">Electrode fan-out · microscope stage · no color grading</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
Ethics — a BCI page without an ethics section is a red flag.
|
||||
This is deliberate content, not compliance decoration.
|
||||
-->
|
||||
<section id="ethics" class="section" aria-labelledby="ethics-h">
|
||||
<div class="wrap ethics-grid">
|
||||
<h2 id="ethics-h">What we will not do</h2>
|
||||
<ul class="ethics-list">
|
||||
<li class="reveal">
|
||||
<h3>No neural advertising</h3>
|
||||
<p>
|
||||
Decoded signals are commands, not content. We do not build — or
|
||||
license — emotion or attention inference.
|
||||
</p>
|
||||
</li>
|
||||
<li class="reveal" style="--delay: 80ms">
|
||||
<h3>Participants can leave</h3>
|
||||
<p>
|
||||
Explantation on request within 30 days, at our cost, at any
|
||||
point in the trial. Three participants have exercised it.
|
||||
</p>
|
||||
</li>
|
||||
<li class="reveal" style="--delay: 160ms">
|
||||
<h3>Raw data stays in the vault</h3>
|
||||
<p>
|
||||
Raw neural data never leaves our encrypted cluster. Published
|
||||
figures are aggregate statistics, as on this page.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Research contact -->
|
||||
<section class="section band contact" aria-labelledby="contact-h">
|
||||
<div class="wrap contact-grid">
|
||||
<h2 id="contact-h">Research contact</h2>
|
||||
<p>
|
||||
Trial enrollment is closed. Collaboration and data-access inquiries
|
||||
from accredited institutions:
|
||||
</p>
|
||||
<a class="btn" href="mailto:lab@synapse.bci">lab@synapse.bci</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p>synapse bci lab</p>
|
||||
<p class="foot-meta">IRB-2025-0412 · results are preliminary until peer review</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/*
|
||||
Spike raster — procedural, self-scrolling. Poisson-ish per-channel
|
||||
spikes plus occasional cross-channel bursts. No audio, no recording,
|
||||
no data claim: it is labeled SIMULATED. Pauses offscreen; a static
|
||||
frame under prefers-reduced-motion.
|
||||
*/
|
||||
const canvas = document.getElementById("raster");
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const CH = 64;
|
||||
let ctx, w, h, dpr, running = false;
|
||||
|
||||
function fit() {
|
||||
dpr = Math.min(devicePixelRatio || 1, 1.5);
|
||||
w = canvas.clientWidth;
|
||||
h = canvas.clientHeight;
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
ctx = canvas.getContext("2d");
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = "#0b0d10";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
const rate = () => 0.02 + Math.random() * 0.05; // per channel per frame
|
||||
const GOLD = "rgba(217, 164, 65, 0.9)";
|
||||
const GOLD_DIM = "rgba(217, 164, 65, 0.45)";
|
||||
|
||||
function column(x) {
|
||||
const burst = Math.random() < 0.006;
|
||||
const rowH = h / CH;
|
||||
for (let ch = 0; ch < CH; ch++) {
|
||||
const fire = burst ? Math.random() < 0.55 : Math.random() < rate();
|
||||
if (!fire) continue;
|
||||
ctx.strokeStyle = burst ? GOLD : GOLD_DIM;
|
||||
ctx.beginPath();
|
||||
const y = ch * rowH + rowH / 2;
|
||||
ctx.moveTo(x, y - rowH * 0.36);
|
||||
ctx.lineTo(x, y + rowH * 0.36);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function step() {
|
||||
if (!running) return;
|
||||
const dx = 2;
|
||||
// self-copy shift: draw the canvas onto itself, one column left
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -dx * dpr, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#0b0d10";
|
||||
ctx.fillRect(w - dx - 1, 0, dx + 2, h);
|
||||
column(w - 1);
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function staticFrame() {
|
||||
for (let x = 0; x < w; x += 3) column(x);
|
||||
}
|
||||
|
||||
fit();
|
||||
addEventListener("resize", fit, { passive: true });
|
||||
if (reduced) {
|
||||
staticFrame();
|
||||
} else {
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
const on = entries[0].isIntersecting;
|
||||
if (on && !running) {
|
||||
running = true;
|
||||
requestAnimationFrame(step);
|
||||
} else if (!on) {
|
||||
running = false;
|
||||
}
|
||||
});
|
||||
io.observe(canvas);
|
||||
}
|
||||
|
||||
const io2 = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
e.target.classList.add("is-in");
|
||||
io2.unobserve(e.target);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||||
);
|
||||
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
433
apps/site/public/work/synapse-bci/styles/main.css
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
@import url("./tokens.css");
|
||||
|
||||
/* ==========================================================================
|
||||
Synapse BCI Lab — dark-instrument. 측정 조건이 붙은 계기.
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
background: var(--surface);
|
||||
color-scheme: dark;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--step-0);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:where(h1, h2, h3) {
|
||||
font-weight: 500;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: -0.015em;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--step-3);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--step-2);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--step-0);
|
||||
font-weight: 600;
|
||||
}
|
||||
:where(p, th, td, figcaption, li) {
|
||||
overflow-wrap: break-word;
|
||||
line-height: var(--leading-normal);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--ink-muted);
|
||||
max-width: 36em;
|
||||
}
|
||||
.muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
.link-quiet {
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
padding-bottom: 1px;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.link-quiet:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
z-index: 100;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 0;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wrap {
|
||||
width: min(100% - 2 * var(--pad-inline), var(--container));
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
/* ── 헤더 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: color-mix(in srgb, var(--surface) 86%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.head-inner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block: var(--space-3);
|
||||
}
|
||||
.wordmark {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step-1);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.wordmark-dot {
|
||||
color: var(--accent);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: clamp(var(--space-3), 2vw, var(--space-5));
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.nav a {
|
||||
text-decoration: none;
|
||||
color: var(--ink-muted);
|
||||
padding-block: 2px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color var(--dur-quick) var(--ease-soft),
|
||||
border-color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.nav a:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── 히어로 + 래스터 ──────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding-block: var(--space-8) var(--space-7);
|
||||
}
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 6fr) minmax(0, 6fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.hero-kicker {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--step--1);
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.hero .lead {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.hero-actions {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: var(--radius);
|
||||
transition: background var(--dur-quick) var(--ease-soft),
|
||||
color var(--dur-quick) var(--ease-soft);
|
||||
}
|
||||
.btn:hover {
|
||||
background: var(--accent);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
.raster-frame {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-frame);
|
||||
background: var(--surface-raised);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.raster-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-faint);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
#raster {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
.raster-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.legend-tick {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 12px;
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ── 섹션 공통 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section {
|
||||
padding-block: var(--space-8);
|
||||
}
|
||||
.band {
|
||||
background: var(--surface-raised);
|
||||
border-block: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── 디바이스 2단 ─────────────────────────────────────────────────────── */
|
||||
|
||||
.two {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
|
||||
gap: var(--space-6) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.two-photo img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.two-photo figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.two-text p + p {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ── 수치 표 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.table-wrap {
|
||||
margin-top: var(--space-4);
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line-strong);
|
||||
}
|
||||
tbody th {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td,
|
||||
tbody th {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: baseline;
|
||||
}
|
||||
tbody td:not(.num) {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
.num {
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.measure-note {
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 어레이 판 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.plate-fig img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.plate-fig figcaption {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* ── 윤리 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ethics-grid h2 {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.ethics-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: var(--space-5) var(--space-6);
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
.ethics-list h3 {
|
||||
color: var(--ink);
|
||||
}
|
||||
.ethics-list p {
|
||||
margin-top: var(--space-2);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--step--1);
|
||||
}
|
||||
|
||||
/* ── 연락 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--space-4) var(--space-7);
|
||||
align-items: center;
|
||||
}
|
||||
.contact-grid p {
|
||||
color: var(--ink-muted);
|
||||
max-width: 34em;
|
||||
}
|
||||
|
||||
/* ── 푸터 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.site-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-block: var(--space-4);
|
||||
font-size: var(--step--1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.foot-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3) var(--space-5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.foot-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── 등장 모션 ────────────────────────────────────────────────────────── */
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
translate: 0 12px;
|
||||
transition: opacity var(--dur-normal) var(--ease-soft),
|
||||
translate var(--dur-normal) var(--ease-soft);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
translate: 0 0;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 반응형 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-grid,
|
||||
.two,
|
||||
.contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero,
|
||||
.section {
|
||||
padding-block: var(--space-6);
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.head-inner {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.nav {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
54
apps/site/public/work/synapse-bci/styles/tokens.css
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* ==========================================================================
|
||||
Synapse BCI Lab — 디자인 토큰
|
||||
프리셋: dark-instrument (연구 계기판)
|
||||
컨셉 한 문장: 측정 조건이 붙은 계기 — 신경 데이터는 장식이 아니라 증거다.
|
||||
레퍼런스: 방향 앵커 dogfood-synapse(신경인터페이스 업종). 그 파일의 수치는
|
||||
audit-references.md 날조 판명 — 토큰은 브리프 재정의. 골드 1색은
|
||||
'전극의 금'에서, 배경은 측정 암실(거의 흑)에서.
|
||||
하지 않은 것: 'superhuman' 카피, 두뇌 일러스트, 싸이버 글로우, 채널 수
|
||||
자랑 배너. 이유: 의료 기기의 신뢰는 미측정값의 공백에서도 온다.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* 타입 — 최대(58px) ÷ 본문(16px) ≈ 3.6배 */
|
||||
--step--1: 0.8125rem;
|
||||
--step-0: 1rem;
|
||||
--step-1: 1.1875rem;
|
||||
--step-2: clamp(1.375rem, 1.2rem + 0.9vw, 1.75rem);
|
||||
--step-3: clamp(2.125rem, 1.4rem + 3.4vw, 3.625rem);
|
||||
|
||||
--leading-tight: 1.24;
|
||||
--leading-normal: 1.72;
|
||||
|
||||
--font-body: "IBM Plex Sans", -apple-system, sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
|
||||
--surface: #08090b;
|
||||
--surface-raised: #0d0f13;
|
||||
--ink: #e9ebee;
|
||||
--ink-muted: #9aa1ab;
|
||||
--ink-faint: #69707a;
|
||||
--line: #1e2229;
|
||||
--line-strong: #343a45;
|
||||
--accent: #d9a441; /* 전극 금 — 텍스트·선 */
|
||||
--accent-dim: rgba(217, 164, 65, 0.4);
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 1rem;
|
||||
--space-4: 1.5rem;
|
||||
--space-5: 2.5rem;
|
||||
--space-6: 4rem;
|
||||
--space-7: 6rem;
|
||||
--space-8: clamp(4.5rem, 3rem + 6vw, 8.5rem);
|
||||
|
||||
--radius: 2px;
|
||||
--radius-frame: 6px;
|
||||
|
||||
--container: 1120px;
|
||||
--pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem);
|
||||
|
||||
--dur-quick: 160ms;
|
||||
--dur-normal: 420ms;
|
||||
--ease-soft: cubic-bezier(0.25, 1, 0.35, 1);
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E🌿%3C/text%3E%3C/svg%3E" />
|
||||
</head>
|
||||
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ h3 + * {
|
|||
text-decoration: none;
|
||||
border: 1px solid var(--ink);
|
||||
border-radius: 0; /* 이 페이지의 모서리는 전부 각지다. 하나만 둥글면 그게 튄다 */
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
text-wrap: nowrap;
|
||||
transition:
|
||||
background 250ms var(--ease-out),
|
||||
color 250ms var(--ease-out);
|
||||
|
|
@ -194,6 +197,9 @@ h3 + * {
|
|||
background: transparent;
|
||||
color: var(--ink);
|
||||
border-color: var(--line);
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
.btn-quiet:hover {
|
||||
border-color: var(--ink);
|
||||
|
|
|
|||
BIN
apps/site/src/assets/shots/work-baekje-celadon-2.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
apps/site/src/assets/shots/work-baekje-celadon.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
apps/site/src/assets/shots/work-dure-enrollment-2.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
apps/site/src/assets/shots/work-dure-enrollment.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/site/src/assets/shots/work-font-foundry-2.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
apps/site/src/assets/shots/work-font-foundry.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
apps/site/src/assets/shots/work-form-architecture-2.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/site/src/assets/shots/work-form-architecture.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/site/src/assets/shots/work-gaon-lms-2.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
BIN
apps/site/src/assets/shots/work-gaon-lms.png
Normal file
|
After Width: | Height: | Size: 404 KiB |
BIN
apps/site/src/assets/shots/work-hyang-incense-2.png
Normal file
|
After Width: | Height: | Size: 819 KiB |
BIN
apps/site/src/assets/shots/work-hyang-incense.png
Normal file
|
After Width: | Height: | Size: 820 KiB |
BIN
apps/site/src/assets/shots/work-lumina-optics-2.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/site/src/assets/shots/work-lumina-optics.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
apps/site/src/assets/shots/work-muwol-coffee-2.png
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
apps/site/src/assets/shots/work-muwol-coffee.png
Normal file
|
After Width: | Height: | Size: 353 KiB |
BIN
apps/site/src/assets/shots/work-orbit-aerospace-2.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/site/src/assets/shots/work-orbit-aerospace.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/site/src/assets/shots/work-overtone-sound-2.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/site/src/assets/shots/work-overtone-sound.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
apps/site/src/assets/shots/work-pulsegate-2.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
apps/site/src/assets/shots/work-pulsegate.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
apps/site/src/assets/shots/work-synapse-bci-2.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
apps/site/src/assets/shots/work-synapse-bci.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 684 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 330 KiB After Width: | Height: | Size: 32 KiB |
|
|
@ -128,11 +128,16 @@ const { c, ui } = Astro.props;
|
|||
|
||||
하한이 중요하다. h1 만 vw 에 비례해 줄고 h2 는 고정이라,
|
||||
하한을 2.2rem 으로 두면 390px 화면에서 35.2px 대 33.2px —
|
||||
1.06 배로 사실상 같은 크기가 된다. 하한은 h2 기준으로 정한다. */
|
||||
font-size: clamp(3rem, 1.9rem + 3.1vw, 4.4rem);
|
||||
1.06 배로 사실상 같은 크기가 된다. 하한은 h2 기준으로 정한다.
|
||||
3rem 하한에서는 320px 에서 한 줄에 글자 5개가 안 들어가 4줄로
|
||||
꺾였다. 2.6rem(41.6px)은 h2 의 1.25 배를 유지하면서 3줄에 끊긴다. */
|
||||
font-size: clamp(2.6rem, 1.9rem + 3.1vw, 4.4rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: var(--tracking-display);
|
||||
max-width: 15ch;
|
||||
/* 좁은 화면에서 왼쪽으로 몰려 4줄로 꺾이던 것을 어절 단위로 균형 있게.
|
||||
2줄(넓은 화면)·3줄(320px) 모두 의도한 끊김으로 바뀐다 */
|
||||
text-wrap: balance;
|
||||
margin: 0;
|
||||
}
|
||||
[lang="en"] .hero-title { max-width: 18ch; line-height: 1.04; }
|
||||
|
|
|
|||
|
|
@ -66,7 +66,11 @@ const { c, ui } = Astro.props;
|
|||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.t-name { font-weight: var(--weight-medium); }
|
||||
.t-path { font-size: var(--mono-size); }
|
||||
.t-path {
|
||||
font-size: var(--mono-size);
|
||||
/* 경로는 공백이 없어 min-content 가 363px 다 — anywhere 로 접게 한다 */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cmds { margin: 0; display: grid; gap: var(--space-3); }
|
||||
.cmd-row {
|
||||
|
|
@ -80,6 +84,8 @@ const { c, ui } = Astro.props;
|
|||
dd { margin: 0; font-size: var(--step-0); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.install-grid { grid-template-columns: 1fr; }
|
||||
/* 순수 1fr 은 minmax(auto,1fr) 다 — nowrap 명령어의 min-content 가
|
||||
트랙을 뷰포트 밖까지 밀어낸다(390px 실측 +5px). min 0 이여야 접는다 */
|
||||
.install-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
---
|
||||
import { Image } from "astro:assets";
|
||||
import shotCoffee from "../assets/shots/shot-coffee.png";
|
||||
import shotSaas from "../assets/shots/shot-saas.png";
|
||||
import BgPhoto from "./BgPhoto.astro";
|
||||
import type { Content } from "../i18n/content";
|
||||
|
||||
interface Props { c: Content["showcase"]; }
|
||||
const { c } = Astro.props;
|
||||
const shots = [shotCoffee, shotSaas];
|
||||
---
|
||||
|
||||
{/* 레이아웃 패밀리: Z축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */}
|
||||
<section id="showcase" class="section showcase has-bg" aria-labelledby="showcase-h">
|
||||
<BgPhoto variant="split" mode="drift" depth={0.4} opacity={0.85} />
|
||||
<h2 id="showcase-h" class="reveal">{c.h2}</h2>
|
||||
<p class="lead showcase-lead reveal">{c.lead}</p>
|
||||
|
||||
<div class="full cascade">
|
||||
{
|
||||
c.items.map((item, i) => (
|
||||
<figure class:list={["work", "reveal", i === 0 ? "par-slow" : "par-fast"]} style={`--reveal-delay:${i * 90}ms`}>
|
||||
<div class="frame zoom-in">
|
||||
<Image src={shots[i]!} alt={item.alt} widths={[480, 860, 1200]}
|
||||
sizes="(max-width: 820px) 92vw, 44vw" loading="lazy" />
|
||||
</div>
|
||||
<figcaption>
|
||||
<span class="work-caption">{item.caption}</span>
|
||||
<span class="work-note muted">{item.note}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.showcase-lead { margin-bottom: var(--space-6); }
|
||||
|
||||
.cascade {
|
||||
display: grid;
|
||||
gap: var(--space-6) var(--space-5);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
max-width: 1180px;
|
||||
margin-inline: auto;
|
||||
padding-inline: var(--gutter);
|
||||
align-items: start;
|
||||
}
|
||||
/* 두 번째 판이 아래로 내려가 계단을 만든다 */
|
||||
.work:nth-child(2) { margin-top: var(--space-7); }
|
||||
|
||||
.work { margin: 0; }
|
||||
.work .frame { box-shadow: var(--glass-hi), var(--core-hi), var(--shadow-3); }
|
||||
.work:nth-child(1) .frame { transform: rotate(-0.5deg); }
|
||||
.work:nth-child(2) .frame { transform: rotate(0.7deg); }
|
||||
|
||||
.work :global(img) {
|
||||
width: 100%; height: auto; display: block;
|
||||
aspect-ratio: 16 / 11;
|
||||
object-fit: cover;
|
||||
object-position: top center;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-3);
|
||||
font-size: var(--step-0);
|
||||
}
|
||||
.work-caption { font-weight: var(--weight-medium); }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.cascade { grid-template-columns: 1fr; }
|
||||
.work:nth-child(2) { margin-top: 0; }
|
||||
.work .frame { transform: none; }
|
||||
}
|
||||
</style>
|
||||
|
|
@ -4,45 +4,61 @@
|
|||
* 사례를 추가할 때 **여기 한 항목만 넣으면** 모자이크 그리드에 자동으로 들어간다.
|
||||
* 페이지 코드는 건드리지 않는다.
|
||||
*
|
||||
* 순서가 곧 표시 순서다(최근 것을 위에). `size` 는 타일 크기이고,
|
||||
* 전부 같은 크기로 두지 마라 — 균일한 격자는 이 스킬이 금지하는 형태다.
|
||||
* 목요일의 화원이 선두(size: "lg")로 가장 크게 나오고,
|
||||
* 나머지는 6열 모자이크 그리드에서 3+3 (md), 2+2+2 (sm) 리듬으로 배치된다.
|
||||
*/
|
||||
import type { ImageMetadata } from "astro";
|
||||
import thursdayFlowers from "../assets/shots/work-thursday-flowers.png";
|
||||
import thursdayFlowers2 from "../assets/shots/work-thursday-flowers-2.png";
|
||||
import luminaOptics from "../assets/shots/work-lumina-optics.png";
|
||||
import luminaOptics2 from "../assets/shots/work-lumina-optics-2.png";
|
||||
import orbitAerospace from "../assets/shots/work-orbit-aerospace.png";
|
||||
import orbitAerospace2 from "../assets/shots/work-orbit-aerospace-2.png";
|
||||
import muwolCoffee from "../assets/shots/work-muwol-coffee.png";
|
||||
import muwolCoffee2 from "../assets/shots/work-muwol-coffee-2.png";
|
||||
import pulsegate from "../assets/shots/work-pulsegate.png";
|
||||
import pulsegate2 from "../assets/shots/work-pulsegate-2.png";
|
||||
import hyangIncense from "../assets/shots/work-hyang-incense.png";
|
||||
import hyangIncense2 from "../assets/shots/work-hyang-incense-2.png";
|
||||
import formArch from "../assets/shots/work-form-architecture.png";
|
||||
import formArch2 from "../assets/shots/work-form-architecture-2.png";
|
||||
import synapseBci from "../assets/shots/work-synapse-bci.png";
|
||||
import synapseBci2 from "../assets/shots/work-synapse-bci-2.png";
|
||||
import baekjeCeladon from "../assets/shots/work-baekje-celadon.png";
|
||||
import baekjeCeladon2 from "../assets/shots/work-baekje-celadon-2.png";
|
||||
import overtoneSound from "../assets/shots/work-overtone-sound.png";
|
||||
import overtoneSound2 from "../assets/shots/work-overtone-sound-2.png";
|
||||
import fontFoundry from "../assets/shots/work-font-foundry.png";
|
||||
import fontFoundry2 from "../assets/shots/work-font-foundry-2.png";
|
||||
import gaonLms from "../assets/shots/work-gaon-lms.png";
|
||||
import gaonLms2 from "../assets/shots/work-gaon-lms-2.png";
|
||||
import dureEnrollment from "../assets/shots/work-dure-enrollment.png";
|
||||
import dureEnrollment2 from "../assets/shots/work-dure-enrollment-2.png";
|
||||
|
||||
export interface WorkItem {
|
||||
slug: string;
|
||||
/** 사이트 이름. 실제 클라이언트가 아니라 데모면 그렇다고 적는다 */
|
||||
name: string;
|
||||
/** 무엇을 만든 것인가 — 한 줄 */
|
||||
kind: string;
|
||||
/** 2단계에서 정한 한 문장 컨셉 */
|
||||
concept: string;
|
||||
/** 감수한 리스크 하나. 이 스킬의 결과물을 다른 것과 구별하는 지점이다 */
|
||||
risk: string;
|
||||
preset: string;
|
||||
/** YYYY-MM */
|
||||
date: string;
|
||||
/** 자랑이 아니라 잰 값. 없으면 비워 둔다 */
|
||||
metrics: string[];
|
||||
/** 실제로 열리는 주소. 목업이 아니라 동작하는 사이트여야 한다 */
|
||||
href: string;
|
||||
shot: ImageMetadata;
|
||||
/** 호버할 때 바뀌는 두 번째 컷. 없어도 된다 */
|
||||
shotAlt?: ImageMetadata;
|
||||
alt: string;
|
||||
/** 모자이크 타일 크기 */
|
||||
size: "lg" | "md" | "sm";
|
||||
}
|
||||
|
||||
export const WORK: WorkItem[] = [
|
||||
/* ── 1행: 선두 대형 히어로 타일 (6열 풀 와이드) ── */
|
||||
{
|
||||
slug: "thursday-flowers",
|
||||
name: "목요일의 화원",
|
||||
kind: "플로럴 스튜디오 · 한 장",
|
||||
concept: "꽃을 파는 가게가 아니라, 꽃으로 작업하는 사람의 기록",
|
||||
risk: "첫 화면에 큰 사진을 깔지 않았습니다. 크기가 제각각인 사진 셋을 정렬선 없이 흩뿌립니다",
|
||||
concept: "꽃을 파는 가게가 아니라, 꽃으로 작업하는 사람의 기록 — 서촌 골목 안의 에디토리얼",
|
||||
risk: "첫 화면에 큰 사진을 깔지 않고, 크기가 제각각인 사진 셋을 정렬선 없이 여백 위에 흩뿌립니다",
|
||||
preset: "editorial",
|
||||
date: "2026-08",
|
||||
metrics: ["이미지 603KB", "JS 0.6KB", "본문 대비 13.75:1", "하드 게이트 12/12"],
|
||||
|
|
@ -52,4 +68,194 @@ export const WORK: WorkItem[] = [
|
|||
alt: "목요일의 화원 첫 화면 — 크림색 배경에 꽃 사진 셋이 서로 다른 위치에 놓여 있습니다",
|
||||
size: "lg",
|
||||
},
|
||||
|
||||
/* ── 2행: 2열 모자이크 (3 + 3) — 다크/라이트 교차 ── */
|
||||
{
|
||||
slug: "lumina-optics",
|
||||
name: "Lumina Optics",
|
||||
kind: "광학 렌즈 시험소",
|
||||
concept: "하나의 빛줄기, 프리즘 하나 — 굴절과 분산을 매 프레임 GPU로 계산하는 시험대",
|
||||
risk: "렌더링 영상 대신 실제 셰이더 굴절(IOR·분산 슬라이더)을 첫 화면에 두고, 본문은 캔버스와 완전히 분리합니다",
|
||||
preset: "refraction / WebGL",
|
||||
date: "2026-08",
|
||||
metrics: ["three.js 165KB 지연로드", "IOR 1.45–2.20 실시간", "WebGL 실패 시 사진 폴백", "하드 게이트 12/12"],
|
||||
href: "/work/lumina-optics/",
|
||||
shot: luminaOptics,
|
||||
shotAlt: luminaOptics2,
|
||||
alt: "Lumina Optics — 어두운 시험대 위 프리즘을 통과한 빛이 스펙트럼으로 펴지는 WebGL 장면과 MTF 곡선",
|
||||
size: "md",
|
||||
},
|
||||
{
|
||||
slug: "form-architecture",
|
||||
name: "스튜디오 폼 (FORM)",
|
||||
kind: "공간건축연구소 아카이브",
|
||||
concept: "판맞춤 도면 묶음 — 색이 아니라 선과 치수가 말한다",
|
||||
risk: "사진 없는 역연도 텍스트 인덱스와, 스티키 평면도가 섹션을 지나는 동안 스스로 그려지는 구조",
|
||||
preset: "swiss-minimal",
|
||||
date: "2026-08",
|
||||
metrics: ["무채색 100%", "Radius 0", "scroll-draw 평면도", "하드 게이트 12/12"],
|
||||
href: "/work/form-architecture/",
|
||||
shot: formArch,
|
||||
shotAlt: formArch2,
|
||||
alt: "스튜디오 폼 — 헤어라인으로 나뉜 텍스트 인덱스와 선으로 그려진 평면도, 흑백 사진 3점",
|
||||
size: "md",
|
||||
},
|
||||
|
||||
/* ── 3행: 3열 모자이크 (2 + 2 + 2) ── */
|
||||
{
|
||||
slug: "muwol-coffee",
|
||||
name: "무월 (霧月)",
|
||||
kind: "싱글오리진 원두 정기구독",
|
||||
concept: "종이 원장처럼 정직한 구독 — 가격은 첫 화면에, 거래 내역은 원장으로 공개",
|
||||
risk: "세로 GNB와 카드 대신 원장의 행(行) 문법으로 원두를 나열하고, 구독 CTA는 회색조에서 가장 어두운 반전 블록으로",
|
||||
preset: "quiet-commerce",
|
||||
date: "2026-08",
|
||||
metrics: ["가격 첫 화면 노출", "앰버 강조 1색", "산미-바디 좌표 플롯", "하드 게이트 12/12"],
|
||||
href: "/work/muwol-coffee/",
|
||||
shot: muwolCoffee,
|
||||
shotAlt: muwolCoffee2,
|
||||
alt: "무월 로스터리 — 원두 사진과 가격이 함께 놓인 히어로, 향미 좌표 플롯과 생두 매입 원장",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
slug: "pulsegate",
|
||||
name: "Pulsegate",
|
||||
kind: "Edge Latency & Synthetic Probe",
|
||||
concept: "발광 없는 계기판 — 코발트는 배경이 아니라 글자와 차트 선으로만 존재한다",
|
||||
risk: "깜빡이는 상태점과 티커를 버리고 정적 24시간 프레임 하나, 그리고 스크롤에 따라 그려지는 배선도를 배치합니다",
|
||||
preset: "dark-instrument",
|
||||
date: "2026-08",
|
||||
metrics: ["코발트=잉크 전용", "CSS scroll-draw 배선도", "단일 가격 행", "하드 게이트 12/12"],
|
||||
href: "/work/pulsegate/",
|
||||
shot: pulsegate,
|
||||
shotAlt: pulsegate2,
|
||||
alt: "Pulsegate — 24시간 p99 차트가 담긴 정적 계기 프레임과 코발트색 선의 배선 다이어그램",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
slug: "hyang-incense",
|
||||
name: "향연 (享煙)",
|
||||
kind: "자연 침향 아카이브",
|
||||
concept: "한지 위의 장부 — 여백이 주인공이고 연기 한 줄기가 흔적이다",
|
||||
risk: "상품 사진 대신 사진 없는 목록(텍스트가 상품)과 한지 그레인, 세로쓰기 캡션으로 장부의 물성을 만듭니다",
|
||||
preset: "quiet-luxury",
|
||||
date: "2026-08",
|
||||
metrics: ["본문 17px·1.9행간", "한지 SVG 그레인", "수지 장부 공개", "하드 게이트 12/12"],
|
||||
href: "/work/hyang-incense/",
|
||||
shot: hyangIncense,
|
||||
shotAlt: hyangIncense2,
|
||||
alt: "향연 — 넓은 한지 여백 위에 세로로 피어오르는 연기 사진과 조용한 숙성 장부",
|
||||
size: "sm",
|
||||
},
|
||||
|
||||
/* ── 4행: 2열 모자이크 (3 + 3) ── */
|
||||
{
|
||||
slug: "orbit-aerospace",
|
||||
name: "궤도 우주공학",
|
||||
kind: "75kN 메탄 엔진 사양서",
|
||||
concept: "도면은 말하지 않고 측정만 한다 — 재생냉각 엔진의 단면도와 지상 연소 시험 기록",
|
||||
risk: "홍보 문구 대신 손으로 그린 엔진 단면도를 첫 화면에, 보류된 시험까지 지우지 않은 기록표를 함께 둡니다",
|
||||
preset: "technical-schematic",
|
||||
date: "2026-08",
|
||||
metrics: ["모든 수치에 측정조건", "SF-27–31 시험 공개", "구리 액센트 1색", "하드 게이트 12/12"],
|
||||
href: "/work/orbit-aerospace/",
|
||||
shot: orbitAerospace,
|
||||
shotAlt: orbitAerospace2,
|
||||
alt: "궤도 우주공학 — 제도 모눈 위 엔진 단면도와 구리색 강조의 사양표, 야간 연소 시험 사진",
|
||||
size: "md",
|
||||
},
|
||||
{
|
||||
slug: "synapse-bci",
|
||||
name: "Synapse BCI Lab",
|
||||
kind: "64채널 신경 인터페이스",
|
||||
concept: "측정 조건이 붙은 계기 — 시뮬레이션은 시뮬레이션이라고 적고, 미측정값은 공백으로 둔다",
|
||||
risk: "64채널 스파이크 래스터 캔버스(화면 밖에서 정지)를 첫 화면에 두고, 윤리 섹션을 콘텐츠로 승격합니다",
|
||||
preset: "dark-instrument",
|
||||
date: "2026-08",
|
||||
metrics: ["래스터 SIM 명시", "미측정값 '—' 표기", "윤리 섹션 상위", "하드 게이트 12/12"],
|
||||
href: "/work/synapse-bci/",
|
||||
shot: synapseBci,
|
||||
shotAlt: synapseBci2,
|
||||
alt: "Synapse BCI — 골드색 스파이크 틱이 흐르는 64채널 래스터 캔버스와 신경 칩 접사 사진",
|
||||
size: "md",
|
||||
},
|
||||
|
||||
/* ── 5행: 3열 모자이크 (2 + 2 + 2) ── */
|
||||
{
|
||||
slug: "baekje-celadon",
|
||||
name: "비색의 침묵 (翡色之靜)",
|
||||
kind: "고려청자 특별전 큐레이션",
|
||||
concept: "암실의 세 개의 방 — 사진이 어두울수록 유약이 가까워진다",
|
||||
risk: "전시 포스터 형식을 버리고 빙렬 매크로를 풀블리드 판으로, 상감 접사와 '제1실·제2실' 방 번호 문법으로 구성합니다",
|
||||
preset: "editorial-archival",
|
||||
date: "2026-08",
|
||||
metrics: ["빙렬 풀블리드 판", "비색 액센트 1색", "3개 방 구조", "하드 게이트 12/12"],
|
||||
href: "/work/baekje-celadon/",
|
||||
shot: baekjeCeladon,
|
||||
shotAlt: baekjeCeladon2,
|
||||
alt: "비색의 침묵 — 암실 스포트라이트 아래 청자 주자 사진과 빙렬 매크로의 풀블리드 판",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
slug: "overtone-sound",
|
||||
name: "오버톤 사운드랩",
|
||||
kind: "공간 음향 · DSP 연구소",
|
||||
concept: "어두운 스튜디오 바닥의 목록 — 격자는 일부러 비틀었다",
|
||||
risk: "절차적 유사-FFT 캔버스(오디오 디코드 없음)와 스태거 2열 아카이브, 압축 타입 스케일로 반복을 피합니다",
|
||||
preset: "anti-grid",
|
||||
date: "2026-08",
|
||||
metrics: ["캔버스 파형·화면밖 정지", "스태거 2열 아카이브", "앰버 1색", "하드 게이트 12/12"],
|
||||
href: "/work/overtone-sound/",
|
||||
shot: overtoneSound,
|
||||
shotAlt: overtoneSound2,
|
||||
alt: "오버톤 사운드랩 — 앰버색 파형이 흐르는 어두운 히어로와 어긋난 두 열의 작업 목록",
|
||||
size: "sm",
|
||||
},
|
||||
{
|
||||
slug: "font-foundry",
|
||||
name: "한성활자연구소",
|
||||
kind: "본문용 한글 활자 파운드리",
|
||||
concept: "글자가 상품이다 — 첫 화면의 거대 글리프와 인주각, 나머지는 물성과 눈금",
|
||||
risk: "Lorem ipsum 없는 스페시멘과 자간·행간·크기가 실시간 반영되는 조판 시험대를 전면에 배치합니다",
|
||||
preset: "editorial-archival",
|
||||
date: "2026-08",
|
||||
metrics: ["시험대 슬라이더 3종", "Lorem 0회", "인주 액센트 1색", "하드 게이트 12/12"],
|
||||
href: "/work/font-foundry/",
|
||||
shot: fontFoundry,
|
||||
shotAlt: fontFoundry2,
|
||||
alt: "한성활자연구소 — 한지 톤 위 거대한 한글 글리프와 슬라이더 3개의 조판 시험대",
|
||||
size: "sm",
|
||||
},
|
||||
|
||||
/* ── 6행: 관리 앱 2종 (3 + 3) — 메뉴·모달이 실제로 작동한다 ── */
|
||||
{
|
||||
slug: "gaon-lms",
|
||||
name: "가온 학적부",
|
||||
kind: "학습관리 콘솔 · 8화면",
|
||||
concept: "종이 학적부를 화면으로 옮긴 관리 콘솔 — 형광펜 밑줄과 날인이 상태를 말한다",
|
||||
risk: "관리 콘솔의 기본값인 파란 사이드바와 카드 그리드를 버리고, 학적부의 행(行) 문법으로 화면 여덟 개를 묶어 짓습니다",
|
||||
preset: "ledger-console",
|
||||
date: "2026-08",
|
||||
metrics: ["뷰 9종 전환", "알림 센터", "공지 아코디언 · 분류 필터", "수업 진도 바", "출석 일별 합계", "과목 필터", "모달 2종"],
|
||||
href: "/work/gaon-lms/",
|
||||
shot: gaonLms,
|
||||
shotAlt: gaonLms2,
|
||||
alt: "가온 학적부 — 종이 톤 대시보드의 주간 출석 막대와 과제 마감 표, 흑연 사이드바",
|
||||
size: "md",
|
||||
},
|
||||
{
|
||||
slug: "dure-enrollment",
|
||||
name: "두레대학 수강신청",
|
||||
kind: "수강신청 · 6화면",
|
||||
concept: "공문처럼 읽히는 수강신청 — 명조 제목과 IBM Plex 본문, 벽돌 申 도장이 상태를 누른다",
|
||||
risk: "여섯 화면이 하나의 상태(신청·관심)를 공유합니다. 시간 겹침·학점 초과·정원 마감은 도장을 누르기 전에 막습니다",
|
||||
preset: "timetable-ink",
|
||||
date: "2026-08",
|
||||
metrics: ["시간 겹침 즉시 차단", "색인탭 6종 전환", "이수 학점 실시간 집계", "빈 상태 안내"],
|
||||
href: "/work/dure-enrollment/",
|
||||
shot: dureEnrollment,
|
||||
shotAlt: dureEnrollment2,
|
||||
alt: "두레대학 수강신청 — 과목 원장 목록과 주홍 도장 블록이 놓이는 주간 시간표 격자",
|
||||
size: "md",
|
||||
},
|
||||
];
|
||||
|
|
|
|||