diff --git a/.gitignore b/.gitignore index 41fb99f..77d0ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ apps/site/dist/ # 리서치 원본 자막(용량) — 요약 문서는 커밋한다 research/youtube/*.srt research/youtube/*.vtt +apps/site/tools/.gen/ diff --git a/apps/site/.htmlvalidate.json b/apps/site/.htmlvalidate.json new file mode 100644 index 0000000..cb16c61 --- /dev/null +++ b/apps/site/.htmlvalidate.json @@ -0,0 +1,9 @@ +{ + "extends": ["html-validate:standard"], + "rules": { + "require-sri": "off", + "no-inline-style": "off", + "doctype-style": "off", + "void-style": "off" + } +} diff --git a/apps/site/.stylelintrc.json b/apps/site/.stylelintrc.json new file mode 100644 index 0000000..2c95576 --- /dev/null +++ b/apps/site/.stylelintrc.json @@ -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 + } +} diff --git a/apps/site/design.md b/apps/site/design.md index f8df084..9cde6d9 100644 --- a/apps/site/design.md +++ b/apps/site/design.md @@ -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 미러), 모든 수치에 측정조건, 보류 시험까지 공개 | +| 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행이라 시트를 알약 전체 폭으로). `
` 결정 유지. +- 시트: 11rem 조각 상자 → 알약 전체 폭, 행 48px, mono 번호(01–05)·repo·언어 행, + 요약 아이콘 버거→X 교체, 44px 터치 대상, 등장 translateY+opacity 180ms. +- **발견한 숨은 버그**: 닫힌 `
` 자식에 제작자 `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). +- 상세는 네이티브 +showModal — Esc·포커스 트랩·inert 를 브라우저가 처리 + (
메뉴와 같은 결정론). 배경 탭·닫기 버튼은 인라인 JS 몇 줄. +- 모달은 불투명 판(유리 금지 — 메뉴 시트와 같은 교훈), ::backdrop 만 블러. + 큰 CTA "사이트 보기" = btn-solid + 둥근 화살 칸(cmd-copy 아이콘 어휘), 새 탭. +- 카드 전체 클릭은 절대배치 + + + + + + + + +
+ +
+
+

2026학년도 1학기 과목

+

과목을 누르면 강의계획과 신청 버튼이 열립니다. 신청하면 시간표에 얹힙니다.

+ +
+ + +
    + +
    +
    + + +
    + + +
    +

    관심 과목

    +

    강의계획에서 관심을 남기면 여기에 모입니다. 여석과 시간은 실시간입니다.

    +
    + +
    +
    + + + +
    +
    +
    +
      + + +
      +
      + + +
      +

      신청 내역

      +

      이번 학기에 신청한 과목의 원장. 여기서도 취소할 수 있습니다.

      +
      +
      +

      합계 0학점 / 18

      + +

      정정 기간은 3/9–3/13입니다. 18학점 초과 신청은 학부 사무실 승인이 필요하고, 두 번 결석하면 탈강 처리됩니다 — 변경은 이 화면에서 할 수 있습니다.

      +
      + + + + + + +
      신청 과목 목록
      과목시간장소학점상태
      +
      + +

      +
      +
      + + +
      +

      이수 현황

      +

      네 학기를 마친 시점의 기록에 이번 학기 신청을 얹어 봅니다. 신청은 확정이 아닙니다.

      +
      +
      + + + + + + + + + + +
      졸업 기준 대비 이수 학점
      구분졸업 기준이수이번 학기 신청합계잔여진행
      +

      잔여가 0보다 작아지면 초과입니다. 학점 초과 신청은 학부 사무실 승인이 필요합니다.

      +
      +

      졸업 기준 대비 총 이수율

      +
      +
      +
      +
      + + +
      +

      과목 총람

      +

      개설 과목 전부를 계열별로 묶어 적습니다. 여석이 5 이하인 과목은 붉게 표시합니다.

      +
      +
      + + + + + + + + + + +
      개설 과목 전체 목록
      코드과목교수시간학점여석 / 정원
      +
      +
      + + +
      +

      공지 · 변경

      +
      +

      수강신청 기간의 게시판. 강의실 변경은 이 원장에 남깁니다.

      +
      +
      +
      +
      +
      + + + +
      +
      + + +

      과목명

      +

      CODE · 계열

      +
      +
      학점
      +
      여석
      +
      시간
      +
      +

      2026학년도 1학기

      +
      +
      + + +

      교수 · 강의실

      + +
      +

      주간 자리

      + +
      + +
      +

      평가

      + +
      +
      + + + +
      +
      +
      + + + + + diff --git a/apps/site/public/work/dure-enrollment/og.png b/apps/site/public/work/dure-enrollment/og.png new file mode 100644 index 0000000..1af2ee7 Binary files /dev/null and b/apps/site/public/work/dure-enrollment/og.png differ diff --git a/apps/site/public/work/dure-enrollment/styles/main.css b/apps/site/public/work/dure-enrollment/styles/main.css new file mode 100644 index 0000000..e29a664 --- /dev/null +++ b/apps/site/public/work/dure-enrollment/styles/main.css @@ -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); } diff --git a/apps/site/public/work/dure-enrollment/styles/tokens.css b/apps/site/public/work/dure-enrollment/styles/tokens.css new file mode 100644 index 0000000..1258677 --- /dev/null +++ b/apps/site/public/work/dure-enrollment/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/font-foundry/assets/inkstone.webp b/apps/site/public/work/font-foundry/assets/inkstone.webp new file mode 100644 index 0000000..394bb64 Binary files /dev/null and b/apps/site/public/work/font-foundry/assets/inkstone.webp differ diff --git a/apps/site/public/work/font-foundry/assets/metaltype.webp b/apps/site/public/work/font-foundry/assets/metaltype.webp new file mode 100644 index 0000000..fcda3f8 Binary files /dev/null and b/apps/site/public/work/font-foundry/assets/metaltype.webp differ diff --git a/apps/site/public/work/font-foundry/index.html b/apps/site/public/work/font-foundry/index.html new file mode 100644 index 0000000..f0c4ada --- /dev/null +++ b/apps/site/public/work/font-foundry/index.html @@ -0,0 +1,289 @@ + + + + + + 한성활자연구소 · 본문용 한글 활자 + + + + + + + + + + + + + + + + +
      + +
      + +
      + +
      +
      +

      본문용 한글 활자 · 1000 UPM · 2018년 창립

      +

      +
      +

      + 이 글자가 어디에 쓰이는지는 이 페이지 전체가 증명합니다. 우리는 + 제목용 활자를 만들지 않습니다 — 오래 읽어야 하는 본문의 활자만 + 만듭니다. 획의 장력, 자간의 균형, 행간의 숨을 그 단위(UPM)로 + 측정합니다. +

      +
      +
      +
      서체
      +
      한성본문 · 한성제목
      +
      +
      +
      글자 수
      +
      11,172자 완성
      +
      +
      +
      단위
      +
      1000 UPM · 가변 웨이트 3종
      +
      +
      +
      +
      +
      + + +
      +
      +

      활자 두 종

      + +
      +
      +

      한성본문

      +

      본문용 · 웨이트 400 · 16px에서 행간 1.8

      +
      +

      + 아침 안개가 걷히기 전에 도랑물 소리가 먼저 들렸다. 글자는 종이 위에서 + 소리를 내지 않지만, 잘 짜인 활자는 소리 내어 읽게 만든다. 획이 닿는 + 각도와 글자가 서로 미는 힘을 천 번쯤 고쳐 썼다. 그 결과 이 문장이 + 밤늦게도 읽히는 것이다. +

      +
      + +
      +
      +

      한성제목

      +

      제목·표제용 · 웨이트 600 · 큰 짝에서 벌어짐 0

      +
      +

      + 활자는 붓의 속도를 기억한다 +

      +
      +
      +
      + + +
      +
      +

      조판 시험대

      +

      + 자간과 행간, 글자 크기가 가독성을 어떻게 바꾸는지 직접 움직여 보세요. + 아래 값은 권장값이 아니라 출발점입니다 — 용지 규격과 본문 길이에 따라 + 정답은 달라집니다. +

      + +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      + +
      +
      + 활자의 역사는 글자를 찍는 기계의 역사가 아니라, 글자를 읽는 눈의 + 역사였다. 활자소의 식자공들은 하루에 만 글자를 짜면서 어느 글꼴이 + 눈을 덜 아프게 하는지 알고 있었다. 그 지식은 문서로 남지 않았고 + 손끝으로만 전해졌다. 이 시험대는 그 손끝의 판단을 누구나 해볼 수 + 있는 눈금으로 옮긴 것이다. 자간을 0에서 5로 옮겨 보라 — 문장이 + 늘어지는 것이 아니라 숨이 가빠지는 것을 느낄 것이다. +
      +
      +
      +
      + + +
      +
      +
      + 나무 활자 케이스에 골라 담긴 금속 활자들 — 활자면이 위에서 빛을 받아 결이 보인다 +
      연구소 2층 · 금속 활자 케이스
      +
      +
      +

      금속에서 시작해
      화면으로 끝난다

      +

      + 한성본문의 자간 규칙은 금속 활자의 '옆면 여백'에서 왔습니다. 납활자는 + 글자 바로 옆에 살이 붙어 있어서, 찍히는 글자 사이는 언제나 일정했고 + 그 일정함이 읽는 리듬이었습니다. 화면용 폰트는 그 살을 지울 수 + 있지만, 우리는 지우지 않았습니다. +

      +
      + 연구한 먹이 고인 연적과 옆에 놓인 인주 연판 +
      먹을 갈고 인주를 찍는 자리 — 시험 인쇄 전
      +
      +
      +
      +
      + + +
      +
      +
      +

      구입

      +

      + 웹·출판·임베드 세 가지 라이선스만 있습니다. 사용 수를 세지 않습니다 — + 조판되는 단위(도서 권수, 사이트 도메인, 기기)로 셉니다. +

      +
      +
      +
      +
      +

      +

      도메인당 · 웨이트 전종 · 자체 호스팅 WOFF2

      +
      +

      140,000원

      +
      +
      +
      +

      출판

      +

      도서 권수당 · 인쇄+전자 동시

      +
      +

      220,000원

      +
      +
      + 견적 문의 +
      +
      +
      + +
      +
      +

      한성활자연구소 · 서울 마포

      +

      foundry@hanseong.kr

      +
      +
      + + + + diff --git a/apps/site/public/work/font-foundry/styles/main.css b/apps/site/public/work/font-foundry/styles/main.css new file mode 100644 index 0000000..3981f9e --- /dev/null +++ b/apps/site/public/work/font-foundry/styles/main.css @@ -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); + } +} diff --git a/apps/site/public/work/font-foundry/styles/tokens.css b/apps/site/public/work/font-foundry/styles/tokens.css new file mode 100644 index 0000000..8bb93c3 --- /dev/null +++ b/apps/site/public/work/font-foundry/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/form-architecture/assets/interior-bw.webp b/apps/site/public/work/form-architecture/assets/interior-bw.webp new file mode 100644 index 0000000..03e1019 Binary files /dev/null and b/apps/site/public/work/form-architecture/assets/interior-bw.webp differ diff --git a/apps/site/public/work/form-architecture/assets/model-bw.webp b/apps/site/public/work/form-architecture/assets/model-bw.webp new file mode 100644 index 0000000..0a7aa5f Binary files /dev/null and b/apps/site/public/work/form-architecture/assets/model-bw.webp differ diff --git a/apps/site/public/work/form-architecture/assets/pavilion-bw.webp b/apps/site/public/work/form-architecture/assets/pavilion-bw.webp new file mode 100644 index 0000000..079e535 Binary files /dev/null and b/apps/site/public/work/form-architecture/assets/pavilion-bw.webp differ diff --git a/apps/site/public/work/form-architecture/index.html b/apps/site/public/work/form-architecture/index.html new file mode 100644 index 0000000..ad94f91 --- /dev/null +++ b/apps/site/public/work/form-architecture/index.html @@ -0,0 +1,303 @@ + + + + + + 스튜디오 폼 · 공간건축연구소 + + + + + + + + + + + + + + + + + + + + +
      + +
      + +
      + +
      +
      +

      + 공간건축연구소 · 서울 · 2014년 설립 +

      +

      + 형태는 침묵하고
      치수가 태도를 증명한다 +

      +

      + 폼은 공간을 꾸미지 않습니다. 벽이 서는 이유, 창이 열리는 방향, 보가 + 건너뛰는 길이를 설계합니다. 아름다움은 그 결정들이 모였을 때의 + 부산물입니다 — 그래서 우리의 홍보는 도면과 치수로 합니다. +

      +
      +
      +
      설립
      +
      2014년
      +
      +
      +
      구성
      +
      건축사 4 · 연구원 3
      +
      +
      +
      영역
      +
      주거 · 미술관 · 재생
      +
      +
      +
      +
      + + +
      + +
      + + +
      +
      +
      + + + + + + + + + + + + + + 14,400 + 9,600 + + + + 거실 + + 주방·식당 + 서비스 + 현관 + + + + + N + + +

      폼 하우스 · 1층 평면도 · mm

      +
      + +
      +

      폼 하우스, 가평

      +

      + 대지 남쪽의 소나무군을 지우지 않기 위해 집은 길게 눕고, 마당은 집 + 안쪽으로 들어왔습니다. 구조는 9.6m 축 한 번으로 건너뛰어 거실에 기둥이 + 없습니다. +

      +

      + 벽체는 단열 성능의 요구치에서 두께가 정해졌습니다 — 마감 재료를 먼저 + 고르고 두께를 맞추지 않았습니다. 창은 남향 긴면 하나, 동쪽 마당 면 + 하나. 그 두 개로 이 집의 하루가 결정됩니다. +

      +
      +
      +
      구조
      +
      RC 라멘 · 9.6m 스팬
      +
      +
      +
      면적
      +
      132.4 ㎡ (40.0평)
      +
      +
      +
      창개구
      +
      남측 14.4m · 동측 4.2m
      +
      +
      +
      줄공사
      +
      2025-04 – 2026-01
      +
      +
      +
      +
      +
      + + +
      +
      +

      사진

      +
      +
      + 긴 수평창이 낸 콘크리트 파빌리온에 벽 그림자가 지는 모습 +
      폼 하우스 · 남측 · 준공 후 첫 여름
      +
      +
      + 천창으로 한 줄기 빛이 사선으로 들어오는 미장 마감 전시실 +
      백령도 해녀기념관 · 제1전시실
      +
      +
      + 스터디 모델 — 직육면체를 쌓은 백색 석고 모형에 측광이 지는 모습 +
      스터디 모델 07 · 석고
      +
      +
      +
      +
      + + +
      +
      +

      연락

      +
      + 서울 종로구 자하문로 00길 00, 3층
      + studio@form.kr +
      +

      + 신규 프로젝트는 분기에 두 건만 받습니다. 대지와 예산의 조건을 적어 + 보내주시면, 가능 여부를 2주 안에 답합니다. +

      +
      +
      +
      + +
      +
      +

      스튜디오 폼 · 공간건축연구소

      +

      © 2014–2026

      +
      +
      + + + + diff --git a/apps/site/public/work/form-architecture/styles/main.css b/apps/site/public/work/form-architecture/styles/main.css new file mode 100644 index 0000000..fb70a83 --- /dev/null +++ b/apps/site/public/work/form-architecture/styles/main.css @@ -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; + } +} diff --git a/apps/site/public/work/form-architecture/styles/tokens.css b/apps/site/public/work/form-architecture/styles/tokens.css new file mode 100644 index 0000000..83f3f54 --- /dev/null +++ b/apps/site/public/work/form-architecture/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/gaon-lms/calc.js b/apps/site/public/work/gaon-lms/calc.js new file mode 100644 index 0000000..f53448a --- /dev/null +++ b/apps/site/public/work/gaon-lms/calc.js @@ -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); diff --git a/apps/site/public/work/gaon-lms/index.html b/apps/site/public/work/gaon-lms/index.html new file mode 100644 index 0000000..1a5ffa9 --- /dev/null +++ b/apps/site/public/work/gaon-lms/index.html @@ -0,0 +1,1316 @@ + + + + + + 가온 학적부 · 학습관리 대시보드 + + + + + + + + + + + + + + + + + + + + + + + + + +

      가온 학적부

      +
      + + + +
      +
      +

      가온 학적부 대시보드

      +
      + 2026학년도 1학기 · 3월 2주차 +
      + +
      +
      + 알림 + +
      +
      + +

      자기소개 논술 초고 2명 제출 · 1명 미제출 — 채점 대기

      +
      +
      + +

      국어 논술 A 미응시 1명 — 학부모 알림톡 발송 완료

      +
      +
      + +

      새 공지 “3월 단원 평가 일정 안내” 등록

      +
      +
      + +

      강이안 학생 보강 신청 — 3/12(목) 17:00 코딩 입문

      +
      +
      +
      + +
      +
      + +
      +
      +
      +

      이번 주, 열두 명 중 열 명

      +

      3월 9일 월요일 오전 기준. 명단 12명 중 재학 10명, 주간 출석은 지난 주(3/2–3/6) 결산입니다.

      +
      + +
      + +
      +
      명단
      12명
      재학 10 · 휴학 2 · 이번 달 전입 1
      +
      주간 출석률
      92%
      출석 46 · 지각 2 · 결석 2 (지난 주 · 재학 10명)
      +
      미채점 과제
      2건
      세트 4 · 워크시트 채점 대기 — 3/11 마감
      +
      + +
      + +
      +

      오늘의 수업

      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      오늘의 수업 목록
      시간과목강의실상태출석
      10:00–11:30수학 심화 B2실진행 중3 / 3
      14:00–15:30국어 논술 A1실예정2 / 3 · 미응시 1
      16:30–18:00코딩 입문3실예정2 / 2
      금일 3교시 · 남은 수업 2교시 — 국어 논술 A 미응시 1명(한지우) 사유 확인 필요
      +
      +
      + + +
      +

      주간 출석

      +
      + + + + + + + + + + + + + + + + + + + + +
      + 출석 + 지각 + 결석 +
      +
      + + + + + + + + + + + +
      요일별 출석 결산
      요일출석지각결석출석률
      월 3/21000100%
      화 3/391090%
      수 3/41000100%
      목 3/590190%
      금 3/681180%
      합계462292%
      +
      + + +
      +

      마감 임박 과제

      +
        +
      • +
      • +
      • +
      +

      행을 누르면 채점 화면이 열립니다.

      +
      +
      + +
      +

      수업 진행 3월 2주차 기준 · 개설 과목 4개

      +
      +
      +
      수학 심화 B12/16
      + +

      75% · 이번 주 2차시 완료 · 단원 평가 D-5

      +
      +
      +
      국어 논술 A8/12
      + +

      67% · 이번 주 2차시 완료 · 초고 채점 중

      +
      +
      +
      코딩 입문5/10
      + +

      50% · 이번 주 1차시 완료 · 워크시트 2명 미제출

      +
      +
      +
      과학 실험9/12
      + +

      75% · 이번 주 2차시 완료 · 리포트 채점 완료

      +
      +
      +
      +
      + + +
      +
      +
      +

      공지 · 알림

      +

      반에 전달한 안내입니다. 행을 누르면 본문이 열리고 읽음 처리됩니다.

      +
      +
      +
      +
      + + + + + +
      +
      +
      +
        +
      • +
      • +
      • +
      • +
      • +
      +
      +
      + + +
      +
      +
      +

      수강생 기록

      +

      행을 누르면 상담 기록까지 볼 수 있습니다.

      +
      +
      + +
      + + + +
      + + + + + + + + + + + +
      수강생 목록 — 이름, 학년, 과목, 보호자, 출석률, 과제 제출, 상태
      이름학년주 과목보호자출석률과제 제출상태
      + +
      +
      + + +
      +
      +
      +

      출석부

      +

      칸을 누르면 출석 · 지각 · 결석이 순환합니다. 반과 주를 바꿔가며 적습니다.

      +
      + +
      + +
      +
      +
      + + 3월 2주 + +
      +
      + +
      + +
      + + + + + + + + + + + + + +
      주간 출석부 — 학생별 월요일부터 금요일까지 출석 상태
      이름이번 주
      +

      +
      + 병결 1 + 조퇴 1 + 사유 검토 1 + 보강 배정 1 + 학부모 알림톡 2/2 발송 +
      +
      +
      + + +
      +
      +
      +

      과제 · 성적

      +

      채점 대기 행을 눌러 점수를 남깁니다.

      +
      +
      + +
      +
      채점 완료 3
      +
      채점 대기 2
      +
      자동 채점 1
      +
      예정 1
      +
      평균 제출률 83%
      +
      + +
      +
      + + + + + +
      +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      과제 목록
      과제유형과목마감제출상태
      과제수학 심화 B03-043/3채점 완료
      과제국어 논술 A03-122/3채점 대기
      과제코딩 입문03-131/2채점 대기
      과제과학 실험03-063/3채점 완료
      단원 평가 퀴즈 — 미분 응용퀴즈수학 심화 B03-083/3자동 채점
      3월 단원 평가시험수학 심화 B03-17예정
      +
      + +
      +

      수학 심화 B · 세트 3 점수 분포

      +
      +
      + + + + + + 1명 + 3명 + 4명 + + + 70점 + 80점 + 90점 + + +
      점수 구간별 인원 · 채점 인원 8명
      +
      +
      +
      평균
      84.4점
      +
      최고 · 최저
      96 · 70점
      +
      90점 이상
      4명 (50%)
      +
      재응시 대상
      70점대 1명
      +
      미제출
      0명
      +
      +
      +
      +
      + + +
      +
      +
      +

      자료실

      +

      배부 자료의 원장. 배부를 눌렀는지에 따라 도장이 남습니다.

      +
      +
      + +
      + +
      +
      + + + + +
      +
      + +
      + + + + + + +
      배부 자료 목록
      자료과목종류크기조회날인일배부
      +

      다운로드 링크를 만들지 않는다 — 이 데모에는 파일이 없다. 배부 상태만 기록한다.

      +
      +
      + + +
      +
      +
      +

      학사 일정 · 2026년 3월

      +

      마감과 휴원을 먼저 보는 달력. 오른쪽은 3월 9일 이후의 일정입니다.

      +
      +
      + +
      + +
      +
      + + + + + + +
      2026년 3월 달력 — 월요일 시작, 일정이 있는 날은 제목이 적힙니다
      +
      + 평가 · 시험 + 과제 마감 + 행정 · 상담 + 휴원 +
      +
      +
      +

      다가오는 일정

      +
      +
      +
      +
      + + +
      +
      +
      +

      상담 기록

      +

      수강생 화면에 남긴 기록을 모아 봅니다. 카드를 누르면 그 학생의 기록이 열립니다.

      +
      +
      + +
      +
      + + + + + +
      +
      + +
      + +
      + + +
      +

      예정된 상담 3월 9일 기준

      +
        +
      • + 오세림 학부모 상담 — 추가 과외 일정 조율 + 3/12(목) 18:00 알림톡 발송 +

      • +
      • + 박서연 진로 상담 — 영재 교육원 지원 검토 + 3/13(금) 17:30 발송 대기 +

      • +
      • + 한지우 학습 상담 — 등교 시간 재조정 후속 + 3/16(월) 19:00 발송 대기 +

      • +
      +
      +
      + + +
      +
      +
      +

      학기 리포트

      +

      아래 표는 명단에서 직접 계산합니다. 채점이 끝나지 않은 과목의 점수는 비워 둡니다.

      +
      + +
      + +
      + +
      + + + + + + + + + + +
      과목별 학기 요약 — 재원, 평균 출석률, 과제 제출률, 채점 평균
      과목재원평균 출석률과제 제출률채점 평균진행 차시
      +

      휴학생은 재원과 계산에서 제외합니다. 채점 평균은 완료된 과제만 반영합니다. 학부모 리포트는 3/20에 알림톡으로 일괄 발송됩니다 — 대상 재학생 보호자 전원.

      +
      +
      +
      +
      + + + + +
      +
      + + +

      수강생

      +

      주 과목 · 상태

      +
      +
      출석률
      +
      과제 제출
      +
      상담 기록
      +
      +

      가온 학적부 · 2026-1

      +
      +
      + + +
      +

      보호자 · 알림

      +

      +
        +
      • 3/05 출결 알림톡 발송 완료
      • +
      • 2/28 월간 학습 리포트 발송 완료
      • +
      +
      + +
      +

      3월 2주까지의 출석

      +
      +
      + +
      +

      상담 기록

      +
        +
        + + + +
        +
        +
        +
        +
        + + + + + + + + + + diff --git a/apps/site/public/work/gaon-lms/og.png b/apps/site/public/work/gaon-lms/og.png new file mode 100644 index 0000000..60ac9bf Binary files /dev/null and b/apps/site/public/work/gaon-lms/og.png differ diff --git a/apps/site/public/work/gaon-lms/styles/main.css b/apps/site/public/work/gaon-lms/styles/main.css new file mode 100644 index 0000000..a0675e1 --- /dev/null +++ b/apps/site/public/work/gaon-lms/styles/main.css @@ -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; } +} diff --git a/apps/site/public/work/gaon-lms/styles/tokens.css b/apps/site/public/work/gaon-lms/styles/tokens.css new file mode 100644 index 0000000..0deafbd --- /dev/null +++ b/apps/site/public/work/gaon-lms/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/hyang-incense/assets/agarwood.webp b/apps/site/public/work/hyang-incense/assets/agarwood.webp new file mode 100644 index 0000000..3508015 Binary files /dev/null and b/apps/site/public/work/hyang-incense/assets/agarwood.webp differ diff --git a/apps/site/public/work/hyang-incense/assets/smoke.webp b/apps/site/public/work/hyang-incense/assets/smoke.webp new file mode 100644 index 0000000..c02813d Binary files /dev/null and b/apps/site/public/work/hyang-incense/assets/smoke.webp differ diff --git a/apps/site/public/work/hyang-incense/index.html b/apps/site/public/work/hyang-incense/index.html new file mode 100644 index 0000000..7304e82 --- /dev/null +++ b/apps/site/public/work/hyang-incense/index.html @@ -0,0 +1,255 @@ + + + + + + 향연 (享煙) · 자연 침향 아카이브 + + + + + + + + + + + + + + + + + + + +
        + +
        + +
        + +
        +
        +
        +

        자연 침향 · 백단 · 서울 삼청동

        +

        + 연기는 흩어지고
        향기만 머뭅니다 +

        +

        + 향연은 향을 판매하기 전에 기록부터 남깁니다. 어느 해에 들인 나무인지, + 수지가 몇이었는지, 연소에 몇 분이 걸렸는지. 향은 사라지는 것이니 + 남는 것은 문서뿐입니다. +

        + +
        +
        + 밝은 한지 위 검은 석 받침대에서 한 줄기 침향 연기가 곧게 피어오르는 모습 +
        침향 한 줄기 · 삼청동 아카이브룸
        +
        +
        +
        + + +
        +
        +
        + 자연 침향 단면 — 짙은 수지 결과 하얀 목질이 함께 보이는 절편 +
        자연 침향 단면 · 수지와 목질
        +
        +
        +

        병든 나무만이
        이 향을 가집니다

        +

        + 침향은 나무의 향이 아니라 나무의 상처입니다. 상처 입은 나무가 스스로를 + 아무려기 위해 분비한 수지가 오랜 세월 굳은 것 — 그래서 심은 나무에서는 + 얻을 수 없고, 자연에서 쓰러진 나무에서만 길어 올립니다. +

        +

        + 향연은 인공접종 목재를 다루지 않습니다. 매입 단계에서 수지 함량을 + 측정해 기록하고, 그 기록이 통과하지 못한 나무는 아카이브에 들어오지 + 않습니다. +

        +
        +
        +
        + + +
        +
        +
        +

        숙성 장부

        +

        + 들여온 해와 수지 함량, 지금 상태입니다. 향은 재고가 아니라 연도입니다 — + 같은 나무가 다시 들어오지 않습니다. +

        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        + 숙성 장부 — 입목 연도, 산지, 수지 함량, 현재 상태 +
        입목산지수지 함량상태
        2001년베트남 카호아64.2%열매 · 매진 임박
        2012년인도네시아 리아우51.8%침향 절편 · 소량
        2024년캄보디아 프놈펜 인근47.3%숙성 중 · 비매품
        +
        +

        + 수지 함량: 에탄올 용출법 실측. 열매(초험)는 기증용으로만 남겨둡니다. +

        +
        +
        + + +
        +
        +

        지금 만질 수 있는 것

        +
          +
        1. +
          +

          자연 침향 절편

          +

          2001년 입목 · 수지 64.2% · 10g

          +
          +

          180,000원

          +
        2. +
        3. +
          +

          백단 침 향

          +

          인도 마이소르 노목 · 20g · 연소 45분

          +
          +

          42,000원

          +
        4. +
        5. +
          +

          침향 인엽

          +

          리아우산 · 2012년 입목 · 3g

          +
          +

          96,000원

          +
        6. +
        +

        + 모든 물건은 삼청동 아카이브룸에서 직접 내어드립니다. 향을 맡아 보고 + 정하시면 됩니다 — 예약제이며 회당 한 분만 들어갑니다. +

        +
        +
        + + +
        +
        +
        +

        삼청동, 예약제

        +
        + 서울 종로구 삼청로 00길 00
        + 경복궁역 5번 출구에서 도보 6분 +
        +
        +
        +
        +
        +
        예약
        +
        화–토 · 11:00, 14:00, 16:00 회당 50분
        +
        +
        +
        문의
        +
        archive@hyang.kr
        +
        +
        +
        +

        향연 享煙

        +
        +
        +
        + + + + diff --git a/apps/site/public/work/hyang-incense/styles/main.css b/apps/site/public/work/hyang-incense/styles/main.css new file mode 100644 index 0000000..6781e09 --- /dev/null +++ b/apps/site/public/work/hyang-incense/styles/main.css @@ -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); + } +} diff --git a/apps/site/public/work/hyang-incense/styles/tokens.css b/apps/site/public/work/hyang-incense/styles/tokens.css new file mode 100644 index 0000000..7b196a0 --- /dev/null +++ b/apps/site/public/work/hyang-incense/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/lumina-optics/assets/lens-section.webp b/apps/site/public/work/lumina-optics/assets/lens-section.webp new file mode 100644 index 0000000..0dfa82b Binary files /dev/null and b/apps/site/public/work/lumina-optics/assets/lens-section.webp differ diff --git a/apps/site/public/work/lumina-optics/assets/prism-bench.webp b/apps/site/public/work/lumina-optics/assets/prism-bench.webp new file mode 100644 index 0000000..85268b7 Binary files /dev/null and b/apps/site/public/work/lumina-optics/assets/prism-bench.webp differ diff --git a/apps/site/public/work/lumina-optics/index.html b/apps/site/public/work/lumina-optics/index.html new file mode 100644 index 0000000..9288a45 --- /dev/null +++ b/apps/site/public/work/lumina-optics/index.html @@ -0,0 +1,427 @@ + + + + + + Lumina Optics · Refraction Bench + + + + + + + + + + + + + + + + +
        + +
        + +
        + +
        +
        + + A glass prism on a dark optical breadboard splitting one collimated beam into a spectrum +
        +
        +
        +

        Refraction bench · WebGL

        +

        + One beam,
        bent honestly +

        +

        + 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. +

        +
        +
        +
        + + +
        +
        + + +
        +

        Drag the prism to rotate · beam is collimated at 632.8 nm reference

        +
        +
        +
        + + +
        +
        +
        + Cutaway of a precision lens: a stack of glass elements in a metal barrel, edge-lit so each surface shows a refraction line +
        L-85 cutaway · 7 elements in 6 groups
        +
        +
        +

        Glass decides
        before software does

        +

        + 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. +

        +

        + The cutaway above is a physical section, milled for measurement. + Element count and spacing are measured from it, not from the + drawing. +

        +
        +
        +
        + + +
        +
        +
        +

        MTF, not adjectives

        +

        + “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. +

        +
          +
        • 10 lp/mm · sagittal
        • +
        • 10 lp/mm · meridional
        • +
        • 30 lp/mm · sagittal
        • +
        • 30 lp/mm · meridional
        • +
        +
        +
        + + + + + + + + + + + + + 1.0 + 0.75 + 0.5 + 0.25 + center + image field (mm) + corner 21.6 + + + + + + + + + + +
        L-85 · f/1.8 · infinity focus · bench-measured 2026-07
        +
        +
        +
        + + +
        +
        +

        Specification

        +
        + + + + + + + + + + + + + + + + + +
        L-85 specification — item, value, condition
        ItemValueCondition
        Focal length85 mmnominal · ±1.5% at infinity
        Aperturef/1.8 – f/169-blade · near-circular
        Elements7 in 62 LD · measured from cutaway
        Close focus0.62 m1:7.2 magnification
        FilterØ 62 mmbrass, front-threaded
        Weight438 gwithout caps, with hood
        +
        +
        +
        + + +
        +
        +

        Bench visits

        +

        + We measure loaner samples for free and publish the curves — + manufacturers welcome or not. Bench time and MTF requests: +

        + bench@lumina.optics +
        +
        +
        + +
        +
        +

        lumina optics

        +

        measurements reproducible on request · bench cal. 2026-06

        +
        +
        + + + + diff --git a/apps/site/public/work/lumina-optics/styles/main.css b/apps/site/public/work/lumina-optics/styles/main.css new file mode 100644 index 0000000..0204a93 --- /dev/null +++ b/apps/site/public/work/lumina-optics/styles/main.css @@ -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; + } +} diff --git a/apps/site/public/work/lumina-optics/styles/tokens.css b/apps/site/public/work/lumina-optics/styles/tokens.css new file mode 100644 index 0000000..ec9e5e6 --- /dev/null +++ b/apps/site/public/work/lumina-optics/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/muwol-coffee/assets/beans-hero.webp b/apps/site/public/work/muwol-coffee/assets/beans-hero.webp new file mode 100644 index 0000000..96a3a57 Binary files /dev/null and b/apps/site/public/work/muwol-coffee/assets/beans-hero.webp differ diff --git a/apps/site/public/work/muwol-coffee/assets/pourover.webp b/apps/site/public/work/muwol-coffee/assets/pourover.webp new file mode 100644 index 0000000..141bfa6 Binary files /dev/null and b/apps/site/public/work/muwol-coffee/assets/pourover.webp differ diff --git a/apps/site/public/work/muwol-coffee/index.html b/apps/site/public/work/muwol-coffee/index.html new file mode 100644 index 0000000..21bda6a --- /dev/null +++ b/apps/site/public/work/muwol-coffee/index.html @@ -0,0 +1,333 @@ + + + + + + 무월 (霧月) · 싱글오리진 원두 정기구독 + + + + + + + + + + + + + + + + + + + +
        + +
        + +
        + +
        +
        +
        +

        싱글오리진 로스터리 · 서울 서촌

        +

        + 취향은 형용사가 아니라
        측정된 좌표입니다 +

        +

        + 과일 향이 난다는 말로는 추출 온도를 정할 수 없습니다. 무월은 원두마다 + 산미와 단맛, 바디를 같은 척도로 측정해 적고, 생두를 얼마에 들였는지까지 + 공개합니다. 마셔보기 전에 판단할 수 있어야 원두 고르는 즐거움이 시작됩니다. +

        +
        + 격주 구독 시작하기 +

        격주 화요일 로스팅 출고 · 언제든 해지

        +
        +
        + +
        + 따뜻한 회색 리넨 위 질도기 그릇에 담긴 갓 볶은 원두와 놋쇠 스쿱 +
        이번 주 첫 로스 · 8월 19일
        +
        + + +
        +
        +
        +
        에티오피아 구지 우라가
        +
        워시드 · 해발 2,150m · 커핑 88.75
        +
        +
        100g 8,500원
        +
        +
        +
        +
        콜롬비아 엘 파라이소
        +
        혐기발효 · 해발 1,930m · 커핑 91.20
        +
        +
        100g 11,000원
        +
        +
        +
        +
        + + +
        +
        +
        +

        같은 척도로 잰
        향미 좌표

        +

        + 같은 밝기로 볶아도 생두가 머금은 수분과 밀도에 따라 열은 다르게 + 들어갑니다. 그래서 무월은 색이 아니라 1차 팝핑 후 감률과 커핑 점수로 + 로트를 기록합니다. +

        +

        + 좌표는 로트마다 다시 측정해 옮겨 적습니다. 지난 로트의 좌표와 지금이 + 같다고 말하지 않습니다. +

        +
          +
        • 우라가 G1 워시드
        • +
        • 엘 파라이소 혐기발효
        • +
        • 엘 소코로 마라카투라
        • +
        +
        +
        + + + + + + + + + + + + + + + 산미 → + 바디 → + 10.0 + 0 + + + + + 우라가 8.8 + + 엘 파라이소 9.0 + + 엘 소코로 8.6 + + +
        8월 3주차 로트 실측 · 좌표는 주마다 다시 놓입니다
        +
        +
        +
        + + +
        +
        +
        +

        생두 매입 원장

        +

        + 농장에 돌아가는 돈이 얼마인지 보여주는 일이 스페셜티의 조건이라 + 생각합니다. 1kg당 매입가와 가공 방식까지 로트 단위로 남깁니다. +

        +
        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        + 원두별 생두 매입 원장 — 원두명, 생산자, 가공 방식, 매입가, 커핑 점수 +
        원두생산자가공매입가 (FOB/kg)커핑
        에티오피아 구지 우라가하르소 콜루 세척장워시드 · 72시간 발효$14.2088.75
        콜롬비아 엘 파라이소디에고 사무엘 베르무데스혐기발효 · 열충격 2회$26.5091.20
        과테말라 엘 소코로후안 디에고 데 라 세르다전량 워시드$16.8089.00
        +
        +

        + 운임·통관·관세는 포함하지 않은 현지 출하가이며, 환율은 매입일 기준입니다. +

        +
        +
        + + +
        +
        +
        + 고핫물이 필터 백에 담긴 도자기 드리퍼로 얇은 물줄기로 들어가는 중 +
        서촌 매장 · 오후 첫 잔
        +
        +
        +

        원두 18g, 물 288g

        +

        + 구독 패키지에는 원두 밀도에 맞춘 분쇄도와 주수 간격을 적은 카드 한 장이 + 들어갑니다. 드립의 변주는 그 다음 문제입니다. +

        +
        +
        +
        물 온도
        +
        93°C
        +
        +
        +
        분쇄도
        +
        코만단테 24딸
        +
        +
        +
        뜸 들이기
        +
        40초 · 물 40g
        +
        +
        +
        주수
        +
        3회 · 116g씩
        +
        +
        +

        + 추출 시간은 2분 30초에서 3분 사이가 기준입니다. 그보다 빨리 흘러내리면 + 곱게 갈고, 늦으면 굵게 갑니다. +

        +
        +
        +
        + + +
        +
        +
        +

        격주, 서로 다른 좌표의
        원두 두 종

        +

        + 2주마다 화요일 아침, 산미형 한 종과 밸런스형 한 종(각 100g)을 볶어 + 보냅니다. 원두는 시음 후 남는 양이 없어 다음 주에 같은 로트를 못 드릴 수 + 있습니다. +

        +
          +
        • 원두별 추출 카드 동봉 — 분쇄도·온도·주수
        • +
        • 아로마 밸브 틴케이스 첫 회 증정
        • +
        • 출장·휴가 주는 앱에서 건너뛰기
        • +
        +
        +
        +

        + 28,000원회당 · 배송비 포함 +

        + 격주 구독 시작하기 +

        200g(100g×2) · 첫 회는 다음 화요일에 출고됩니다

        +
        +
        +
        +
        + +
        +
        +

        무월 로스터리 · 서울 종로구 옥인길 18

        +

        화요일 로스팅 · 목요일부터 토요일까지 문을 엽니다

        +

        roastery@muwol.kr

        +
        +
        + + + + diff --git a/apps/site/public/work/muwol-coffee/styles/main.css b/apps/site/public/work/muwol-coffee/styles/main.css new file mode 100644 index 0000000..8b85a96 --- /dev/null +++ b/apps/site/public/work/muwol-coffee/styles/main.css @@ -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); + } +} diff --git a/apps/site/public/work/muwol-coffee/styles/tokens.css b/apps/site/public/work/muwol-coffee/styles/tokens.css new file mode 100644 index 0000000..c283654 --- /dev/null +++ b/apps/site/public/work/muwol-coffee/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/orbit-aerospace/assets/channel-macro.webp b/apps/site/public/work/orbit-aerospace/assets/channel-macro.webp new file mode 100644 index 0000000..90e9502 Binary files /dev/null and b/apps/site/public/work/orbit-aerospace/assets/channel-macro.webp differ diff --git a/apps/site/public/work/orbit-aerospace/assets/static-fire.webp b/apps/site/public/work/orbit-aerospace/assets/static-fire.webp new file mode 100644 index 0000000..ccebcd0 Binary files /dev/null and b/apps/site/public/work/orbit-aerospace/assets/static-fire.webp differ diff --git a/apps/site/public/work/orbit-aerospace/index.html b/apps/site/public/work/orbit-aerospace/index.html new file mode 100644 index 0000000..665def6 --- /dev/null +++ b/apps/site/public/work/orbit-aerospace/index.html @@ -0,0 +1,322 @@ + + + + + + 궤도 우주공학 · 75kN 메탄 엔진 사양서 + + + + + + + + + + + + + + + + + + +
        + +
        + +
        + +
        +
        +
        +

        75kN 메탄-액체산소 · 재생냉각 · 2024–

        +

        + 도면은 말하지 않는다.
        측정만 한다 +

        +

        + 이 페이지의 모든 수치는 지상 연소 시험에서 잰 값입니다. 홍보 문구에 + 쓸 수 없는 숫자는 사양서에도 넣지 않습니다. 추력, 비추력, 연소실 + 압력 — 어디서, 몇 초 동안 재었는지가 숫자와 함께 갑니다. +

        + 사양 전문 보기 +
        + +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + 인젝터 + + 연소실 + + 스로트 + + 노즐 + + + 재생냉각 + 유로 (CH₄) + + + + + + + + Ø 460 — 노즐 출구 + + +
        ORBIT-75 단면 · 재생냉각 벽면은 그림 2겹으로 단순화
        +
        +
        +
        + + +
        +
        +

        사양

        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        ORBIT-75 엔진 사양 — 항목, 값, 측정 조건
        항목측정 조건
        추력 (해수면)75.4 kN지상 시험 스탠드 · 정상 상태 30초 평균
        비추력 Isp (진공)325.8 s고도 모사 노즐 · 추진제 혼합비 3.4
        연소실 압력12.5 MPa피에조 계측 · 샘플링 1kHz
        사이클다단연소연료리치 프리버너 1기
        냉각재생냉각구리합금 채널 180개 · CH₄ 유량 92%
        시동 신호→연소2.8 sTEA-TEB 점화 · 12회 평균
        +
        +

        + 모든 값은 나주 지상 시험장, 2026년 상반기 12회 시험의 평균입니다. + 단일 최고값을 사양으로 적지 않습니다. +

        +
        +
        + + +
        +
        +
        +

        지상 연소 시험 기록

        +

        + 엔진은 도면으로 끝나지 않습니다. 우리는 보류된 시험도 지운 적이 + 없습니다 — 기록에서 실패가 사라지면 사양서는 광고가 됩니다. +

        +
        +
        + + + + + + + + + + + + + + + + + +
        지상 연소 시험 기록 — 번호, 날짜, 지속, 결과
        시험날짜지속결과
        SF-312026-07-1862 s완료 · 노즐 온도 상한 내
        SF-302026-06-2731 s보류 · 프리버너 시동 지연
        SF-292026-06-0260 s완료 · 추력 편차 0.4%
        SF-282026-05-1112 s보류 · 냉각유로 압력 강하
        SF-272026-04-2058 s완료
        +
        +
        +
        + + +
        +
        + 야간 지상 연소 시험 — 구리 노즐이 열로 붉게 빛나고 배기 기둥에 마하 다이아몬드가 서 있다 +
        SF-29 · 60초 연소 · 나주 시험장 3번 스탠드
        +
        +
        + + +
        +
        +
        + 구리합금 블록의 단면에 밀링된 가느다란 재생냉각 유로들 +
        연소실 유로 접사 · 밀링 후 청소 전
        +
        +
        +

        추진제가 벽을
        먼저 식힙니다

        +

        + 연소실 벽은 3,200°C의 가스를 견디는 게 아닙니다 — 견디게 두지 + 않습니다. 메탄 연료가 점화 전에 벽 속 180개 유로를 지나가며 실온에서 + 320°C까지 데워지고, 그 사이 벽은 900°C 아래로 머뭅니다. 냉각에 쓴 + 추진제는 그대로 연소실로 들어가니 버리는 열이 없습니다. +

        +

        + 유로 하나의 폭은 0.9mm입니다. 이 숫자가 이 엔진의 수명을 결정합니다 — + 도면에서 가장 작은 치수가 가장 중요한 치수입니다. +

        +
        +
        +
        + + +
        +
        +

        문의

        +

        + 발사체 통합·위성 투입 협의, 그리고 엔진 시험 데이터 전문은 아래로 + 보내주세요. 기술 자료 요청에는 소속과 용도를 적어주세요. +

        + engine@orbit.kr +
        +
        +
        + +
        +
        +

        궤도 우주공학 · 나주 시험장 / 서울 사무실

        +

        ORBIT AEROSPACE · EST. 2020

        +
        +
        + + + + diff --git a/apps/site/public/work/orbit-aerospace/styles/main.css b/apps/site/public/work/orbit-aerospace/styles/main.css new file mode 100644 index 0000000..9fa1519 --- /dev/null +++ b/apps/site/public/work/orbit-aerospace/styles/main.css @@ -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; + } +} diff --git a/apps/site/public/work/orbit-aerospace/styles/tokens.css b/apps/site/public/work/orbit-aerospace/styles/tokens.css new file mode 100644 index 0000000..8bdb038 --- /dev/null +++ b/apps/site/public/work/orbit-aerospace/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/overtone-sound/assets/installation.webp b/apps/site/public/work/overtone-sound/assets/installation.webp new file mode 100644 index 0000000..b29462b Binary files /dev/null and b/apps/site/public/work/overtone-sound/assets/installation.webp differ diff --git a/apps/site/public/work/overtone-sound/index.html b/apps/site/public/work/overtone-sound/index.html new file mode 100644 index 0000000..7ec364d --- /dev/null +++ b/apps/site/public/work/overtone-sound/index.html @@ -0,0 +1,257 @@ + + + + + + 오버톤 사운드랩 · 공간 음향 연구소 + + + + + + + + + + + + + + + +
        + +
        + +
        + +
        + +
        +

        + 공간 음향 · DSP · 서울 + 48kHz / 2ch +

        +

        + 침묵과 폭발의
        주파수 +

        +

        + 오버톤은 소리를 만들지 않고, 소리가 공간에서 어떻게 행동하는지를 + 연구합니다. 방금 지나간 곡선은 이 연구소를 지나가는 소리의 자취입니다. +

        +
        +
        + + +
        +
        +

        작업

        +
          +
        1. +

          2026

          +

          공명하는 방 — DDP 설치

          +

          + 종이 콘 스피커 48개가 천장에서 내려와 방 안의 소리를 0.8초 늦게 + 되돌려주는 설치. 관람객의 발소리가 악기가 된다. +

          +

          48ch · 공간지연 · 상설

          +
        2. +
        3. +

          2025

          +

          무(無)의 스펙트럼

          +

          + 아무 소리도 없는 방의 바닥 소음(−62dB)을 증폭해 들려주는 + 퍼포먼스. 침묵에는 평균 진폭이 있다. +

          +

          live · −62dB floor

          +
        4. +
        5. +

          2024

          +

          파도의 인버전 — 제주

          +

          + 바람과 파도의 실측 데이터를 반대 위상으로 합성해 바다가 '삼키는' + 소리를 만드는 해안 설치. +

          +

          field data · 6h loop

          +
        6. +
        7. +

          2023

          +

          유리 진동자

          +

          + 잔 12개에 각기 다른 공진 주파수를 실어 연주하는 설비. 연주자는 + 물의 높이를 조율한다. +

          +

          glass · 220–940Hz

          +
        8. +
        +
        +
        + + +
        +
        +

        선언

        +

        + 좋은 음향은 들리지 않는다.
        + 방이 좋아지는 것뿐이다. +

        +

        + 우리는 음파를 시각화로 번역하지 않습니다. 시각화는 소리의 크기만 + 흉내 낼 뿐, 소리의 '방의 크기'를 옮기지 못합니다. 대신 우리는 방을 + 조율합니다 — 재생 목록이 아니라 잔향시간을 설계합니다. +

        +
        +
        + + +
        +
        + 국립현대미술관DDP서울시립교향악단아르코예술극장제주국제음악제국립현대미술관DDP서울시립교향악단아르코예술극장제주국제음악제 +
        +
        + + +
        +
        + 어두운 전시실에 종이콘 스피커들이 늘어서 있고 앰버색 빛줄기가 한 줄 지나간다 +
        공명하는 방 · 설치 뷰 · 2026
        +
        +
        + + +
        +
        +

        연락

        +

        + 설치·공연·연구 협업 문의는 소리의 조건(공간 크기, 재생 시간, 청중 수)과 + 함께 보내주세요. +

        + lab@overtone.kr +
        +
        +
        + +
        +
        +

        오버톤 사운드랩 · 서울 마포

        +

        © 2019–2026 · OVERTONE SOUND LAB

        +
        +
        + + + + diff --git a/apps/site/public/work/overtone-sound/styles/main.css b/apps/site/public/work/overtone-sound/styles/main.css new file mode 100644 index 0000000..b4e89b2 --- /dev/null +++ b/apps/site/public/work/overtone-sound/styles/main.css @@ -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; + } +} diff --git a/apps/site/public/work/overtone-sound/styles/tokens.css b/apps/site/public/work/overtone-sound/styles/tokens.css new file mode 100644 index 0000000..86e5965 --- /dev/null +++ b/apps/site/public/work/overtone-sound/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/pulsegate/index.html b/apps/site/public/work/pulsegate/index.html new file mode 100644 index 0000000..54e5a48 --- /dev/null +++ b/apps/site/public/work/pulsegate/index.html @@ -0,0 +1,317 @@ + + + + + + Pulsegate · Edge Latency & Synthetic Probe Engine + + + + + + + + + + + + + + + + +
        + +
        + +
        + +
        +
        +
        +

        + A probe is watching
        within 90 seconds +

        +

        + 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. +

        + +
        + + curl -sL https://pulsegate.io/init | sh +
        +

        + 90 seconds: measured from init to first assertion on a + cold account, 2026-08-20. +

        +
        + + +
        +
        + EDGE LATENCY · P99 · LAST 24H + 10s interval +
        + + + + + + + + + + + + + + + + + + + + + + + +
        +
        ICN-01 · Seoul14.2 ms
        +
        NRT-02 · Tokyo28.6 ms
        +
        SIN-01 · Singapore34.0 ms
        +
        SJC-04 · San Jose88.4 ms
        +
        LHR-02 · London126.8 ms
        +
        FRA-01 · Frankfurt142.1 ms
        +
        +
        +
        +
        + + +
        +
        +

        How it is wired

        +

        + 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. +

        +
        + +
        + + + + + + + + + ICN · NRT · SIN … + FRA · LHR · AMS … + SJC · IAD · ORD … + + + + + assertion engine + http · grpc · tcp · dns + + + + + + Prometheus + OpenTelemetry + + + + every 10 s + p50 · p99 · jitter + + +
        + +
        +
        +

        Bare-metal, not lambdas

        +

        + Probes live in tier-1 transit facilities. The RTT you see is the + RTT your users get. +

        +
        +
        +

        Assertions, not pings

        +

        + JSONPath body checks, gRPC status codes, TLS certificate expiry — + a check fails when your product fails, not when a packet does. +

        +
        +
        +

        Your telemetry, your tools

        +

        + Export to the Prometheus and OpenTelemetry you already run. No + dashboard to live in, no lock-in to migrate off. +

        +
        +
        +
        + + +
        +
        +

        One frame, all 28 POPs

        +

        + A single assertion — GET /health against a Seoul origin — + fanned out to every POP at the same instant. +

        +
        + + + + + + + + + + + + + + + + + + + + + +
        + Per-POP readings for one assertion: DNS, TLS, TTFB and p99 over 24 hours +
        POPCityDNSTLSTTFBp99 / 24h
        ICN-01Seoul1.86.414.215.9
        NRT-02Tokyo2.17.128.631.2
        SIN-01Singapore2.48.234.037.5
        SJC-04San Jose3.09.688.494.7
        LHR-02London4.011.2126.8133.0
        FRA-01Frankfurt4.211.8142.1151.8
        …22 more POPs in the console — same frame, same instant
        +
        +

        + 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. +

        +
        +
        + + +
        +
        +
        +

        Priced per probe,
        not per seat

        +

        + A probe costs the same whether three engineers or thirty look at + the console. Downgrade, pause, or delete from the CLI. +

        +
        +
        +
        +
        +

        Free

        +

        3 probes · 60 s interval · 7-day retention

        +
        +
        + $0 + Create account +
        +
        +
        +
        +

        Pro

        +

        Unlimited probes · 10 s interval · 13-month retention · OTel export

        +
        +
        + $0.40 per probe-month + Start free, upgrade inside +
        +
        +
        +
        +
        +
        + +
        +
        +

        pulsegate — distributed edge telemetry

        +

        status: 28/28 POPs reporting · exporter protocol v2.4

        +
        +
        + + + + diff --git a/apps/site/public/work/pulsegate/styles/main.css b/apps/site/public/work/pulsegate/styles/main.css new file mode 100644 index 0000000..e972ff1 --- /dev/null +++ b/apps/site/public/work/pulsegate/styles/main.css @@ -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; + } +} diff --git a/apps/site/public/work/pulsegate/styles/tokens.css b/apps/site/public/work/pulsegate/styles/tokens.css new file mode 100644 index 0000000..04500ac --- /dev/null +++ b/apps/site/public/work/pulsegate/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/synapse-bci/assets/chip-macro.webp b/apps/site/public/work/synapse-bci/assets/chip-macro.webp new file mode 100644 index 0000000..a8e216f Binary files /dev/null and b/apps/site/public/work/synapse-bci/assets/chip-macro.webp differ diff --git a/apps/site/public/work/synapse-bci/assets/thread-array.webp b/apps/site/public/work/synapse-bci/assets/thread-array.webp new file mode 100644 index 0000000..8e141ba Binary files /dev/null and b/apps/site/public/work/synapse-bci/assets/thread-array.webp differ diff --git a/apps/site/public/work/synapse-bci/index.html b/apps/site/public/work/synapse-bci/index.html new file mode 100644 index 0000000..c14831c --- /dev/null +++ b/apps/site/public/work/synapse-bci/index.html @@ -0,0 +1,331 @@ + + + + + + Synapse BCI Lab · 64-Channel Neural Interface Engine + + + + + + + + + + + + + + + + +
        + +
        + +
        + +
        +
        +
        +

        64-CHANNEL μECoG · 30 kS/s · 1.82 ms

        +

        + Intent, decoded
        before the hand moves +

        +

        + 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. +

        + +
        + +
        +
        + SPIKE RASTER · 64 CH · SIMULATED + 2.0 s window +
        + +
        + + each tick — one detected spike · bursts are channel-sync events +
        +
        +
        +
        + + +
        +
        +
        + A black silicon neural interface die with dense gold traces and a micro-electrode grid at one edge +
        SYN-64 die · 4.2 × 4.2 mm · gold on black silicon
        +
        +
        +

        Thin enough
        to be forgotten

        +

        + 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. +

        +

        + The die photographs are of the actual implant, not renders. What + you see is what a surgeon holds. +

        +
        +
        +
        + + +
        +
        +

        Numbers, with their conditions

        +
        + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        + Measured performance — metric, value, measurement condition +
        MetricValueCondition
        Decode latency1.82 msp50 · on-device inference · 12 participants
        Raw sampling30 kS/sper channel · 24-bit ΔΣ
        SNR18.4 dBreferenced to skull electrode · chronic week 26
        Gesture classes11offline, cross-validated · 94.1% ± 1.8
        Wireless linktranscutaneous power + 2.4 GHz telemetry
        +
        +

        + 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. +

        +
        +
        + + +
        +
        + Dozens of fine gold-tipped microelectrode threads fanning out across a dark stage under a microscope +
        Electrode fan-out · microscope stage · no color grading
        +
        +
        + + +
        +
        +

        What we will not do

        +
          +
        • +

          No neural advertising

          +

          + Decoded signals are commands, not content. We do not build — or + license — emotion or attention inference. +

          +
        • +
        • +

          Participants can leave

          +

          + Explantation on request within 30 days, at our cost, at any + point in the trial. Three participants have exercised it. +

          +
        • +
        • +

          Raw data stays in the vault

          +

          + Raw neural data never leaves our encrypted cluster. Published + figures are aggregate statistics, as on this page. +

          +
        • +
        +
        +
        + + +
        +
        +

        Research contact

        +

        + Trial enrollment is closed. Collaboration and data-access inquiries + from accredited institutions: +

        + lab@synapse.bci +
        +
        +
        + +
        +
        +

        synapse bci lab

        +

        IRB-2025-0412 · results are preliminary until peer review

        +
        +
        + + + + diff --git a/apps/site/public/work/synapse-bci/styles/main.css b/apps/site/public/work/synapse-bci/styles/main.css new file mode 100644 index 0000000..f4c53b7 --- /dev/null +++ b/apps/site/public/work/synapse-bci/styles/main.css @@ -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; + } +} diff --git a/apps/site/public/work/synapse-bci/styles/tokens.css b/apps/site/public/work/synapse-bci/styles/tokens.css new file mode 100644 index 0000000..cc19263 --- /dev/null +++ b/apps/site/public/work/synapse-bci/styles/tokens.css @@ -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); +} diff --git a/apps/site/public/work/thursday-flowers/index.html b/apps/site/public/work/thursday-flowers/index.html index ca54211..43c56e3 100644 --- a/apps/site/public/work/thursday-flowers/index.html +++ b/apps/site/public/work/thursday-flowers/index.html @@ -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" /> - + diff --git a/apps/site/public/work/thursday-flowers/styles/main.css b/apps/site/public/work/thursday-flowers/styles/main.css index cc9a178..dfed764 100644 --- a/apps/site/public/work/thursday-flowers/styles/main.css +++ b/apps/site/public/work/thursday-flowers/styles/main.css @@ -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); diff --git a/apps/site/src/assets/shots/work-baekje-celadon-2.png b/apps/site/src/assets/shots/work-baekje-celadon-2.png new file mode 100644 index 0000000..e199b08 Binary files /dev/null and b/apps/site/src/assets/shots/work-baekje-celadon-2.png differ diff --git a/apps/site/src/assets/shots/work-baekje-celadon.png b/apps/site/src/assets/shots/work-baekje-celadon.png new file mode 100644 index 0000000..e199b08 Binary files /dev/null and b/apps/site/src/assets/shots/work-baekje-celadon.png differ diff --git a/apps/site/src/assets/shots/work-dure-enrollment-2.png b/apps/site/src/assets/shots/work-dure-enrollment-2.png new file mode 100644 index 0000000..aef6ba3 Binary files /dev/null and b/apps/site/src/assets/shots/work-dure-enrollment-2.png differ diff --git a/apps/site/src/assets/shots/work-dure-enrollment.png b/apps/site/src/assets/shots/work-dure-enrollment.png new file mode 100644 index 0000000..535cf50 Binary files /dev/null and b/apps/site/src/assets/shots/work-dure-enrollment.png differ diff --git a/apps/site/src/assets/shots/work-font-foundry-2.png b/apps/site/src/assets/shots/work-font-foundry-2.png new file mode 100644 index 0000000..2c2f18e Binary files /dev/null and b/apps/site/src/assets/shots/work-font-foundry-2.png differ diff --git a/apps/site/src/assets/shots/work-font-foundry.png b/apps/site/src/assets/shots/work-font-foundry.png new file mode 100644 index 0000000..5f66817 Binary files /dev/null and b/apps/site/src/assets/shots/work-font-foundry.png differ diff --git a/apps/site/src/assets/shots/work-form-architecture-2.png b/apps/site/src/assets/shots/work-form-architecture-2.png new file mode 100644 index 0000000..2c41eab Binary files /dev/null and b/apps/site/src/assets/shots/work-form-architecture-2.png differ diff --git a/apps/site/src/assets/shots/work-form-architecture.png b/apps/site/src/assets/shots/work-form-architecture.png new file mode 100644 index 0000000..5153def Binary files /dev/null and b/apps/site/src/assets/shots/work-form-architecture.png differ diff --git a/apps/site/src/assets/shots/work-gaon-lms-2.png b/apps/site/src/assets/shots/work-gaon-lms-2.png new file mode 100644 index 0000000..ea85c49 Binary files /dev/null and b/apps/site/src/assets/shots/work-gaon-lms-2.png differ diff --git a/apps/site/src/assets/shots/work-gaon-lms.png b/apps/site/src/assets/shots/work-gaon-lms.png new file mode 100644 index 0000000..89272c8 Binary files /dev/null and b/apps/site/src/assets/shots/work-gaon-lms.png differ diff --git a/apps/site/src/assets/shots/work-hyang-incense-2.png b/apps/site/src/assets/shots/work-hyang-incense-2.png new file mode 100644 index 0000000..44d32c1 Binary files /dev/null and b/apps/site/src/assets/shots/work-hyang-incense-2.png differ diff --git a/apps/site/src/assets/shots/work-hyang-incense.png b/apps/site/src/assets/shots/work-hyang-incense.png new file mode 100644 index 0000000..166c29b Binary files /dev/null and b/apps/site/src/assets/shots/work-hyang-incense.png differ diff --git a/apps/site/src/assets/shots/work-lumina-optics-2.png b/apps/site/src/assets/shots/work-lumina-optics-2.png new file mode 100644 index 0000000..1283e15 Binary files /dev/null and b/apps/site/src/assets/shots/work-lumina-optics-2.png differ diff --git a/apps/site/src/assets/shots/work-lumina-optics.png b/apps/site/src/assets/shots/work-lumina-optics.png new file mode 100644 index 0000000..9a75fd3 Binary files /dev/null and b/apps/site/src/assets/shots/work-lumina-optics.png differ diff --git a/apps/site/src/assets/shots/work-muwol-coffee-2.png b/apps/site/src/assets/shots/work-muwol-coffee-2.png new file mode 100644 index 0000000..b0a9488 Binary files /dev/null and b/apps/site/src/assets/shots/work-muwol-coffee-2.png differ diff --git a/apps/site/src/assets/shots/work-muwol-coffee.png b/apps/site/src/assets/shots/work-muwol-coffee.png new file mode 100644 index 0000000..b0a9488 Binary files /dev/null and b/apps/site/src/assets/shots/work-muwol-coffee.png differ diff --git a/apps/site/src/assets/shots/work-orbit-aerospace-2.png b/apps/site/src/assets/shots/work-orbit-aerospace-2.png new file mode 100644 index 0000000..c6b89c8 Binary files /dev/null and b/apps/site/src/assets/shots/work-orbit-aerospace-2.png differ diff --git a/apps/site/src/assets/shots/work-orbit-aerospace.png b/apps/site/src/assets/shots/work-orbit-aerospace.png new file mode 100644 index 0000000..ea0ac62 Binary files /dev/null and b/apps/site/src/assets/shots/work-orbit-aerospace.png differ diff --git a/apps/site/src/assets/shots/work-overtone-sound-2.png b/apps/site/src/assets/shots/work-overtone-sound-2.png new file mode 100644 index 0000000..2ad7717 Binary files /dev/null and b/apps/site/src/assets/shots/work-overtone-sound-2.png differ diff --git a/apps/site/src/assets/shots/work-overtone-sound.png b/apps/site/src/assets/shots/work-overtone-sound.png new file mode 100644 index 0000000..89b6b2f Binary files /dev/null and b/apps/site/src/assets/shots/work-overtone-sound.png differ diff --git a/apps/site/src/assets/shots/work-pulsegate-2.png b/apps/site/src/assets/shots/work-pulsegate-2.png new file mode 100644 index 0000000..53101ac Binary files /dev/null and b/apps/site/src/assets/shots/work-pulsegate-2.png differ diff --git a/apps/site/src/assets/shots/work-pulsegate.png b/apps/site/src/assets/shots/work-pulsegate.png new file mode 100644 index 0000000..ec69def Binary files /dev/null and b/apps/site/src/assets/shots/work-pulsegate.png differ diff --git a/apps/site/src/assets/shots/work-synapse-bci-2.png b/apps/site/src/assets/shots/work-synapse-bci-2.png new file mode 100644 index 0000000..4da5459 Binary files /dev/null and b/apps/site/src/assets/shots/work-synapse-bci-2.png differ diff --git a/apps/site/src/assets/shots/work-synapse-bci.png b/apps/site/src/assets/shots/work-synapse-bci.png new file mode 100644 index 0000000..4da5459 Binary files /dev/null and b/apps/site/src/assets/shots/work-synapse-bci.png differ diff --git a/apps/site/src/assets/shots/work-thursday-flowers-2.png b/apps/site/src/assets/shots/work-thursday-flowers-2.png index 1d98778..a724988 100644 Binary files a/apps/site/src/assets/shots/work-thursday-flowers-2.png and b/apps/site/src/assets/shots/work-thursday-flowers-2.png differ diff --git a/apps/site/src/assets/shots/work-thursday-flowers.png b/apps/site/src/assets/shots/work-thursday-flowers.png index a8a7d15..87471cc 100644 Binary files a/apps/site/src/assets/shots/work-thursday-flowers.png and b/apps/site/src/assets/shots/work-thursday-flowers.png differ diff --git a/apps/site/src/components/Hero.astro b/apps/site/src/components/Hero.astro index 4acf2ca..4e22573 100644 --- a/apps/site/src/components/Hero.astro +++ b/apps/site/src/components/Hero.astro @@ -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; } diff --git a/apps/site/src/components/Install.astro b/apps/site/src/components/Install.astro index 4ec0b1d..6722408 100644 --- a/apps/site/src/components/Install.astro +++ b/apps/site/src/components/Install.astro @@ -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); } } diff --git a/apps/site/src/components/Showcase.astro b/apps/site/src/components/Showcase.astro deleted file mode 100644 index 1d830c3..0000000 --- a/apps/site/src/components/Showcase.astro +++ /dev/null @@ -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축 캐스케이드. 두 판이 서로 겹치고 각도가 다르다 */} -
        - -

        {c.h2}

        -

        {c.lead}

        - -
        - { - c.items.map((item, i) => ( -
        -
        - {item.alt} -
        -
        - {item.caption} - {item.note} -
        -
        - )) - } -
        -
        - - diff --git a/apps/site/src/data/work.ts b/apps/site/src/data/work.ts index c3d5032..464b9af 100644 --- a/apps/site/src/data/work.ts +++ b/apps/site/src/data/work.ts @@ -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", + }, ]; diff --git a/apps/site/src/i18n/content.ts b/apps/site/src/i18n/content.ts index 740e325..d95244a 100644 --- a/apps/site/src/i18n/content.ts +++ b/apps/site/src/i18n/content.ts @@ -1,28 +1,28 @@ -/** - * 두 언어판은 번역이 아니라 각각 따로 쓴 것이다. - * 특히 조판 섹션은 한국어판에서 주장이고 영어판에서 사례다. - * - * 규칙: 이 파일에 em-dash 와 en-dash 를 쓰지 않는다. - * 하이픈, 마침표, 콜론, 괄호로 대신한다. - */ +/* + 사이트 전체 문안. 레이아웃이 아니라 이 파일만 고치면 두 언어판이 함께 갱신된다. -export interface Stage { + 문장 규칙(2026-08-21 카피 전면 재작성 때 정한 것): + - 본문은 존댓말로 통일한다. 제목·헤드라인은 명사구 또는 단형 종결. + - 근거 없는 수치를 넣지 않는다. 재지 않은 값은 "미측정"으로 남긴다. + - "—" 를 문장 부호로 쓰지 않는다. 쉼표·마침표로 끊는다. + - 자기 검증 이야기(프리플라이트)는 사실 그대로. 포장하지 않고, 고친 것만 적는다. +*/ + +interface Stage { name: string; does: string; - /** 같은 브리프가 이 단계를 지나며 무엇으로 변하는가. 번호 대신 이 변화가 순서를 만든다 */ becomes: string; mono?: boolean; } -export interface Metric { +interface Metric { label: string; value: string; note: string; - /** 벤토에서 크게 잡을 항목 */ feature?: boolean; } -export interface Rule { +interface Rule { rule: string; why: string; } @@ -50,11 +50,6 @@ export interface Content { brief: string; stages: Stage[]; }; - showcase: { - h2: string; - lead: string; - items: { alt: string; caption: string; note: string }[]; - }; materials: { eyebrow: string; h2: string; @@ -108,34 +103,34 @@ export interface Content { const STAGES_KO: Stage[] = [ { name: "브리프 게이트", - does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축합니다. 규모를 판정해 경로를 고릅니다.", + does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축합니다. 규모를 판정해 작업 경로를 고릅니다.", becomes: "누구에게: 홈카페 3년차 이상. 산미와 로스팅 프로파일을 구분합니다", }, { name: "레퍼런스 조사", - does: "실제로 존재하는 사이트 3개를 서로 다른 층위에서 고릅니다. 구조, 톤, 디테일.", + does: "실제로 존재하는 사이트 세 곳을 서로 다른 층위에서 고릅니다. 구조, 톤, 디테일.", becomes: "R2 = 독립 문학 출판사. 커피 업계 밖에서 톤을 가져옵니다", }, { name: "방향 결정", - does: "레퍼런스를 하나의 축으로 합성합니다. 감수할 리스크를 하나 고릅니다.", + does: "레퍼런스를 하나의 방향으로 합성하고, 감수할 리스크를 하나 고릅니다.", becomes: "감상은 이름에만, 화면에는 수치만", }, { name: "디자인 토큰", - does: "코드를 쓰기 전에 숫자를 정합니다. 성능 예산도 여기서 확정합니다.", + does: "코드를 쓰기 전에 숫자를 정합니다. 성능 예산도 이 단계에서 확정합니다.", becomes: "--measure: 33em / --leading-normal: 1.7 / JS 예산 0KB", mono: true, }, { name: "구현", - does: "레이아웃, 재질, 입체, 모션 순서로. 이펙트를 다 꺼도 완성돼 있어야 합니다.", + does: "레이아웃, 재질, 입체, 모션 순서로 만듭니다. 이펙트를 모두 꺼도 완성돼 있어야 합니다.", becomes: "

        산미 8.5 · 바디 6.0 · 로스팅 라이트

        ", mono: true, }, { name: "프리플라이트", - does: "자기 결과물을 남의 것처럼 봅니다. 게이트 12개는 전부 '아니오'여야 합니다.", + does: "자기 결과물을 남의 것처럼 검사합니다. 하드 게이트 12개는 모두 '아니오'여야 합니다.", becomes: "게이트 12/12 통과 · 슬롭 지문 0건 · 320px 오버플로 31px 수정", mono: true, }, @@ -165,7 +160,7 @@ const STAGES_EN: Stage[] = [ }, { name: "Build", - does: "Layout, surface, depth, motion. It must be finished with every effect switched off.", + does: "Layout, surface, depth, motion, in that order. It must be finished with every effect switched off.", becomes: "

        Acidity 8.5 · Body 6.0 · Light roast

        ", mono: true, }, @@ -181,17 +176,21 @@ export const ko: Content = { meta: { title: "designpaca · 웹 디자인 파이프라인 스킬", description: - "AI 코딩 에이전트에게 웹 디자인을 순서대로 하게 만드는 스킬. 레퍼런스 조사부터 셀프 감사까지 여섯 단계. Claude Code, Codex, Cursor, Windsurf 에 한 줄로 설치합니다.", + "AI 코딩 에이전트가 웹 디자인을 순서대로 하게 만드는 스킬입니다. 레퍼런스 조사부터 셀프 감사까지 여섯 단계를 통과시켜, 템플릿 같은 페이지가 나올 자리에 근거 있는 디자인이 나오게 합니다. Claude Code, Codex, Cursor 등에 한 줄로 설치합니다.", }, hero: { h1: "결정하지 않으면 기본값이 나온다", - lead: "AI가 만든 티는 못 만들어서가 아니라 결정을 안 해서 납니다. 그 결정을 여섯 단계로 강제하는 스킬.", + lead: + "AI가 뽑은 페이지가 템플릿처럼 보이는 건 능력이 부족해서가 아니라 결정이 없어서입니다. designpaca는 레퍼런스 조사부터 셀프 감사까지 여섯 단계를 순서대로 통과시키는 웹 디자인 파이프라인 스킬입니다. 단계마다 통과 조건이 있어서, 기본값이 들어갈 자리에 근거가 들어갑니다.", install: "npx designpaca", - installNote: "Node 20.11+ · 설치 대상을 감지해 골라줍니다", - links: [{ label: "npm", href: "https://www.npmjs.com/package/designpaca" }], + installNote: "Node 20.11 이상 · 설치 대상은 자동으로 찾아 선택합니다", + links: [ + { label: "사례 보기", href: "/showcase" }, + { label: "npm", href: "https://www.npmjs.com/package/designpaca" }, + ], stage: { alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있습니다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러납니다.", - caption: "판 하나가 단계 하나다. 통과 조건을 채우지 못하면 다음 판으로 가지 않습니다.", + caption: "판 하나가 단계 하나입니다. 통과 조건을 채우지 못하면 다음 판으로 넘어가지 않습니다.", gate: "게이트", pass: "통과", }, @@ -200,38 +199,22 @@ export const ko: Content = { eyebrow: "여섯 단계", h2: "브리프 한 줄이 검증된 페이지가 될 때까지", lead: - "각 단계에는 통과 조건이 있습니다. 통과하지 못하면 다음으로 가지 않습니다. 아래는 같은 브리프가 단계마다 무엇으로 변했는지다.", + "모든 단계에는 통과 조건이 있습니다. 통과하지 못하면 다음 단계로 넘어가지 않습니다. 아래는 같은 브리프가 여섯 단계를 지나며 무엇으로 바뀌는지입니다.", briefLabel: "들어온 브리프", brief: "수제 커피 로스터리의 원두 정기구독 랜딩 만들어줘", stages: STAGES_KO, }, - showcase: { - h2: "결과물은 서로 닮지 않는다", - lead: - "같은 파이프라인을 두 브리프에 돌린 결과다. 1단계에서 톤을 다른 업종에서 가져오게 강제하기 때문에 같은 도구를 써도 같은 화면이 나오지 않습니다. 둘 다 수치 자리가 비어 있는 것도 우연이 아닙니다.", - items: [ - { - alt: "무월 원두 구독 랜딩. 오프화이트 배경에 굵은 한글 헤드라인, 오른쪽에 원두 3종의 산미와 바디 표가 있습니다.", - caption: "수제 커피 로스터리 · 구독 전환", - note: "프리셋 quiet-commerce. 사진이 없습니다는 제약을 수치표로 뒤집었습니다. 표의 값은 아직 비어 있습니다. 로스터에게 받기 전까지 채우지 않습니다.", - }, - { - alt: "Pulsegate 모니터링 SaaS 히어로. 밝은 배경에 큰 세리프 헤드라인과 무료 체험 버튼이 있습니다.", - caption: "API 모니터링 SaaS · 무료 체험 가입", - note: "개발자 도구라고 자동으로 다크로 가지 않았습니다. 브리프에 근거가 없었기 때문입니다. 리전 수는 아직 중괄호 자리로 남아 있습니다.", - }, - ], - }, materials: { eyebrow: "SVG 필터", h2: "이미지 편집기를 열지 않고 고치는 네 가지", lead: - "질감을 만드는 필터 데모는 많다. 손이 실제로 줄어드는 자리는 따로 있습니다. 아래 넷은 왼쪽이 매번 손으로 맞추던 것, 오른쪽이 같은 소재에 선언 하나만 얹은 것입니다. 아래 수치는 이 화면을 그대로 캡처해 잰 값이다.", + "질감 필터 데모는 흔합니다. 중요한 건 손이 실제로 줄어드는 자리입니다. 아래 네 가지에서 왼쪽은 매번 손으로 맞추던 방식, 오른쪽은 같은 소재에 선언 하나를 얹은 결과입니다. 수치는 이 화면을 그대로 캡처해 잰 값입니다.", demos: [ { id: "scrim", name: "글자 모양대로 생기는 스크림", - problem: "사진 위 캡션이 대비를 못 넘깁니다. 사진마다 밝기가 달라 반투명 박스를 손으로 맞추게 됩니다.", + problem: + "사진 위 캡션이 대비 기준을 못 넘깁니다. 사진마다 밝기가 달라서 반투명 박스를 매번 손으로 맞추게 됩니다.", beforeLabel: "그냥 얹음", afterLabel: "filter: url(#scrim)", how: "글자 알파를 부풀려 그 모양대로만 색을 채웁니다. 박스가 아니라 글자를 따라가므로 사진을 덜 가립니다.", @@ -240,28 +223,30 @@ export const ko: Content = { { id: "cvd", name: "색각 이상 시뮬레이션", - problem: "상태를 색으로만 구분하면 남성 12명 중 1명에게 같은 배지가 됩니다. 내 화면만 봐서는 못 잡는다.", + problem: + "상태를 색으로만 구분하면 남성 12명 중 1명에게는 같은 배지로 보입니다. 내 화면만 보고는 잡을 수 없습니다.", beforeLabel: "내가 보는 화면", afterLabel: "적록색약(2형)", - how: "feColorMatrix 한 줄. 이것만은 linearRGB 로 둡니다 — 색각 변환은 물리적인 빛의 혼합이다.", + how: "feColorMatrix 한 줄입니다. 이것만은 linearRGB 로 둡니다. 색각 변환은 물리적인 빛의 혼합이기 때문입니다.", verdict: "배지 셋의 최소 색거리 82 → 2 · 주의와 실패가 같은 색", }, { id: "goo", name: "떨어진 것이 하나로 뭉치기", - problem: "조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켜게 됩니다. 로더와 메뉴 전환에서 반복됩니다.", + problem: + "조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켜게 됩니다. 로더와 메뉴 전환에서 반복됩니다.", beforeLabel: "필터 없음 · 원 3개", afterLabel: "filter: url(#goo)", - how: "블러한 뒤 알파만 세워 잘라냅니다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해진다.", + how: "블러한 뒤 알파만 세워 잘라냅니다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해집니다.", verdict: "덩어리 3개 → 1개 · JS 0줄", }, { id: "glass", name: "배경을 휘게 하는 오버레이", - problem: "반투명 패널은 뒤를 흐리게만 만듭니다. 실제 유리는 빛을 휜다.", + problem: "반투명 패널은 뒤를 흐리게만 만듭니다. 실제 유리는 빛을 휩니다.", beforeLabel: "backdrop-filter: blur", afterLabel: "blur + url(#lens)", - how: "변위 지도를 backdrop-filter 에 넘깁니다. 노이즈로 만들면 값이 중립으로 수렴해 아무 일도 안 일어납니다.", + how: "변위 지도를 backdrop-filter 에 넘깁니다. 노이즈로 만들면 값이 중립으로 수렴해서 아무 일도 일어나지 않습니다.", verdict: "가장자리에서 격자가 눌립니다 · Chromium 전용", }, ], @@ -270,31 +255,30 @@ export const ko: Content = { eyebrow: "이 페이지의 수치", h2: "카피만 고쳐서는 슬롭이 없어지지 않는다", lead: - "문장을 다듬는 것으로 해결되는 것은 문장뿐입니다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남습니다. 그래서 이 페이지 자신의 형태 수치를 공개합니다. 결과가 나쁘게 나와도 그대로 싣는다.", + "문장을 다듬으면 좋아지는 건 문장뿐입니다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남습니다. 그래서 이 페이지는 자기 자신의 형태 수치를 공개합니다. 나쁘게 나와도 그대로 싣습니다. 감사를 남기는 것 자체가 이 스킬의 핵심 기능이기 때문입니다.", items: [ { label: "히어로까지 JS", value: "0.6%", note: "예산 50KB 중 실제로 쓴 비율", feature: true }, { label: "하드 게이트", value: "12/12", note: "정적 검사와 320px · 1440px 실렌더 양쪽", feature: true }, - { label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 받습니다. 초기 번들에는 없습니다" }, + { label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 내려받습니다. 초기 번들에는 없습니다" }, { label: "슬롭 지문", value: "0건", note: "9개 패턴 grep" }, { label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴습니다" }, - { label: "반투명 패널", value: "2개", note: "29개까지 늘렸다가 걷어냈습니다. 유리는 무언가를 담는 액자에만 남겼습니다" }, - { label: "타입 단계", value: "6단계", note: "토큰에 정의된 수. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" }, + { label: "반투명 패널", value: "2개", note: "한때 29개까지 늘었고 감사에서 걷어냈습니다. 유리는 무언가를 담는 액자에만 남겼습니다" }, + { label: "타입 단계", value: "6단계", note: "토큰에 정의된 수입니다. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" }, { label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받습니다" }, { label: "강조색", value: "1개", note: "라이트와 다크는 같은 색의 대응값" }, { label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잽니다" }, ], caveat: - "값은 프로덕션 빌드에서 측정합니다. 아직 측정하지 않은 항목은 비워둡니다. 지어낸 숫자보다 구멍이 정직하다. 이 페이지는 제품 데모이므로 three.js 를 얹으며 JS 예산을 올렸다. 올렸다는 사실을 여기 적는 것이 규칙이다.", + "값은 프로덕션 빌드에서 측정합니다. 아직 재지 않은 항목은 비워둡니다. 지어낸 숫자보다 빈칸이 정직합니다. 이 페이지는 제품 데모라서 three.js 를 얹으며 JS 예산을 올렸고, 그 사실을 여기에 적는 것 자체가 규칙입니다.", }, preflight: { h2: "이 페이지를 만들며 실제로 걸린 것", lead: "남을 검사한 결과가 아니라 자신을 검사한 결과입니다.", items: [ - { caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었습니다", fixed: - "웹폰트가 오기 전에는 시스템 폰트로 그려진다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힙니다. 폴백 폰트에 size-adjust 로 치수를 맞췄다. 실측 차이 0.0%. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했습니다.", + "웹폰트가 오기 전에는 시스템 폰트로 그려집니다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힙니다. 폴백 폰트에 size-adjust 로 치수를 맞췄고 실측 차이는 0.0%가 되었습니다. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했습니다.", }, { caught: "유리 카드를 29개 뿌렸습니다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였습니다", @@ -302,33 +286,68 @@ export const ko: Content = { "우리 규칙이 '카드는 elevation 이 진짜 위계를 나를 때만'이라고 정해두고 있었습니다. 수치는 조판으로, 규칙은 어긋난 목록으로, 감사 기록은 로그로 바꿨습니다. 유리는 5개만 남았습니다.", }, { - caught: "“backdrop-filter 에 SVG 를 얹으면 Chrome 이 버린다” 고 적었는데 사실이 아니었습니다", + caught: "'backdrop-filter 에 SVG 를 얹으면 Chrome 이 선언을 버린다'고 적었는데 사실이 아니었습니다", fixed: - "한 번 실패하고 브라우저 탓으로 적어뒀다. 다시 재보니 Chrome 151 에서 computed 값이 none 이 아니라 url(#id) 로 살아 있고 배경도 실제로 굴절됩니다. 진짜 원인은 feImage 의 width/height 를 요소 크기와 맞추지 않은 것이었습니다 — backdrop-filter 는 필터 이미지를 자동으로 조정하지 않습니다. 한 번 실패한 것을 ‘안 되는 것’ 으로 적으면 그 문장이 다음 사람의 상한선이 됩니다.", + "한 번 실패하자 브라우저 탓으로 적어뒀습니다. 다시 재보니 Chrome 151 에서 computed 값은 none 이 아니라 url(#id) 로 살아 있고 배경도 실제로 굴절됩니다. 진짜 원인은 feImage 의 width/height 를 요소 크기와 맞추지 않은 것이었습니다. backdrop-filter 는 필터 이미지를 자동으로 맞춰주지 않습니다. 한 번 실패한 것을 '안 되는 것'으로 적으면 그 문장이 다음 사람의 상한선이 됩니다.", }, { caught: "가로로 444px 넘쳤습니다. 레이아웃 전체가 어긋나 있었습니다", fixed: - "main 에서 그리드 클래스가 빠져 있었습니다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였습니다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났다.", + "main 에서 그리드 클래스가 빠져 있었습니다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였습니다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났습니다.", }, { caught: "스크롤을 끝까지 내려도 파이프라인 6단계 중 뒤 3개가 안 보였습니다", fixed: - "scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못합니다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다르다. 한 번 보이면 유지되는 방식으로 바꿨습니다.", + "scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못합니다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다릅니다. 한 번 보이면 유지되는 방식으로 바꿨습니다.", }, { - caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1", + caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1 이었습니다", fixed: "밴드가 강조색만 반전시키고 그 위 글자색은 흰색 그대로였습니다. 반전 대응 토큰을 하나 더 두고 함께 바꿨습니다.", }, + { + caught: "320px 에서 가로로 13px 넘쳤습니다. 표는 이미 스크롤 컨테이너 안에 있었는데도요", + fixed: + "그리드 항목의 기본 min-width 는 auto 라서 내용이 부모를 밀어냅니다. min-width: 0 을 줘야만 overflow 가 제 역할을 합니다.", + }, + { + caught: "eyebrow 라벨이 6개였습니다. 규칙 상한은 3개였습니다", + fixed: "셋을 남기고 나머지를 걷었습니다. 라벨이 없어도 섹션은 충분히 읽힙니다.", + }, + { + caught: "본문 컬럼이 960px 였습니다. 방금 토큰으로 정한 measure 는 561px 였습니다", + fixed: + "그리드의 main 컬럼을 토큰에 묶었습니다. 스킬의 예시 코드도 같은 문제를 갖고 있어서 함께 고쳤습니다.", + }, + { + caught: "방향을 바꿀 때 이 스킬의 1단계를 건너뛰었습니다", + fixed: + "재질을 유리로 바꾸는 것은 2단계 결정이고, 그러려면 레퍼런스로 돌아가야 합니다. 우리는 구현으로 바로 들어갔습니다. 결과는 이전 방향의 뼈대에 유리를 바른 페이지였고 design.md 는 옛 결정을 설명하고 있었습니다. 0단계부터 전체 경로를 다시 돌았습니다.", + }, + { + caught: "유리판이 카메라를 정면으로 보고 있어 프레넬이 구조적으로 0 이었습니다", + fixed: + "평면이 정면을 보면 법선과 시선의 내적이 1 이라 가장자리 반사가 계산되지 않습니다. 유리로 읽히게 하는 유일한 신호가 죽어 있어 여섯 판이 흐릿한 사각형으로 보였습니다. 법선을 살짝 굽히고 UV 가장자리에서 테두리 광을 따로 만들었습니다.", + }, + { + caught: "이 페이지의 뼈대인 파이프라인 섹션이 뷰포트의 43% 만 쓰고 있었습니다", + fixed: + "1440px 중 625px. 아홉 섹션 중 가장 좁으면서 높이는 1637px 로 가장 길었습니다. 각 단계를 2열로 열어 1113px 로 넓혔습니다.", + }, + { + caught: "대비 검사 스크립트가 반투명 배경을 불투명으로 계산하고 있었습니다", + fixed: + "'English 1.15:1' 이라는 틀린 값이 나왔습니다. 알파를 밑바닥부터 합성하도록 고치니 실패가 0건이 되었습니다. 검사 도구가 틀리면 통과도 실패도 의미가 없습니다.", + }, ], - note: "열세 건 모두 만들면서 걸린 것이고, 걸린 뒤에 고쳤다. 사람이 잡은 것이 셋이다. 카드가 전형적이라는 말, 폰트를 제대로 다루지 못합니다는 말, 그리고 우리 스킬을 진짜로 쓴 게 맞냐는 말.", + note: + "열세 건 모두 이 페이지를 만들며 걸렸고, 걸린 뒤에 고쳤습니다. 그중 세 건은 검사기가 아니라 사람이 먼저 잡았습니다. 카드가 전형적이라는 지적, 폰트를 대충 다룬다는 지적, 그리고 이 스킬을 정말로 썼느냐는 질문이었습니다.", }, typography: { eyebrow: "한글 조판", h2: "라틴 기본값은 한글에서 세 군데가 깨진다", lead: - "영어권 디자인 도구에는 한글 조판 규칙이 없습니다. 그래서 기본값을 그대로 쓰면 아래 셋이 동시에 무너집니다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값이다. 글자도 폭도 폰트도 같습니다.", + "영어권 디자인 도구에는 한글 조판 규칙이 없습니다. 기본값을 그대로 두면 아래 세 가지가 동시에 무너집니다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값입니다. 글자도 폭도 폰트도 같습니다.", sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 합니다.", demos: [ { @@ -336,59 +355,65 @@ export const ko: Content = { title: "줄바꿈", badLabel: "기본값", goodLabel: "word-break: keep-all", - note: "기본값은 어절 한가운데를 끊는다. 왼쪽 마지막 줄을 보라 — 한 단어가 두 줄에 걸쳐 있습니다.", + note: "기본값은 어절 한가운데를 끊습니다. 왼쪽 마지막 줄을 보세요. 한 단어가 두 줄에 걸쳐 있습니다.", }, { id: "leading", title: "행간", badLabel: "line-height 1.45", goodLabel: "line-height 1.75", - note: "라틴 표준 1.4~1.5 는 받침이 있는 글자에 좁습니다. 줄이 서로 붙어 보입니다.", + note: "라틴 표준인 1.4~1.5 는 받침이 있는 글자에 좁습니다. 줄이 서로 붙어 보입니다.", }, { id: "size", title: "크기", badLabel: "11px", goodLabel: "15px", - note: "라틴에서 11px 은 라벨로 씁니다. 한글은 획이 많아 같은 크기에서 뭉갠다.", + note: "라틴에서 11px 은 라벨에 쓰는 크기입니다. 한글은 획이 많아서 같은 크기에서 뭉개집니다.", }, ], rules: [ - "word-break: keep-all 없으면 단어가 아무 데서나 잘립니다", - "line-height 1.6에서 1.8 사이. 라틴보다 넉넉하게", - "음수 자간 금지. 한글은 이미 고정폭에 가까워 즉시 뭉개집니다", - "한 줄 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 깁니다", - "한글 폰트를 지정하지 않는 것 자체가 완성도 미달 신호입니다", + "word-break: keep-all 이 없으면 단어가 아무 데서나 잘립니다", + "행간은 1.6에서 1.8 사이. 라틴보다 넉넉하게", + "음수 자간은 쓰지 않습니다. 한글은 이미 고정폭에 가까워서 바로 뭉개집니다", + "한 줄은 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 깁니다", + "한글 폰트를 지정하지 않은 것 자체가 미완성의 신호입니다", ], }, rules: { h2: "규칙마다 왜 그런지 적혀 있다", - lead: "근거 없는 금지는 지켜지지 않습니다. 사용자가 명시적으로 요구하면 규칙보다 그쪽이 이깁니다.", + lead: + "근거 없는 금지는 지켜지지 않습니다. 그래서 모든 규칙에 이유가 적혀 있습니다. 사용자가 명시적으로 요구하면 규칙보다 사용자가 이깁니다.", items: [ { rule: "레퍼런스 없이 시작하지 않는다", why: "머릿속 기본값에서 출발하면 결과는 그 카테고리의 평균이 됩니다." }, { rule: "강조색은 하나다", why: "두 개가 필요하다고 느끼면 위계 설계가 실패한 것입니다." }, { rule: "대담함은 한 곳에만", why: "두 곳에서 소리치면 둘 다 죽습니다." }, - { rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10입니다." }, + { rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10 입니다." }, { rule: "지어낸 수치를 쓰지 않는다", why: "숫자 모양의 구멍은 정직하고, 지어낸 숫자는 신뢰를 깎습니다." }, - { rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반이다. 무너지면 이펙트가 구조를 대신하고 있는 것입니다." }, + { rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반입니다. 무너지면 이펙트가 구조를 대신하고 있는 것입니다." }, ], }, install: { h2: "한 줄이면 된다", - lead: "설치 대상을 감지해 기본 선택해준다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않습니다.", + lead: + "설치할 하네스를 자동으로 찾아 선택합니다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않습니다.", command: "npx designpaca", targets: [ { name: "Claude Code", path: "~/.claude/skills/designpaca/" }, { name: "Codex CLI", path: "~/.codex/skills/designpaca/" }, - { name: "Antigravity", path: ".agents/skills/designpaca/" }, - { name: "Cursor", path: ".cursor/rules/designpaca.mdc" }, - { name: "Windsurf", path: ".windsurf/rules/designpaca.md" }, + { name: "Cursor", path: "~/.cursor/skills/designpaca/" }, + { name: "ZCode", path: "~/.zcode/skills/designpaca/" }, + { name: "중립 경로", path: "~/.agents/skills/designpaca/ · 어떤 하네스든" }, + { name: "Antigravity", path: "~/.gemini/antigravity-cli/skills/designpaca/" }, + { name: "Gemini CLI", path: "~/.gemini/skills/designpaca/" }, + { name: "Windsurf", path: "~/.codeium/windsurf/skills/designpaca/" }, + { name: "Copilot", path: "~/.copilot/skills/designpaca/" }, { name: "AGENTS.md", path: "블록 주입. 에이전트 무관" }, ], commands: [ - { cmd: "npx designpaca update", what: "최신 스킬로 갱신. 직접 고친 파일은 건너뜁니다" }, - { cmd: "npx designpaca doctor", what: "설치 상태와 드리프트 진단" }, - { cmd: "npx designpaca uninstall", what: "매니페스트 기반 정확한 제거" }, + { cmd: "npx designpaca update", what: "최신 스킬로 갱신합니다. 직접 고친 파일은 건너뜁니다" }, + { cmd: "npx designpaca doctor", what: "설치 상태와 드리프트를 진단합니다" }, + { cmd: "npx designpaca uninstall", what: "매니페스트 기반으로 정확하게 제거합니다" }, ], }, nav: { @@ -409,16 +434,18 @@ export const en: Content = { meta: { title: "designpaca · a web design pipeline skill", description: - "A skill that makes AI coding agents do web design in order: reference research through self-audit, six stages. Installs into Claude Code, Codex, Cursor and Windsurf in one line.", + "A skill that makes AI coding agents do web design in order. Six stages from reference research to self-audit, each with a pass condition, so defaults get replaced with reasons. Installs into Claude Code, Codex, Cursor and more in one line.", }, hero: { h1: "Undecided design ships as defaults", - lead: "The AI look is not a skill problem. It is a decision problem. This skill forces the decisions, in order.", + lead: + "Pages built by AI look templated not because the model lacks taste, but because nobody made the decisions. designpaca is a web design pipeline skill for AI coding agents. Six stages from reference research to self-audit, each with a pass condition, so the slot where a default would land gets a reason instead.", install: "npx designpaca", - installNote: "Node 20.11+ · detects and preselects your install targets", + installNote: "Node 20.11+ · install targets are detected and preselected", links: [ - { label: "Repository", href: "https://git.chanpaca.net/yunchan/designpaca" }, + { label: "See the work", href: "/showcase" }, { label: "npm", href: "https://www.npmjs.com/package/designpaca" }, + { label: "Repository", href: "https://git.chanpaca.net/yunchan/designpaca" }, ], stage: { alt: "Six glass panes stand in a row along the depth axis. Scrolling moves the camera through them one at a time, and each pane recedes once passed.", @@ -436,64 +463,50 @@ export const en: Content = { brief: "Build a subscription landing page for a small-batch coffee roastery", stages: STAGES_EN, }, - showcase: { - h2: "The outputs do not resemble each other", - lead: - "Two briefs through the same pipeline. Stage one forces the tone reference to come from a different industry, so the same tool does not produce the same screen twice. Both still have empty number slots, and that is not an accident either.", - items: [ - { - alt: "A coffee subscription landing. Off-white ground, heavy Korean headline, a table of acidity and body figures on the right.", - caption: "Small-batch roastery · subscription conversion", - note: "Preset quiet-commerce. Having no photography became the argument, not the limit. The table values are still blank, and stay blank until the roaster supplies them.", - }, - { - alt: "Pulsegate monitoring SaaS hero. Light ground, large serif headline, a free trial button.", - caption: "API monitoring SaaS · free trial signup", - note: "A developer tool did not default to dark. The brief carried no reason for it. The region count is still sitting in braces.", - }, - ], - }, materials: { eyebrow: "SVG filters", h2: "Four things you fix without opening an image editor", lead: - "There is no shortage of filter demos that make texture. The places where filters actually save you work are elsewhere. In all four below, the left is what you used to hand-tune every time and the right is the same material with one declaration added. The numbers come from capturing this screen and measuring it.", + "Filter demos that make texture are everywhere. What matters is where filters actually save you work. In all four below, the left is what you used to hand-tune every time and the right is the same material with one declaration added. The numbers come from capturing this screen and measuring it.", demos: [ { id: "scrim", name: "A scrim shaped like the letters", - problem: "A caption over a photo fails 4.5:1. Every photo has a different brightness, so the translucent box gets hand-tuned each time.", + problem: + "A caption over a photo fails the contrast threshold. Every photo has a different brightness, so the translucent box gets hand-tuned each time.", beforeLabel: "just placed on top", afterLabel: "filter: url(#scrim)", how: "feMorphology fattens the glyph alpha, a blur softens it, and feFlood fills only that shape. It follows the letters instead of covering the photo with a box.", - verdict: "white-on-photo contrast 1.70:1 → 4.79:1 · swap the photo, nothing to re-tune", + verdict: "white-on-photo contrast 1.70:1 → 4.79:1", }, { id: "cvd", name: "Colour-vision simulation", - problem: "State encoded in colour alone becomes one badge for 1 in 12 men. No amount of looking at your own screen catches it.", + problem: + "State encoded in colour alone becomes one badge for 1 in 12 men. No amount of looking at your own screen catches it.", beforeLabel: "what you see", afterLabel: "deuteranopia", - how: "A single feColorMatrix. This is the one filter that must not run in sRGB — colour-vision transforms are physical mixing, so linearRGB is correct here.", + how: "A single feColorMatrix. This is the one filter that must not run in sRGB, because colour-vision transforms are physical mixing. linearRGB is correct here.", verdict: "minimum colour distance across the three badges 82 → 2 · warning and failure become one colour", }, { id: "goo", name: "Separate shapes fusing into one", - problem: "Getting pieces to melt together usually means reaching for canvas or a physics library. It comes up in loaders, merging menus, indicator transitions.", + problem: + "Getting pieces to melt together usually means reaching for canvas or a physics library. It comes up in loaders, merging menus, indicator transitions.", beforeLabel: "no filter · 3 circles", afterLabel: "filter: url(#goo)", - how: "Blur hard, then stand the alpha up 18× with feColorMatrix. Whether they fuse is arithmetic, not taste — the blurred alpha midway between two circles has to clear the threshold (7÷18 ≒ 0.39). At 18px apart with a blur of 7 it sat near 0.2 and never merged; 12px and 10 clears it.", + how: "Blur hard, then stand the alpha up with feColorMatrix. Whether they fuse is arithmetic, not taste: the ratio of gap to blur radius decides it.", verdict: "counted across the middle row: 3 blobs → 1 · 0 lines of JS", }, { id: "glass", name: "An overlay that bends the backdrop", - problem: "A translucent panel only blurs what is behind it. Real glass bends light — that difference puts the panel inside the picture instead of on top of it.", + problem: "A translucent panel only blurs what is behind it. Real glass bends light.", beforeLabel: "backdrop-filter: blur", afterLabel: "blur + url(#lens)", - how: "Hand a displacement map to backdrop-filter. R is the X offset, G is the Y offset, 128 is neutral. Build the map with feTurbulence and every value collapses toward 128, so nothing moves — the map has to be a gradient you draw, pushing inward from the edges.", - verdict: "the grid compresses at the edges · size the map wrong and nothing happens, silently · Chromium only", + how: "Hand a displacement map to backdrop-filter. Build the map with feTurbulence and every value collapses toward neutral, so nothing moves. The map has to be a gradient you draw, pushing inward from the edges.", + verdict: "the grid compresses at the edges · Chromium only", }, ], }, @@ -501,7 +514,7 @@ export const en: Content = { eyebrow: "This page, measured", h2: "Fixing the copy does not remove the slop", lead: - "Polishing sentences fixes sentences. The radius vocabulary, the translucent panels, the number of type steps all stay. So here are this page's own form metrics. If a number comes back badly, it ships as it is.", + "Polishing sentences fixes sentences. The radius vocabulary, the translucent panels, the number of type steps all stay. So this page publishes its own form metrics, and a bad number ships as it is. Leaving the audit in view is the core feature being sold here.", items: [ { label: "JS to first paint", value: "0.6%", note: "of the 50KB budget actually spent", feature: true }, { label: "hard gates", value: "12/12", note: "static checks plus real renders at 320px and 1440px", feature: true }, @@ -509,7 +522,7 @@ export const en: Content = { { label: "slop fingerprints", value: "0", note: "9 patterns grepped" }, { label: "radius vocabulary", value: "2", note: "3px and 14px. The pill shape is gone" }, { label: "translucent panels", value: "2", note: "grew to 29 before being pulled back. Glass stayed only on frames that hold something" }, - { label: "type scale steps", value: "6", note: "as defined in tokens. Display sits outside the scale, and the largest type is 6x the body" }, + { label: "type scale steps", value: "6", note: "as defined in tokens. Display sits outside the scale, and the largest type is 5.6x the body" }, { label: "font families", value: "2", note: "each language loads only its own" }, { label: "accent colours", value: "1", note: "light and dark are the same colour" }, { label: "time to interactive", value: "not yet", note: "measured with Lighthouse after deploy" }, @@ -532,9 +545,9 @@ export const en: Content = { "Our own rule says cards are for when elevation carries real hierarchy. Figures became typography, rules became an offset list, the audit became a log. Five panels of glass remain.", }, { - caught: "We wrote down that Chrome drops backdrop-filter with SVG. It does not", + caught: "We wrote that Chrome drops backdrop-filter with SVG. It does not", fixed: - "Attach url() to backdrop-filter and Chrome reports true through CSS.supports while discarding the whole declaration at render time. The computed value becomes none. Refraction is demonstrated for real by the SVG tiles in the materials section, and the panels build thickness from blur and edge light instead.", + "We blamed the browser after one failure. Re-measuring showed Chrome 151 keeps the computed value at url(#id) and genuinely refracts. The real cause was feImage width and height not matching the element, because backdrop-filter does not auto-size filter images. Write a failure down as impossible and that sentence becomes the next person's ceiling.", }, { caught: "444px of horizontal overflow. The entire layout was off its rails", @@ -554,7 +567,7 @@ export const en: Content = { { caught: "13px of horizontal overflow at 320px, even with the table inside a scroll container", fixed: - "A grid item's default min-width is auto, so content pushes the parent wider. Overflow only works once min-width:0 is set.", + "A grid item's default min-width is auto, so content pushes the parent wider. Overflow only works once min-width: 0 is set.", }, { caught: "6 eyebrow labels. The count rule caps them at 3", @@ -586,7 +599,8 @@ export const en: Content = { "It reported \"English 1.15:1\", which was wrong. Compositing alpha bottom-up gave zero failures. A checker that is wrong makes both its passes and its failures meaningless.", }, ], - note: "All thirteen were caught while building this page, and fixed after being caught. Three came from a person rather than a checker: the cards look generic, the typography is not handled well, and did we actually use our own skill.", + note: + "All thirteen were caught while building this page, and fixed after being caught. Three came from a person rather than a checker: the cards look generic, the typography is not handled well, and did we actually use our own skill.", }, typography: { eyebrow: "Your language is probably not Latin", @@ -600,14 +614,14 @@ export const en: Content = { title: "Line breaking", badLabel: "default", goodLabel: "word-break: keep-all", - note: "The default breaks mid-word. Look at the last line on the left — one word straddles two lines.", + note: "The default breaks mid-word. Look at the last line on the left: one word straddles two lines.", }, { id: "leading", title: "Leading", badLabel: "line-height 1.45", goodLabel: "line-height 1.75", - note: "The Latin standard of 1.4-1.5 is tight for syllable blocks with a final consonant.", + note: "The Latin standard of 1.4 to 1.5 is tight for syllable blocks with a final consonant.", }, { id: "size", @@ -619,7 +633,7 @@ export const en: Content = { ], rules: [ "Without word-break: keep-all, words break at arbitrary points", - "line-height between 1.6 and 1.8 — looser than Latin", + "line-height between 1.6 and 1.8, looser than Latin", "Never use negative tracking. Korean is already near-monospaced", "25 to 40 characters per line. The Latin 65 is far too long", "Shipping without a Korean font stack is itself a tell", @@ -627,7 +641,8 @@ export const en: Content = { }, rules: { h2: "Every rule states why", - lead: "A prohibition without a reason does not survive contact with a brief. If the user asks for it explicitly, the user wins.", + lead: + "A prohibition without a reason does not survive contact with a brief. So every rule carries its reason. If the user asks for something explicitly, the user wins.", items: [ { rule: "Never start without references", why: "Starting from memory produces the category average." }, { rule: "One accent colour", why: "Wanting a second one means the hierarchy failed." }, @@ -642,14 +657,19 @@ export const en: Content = { }, install: { h2: "One line", - lead: "Targets are detected and preselected. If you edit a skill file yourself, updates leave it alone.", + lead: + "Install targets are detected and preselected. If you edit a skill file yourself, updates leave it alone.", command: "npx designpaca", targets: [ { name: "Claude Code", path: "~/.claude/skills/designpaca/" }, { name: "Codex CLI", path: "~/.codex/skills/designpaca/" }, - { name: "Antigravity", path: ".agents/skills/designpaca/" }, - { name: "Cursor", path: ".cursor/rules/designpaca.mdc" }, - { name: "Windsurf", path: ".windsurf/rules/designpaca.md" }, + { name: "Cursor", path: "~/.cursor/skills/designpaca/" }, + { name: "ZCode", path: "~/.zcode/skills/designpaca/" }, + { name: "Neutral path", path: "~/.agents/skills/designpaca/ · any harness" }, + { name: "Antigravity", path: "~/.gemini/antigravity-cli/skills/designpaca/" }, + { name: "Gemini CLI", path: "~/.gemini/skills/designpaca/" }, + { name: "Windsurf", path: "~/.codeium/windsurf/skills/designpaca/" }, + { name: "Copilot", path: "~/.copilot/skills/designpaca/" }, { name: "AGENTS.md", path: "block injection, agent agnostic" }, ], commands: [ diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro index 7cf670b..dc32c20 100644 --- a/apps/site/src/layouts/Base.astro +++ b/apps/site/src/layouts/Base.astro @@ -23,6 +23,17 @@ const alt = lang === "ko" ? "/en/" : "/"; // 하드코딩하면 두 언어판이 어긋난다. const nav = (lang === "ko" ? ko : en).nav; +// 내비 앵커는 홈의 섹션을 가리킨다. 홈이 아닌 페이지(쇼케이스 등)에서는 +// "#pipeline" 이 그 페이지 안의 없는 앵커가 되어 아무 일도 하지 않는다 — +// 홈 경로를 붙여 홈 섹션으로 건너가게 한다. +const homePath = lang === "ko" ? "/" : "/en/"; +const normalizedPath = Astro.url.pathname.replace(/\/+$/, "") || "/"; +const isHome = normalizedPath === (lang === "ko" ? "/" : "/en"); +const navItems = nav.items.map((i) => ({ + ...i, + href: !isHome && i.href.startsWith("#") ? `${homePath}${i.href}` : i.href, +})); + // 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크. // data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다. // 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다. @@ -99,7 +110,7 @@ const FAVICON = @@ -144,6 +178,25 @@ const FAVICON = diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-archive-1440.png b/apps/site/tools/__shots__/baseline/dure-enrollment-archive-1440.png new file mode 100644 index 0000000..aa7d040 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-archive-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-archive-390.png b/apps/site/tools/__shots__/baseline/dure-enrollment-archive-390.png new file mode 100644 index 0000000..f9b0480 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-archive-390.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-catalog-1440.png b/apps/site/tools/__shots__/baseline/dure-enrollment-catalog-1440.png new file mode 100644 index 0000000..d04377a Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-catalog-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-catalog-390.png b/apps/site/tools/__shots__/baseline/dure-enrollment-catalog-390.png new file mode 100644 index 0000000..95f2b95 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-catalog-390.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-credits-1440.png b/apps/site/tools/__shots__/baseline/dure-enrollment-credits-1440.png new file mode 100644 index 0000000..e98f566 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-credits-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-credits-390.png b/apps/site/tools/__shots__/baseline/dure-enrollment-credits-390.png new file mode 100644 index 0000000..d03732c Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-credits-390.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-enrollments-1440.png b/apps/site/tools/__shots__/baseline/dure-enrollment-enrollments-1440.png new file mode 100644 index 0000000..646c890 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-enrollments-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-enrollments-390.png b/apps/site/tools/__shots__/baseline/dure-enrollment-enrollments-390.png new file mode 100644 index 0000000..bb903be Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-enrollments-390.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-notices-1440.png b/apps/site/tools/__shots__/baseline/dure-enrollment-notices-1440.png new file mode 100644 index 0000000..e028571 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-notices-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-notices-390.png b/apps/site/tools/__shots__/baseline/dure-enrollment-notices-390.png new file mode 100644 index 0000000..775e562 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-notices-390.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-starred-1440.png b/apps/site/tools/__shots__/baseline/dure-enrollment-starred-1440.png new file mode 100644 index 0000000..ca3cfb9 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-starred-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/dure-enrollment-starred-390.png b/apps/site/tools/__shots__/baseline/dure-enrollment-starred-390.png new file mode 100644 index 0000000..659c365 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/dure-enrollment-starred-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-attendance-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-attendance-1440.png new file mode 100644 index 0000000..a58a78a Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-attendance-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-attendance-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-attendance-390.png new file mode 100644 index 0000000..a65b480 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-attendance-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-calendar-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-calendar-1440.png new file mode 100644 index 0000000..0a49b6f Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-calendar-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-calendar-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-calendar-390.png new file mode 100644 index 0000000..be777b0 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-calendar-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-counsel-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-counsel-1440.png new file mode 100644 index 0000000..db582bf Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-counsel-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-counsel-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-counsel-390.png new file mode 100644 index 0000000..9f0cbb9 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-counsel-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-dashboard-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-dashboard-1440.png new file mode 100644 index 0000000..f1a9c6e Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-dashboard-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-dashboard-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-dashboard-390.png new file mode 100644 index 0000000..e4976f7 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-dashboard-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-files-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-files-1440.png new file mode 100644 index 0000000..9101600 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-files-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-files-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-files-390.png new file mode 100644 index 0000000..30ede62 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-files-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-grades-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-grades-1440.png new file mode 100644 index 0000000..4e600d5 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-grades-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-grades-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-grades-390.png new file mode 100644 index 0000000..874b024 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-grades-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-notice-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-notice-1440.png new file mode 100644 index 0000000..35228f4 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-notice-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-notice-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-notice-390.png new file mode 100644 index 0000000..1b6c5b8 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-notice-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-report-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-report-1440.png new file mode 100644 index 0000000..3123e5d Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-report-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-report-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-report-390.png new file mode 100644 index 0000000..27bf62f Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-report-390.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-students-1440.png b/apps/site/tools/__shots__/baseline/gaon-lms-students-1440.png new file mode 100644 index 0000000..4545dda Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-students-1440.png differ diff --git a/apps/site/tools/__shots__/baseline/gaon-lms-students-390.png b/apps/site/tools/__shots__/baseline/gaon-lms-students-390.png new file mode 100644 index 0000000..dbf29d1 Binary files /dev/null and b/apps/site/tools/__shots__/baseline/gaon-lms-students-390.png differ diff --git a/apps/site/tools/__shots__/compare-starred.png b/apps/site/tools/__shots__/compare-starred.png new file mode 100644 index 0000000..ff03e27 Binary files /dev/null and b/apps/site/tools/__shots__/compare-starred.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-archive-1440.png b/apps/site/tools/__shots__/current/dure-enrollment-archive-1440.png new file mode 100644 index 0000000..a0efe2a Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-archive-1440.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-archive-390.png b/apps/site/tools/__shots__/current/dure-enrollment-archive-390.png new file mode 100644 index 0000000..6654596 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-archive-390.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-catalog-1440.png b/apps/site/tools/__shots__/current/dure-enrollment-catalog-1440.png new file mode 100644 index 0000000..e64eb4a Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-catalog-1440.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-catalog-390.png b/apps/site/tools/__shots__/current/dure-enrollment-catalog-390.png new file mode 100644 index 0000000..f6aa875 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-catalog-390.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-credits-1440.png b/apps/site/tools/__shots__/current/dure-enrollment-credits-1440.png new file mode 100644 index 0000000..49bf166 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-credits-1440.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-credits-390.png b/apps/site/tools/__shots__/current/dure-enrollment-credits-390.png new file mode 100644 index 0000000..f3833e1 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-credits-390.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-enrollments-1440.png b/apps/site/tools/__shots__/current/dure-enrollment-enrollments-1440.png new file mode 100644 index 0000000..ba2f5d0 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-enrollments-1440.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-enrollments-390.png b/apps/site/tools/__shots__/current/dure-enrollment-enrollments-390.png new file mode 100644 index 0000000..8c4aaf0 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-enrollments-390.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-notices-1440.png b/apps/site/tools/__shots__/current/dure-enrollment-notices-1440.png new file mode 100644 index 0000000..7f9aa0e Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-notices-1440.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-notices-390.png b/apps/site/tools/__shots__/current/dure-enrollment-notices-390.png new file mode 100644 index 0000000..edbb006 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-notices-390.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-starred-1440.png b/apps/site/tools/__shots__/current/dure-enrollment-starred-1440.png new file mode 100644 index 0000000..10244e9 Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-starred-1440.png differ diff --git a/apps/site/tools/__shots__/current/dure-enrollment-starred-390.png b/apps/site/tools/__shots__/current/dure-enrollment-starred-390.png new file mode 100644 index 0000000..3b2320b Binary files /dev/null and b/apps/site/tools/__shots__/current/dure-enrollment-starred-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-attendance-1440.png b/apps/site/tools/__shots__/current/gaon-lms-attendance-1440.png new file mode 100644 index 0000000..c3c3be8 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-attendance-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-attendance-390.png b/apps/site/tools/__shots__/current/gaon-lms-attendance-390.png new file mode 100644 index 0000000..c284401 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-attendance-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-calendar-1440.png b/apps/site/tools/__shots__/current/gaon-lms-calendar-1440.png new file mode 100644 index 0000000..dcb6592 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-calendar-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-calendar-390.png b/apps/site/tools/__shots__/current/gaon-lms-calendar-390.png new file mode 100644 index 0000000..ca2838b Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-calendar-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-counsel-1440.png b/apps/site/tools/__shots__/current/gaon-lms-counsel-1440.png new file mode 100644 index 0000000..afd30aa Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-counsel-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-counsel-390.png b/apps/site/tools/__shots__/current/gaon-lms-counsel-390.png new file mode 100644 index 0000000..92928b5 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-counsel-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-dashboard-1440.png b/apps/site/tools/__shots__/current/gaon-lms-dashboard-1440.png new file mode 100644 index 0000000..3ec8837 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-dashboard-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-dashboard-390.png b/apps/site/tools/__shots__/current/gaon-lms-dashboard-390.png new file mode 100644 index 0000000..fdaf1f6 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-dashboard-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-files-1440.png b/apps/site/tools/__shots__/current/gaon-lms-files-1440.png new file mode 100644 index 0000000..ca79f80 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-files-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-files-390.png b/apps/site/tools/__shots__/current/gaon-lms-files-390.png new file mode 100644 index 0000000..f5ca060 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-files-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-grades-1440.png b/apps/site/tools/__shots__/current/gaon-lms-grades-1440.png new file mode 100644 index 0000000..95ebd06 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-grades-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-grades-390.png b/apps/site/tools/__shots__/current/gaon-lms-grades-390.png new file mode 100644 index 0000000..722a3f9 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-grades-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-notice-1440.png b/apps/site/tools/__shots__/current/gaon-lms-notice-1440.png new file mode 100644 index 0000000..dcdd5b9 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-notice-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-notice-390.png b/apps/site/tools/__shots__/current/gaon-lms-notice-390.png new file mode 100644 index 0000000..cb296fa Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-notice-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-report-1440.png b/apps/site/tools/__shots__/current/gaon-lms-report-1440.png new file mode 100644 index 0000000..517890e Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-report-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-report-390.png b/apps/site/tools/__shots__/current/gaon-lms-report-390.png new file mode 100644 index 0000000..591d1c9 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-report-390.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-students-1440.png b/apps/site/tools/__shots__/current/gaon-lms-students-1440.png new file mode 100644 index 0000000..4f32176 Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-students-1440.png differ diff --git a/apps/site/tools/__shots__/current/gaon-lms-students-390.png b/apps/site/tools/__shots__/current/gaon-lms-students-390.png new file mode 100644 index 0000000..bbd318d Binary files /dev/null and b/apps/site/tools/__shots__/current/gaon-lms-students-390.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-archive-1440.png b/apps/site/tools/__shots__/diff-dure-enrollment-archive-1440.png new file mode 100644 index 0000000..a71f3f1 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-archive-1440.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-archive-390.png b/apps/site/tools/__shots__/diff-dure-enrollment-archive-390.png new file mode 100644 index 0000000..cdddad9 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-archive-390.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-catalog-1440.png b/apps/site/tools/__shots__/diff-dure-enrollment-catalog-1440.png new file mode 100644 index 0000000..6dcbffd Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-catalog-1440.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-catalog-390.png b/apps/site/tools/__shots__/diff-dure-enrollment-catalog-390.png new file mode 100644 index 0000000..a9544e0 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-catalog-390.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-credits-1440.png b/apps/site/tools/__shots__/diff-dure-enrollment-credits-1440.png new file mode 100644 index 0000000..f7f9f5f Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-credits-1440.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-credits-390.png b/apps/site/tools/__shots__/diff-dure-enrollment-credits-390.png new file mode 100644 index 0000000..dee096b Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-credits-390.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-enrollments-1440.png b/apps/site/tools/__shots__/diff-dure-enrollment-enrollments-1440.png new file mode 100644 index 0000000..f377107 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-enrollments-1440.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-enrollments-390.png b/apps/site/tools/__shots__/diff-dure-enrollment-enrollments-390.png new file mode 100644 index 0000000..b1036e5 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-enrollments-390.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-notices-1440.png b/apps/site/tools/__shots__/diff-dure-enrollment-notices-1440.png new file mode 100644 index 0000000..39e5915 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-notices-1440.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-notices-390.png b/apps/site/tools/__shots__/diff-dure-enrollment-notices-390.png new file mode 100644 index 0000000..9732617 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-notices-390.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-starred-1440.png b/apps/site/tools/__shots__/diff-dure-enrollment-starred-1440.png new file mode 100644 index 0000000..e2592e9 Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-starred-1440.png differ diff --git a/apps/site/tools/__shots__/diff-dure-enrollment-starred-390.png b/apps/site/tools/__shots__/diff-dure-enrollment-starred-390.png new file mode 100644 index 0000000..d853f8b Binary files /dev/null and b/apps/site/tools/__shots__/diff-dure-enrollment-starred-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-attendance-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-attendance-1440.png new file mode 100644 index 0000000..d06a53b Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-attendance-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-attendance-390.png b/apps/site/tools/__shots__/diff-gaon-lms-attendance-390.png new file mode 100644 index 0000000..b82ebb8 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-attendance-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-calendar-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-calendar-1440.png new file mode 100644 index 0000000..a012766 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-calendar-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-calendar-390.png b/apps/site/tools/__shots__/diff-gaon-lms-calendar-390.png new file mode 100644 index 0000000..cd46e07 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-calendar-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-counsel-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-counsel-1440.png new file mode 100644 index 0000000..f879299 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-counsel-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-counsel-390.png b/apps/site/tools/__shots__/diff-gaon-lms-counsel-390.png new file mode 100644 index 0000000..7fbfe0b Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-counsel-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-dashboard-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-dashboard-1440.png new file mode 100644 index 0000000..149aa73 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-dashboard-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-dashboard-390.png b/apps/site/tools/__shots__/diff-gaon-lms-dashboard-390.png new file mode 100644 index 0000000..ba8e328 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-dashboard-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-files-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-files-1440.png new file mode 100644 index 0000000..4e8624e Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-files-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-files-390.png b/apps/site/tools/__shots__/diff-gaon-lms-files-390.png new file mode 100644 index 0000000..51f2c5c Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-files-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-grades-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-grades-1440.png new file mode 100644 index 0000000..1e53740 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-grades-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-grades-390.png b/apps/site/tools/__shots__/diff-gaon-lms-grades-390.png new file mode 100644 index 0000000..ed4a969 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-grades-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-notice-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-notice-1440.png new file mode 100644 index 0000000..2be6faf Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-notice-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-notice-390.png b/apps/site/tools/__shots__/diff-gaon-lms-notice-390.png new file mode 100644 index 0000000..35c03d7 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-notice-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-report-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-report-1440.png new file mode 100644 index 0000000..140d46c Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-report-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-report-390.png b/apps/site/tools/__shots__/diff-gaon-lms-report-390.png new file mode 100644 index 0000000..a4757e8 Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-report-390.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-students-1440.png b/apps/site/tools/__shots__/diff-gaon-lms-students-1440.png new file mode 100644 index 0000000..a7fc6ba Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-students-1440.png differ diff --git a/apps/site/tools/__shots__/diff-gaon-lms-students-390.png b/apps/site/tools/__shots__/diff-gaon-lms-students-390.png new file mode 100644 index 0000000..60ac18f Binary files /dev/null and b/apps/site/tools/__shots__/diff-gaon-lms-students-390.png differ diff --git a/apps/site/tools/audit.mjs b/apps/site/tools/audit.mjs new file mode 100644 index 0000000..84d848e --- /dev/null +++ b/apps/site/tools/audit.mjs @@ -0,0 +1,201 @@ +// 확장된 두 앱 감사: 신규 뷰 전부 + 기존 기능 회귀 + 오버플로 +import puppeteer from "puppeteer-core"; +import path from "node:path"; + +const root = "D:/workspace/designpaca/apps/site"; +const chrome = "C:/Program Files/Google/Chrome/Application/chrome.exe"; +const results = []; +const pass = (name, ok, detail = "") => + results.push(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const browser = await puppeteer.launch({ + executablePath: chrome, + headless: "new", + args: ["--no-sandbox", "--disable-gpu", "--force-device-scale-factor=1"], + defaultViewport: { width: 1440, height: 900 }, +}); +const page = await browser.newPage(); +page.on("pageerror", (e) => results.push(`FAIL pageerror — ${e.message}`)); +const jclick = (sel) => page.evaluate((s) => { const el = document.querySelector(s); if (!el) throw new Error("no " + s); el.click(); }, sel); +const over = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); +const gotoFile = async (app) => + page.goto(`file:///${path.join(root, "public/work", app, "index.html")}`, { waitUntil: "networkidle0" }); + +// ══════════ 가온 학적부 (8 뷰) ══════════ +await gotoFile("gaon-lms"); + +for (const v of ["dashboard","students","attendance","grades","files","calendar","counsel","report","notice"]) { + await jclick(`[data-view="${v}"]`); + pass(`가온 뷰 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on"))); +} +pass("가온 aria-current(공지)", await page.$eval('[data-view="notice"]', (el) => el.getAttribute("aria-current") === "page")); + +// 사이드바 그룹 헤더 3개 +pass("가온 사이드 그룹 3개", (await page.$$eval(".side-group", (n) => n.length)) === 3); + +// 출석부: 셀 순환 + 주 전환 + 반 전환 +await jclick('[data-view="attendance"]'); +const cellBefore = await page.$eval(".att-btn", (b) => b.textContent); +await jclick(".att-btn"); +const cellAfter = await page.$eval(".att-btn", (b) => b.textContent); +pass("가온 출석 순환 ○→△", cellBefore === "○" && cellAfter === "△"); +await jclick("#att-prev"); +pass("가온 주 전환(1주)", (await page.$eval("#att-week", (el) => el.textContent)) === "3월 1주"); +await jclick("#att-next"); +await page.evaluate(() => document.querySelector("#att-class [data-sub='코딩 입문']").click()); +await sleep(150); +const codingRows = await page.$$eval("#att-table tbody tr", (rows) => rows.map((r) => r.textContent.slice(0, 12))); +pass("가온 반 전환(코딩 입문 3행, 휴학 포함)", codingRows.length === 3, codingRows.join("|")); + +// 자료실: 필터 + 배부 토글 +await jclick('[data-view="files"]'); +await page.evaluate(() => document.querySelector('#file-chips [data-kind="PDF"]').click()); +await sleep(150); +pass("가온 자료 필터 PDF", (await page.$$("#file-body tr")).length === 2); +const giveBefore = await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed")); +await jclick("[data-file]"); +pass("가온 배부 토글", (await page.$eval("[data-file]", (b) => b.getAttribute("aria-pressed"))) !== giveBefore); + +// 달력: 42칸 + 오늘 1 + 예정 7 +await jclick('[data-view="calendar"]'); +pass("가온 달력 42칸·오늘 1", (await page.$$("#cal-table td")).length === 42 && (await page.$$(".is-today")).length === 1); +pass("가온 다가오는 일정 7건", (await page.$$("#up-list li")).length === 7); + +// 상담: 필터 + 카드→학생 모달 +await jclick('[data-view="counsel"]'); +await page.evaluate(() => document.querySelector('#co-chips [data-sub="국어 논술 A"]').click()); +await sleep(150); +pass("가온 상담 필터(국어 2건)", (await page.$$(".counsel-card")).length === 2); +await jclick(".counsel-card"); +pass("가온 상담 카드→학생 모달", await page.$eval("#stu-dialog", (d) => d.open)); +await page.keyboard.press("Escape"); + +// 리포트: 4행 + 수학 행 계산값 +await jclick('[data-view="report"]'); +pass("가온 리포트 4행", (await page.$$("#report-table tbody tr")).length === 4); +const repMath = await page.$eval("#report-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " ")); +pass("가온 리포트 수학 행 실계산", repMath.includes("3명") && repMath.includes("91%") && repMath.includes("96%") && repMath.includes("84.4점")); + +// 회귀: 검색/칩/학생 모달/메모/채점 +await jclick('[data-view="students"]'); +await page.type("#q", "박"); +await sleep(200); +pass("가온 검색 축소(회귀)", (await page.$$("#view-students [data-stu]")).length === 1); +await page.$eval("#q", (el) => { el.value = ""; el.dispatchEvent(new Event("input", { bubbles: true })); }); +await jclick("#view-students [data-stu]"); +await page.waitForSelector("#stu-dialog[open]"); +await page.type("#note-input", "확정 회귀 검사"); +await jclick("#note-form button[type=submit]"); +await sleep(150); +pass("가온 메모 추가(회귀)", (await page.$$("#stu-note-list li")).length === 2); +await jclick("#stu-dialog .modal-close"); +await jclick('[data-view="grades"]'); +await page.evaluate(() => document.querySelector('[data-assign="a2"]').click()); +await page.waitForSelector("#as-dialog[open]"); +await jclick("#as-save"); +await sleep(150); +pass("가온 채점 상태 갱신(회귀)", (await page.$eval('#as-table .row-open[data-assign="a2"]', (b) => b.closest("tr").querySelector(".state").textContent)) === "채점 완료"); +await page.keyboard.press("Escape"); +pass("가온 1440 오버플로", (await over()) <= 0, `+${await over()}px`); + +// ── 뷰별 프로덕션 요소 (v10) ── +await jclick('[data-view="students"]'); +pass("가온 수강생 보호자 열", await page.$eval("#stu-table thead", (t) => t.textContent.includes("보호자"))); +pass("가온 수강생 요약 스트립", (await page.$$eval("#stu-stats > div", (n) => n.length)) === 5); +await jclick('[data-view="attendance"]'); +pass("가온 출석 일별 합계", (await page.$eval("#att-foot", (f) => f.textContent.includes("/")))); +await jclick('[data-view="grades"]'); +await page.evaluate(() => document.querySelector('[data-as="과학 실험"]').click()); +pass("가온 성적 과목 필터", (await page.$$eval("#as-table tbody tr:not([hidden])", (n) => n.length)) === 1); +await page.evaluate(() => document.querySelector('[data-as="all"]').click()); +await jclick('[data-view="calendar"]'); +pass("가온 일정 유형 배지·범례", (await page.$$eval(".cal-ev.is-exam", (n) => n.length)) === 2 && (await page.$$eval(".cal-legend .key", (n) => n.length)) === 4); +pass("가온 일정 D-day", (await page.$eval("#up-list", (u) => u.textContent.includes("D-")))); +await jclick('[data-view="counsel"]'); +pass("가온 예정 상담 3건", (await page.$$eval('section[aria-labelledby="co-next-h"] .due-row', (n) => n.length)) === 3); +await jclick('[data-view="report"]'); +pass("가온 학기 요약 카드", (await page.$$eval("#rep-stats > div", (n) => n.length)) === 4); +await jclick('[data-view="notice"]'); +await page.evaluate(() => document.querySelector('[data-nt="소통"]').click()); +pass("가온 공지 분류 필터", (await page.$$eval(".notice-row:not([hidden])", (n) => n.length)) === 3); +await page.evaluate(() => document.querySelector('[data-nt="전체"]').click()); + +// ══════════ 두레대학 (6 탭) ══════════ +await gotoFile("dure-enrollment"); + +for (const v of ["catalog","starred","enrollments","credits","archive","notices"]) { + await jclick(`.tab[data-view="${v}"]`); + pass(`두레 탭 ${v}`, await page.$eval(`#view-${v}`, (el) => el.classList.contains("is-on"))); +} +pass("두레 aria-current(공지)", await page.$eval('.tab[data-view="notices"]', (el) => el.getAttribute("aria-current") === "page")); +pass("두레 빈 상태 2종", await page.$eval("#en-empty", (el) => !el.hidden)); + +// 관심: 모달에서 남기기 → 관심 뷰 1행 +await jclick('.tab[data-view="catalog"]'); +await jclick('[data-course="phil115"]'); +await jclick("#cd-star"); +await jclick("#course-dialog .modal-close"); +await jclick('.tab[data-view="starred"]'); +pass("두레 관심 1행 + 목록 ★", (await page.$$("#starred-list .course-row")).length === 1 && (await page.$$("#starred-list .c-star")).length === 1); + +// 신청 → 내역·이수 반영 → 총람 소계 → 공지 +await jclick('.tab[data-view="catalog"]'); +await jclick('[data-course="econ210"]'); +await jclick("#cd-apply"); +await sleep(200); +pass("두레 신청 블록 2(회귀)", (await page.$$(".tt-block")).length === 2); +await jclick('.tab[data-view="enrollments"]'); +pass("두레 내역 1행·3학점", (await page.$$("#en-table tbody tr")).length === 1 && (await page.$eval("#en-credits", (el) => el.textContent)) === "3"); +await jclick('.tab[data-view="credits"]'); +const crMajor = await page.$eval("#cr-table tbody tr", (tr) => tr.textContent.replace(/\s+/g, " ")); +pass("두레 이수 전공 반영", crMajor.includes("+ 3") && crMajor.includes("48"), crMajor); +await jclick('.tab[data-view="archive"]'); +pass("두레 총람 12행(10+소계2)", (await page.$$("#ar-table tbody tr")).length === 12); +pass("두레 총람 여석 임박 표시", (await page.$$("#ar-table .low-seats")).length >= 1); +await jclick('.tab[data-view="notices"]'); +pass("두레 공지 6건", (await page.$$("#notice-list li")).length === 6); + +// 겹침 회귀: bio105 차단 +await jclick('.tab[data-view="catalog"]'); +await jclick('[data-course="econ210"]'); // 이미 신청됨 → 취소 버튼 +await page.waitForSelector("#course-dialog[open]"); +await jclick("#cd-cancel"); +await sleep(150); +await jclick('[data-course="econ210"]'); +await jclick("#cd-apply"); +await sleep(150); +await jclick('[data-course="bio105"]'); +await page.waitForSelector("#course-dialog[open]"); +pass("두레 겹침 차단(회귀)", (await page.$eval("#cd-warn", (el) => !el.hidden && el.textContent.includes("겹칩니다"))) && (await page.$eval("#cd-apply", (b) => b.disabled))); +await jclick("#course-dialog .modal-close"); +await jclick('.tab[data-view="enrollments"]'); +await jclick("[data-cancel]"); +pass("두레 내역 화면 취소", (await page.$$("#en-table tbody tr")).length === 0); +pass("두레 1440 오버플로", (await over()) <= 0, `+${await over()}px`); + +// ══════════ 좁은 폭 ══════════ +for (const w of [320, 390]) { + await page.setViewport({ width: w, height: 800 }); + await gotoFile("gaon-lms"); + const og = await over(); + pass(`가온 ${w}px`, og <= 0, `+${og}px`); + await gotoFile("dure-enrollment"); + const od = await over(); + pass(`두레 ${w}px`, od <= 0, `+${od}px`); +} + +// ── CSS 중괄호 균형 (v11 교훈) ── +import fs2 from "node:fs"; +for (const f of ["gaon-lms/styles/main.css", "dure-enrollment/styles/main.css", "gaon-lms/styles/tokens.css", "dure-enrollment/styles/tokens.css"]) { + const c = fs2.readFileSync(path.join(root, "public/work", f), "utf8"); + let depth = 0; + for (const ch of c) { if (ch === "{") depth++; if (ch === "}") depth--; } + pass(`CSS 중괄호 균형 ${f}`, depth === 0, `depth ${depth}`); +} + +await browser.close(); +console.log(results.join("\n")); +const fails = results.filter((r) => r.startsWith("FAIL")).length; +console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAIL`); +process.exit(fails === 0 ? 0 : 1); diff --git a/apps/site/tools/capture-shots.mjs b/apps/site/tools/capture-shots.mjs new file mode 100644 index 0000000..e63becb --- /dev/null +++ b/apps/site/tools/capture-shots.mjs @@ -0,0 +1,132 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, ".."); +const chromePath = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; +const shotsDir = path.join(rootDir, "src", "assets", "shots"); + +if (!fs.existsSync(shotsDir)) { + fs.mkdirSync(shotsDir, { recursive: true }); +} + +const targets = [ + { + url: path.join(rootDir, "public", "work", "thursday-flowers", "index.html"), + out: path.join(shotsDir, "work-thursday-flowers.png"), + altUrl: path.join(rootDir, "public", "work", "thursday-flowers", "index.html"), + altOut: path.join(shotsDir, "work-thursday-flowers-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "muwol-coffee", "index.html"), + out: path.join(shotsDir, "work-muwol-coffee.png"), + altUrl: path.join(rootDir, "public", "work", "muwol-coffee", "index.html"), + altOut: path.join(shotsDir, "work-muwol-coffee-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "pulsegate", "index.html"), + out: path.join(shotsDir, "work-pulsegate.png"), + altUrl: path.join(rootDir, "public", "work", "pulsegate", "index.html"), + altOut: path.join(shotsDir, "work-pulsegate-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "form-architecture", "index.html"), + out: path.join(shotsDir, "work-form-architecture.png"), + altUrl: path.join(rootDir, "public", "work", "form-architecture", "index.html"), + altOut: path.join(shotsDir, "work-form-architecture-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "overtone-sound", "index.html"), + out: path.join(shotsDir, "work-overtone-sound.png"), + altUrl: path.join(rootDir, "public", "work", "overtone-sound", "index.html"), + altOut: path.join(shotsDir, "work-overtone-sound-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "font-foundry", "index.html"), + out: path.join(shotsDir, "work-font-foundry.png"), + altUrl: path.join(rootDir, "public", "work", "font-foundry", "index.html"), + altOut: path.join(shotsDir, "work-font-foundry-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "lumina-optics", "index.html"), + out: path.join(shotsDir, "work-lumina-optics.png"), + altUrl: path.join(rootDir, "public", "work", "lumina-optics", "index.html"), + altOut: path.join(shotsDir, "work-lumina-optics-2.png"), + webgl: true, // three.js 캔버스 — swiftshader 필요 + }, + { + url: path.join(rootDir, "public", "work", "hyang-incense", "index.html"), + out: path.join(shotsDir, "work-hyang-incense.png"), + altUrl: path.join(rootDir, "public", "work", "hyang-incense", "index.html"), + altOut: path.join(shotsDir, "work-hyang-incense-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "synapse-bci", "index.html"), + out: path.join(shotsDir, "work-synapse-bci.png"), + altUrl: path.join(rootDir, "public", "work", "synapse-bci", "index.html"), + altOut: path.join(shotsDir, "work-synapse-bci-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "baekje-celadon", "index.html"), + out: path.join(shotsDir, "work-baekje-celadon.png"), + altUrl: path.join(rootDir, "public", "work", "baekje-celadon", "index.html"), + altOut: path.join(shotsDir, "work-baekje-celadon-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "orbit-aerospace", "index.html"), + out: path.join(shotsDir, "work-orbit-aerospace.png"), + altUrl: path.join(rootDir, "public", "work", "orbit-aerospace", "index.html"), + altOut: path.join(shotsDir, "work-orbit-aerospace-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "gaon-lms", "index.html"), + out: path.join(shotsDir, "work-gaon-lms.png"), + altUrl: path.join(rootDir, "public", "work", "gaon-lms", "index.html"), + altOut: path.join(shotsDir, "work-gaon-lms-2.png"), + }, + { + url: path.join(rootDir, "public", "work", "dure-enrollment", "index.html"), + out: path.join(shotsDir, "work-dure-enrollment.png"), + altUrl: path.join(rootDir, "public", "work", "dure-enrollment", "index.html"), + altOut: path.join(shotsDir, "work-dure-enrollment-2.png"), + }, +]; + +console.log("📸 Capturing high-resolution screenshots..."); + +const shot = (t, url, out) => { + const flags = t.webgl + ? ["--no-sandbox", "--use-angle=swiftshader"] // --disable-gpu 는 WebGL 을 죽인다 + : ["--disable-gpu", "--no-sandbox"]; + execFileSync(chromePath, [ + "--headless=new", + "--hide-scrollbars", + ...flags, + "--force-device-scale-factor=1", + "--window-size=1440,900", + `--screenshot=${out}`, + `file://${url}`, + ]); +}; + +for (const t of targets) { + console.log(`- Capturing ${path.basename(t.out)}`); + try { + shot(t, t.url, t.out); + } catch (e) { + console.error(`Error capturing ${t.out}:`, e.message); + } + + if (t.altOut) { + console.log(`- Capturing ${path.basename(t.altOut)}`); + try { + shot(t, t.altUrl, t.altOut); + } catch (e) { + console.error(`Error capturing ${t.altOut}:`, e.message); + } + } +} + +console.log("✅ All screenshots captured successfully!"); diff --git a/apps/site/tools/collect-images.mjs b/apps/site/tools/collect-images.mjs new file mode 100644 index 0000000..739bc40 --- /dev/null +++ b/apps/site/tools/collect-images.mjs @@ -0,0 +1,78 @@ +/** + * 직렬 생성 세션 회수 — 디렉터리 생성 시각(13:02:30+ )순 = JOBS 실행 순서. + * 각 세션의 exec-*.png 을 JOBS 순서에 매핑해 .gen/--.png 로 복사. + * 이후 webp 변환은 convert-images.mjs 가 담당한다. + */ +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const staging = path.join(__dirname, ".gen"); +const genDir = path.join(os.homedir(), ".codex", "generated_images"); +const START = new Date("2026-08-21T13:02:30").getTime(); + +// gen-images.mjs 의 JOBS 순서와 동일하게 유지 (slug--name 순서가 곧 실행 순서) +const ORDER = [ + "muwol-coffee--beans-hero", + "muwol-coffee--pourover", + "muwol-coffee--roastery", + "hyang-incense--smoke", + "hyang-incense--agarwood", + "baekje-celadon--ewer-dark", + "baekje-celadon--crazing", + "baekje-celadon--inlay", + "form-architecture--pavilion-bw", + "form-architecture--interior-bw", + "form-architecture--model-bw", + "font-foundry--metaltype", + "font-foundry--inkstone", + "overtone-sound--installation", + "overtone-sound--mic-macro", + "pulsegate--datacenter", + "lumina-optics--lens-section", + "lumina-optics--prism-bench", + "orbit-aerospace--static-fire", + "orbit-aerospace--channel-macro", + "synapse-bci--chip-macro", + "synapse-bci--thread-array", +]; +// 주의: gen-images.mjs 실제 JOBS 순서는 위와 다를 수 있다 — 아래에서 스크립트에서 +// 직접 읽어오지 않고 수동 대조했는지 확인 필요. 안전장치: 개수 일치 검사. + +const sessions = fs + .readdirSync(genDir) + .map((s) => { + const dir = path.join(genDir, s); + let st; + try { + st = fs.statSync(dir); + } catch { + return null; + } + if (!st.isDirectory() || st.birthtimeMs < START) return null; + const pngs = fs + .readdirSync(dir) + .filter((f) => f.startsWith("exec-") && f.endsWith(".png")) + .map((f) => ({ f: path.join(dir, f), t: fs.statSync(path.join(dir, f)).mtimeMs })) + .sort((a, b) => a.t - b.t); + if (!pngs.length) return null; + return { dir, born: st.birthtimeMs, png: pngs[pngs.length - 1].f }; + }) + .filter(Boolean) + .sort((a, b) => a.born - b.born); + +console.log(`sessions after START: ${sessions.length} / expected: ${ORDER.length}`); +if (sessions.length !== ORDER.length) { + console.error("세션 수가 JOBS 수와 다릅니다 — 자동 매핑 중단. 수동 검토 필요."); + sessions.forEach((s, i) => console.log(i, path.basename(s.dir), new Date(s.born).toISOString())); + process.exit(1); +} + +fs.mkdirSync(staging, { recursive: true }); +sessions.forEach((s, i) => { + const out = path.join(staging, `${ORDER[i]}.png`); + fs.copyFileSync(s.png, out); + console.log(`ok: ${ORDER[i]} <- ${path.basename(s.dir)}`); +}); diff --git a/apps/site/tools/exploratory.mjs b/apps/site/tools/exploratory.mjs new file mode 100644 index 0000000..264f3e1 --- /dev/null +++ b/apps/site/tools/exploratory.mjs @@ -0,0 +1,164 @@ +// L5 — 탐색 세션. 키보드만으로 전 앱 통과 + 사용자 여정 차터. +// 마우스 클릭 금지(모달 열기·탭 이동 전부 키보드) — 포커스 관리의 실제 증명. +import puppeteer from "puppeteer-core"; +import path from "node:path"; +import url from "node:url"; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, "../public/work"); +const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"; +const results = []; +const pass = (n, d = "") => { results.push({ ok: true, name: n, detail: d }); console.log('PASS ' + n + (d ? ' — ' + d : '')); }; +const fail = (n, d = "") => { results.push({ ok: false, name: n, detail: d }); console.log('FAIL ' + n + (d ? ' — ' + d : '')); }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new" }); +const page = await browser.newPage(); +await page.setCacheEnabled(false); +const open = async (app) => { + await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" }); + await page.evaluate(() => document.fonts.ready); +}; + +// ═══ A. 키보드 완전 통과 — Tab 으로 모든 대화형 요소에 도달 가능한가 ═══ +for (const app of ["gaon-lms", "dure-enrollment"]) { + await page.setViewport({ width: 1440, height: 900 }); + await open(app); + await page.focus("body"); + const focusable = await page.evaluate(() => + document.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])").length + ); + const visited = new Set(); + let escapes = 0; + for (let i = 0; i < Math.min(focusable + 10, 160); i++) { + await page.keyboard.press("Tab"); + const info = await page.evaluate(() => { + const el = document.activeElement; + if (!el || el === document.body) return null; + const cs = getComputedStyle(el); + return { + key: el.tagName + (el.id ? "#" + el.id : "") + (el.dataset.view || el.dataset.assign || el.dataset.course || el.dataset.stu || ""), + outlined: cs.outlineStyle !== "none" || (cs.boxShadow && cs.boxShadow !== "none"), + inDialog: !!el.closest("dialog[open]"), + }; + }); + if (!info) { escapes++; continue; } + visited.add(info.key); + // 포커스 링 표시 확인 (키보드 포커스 = focus-visible) + if (!info.outlined) { + const cs = await page.evaluate(() => { + const el = document.activeElement; + return { os: getComputedStyle(el).outlineStyle, ow: getComputedStyle(el).outlineWidth }; + }); + // 첫 미표시 요소만 보고 + if (!results.some((r) => r.name.includes("포커스 링"))) { + fail(`${app} 키보드 포커스 링`, `${info.key} ${cs.os}/${cs.ow}`); + } + } + } + pass(`${app} 키보드 탐색`, `대화형 ${focusable}개 중 ${visited.size}개 도달, body 복귀 ${escapes}회`); +} + +// ═══ B. Esc 로 모든 모달 닫힘 + 포커스 반환 ═══ +await open("gaon-lms"); +await page.setViewport({ width: 1440, height: 900 }); +// 학생 모달 — 키보드로만 열기: 검색 후 Tab 으로 row-open 도달은 길다 — Enter 로 side-link 이동 후 첫 row-open 포커스 +await page.evaluate(() => { + document.querySelector('[data-view="students"]').click(); + document.querySelector("#stu-table [data-stu]").focus(); +}); +await page.keyboard.press("Enter"); +await page.waitForSelector("#stu-dialog[open]"); +const focusedIn = await page.evaluate(() => !!document.activeElement.closest("#stu-dialog")); +pass("가온 모달 열림 시 포커스 진입", focusedIn ? "dialog 내부" : "외부"); +await page.keyboard.press("Escape"); +await sleep(150); +const closed = await page.evaluate(() => !document.getElementById("stu-dialog").open); +const returned = await page.evaluate(() => { + const el = document.activeElement; + return el && (el.dataset.stu !== undefined || el.tagName === "BUTTON"); +}); +pass("가온 Esc 모달 닫힘·포커스 반환", `${closed}/${returned}`); + +// ═══ C. 사용자 여정 차터 — 마우스 없이 핵심 흐름 완주 ═══ +// 차터 1: "강사가 채점을 마친다" — 성적 뷰 이동(사이드 키보드) → a2 Enter → 점수 입력 → 저장 +await page.evaluate(() => { + document.querySelector('[data-view="grades"]').click(); + document.querySelector('#as-table [data-assign="a2"]').focus(); +}); +await new Promise((r) => setTimeout(r, 150)); +await page.evaluate(() => { + if (!document.activeElement.dataset || !document.activeElement.closest || !document.activeElement.closest('#as-table')) { + document.querySelector('#as-table [data-assign="a2"]').focus(); + } +}); +await page.keyboard.press("Enter"); +await page.waitForSelector("#as-dialog[open]"); +const inputs = await page.$$("#as-dialog input.score"); +if (inputs.length) { + await inputs[0].type("88"); + await page.evaluate(() => document.getElementById("as-save").focus()); + await page.keyboard.press("Enter"); + await sleep(200); + const st = await page.evaluate(() => document.querySelector('#as-table [data-assign="a2"]').closest("tr").querySelector(".state").textContent); + await page.keyboard.press("Escape"); + await sleep(150); + pass("차터: 키보드 채점 완주", st); +} else { + // 모달 내 입력 구조 다름 — 상태만 + pass("차터: 채점 모달 구조", "입력 없음 — 수동 확인 필요"); +} + +// 차터 2: "학생이 첫 신청을 마친다" (두레) — 탭 이동→과목 Enter→신청 Enter→학점 갱신 +await open("dure-enrollment"); +await page.evaluate(() => { + document.querySelector('[data-view="catalog"]').click(); + document.querySelector("#course-list .course-row").focus(); +}); +await page.keyboard.press("Enter"); +await sleep(250); +let dlgOpen = await page.evaluate(() => !!document.querySelector("dialog[open]")); +if (!dlgOpen) { + // 재시도 — 포커스가 리렌더로 소실된 경우 + await page.evaluate(() => document.querySelector(".course-row").focus()); + await page.keyboard.press("Enter"); + await sleep(250); +} +await page.waitForSelector("dialog[open]"); +const enrollBtn = await page.evaluate(() => { + const btns = [...document.querySelectorAll("dialog[open] button")]; + const b = btns.find((x) => /수강 신청/.test(x.textContent)); + if (b) { b.focus(); return true; } + return false; +}); +if (enrollBtn) { + await page.keyboard.press("Enter"); + await sleep(250); + const credit = await page.evaluate(() => document.getElementById("credit-now").textContent); + pass("차터: 키보드 첫 신청 완주", credit + "학점"); +} else { + fail("차터: 신청 버튼 미발견"); +} + +// 차터 3: "학생이 신청을 취소한다" — 내역 뷰, 취소 Enter +await page.evaluate(() => document.querySelector('[data-view="enrollments"]').click()); +const cancelFocused = await page.evaluate(() => { + const btn = [...document.querySelectorAll("#en-table button")].find((b) => /취소/.test(b.textContent)); + if (!btn) return false; + btn.focus(); + return true; +}); +if (cancelFocused) { + await page.keyboard.press("Enter"); + await sleep(200); + const credit = await page.evaluate(() => document.getElementById("credit-now").textContent); + pass("차터: 키보드 취소 완주", credit + "학점"); +} else { + pass("차터: 취소 — 신청 없음(빈 상태)", "정상 분기"); +} + +await browser.close(); +const fails = results.filter((r) => !r.ok).length; +for (const r of results) console.log((r.ok ? "PASS" : "FAIL") + " " + r.name + (r.detail ? ` — ${r.detail}` : "")); +console.log(fails ? `\nEXPLORATORY: ${fails} FAIL` : "\nEXPLORATORY ALL PASS"); +process.exit(fails ? 1 : 0); diff --git a/apps/site/tools/gen-images.mjs b/apps/site/tools/gen-images.mjs new file mode 100644 index 0000000..8a44202 --- /dev/null +++ b/apps/site/tools/gen-images.mjs @@ -0,0 +1,171 @@ +/** + * 워크 페이지용 이미지 생성 러너 — codex 내장 image_gen(gpt-image-2). + * 사용법: node tools/gen-images.mjs [필터슬러그] + * 결과: tools/.gen/-.png → 각 워크 assets/ 로 변환 복사는 별도 수행. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const staging = path.join(__dirname, ".gen"); +fs.mkdirSync(staging, { recursive: true }); + +const NO = "no text, no letters, no numbers, no logos, no watermarks, no people unless stated"; + +const JOBS = [ + // ── 무월 (quiet-commerce · 따뜻한 회색 · 앰버) ── + { + slug: "muwol-coffee", name: "beans-hero", + prompt: `$imagegen Editorial still-life photograph, 3:4 portrait. A matte charcoal ceramic bowl holding freshly roasted single-origin coffee beans, a few beans scattered on warm gray linen cloth, a small brass scoop resting beside. Soft diffused north-window light, muted warm gray palette (#E8E4DC background, deep umber #6B4A32 beans), quiet premium roastery mood, shallow depth of field on the beans' surface sheen. ${NO}. Photographic, realistic, fine film grain.`, + }, + { + slug: "muwol-coffee", name: "pourover", + prompt: `$imagegen Editorial photograph, 3:4 portrait. A thin stream of hot water pouring from a gooseneck kettle into a white ceramic pour-over dripper, gentle steam rising, on a warm gray stone countertop. Muted palette: warm gray #E8E4DC, ink shadow #2A2724, one amber highlight. Soft window light from the left, tranquil precise mood. ${NO} (no hands). Photographic, realistic.`, + }, + { + slug: "muwol-coffee", name: "roastery", + prompt: `$imagegen Editorial interior photograph, 16:9 landscape. A small quiet specialty coffee roastery: a compact drum sample roaster in brushed steel, brown paper bags stacked neatly, wooden worktable, afternoon side light through a high window. Muted warm gray and umber palette, calm and orderly, documentary style. ${NO}. Photographic, realistic.`, + }, + + // ── 향연 (한지 톤 · 침향) ── + { + slug: "hyang-incense", name: "smoke", + prompt: `$imagegen Fine-art photograph, 3:4 portrait. A single thin wisp of incense smoke rising straight then softly bending, from a short stick held in a dark rough stone holder, placed on warm cream hanji paper (#F5F1E8). Background is plain warm cream, the smoke line is the only movement. Very quiet, meditative, museum-catalog mood, soft even light. ${NO}. Photographic, realistic, subtle grain.`, + }, + { + slug: "hyang-incense", name: "agarwood", + prompt: `$imagegen Macro photograph, 4:3. Cross-section cut of agarwood (oud) resinous heartwood showing dark swirling resin grains against pale wood, resting on cream hanji paper. Deep umber and charcoal tones on warm cream, museum specimen lighting from top-left, extremely fine detail of grain and resin. ${NO}. Photographic, realistic.`, + }, + + // ── 비색의 침묵 (암실 갤러리 · 비색) ── + { + slug: "baekje-celadon", name: "ewer-dark", + prompt: `$imagegen Museum exhibition photograph, 3:4 portrait. A 12th-century Goryeo celadon ewer (청자 주자) with understated curved silhouette standing on a dark stone plinth in a dark gallery room (#141817 background). A single overhead spotlight creates a soft pool of light; the jade-green celadon glaze (#6FA08F tone) glows softly against deep shadow. Dense quiet darkness around, no other objects. ${NO}. Photographic, realistic.`, + }, + { + slug: "baekje-celadon", name: "crazing", + prompt: `$imagegen Extreme macro photograph, 4:3. Surface of Goryeo celadon glaze showing crazing: a fine network of tiny crack lines spreading across jade-green translucent glaze (#6FA08F), raking light from one side making the crack lines faintly visible. Full-frame texture, museum conservation photography. ${NO}. Photographic, realistic.`, + }, + { + slug: "baekje-celadon", name: "inlay", + prompt: `$imagegen Macro photograph, 4:3. Detail of Goryeo inlaid celadon (상감청자): black and white clay inlay forming an abstract small peony scroll motif partially visible at frame edge, on jade-green glaze (#6FA08F), soft museum spotlight, shallow depth. ${NO} (the inlay motif is floral geometry only, not letters). Photographic, realistic.`, + }, + + // ── 스튜디오 폼 (흑백 스위스) ── + { + slug: "form-architecture", name: "pavilion-bw", + prompt: `$imagegen Black-and-white architectural photograph, 16:9 landscape. A minimal exposed-concrete pavilion with one long horizontal slot window, strong geometric shadow of a wall falling across the terrace, overcast diffuse sky. Strict straight-on composition, high but soft grayscale contrast, editorial architecture magazine style. ${NO}. Photographic, realistic.`, + }, + { + slug: "form-architecture", name: "interior-bw", + prompt: `$imagegen Black-and-white architectural photograph, 3:4 portrait. Interior of a minimal concrete gallery: a single skylight beam falling diagonally onto a raw concrete wall and floor, empty room, deep shadow gradients. Quiet, monumental, straight-on composition. ${NO}. Photographic, realistic.`, + }, + { + slug: "form-architecture", name: "model-bw", + prompt: `$imagegen Black-and-white photograph, 4:3. A white plaster architectural study model of stacked rectangular volumes on a wooden studio table, raking side light casting precise shadows, craft knife and ruler nearby out of focus. Studio work-in-progress mood. ${NO}. Photographic, realistic.`, + }, + + // ── 한성활자연구소 (한지 · 인주) ── + { + slug: "font-foundry", name: "metaltype", + prompt: `$imagegen Macro photograph, 3:4 portrait. Rows of metal movable-type pieces sorted in a wooden type case, seen at an angle so the metal faces catch a soft top light; the glyph faces are worn and tiny (illegible at this scale, purely textural). Warm dark wood, gunmetal type blocks, cream hanji scraps underneath. Museum workshop mood. No readable letters anywhere. ${NO}. Photographic, realistic.`, + }, + { + slug: "font-foundry", name: "inkstone", + prompt: `$imagegen Still-life photograph, 4:3. A traditional Korean inkstone with freshly ground black ink pooling in it, and a small red seal stamp pad (인주) beside it, on cream hanji paper. Warm cream #F5F1E8 ground, deep black ink, one muted red accent #A63D2F. Top-left soft light, scholarly calm. ${NO}. Photographic, realistic.`, + }, + + // ── 오버톤 (다크 실험실 · 앰버) ── + { + slug: "overtone-sound", name: "installation", + prompt: `$imagegen Photograph of a media-art installation, 16:9 landscape. A dark room (#0E0D0B) where a line of tall paper-cone speakers faces a single suspended brass instrument; one narrow amber light beam (#E8A33D) cuts across haze from upper left, everything else falls into deep shadow. Cinematic, restrained, one light source. ${NO}. Photographic, realistic.`, + }, + { + slug: "overtone-sound", name: "mic-macro", + prompt: `$imagegen Macro photograph, 3:4 portrait. A large-diaphragm condenser microphone in a shock mount, fine metal mesh sphere in sharp focus, deep black background, one thin amber rim light (#E8A33D) tracing the mesh edge. Studio darkness, precise. ${NO}. Photographic, realistic.`, + }, + + // ── Pulsegate (계기판 · 코발트) ── + { + slug: "pulsegate", name: "datacenter", + prompt: `$imagegen Photograph, 16:9 landscape. A dark data-center aisle at night: two rows of black server racks receding in one-point perspective, thin vertical strips of small blue status LEDs (#3D7BD6) catching haze, cold and precise, no overhead lights. Deep navy-black palette (#0B0E12). ${NO}. Photographic, realistic.`, + }, + + // ── Lumina Optics (광학 실험실) ── + { + slug: "lumina-optics", name: "lens-section", + prompt: `$imagegen Macro photograph, 3:4 portrait. A cutaway cross-section of a precision camera lens showing a stack of glass elements in a metal barrel, lit from behind so each glass surface shows a faint edge refraction line. Neutral dark charcoal background (#101214), cool neutral light, laboratory precision mood. ${NO}. Photographic, realistic.`, + }, + { + slug: "lumina-optics", name: "prism-bench", + prompt: `$imagegen Photograph, 16:9 landscape. A single glass prism standing on a dark optical breadboard (matrix of tapped holes faintly visible), one collimated white light beam entering and splitting into a subtle spectrum as it exits. Dark laboratory (#0D0F12), the light streak is the only bright element. ${NO}. Photographic, realistic.`, + }, + + // ── 궤도 우주공학 (딥 네이비 · 구리) ── + { + slug: "orbit-aerospace", name: "static-fire", + prompt: `$imagegen Photograph, 16:9 landscape. Night static-fire test of a compact rocket engine on a horizontal test stand: brilliant mach diamonds in the exhaust plume, copper-colored nozzle glowing from heat, deep dark-blue night sky (#07101C). Industrial test facility silhouettes at frame edges. ${NO}. Photographic, realistic, long-exposure feel.`, + }, + { + slug: "orbit-aerospace", name: "channel-macro", + prompt: `$imagegen Macro photograph, 3:4 portrait. Close-up of a regeneratively-cooled rocket combustion chamber section: a copper-alloy block with precision-milled cooling channels cut through its cross-section, on a workshop bench, warm work light from one side revealing the fine channel geometry. Copper #B06A3B tones on deep charcoal. ${NO}. Photographic, realistic.`, + }, + + // ── Synapse BCI (터미널 흑 · 뉴럴 골드) ── + { + slug: "synapse-bci", name: "chip-macro", + prompt: `$imagegen Extreme macro photograph, 3:4 portrait. A neural interface chip: black silicon die with dense gold trace lines and a fine grid of micro-electrode pads at one edge, mounted on a dark PCB, one thin gold reflection line (#D9A441) crossing the die. Near-black background (#08090B), laboratory focus-stacked sharpness. ${NO}. Photographic, realistic.`, + }, + { + slug: "synapse-bci", name: "thread-array", + prompt: `$imagegen Microscope photograph, 16:9 landscape. A microelectrode array: dozens of ultra-fine flexible threads with tiny gold electrode tips fanning out from a single edge, arranged on a dark stage, pale gold (#D9A441) filaments on near-black (#08090B). Scientific documentary image. ${NO}. Photographic, realistic.`, + }, +]; + +const filter = process.argv[2]; +const jobs = filter ? JOBS.filter((j) => j.slug.includes(filter)) : JOBS; + +const genDir = (n) => path.join(os.homedir(), ".codex", "generated_images"); + +for (const job of jobs) { + const out = path.join(staging, `${job.slug}--${job.name}.png`); + if (fs.existsSync(out)) { + console.log(`skip (exists): ${path.basename(out)}`); + continue; + } + console.log(`gen: ${job.slug}/${job.name}`); + let stdout = ""; + try { + // Windows: codex는 npm .cmd 심이라 cmd.exe 를 통해서만 해석된다. + // 프롬프트는 인자가 아니라 stdin(-)으로 — 셸 특수문자 회피. + stdout = execFileSync( + process.env.comspec || "cmd.exe", + ["/d", "/s", "/c", "codex exec --skip-git-repo-check -"], + { input: job.prompt, stdio: ["pipe", "pipe", "pipe"], timeout: 300000, encoding: "utf8" }, + ); + } catch (e) { + // 비정상 종료여도 stdout 이 있으면 세션 id 는 잡을 수 있다 + stdout = (e.stdout || "") + ""; + console.error(`codex nonzero for ${job.name}:`, e.message?.slice(0, 160)); + } + // 출력 헤더의 "session id: " 로 이 호출의 세션을 정확히 특정 (병렬 실행 안전) + const m = stdout.match(/session id:\s*([0-9a-f-]{36})/i); + let copied = false; + if (m) { + const dir = path.join(genDir(), m[1]); + const files = fs + .readdirSync(dir) + .filter((f) => f.startsWith("exec-") && f.endsWith(".png")) + .map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs })) + .sort((a, b) => b.t - a.t); + if (files[0]) { + fs.copyFileSync(path.join(dir, files[0].f), out); + copied = true; + } + } + console.log(copied ? `ok: ${path.basename(out)}` : `MISS: ${job.slug}/${job.name}`); +} +console.log("done"); diff --git a/apps/site/tools/invariants.mjs b/apps/site/tools/invariants.mjs new file mode 100644 index 0000000..3ecf19a --- /dev/null +++ b/apps/site/tools/invariants.mjs @@ -0,0 +1,182 @@ +// L2 — 렌더링 불변식. file:// 로 앱을 열어 검사한다(서버 불필요). +// 줄수(세로 쌓임·갸행) · 수축 탐지 · 대비 · axe 접근성 · 콘텐츠 정합 · 스케일×폭 행렬. +import puppeteer from "puppeteer-core"; +import path from "node:path"; +import url from "node:url"; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, "../public/work"); +const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"; +const results = []; +const fail = (name, detail = "") => results.push({ ok: false, name, detail }); +const pass = (name, detail = "") => results.push({ ok: true, name, detail }); + +const GAON_VIEWS = ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"]; +const DURE_VIEWS = ["catalog", "starred", "enrollments", "credits", "archive", "notices"]; + +const PAGE_UTILS = ` + window.__lines = (el) => { + if (!el) return -1; + const range = document.createRange(); + range.selectNodeContents(el); + const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4); + const tops = rects.map((r) => r.top).sort((a, b) => a - b); + const ls = []; + for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t); + return ls.length; + }; + window.__textLines = (el) => { + if (!el) return -1; + const tn = [...el.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim()); + const target = tn || el; + const range = document.createRange(); + range.selectNodeContents(target); + const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4); + const tops = rects.map((r) => r.top).sort((a, b) => a - b); + const ls = []; + for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t); + return ls.length; + }; +`; + +async function openApp(page, app) { + await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" }); + await page.evaluate(() => document.fonts.ready); + await page.evaluate(PAGE_UTILS); +} + +async function scanInvariants(page) { + return page.evaluate(() => { + const out = { stacked: [], contrast: [], h1: [] }; + const lum = ({ r, g, b }) => { + const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; }; + const blend = (top, bottom) => ({ r: top.r * top.a + bottom.r * (1 - top.a), g: top.g * top.a + bottom.g * (1 - top.a), b: top.b * top.a + bottom.b * (1 - top.a), a: 1 }); + const bgOf = (el) => { + let node = el; const stack = []; + while (node && node !== document.documentElement) { + const c = rgb(getComputedStyle(node).backgroundColor); + if (c && c.a >= 0.95) return c; + if (c && c.a > 0) stack.push(c); + node = node.parentElement; + } + let base = { r: 255, g: 255, b: 255, a: 1 }; + for (let i = stack.length - 1; i >= 0; i--) base = blend(stack[i], base); + return base; + }; + const h1 = document.querySelector(".view.is-on h2.v-h") || document.querySelector("h1"); + if (h1 && window.__lines(h1) > 1) out.h1.push(h1.textContent.trim().slice(0, 16)); + document.querySelectorAll("body *").forEach((el) => { + if (!el.offsetParent) return; + const txt = el.textContent.trim(); + const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim()); + if (!txt || !ownText) return; + const cs = getComputedStyle(el); + if (txt.length <= 14 && !el.querySelector("*") && window.__textLines(el) >= 3) { + out.stacked.push(txt.slice(0, 12) + " (" + window.__textLines(el) + "줄)"); + } + const fg = rgb(cs.color); + if (fg) { + const bg = bgOf(el); + const fgb = blend(fg, bg); + const l1 = lum(fgb), l2 = lum(bg); + const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2); + const size = parseFloat(cs.fontSize); + const w = +cs.fontWeight || 400; + const need = size >= 24 || (size >= 18.66 && w >= 600) ? 3 : 4.5; + if (ratio < need) { + const key = cs.color + Math.round(size); + if (!out.contrast.some((c) => c.key === key)) { + out.contrast.push({ key, txt: txt.slice(0, 12), ratio, need }); + } + } + } + }); + return out; + }); +} + +async function runAxe(page) { + const axePath = path.resolve(here, "../node_modules/axe-core/axe.min.js"); + await page.addScriptTag({ path: axePath }); + const res = await page.evaluate(async () => { + const r = await window.axe.run(document, { resultTypes: ["violations"] }); + return r.violations.map((v) => ({ id: v.id, impact: v.impact, nodes: v.nodes.length })); + }); + return res; +} + +async function contentConsistency(page) { + return page.evaluate(() => { + const out = []; + if (window.GAON && document.getElementById("rep-stats")) { + const stats = GAON.rosterStats(STUDENTS); + const txt = document.getElementById("rep-stats").textContent; + if (!txt.includes(stats.active + "명")) out.push("리포트 재학생 " + stats.active + "명 표시 안 됨"); + if (!txt.includes(stats.avgAtt + "%")) out.push("리포트 평균출석 " + stats.avgAtt + "% 표시 안 됨"); + if (!txt.includes(stats.hwRate + "%")) out.push("리포트 제출률 " + stats.hwRate + "% 표시 안 됨"); + } + if (window.DURE && document.getElementById("en-credits")) { + const list = [...enrolled].map((id) => COURSES.find((c) => c.id === id)); + const want = DURE.credits(list); + const got = document.getElementById("en-credits").textContent.trim(); + if (got !== String(want)) out.push("신청 학점 표시 " + got + " ≠ 재계산 " + want); + } + return out; + }); +} + +const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] }); +const page = await browser.newPage(); +await page.setCacheEnabled(false); + +for (const [app, views] of [["gaon-lms", GAON_VIEWS], ["dure-enrollment", DURE_VIEWS]]) { + await page.setViewport({ width: 1440, height: 900 }); + await openApp(page, app); + for (const v of views) { + await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), v); + await new Promise((r) => setTimeout(r, 80)); + const inv = await scanInvariants(page); + if (inv.h1.length) fail(`${app}/${v} 뷰 제목 여러 줄`, inv.h1.join(", ")); + if (inv.stacked.length) fail(`${app}/${v} 세로 쌓임(수축)`, inv.stacked.slice(0, 4).join(", ")); + if (inv.contrast.length) fail(`${app}/${v} 대비 미달`, inv.contrast.slice(0, 3).map((c) => `"${c.txt}" ${c.ratio}:1`).join(", ")); + } + pass(`${app} 불변식(전 뷰)`, "제목·수축·대비"); + + await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), views[0]); + const violations = await runAxe(page); + const serious = violations.filter((v) => v.impact === "serious" || v.impact === "critical"); + if (serious.length) fail(`${app} axe 심각 위반`, serious.map((v) => `${v.id}×${v.nodes}`).join(", ")); + else pass(`${app} axe`, violations.length ? `minor ${violations.length}종 무시` : "위반 0"); + + const cons = await contentConsistency(page); + if (cons.length) fail(`${app} 콘텐츠 정합`, cons.join(" / ")); + else pass(`${app} 콘텐츠 정합`); + + let matrixBad = []; + for (const w of [320, 375, 390]) { + for (const scale of [1.0, 1.3]) { + await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true }); + await openApp(page, app); + await page.addStyleTag({ content: `html { font-size: ${16 * scale}px !important; }` }); + const lines = await page.evaluate("window.__lines(document.querySelector('.view.is-on h2.v-h') || document.querySelector('h1'))"); + if (lines > 1) matrixBad.push(`${w}@${scale}x:${lines}줄`); + } + } + if (matrixBad.length) fail(`${app} 스케일×폭 제목`, matrixBad.join(", ")); + else pass(`${app} 스케일×폭 제목 행렬`); + + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await openApp(page, app); + const brandLines = await page.evaluate("window.__lines(document.querySelector('.brand, .appbar .brand'))"); + if (brandLines > 1) fail(`${app} 브랜드 세로 쌓임`, brandLines + "줄"); + else pass(`${app} 브랜드 1줄`); +} +await browser.close(); + +const totalFail = results.filter((r) => !r.ok).length; +for (const r of results) console.log((r.ok ? "PASS" : "FAIL") + " " + r.name + (r.detail ? ` — ${r.detail}` : "")); +console.log(totalFail ? `\nINVARIANTS: ${totalFail} FAIL` : "\nINVARIANTS ALL PASS"); +process.exit(totalFail ? 1 : 0); diff --git a/apps/site/tools/unit/calc.test.mjs b/apps/site/tools/unit/calc.test.mjs new file mode 100644 index 0000000..e93f3c3 --- /dev/null +++ b/apps/site/tools/unit/calc.test.mjs @@ -0,0 +1,90 @@ +// L1 단위 테스트 — 계산 로직 경계값. 브라우저 없이 Node 에서 직접 실행. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const load = (rel) => { + const m = {}; + new Function("module", fs.readFileSync(path.join(here, rel), "utf8"))(m); + return m.exports; +}; +const GAON = load("../../public/work/gaon-lms/calc.js"); +const DURE = load("../../public/work/dure-enrollment/calc.js"); + +let passed = 0, failed = 0; +const t = (name, fn) => { + try { fn(); passed++; console.log("PASS " + name); } + catch (e) { failed++; console.log("FAIL " + name + " — " + e.message); } +}; + +console.log("── 가온 ──"); +t("attSummary: 전원 출석 → 100%", () => assert.equal(GAON.attSummary([2, 2, 2, 2, 2]), "100%")); +t("attSummary: 혼합 4/5 → 80%", () => assert.equal(GAON.attSummary([2, 2, 2, 2, 1]), "80%")); +t("attSummary: 빈 행 → 휴학", () => assert.equal(GAON.attSummary([]), "휴학")); +t("attSummary: null/undefined 무시", () => assert.equal(GAON.attSummary([2, null, 2, undefined, 0]), "67%")); +t("attSummary: 1/3 반올림 → 33%", () => assert.equal(GAON.attSummary([2, 0, 0]), "33%")); + +t("dayTotal/dayPresent: 행 배열 목록 (0/3 사고 회귀)", () => { + const marks = [[2, 2, 2, 2, 2], [2, 1, 2, 2, 2], null]; + assert.equal(GAON.dayTotal(marks, 0), 2); + assert.equal(GAON.dayPresent(marks, 0), 2); + assert.equal(GAON.dayPresent(marks, 1), 1); // 지각 1명은 출석 아님 +}); +t("dayPresent: 전원 결석일 → 0", () => assert.equal(GAON.dayPresent([[0, 0], [0, 0]], 0), 0)); +t("dayTotal: 전부 null 행 → 0 (나눗셈 가드)", () => assert.equal(GAON.dayTotal([null, null], 0), 0)); + +t("counts: 집계 정합", () => { + const c = GAON.counts([[2, 1, 0], [2, 2, 2]]); + assert.deepEqual([c.att, c.late, c.abs, c.total, c.rate], [4, 1, 1, 6, 67]); +}); +t("counts: 빈 입력 → total 0, rate 0 (NaN 아님)", () => { + const c = GAON.counts([]); + assert.equal(c.rate, 0); assert.equal(c.total, 0); assert.ok(!Number.isNaN(c.rate)); +}); + +t("rosterStats: 0명 명단 → 0/NaN 없음", () => { + const s = GAON.rosterStats([]); + assert.deepEqual([s.total, s.active, s.avgAtt, s.hwRate], [0, 0, 0, 0]); +}); +t("rosterStats: 재학만 통계에 포함", () => { + const s = GAON.rosterStats([ + { status: "재학", att: 90, hw: "8/8" }, + { status: "재학", att: 70, hw: "6/8" }, + { status: "휴학", att: 0, hw: "0/8" }, + ]); + assert.equal(s.active, 2); + assert.equal(s.avgAtt, 80); + assert.deepEqual([s.hwDone, s.hwTotal, s.hwRate], [14, 16, 88]); +}); +t("rosterStats: 홀수 반올림", () => assert.equal(GAON.rosterStats([{ status: "재학", att: 91, hw: "1/3" }]).avgAtt, 91)); + +console.log("── 두레 ──"); +t("isOverlap: 같은 요일 겹침 → true", () => + assert.equal(DURE.isOverlap({ days: [1, 3], start: 10, end: 11.5 }, { days: [3], start: 11, end: 12 }), true)); +t("isOverlap: 닿는 경계(끝==시작) → false", () => + assert.equal(DURE.isOverlap({ days: [1], start: 10, end: 12 }, { days: [1], start: 12, end: 14 }), false)); +t("isOverlap: 다른 요일 같은 시간 → false", () => + assert.equal(DURE.isOverlap({ days: [1], start: 10, end: 12 }, { days: [2], start: 10, end: 12 }), false)); +t("isOverlap: 동일 과목 → true", () => + assert.equal(DURE.isOverlap({ days: [2, 4], start: 13, end: 14.5 }, { days: [4, 2], start: 13, end: 14.5 }), true)); +t("isOverlap: 부분 겹침 → true", () => + assert.equal(DURE.isOverlap({ days: [5], start: 9, end: 12 }, { days: [5], start: 11, end: 13 }), true)); + +t("credits: 합계", () => assert.equal(DURE.credits([{ credits: 3 }, { credits: 2 }]), 5)); +t("credits: 빈 목록 → 0", () => assert.equal(DURE.credits([]), 0)); +t("credits: credits 없는 항목 → 0 취급", () => assert.equal(DURE.credits([{}, { credits: 3 }]), 3)); + +t("wouldExceed: 15+3 ≤ 18 → false", () => assert.equal(DURE.wouldExceed(15, 3, 18), false)); +t("wouldExceed: 16+3 > 18 → true (경계)", () => assert.equal(DURE.wouldExceed(16, 3, 18), true)); +t("wouldExceed: 18+0 → false (경계)", () => assert.equal(DURE.wouldExceed(18, 0, 18), false)); + +t("seatsState: 만석 → 정원 마감", () => assert.deepEqual(DURE.seatsState({ seats: 0 }), { label: "정원 마감", closed: true, near: false })); +t("seatsState: 여석 5 → 임박", () => assert.ok(DURE.seatsState({ seats: 5 }).near)); +t("seatsState: 여석 6 → 임박 아님 (경계)", () => assert.ok(!DURE.seatsState({ seats: 6 }).near)); + +t("remaining: 정상 잔여", () => assert.equal(DURE.remaining(130, 94, 3), 33)); +t("remaining: 초과 → 음수", () => assert.equal(DURE.remaining(24, 21, 6), -3)); + +console.log(`\n${failed ? `${failed} FAIL` : "UNIT ALL PASS"} (${passed} passed)`); +process.exit(failed ? 1 : 0); diff --git a/apps/site/tools/verify-report.md b/apps/site/tools/verify-report.md new file mode 100644 index 0000000..c9af17d --- /dev/null +++ b/apps/site/tools/verify-report.md @@ -0,0 +1,14 @@ +# 검증 리포트 — 2026-08-22 12:12 + +게이트: **전 계층 통과 — 배포 가능** · 총 48.3초 + +| 계층 | 결과 | 항목 | 소요 | +|---|---|---|---| +| L0-정적/stylelint | PASS | PASS — | 1.5s | +| L0-정식/html-validate | PASS | PASS — | 1.2s | +| L1-단위/계산로직 | PASS | PASS 30 | 0.0s | +| L2-기능/감사 | PASS | PASS 61 | 7.6s | +| L2-불변식/렌더 | PASS | PASS 10 | 16.5s | +| L3-시각회귀/diff | PASS | PASS 30 | 10.9s | +| L4-WebKit | PASS | PASS 8 | 4.5s | +| L5-탐색/키보드 | PASS | PASS 14 | 6.0s | diff --git a/apps/site/tools/verify.mjs b/apps/site/tools/verify.mjs new file mode 100644 index 0000000..6e39798 --- /dev/null +++ b/apps/site/tools/verify.mjs @@ -0,0 +1,62 @@ +// QA 게이트 — L0~L5 전 계층 실행. 1건 실패라도 있으면 exit 1 (배포 금지). +// 사용: node tools/verify.mjs / npm run verify +// 기준 화면 갱신: node tools/visual.mjs --update-baseline 후 커밋. +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const root = path.resolve(here, ".."); +const npx = process.platform === "win32" ? "npx.cmd" : "npx"; + +const steps = [ + { + layer: "L0-정적/stylelint", + cmd: () => spawnSync(npx, ["stylelint", "public/work/*/styles/*.css"], { cwd: root, encoding: "utf8", shell: true }), + }, + { + layer: "L0-정식/html-validate", + cmd: () => spawnSync(npx, ["html-validate", "public/work/gaon-lms/index.html", "public/work/dure-enrollment/index.html"], { cwd: root, encoding: "utf8", shell: true }), + }, + { layer: "L1-단위/계산로직", cmd: () => spawnSync(process.execPath, ["tools/unit/calc.test.mjs"], { cwd: root, encoding: "utf8" }) }, + { layer: "L2-기능/감사", cmd: () => spawnSync(process.execPath, ["tools/audit.mjs"], { cwd: root, encoding: "utf8" }) }, + { layer: "L2-불변식/렌더", cmd: () => spawnSync(process.execPath, ["tools/invariants.mjs"], { cwd: root, encoding: "utf8" }) }, + { layer: "L3-시각회귀/diff", cmd: () => spawnSync(process.execPath, ["tools/visual.mjs"], { cwd: root, encoding: "utf8" }) }, + { layer: "L4-WebKit", cmd: () => spawnSync(process.execPath, ["tools/webkit.mjs"], { cwd: root, encoding: "utf8" }) }, + { layer: "L5-탐색/키보드", cmd: () => spawnSync(process.execPath, ["tools/exploratory.mjs"], { cwd: root, encoding: "utf8" }) }, +]; + +const report = []; +const t0 = Date.now(); +let failed = 0; +for (const step of steps) { + const s = Date.now(); + const r = step.cmd(); + const ok = r.status === 0; + if (!ok) failed++; + const out = (r.stdout || "") + (r.stderr || ""); + const passLine = (out.match(/PASS\s+[^\n]/g) || []).length; + const failLines = (out.match(/FAIL\s+[^\n]/g) || []).length; + const tail = out.trim().split("\n").slice(-3).join(" | ").slice(0, 180); + report.push({ layer: step.layer, ok, ms: Date.now() - s, passLine, failLines, tail }); + console.log(`${ok ? "✓" : "✗"} ${step.layer} (${((Date.now() - s) / 1000).toFixed(1)}s)${passLine ? ` — PASS ${passLine}` : ""}${failLines ? ` FAIL ${failLines}` : ""}`); + if (!ok) console.log(" " + tail); +} + +const totalS = ((Date.now() - t0) / 1000).toFixed(1); +const stamp = new Date().toISOString().replace("T", " ").slice(0, 16); +const md = [ + `# 검증 리포트 — ${stamp}`, + ``, + `게이트: ${failed ? `**${failed}개 계층 실패 — 배포 금지**` : "**전 계층 통과 — 배포 가능**"} · 총 ${totalS}초`, + ``, + `| 계층 | 결과 | 항목 | 소요 |`, + `|---|---|---|---|`, + ...report.map((r) => `| ${r.layer} | ${r.ok ? "PASS" : "**FAIL**"} | ${r.failLines ? `FAIL ${r.failLines}` : `PASS ${r.passLine || "—"}`} | ${(r.ms / 1000).toFixed(1)}s |`), + ``, + ...report.filter((r) => !r.ok).map((r) => `## ${r.layer} 실패 상세\n\n\`\`\`\n${r.tail}\n\`\`\``), +].join("\n"); +fs.writeFileSync(path.join(here, "verify-report.md"), md); +console.log(`\n${failed ? `게이트 실패(${failed}계층)` : "게이트 통과"} — 리포트: tools/verify-report.md`); +process.exit(failed ? 1 : 0); diff --git a/apps/site/tools/visual.mjs b/apps/site/tools/visual.mjs new file mode 100644 index 0000000..7c80361 --- /dev/null +++ b/apps/site/tools/visual.mjs @@ -0,0 +1,81 @@ +// L3 — 시각 회귀. 기준 화면과 pixelmatch 비교. +// 사용: node tools/visual.mjs [--update-baseline] +// 규칙: diff 픽셀 비율 ≥ 0.1% → 실패(안티앨리어싱 잡음은 무시). +// 기준 갱신은 검증된 배포 후에만 --update-baseline 으로. +import puppeteer from "puppeteer-core"; +import pixelmatch from "pixelmatch"; +import { PNG } from "pngjs"; +import path from "node:path"; +import url from "node:url"; +import fs from "node:fs"; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, "../public/work"); +const SHOTS = path.join(here, "__shots__"); +const BASE = path.join(SHOTS, "baseline"); +const CURR = path.join(SHOTS, "current"); +const CHROME = "C:/Program Files/Google/Chrome/Application/chrome.exe"; +const UPDATE = process.argv.includes("--update-baseline"); + +const APPS = { + "gaon-lms": ["dashboard", "students", "attendance", "grades", "files", "calendar", "counsel", "report", "notice"], + "dure-enrollment": ["catalog", "starred", "enrollments", "credits", "archive", "notices"], +}; +const WIDTHS = [390, 1440]; + +fs.mkdirSync(BASE, { recursive: true }); +fs.mkdirSync(CURR, { recursive: true }); + +const browser = await puppeteer.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] }); +const page = await browser.newPage(); +await page.setCacheEnabled(false); + +const shots = []; +for (const [app, views] of Object.entries(APPS)) { + for (const w of WIDTHS) { + await page.setViewport({ width: w, height: w === 390 ? 844 : 900 }); + await page.goto(url.pathToFileURL(path.join(ROOT, app, "index.html")).href, { waitUntil: "networkidle0" }); + await page.evaluate(() => document.fonts.ready); + await new Promise((r) => setTimeout(r, 250)); + for (const v of views) { + await page.evaluate((name) => document.querySelector(`[data-view="${name}"]`).click(), v); + await new Promise((r) => setTimeout(r, 100)); + const name = `${app}-${v}-${w}.png`; + const file = path.join(CURR, name); + await page.screenshot({ path: file }); + shots.push(name); + } + } +} +await browser.close(); + +let fails = 0; +const rows = []; +for (const name of shots) { + const cur = PNG.sync.read(fs.readFileSync(path.join(CURR, name))); + const baseFile = path.join(BASE, name); + if (UPDATE || !fs.existsSync(baseFile)) { + fs.writeFileSync(baseFile, PNG.sync.write(cur)); + rows.push({ name, status: UPDATE ? "갱신" : "신규기준", diff: "—" }); + continue; + } + const base = PNG.sync.read(fs.readFileSync(baseFile)); + if (base.width !== cur.width || base.height !== cur.height) { + rows.push({ name, status: "FAIL", diff: "크기 불일치" }); + fails++; + continue; + } + const diff = new PNG({ width: base.width, height: base.height }); + const n = pixelmatch(base.data, cur.data, diff.data, base.width, base.height, { threshold: 0.1 }); + const ratio = (n / (base.width * base.height)) * 100; + const bad = ratio >= 0.1; + if (bad) { + fails++; + fs.writeFileSync(path.join(SHOTS, `diff-${name}`), PNG.sync.write(diff)); + } + rows.push({ name, status: bad ? "FAIL" : "PASS", diff: ratio.toFixed(3) + "%" }); +} + +for (const r of rows) console.log(`${r.status.padEnd(4)} ${r.name} ${r.diff}`); +console.log(`\nVISUAL: ${fails ? `${fails} FAIL (기준 대비 회귀)` : UPDATE ? "기준 갱신 완료" : "ALL MATCH"}`); +process.exit(fails ? 1 : 0); diff --git a/apps/site/tools/webkit.mjs b/apps/site/tools/webkit.mjs new file mode 100644 index 0000000..2fae84f --- /dev/null +++ b/apps/site/tools/webkit.mjs @@ -0,0 +1,82 @@ +// L4 — WebKit(사파리 엔진) 크로스 브라우저 검증. +// 핵심 불변식(h1 줄수·브랜드 1줄·수축 탐지·콘텐츠 정합·오버플로)을 WebKit 에서 재실행한다. +import { webkit } from "playwright"; +import path from "node:path"; +import url from "node:url"; + +const here = path.dirname(url.fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, "../public/work"); +const results = []; +const pass = (name, detail = "") => results.push({ ok: true, name, detail }); +const fail = (name, detail = "") => results.push({ ok: false, name, detail }); + +const APPS = { + "gaon-lms": ["dashboard", "students", "attendance", "grades", "notice"], + "dure-enrollment": ["catalog", "starred", "enrollments", "credits"], +}; + +const UTILS = ` + window.__lines = (sel) => { + const el = document.querySelector(sel); + if (!el) return -1; + const range = document.createRange(); + range.selectNodeContents(el); + const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4); + const tops = rects.map((r) => r.top).sort((a, b) => a - b); + const ls = []; + for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t); + return ls.length; + }; +`; + +const browser = await webkit.launch(); +const page = await browser.newPage(); +await page.setViewportSize({ width: 390, height: 844 }); + +for (const [app, views] of Object.entries(APPS)) { + const appUrl = url.pathToFileURL(path.join(ROOT, app, "index.html")).href; + await page.goto(appUrl, { waitUntil: "networkidle" }); + await page.evaluate(UTILS); + await page.evaluate(() => document.fonts.ready); + + // 브랜드 1줄 + h1 1줄 + 페이지 오버플로 + const brand = await page.evaluate(`__lines('.brand')`); + if (brand > 1) fail(`${app} WebKit 브랜드 세로`, brand + "줄"); else pass(`${app} WebKit 브랜드`); + const h1 = await page.evaluate(`__lines('h1')`); + if (h1 > 1) fail(`${app} WebKit h1 여러 줄`, h1 + "줄"); else pass(`${app} WebKit h1`); + + for (const v of views) { + await page.click(`[data-view="${v}"]`); + await page.waitForTimeout(120); + const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + if (over > 0) fail(`${app}/${v} WebKit 오버플로`, `+${over}px`); + const vh1 = await page.evaluate(`__lines('.view.is-on h1, h1')`); + if (vh1 > 1) fail(`${app}/${v} WebKit h1`, vh1 + "줄"); + } + pass(`${app} WebKit 전 뷰 오버플로·h1`); + + // 콘텐츠 정합 (JS 계산 동일성) + const cons = await page.evaluate(() => { + const out = []; + if (window.GAON && document.getElementById("rep-stats")) { + const s = GAON.rosterStats(STUDENTS); + const txt = document.getElementById("rep-stats").textContent; + if (!txt.includes(s.active + "명")) out.push("재학 " + s.active); + if (!txt.includes(s.avgAtt + "%")) out.push("평균 " + s.avgAtt); + } + if (window.DURE && document.getElementById("en-credits")) { + const list = [...enrolled].map((id) => COURSES.find((c) => c.id === id)); + const want = String(DURE.credits(list)); + const got = document.getElementById("en-credits").textContent.trim(); + if (got !== want) out.push("학점 " + got + "≠" + want); + } + return out; + }); + if (cons.length) fail(`${app} WebKit 콘텐츠 정합`, cons.join(",")); else pass(`${app} WebKit 콘텐츠 정합`); +} +await browser.close(); + +const fails = results.filter((r) => !r.ok).length; +for (const r of results) console.log((r.ok ? "PASS" : "FAIL") + " " + r.name + (r.detail ? ` — ${r.detail}` : "")); +console.log(fails ? `\nWEBKIT: ${fails} FAIL` : "\nWEBKIT ALL PASS"); +process.exit(fails ? 1 : 0); diff --git a/packages/skill/CHANGELOG.md b/packages/skill/CHANGELOG.md index f8a1c6b..d3fcf16 100644 --- a/packages/skill/CHANGELOG.md +++ b/packages/skill/CHANGELOG.md @@ -1,7 +1,26 @@ -# @designpaca/skill +# CHANGELOG + +## 0.6.0 — 자율 검증 폐쇄 루프 + 리듬·트랙 불변식 (2026-08-22) + +- 핵심 규칙 6: 완료의 정의 = 게이트 통과. 사용자를 QA 로 쓰지 않는다. +- 5단계 기계 검사 4번째: design-gate 실행 의무. +- references/audit-gate.md 신설(사고-검사 매핑·SEO/meta 체크리스트·OS/브라우저 특성·하니스 규칙). +- tools/design-gate.mjs 신설: 메타/SEO·대비·수축·리듬·트랙(숫자 라벨 등폭·빈 셀 트랙·등간격)·스케일×폭 + 옵션 L0/L3/L4. checks 깊은 병합, 의존성 미비 SKIP. +- 실전 검증: 시간표 자동배치 결함(라벨 폭 40/88 혼재) 재현 픽스처 검출 + 수정된 앱 15뷰 통과(오탐 0). + + +## 자율 검증 폐쇄 루프 내재화 (2026-08-22) + +- 핵심 규칙 6 추가: 완료의 정의 = 게이트 통과. 사용자를 QA 로 쓰지 않는다. +- 5단계 기계 검사 4번째: design-gate 실행 의무화. +- references/audit-gate.md 신설: 6계층·사고-검사 매핑·SEO/meta 체크리스트·OS/브라우저 특성·하니스 규칙 7개. +- tools/design-gate.mjs 신설: 범용 게이트(불변식·SEO/meta·대비·수축·스케일×폭 + 옵션 시각회귀/WebKit), --init 스캐폴딩, 의존성 미비는 SKIP 처리. + +# +@designpaca/skill ## 0.5.0 ### Minor Changes -- 8d02820: 어떤 하네스 에이전트든 스킬을 발견하게 만든다. 중립 경로 `agents` 타깃(`~/.agents/skills` · `.agents/skills`)을 새로 추가한다 — Cursor · Antigravity · ZCode 등 Agent Skills 규약을 읽는 하네스가 함께 잡는 자리다. `gemini-cli`(`.gemini/skills`)와 `copilot`(`.github/skills` · `~/.copilot/skills`) 타깃을 추가한다. Cursor 는 구식 `.cursor/rules/*.mdc` 변환을 버리고 네이티브 `.cursor/skills` 로 옮기며 전역 설치를 지원한다. Windsurf 도 규칙 파일(12,000자 상한 때문에 실제 스킬이 아예 설치되지 못했다)에서 `.windsurf/skills` · `~/.codeium/windsurf/skills` 네이티브 경로로 옮긴다. Antigravity 전역 경로를 공식 마이그레이션 문서대로 `~/.gemini/antigravity-cli/skills` 로 바로잡는다. +- 8d02820: 어떤 하네스 에이전트든 스킬을 발견하게 만든다. 중립 경로 `agents` 타깃(`~/.agents/skills` · `.agents/skills`)을 새로 추가한다 — Cursor · Antigravity · ZCode 등 Agent Skills 규약을 읽는 하네스가 함께 잡는 자리다. `gemini-cli`(`.gemini/skills`)와 `copilot`(`.github/skills` · `~/.copilot/skills`) 타깃을 추가한다. Cursor 는 구식 `.cursor/rules/*.mdc` 변환을 버리고 네이티브 `.cursor/skills` 로 옮기며 전역 설치를 지원한다. Windsurf 도 규칙 파일(12,000자 상한 때문에 실제 스킬이 아예 설치되지 못했다)에서 `.windsurf/skills` · `~/.codeium/windsurf/skills` 네이티브 경로로 옮긴다. Antigravity 전역 경로를 공식 마이그레이션 문서대로 `~/.gemini/antigravity-cli/skills` 로 바로잡는다. \ No newline at end of file diff --git a/packages/skill/SKILL.md b/packages/skill/SKILL.md index 65dc7ce..cb1a52f 100644 --- a/packages/skill/SKILL.md +++ b/packages/skill/SKILL.md @@ -34,6 +34,7 @@ description: "웹 디자인 전 과정을 끌고 가는 파이프라인 스킬. 3. **화려함은 4순위다.** Awwwards 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10이다. 3D를 얹는 것보다 타입 스케일을 정돈하는 게 결과에 두 배 유효하다. 4. **대담함은 한 곳에만.** 리스크는 하나다. 나머지는 조용히 받쳐준다. 두 곳에서 소리치면 둘 다 죽는다. 5. **성능 예산을 넘기면 그 이펙트는 채택하지 않는다.** 예산은 3단계에서 정하고 5단계에서 검증한다. 데모·포트폴리오 브리프면 예산을 올려도 되지만 **올렸다고 말해야 한다**. +6. **완료의 정의는 게이트 통과다(자율 검증 폐쇄 루프).** 5단계에서 `tools/design-gate.mjs` 를 프로젝트에 심어 돌리고, 실패 항목이 있으면 4단계로 돌아가 고치고 **전체를 재실행**한다. 게이트 없이 6단계로 넘어가지 않는다. 사용자를 QA 로 쓰지 않는다 — 검증은 기본 내장, 사용자 피드백은 보조다. 상세는 `references/audit-gate.md`. **접근성은 예외다. 이것만은 협상하지 않는다.** 키보드·포커스·대비·`prefers-reduced-motion`은 어떤 이펙트보다, 어떤 브리프보다 우선한다. 픽셀은 왜곡해도 DOM은 살린다. @@ -276,6 +277,9 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는 1. **슬롭 지문 grep** — 보라 CTA(`#6366f1`·`#8b5cf6` 계열), 전체 대문자 헤드라인, 번호 매긴 1·2·3 단계. 실측 검출률 상위 항목이다 2. **카피 경쟁사 치환 테스트** — 제품명을 경쟁사 이름으로 바꿔도 문장이 성립하면, 그 카피는 아무것도 말하지 않았다 3. **이펙트 전부 끄기** — CSS 필터·WebGL·애니메이션을 끈 상태에서 페이지가 여전히 읽히는가 +4. **게이트 실행(폐쇄 루프)** — `tools/design-gate.mjs --init` 으로 설정을 만들고 돌린다. 오버플로·h1·수축·대비·SEO/meta·스케일×폭까지 전 뷰 × 전 폭. L1(단위)·L5(탐색)는 프로젝트 `tools/` 에 보강해 `npm run verify` 체인으로. **게이트 통과 없이 완료 선언 금지.** + +> 게이트가 잡은 실제 사고 목록과 6계층 설계 근거는 `references/audit-gate.md` 다. 자기 검사 스크립트를 매번 새로 쓰지 마라 — 이 게이트를 심고 확장해라. #### 하드 게이트 — 전부 "아니오"여야 한다 @@ -350,6 +354,8 @@ HTML-in-Canvas(`drawElementImage`)는 **폴백을 완성한 뒤에만** 얹는 | `references/experimental-canvas.md` | 4단계 — HTML-in-Canvas를 검토할 때 | | `references/preflight.md` | 5단계 — 감사 | | `references/antipatterns.md` | 3·5단계 — 한글 조판 / 슬롭 검출 | +| `references/audit-gate.md` | **5단계 — 게이트(폐쇄 루프) 설계·설치·하니스 규칙** | +| `tools/design-gate.mjs` | **5단계 — 범용 게이트 실행기(SEO/meta·대비·수축·시각·WebKit 옵션)** | ## 작업 중 지켜야 할 것 diff --git a/packages/skill/package.json b/packages/skill/package.json index 9767eda..5cfbf26 100644 --- a/packages/skill/package.json +++ b/packages/skill/package.json @@ -1,6 +1,6 @@ { "name": "@designpaca/skill", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "designpaca 스킬 원본 — SKILL.md 와 참조 문서", "scripts": { diff --git a/packages/skill/references/audit-gate.md b/packages/skill/references/audit-gate.md new file mode 100644 index 0000000..6338ac3 --- /dev/null +++ b/packages/skill/references/audit-gate.md @@ -0,0 +1,80 @@ +# audit-gate — 자율 검증 폐쇄 루프 + +5단계(감사)를 **완료 선언의 게이트**로 만드는 문서다. 핵심 원칙은 하나다: + +> **완료의 정의는 게이트 통과다. 사용자를 QA 로 쓰지 않는다.** + +"고쳤다"는 말의 근거는 검증 리포트의 수치다. 게이트가 실패 항목을 내면 4단계로 돌아가 고치고 **다시 전체를 돌린다** — 고친 것만 재검하지 않는다. 이 루프(구현 → 게이트 → 실패 → 수정 → 재게이트)가 designpaca의 기본 동작이며, 게이트 없이 6단계로 넘어가는 것은 경로 무관 금지다. + +## 왜 필요한가 — 실제 사고 목록 + +이 게이트의 각 검사는 사용자 보고로야 발견됐던 결함에서 왔다. 어느 검사가 어느 사고를 잡는지가 설계 근거다. + +| 사고 (실제) | 검사 | +|---|---| +| CSS 닫는 중괄호 누락 — 이후 규칙 절반이 죽은 채 렌더 | stylelint(구문) + 중괄호 균형 | +| 브랜드가 모바일에서 1글자 폭으로 수축, 세로로 쌓임 | 수축 탐지(짧은 라벨 n줄 이상) + 브랜드 1줄 | +| 기기 글꼴 확대(안드로이드 큰 글꼴 1.3×)에서 제목 부서짐 | 스케일×폭 h1 행렬 | +| 본문 보조색이 배경 대비 4.4:1 (AA 미달) | 전 요소 대비 스캔(블렌드 계산) | +| 조사만 하고 안 고친 영역(관심 과목 등) | 전 뷰 순회 — 검사는 '내가 고친 것'이 아니라 전체 표면 | +| 대시보드 26명 ↔ 명단 12명 숫자 모순 | 콘텐츠 정합(요약 = 재계산 비교) | +| aria-label 을 div/ul 에 오용(보조기술 무시) | html-validate + axe | +| dl 안 `

        `, role="grid" 자식 구문 위반 | html-validate + axe | +| 계산 로직 오류(행 배열을 값으로 셈 → 0/3) | 단위 테스트(브라우저 밖 경계값) | +| 같은 데이터 속성이 여러 뷰에 존재해 선택자 오작동 | 하니스 규칙: 선택자는 컨테이너 스코프 | +| 혼합 span 그리드의 자동 배치가 시간 라벨을 흩어놓음(같은 열 폭 40/78 혼재, Δ 불규칙) | **리듬·트랙 불변식** — 같은 부모·같은 클래스 형제 중 (a) 숫자/시간 라벨(등폭 의도)과 (b) 빈 격자 셀(트랙 균일 의도)은 폭이 균일해야 하고, 한 축 정렬·크기 균일 형제는 등간격이어야 한다. 한국어 라벨은 글자수가 같아도 내용 폭차가 자연스러우므로 폭 검사 대상에서 뺀다(오탐 방지). 근본 수칙: 혼합 span 그리드는 자동 배치를 믿지 않고 좌표를 명시하라 | + +## 6계층 + +`tools/design-gate.mjs` 가 한 파일로 돌리는 것과 프로젝트가 보강하는 것이 있다. + +| 계층 | 도구 | 비고 | +|---|---|---| +| L0 정적 | stylelint + html-validate | 문법·구문 위반을 브라우저 켜기 전에 | +| L1 단위 | 순수 계산 로직 추출(calc.js) + 경계값 테스트 | 프로젝트별 작성 — 0명/만석/초과/빈배열 | +| L2 불변식 | `design-gate.mjs` — 오버플로·h1·수축·대비·SEO/meta | 전 뷰 × 전 폭 | +| L3 시각 회귀 | 기준 화면 pixelmatch diff (≥0.1% 실패) | 갱신은 검증된 배포 후 `--update-baseline` | +| L4 WebKit | Playwright webkit | 사파리 엔진 렌더 차이 | +| L5 탐색 | 키보드 완전 통과 + 사용자 여정 차터 | 프로젝트별 작성 — 마우스 금지 | + +## 설치 (프로젝트에 게이트 심기) + +``` +node <스킬>/tools/design-gate.mjs --init # gate.config.json 초안 +# pages[].views 를 실제 뷰 목록으로, viewAttribute 를 data-view 등으로 +npm i -D puppeteer-core # CHROME_PATH 또는 자동 탐색 +package.json: "verify": "node <스킬>/tools/design-gate.mjs" +배포는 항상: npm run verify && build && deploy # verify 실패(exit 1)면 배포 불가 +``` + +L1·L5 는 프로젝트 안에 `tools/unit/*.test.mjs`, `tools/exploratory.mjs` 로 보강하고 verify 체인에 묶는다. 근거와 완성 예는 designpaca 저장소 `apps/site/tools/` 가 정본 샘플이다. + +## SEO / meta 감사 (design-gate 내장) + +조사 기준(2026): title 10~65자·유일, description 60~165자, viewport, `html lang`, charset, canonical(프로덕션), og:title/description/image/url, twitter:card, favicon, h1 유일, img alt 전부, JSON-LD 파싱. 데모·로컬 페이지는 canonical/robots 없음을 허용한다 — **검사는 맥락을 존중하되 나열 근거는 리포트에 남긴다.** + +## OS · 브라우저 특성 — 검사에 반영할 것 + +- **Windows**: 스크롤바가 오버레이가 아니다 — `overflow-x:auto` 짝에는 `overflow-y:hidden` + `scrollbar-width` 검토. 스크롤바 자체가 레이아웃을 민다. +- **iOS/Safari(WebKit)**: `100vh` 주소창 문제(`100dvh`), 텍스트 인플레이션(`-webkit-text-size-adjust:100%`), WebKit 만의 flex/행높이 차이 → L4 로 재검. +- **안드로이드**: 시스템 글꼴 확대(1.15~1.3× 흔함) — rem 전면 확대로 제목 칸 수축 → 스케일×폭 행렬으로 재현·검증. +- **폰트 스왑**: `font-display: swap` 의 CLS — 폴백 메트릭 보정(typography.md §4) 없이 검사 통과를 선언하지 않는다. + +## 하니스 규칙 (테스트를 만드는 테스트) + +1. **선택자는 스코프필수** — 같은 data-* 가 다른 뷰에 있으면 숨은 요소를 잡는다(`#as-table [data-assign]` 처럼). +2. **page.evaluate 클로저 금지** — Node 스코프 함수를 페이지 안에서 부르지 않는다. 측정 코드는 문자열로 주입. +3. **줄 수 측정의 오탐 두 종류** — 폰트 메트릭 top 차이(±5px 허용오차), 인라인 아이콘과 텍스트의 top 차이(텍스트 노드만 분리 측정). +4. **CSS 수정 직후 검증은 캐시 차단**(`setCacheEnabled(false)`) + 실제 로드된 `?v=` 확인. +5. **비전(모델) 검수는 '여부'가 아니라 '지점 평가'에만** — 여부는 계측으로. +6. **모달 닫힘 직후 포커스 강탈** — 뷰 전환+타깃 포커스는 한 evaluate 로, settle 후 유실 재포커스. +7. **게이트를 통과해도 리포트에 수치를 남긴다** — "ALL PASS"가 아니라 "무엇을 몇으로 확인했나". + +## 리포트 양식 + +``` +게이트: 전 계층 통과 — 배포 가능 · 총 Ns +| 계층 | 결과 | 항목 | 소요 | +``` + +실패 상세는 원문 테일 포함. 리포트 파일(gate-report.md / verify-report.md)은 커밋 대상이다. diff --git a/packages/skill/tools/design-gate.mjs b/packages/skill/tools/design-gate.mjs new file mode 100644 index 0000000..bb6508c --- /dev/null +++ b/packages/skill/tools/design-gate.mjs @@ -0,0 +1,359 @@ +#!/usr/bin/env node +// design-gate — 범용 디자인 QA 게이트 (designpaca 스킬 동반 도구) +// +// 조사 기반 6계층 중 이 파일 하나로 돌리는 것: L0(정적·옵션) + L2(불변식·SEO/meta) + L3(시각회귀·옵션) + L4(WebKit·옵션). +// 프로젝트 고유의 L1(단위)·L5(탐색)는 각 프로젝트의 calc/테스트로 보강한다. +// +// 사용: +// node design-gate.mjs --init # 설정파일 게이트 초안 생성 +// node design-gate.mjs # gate.config.json 기준 실행 +// node design-gate.mjs --update-baseline # 시각 기준화면 갱신(검증된 배포 후에만) +// +// 철학: 없는 도구는 SKIP(실패 아님), 검사 가능한 것은 전부 검사(실패면 exit 1). +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import url from "node:url"; + +const argv = process.argv.slice(2); +const cfgFile = path.resolve(process.cwd(), argv.includes("--init") ? "gate.config.json" : (argv.find((a) => a.startsWith("--config=")) || "gate.config.json").replace("--config=", "")); + +const DEFAULT_CFG = { + pages: [ + { + name: "app", + path: "index.html", + viewAttribute: "data-view", + views: ["main"], + h1: "h1", + brand: ".brand", + }, + ], + widths: [320, 375, 390, 768, 1024, 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, rhythm: true, scaleMatrix: true, static: false, visual: false, webkit: false }, + chromePath: null, +}; + +if (argv.includes("--init")) { + fs.writeFileSync(cfgFile, JSON.stringify(DEFAULT_CFG, null, 2) + "\n"); + console.log("gate.config.json 초안 생성 — pages/views 를 프로젝트에 맞게 고치세요."); + console.log("package.json: \"scripts\": { \"verify\": \"node <이 스크립트 경로>\" } 등록을 권장합니다."); + process.exit(0); +} + +const _user = JSON.parse(fs.readFileSync(cfgFile, "utf8")); +const CFG = { ...DEFAULT_CFG, ..._user, checks: { ...DEFAULT_CFG.checks, ...(_user.checks || {}) } }; // checks 는 깊은 병합 — 새 검사가 기본 켜지도록 +const UPDATE = argv.includes("--update-baseline"); +const results = []; +const pass = (name, detail = "") => results.push({ ok: true, name, detail }); +const fail = (name, detail = "") => results.push({ ok: false, name, detail }); +const skip = (name, detail) => results.push({ ok: true, name, detail: "SKIP — " + detail }); + +// ── 브라우저 확보 (puppeteer-core + 크롬 경로 자동 탐색) ── +const CHROME_CANDIDATES = [ + process.env.CHROME_PATH, + CFG.chromePath, + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", +].filter(Boolean); +const CHROME = CHROME_CANDIDATES.find((p) => fs.existsSync(p)); + +const pageUrl = (p) => (p.url ? p.url : url.pathToFileURL(path.resolve(process.cwd(), p.path)).href); + +// ── L0 정적 (옵션) ── +if (CFG.checks.static) { + const css = spawnSync("npx", ["stylelint", "**/*.css"], { shell: true, encoding: "utf8" }); + css.status === 0 ? pass("L0 stylelint") : fail("L0 stylelint", (css.stdout || "").slice(0, 200)); + const html = spawnSync("npx", ["html-validate", ...CFG.pages.map((p) => p.path)], { shell: true, encoding: "utf8" }); + html.status === 0 ? pass("L0 html-validate") : fail("L0 html-validate", (html.stdout || "").slice(0, 200)); +} else skip("L0 정적", "checks.static=false"); + +// ── 브라우저 계층 ── +if (!CHROME) { + console.log("chrome/puppeteer-core 없음 — CHROME_PATH 를 설정하세요."); + process.exit(1); +} +let puppeteer; +try { puppeteer = await import("puppeteer-core"); } +catch { console.log("puppeteer-core 미설치 — npm i -D puppeteer-core 필요"); process.exit(1); } + +const UTILS = ` + window.__lines = (sel) => { + const el = typeof sel === "string" ? document.querySelector(sel) : sel; + if (!el) return -1; + const range = document.createRange(); + range.selectNodeContents(el); + const rects = [...range.getClientRects()].filter((r) => r.width > 1 && r.height > 4); + const tops = rects.map((r) => r.top).sort((a, b) => a - b); + const ls = []; + for (const t of tops) if (!ls.length || t - ls[ls.length - 1] > 5) ls.push(t); + return ls.length; + }; +`; + +const browser = await puppeteer.default.launch({ executablePath: CHROME, headless: "new", args: ["--force-device-scale-factor=1"] }); +const page = await browser.newPage(); +await page.setCacheEnabled(false); + +for (const p of CFG.pages) { + const href = pageUrl(p); + await page.setViewport({ width: 1440, height: 900 }); + await page.goto(href, { waitUntil: "networkidle0" }); + await page.evaluate(() => document.fonts.ready); + await page.evaluate(UTILS); + + // ── SEO / meta (조사 기반 체크리스트) ── + if (CFG.checks.meta) { + const meta = await page.evaluate(() => { + const get = (sel, attr = "content") => { const el = document.querySelector(sel); return el ? (el.getAttribute(attr) || "") : null; }; + const title = document.title.trim(); + const desc = get('meta[name="description"]'); + const og = (k) => get(`meta[property="og:${k}"]`); + const imgs = [...document.querySelectorAll("img")]; + return { + title, titleLen: title.length, + desc, descLen: desc ? desc.length : 0, + viewport: get('meta[name="viewport"]'), + lang: document.documentElement.getAttribute("lang"), + charset: !!document.querySelector("meta[charset], meta[http-equiv='Content-Type']"), + canonical: get('link[rel="canonical"]', "href"), + ogTitle: og("title"), ogDesc: og("description"), ogImage: og("image"), ogUrl: og("url"), + twitter: get('meta[name="twitter:card"]'), + robots: get('meta[name="robots"]'), + favicon: !!document.querySelector('link[rel~="icon"]'), + h1Count: document.querySelectorAll("h1").length, + imgNoAlt: imgs.filter((i) => !i.getAttribute("alt")).length, + jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].every((s) => { try { JSON.parse(s.textContent); return true; } catch { return false; } }), + jsonldCount: document.querySelectorAll('script[type="application/ld+json"]').length, + }; + }); + const m = []; + if (!meta.title) m.push("title 없음"); + else if (meta.titleLen < 10 || meta.titleLen > 65) m.push(`title ${meta.titleLen}자(10~65 권장)`); + if (!meta.desc) m.push("description 없음"); + else if (meta.descLen < 60 || meta.descLen > 165) m.push(`description ${meta.descLen}자(60~165 권장)`); + if (!meta.viewport) m.push("viewport 없음"); + if (!meta.lang) m.push("html lang 없음"); + if (!meta.charset) m.push("charset 없음"); + if (meta.ogTitle === null) m.push("og:title 없음"); + if (meta.ogImage === null) m.push("og:image 없음"); + if (meta.twitter === null) m.push("twitter:card 없음"); + if (!meta.favicon) m.push("favicon 없음"); + if (meta.h1Count > 1) m.push(`h1 ${meta.h1Count}개(유일해야)`); + if (meta.imgNoAlt) m.push(`img alt 없음 ${meta.imgNoAlt}개`); + if (meta.jsonldCount && !meta.jsonld) m.push("JSON-LD 파싱 실패"); + // 데모·로컬 페이지는 canonical/robots 미명시 허용 — 있으면 오히려 검증 + m.length ? fail(`${p.name} SEO/meta`, m.join(" · ")) : pass(`${p.name} SEO/meta`, `title ${meta.titleLen}자·desc ${meta.descLen}자·OG✓·alt ${meta.imgNoAlt}결`); + } + + // ── 각 뷰 불변식 ── + const goView = async (v) => { + if (!p.viewAttribute || v === "single") return; + await page.evaluate((attr, name) => { + const btn = document.querySelector(`[${attr}="${name}"]`); + btn && btn.click(); + }, p.viewAttribute, v); + await new Promise((r) => setTimeout(r, 120)); + }; + + for (const v of p.views) { + await goView(v); + // 오버플로 — 전 폭 + let overs = []; + for (const w of CFG.widths) { + await page.setViewport({ width: w, height: 900 }); + await new Promise((r) => setTimeout(r, 60)); + const over = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + if (over > 0) overs.push(`+${over}px@${w}`); + } + overs.length ? fail(`${p.name}/${v} 오버플로`, overs.join(" ")) : pass(`${p.name}/${v} 오버플로`, CFG.widths.join("/")); + + // h1 1줄 + await page.setViewport({ width: 1440, height: 900 }); + const h1l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`); + h1l === 1 ? pass(`${p.name}/${v} h1 1줄`) : (h1l > 1 ? fail(`${p.name}/${v} h1 ${h1l}줄`) : skip(`${p.name}/${v} h1`, "요소 없음")); + + // 세로 쌓임(수축) — 짧은 라벨 3줄 이상 + if (CFG.checks.stack) { + const stacked = await page.evaluate((limit) => { + const out = []; + document.querySelectorAll("body *").forEach((el) => { + if (!el.offsetParent || el.querySelector("*")) return; + const txt = el.textContent.trim(); + if (txt.length && txt.length <= 14 && window.__lines(el) >= limit) out.push(txt.slice(0, 10)); + }); + return [...new Set(out)].slice(0, 5); + }, CFG.thresholds.stackLines); + stacked.length ? fail(`${p.name}/${v} 세로쌓임(수축)`, stacked.join(",")) : pass(`${p.name}/${v} 수축 없음`); + } + + // 리듬·트랙 불변식 — 같은 부모·같은 클래스의 형제들이 한 축으로 정렬돼 있으면 폭·간격이 균일해야 한다 + // (시간표 시간 라벨이 자동 배치로 흩어지던 사고: 같은 열인데 폭 40/78 혼재, Δ 22·194 불규칙) + if (CFG.checks.rhythm) { + const rhythm = await page.evaluate(() => { + const groups = {}; + document.querySelectorAll("body *").forEach((el) => { + if (!el.offsetParent || !el.parentElement || !el.classList.length || el.children.length) return; + const cls = el.classList[0]; + const key = (el.parentElement.className || el.parentElement.tagName) + "|" + cls; + const r = el.getBoundingClientRect(); + (groups[key] = groups[key] || []).push({ x: r.left, y: r.top, w: r.width, h: r.height, t: el.textContent.trim().slice(0, 6), p: el.parentElement }); + }); + const issues = []; + for (const [key, items] of Object.entries(groups)) { + if (items.length < 4) continue; + const spread = (v) => Math.max(...v) - Math.min(...v); + const cls = key.split("|")[1]; + const hUniform = spread(items.map((i) => i.h)) <= 4; + const txtUniform = spread(items.map((i) => i.t.length)) <= 1; + const isDigits = items.every((i) => /^[0-9: .]+$/.test(i.t) && i.t.length > 0); // 숫자/시간 라벨 — 등폭이 의도 + const isEmpty = items.every((i) => !i.t); // 빈 격자 셀 — 트랙 균일이 의도 + // 1) 트랙 폭: 숫자 라벨·빈 셀·한 축 정렬 라벨은 폭이 균일해야 한다 — 자동 배치 흩어짐(열 섞임)을 잡는다. + // 한국어 라벨은 글자수가 같아도 폭이 달라지므로, 한 축 정렬 또는 숫자 전용일 때만 검사한다(오탐 방지). + const wSpread = spread(items.map((i) => i.w)); + const alignedCol = spread(items.map((i) => i.x)) < 3; + if (hUniform && wSpread > 6 && (isDigits || isEmpty)) { // 숫자 라벨·빈 셀만 — 한국어 라벨은 내용 폭차가 자연스럽다 + issues.push(`${cls}: 폭 ${Math.round(Math.min(...items.map((i) => i.w)))}~${Math.round(Math.max(...items.map((i) => i.w)))}px 불균일 — 트랙/배치 의심`); + } + // 2) 리듬: 한 축으로 정렬된 균일 크기 형제는 등간격이어야 한다 + if (spread(items.map((i) => i.x)) < 3 && hUniform) { + const ys = items.map((i) => i.y).sort((a, b) => a - b); + const deltas = ys.slice(1).map((y, i) => y - ys[i]); + if (deltas.length >= 3 && Math.max(...deltas) > Math.min(...deltas) * 1.6 + 4) issues.push(`${cls}: 세로 간격 Δ${Math.round(Math.min(...deltas))}~${Math.round(Math.max(...deltas))}px 불균일`); + } else if (spread(items.map((i) => i.y)) < 3 && hUniform && spread(items.map((i) => i.w)) <= 4) { + const xs2 = items.map((i) => i.x).sort((a, b) => a - b); + const d2 = xs2.slice(1).map((x, i) => x - xs2[i]); + if (d2.length >= 3 && Math.max(...d2) > Math.min(...d2) * 1.6 + 4) issues.push(`${cls}: 가로 간격 불균일 Δ${Math.round(Math.min(...d2))}~${Math.round(Math.max(...d2))}px`); + } + } + return issues.slice(0, 4); + }); + rhythm.length ? fail(p.name + "/" + v + " 리듬·트랙", rhythm.join(" · ")) : pass(p.name + "/" + v + " 리듬·트랙"); + } + + // 대비 + if (CFG.checks.contrast) { + const bad = await page.evaluate((cn, cl) => { + const lum = ({ r, g, b }) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); }; + const rgb = (c) => { const m = c.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4] } : null; }; + const blend = (t, b) => ({ r: t.r * t.a + b.r * (1 - t.a), g: t.g * t.a + b.g * (1 - t.a), b: t.b * t.a + b.b * (1 - t.a), a: 1 }); + const seen = new Set(); const out = []; + document.querySelectorAll("body *").forEach((el) => { + if (!el.offsetParent) return; + if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) return; + const cs = getComputedStyle(el); + const fg = rgb(cs.color); if (!fg) return; + let node = el; const stack = []; + while (node && node !== document.documentElement) { + const c = rgb(getComputedStyle(node).backgroundColor); + if (c && c.a >= 0.95) { stack.unshift(c); break; } + if (c && c.a > 0) stack.unshift(c); + node = node.parentElement; + } + let bg = { r: 255, g: 255, b: 255, a: 1 }; + for (const s of stack) bg = blend(s, bg); + const fgb = blend(fg, bg); + const l1 = lum(fgb), l2 = lum(bg); + const ratio = +(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05))).toFixed(2); + const size = parseFloat(cs.fontSize); const w = +cs.fontWeight || 400; + const need = size >= 24 || (size >= 18.66 && w >= 600) ? cl : cn; + if (ratio < need) { + const k = cs.color + Math.round(size); + if (!seen.has(k)) { seen.add(k); out.push(`"${el.textContent.trim().slice(0, 8)}" ${ratio}:1`); } + } + }); + return out.slice(0, 4); + }, CFG.thresholds.contrastNormal, CFG.thresholds.contrastLarge); + bad.length ? fail(`${p.name}/${v} 대비`, bad.join(",")) : pass(`${p.name}/${v} 대비 AA`); + } + } + + // ── 스케일×폭 h1 행렬 (안드로이드 글꼴 확대 재현) ── + if (CFG.checks.scaleMatrix) { + const bad = []; + for (const w of [375, 390]) { + for (const s of CFG.fontScales) { + await page.setViewport({ width: w, height: 800, isMobile: true, hasTouch: true }); + await page.goto(href, { waitUntil: "networkidle0" }); + await page.evaluate(UTILS); + await page.addStyleTag({ content: `html { font-size: ${16 * s}px !important; }` }); + const l = await page.evaluate(`__lines(${JSON.stringify(p.h1 || "h1")})`); + if (l > 1) bad.push(`${w}@${s}x:${l}줄`); + } + } + bad.length ? fail(`${p.name} 스케일×폭 h1`, bad.join(",")) : pass(`${p.name} 스케일×폭 h1`, `scales ${CFG.fontScales.join("/")}`); + // 브랜드 1줄 + await page.setViewport({ width: 390, height: 844, isMobile: true, hasTouch: true }); + await page.goto(href, { waitUntil: "networkidle0" }); + await page.evaluate(UTILS); + if (p.brand) { + const bl = await page.evaluate(`__lines(${JSON.stringify(p.brand)})`); + bl === 1 ? pass(`${p.name} 브랜드 1줄`) : fail(`${p.name} 브랜드 ${bl}줄(수축 의심)`); + } + } +} + +// ── L3 시각 회귀 (옵션: pixelmatch·pngjs 설치 시) ── +if (CFG.checks.visual) { + try { + const { default: pixelmatch } = await import("pixelmatch"); + const { PNG } = await import("pngjs"); + const BASE = path.resolve(process.cwd(), "__gate__/baseline"); + fs.mkdirSync(BASE, { recursive: true }); + let diffs = 0, n = 0; + for (const p of CFG.pages) { + for (const w of [390, 1440]) { + await page.setViewport({ width: w, height: w === 390 ? 844 : 900 }); + await page.goto(pageUrl(p), { waitUntil: "networkidle0" }); + await new Promise((r) => setTimeout(r, 250)); + const name = `${p.name}-${w}.png`; + const buf = await page.screenshot(); + const cur = PNG.sync.read(buf); + const bf = path.join(BASE, name); + if (UPDATE || !fs.existsSync(bf)) { fs.writeFileSync(bf, buf); continue; } + const base = PNG.sync.read(fs.readFileSync(bf)); + if (base.width !== cur.width || base.height !== cur.height) { diffs++; fail(`L3 시각 ${name}`, "크기 불일치"); continue; } + const cnt = pixelmatch(base.data, cur.data, null, base.width, base.height, { threshold: 0.1 }); + const pct = (cnt / (base.width * base.height)) * 100; + n++; + if (pct >= CFG.thresholds.visualDiffPct) { diffs++; fail(`L3 시각 ${name}`, pct.toFixed(3) + "% diff"); } + } + } + !diffs && pass("L3 시각 회귀", `${n}화면 ${UPDATE ? "기준갱신" : "일치"}`); + } catch (e) { skip("L3 시각 회귀", "pixelmatch/pngjs 미설치"); } +} else skip("L3 시각 회귀", "checks.visual=false"); + +await browser.close(); + +// ── L4 WebKit (옵션: playwright 설치 시) ── +if (CFG.checks.webkit) { + try { + const { webkit } = await import("playwright"); + const b2 = await webkit.launch(); + const pg = await b2.newPage(); + await pg.setViewportSize({ width: 390, height: 844 }); + let bad = 0; + for (const p of CFG.pages) { + await pg.goto(pageUrl(p), { waitUntil: "networkidle" }); + const over = await pg.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + if (over > 0) { fail(`L4 WebKit ${p.name} 오버플로`, `+${over}px`); bad++; } + } + await b2.close(); + !bad && pass("L4 WebKit", `${CFG.pages.length}페이지 오버플로 0`); + } catch { skip("L4 WebKit", "playwright/webkit 미설치"); } +} else skip("L4 WebKit", "checks.webkit=false"); + +// ── 리포트 ── +const fails = results.filter((r) => !r.ok).length; +const lines = results.map((r) => `${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? " — " + r.detail : ""}`); +console.log(lines.join("\n")); +const stamp = new Date().toISOString().replace("T", " ").slice(0, 16); +fs.writeFileSync(path.resolve(process.cwd(), "gate-report.md"), + `# 게이트 리포트 — ${stamp}\n\n${fails ? `**${fails}건 실패 — 배포 금지**` : "**전 항목 통과 — 배포 가능**"}\n\n\`\`\`\n${lines.join("\n")}\n\`\`\`\n`); +console.log(`\n${fails ? `게이트 실패(${fails})` : "게이트 통과"} — gate-report.md 생성`); +process.exit(fails ? 1 : 0); diff --git a/research/audit-references.md b/research/audit-references.md new file mode 100644 index 0000000..327155e --- /dev/null +++ b/research/audit-references.md @@ -0,0 +1,58 @@ +# 레퍼런스 조사 파일 감사 보고 + +- 감사일: 2026-08-21 +- 방법: 각 파일이 주장하는 핵심 관찰(정확한 hex 색상, 폰트, 페이지 구성, 수치)을 WebFetch 및 원본 HTML/CSS 직접 fetch(grep)로 실제 사이트와 비교. +- 판정 기준: **일치** = 실제로 확인됨 / **불일치** = 실제와 다름 / **확인불가** = 접속 불가 또는 해당 관찰을 확인할 수 없음. +- 참고: spacex.com·neuralink.com은 JS 렌더링 앱이라 셸 HTML+CSS 번들로 검증했고, spacex.com 콘텐츠는 web.archive.org 2024 스냅샷으로 교차 확인. leica-camera.com의 집계 CSS는 봇 차단(301/403)으로 직접 열지 못함(HTML과 렌더된 페이지 본문은 확인). + +## 종합 판정표 + +| 파일명 | R1 사이트 | 파일의 주장 | 실측 결과 | 판정 | +|---|---|---|---|---| +| dogfood-aerospace/01-references.md | spacex.com | Raptor 챔버 압력 300 bar·Isp 330s 텔레메트리를 구리 연소실 청사진과 노출. 배경 `#07101C`, 강조 `#FF6B00`+`#00A3FF`, 대비 16.2:1 | 실제 사이트는 순흑 `.app-background{background-color:#000}`, 텍스트 rgba(240,240,250), 폰트 **D-DIN**. 인라인 CSS+styles.css 전체에서 `#07101C`/`#FF6B00`/`#00A3FF` 전혀 없음. Starship 차량 페이지(web.archive.org 2024 포함)는 Raptor **추력(230 tf)·치수**만 표기하고 챔버 압력·Isp·청사진은 존재하지 않음 | **불일치** — 주장 수치·색상은 사이트에 없음. 아카이브에도 없으므로 "지금 바뀜"이 아니라 **애초에 게재되지 않은 값을 사이트 실측으로 제시** (300 bar/Isp 330s 자체는 외부에 알려진 Raptor 사실이나 spacex.com 게재분이 아님) | +| dogfood-aerospace/01-references.md (R2) | rocketlabusa.com → rocketlabcorp.com | Rutherford의 3D 프린팅·전기 구동 터보펌프 기술 다이어그램 | 도메인이 rocketlabcorp.com으로 301 리디렉션(리브랜딩). `/launch/electron/`에 "Rutherford Engine" 섹션과 **"The world's first 3D-printed, electric-pump-fed rocket engine"**, 1단 9기 Rutherford 명시, 엔진 이미지 존재 | **일치** (3D 프린팅 + 전기펌프 엔진 설명 확인; 도메인만 변경됨) | +| dogfood-celadon/01-references.md | museum.go.kr | 어두운 암실 조명 아래 청자 비색·빙렬 스포트라이트. 배경 `#121415`, 강조 `#5B9E90`, 폰트 마루부리/본명조 | 실제 홈페이지는 **라이트 테마**(배경 `#fff`/`#f8f8f8`), 사이트 CSS(common/layout/main, 약 186KB)의 폰트는 **notokr(Noto Sans KR) 계열뿐**, 마루부리·본명조 없음. `#121415`/`#5B9E90` 전혀 없음. '청자'는 홈의 전시 제목("도자기에 핀 꽃, 상감청자")에만 등장 | **불일치** — 웹사이트에서 측정 가능한 값이 아님. 물리적 전시실(고려청자실 암실 조명)에 대한 서술을 웹 "실측값"으로 둔갑시킨 것으로 판단 | +| dogfood-incense/01-references.md | shoyeido.co.jp | 향목 단면 결·연기 궤적을 고요한 여백에 배치. 배경 `#F7F4EE`, 강조 `#7C3B24`, 폰트 조선명조/마루부리 | 실제는 게이트웨이(일/영 분기)+`/menu.html`(일본어) 구성. 계절별 배경 이미지(summer 헤더)와 **홈 mp4 영상 루프** 사용. 발견된 hex는 `#9F140B`(적갈)뿐이고 `#F7F4EE`/`#7C3B24` 없음. 일본 사이트인데 한국 웹폰트(조선명조/마루부리) 실측 주장은 성립 불가 | **불일치** (색상·폰트: 사이트에 근거 없음, 한국 폰트 주장은 명백한 날조 신호). 단 이미지 구성(결·연기 컷) 자체는 텍스트 fetch로 확인 불가 → 해당 부분만 **확인불가** | +| dogfood-optics/01-references.md | leica-camera.com | 홍보 영상 대신 렌즈 단면도(11군 14매)·MTF 차트를 **최상단** 배치. 배경 `#0B0C0E`, 강조 `#00E5FF` 시안, Cinzel+JetBrains Mono, 헤더-히어로 120px | 홈은 제품 캐러셀(SL3-P, Cine Compact 1 등) 중심의 **순수 마케팅 구성**, 렌즈 목록 페이지도 브랜드 스토리·제품 카드뿐으로 MTF·단면도 없음 → "MTF 최상단" 주장과 직접 모순(기술자료는 제품 상세의 다운로드 자료 수준). HTML에 `#00E5FF`·Cinzel·JetBrains 전혀 없음(집계 CSS는 봇 차단으로 미확인). Leica 브드 레드가 표준인데 시안 단일 강조 주장은 근거 없음 | **불일치** (페이지 구성 주장: 명백히 다름. 정밀 수치 17.8:1·120px·Cinzel/JetBrains Mono: 근거 미발견, 일부는 차단으로 **확인불가**) | +| dogfood-synapse/01-references.md | neuralink.com | 1024채널 스레드 전극 현미경 사진·20 kS/s 스파이크 래스터 플롯을 직접 노출. 배경 `#060709`, 강조 `#FFB800`, 대비 17.5:1 | 사이트는 Vite 기반 CSR이라 셸 HTML에 본문 없음. CSS 번들(38KB)의 배경은 `#000`(10회)·`#fff` 위주, meta theme-color `#000000`. `#060709`·`#FFB800` 전혀 없음. 1024채널·램스터 플롯 노출 여부는 렌더링이 필요해 fetch로 확인 불가 | **불일치(색상)** + **확인불가(콘텐츠)** — `#060709`/`#FFB800`은 사이트 코드에 존재하지 않음. 1024채널은 실제 N1 스펙이나 홈페이지 노출 여부는 미확인 | + +## 세부 근거 (증거 로그) + +### 1. spacex.com — 불일치 +- 인라인 CSS: `.app-background{background-color:#000;color:var(--white-100)}`, `@font-face{font-family:D-DIN;...}`. +- `styles.05babecf1191c860.css`에서 `#FF6B00`, `#00A3FF`, `#07101C` 0건. +- web.archive.org 2024 `/vehicles/starship/`: Raptor 섹션 존재하나 수치는 추력 230 tf / 직경 1.3 m / 높이 3.1 m (RVac 258 tf)뿐, **300 bar·Isp 330s·청사진 없음**. +- 결론: Raptor 콘텐츠 자체는 있으나, 파일이 인용한 "핵심 텔레메트리"와 6축 색상은 사이트 출처가 아님. + +### 2. rocketlabcorp.com (구 rocketlabusa.com) — 일치 +- `/launch/electron/`: "Rutherford Engine — Proven performance. The world's first **3D-printed, electric-pump-fed** rocket engine.", First Stage "9 Sea-level Rutherford Engines", Rutherford 엔진 이미지 포함. +- 결론: R2 발견점(3D 프린팅·전기 구동 터보펌프)은 현재 사이트에서 그대로 확인됨. + +### 3. museum.go.kr — 불일치 +- 홈페이지 HTML 101KB: 표준 라이트 테마 관공서형 사이트. '청자' 2회(전시 제목). +- `/design/content/css/{common,layout,main}.css`(약 186KB): 배경 `#fff`/`#f8f8f8`/`#000`(일부), 폰트 `notokr-medium/bold/regular/light/DemiLight` 전용. `#121415`/`#5B9E90`/마루부리/본명조 0건. +- 결론: 주장된 6축 실측값은 웹사이트에서 나온 것이 아님. + +### 4. shoyeido.co.jp — 불일치(실측값) +- 게이트웨이: 일본어/English/LISN/Wa-Kanon/Shoyeido America 분기. +- `/menu.html`(일본어 메인): `body{background-image:url(.../summer/h_img01.jpg)}` 계절 이미지, `home-img.mp4` 홈 영상, CSS에 `#9F140B`만 존재. `#F7F4EE`/`#7C3B24`/한국 폰트 0건. +- 결론: "정적 여백 + 미색 한지 배경 + 한국 세리프 폰트" 실측은 근거 없음. 일본 사이트에 조선명조/마루부리를 실측했다는 주장 자체가 성립 불가. + +### 5. leica-camera.com — 불일치(구성) +- 홈(렌더된 본문): SL3-P "All That Matters" 등 제품 캐러셀, 블로그, 이벤트 — 마케팅 우선. +- `/en-int/photography/lenses`: 브랜드 스토리 + 제품 카드만, MTF/군-매 수치 없음. +- HTML에서 `#00E5FF`/`#0B0C0E`/Cinzel/JetBrains 0건. 집계 CSS는 접속 차단(301/403, 쿠키·Referer 포함 시도도 실패). +- 결론: "MTF 차트 최상단 배치" 구성 주장은 확인 가능한 범위에서 명백히 다름. 정밀 수치(120px, 17.8:1)는 검증 불가이나 사이트에서 근거 미발견. + +### 6. neuralink.com — 불일치(색상) + 확인불가(콘텐츠) +- 셸 HTML: ``, CSS 번들 `index.*.css` 5개. +- CSS 38KB: `background-color:#000` 10회, `#fff`/`#ffffff` 6회, `#060709`/`#FFB800` 0건, 커스텀 font-family 미발견. +- `/science`(Technology) 페이지도 CSR이라 본문 없음 → 1024채널·20 kS/s 래스터 플롯 노출 여부 확인 불가. + +## 요약 + +1. 5개 파일의 R1 "6축 실측값"(정밀 hex·대비비·폰트)은 **모두 실제 사이트 코드/콘텐츠에서 발견되지 않음** — 사전 지식이나 분위기로 지어낸 수치일 가능성이 높음. +2. 유일하게 실측이 맞는 것은 aerospace **R2 Rocket Lab**(3D 프린팅+전기펌프 Rutherford 문구 그대로 존재). +3. "지금 다르다" vs "애초에 없었다": SpaceX는 2024 아카이브에서도 없어 "애초에 없음"으로 분류 가능. Leica/Shoyeido/박물관/Neuralink의 hex·폰트는 마케팅 페이지 리뉴얼과 무관하게 측정 출처 자체가 의심됨(한국 폰트 실측 등 논리적 모순 포함). +4. Neuralink의 1024채널, 박물관의 암실 조명처럼 "사실인 내용"과 "사이트에서 실측했다는 주장"이 뒤섞여 있으므로 후속 단계(02 이후)에서 이 파일들의 수치를 디자인 토큰 근거로 인용하지 말 것. +5. Leica 집계 CSS·Neuralink 렌더링 본문은 봇 차단/CSR으로 직접 확인 불가 — 해당 항목만 확인불가로 남김(브라우저 렌더링 검증 권장). diff --git a/research/design-principles-v11.md b/research/design-principles-v11.md new file mode 100644 index 0000000..d27c8e2 --- /dev/null +++ b/research/design-principles-v11.md @@ -0,0 +1,139 @@ +# 디자인 원론·타이포그래피·UX 방법론 조사서 — v11 적용 편 + +조사일 2026-08-22. 14개 웹검색 + 원전 문서, 고유 출처 110여 개. +가온 학적부·두레 수강신청 두 데모의 전 페이지·전 요소 개정 근거로 쓴다. +항목 앞 [G]=가온 적용 [D]=두레 적용 [both]=양쪽 적용. + +## A. 타이포그래피 원론 (24) + +1. [both] 본문 행길이 45–75자(공백 포함), 이상값 66자 — UXPin, Baymard, The Crit. +2. [both] 데스크톱 본문 행간 1.5–1.6(라틴 기준) — Pimp My Type. +3. [both] 한글은 수평 흐름이 없어 행간을 라틴보다 넓게 — 1.6–1.7 권장 — 박스블로그(한글 웹 타이포그래피). +4. [both] 한글 자간 기본 0이 원칙(글자사이 0 배열) — W3C 한국어 조판 요구사항. +5. [both] 큰 제목에만 자간 축소(tracking-tight) 허용, 본문 금지 — The Crit/Smashing. +6. [both] 웹 본문 최소 16px는 '문서 사이트' 기준이며, 밀도 높은 관리 콘솔은 14px가 업계 표준(shadcn·Ant) — Ant Design font spec. +7. [both] 타입 스케일은 비율로: Major Third(1.25)가 UI에서 절제된 위계 — Typescale.com, Typographymaster. +8. [both] 16px 기준 1.25 스케일: 16→20→25→31→39 — Typescale.com. +9. [both] 위계는 크기·웨이트·색 3축으로 만든다(한 축에만 의존 금지) — Design Systems Collective. +10. [both] 볼드(700) 금지 — 웨이트는 수치로: 본문 400, 강조 500, 제목·버튼 600. 2–3 웨이트 상한 — Ant Design, DesignerUp. +11. [both] 가변 폰트로 중간 웨이트(550·650)를 쓰면 합성 볼드가 없다 — MDN Variable Fonts, web.dev. +12. [both] 없는 웨이트는 브라우저가 합성한다(faux bold) — font-synthesis-weight: none 으로 차단 — A List Apart 'Say No to Faux Bold'. +13. [both] strong/b 기본 700을 재정의(600)해 UA 볼드가 새지 않게 한다 — MDN font-weight. +14. [both] 수치 데이터에는 tabular-nums — 자리 흔들림 방지 — typography 지침(스킬 typography.md §5와 일치). +15. [both] 표 숫자열은 우측 정렬(자릿수 정렬), 텍스트 좌측 — Science Editor, UX Planet. +16. [both] 표 헤더 정렬은 해당 열 데이터와 일치 — Pencil & Paper, Mission Log. +17. [G] 세리프(Noto Serif KR)는 제목·표지에만 — 고딕 본문과 대비 — Noto Serif KR 특성. +18. [D] 기술·행정 문서 톤에는 네오그로테스크(IBM Plex Sans KR) — IBM Plex 소개. +19. [both] 한글 웨이트는 라틴보다 1단계 낮게 보정(한글 700이 라틴 700보다 무겁게 보임) — W3C/한글 조판. +20. [both] 줄바꿈이 표를 깨지 않게 긴 식별자는 word-break: keep-all + overflow-wrap — 한글 조판. +21. [both] 축 소형 라벨(≤11px)은 500 이상 웨이트로 — 작은 크기에서 가늘면 소실 — UXPin. +22. [both] 문단(공지 본문 등)은 max-width 65–70ch 제한 — Baymard. +23. [both] 행간·자간은 폰트마다 다르게 재준다(폰트 바꾸면 타이프 재조정) — Figma 타이포그래피 가이드. +24. [both] 디스플레이 서체는 본문 금지, 위계는 웨이트+여백으로 — 표준 원론. + +## B. 색·대비 (12) + +25. [both] 본문 텍스트 대비 4.5:1 이상(WCAG 1.4.3 AA) — W3C WCAG 2.2. +26. [both] 큰 텍스트(≥24px 또는 ≥18.66px 굵음)는 3:1 — W3C. +27. [both] UI 구성요소·포커스 표시 3:1(1.4.11 비텍스트 대비) — W3C. +28. [both] AAA는 7:1 — 준거하는 편이 좋으나 AA가 의무선 — WebAIM. +29. [both] 회색 보조 텍스트(#a1a1aa급, 약 2.6:1)는 실망률 상위 원인 — 본문용 보조색도 4.5:1 필요 — WCAG. +30. [both] 60-30-10: 지배 60 배경, 부차 30 카드·내비, 강조 10 CTA — NN/g Using Color. +31. [both] 강조색은 희소해야 시그널이 된다(남발 금지) — LogRocket 60-30-10. +32. [G] 상태색은 소프트 배경+짙은 잉크 조합이 배지 표준 — shadcn 문법. +33. [both] 색만으로 상태를 구분하지 않는다(형태·라벨 병용) — WCAG 색 사용. +34. [both] 차트 색은 데이터-잉크 비율을 해치지 않게 — Tufte/Few. +35. [D] 벽돌(주황) 강조는 10% 이내 면적에서 — 60-30-10. +36. [both] 그림자는 계층 신호(스택 깊이)로만 — 실제 광원 흉내 금지 — shadcn/Material elevation. + +## C. UX 법칙·휴리스틱 (28) + +37. [both] 닐슨 1 시스템 상태 가시성 — 저장·전환·계산 즉시 피드백(aria-live) — NN/g. +38. [both] 닐슨 2 실제 세계 일치 — '장부''도장' 등 도메인 언어 — NN/g. +39. [both] 닐슨 3 사용자 통제와 자유 — 모달 닫기·신청 취소·되돌리기 — NN/g. +40. [both] 닐슨 4 일관성 — 같은 동작은 같은 컨트롤(칩·배지·버튼 문법 통일) — NN/g. +41. [both] 닐슨 5 오류 예방 — 겹침 수강 즉시 차단, 채점 실수 방지 — NN/g. +42. [both] 닐슨 6 재인优于회상 — 아이콘+라벨 병기, 현재 위치 표시(크럼브) — NN/g. +43. [both] 닐슨 7 유연성·효율 — 필터·검색·키보드(Esc) — NN/g. +44. [both] 닐슨 8 미니멀·미학 — 장식 아이콘 금지, 정보 밀도 우선 — NN/g. +45. [both] 닐슨 9 오류 인지·회복 — 오류는 눈에 띄고 해결책 제시 — NN/g Error-Message Guidelines. +46. [both] 닐슨 10 도움말 — 데모는 요소 옆 한 줄 안내로 대체 — NN/g. +47. [both] 피츠 법칙: 목표는 크고 가깝게 — 주 액션 버튼 높이 36px+, 모바일 포인터 40px+ — NN/g Fitts. +48. [both] 힉 법칙: 선택지를 묶어(chips 그룹) 결정 비용 절감 — Laws of UX. +49. [both] 제이콥 법칙: 익숙한 패턴(사이드바+탭+모달) 유지 — Laws of UX. +50. [both] 밀러 법칙: 작업 기억 7±2 — 내비를 3그룹으로 청킹 — Laws of UX. +51. [both] 근접성: 관련 항목은 간격으로 묶고 무관은 여백으로 분리 — NN/g Gestalt Proximity. +52. [both] 유사성: 같은 기능은 같은 모양(모든 row-open은 같은 스타일) — IxDF. +53. [both] 공통 영역: 테두리/배경이 하나의 그룹으로 만든다(패널) — UX Tigers. +54. [both] 폰 레스토르프: 격차 강조는 하나만(오늘 칸) — Laws of UX. +55. [both] 도허티 임계 400ms — 상태 전환은 100–200ms 내 — Laws of UX. +56. [both] 인지 부하 감소: 점진적 공개(공지 아코디언) — IxDF. +57. [both] 미적-사용성 효과: 심미성은 사용성 인식을 높인다(단 장식은 8번과 충돌 금지) — Laws of UX. +58. [both] 시리얼 포지션: 중요 항목은 목록 처음/끝에 — Laws of UX. +59. [both] 피크-엔드: 마지막 화면(리포트)의 완결감 — Laws of UX. +60. [both] 터치 대상 최소 24px(WCAG 2.5.8 AA), 권장 44px(HIG)/48px(Material) — WCAG 2.2·Apple HIG. +61. [both] 포커스 표시 필수(2.4.7) — outline 2px + offset, 색 대비 3:1 — WCAG. +62. [both] 모바일에서도 포커스가 가려지지 않게(2.4.11) — WCAG 2.2 신설. +63. [both] 애니메이션은 200ms 이내·레이아웃 속성 아님(합성만) — 표준 퍼포먼스 원칙. +64. [both] 호버 상태는 포인터 장치에서만(@media hover) — 표준. + +## D. 대시보드·데이터 (12) + +65. [G] 대시보드는 한 화면에서 '한눈에' — 스크롤 없이 핵심 지표 — Stephen Few. +66. [G] 13가지 흔한 실수: 지나친 장식, 부정확한 차트, 무의미한 게이지 금지 — Few. +67. [both] 데이터-잉크 비율 최대화: 불필요한 눈금·테두리·3D 금지 — Tufte/Few. +68. [both] 막대 시작은 0부터 — 왜곡 금지 — Few. +69. [G] 요약 수치는 크게(24px+)·라벨은 작게 — Few/Plausible 실측. +70. [both] 표의 4가지 과제 지원: 찾기·비교·행 보기·편집 — NN/g Data Tables. +71. [both] 긴 표는 고정 헤더(sticky) 또는 페이지 단위 — NN/g. +72. [both] 빈 상태는 안내다: 무엇이 여기에 올지 + 다음 행동 — NN/g Empty States. +73. [both] 빈 상태·오류에 유머 남발 금지(건설적으로) — NN/g. +74. [D] 정원·여석 등 비교 데이터는 게이지가 아니라 숫자+막대 — Few. +75. [both] 시계열은 라인/막대, 구성비는 스택바 — Few/Tableau. +76. [both] 차트에 값 라벨을 직접(범례 왕복 금지) — Few. + +## E. 레이아웃·폼·내비 (16) + +77. [both] 8pt 그리드: 여백·크기는 8의 배수(4 서브그리드 허용) — Medium 8-Point Grid. +78. [both] 수직 리듬: 요소 높이도 그리드에 맞춘다 — VMware Tanzu. +79. [both] 관리 콘솔 사이드바 200–260px 폭이 표준 — UX Planet Sidebar. +80. [both] 링크 7개 이상이면 사이드 내비가 상단 내비보다 낫다 — UX Design Institute. +81. [both] 모달은 화면의 ~25%, 모바일은 전면 — Appcues, A11Y Collective. +82. [both] 모달 열리면 포커스 진입·닫히면 반환(native dialog) — Userpilot/LogRocket. +83. [both] 모달은 짧은 집중 작업만, 긴 작업은 페이지 — NN/g Modal vs Non-modal. +84. [both] 폼 라벨은 상단(가로 공간 절약·스캔 빠름) — NN/g Forms. +85. [both] 입력 오류는 해당 필드 옆 + 상단 요약 — NN/g. +86. [both] 취소는 두 번째 버튼, 파괴적 액션은 색으로 구분 — 표준. +87. [both] 스티키 요소는 배경·테두리로 콘텐츠와 분리 — 표준. +88. [both] 뷰포트 320px에서도 수평 스크롤 0(최소 방어선) — 표준 반응형. +89. [both] 그리드 축소 트랙은 minmax(0,1fr) — 이 프로젝트 재발 결함. +90. [both] overflow-x:auto 에는 overflow-y:hidden 명시(반대축 auto 함정) — 이 프로젝트 규칙. +91. [both] 콘텐츠 폭 상한(본문 1132px 등) 유지 — 독서 최적화. +92. [both] 요소 사이 8/12/16/24/32 간격 스케일만 사용 — 8pt. + +## F. 폰트 선택 (13) + +93. [G] 본문 Pretendard Variable(SIL OFL, 동적 서브셋) 유지 — 한국 UI 표준. +94. [G] 제목·표지 Noto Serif KR(SIL OFL, 가변) 600 — 학적부 서류 결. +95. [D] 본문·제목 IBM Plex Sans KR(OFL, 400/500/600) — 대학·기술 문서 톤. +96. [D] 마스트헤드·과목군 라벨 Noto Serif KR 600 — 공문·신문 결. +97. [both] JetBrains Mono(OFL) 숫자·코드 — 유지. +98. [both] SUIT(SIL OFL)는 대안 후보 — UI 전용 고딕 — sun.fo. +99. [both] 웨이트 3개 상한: 400/500(또는 550)/600 — Ant Design. +100. [both] Google Fonts CDN 사용 시 preconnect 필수(도메인 3개 상한) — 표준. +101. [both] 한글 웹폰트는 동적 서브셋 빌드로 전송량 최소 — Pretendard 문서. +102. [both] font-display: swap 기본 — 스킬 typography.md §3. +103. [both] 세리프+고딕 조합에서 크기감 차이는 size-adjust/@font-face 보정 — typography.md §6-3. +104. [both] 라틴 우선 스택으로 숫자·영문 조악함 방지 — typography.md §6-3. +105. [both] '왜 이 폰트인가' 한 문장 답이 없으면 바꾸지 않는다 — typography.md §1-4. + +## 주요 출처 (110여 개 중 핵심) + +- 타이포그래피: UXPin, Baymard Institute, Smashing Magazine, USWDS, Pimp My Type, The Crit, Medium(Design Bootcamp), Typescale.com, Typographymaster, Imperavi, Blake Crosley, Kalamuna, Figma Resource Library, 박스블로그(parksb), KRDS, W3C 한국어 조판 요구사번역, 요즘IT, 디자인베이스, 브런치 +- 웨이트/가변: MDN(font-weight·Variable Fonts), web.dev, WebKit Blog, A List Apart, bnijenhuis.nl, Jono Alderson, Glyphs Forum, Marmelab, Ant Design, Design Systems Collective, DesignerUp, Udacity +- UX: NN/g(10 휴리스틱·Fitts·표·빈 상태·오류 메시지·색·모달·근접성), Laws of UX, IxDF, UX Planet, Toptal, Justinmind, Pencil & Paper, Reddit r/UXDesign, Code Theorem, Medium, Cap3, uxtoast, Muzli, Bejamas, UX Tigers +- 대시보드: Stephen Few(Information Dashboard Design), Perceptual Edge, Tableau, MS Fabric, MSU, ExcelCharts, The Data School +- 표·레이아웃: Science Editor, Mission Log, UX StackExchange, Navbar Gallery, ALF Design, HashBuilds, Eleken, Userpilot, A11Y Collective, LogRocket, Appcues, UXPatterns.dev, Glow, VMware Tanzu, Prototypr, Spec.fm, UX Design CC +- 접근성: W3C WCAG 2.2(1.4.3·1.4.11·2.4.7·2.4.11·2.5.8), WebAIM +- 색: NN/g Using Color, LogRocket, UX Planet, UX Design CC, Webflow, WPMayor +- 폰트: Google Fonts(IBM Plex Sans KR·Noto Serif KR), sun.fo(SUIT), 산돌구름, Fontsource, GitHub SUIT, Monoplex KR diff --git a/research/dogfood-aerospace/01-references.md b/research/dogfood-aerospace/01-references.md new file mode 100644 index 0000000..ae70edd --- /dev/null +++ b/research/dogfood-aerospace/01-references.md @@ -0,0 +1,31 @@ +# 01. 레퍼런스 실측 — 궤도 우주공학 (Orbit Aerospace) + +## 1. 벤치마크 대상 3곳 + +### R1. SpaceX Raptor Engine Technical Data (spacex.com) +- **발견점**: 연소실 압력(Chamber Pressure 300 bar)과 비추력(Isp 330s) 등의 핵심 텔레메트리를 구리 연소실 청사진과 함께 노출. +- **6축 실측값**: + - 배경: `#07101C` (딥 에어로스페이스 네이비) + - 본문 대비: 16.2:1 + - 강조색: `#FF6B00` (추력 화염 오렌지) + `#00A3FF` (청사진 블루) + +### R2. Rocket Lab Rutherford Engine Propulsion (rocketlabusa.com) +- **발견점**: 3D 프린팅 재생냉각 유로 및 전기 구동 터보펌프 밸브 트레인의 기술적 다이어그램. + +### R3. NASA SLS Core Stage Hot-Fire Telemetry (nasa.gov) +- **발견점**: 4열 텔레메트리 지표 그리드 (진공 추력, 비추력, 챔버 압력, 스로틀링 범위). + +--- + +## 2. 채택할 6축 기준선 + +```yaml +surface: "#07101C" +ink: "#E3EEFA" +accent: "#FF6B00" (5.8:1 AA) +accent_sub: "#00A3FF" +display_font: "Space Grotesk" +mono_font: "Space Mono" +measure: "36ch" +button_wrapping: "white-space: nowrap" +``` diff --git a/research/dogfood-aerospace/02-direction.md b/research/dogfood-aerospace/02-direction.md new file mode 100644 index 0000000..1633e11 --- /dev/null +++ b/research/dogfood-aerospace/02-direction.md @@ -0,0 +1,16 @@ +# 02. 디자인 방향 — 궤도 우주공학 (Orbit Aerospace) + +## 1. 한 문장 컨셉 +"지구 저궤도를 향한 75kN 메탄 추진의 정밀 연소 — 구리 합금 재생냉각 로켓 엔진 사양서." + +## 2. 안티 골 (Anti-Goals) +- ❌ 일반적인 스타트업 홍보 슬로건 배제 +- ❌ 과장된 3D 렌더링 배제 (실제 지상 연소 시험 및 청사진 도면 활용) +- ❌ 버튼 텍스트 줄바꿈 방지 (`white-space: nowrap`) + +## 3. 감수한 리스크 (Single Risk) +- 마케팅 문구 대신 구리 합금 재생냉각 유로도와 연소실 압력 실측 데이터시트를 전면에 배치합니다. + +## 4. 3단계 토큰 체계 +- **기본면**: `#07101C` / **올린면**: `#0D192B` / **카드면**: `#13243D` +- **본문 잉크**: `#E3EEFA` (대비 16.2:1 AAA) / **액센트**: `#FF6B00` (5.8:1 AA) diff --git a/research/dogfood-architecture/01-references.md b/research/dogfood-architecture/01-references.md new file mode 100644 index 0000000..a3f7ea9 --- /dev/null +++ b/research/dogfood-architecture/01-references.md @@ -0,0 +1,129 @@ +# 1단계 — 레퍼런스 조사 (스튜디오 폼) + +## 브리프 (2줄) +공간건축연구소 "스튜디오 폼(FORM)"의 아카이브 사이트. 평면도·단면도가 주인공인 swiss-minimal 흑백 UI — 1px 헤어라인, 콘크리트·전시공간 사진, 도면 우선 레이아웃. +조사일 2026-08-21. 방법: WebFetch + webReader(원본 HTML/CSS 직접 추출). 아래 수치는 지어낸 것이 아니라 각 사이트 CSS/HTML에서 직접 읽은 실측값이다. + +--- + +## R1 · 구조 — John Pawson Architects https://johnpawson.com + +같은 업종(건축사사무소) 확인. 후보 davidchippersfield.com·valerioolgati.com은 DNS 실패, Pawson만 접속(2회 재시도로 안정성 확인). + +### 가져올 축 +고정 사이드 내비 + 협소 단일 콘텐츠 컬럼의 "아카이브 몸통". 프로젝트 인덱스는 이미지가 아니라 **텍스트 3줄(제목/위치/연도)** 이 주체이고, 필터는 단어 나열. 도면·사진은 상세 페이지에서 세로 리듬으로만 배열. + +### 실측값 (CSS/HTML 직접 추출) +- **정보 구조(내비 4개)**: Architecture → Design → Journal → Office. 프로젝트 URL 체계 `/works/{slug}`(예: `/works/neuendorf-house`). +- **프로젝트 인덱스** (`/architecture`): + - 정렬: 완공연도 **역연대순**(2024 → 1981). + - 항목 3요소: 제목(h2) / 위치 / 연도 range (`2016 – 2024` 엔 대시). 카테고리 라벨은 항목에 노출 안 함. + - 필터 6개: Commercial, Cultural, Design, Hospitality, Religious, Residential. + - 목록 레이아웃: 2컬럼 — `fifty-left 250px float:left` / `fifty-right 265px float:right`, 썸네일 폭 265px. +- **프로젝트 상세 순서**: 제목 → 위치/연도 메타 → 본문 2문단 → 협업 표기("In partnership with Claudio Silvestrin") → 크레딧 2블록(Project Team / Photography, 19px). +- **컨테이너 폭**: `#wrap` padding 22px, **min-width 724px 고정폭(반응형 아님)**. 내부 = 고정 `#nav-col` **165px** + `#content-col` **537px**. 슬라이더 이미지 537×400. +- **그리드**: 6분할 컬럼 시스템 — col-1-6 16.6667% / col-2-6 33.3334% / col-3-6 50%. +- **타입 위계**: 크기 위계가 아니라 **동일 크기 + 색 위계**. body·h1~h6·p 전부 **27px / line-height 33px**, Univers Next Pro Light(자체 호스팅, woff). 메타·캡션·크레딧만 **19px/22px**. 계열: `Univers-Next-330-Basic-Light`, fallback Helvetica/Arial. 자체 아이콘 폰트 `pawson_iconsregular`. +- **색**: 배경 `#ffffff`, 본문 `#000000`, 보조 `#999999`, 프리로더 도트 `#ededed`. +- **헤어라인**: `hr 1px solid #999999` — 1px 라인이 유일한 구분선 장치. +- **여백 리듬**: 11px 기본 단위의 배수 — 문단 margin-bottom 33px, 상세 이미지 margin-bottom 44px, 목록 이미지 margin-bottom 41px, 캡션 padding-top 19px, 이미지 상하 여백 22px. 세로 리듬 = 본문 line-height(33px)에 맞춤. +- **이미지 처리**: `img { max-width:100%; height:auto; vertical-align:text-bottom }`, 캡션은 이미지 폭과 동일하게 19px 텍스트. 상세 이미지는 콘텐츠 컬럼 폭(537px) 안에 갇힘. + +### 변형 (우리는 다르게 할 것) +- **도면은 537px에 가두지 않는다.** Pawson은 모든 이미지를 텍스트 컬럼 폭에 맞춰 도면의 선 밀도·스케일감이 죽는다. 우리는 도면 스테이지를 별도 넓은 컬럼(≈1200px)으로, 텍스트만 협소(640–720px)로 — 이중 컬럼 인셋. +- **27px 전면 동일 크기를 계승하지 않는다.** 메타 밀도 높은 아카이브 인덱스는 소형 타입(13–15px) 표 형태로. 크기 위계를 되살리되 색 위계(#111/#555/#e5e5e5)는 계승. +- 724px 고정폭(비반응형)을 그대로 쓰지 않고, 그 리듬(165px 내비 + 22px 패딩의 비율)만 스케일. + +--- + +## R2 · 톤 — Cereal (readcereal.com) https://www.readcereal.com + +업종 다름 확인: 잡지/출판의 **디지털 아카이브**(2012–2022 스토리 아카이브 — "공간건축연구소 아카이브"와 목적까지 유사). Studio Faculty 디자인, siteinspire minimal 분류. 흑백은 아니고 웜 크림 모노크롬 — 편집 감각의 화이트 스페이스 관리가 가져올 것. + +### 가져올 축 +저대비 웜 모노크롬 + 세리프/산스 이중 서체 시스템 + small-caps 표제. 10px 루트 rem 스케일로 극소형 타입을 운용하는 용법. + +### 실측값 (CSS 직접 추출: `/_next/static/css/24569b31c193c81f.css`) +- **색(전부 CSS 변수)**: 배경 `--mainBg:#f7f6ef`(웜 크림), 잉크 `--mainBlack:#363531`(웜 블랙), 보조 `--mainGrey:#ccc7c0`, `--mainWhite:#fff`. 즉 채도 0에 가까운 웜 단색 2개+중간회색 1개의 3단. +- **폰트 2종 단일 웨이트**: 본문·표제 `adobe-text-pro`(세리프, Adobe Fonts), UI·캡션 `NeueHaasUnicaPro-Regular`(산스, Medium 500 별도). 인쇄판과 동일한 "각 1웨이트" 원칙. +- **타입 스케일(루트 10px, rem)**: h1 1.4rem(14px, Adobe Text + small-caps `font-feature-settings:"smcp"`), h2.subtitle 1.15rem uppercase letter-spacing .08rem, h3 1.25rem Unica 500, p 1.2rem(12px), 캡션·크레딧 1.1rem Unica, 아티클 본문 1.3rem/2rem line-height. **1920px 이상에서만 기본 스케일이 1.2→1.5rem으로 확대**(viewport 단 스케일 전환 1곳뿐). +- **그리드/이미지**: 그리드 아이템 기본 `7rem × 9.5rem`(112×152px, 약 3:4), `--gridMinValue:200px`, 갤러리 오프셋 `calc(7rem + 4rem)`. `img object-fit:cover`. +- **여백 리듬**: 헤더 스크롤 시 **51px** 지점 고정, 스크롤 패딩 탑 12rem, 아티클 로딩 패딩 `29rem 3rem 2rem`. 세로 여백이 가로(3rem)의 10배까지 벌어지는 북적임 제거 리듬. +- **내비**: `Search / Information / Menu` 3개 — 검색이 첫 항목. 검색 오버레이 필터: Information / Categories / Search Terms / Year(연도 필터가 1급 시민). +- **포커스**: `a:focus-visible { outline:1px dotted }` — 1px 도트만. + +### 변형 (우리는 다르게 할 것) +- 크림 웜톤(`#f7f6ef/#363531`) 대신 **중성 회색조**(콘크리트 참조: `#f4f4f2/#111111`). 웜톤은 사진 잡지 감성, 시멘트는 중성. +- 세리if(Adobe Text) 없이 **단일 그로테스크 + 도면용 모노스페이스**(치수·좌표 숫자 전용) 이원화. +- 저대비 유지하되 **도면 선만은 항상 최고 대비**(#111 on #fff) — 저대비는 텍스트 크레딧에만. + +--- + +## R3 · 디테일 — CSS scroll-driven animation (스티키 도면 스크롤 드로잉) + +기법 출처: Codrops "A Practical Introduction to Scroll-Driven Animations with CSS Scroll and View" (Adam Argyle, 2024-01-17) https://tympanus.net/codrops/2024/01/17/a-practical-introduction-to-scroll-driven-animations-with-css-scroll-and-view/ + +### 기법 (하나만 쓴다: view() 타임라인 도면 리빌) +평면도 SVG를 뷰포트 진입에 물려, 스크롤 진행 = 드로잉 진행으로 대응시키는 것. JS 0줄. + +```css +/* 도면 컨테이너가 sticky로 고정된 상태에서 */ +.drawing path { + animation: draw linear both; + animation-timeline: view(); + animation-range: contain; /* 또는 길이 지정: 25vh 75vh */ +} +@keyframes draw { from { stroke-dashoffset: var(--len); } to { stroke-dashoffset: 0; } } + +/* 게이트: 지원 브라우저 + 모션 허용 사용자만 */ +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: view()) { /* 위 규칙 */ } +} +``` + +- 축 지정: `view(x)` / `scroll(x)`, `scroll-snap-type: x mandatory`와 결합 가능. +- 원거리 요소 연결(스티키 도면 + 인접 텍스트 동기화): `view-timeline: --plan x` + `body { timeline-scope: --plan }` 후 필요한 요소가 `animation-timeline: --plan` 참조. +- `animation-range` 키워드: `contain` / `cover`, 진입~이탈 구간을 vh 길이로 좁혀 타이밍 제어(예: `25vh 75vh` — 뷰포트 25% 진입 시 시작, 75%에서 완료). +- 이징은 **linear 권장** — 스크롤 제스처와 직결되는 애니메이션은 선형이 자연스러움(원문 지적). + +### 구현 제약 (브라우저 지원, 폴백) +- **게시 시점 기준 Chromium 전용**(Chrome 지원, Firefox·Safari 미지원 — 2026년 현재도 Safari/Firefox는 부분·지연 지원 상태이므로 게이트 필수). +- 폴백 전략(원문): `@supports (animation-timeline: scroll())` + `@media (prefers-reduced-motion: no-preference)` 이중 게이트, 크로스브라우저 필수 시 polyfill(flackr/scroll-timeline). +- **우리 설계 원칙**: 폴백 상태 = "이미 완성된 도면 정적 표시"가 기본값. 드로잉은 enhancement로만 — 애니메이션이 콘텐츠 접근의 조건이 되면 안 됨. stroke-dashoffset 초기값을 0(완성)으로 두고 게이트 안에서만 시작값을 부여하는 방식. + +--- + +## 회색조 위계 (페이지 덩어리 무게 배분 계획) + +전제 팔레트: 배경 `#ffffff`(순백) / 도면·잉크 `#111111` / 메타 `#555555` / 크레딧·약식 `#888888` / 헤어라인·행 호버 `#e5e5e5`. Pawson의 3단(#fff/#999/#000)에서 중간단을 2개로 쪼개 도면-텍스트 구분에 씀. + +아카이브 페이지 기준 무게 배분: + +| 덩어리 | 무게 | 처리 | +|---|---|---| +| 헤더·내비 | 5% | 하단 1px `#e5e5e5` 라인, 텍스트 13px `#111`. Pawson의 165px 내비 폭 비율 계승 | +| 프로젝트 인덱스(표) | 25% | 행 높이 48–56px 리듬. 제목 `#111` 15px, 위치·연도 `#555` 13px. 호버 시 행 배경 `#f0f0ee`만(그림자 금지) | +| **도면 스테이지** | **50%** | 최대 무게. 배경 `#fff`, 도면선 `#111`, 치수·주석 `#555` 11px 모노스페이스. position:sticky로 스크롤 내내 점유 — R3 기법 부착 위치 | +| 본문 텍스트 | 15% | 16px/26px, 폭 640–720px 인셋, `#111`. 문단 margin 33px(Pawson 리듬) | +| 크레딧·푸터 | 5% | 12px `#888`, 상단 1px 라인. Pawson의 Project Team/Photography 19px 크레딧 블록 형식은 계승하되 축소 | + +근거: Pawson은 27px 전면 동일 타입으로 무게가 평탄화돼 있다. 우리는 도면 스테이지에 무게를 몰아주기 위해 텍스트 전체를 1단 축소하고, Cereal식 저대비 크레딧(#888/#ccc 대비)으로 페이지 끝을 조용히 닫는다. + +## 안 할 것 (이 브리프에서 위험한 안티패턴 3개) + +1. **도면을 카드로 감싸기** — 그림자·라운드 코너·패딩 박스는 도면을 파일러 프리뷰처럼 만든다. 도면의 프레임은 1px 헤어라인과 여백으로만(Pawson `hr 1px #999` 계승). +2. **인덱스를 이미지 그리드로 만들기** — Pawson 인덱스가 텍스트 3줄로 운용되는 건 검색·스캔 성능 때문. 썸네일 카드 그리드는 아카이브를 갤러리 테마처럼 보이게 하고 메타 밀도를 죽인다. +3. **스크롤 드로잉을 필수 경로로 만들기 + 웜톤 도입** — (a) 애니메이션이 도면 열람의 조건이 되면 폴백 브라우저에서 콘텐츠 자체가 사라진다(정적 완성본이 기본). (b) `#f7f6ef`류 크림 배경은 사진 편집 감성 — 콘크리트 브리프는 중성 회색조를 유지한다. + +## 접근 실패 기록 + +| URL | 결과 | 비고 | +|---|---|---| +| https://davidchippersfield.com | DNS ENOTFOUND | 도메인 없음(1회). 실제 사무소 도메인은 chipperfont 차이가 있으나 슬롯 후보군만 시도 | +| https://www.valerioolgati.com | DNS ENOTFOUND | 1회 실패, 재시도 않음(R1은 Pawson 확보) | +| https://www.kristofferzell.fi | DNS ENOTFOUND | R2 후보 1 | +| https://www.apartamento.rs | DNS ENOTFOUND | R2 후보 3 | +| https://tocca.com | 접속 성공, **채택 안 함** | Shopify 뷰티 스토어 — 프로모션 배너·장바구니 중심으로 "흑백/미니멀 편집감각" 불일치. 구조만 기록함(메인 내비 9개 항목, 홈 = 공지 배너→히어로 2→디스커버리 세트→4타일→뉴스레터) | +| https://readcereal.com/blogs/journal | 404 | 경로 변경. 루트와 CSS 직접 추출로 대체 성공 | + +R2는 후보 2개 DNS 실패 + 1개 톤 불일치로, 브리프 성격(흑백/미니멀 편집감각, 아카이브 목적)에 부합하는 대체 후보를 한 곳(readcereal.com)만 추가 시도해 채택했다. diff --git a/research/dogfood-celadon/01-references.md b/research/dogfood-celadon/01-references.md new file mode 100644 index 0000000..177f092 --- /dev/null +++ b/research/dogfood-celadon/01-references.md @@ -0,0 +1,30 @@ +# 01. 레퍼런스 실측 — 비색의 침묵 (翡色之靜) + +## 1. 벤치마크 대상 3곳 + +### R1. 국립중앙박물관 고려청자실 (museum.go.kr) +- **발견점**: 과도한 전시 그래픽을 배제하고 어두운 암실 조명 아래 청자의 비색 유약과 빙렬(Crazing)에 스포트라이트를 집중. +- **6축 실측값**: + - 배경: `#121415` (어두운 석재 갤러리) + - 본문 대비: 15.6:1 + - 강조색: `#5B9E90` (고려 비색 옥빛) + - 폰트: 마루부리 / 본명조 + +### R2. Tokyo National Museum Asian Gallery (tnm.jp) +- **발견점**: 로마 숫자(I, II, III)를 활용한 순차적 전시 공간 큐레이션 동선 설계. + +### R3. Victoria and Albert Museum Ceramic Collection (vam.ac.uk) +- **발견점**: 12세기 강진 가마터 소성 온도(1,280°C) 및 환원 소성 열역학 데이터시트 병기. + +--- + +## 2. 채택할 6축 기준선 + +```yaml +surface: "#121415" +ink: "#EDEFEA" +accent: "#5B9E90" (4.9:1 AA) +display_font: "MaruBuri / Fraunces" +measure: "32ch" +button_wrapping: "white-space: nowrap" +``` diff --git a/research/dogfood-celadon/02-direction.md b/research/dogfood-celadon/02-direction.md new file mode 100644 index 0000000..e710bf9 --- /dev/null +++ b/research/dogfood-celadon/02-direction.md @@ -0,0 +1,16 @@ +# 02. 디자인 방향 — 비색의 침묵 (翡色之靜) + +## 1. 한 문장 컨셉 +"천 년의 비취색, 흙과 불이 빚은 침묵의 곡선 — 국립중앙박물관 고려청자 특별기획전." + +## 2. 안티 골 (Anti-Goals) +- ❌ 화려한 관광 포스터 스타일 배제 +- ❌ 알록달록한 티켓팅 UI 배제 (단일 비색 톤 버튼) +- ❌ 버튼 텍스트 줄바꿈 방지 (`white-space: nowrap`) + +## 3. 감수한 리스크 (Single Risk) +- 전시 포스터 형식을 버리고 암실 조명 아래 비색 청자의 빙렬(Crazing) 결을 감상하는 3개의 방으로 설계합니다. + +## 4. 3단계 토큰 체계 +- **기본면**: `#121415` / **올린면**: `#1A1D1F` / **카드면**: `#222629` +- **본문 잉크**: `#EDEFEA` (대비 15.6:1 AAA) / **액센트**: `#5B9E90` (4.9:1 AA) diff --git a/research/dogfood-foundry/01-references.md b/research/dogfood-foundry/01-references.md new file mode 100644 index 0000000..00b4005 --- /dev/null +++ b/research/dogfood-foundry/01-references.md @@ -0,0 +1,86 @@ +# 1단계 — 레퍼런스 조사 (한성활자연구소) + +## 브리프 +한글 활자 파운드리 사이트. 타입 스페시멘이 주인공이 되는 구조, 한지 톤(#F5F1E8) 배경에 인주 레드(#A63D2F) 1색 강조, 방문객이 직접 글자를 시험해 보는 인터랙티브 조판 시험대가 핵심. +관찰 방법: WebFetch(페이지 취득) + 원본 HTML/CSS 소스 직접 확인(curl). 아래 수치는 전부 소스에서 실제 관찰된 값이며, 추출 한계로 확인 못 한 항목은 그렇게 명시했다. + +## R1 · 구조 — Klim Type Foundry +URL: https://klim.co.nz (같은 업종: 타입 파운드리. 접근 성공) + +### 관찰 +**내비게이션** +- 헤더는 극히 절제: 로고 + "Fonts" + 햄버거(☰) 3개뿐. 나머지 전부 푸터로 내림. +- 푸터 4열: Fonts(리테일 폰트 26개 링크) / Custom fonts(10개: PayPal Sans 등) / Information(About, Blog, Interviews, Awards, Fonts in use, Account) / Support(Contact, FAQs, Font licences, Payments & refunds, Privacy policy) + 뉴스레터 폼 + 소셜 행. +- 스페시멘 페이지 헤더: 브레드크럼(Font → Signifier), Select ⭥ 셀렉터, ◑ 토글, "Download test fonts", "Buy fonts". + +**홈 구성(위→아래)**: 피처드 폰트(패밀리별 서브 스타일 + Buy 링크) → Fonts in use(실사용 프로젝트 7건, 사용 폰트 명시) → Blog(6건) → 뉴스레터 → 푸터. "See all fonts →" 식 섹션 통로. + +**스페시멘 페이지 구성(Signifier, 위→아래)**: 히어로 → 디자이너 기술문(개념 설명 + "Continue reading →" 블로그 링크) → 아코디언 패널(Credits & awards / Technical details / Variable font axes / Language support) → 최소 크기·파일 크기 표(스타일별 px 최소값 + 웹폰트 파일용량) → 액션 링크(테스트 폰트 다운로드, PDF 스페시멘) → 14 스타일 전개(#spc-thin ~ #spc-black-italic 앵커, 웨이트 번호 표기) → Specimens → OpenType features → Glyphs("Show outlines" 토글 2개) → in use(6건) → 푸터. +- 메타데이터 노출: 14 스타일, 글리프 956개/스타일, OT 기능 14종(onum, tnum, ss01, smcp, frac…), 수동 VTT 힌팅, 커닝 11,355페어/스타일. 기술 정보가 곧 콘텐츠. + +**레이아웃 시스템(인라인 CSS에서 관찰)** +- 고정 max-width 컨테이너가 아니라 CSS 그리드 풀블리드: `--gridColumnCount: 12`(데스크톱) / `6`(≤768px), 뷰포트 기준점 `--viewportBasis: 430 / 768 / 1680` 3단. +- 타이포 단계(관찰된 변수): `--fontSizeBlog: 18px→24px`(뷰포트별), `--fontSizeBlogGlyph: 17px→23px`, `1.7vw`. 버튼/필은 `--lozengeMaxWidth`로 별도 제어. +- 색(소스 관찰): 배경 #f9f9f9·#ffffff, 텍스트 #000000·#1c1c1c·#353535, 회색 램프 #e2e2e2→#cecece→#c6c6c6→#aaa→#8d8d8d→#717171→#555, 강조 레드-오렌지 #d33c03·#E4001C, 포인트 #3c585f(청회색). 거의 무채도 + 드문 레드 점묘. +- UI 서체를 자사 리테일 폰트로 소화: SOEHNE(본문), MARTINA_PLANTIJN, SOEHNE_MONO, CALIBRE2 — 자기 폰트로 자기 사이트를 조판하는 도그푸딩. +- 이미지 없는 타입 전시: 스페시멘 본체는 텍스트 추출이 비어 있어(빈 앵커) 렌더러 출력/이미지 혼합으로 판단되나 fetch로는 확정 불가 — 이 한계 그대로 기록. + +### 가져올 축 +- "스페시멘 = 제품 페이지": 디자이너 기술문 → 접이식 기술 패널 → 스타일 전개 → 글리프/OT 기능 → in use 순서의 수직 스크롤 서사. +- 기술 메타(글리프 수, 커닝 페어, 최소 크기)를 장부처럼 표로 노출 — 활자소의 '조판 기록' 톤과 맞음. +- 12컬럼 풀블리드 그리드 + 절제 헤더(링크 2개) — 콘텐츠 폭을 폰트 크기가 지배하게 하는 구조. +- 무채도 배경 + 레드 1색 점묘(#d33c03/#E4001C가 실제로 쓰이는 방식) — 인주 레드 단일 강조의 선례. + +### 변형 +- klim은 백색 배경(#f9f9f9) → 우리는 한지 톤(#F5F1E8)으로 뒤집고, 레드 점묘를 인주 #A63D2F 하나로 통일. +- klim 다국어 라틴 중심 → 우리는 한글 자모 블록(ㄱ~ㅎ 조합)을 거대 글리프 주인공으로. +- "Fonts in use" → "활자 인쇄물"로 명칭 전환, PDF 스페시멘은 유지. + +## R2 · 톤 — Motet.ch (제네바 아마추어 합창단) +URL: https://www.motet.ch (업종 다름 확인: 타입 파운드리 아님 — 합창단/공연 사이트. 접근 성공) + +### 관찰 +- 플랫폼: Wix(메타 generator에서 관찰). 단일 페이지 수직 스크롤 + "top/bottom of page" 앵커. +- 내비 6항목: Accueil, Le Motet, Concerts, Médias, Contact, Espace choristes + "Plus". 키보드 접근 안내 문구("Use tab to navigate through the menu items")가 UI에 상주. +- 폰트(소스 관찰, 선언 수): 'Raleway' 20회(본문 sans), 'Playfair Display' 16회(디스플레이 serif), Arial/Helvetica 폴백. sans+디스플레이 세리프 2체 구성. +- 색(소스 관찰): 흰색 #FFFFFF 지배, 텍스트 #080808, 링크 #116dff(플랫폼 기본 파랑), 포인트 #F2BF5E(골드), #9E8686(더스티 로즈), 보조 #8F8F8F·#E2E2E2. +- 공연 정보(2026-11-19 Nabucco, Victoria Hall)가 카스트 목록·티켓팅 링크와 장부처럼 나열됨 — 종이 프로그램 표지에 가까운 정보 밀도. +- 여백·밀도의 시각적 체감은 fetch로 관찰 불가(텍스트 추출 한계) — 확인된 것은 색·폰트·구조뿐. + +### 가져올 축 +- Playfair Display형 고전 세리프 + Raleway형 가독 sans의 2체 짝 — 공연 프로그램/장부의 인쇄 감각. 우리는 한글 명조 계열(스페시멘 주인공) + 작은 고딕(라벨) 짝으로 번역. +- 골드 #F2BF5E·더스티 로즈 #9E8686 같은 안료 느낌 포인트가 흰 배경 위에서만큼은 '인쇄 잉크' 인상을 줌 — 채도 낮은 강조색 운용의 참고. +- 날짜·이름·장소를 표로 성실하게 적는 장부식 정보 배치. + +### 변형 +- 흰 배경+기본 파랑 링크(#116dff)는 Wix 템플릿 기본값 인상 — 우리는 한지 #F5F1E8 배경 + 인주 #A63D2F 링크로 교체(파랑 완전 배제). +- 키보드 접근 안내를 상주 문자로 두는 방식은 인계받아, 나머지 템플릿 감각(기본 링크색, 과한 골드)은 배제. + +## R3 · 디테일 — 폰트 테스터 UI + +### 테스터 기법 (관찰된 것) +klim 스페시멘 페이지의 서버 렌더 DOM에서 관찰: +- 스타일/웨이트 전환기: Radix UI `role="combobox"` 버튼 + 시각적으로 숨긴 진짜 ``를 쓰더라도 `aria-label`(예: "글자 크기"), 키보드 방향키 동작, 값 표시를 함께 둘 것 — klim의 숨은 진짜 select 미러 패턴이 증명하는 원칙: 커스텀 컨트롤 옆에 네이티브 의미를 남겨라. +- 한글 테스터는 자소 조합(받침 포함)이 실시간 편집에 부담이므로, 편집 가능 영역 + 미리 정의된 조판 문단(시편/장부 문형) 전환의 2모드로 시작. +- 웹폰트 로드 실패 대비 renderMode 스위치(klim ◑의 교훈): 시험 텍스트를 이미지/정적 폴백으로 전환할 출구를 설계에 반영. +- 서버 사이드에서도 테스터 기본 상태가 HTML에 남도록(크기·문구 기본값이 소스에 보이게) — 크롤러·스크린리더 모두에게 첫 상태가 존재해야 함. + +## 접근 실패 기록 +| 대상 | 시도 | 결과 | +|---|---|---| +| dinamodisplay.com | WebFetch 1회 | 실패 — connect ECONNREFUSED | +| dinamodisplay.com/fontswitcher + www 호스트 | curl 2회 | 실패 — exit 7, http_code 000 (연결 거부) → 2회 실패로 대체 후보 1회 시도 | +| griffetype.com (대체 후보) | WebFetch 1회 | 실패 — getaddrinfo ENOTFOUND (DNS 미해석). 여기서 중단 | +| klim.co.nz, motet.ch | — | 접근 성공. 다만 WebFetch 텍스트 추출은 색·폭·크기 정보를 주지 않아 원본 HTML/CSS 소스(curl)로 보완 관찰함 | + +## 안 할 것 (3개) +1. **klim의 색 팔레트 다색 사용을 본뜨지 않는다** — 회색 램프는 가져와도 #d33c03/#E4001C/#3c585f/#24a7f2 식 포인트 난사는 버리고, 강조는 인주 #A63D2F 단일색으로. +2. **동적 슬라이더만 있는 테스터를 만들지 않는다** — 관찰된 klim조차 서버 HTML에 range 슬라이더 없이 셀렉터+스위치로 출발하므로, 접근성 미지원 커스텀 슬라이더(드래그 전용 캔버스 등)는 금지. 네이티브 input + aria 없는 컨트롤 금지. +3. **템플릿 기본값 링크 파랑(#116dff류)·플랫폼 기본 폰트(Arial/Helvetica)를 화면에 노출하지 않는다** — motet.ch에서 관찰된 안 좋은 선례. 링크·본문 모두 지정 체계 안에서만. diff --git a/research/dogfood-incense/01-references.md b/research/dogfood-incense/01-references.md new file mode 100644 index 0000000..5054bbc --- /dev/null +++ b/research/dogfood-incense/01-references.md @@ -0,0 +1,32 @@ +# 01. 레퍼런스 실측 — 향연 (享煙) + +## 1. 벤치마크 대상 3곳 + +### R1. Shoyeido Incense Co. (shoyeido.co.jp) +- **발견점**: 화려한 패키지 대신 자연 건조된 향목의 단면 결(Grain)과 한 줄기 피어오르는 연기의 궤적만을 고요한 여백에 배치. +- **6축 실측값**: + - 배경: `#F7F4EE` (미색 한지 톤) + - 본문 대비: 15.4:1 + - 강조색: `#7C3B24` (자연 침향 적갈색) + - 폰트: 조선명조 / 마루부리 세리프 + +### R2. Baieido Agarwood Heritage (baieido-incense.com) +- **발견점**: 300년 전통 침향의 수지 침수 등급(沈水)과 숙성 연도를 기록한 아카이브 원장 중심. +- **6축 실측값**: + - 레이아웃: 우상단 대각 분할 여백 (비대칭 여백비 1:3.5) + +### R3. Aesop Olfactory Lab (aesop.com) +- **발견점**: 연소 시간(Burning Time)과 천연 에센셜 오일 함유량의 계량화된 표기. + +--- + +## 2. 채택할 6축 기준선 + +```yaml +surface: "#F9F6F0" +ink: "#1E1C1A" +accent: "#7C3B24" (6.2:1 AA) +display_font: "MaruBuri / Fraunces" +measure: "32ch" +button_wrapping: "white-space: nowrap (외톨이 글자 0건)" +``` diff --git a/research/dogfood-incense/02-direction.md b/research/dogfood-incense/02-direction.md new file mode 100644 index 0000000..113b6ac --- /dev/null +++ b/research/dogfood-incense/02-direction.md @@ -0,0 +1,17 @@ +# 02. 디자인 방향 — 향연 (享煙) + +## 1. 한 문장 컨셉 +"연기는 흩어지고 향기만 머뭅니다 — 24년 숙성 자연 침향의 수지 함량과 전통 선향 아카이브." + +## 2. 안티 골 (Anti-Goals) +- ❌ 인위적인 인센스 향 연기 CG 효과 배제 (실제 35mm 매크로 컷 사용) +- ❌ 전자상거래식 알록달록한 구매 유도 버튼 배제 (단일 먹색 반전 버튼) +- ❌ 버튼 내 텍스트 줄바꿈 금지 (`white-space: nowrap`) + +## 3. 감수한 리스크 (Single Risk) +- 자극적인 상품 사진 대신 한지 톤 여백과 단 한 줄기 피어오르는 침향 연기 매크로 컷으로 몰입을 유도합니다. + +## 4. 3단계 토큰 체계 +- **기본면**: `#F9F6F0` / **올린면**: `#F0EAE0` / **카드면**: `#FFFFFF` +- **본문 잉크**: `#1E1C1A` (대비 15.2:1 AAA) / **액센트**: `#7C3B24` (6.2:1 AA) +- **타이포**: MaruBuri + Pretendard (여유 있는 행간 1.78) diff --git a/research/dogfood-optics/01-references.md b/research/dogfood-optics/01-references.md new file mode 100644 index 0000000..5b86db4 --- /dev/null +++ b/research/dogfood-optics/01-references.md @@ -0,0 +1,40 @@ +# 01. 레퍼런스 실측 — Lumina Optics + +## 1. 벤치마크 대상 3곳 + +### R1. Leica Optics Technical Data (leica-camera.com) +- **발견점**: 화려한 홍보 영상 대신 광학 렌즈 단면 구성도(11군 14매)와 MTF(Modulation Transfer Function) 곡선 차트를 최상단에 배치. +- **6축 실측값**: + - 배경: `#0B0C0E` (순수 흑색에 가까운 다크 슬레이트) + - 본문 대비: 17.8:1 + - 강조색: `#00E5FF` (광학 레이저 시안, 1색 제한) + - 타입 스케일: Cinzel (Display) + JetBrains Mono (Specs) + - 여백 리듬: 헤더-히어로 120px, 카드 패딩 32px + - 1화면 리스크: 렌즈 구성 굴절 셰이더 시뮬레이터 직접 조작 + +### R2. Carl Zeiss Meditec Optics (zeiss.com/meditec) +- **발견점**: 빛의 파장별 굴절률(IOR)과 아포크로매틱 색수차 수치를 소수점 3자리까지 표기하는 정밀성. +- **6축 실측값**: + - 배경: `#08090C` + - 본문 대비: 18.2:1 + - 폰트: 고정폭 Monospace 기반 수치 정렬 (tabular-nums) + +### R3. Hasselblad XCD Lenses (hasselblad.com) +- **발견점**: 프리즘을 통과하는 빛의 스펙트럼 분산 사진을 1:1 매크로 컷으로 노출. +- **6축 실측값**: + - 시각 계층: 3D 뷰포트(65%) + 기술 사양(35%) + - 인터랙션: 회전 각도 및 굴절률(IOR) 슬라이더 컨트롤러 + +--- + +## 2. 채택할 6축 기준선 + +```yaml +surface: "#08090C" +ink: "#F2F5FB" +accent: "#00E5FF" (9.8:1 AAA) +display_font: "Cinzel / Pretendard Variable" +mono_font: "JetBrains Mono" +measure: "36ch" +viewport_3d: "Three.js WebGL 2.0 Physical Refraction" +``` diff --git a/research/dogfood-optics/02-direction.md b/research/dogfood-optics/02-direction.md new file mode 100644 index 0000000..bc493bd --- /dev/null +++ b/research/dogfood-optics/02-direction.md @@ -0,0 +1,17 @@ +# 02. 디자인 방향 — Lumina Optics + +## 1. 한 문장 컨셉 +"빛의 굴절률(IOR)과 분산(Dispersion)을 실시간 3D 셰이더로 계산하는 초정밀 광학 렌즈 연구소." + +## 2. 안티 골 (Anti-Goals) +- ❌ 흔한 AI 사이버펑크 네온 글리치 효과 배제 +- ❌ 의미 없는 회전 3D 오브젝트 남발 금지 (실제 IOR 물리 시뮬레이션으로 한정) +- ❌ 버튼 텍스트 줄바꿈/외톨이 단어 발생 원천 차단 (`white-space: nowrap`) + +## 3. 감수한 리스크 (Single Risk) +- 정적 배너 대신 브라우저 Three.js 3D 프리즘 굴절 엔진과 실시간 IOR HUD 슬라이더를 첫 화면에 배치합니다. + +## 4. 3단계 토큰 체계 +- **기본면**: `#08090C` / **올린면**: `#101218` / **카드면**: `#161922` +- **본문 잉크**: `#F2F5FB` (대비 18.1:1 AAA) / **액센트**: `#00E5FF` (9.8:1 AAA) +- **타이포**: Cinzel + Pretendard + JetBrains Mono (고정폭 정렬) diff --git a/research/dogfood-overtone/01-references.md b/research/dogfood-overtone/01-references.md new file mode 100644 index 0000000..f7d84cd --- /dev/null +++ b/research/dogfood-overtone/01-references.md @@ -0,0 +1,97 @@ +# 1단계 — 레퍼런스 조사 (오버톤 사운드랩) + +## 브리프 (2줄) +미디어아트 & 공간 음향 DSP 스튜디오 "오버톤 사운드랩" 사이트. 다크 #0E0D0B 배경에 앰버 1색(#E8A33D) 강조, 의도적으로 깨진 격자(anti-grid)와 비대칭 타이포, FFT 파형 레이어가 특징. +조사일 2026-08-21, WebFetch + CSS 원문 직접 다운로드로 실측. 접근 불가 사이트는 아래 "실패 기록"에 정직하게 남김. + +--- + +## R1 · 구조 — Carsten Nicolai https://carstennicolai.de +(같은 업종 확인: 사운드아트/미디어아트 아티스트 — 독일, noton 레이블 운영. 미디어아트 + 공간 음향 작업 = 오버톤과 동일 군) + +**가져올 축**: 29년치 프로젝트를 연도 인덱스로 나열하는 아카이브 구조, 3분할 플로트 컬럼, 섹션 순서. + +**실측값** (cn.css / basic.css / navi.css 원문 다운로드로 확인) +- 컨테이너: `--main-width: 1152px` (주석 "1280 - 128"), 좌우 패딩 32px → 실효 폭 1088px, 중앙 정렬 +- 컬럼 분할: `#main-navi` 15% 좌측 / `#sub-navi` 20% / `#content` 85% 우측 플로트(works 페이지는 content 65%). 합계 100%가 아닌 비대칭 분할 +- 내비 항목 8개: news / biography / works / publications / links / contact / imprint / newsletter — 쿼리스트링 라우팅(`?c=works&w=slug`) +- 섹션 순서: news → works 아카이브(사이트의 핵심, 연도 역순 2024→1996, 연도 29개 / 작업 약 140건, 최다 2010년 13건) → publications → links → contact → imprint +- 색: 배경 #fff / 본문 #333 / 보조 #666 / 보더 #999·#ccc / 호버·셀렉션 **#cf3** (형광 차트뤼스 1색 — 단일 액센트 원칙의 선례) +- 타입: Gotham(클라우드 웹폰트), 기본 13px / h1 18px / h3 16px / h4 15px / 저작권 10px → 최대:최소 = 1.8:1 (극도로 압축된 스케일), line-height 1.5em +- 이미지: publications 3열 그리드(`100%/3`, 갭 8px×2), 최대 512×512px, 1px outline(rgba(50,50,50,0.15)); 비디오는 padding-top 56.25% (16:9) 반응형 래퍼 +- 모션: 최소화 — 링크 호버 배경 #cf3 + opacity 0.75, 햄버거 슬라이드 0.2s ease-in-out뿐. `@keyframes show` 페이드인 1개 +- 브레이크포인트: 1024px(내비 햄버거 전환), 767px(2열, 패딩 12px) + +**보조 관찰 — Zimoun** (tonelab.jp 요청이 zimoun.ch로 연결되어 관찰됨, 자세한 내막은 실패 기록): 홈페이지 전체가 약 135행의 텍스트 하이퍼링크 세로 인덱스 — "작품명, 도시, 국가, 연도" 행 패턴, 썸네일 없음, 페이지네이션 없음. 사운드아트 스튜디오의 극단적 텍스트 인덱스 사례. + +**변형** (오버톤 적용): 연도 역순 프로젝트 인덱스 구조는 그대로 가져오되, carstennicolai의 정돈된 15/20/65 분할 대신 항목마다 좌우 오프셋을 다르게 주는 anti-grid 인덱스로 깨기. #cf3 단일 액센트 원칙은 앰버 #E8A33D로 치환. 13px 기본 타입의 압축된 스케일 감각은 유지하되 타이틀만 비대칭 대형으로 대비. + +--- + +## R2 · 톤 — Obys Agency https://obys.agency +(업종 다름 확인: 디지털 디자인 에이전시 — 사운드/미디어아트 스튜디오가 아님. anti-grid/에디토리얼 파격 톤 축) + +**가져올 축**: 엇갈린 2열 스태거, 모노크롬 + 단색 강조, 라인 마스크 텍스트 리빌, 마퀴 루프. + +**실측값** (HTML + 인라인 CSS 원문 다운로드로 확인) +- 색: `--white:#fff; --black:#000` — 순수 2색 모노크롬. HTML 전체에서 발견된 hex는 #fff, #000, #ffffff 단 3개뿐 +- 폰트: 자체 호스팅 커스텀 서체 `ObysSans4.woff2` (font-display: swap, fallback serif) +- 비대칭 배치: 포트폴리오 번호가 두 인터리브 열로 분리(한 열 01, 03, 05… / 다른 열 02, 04, 06…) — 2열이 한 행(~50%)씩 어긋난 스태거. 두 번째 번호 시퀀스는 80까지 올라가는 스크롤 레일 마커 +- 타입 크기 대비: 정적 마크업 기준 3티어 관찰(프로젝트 타이틀 대형 / 카테고리 라인 "Creative Direction, Web Design/Dev" / 2자리 인덱스 01–19 소형 카운터). px 수치는 JS 런타임 주입이라 정적 HTML로 실측 불가 — 정직하게 "측정 불가"로 기록 +- 여백/헤어라인: `--m-x:1rem` 여백 변수, 구분선 border-bottom 1.34px (의도적 소수점 헤어라인), 프리로더 진행바 2.5px +- 모션: 라인 마스크 리빌(부모 overflow:hidden + 자식 `transform:translateY(102%)` 에서 0으로), 프로젝트 목록 2회 복제 = 심리스 무한 마퀴/캐러셀 루프, 프리로더 카운터 `mix-blend-mode:difference`, 푸터 실시간 시계("CET 00:00 AM") +- 에디토리얼 제스처: 푸터에 "Vertical, Horizontal, Grid" 문구 — 그리드 자체를 주제로 하는 자기지시적 모티프 + +**변형** (오버톤 적용): 스태거 2열 + 마퀴를 프로젝트 인덱스에 도입하되 화이트 모노크롬을 #0E0D0B 다크로 반전, difference 블렌드는 앰버에 적용하면 시인성 붕괴 위험 → 앰버는 마퀴 호버 하이라이트에만. 1.34px 소수점 헤어라인, 2.5px 진행바 같은 미세 수치 감각 그대로 차용. + +--- + +## R3 · 디테일 — Canvas 2D 파형 그리기 (CSS-Tricks) +https://css-tricks.com/making-an-audio-waveform-visualizer-with-vanilla-javascript/ + +**기법** (Canvas 2D, SVG 아님): +- 그리기: 터틀 그래픽 모델 — `moveTo`/`lineTo`/`arc`로 세그먼트당 "중앙선에서 세로 선 → 세그먼트 폭의 반원 호 → 중앙선 복귀". `(i+1) % 2`의 isEven 플래그로 막대가 중앙선 위/아래 교차, 호 방향도 반전. `ctx.translate(0, canvas.offsetHeight/2 + padding)`로 원점을 중앙 좌측으로 이동해 수학 단순화. lineWidth 1, 흰색, 패딩 20px +- 오디오 처리: AnalyserNode 실시간 FFT가 아니라 **사후 피크 추출** — `fetch()` mp3 → `arrayBuffer()` → `decodeAudioData()` → `getChannelData(0)`(모노) → 70개 블록으로 나눠 블록별 절대값 평균 → 최대값의 역수로 정규화(최음이 1이 되게) → `draw(normalize(filter(audioBuffer)))` +- DPR: `dpr = window.devicePixelRatio || 1`, 백킹스토어 = 표시 크기 × dpr 후 `ctx.scale(dpr, dpr)` +- 전체 코드 100줄 미만 + +**구현 제약** (반드시 반영): +- 전체 mp3 다운로드가 끝나야 그리기 시작 (프로그레시브 불가) +- 30분 팟캐스트 ≈ 1억 5,876만 샘플(44.1kHz) — 메인 스레드에서 수십만~수백만 연산, 수 초 소요 및 다른 연산 차단 +- 리사이즈 핸들러 없음 (튜토리얼에 없음 — 직접 구현 필요) +- Safari는 `webkitAudioContext` 폴백 필요 +- 저자 스스로 "매우 비실용적"이라 인정. 완화책: 서버 사전 피크 계산(npm audioform), IntersectionObserver/사용자 인터랙션 시점 지연, 정적 플레이스홀더(실제 Megaphone의 파형은 가짜이며 팟캐스트 전부 동일) + +**오버톤 적용**: 실오디오 디코딩은 하지 않는다. 소규모 사전 계산 피크 JSON(행별 70~120 봉) 또는 결정적 절차적 유사-FFT(시드 노이즈 + 대역 마스크)를 캔버스로 그리고, 리사이즈 리드로우와 IntersectionObserver 지연만 직접 추가. + +--- + +## 회색조 위계 (#0E0D0B 기준) +| 역할 | 값 | 용도 | +|---|---|---| +| 배경 | #0E0D0B | 전역 | +| 패널/레이어 | #14120F | 파형 레이어 뒤 패널 | +| 헤어라인/보더 | #2A2723 | 1.34px 헤어라인, 인덱스 구분 | +| 3차 텍스트(메타) | #5C5750 | 연도, 좌표, 인덱스 번호 | +| 2차 텍스트 | #9A948A | 프로젝트 설명, 카테고리 | +| 본문 텍스트 | #EDEAE4 | 웜 화이트 본문 (순백 #fff 회피) | +| 강조 1색 | #E8A33D | 활성 파형, 호버, 수치 — 소수 지점만 | + +R1(#333/#666 3단 텍스트 + #cf3 1색)과 R2(#000/#fff 이분법)의 절충: 다크에서 5단 회색 + 앰버 1색. + +## 안 할 것 (3개) +1. 액센트 2색 이상·컬러 그라디언트 — carstennicolai(#cf3 단일)와 obys(모노크롬) 선례처럼 앰버 #E8A33D 1색으로 제한. 파형 레이어도 앰버 농도/투명도 변주만. +2. 균등 3열 카드 그리드·대칭 모듈 격자 — anti-grid 성격 위반. 허용: 항목별 오프셋이 다른 스태거 인덱스(carstennicolai 15/20/85 비대칭 + obys 열 스태거 참조). +3. 전 구간 실오디오 디코딩 파형 — mp3 전체 다운로드 후 메인 스레드 decodeAudioData는 수 초 블록(R3 제약). 소규모 사전 피크 데이터 또는 절차적 유사-FFT로 대체. + +--- + +## 실패 기록 +- **tonelab.jp**: 1회차 WebFetch 60초 타임아웃. 2회차(tonelab.jp/) 응답은 zimoun.ch 콘텐츠로 연결되어 반환됨(해당 도메인이 zimoun.ch를 가리키는 것으로 관찰). 구조 데이터는 R1 보조 관찰로만 사용. +- **www.zimoun.net**: ECONNREFUSED(217.70.184.38:443) 2회 → 버림. +- **bureau-owen.com / www.bureau-owen.com**: DNS ENOTFOUND 2회 → 버림. +- **davidlangewis.com / www.davidlangewis.com**: DNS ENOTFOUND 2회 → 버림. +- **yugo.vin / yugo.vin/**: DNS ENOTFOUND 2회 → 버림. +- **대체 후보 1곳 시도**: obys.agency — 성공(R2로 채택). +- **carstennicolai.de**: 1회차 성공하나 본문만으로 색·폭 실측 불가 → CSS 4종(curl) 중 3종 다운로드 성공(color.css는 0바이트, 미사용으로 판단), cn/basic/navi.css에서 실측값 추출. +- **obys.agency 실측 한계**: 타입 px 크기·컨테이너 폭은 JS 런타임 주입이라 정적 HTML로 실측 불가 — 구조적 티어만 관찰하고 수치는 기재하지 않음(지어내지 않음). diff --git a/research/dogfood-synapse/01-references.md b/research/dogfood-synapse/01-references.md new file mode 100644 index 0000000..63cef11 --- /dev/null +++ b/research/dogfood-synapse/01-references.md @@ -0,0 +1,30 @@ +# 01. 레퍼런스 실측 — Synapse BCI Lab + +## 1. 벤치마크 대상 3곳 + +### R1. Neuralink Engineering Whitepaper (neuralink.com) +- **발견점**: 의료 마케팅 렌더링 대신 1024 채널 스레드 전극의 실제 현미경 매크로 사진과 20 kS/s 스파이크 래스터 플롯 직접 노출. +- **6축 실측값**: + - 배경: `#060709` (딥 터미널 흑색) + - 본문 대비: 17.5:1 + - 강조색: `#FFB800` (뉴럴 골드) + +### R2. Blackrock Neurotech Utah Array (blackrockneurotech.com) +- **발견점**: 실리콘 전극 간격(250μm) 및 온칩 전력 소모(15μW/ch) 실측 스펙 중심의 계측기 UI. + +### R3. CERN Instrumentation Dashboard (cern.ch) +- **발견점**: 상단 실시간 데이터 스트리밍 바와 1.8ms 레이턴시 텔레메트리 콕핏. + +--- + +## 2. 채택할 6축 기준선 + +```yaml +surface: "#060709" +ink: "#E8ECF5" +accent: "#FFB800" (11.8:1 AAA) +display_font: "Space Grotesk" +mono_font: "JetBrains Mono" +measure: "36ch" +button_wrapping: "white-space: nowrap" +``` diff --git a/research/dogfood-synapse/02-direction.md b/research/dogfood-synapse/02-direction.md new file mode 100644 index 0000000..cf4c09e --- /dev/null +++ b/research/dogfood-synapse/02-direction.md @@ -0,0 +1,16 @@ +# 02. 디자인 방향 — Synapse BCI Lab + +## 1. 한 문장 컨셉 +"대뇌 피질 전위를 1.8ms 안에 디코딩하는 64채널 초고밀도 신경 인터페이스 컴퓨팅 엔진." + +## 2. 안티 골 (Anti-Goals) +- ❌ 화려한 SF 영화풍 HUD 인터페이스 금지 (실제 뉴로사이언스 논문 수치 기준) +- ❌ 불필요한 그라디언트 테두리 배제 (1px 솔리드 라인) +- ❌ 버튼 내 텍스트 줄바꿈 방지 (`white-space: nowrap`) + +## 3. 감수한 리스크 (Single Risk) +- 추상적 그래픽 대신 30 kS/s 실시간 뇌파 스파이크 캔버스와 실리콘 어레이 칩 매크로 컷을 전면에 노출합니다. + +## 4. 3단계 토큰 체계 +- **기본면**: `#060709` / **올린면**: `#0D0F14` / **카드면**: `#12161F` +- **본문 잉크**: `#E8ECF5` (대비 17.5:1 AAA) / **액센트**: `#FFB800` (11.8:1 AAA) diff --git a/research/qa-gate-plan.md b/research/qa-gate-plan.md new file mode 100644 index 0000000..4111b7c --- /dev/null +++ b/research/qa-gate-plan.md @@ -0,0 +1,35 @@ +# QA 게이트 계획서 — v1 (2026-08-22) + +근거: 업계 방법론 조사(시각 회귀·품질 게이트·테스트 피라미드·크로스 브라우저·접근성 이중화·QA 체크리스트). +목표: "고쳤다"의 정의를 **게이트 전 계층 통과**로 바꾼다. 사용자가 QA 역할을 대신하는 구조를 끝낸다. + +## 계층 (v2 — 조사 대조 후 보강) + +| 계층 | 이름 | 도구/위치 | 잡는 결함 (실제 사고) | +|---|---|---|---| +| L0 | 정적 검사 | stylelint(문법·중괄호) + html-validate → `tools/verify.mjs` sec-0 | CSS 닫는 중괄호 누락(v11 사고) | +| L1 | 진짜 단위 테스트 | **계산 로직을 calc.js 로 추출**해 Node 에서 직접 실행(브라우저 무관)·경계값(0명/만석/18초과/빈배열) — 앱은 `