diff --git a/apps/site/design.md b/apps/site/design.md index 39edbf2..d43f4ae 100644 --- a/apps/site/design.md +++ b/apps/site/design.md @@ -891,3 +891,189 @@ v23 스크롤 리셋·단일 헤더에도 사용자 평가가 "여전히 깨져 - `base.css`에 글로벌 미디어 리셋(`img, picture, video, canvas, svg { max-width: 100%; display: block; }`) 및 `html { overflow-x: hidden; }` 안전장치 추가. - **자동화 검증**: - `tools/test-showcase-dialog.mjs` (1440px / 768px / 390px / 320px 4개 뷰포트 E2E): 데스크톱 0px 스크롤 CTA 노출 확인, 좌측 cover 핏 실측, 모바일 스티키 독 활성화 확인, 모달 닫기/Esc/배경 클릭/카드 액션 등 17개 단언 100% PASS. + +## 2026-08-24 — 신규 쇼케이스 3종 (RFS · 트웬티온스 · 새벽청과) + 게이트 두 겹 신설 + +각 워크는 서로 다른 클라이언트이므로 0~6단계를 워크 단위로 따로 돌았다(토큰 체계 신규 정의 = 전체 경로). +세 워크 모두 **라이트/다크 반전을 토큰 규칙으로 정의**해 하드 게이트 4를 통과시켰다 — +`.band-light` / `.band-dark` 가 `--band-*` 를 재정의하고, 컴포넌트는 `--band-*` 만 본다. +그래서 반전은 섹션마다의 기분이 아니라 두 클래스가 만드는 규칙이다. + +### 워크별 한 줄 (상세는 각 tokens.css 머리글에 실측치까지 기록) + +| 워크 | 프리셋 | 레퍼런스 (실측) | 컨셉 · 감수한 리스크 | +|---|---|---|---| +| **RFS** `rfs-cfd` | `darkroom-spec` (신규) | R1 luminary.ai(배경 #2A2C2F·h1 72/600·radius 0 1898회·히어로 영상이 첫 화면 100%) · R2 teenage.engineering EP-133(#F9FAF9·강조 #FF5000 단 10회·밝은 판에 다크 블록 18회) · 보조 simscale.com(1192↔1425 교대·h1 49.78/400) | **암실과 사양서.** 유동이 있는 곳은 어둡고 읽는 곳은 밝다. 리스크: 히어로에서 브라우저가 실제로 Navier–Stokes 를 푼다 | +| **트웬티온스** `twentyoz-corp` | `field-record` (신규) | R1 samsara.com(#FFFFFF·FK Grotesk 2패밀리·최대÷본문 5.0·radius 0 2399회) · R2 lars-mueller-publishers.com(**본문 20px**·h1 34px·최대÷본문 1.7·radius 0 1709회) · R3=현행 twentyoz.kr 감사 | **네 개의 현장.** 리스크: 첫 화면에 회사 이름·슬로건을 두지 않고 현장 선택기를 둔다 | +| **새벽청과** `saebyeok-cheonggwa` | `market-board` (신규) | R1 natoora.com(#121212·본문 16px 224회·최대÷본문 4.5·히어로 이미지 1313×900) · R2 phillips.com(무채색·h1 28px/400·날짜와 상태가 제목보다 앞) · R3 제철 12개월 띠 | **오늘 새벽 두 시의 값.** 리스크: 상품 사진이 아니라 시세판이 히어로다 | + +두 레퍼런스의 합성을 기록해 둔다 — 트웬티온스는 R2(본문 20px)로 본문을 키우고 +R1(최대÷본문 5.0)로 스케일을 벌렸다. 한쪽만 따르면 평평하거나 본문이 작아진다. + +### RFS — 히어로가 실제로 유체를 푼다 + +`scripts/thermal-field.js`, 순수 WebGL2. three.js 를 쓰지 않았다(예산 절약이 아니라 +셰이더 플레인 몇 장이면 되는 일에 씬 그래프가 필요 없어서다). + +- 패스: 이류(semi-Lagrangian) → 온도 소스 → 부력·설비 힘 → 발산 → 압력 Jacobi → 투영 → 파티클 전진 +- 격자 224×126 / 파티클 26,000 / Jacobi 22회. 좁은 화면·저코어면 128×72 / 9,000 / 14회, DPR 1.25 +- `prefers-reduced-motion` 이면 **아예 시작하지 않는다**. 그 자리에 RFS 실물 파티클 렌더가 남는다 — 폴백이 아니라 결과물이다 +- IntersectionObserver 로 화면 밖이면 정지, 탭이 숨어도 정지 + +**세 번 고쳤고 원인은 전부 단위계였다.** + +1. **파티클이 0.2초 만에 사라졌다.** `dt` 를 "프레임 단위"로 넘기면서 수명 감쇠를 + `age -= dt * 0.115` 로 걸었다. dt≈0.9 라 프레임당 0.1씩 줄어 10프레임이면 소멸이다. + **dt 를 초로 못박고** 속도의 단위를 "초당 격자 셀"로 통일해 해결했다. + 감쇠도 프레임 상수(0.9965)에서 `pow(base, dt*60)` 으로 바꿨다 — 주사율이 물리를 바꾸면 안 된다. +2. **입자가 실 한 가닥으로 뭉쳤다.** 전부 급기구에서만 재생성해서 같은 경로를 따라갔다. + 방이 아니라 소용돌이 그림이 됐다. 절반만 급기구에서, 나머지는 방 안 임의 위치에서 되살린다. +3. **핫 아일/콜드 아일이 안 보였다.** 랙이 위로 뿜게 했더니 냉기와 열기가 랙을 사이에 두고 + 갈라지지 않았다. 전면 흡입 → 배면 토출로 바꿔 이 업계의 기본 그림이 화면에 나오게 했다. + +수치는 전부 RFS 개발 저장소 README 출처다(파티클 100,000 / GPU 커널 53+ / 20B SoA / +테스트 2,000+ / Unity 6.4 · URP 17.2 / GTX 1660~RTX 3060). **정확도 지표는 싣지 않았다** — +사내 검증 문서의 등급은 고객사 대상 내부 자료이고 현재 통과 상태가 아니다. +성능이 아니라 정직함이 이 페이지의 자산이므로, 없는 것은 없다고 두었다. + +### 트웬티온스 — 리디자인 감사가 R3 를 대신했다 + +현행 twentyoz.kr 을 브라우저로 열어 computed style 을 실측했다. 여기서 나온 것이 +리디자인의 근거 전부다. + +- **대비 실패 172개 요소** — `rgb(102,102,102)` 가 배경 `rgb(2,2,2)` 위 **3.61:1**, 그것도 9~11px 로 284곳 +- **한글 폰트 미지정** — `font-family` 가 Inter 뿐이라 한국 회사의 한국어 사이트에서 한글이 시스템 기본으로 떨어진다 +- h1 174.86px 에 `letter-spacing: -10.49px` = **−0.06em**. 한글 상한(−0.02em)의 세 배 +- 배경 `#020202` — 순흑에 가깝다 +- 'Digital Genetics' 은유가 전면에 있다(CULTIVATING · DROPS · Sativa/Indica · Germination · + Wholesale Desk · ACQUIRE GENETICS). SK하이닉스·LG디스플레이·한화솔루션·GS칼텍스에 + 산업용 IoT 를 파는 회사의 어휘로는 사업을 가린다 + +**유지한 자산**: radius 0(742회로 이미 통일), 무채색 기조, 모노스페이스. 형태 어휘는 옳았다. + +현장 전환은 **라디오 그룹 + `:checked ~`** 로 JS 0바이트다. 탭 흉내를 내지 않고 라디오를 +쓴 이유는 좌우 화살표 키가 그냥 먹기 때문이다 — 직접 만든 탭은 그 키를 다시 구현해야 한다. + +파트너 19곳을 현장별로 나누지 않았다. 원본 사이트가 도메인을 표기하지 않으므로 +내가 나누면 그건 추측이다. 프로젝트 12건은 이름·연도·기술 스택이 원본에 있으므로 그대로 옮겼다 +(공개 카탈로그 49건 중 12건만 노출돼 있었다 — 그 사실도 페이지에 적었다). + +### 새벽청과 — 값이 바뀐다는 사실을 감추지 않는다 + +커머스가 보통 감추는 것(시세 변동)을 히어로의 주인공으로 삼았다. 그래서 상품 사진은 +아래로 내렸고, 첫 화면은 표다. + +카피의 사실 근거를 전부 조사해서 넣었다: 가락시장 과일 경매는 새벽 2시~아침 8시이고 +사과·배는 08:30 이다 · 유통 경로는 출하 → 도매법인 수집 → 경매 → 중도매인 낙찰 → 소매 · +낙찰가를 경락가격이라 부른다 · 등급은 특·상·보통이고 **당도 표시는 특·상 등급에만 허용**된다 · +표시 가능 품목은 8+5 종이라 무화과는 들어 있지 않다 · 선과기는 드럼식(지름)과 중량식(무게) · +거래단위는 사과 2·5·7.5·10kg, 포도 2·3·4·5kg. + +무화과의 당도 칸을 `—` 로 두고 "값이 없다는 뜻이 아니라 표시가 허용되지 않는 품목"이라고 +적은 것이 이 페이지에서 가장 마음에 드는 한 줄이다. 규정을 아는 사람만 쓸 수 있는 문장이다. + +**하드 게이트 11 대응**: 표의 숫자는 가상 상호의 예시값이므로, 판 아래와 푸터 두 곳에 +"이 쇼케이스를 위한 예시" 라고 명시했다. 주소·전화·사업자 정보는 지어내지 않고 비웠다. + +등락은 색으로만 말하지 않는다. `▲ ▼ 보합` 기호가 먼저고 색은 거들지 않는다 — +색맹 사용자에게 빨강/파랑만 남는 판은 판이 아니다. + +이미지 4장은 codex 내장 `image_gen`(gpt-image-2)으로 생성했다. 스톡이 아니다. +프롬프트에 이 페이지의 토큰값(#FBFAF7 · #16181A · #1F3F6B)을 그대로 넣었다. +합계 341KB(webp). + +### 게이트 두 겹을 새로 심었다 + +기존 체인(stylelint · html-validate · L1~L6)이 **구조적으로 못 보던 자리**가 있었다. + +**1) `tools/integrity.mjs` — L0-무결성 (verify 체인에 편입)** + +`stylelint` 도 `html-validate` 도 "`var(--x)` 를 썼는데 `--x` 가 어디에도 없다"를 못 잡는다. +미정의 변수는 **선언 전체를 무효로 만드는데 콘솔 에러가 없어서** 조용히 살아남는다. + +심자마자 직전 릴리스(v0.9.1)의 실제 결함이 나왔다: + +| 발견 | 내용 | +|---|---| +| 미정의 CSS 변수 | `cask-archive` 25건 · `cheongon-care` 20건 · `materia-interior` 19건. `--dur-fast` · `--radius-xs` 가 정의된 적이 없어 **transition 선언이 통째로 무효**였다(호버 전환이 전부 즉시 점프) | +| 한글 조판 누락 | `lumina-optics` · `pulsegate` · `synapse-bci` 가 `word-break: keep-all` 없음, 그리고 `IBM Plex Sans` 만 지정해 **한글 폰트 미지정** | +| 슬롭 지문 | `text-transform: uppercase` 로 만든 영문 대문자 eyebrow 19건(슬롭 검출률 2위 항목) | +| 하드 게이트 9 | `materia-interior/hannam-duplex.html` 의 `100vh` | +| 죽은 파일 | 그 리다이렉트 스텁은 어디서도 참조되지 않으면서 인라인 hex(게이트 1)까지 갖고 있었다 | + +전부 고쳤다. `--dur-fast → --dur-quick`, `--radius-xs → --radius` 로 **기존 토큰에 매핑**했다 — +새 토큰을 추가하면 radius 어휘가 4종이 되어 하드 게이트 3에 걸린다. +대문자 eyebrow 는 한글 문장으로 바꾸고 `font-mono` 도 본문 서체로 되돌렸다(한글에 모노는 폴백된다). + +**2) `tools/showcase-audit.mjs` — 실렌더 감사 (320/390/768/1440)** + +정적 grep 이 못 보는 것을 실제 렌더에서 잰다: 가로 스크롤 · 뷰포트 초과 · 컨트롤 줄바꿈 · +텍스트 대비 AA · 왼쪽 인셋 최소값 · h1 개수 · 히어로 헤드라인 줄 수 · 깨진 이미지 · 콘솔 에러. +`html-validate` 대상도 두 페이지에서 `public/work/*/index.html` 전체로 넓혔다 — +넓히자 기존 페이지에서 11건이 나왔다(라벨 하나가 컨트롤 둘에 걸림 5건, +`dl > div > div > dt` 4건, div 에 붙은 `aria-label` 2건). 전부 수리. + +**측정기가 두 번 거짓말했고 둘 다 고쳤다.** + +- `.sr-only` 를 대비 판정에 넣어 34건을 실패로 잡았다. 눈에 보이지 않는 텍스트다. + `clip-path` + 2px 이하 상자를 sr-only 로 판정해 제외한다 +- `overflow-x: auto` 상자 안의 넓은 표(제철 띠)를 오버플로로 잡았다. + 그 상자가 **실제로 스크롤될 때만** 면제하도록 조상을 걷어 올려 확인한다 + +두 번 다 페이지가 아니라 도구가 틀렸다. 고치기 전의 통과·실패는 의미가 없었다. + +### 의도적으로 하지 않은 것 + +- **RFS 에 정확도 지표를 싣지 않았다** — 사내 검증 등급은 공개할 성질이 아니고 현재 통과 상태도 아니다. 없는 것은 없다고 둔다 +- **RFS 히어로를 3D 로 만들지 않았다** — 제품이 3D 인 것과 홍보 페이지가 3D 여야 하는 것은 다른 문제다. 2D 축소판이라고 캡션에 적었다 +- **트웬티온스 파트너를 현장별로 배정하지 않았다** — 원본에 없는 정보다 +- **새벽청과에 주소·전화·사업자번호를 넣지 않았다** — 가상 상호에 그럴듯한 연락처를 적으면 하드 게이트 11 이다 +- **세 페이지 모두 마퀴·펄스 상태점·요금제 3열 카드를 쓰지 않았다** +- **`dark-instrument` 를 세 번째로 쓰지 않았다** — 저장소에 이미 둘(Pulsegate·Synapse) 있다. 같은 프리셋의 셋째는 결정이 아니라 카테고리 평균이다 + +### 다음 실행이 해야 할 일 + +1. `tools/verify.mjs` 의 L2~L6 은 여전히 관리 앱 2종(가온·두레) 전용이다. 정적 쇼케이스 17종은 L0 두 겹과 `showcase-audit` 만 돈다 — 시각 회귀 기준을 쇼케이스로 넓힐 여지가 있다 +2. LCP·CLS 는 세 페이지 모두 **미측정**이다. 배포 후 모바일 프로파일 Lighthouse 로 재야 한다 +3. `pulsegate` · `synapse-bci` · `lumina-optics` 의 한글 폰트를 스택에만 추가했다. 실제 조판(자간·행간)은 IBM Plex Sans 기준으로 잡혀 있어 한글 눈 검수가 남아 있다 + +### 성능 — 실측과 예산 이탈 한 건 + +`performance.getEntriesByType('resource')` 로 첫 화면 전송량을 실측했다(RFS 기준). + +| 항목 | 예산(데모) | 실측 | 판정 | +|---|---|---|---| +| 히어로까지 JS (gzip) | 400KB | **24KB** | 통과 — three.js 를 쓰지 않았다 | +| WebGL 추가분 | +600KB | **0KB** | 통과 — 순수 WebGL2 라 라이브러리가 없다 | +| CSS | — | 41KB | — | +| 이미지 | — | 107KB | — | +| **폰트** | **200KB** | **530KB** | **초과** | +| 총 전송량 | 2MB | **704KB** | 통과 | +| 리소스 수 | — | 27 | — | +| LCP · CLS | 3.5초 / 0.1 | **미측정** | 배포 후 Lighthouse | + +**폰트 예산 초과를 그대로 적는다.** 원인은 이펙트가 아니라 한글 웹폰트 자체다. +Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고 브라우저는 실제로 쓰인 +범위만 받는데, 한국어 본문은 받는 범위가 많다. 비교 실측: 기존 `cheongon-care` 는 +**650KB / 25개 파일**(jsdelivr 619KB + gstatic 31KB)로 더 크다. 이건 이번 판이 +악화시킨 값이 아니라 **저장소 공통 baseline** 이다. + +여기서 할 수 있는 선택은 셋이었다. +(a) 한글 폰트를 빼고 시스템 기본으로 — `antipatterns.md` 가 금지한다. +(b) 서브셋을 직접 만들어 호스팅 — 페이지에 실제로 쓰인 글자만 담으면 수십 KB로 떨어진다. +(c) 초과를 기록하고 넘어간다. + +이번 판은 (c)다. 세 페이지가 정적 HTML 이고 CDN 캐시를 공유하므로 두 번째 페이지부터는 +0KB 이며, (b)는 쇼케이스마다 빌드 단계를 새로 붙여야 해서 이 작업의 범위를 넘는다. +**다만 `tokens.md` §5 의 폰트 예산 200KB 는 한글 프로젝트에서 달성 불가능한 값이다** — +한글 전용 예산선을 따로 두거나 (b)를 파이프라인에 넣는 것이 다음 판의 일이다. + +### 기존 16종에 남아 있는 것 (이번에 고치지 않았다) + +`showcase-audit.mjs` 를 19종 전체에 돌린 결과 신규 3종은 12/12 통과, **기존 16종에서 70건**이 +나왔다. 이번 작업의 범위(신규 3종)를 넘으므로 고치지 않고 목록만 남긴다. + +- **대비 미달** — `overtone-sound` 의 `.work-meta` 13px 모노 3.57:1, `synapse-bci` 의 11~12px 캡션 3.84~3.98:1, `thursday-flowers` 의 `.visit-mark` **1.33:1**(크림 #DDD7CA 글자가 크림 #F8F6F0 배경 위에 있다. 장식용 세로쓰기 표시지만 사실상 안 보인다) +- **히어로 헤드라인 줄 수 초과** — `pulsegate` 320px 에서 4줄 / 1440px 에서 3줄, `synapse-bci` 3줄 (카운트 규칙 상한 2줄) + +전부 `node tools/showcase-audit.mjs ` 로 재현된다. diff --git a/apps/site/public/work/rfs-cfd/assets/sph-sideview.webp b/apps/site/public/work/rfs-cfd/assets/sph-sideview.webp new file mode 100644 index 0000000..97bdb45 Binary files /dev/null and b/apps/site/public/work/rfs-cfd/assets/sph-sideview.webp differ diff --git a/apps/site/public/work/rfs-cfd/assets/sph-topview.webp b/apps/site/public/work/rfs-cfd/assets/sph-topview.webp new file mode 100644 index 0000000..881f451 Binary files /dev/null and b/apps/site/public/work/rfs-cfd/assets/sph-topview.webp differ diff --git a/apps/site/public/work/rfs-cfd/index.html b/apps/site/public/work/rfs-cfd/index.html new file mode 100644 index 0000000..ad9bd1b --- /dev/null +++ b/apps/site/public/work/rfs-cfd/index.html @@ -0,0 +1,545 @@ + + + + + + RFS — 실시간 유체 시뮬레이터 · 데이터센터 열관리 GPU 엔진 + + + + + + + + + + + + + + + + + + +
+
+ + + RFS + + + + +

v0.13.0

+
+
+ +
+ +
+
+ + + + RFS 실물 파티클 뷰 — 서버랙을 통과한 공기가 데워져 위로 오르는 측면 단면 + + + + + + + + + + + +
+ +
+

+ 메시를 다시
+ 만들지 않는다 +

+

+ 설비를 옮기면 유동이 그 자리에서 바뀐다. 데이터센터 열관리를 위한 실시간 GPU 유체 + 시뮬레이터. +

+ +
+
+
+ + 70% +
+ +
+
+
+ + 65% +
+ +
+
+ +

+ 위 화면은 브라우저에서 도는 2D 축소판이다. 슬라이더를 움직이거나 화면을 + 문지르면 유동이 즉시 반응한다. 실물 RFS 는 Unity 6.4 위에서 3D δ-SPH 를 돌린다 — + 파티클 100,000개, 60 FPS. +

+
+
+ + +
+
+
+

+ 해가 나올 즈음에는
이미 다른 결정을 내린 뒤다 +

+
+
+

+ 범용 CFD 도구는 시뮬레이션 한 건에 수 시간에서 수 일이 걸린다. 랙 한 대를 옮길 때마다 + 메시를 다시 만들고 처음부터 다시 풀어야 하므로, 운영 중에 답을 얻는 용도로는 쓰이지 + 않는다. 그래서 현장의 배치 변경은 대개 계산이 아니라 경험으로 결정된다. +

+

+ RFS 는 정확도의 상한을 낮추는 대신 회신 시간을 바꾼 도구다. + 발열량을 올리고, 급기 밸브를 조이고, 랙을 한 칸 옮기는 조작이 같은 화면 안에서 + 유동에 반영된다. 검증할 것이 "결과 파일"이 아니라 "지금 보고 있는 장면"이 된다. +

+
+
+
범용 CFD 도구
+
배치를 바꾸면 메시 재생성 → 재계산. 한 건에 수 시간~수 일
+
+
+
RFS
+
배치·발열·제어 변경이 같은 세션 안에서 유동에 반영된다
+
+
+
+
+
+ + +
+
+

유체 엔진

+

GPU Compute Shader 위의 δ-SPH

+

+ 격자를 깔지 않는다. 공기를 입자로 놓고 이웃 입자끼리 밀도·압력·열을 주고받게 한다. + 벽이 움직여도 격자를 다시 만들 일이 없다 — 실시간이 가능한 이유가 여기에 있다. +

+ +
    +
  • +

    보간 커널

    +

    Wendland C2

    +

    Poly6·Spiky 혼용을 걷어내고 단일 경로로 통일했다

    +
  • +
  • +

    상태방정식

    +

    Tait

    +

    약압축성 가정. 압력 반복 없이 밀도에서 압력을 직접 얻는다

    +
  • +
  • +

    벽면 경계

    +

    Adami Ghost Particle

    +

    엣지·코너까지 덮는다. 경계에서 입자가 새던 결함을 해소한 방식

    +
  • +
  • +

    시간 적분

    +

    CFL 적응 서브스텝

    +

    유속이 빨라지면 스텝을 자동으로 잘게 쪼갠다

    +
  • +
  • +

    배속

    +

    0.1× — 10×

    +

    배속을 바꿔도 같은 물리 결과가 나오도록 시간 불변을 보장한다

    +
  • +
  • +

    열·습도

    +

    입자 간 전도 + 부력

    +

    습공기 밀도 보정을 포함한다

    +
  • +
  • +

    주기 경계

    +

    PBC · 최소 이미지 규약

    +

    셀 래핑으로 무한 반복 공간을 다룬다

    +
  • +
  • +

    이웃 탐색

    +

    공간 해시 그리드

    +

    SpatialHash 커널 1개가 전담한다

    +
  • +
+ +
+
+

01

+

GPU 커널

+

53+

+

Compute Shader 10개 · HLSL 67개로 분할

+
+
+

02

+

파티클

+

100,000

+

최대 512,000. 60 FPS 안정 구간 기준

+
+
+

03

+

파티클 1개의 크기

+

20B

+

AoS 를 SoA 6개 버퍼로 갈라 얻은 값

+
+
+

04

+

테스트

+

2,000+

+

499개 파일 · EditMode · PlayMode · Performance

+
+
+
+
+ + +
+
+

전산실에 있는 물건을 그대로 안다

+

+ 유동만 푸는 것으로는 답이 안 나온다. 열을 만드는 것과 식히는 것이 각자의 제어 논리를 + 가지고 장 안에 들어가 있어야 배치 변경이 의미를 갖는다. +

+ +
+
+

항온항습기 FWU

+

+ PID 자동 제어로 목표 온도를 좇는다. 열교환은 ε-NTU 로 계산하고, + 프리쿨링·쿨링 밸브와 댐퍼 모드를 각각 조작할 수 있다. +

+
+
+

서버랙

+

+ CFM 기반으로 통과 공기에 열을 싣는다. 동적 전력 프로파일 5종을 갖고 있고, + 장애 상황을 만들어 그때 열이 어디로 가는지 볼 수 있다. +

+
+
+

팬 · 댐퍼 · 냉각코일

+

+ 듀얼 원뿔 존 모델로 GPU 파티클 물리에 직접 개입한다. + 후처리로 그린 화살표가 아니라 실제로 입자를 민다. +

+
+
+

온도계

+

+ 물리 기반 센서 응답 특성을 갖는다. 값이 즉시 튀지 않고 실제 계기처럼 따라온다. + Cool Zone 과 Hot Zone 을 자동으로 분류한다. +

+
+
+

급기구 · 배기구

+

+ CFD inlet / outlet 패턴을 따른다. 경계에 걸린 파티클을 자동으로 재배치해 + 유입·유출이 끊기지 않게 한다. +

+
+
+
+
+ + +
+
+

보이는 방식

+

읽는 방법을 네 가지로 나눈다

+ +
+
    +
  1. +

    파티클

    +

    온도로 색을 매핑하고 발광과 LOD 를 건다. 공기가 어디서 데워지는지 그대로 보인다.

    +
  2. +
  3. +

    트레일

    +

    Ring Buffer 로 궤적을 남긴다. 한 순간이 아니라 지나온 경로를 본다.

    +
  4. +
  5. +

    히트맵 슬라이스

    +

    + 온도·속도·압력·밀도·습도 다섯 물리량의 단면을 5패스 GPU 파이프라인으로 그린다. + X · Y · + Z 키로 축을 바꾼다. +

    +
  6. +
  7. +

    벡터 필드

    +

    화살표 글리프와 스트림라인을 GPU 인스턴싱으로 띄운다. 방향과 세기를 함께 읽는다.

    +
  8. +
+ +
+
+ RFS 상면 뷰 — 랙 상부에서 데워진 입자가 초록으로 퍼지고 주변 저온 입자는 청록으로 남는다 +
상면 · 랙 상부의 열기둥
+
+
+ RFS 측면 뷰 — 랙을 통과하며 데워진 공기가 오른쪽으로 밀려 나가는 단면 +
측면 · 랙을 통과한 공기
+
+

+ 두 장 모두 RFS 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 — + 이 페이지 첫 화면의 색도 같은 규칙을 쓴다. +

+
+
+
+
+ + +
+
+
+

시뮬레이터가 아니라 작업 환경이다

+

+ 배치를 바꿔 보려면 배치를 바꿀 수 있어야 한다. RFS Editor 는 Unity UI Toolkit 으로 만든 + 런타임 편집 환경이고, 시뮬레이션이 도는 채로 물건을 옮긴다. +

+
+
+
+
+
오브젝트 편집
+
이동·회전·스케일 핸들, 다중 선택, Undo / Redo, 드래그 앤 드롭 배치
+
+
+
속성 패널
+
실시간 속성 편집. 관제 시스템의 XD 태그를 여기서 바인딩한다
+
+
+
리플레이
+
+ CSV 또는 XD API 를 데이터소스로 삼아 시계열을 재생하고, 실측 대비 정확도 리포트를 + 만든다 +
+
+
+
AI Advisor
+
+ 26개 규칙의 Rule Engine 과 LLM 프로바이더 4종(Anthropic · OpenAI · Gemini · + 로컬 Ollama) 연동 +
+
+
+
모니터링
+
PUE · COP · 전력 · CO₂ 대시보드, MetricsHUD 7섹션 26메트릭
+
+
+
프로젝트
+
생성·저장·불러오기, 다중 씬, glTF / GLB 3D 모델 임포트
+
+
+ +
+

손에 익는 키

+
    +
  • Space 일시정지 · 재개
  • +
  • H 히트맵
  • +
  • XYZ 슬라이스 축
  • +
  • F3 성능 통계
  • +
  • Tab UI 접기
  • +
+
+
+
+
+ + +
+
+

다섯 켜

+
    +
  1. +

    L1

    +

    애플리케이션

    +

    RFS Editor · Simulation3D · AI Advisor · 리플레이

    +
  2. +
  3. +

    L2

    +

    파이프라인 · 컨트롤러

    +

    SimulationPipeline → 스테이지 10개 → 컨트롤러 26개

    +
  4. +
  5. +

    L3

    +

    GPU 컴퓨트

    +

    + SPHCore 8 · DomainExt 14 · SpatialHash 1 · TempGrid 8 · ObjectGrid 7 · + Integration 5 · Scatter 4 · Trail 2 · GlobalSDF 3 · Coil 1 +

    +
  6. +
  7. +

    L4

    +

    데이터 버퍼

    +

    SoA 버퍼 6개 · ParticleData 20B · 충돌 메시 데이터

    +
  8. +
  9. +

    L5

    +

    외부 통신

    +

    Socket.IO v2 (RFS-XD 관제 연동) · REST API · CSV 내보내기

    +
  10. +
+
+
+ + +
+
+
+

돌려 보려면 무엇이 필요한가

+

+ DirectCompute 를 쓰므로 NVIDIA GPU 를 권장한다. 아래는 개발 저장소가 명시한 값 + 그대로다. +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RFS 시스템 요구사항 — 최소 사양과 권장 사양
항목최소권장
운영체제Windows 10 64bitWindows 11
GPUGTX 1660 · VRAM 6GBRTX 3060 이상 · VRAM 8GB 이상
메모리16 GB16 GB 이상
엔진Unity 6.4 (6000.4.5f1) · URP 17.2
+ +
+

+ 도입 검토·기술 문의는 개발사인 트웬티온스로 연락하면 된다. + 제품은 독점 소프트웨어이고, 평가판 제공 여부는 협의 사항이다. +

+ + RFS 도입 문의 + +

hello@twentyoz.kr · 070-4353-1190

+
+
+
+
+
+ + + + + + diff --git a/apps/site/public/work/rfs-cfd/og.png b/apps/site/public/work/rfs-cfd/og.png new file mode 100644 index 0000000..564a868 Binary files /dev/null and b/apps/site/public/work/rfs-cfd/og.png differ diff --git a/apps/site/public/work/rfs-cfd/scripts/thermal-field.js b/apps/site/public/work/rfs-cfd/scripts/thermal-field.js new file mode 100644 index 0000000..6138126 --- /dev/null +++ b/apps/site/public/work/rfs-cfd/scripts/thermal-field.js @@ -0,0 +1,686 @@ +/* ========================================================================== + RFS 히어로 — 2D 열유동 데모 (WebGL2) + + 무엇인가 / 무엇이 아닌가 + ------------------------ + 이것은 브라우저에서 도는 **2D 축소판**이다. 실제 RFS 는 Unity 6.4 위에서 + GPU Compute Shader 로 3D δ-SPH 를 돌린다(파티클 100,000개 / 60 FPS). + 여기서 파는 것은 성능 수치가 아니라 "설비를 건드리면 유동이 즉시 바뀐다" 는 + 제품의 핵심 주장 하나이고, 그 주장은 2D 로도 정직하게 보일 수 있다. + 페이지 캡션에 이 사실을 적어 두었다 — 데모가 제품인 척하면 그게 슬롭이다. + + 푸는 것 + ------- + 비압축 Navier–Stokes 를 격자에서 푼다. Stam 의 Stable Fluids 절차다. + 1) advect — semi-Lagrangian 이류 (속도·온도) + 2) buoyancy — 온도 편차에 비례하는 부력 + 설비 소스 주입 + 3) divergence + 4) pressure — Jacobi 반복 + 5) project — 압력 기울기를 빼서 발산을 0으로 + 그 위에 파티클을 띄워 속도장을 따라 흐르게 한다. 제품이 입자법(SPH)이므로 + 화면에 보이는 것도 입자여야 한다 — 격자만 칠하면 다른 제품의 그림이 된다. + + 설비 두 개가 실제로 장에 개입한다 + · FWU(항온항습기) — 왼쪽 아래에서 찬 공기를 수평으로 분사 + · 서버랙 — 가운데 블록. 통과하는 공기에 열을 싣고 위로 띄운다 + ========================================================================== */ + +(() => { + "use strict"; + + const canvas = document.getElementById("thermalField"); + if (!canvas) return; + + const shell = canvas.closest("[data-field-shell]"); + const setState = (s) => shell && shell.setAttribute("data-field-state", s); + + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + if (reduced.matches) { + setState("static"); + return; + } + + /** @type {WebGL2RenderingContext | null} */ + const gl = canvas.getContext("webgl2", { + alpha: true, + antialias: false, + depth: false, + stencil: false, + powerPreference: "low-power", + preserveDrawingBuffer: false, + }); + + if (!gl) { + setState("static"); + return; + } + + const floatLinear = gl.getExtension("OES_texture_float_linear"); + if (!gl.getExtension("EXT_color_buffer_float")) { + setState("static"); + return; + } + + /* ── 해상도 예산 ───────────────────────────────────────────────── + 좁은 화면은 대개 배터리로 돌고 열이 더 난다. 격자와 파티클을 함께 줄인다. + 하드웨어 코어 수만 보면 요즘 폰이 전부 고사양으로 잡히므로 폭을 같이 본다. */ + const narrow = window.innerWidth < 760; + const lowCore = (navigator.hardwareConcurrency || 8) <= 4; + const lite = narrow || lowCore; + + const SIM_W = lite ? 128 : 224; + const SIM_H = lite ? 72 : 126; + const PARTICLES = lite ? 9000 : 26000; + const JACOBI = lite ? 14 : 22; + const DPR_CAP = lite ? 1.25 : 1.5; + + /* ── 셰이더 유틸 ──────────────────────────────────────────────── */ + const VERT_QUAD = `#version 300 es + in vec2 aPos; + out vec2 vUv; + void main() { + vUv = aPos * 0.5 + 0.5; + gl_Position = vec4(aPos, 0.0, 1.0); + }`; + + function compile(type, src) { + const sh = gl.createShader(type); + gl.shaderSource(sh, src); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + throw new Error(gl.getShaderInfoLog(sh) || "shader compile failed"); + } + return sh; + } + + function program(vsSrc, fsSrc) { + const p = gl.createProgram(); + gl.attachShader(p, compile(gl.VERTEX_SHADER, vsSrc)); + gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { + throw new Error(gl.getProgramInfoLog(p) || "program link failed"); + } + const uniforms = {}; + const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); + for (let i = 0; i < n; i++) { + const name = gl.getActiveUniform(p, i).name.replace(/\[0\]$/, ""); + uniforms[name] = gl.getUniformLocation(p, name); + } + return { p, u: uniforms }; + } + + /* ── 설비 배치는 셰이더와 JS 가 같은 값을 봐야 한다 ── */ + const RACK = { x0: 0.44, x1: 0.56, y0: 0.12, y1: 0.62 }; + const FWU = { x: 0.055, y0: 0.1, y1: 0.34 }; + + const GLSL_LAYOUT = ` + const vec4 RACK = vec4(${RACK.x0}, ${RACK.x1}, ${RACK.y0}, ${RACK.y1}); + const vec3 FWU = vec3(${FWU.x}, ${FWU.y0}, ${FWU.y1}); + bool inRack(vec2 p) { + return p.x > RACK.x && p.x < RACK.y && p.y > RACK.z && p.y < RACK.w; + }`; + + /* 1) 이류 — 역추적 후 쌍선형 보간 */ + const FS_ADVECT = `#version 300 es + precision highp float; + in vec2 vUv; + out vec4 outColor; + uniform sampler2D uVel; + uniform sampler2D uSrc; + uniform vec2 uTexel; + uniform float uDt; + uniform float uDissipate; + ${GLSL_LAYOUT} + void main() { + vec2 v = texture(uVel, vUv).xy; + vec2 back = vUv - uDt * v * uTexel; + back = clamp(back, uTexel * 0.5, 1.0 - uTexel * 0.5); + outColor = texture(uSrc, back) * uDissipate; + }`; + + /* 2) 힘 — 부력 + 설비 소스. 랙 안은 발열, FWU 는 찬 공기를 분사. + uDt 는 **초**다. 속도의 단위는 초당 격자 셀이다. 둘을 프레임 단위와 섞으면 + 화면 주사율에 따라 물리가 달라진다 — 한 번 그렇게 짰다가 파티클이 0.2초 만에 + 사라졌다. 단위를 초로 못박은 것이 그 수리다. */ + const FS_FORCE = `#version 300 es + precision highp float; + in vec2 vUv; + layout(location = 0) out vec4 outVel; + uniform sampler2D uVel; + uniform sampler2D uTemp; + uniform float uDt; + uniform float uTime; + uniform float uLoad; // 랙 부하 0..1 + uniform float uSupply; // FWU 급기 0..1 + uniform vec2 uPointer; + uniform float uPointerOn; + ${GLSL_LAYOUT} + void main() { + vec2 v = texture(uVel, vUv).xy; + float t = texture(uTemp, vUv).r; + + // 부력 — 주변보다 뜨거우면 뜬다 + v.y += uDt * t * 190.0 * (0.7 + 0.3 * uLoad); + + // FWU 급기: 왼쪽 벽에서 수평 제트 + float band = smoothstep(FWU.y - 0.015, FWU.y + 0.03, vUv.y) + * (1.0 - smoothstep(FWU.z - 0.03, FWU.z + 0.015, vUv.y)); + float jet = band * (1.0 - smoothstep(FWU.x, FWU.x + 0.2, vUv.x)); + v.x += uDt * jet * 300.0 * uSupply; + v.y += uDt * jet * sin(uTime * 0.8 + vUv.y * 20.0) * 26.0; + + // 서버랙: 전면(왼쪽)에서 빨아들여 배면(오른쪽)으로 밀어낸다. + // 위로 뿜게 두면 냉기와 열기가 랙을 사이에 두고 갈라지지 않아 + // '핫 아일 / 콜드 아일' 이라는 이 업계의 기본 그림이 화면에 안 나온다. + if (inRack(vUv)) { + v.x += uDt * 240.0 * uLoad; + v.y += uDt * 45.0 * uLoad; + } + + // 포인터 교란 — 손으로 유동을 흔들 수 있어야 '실시간'이 증명된다 + if (uPointerOn > 0.5) { + vec2 d = vUv - uPointer; + float f = exp(-dot(d, d) * 520.0); + v += uDt * normalize(d + 1e-5) * f * 900.0; + } + + v *= 1.0 - min(0.45, uDt * 0.5); + outVel = vec4(clamp(v, -260.0, 260.0), 0.0, 1.0); + }`; + + /* 온도 소스 — 랙은 데우고 FWU 는 식힌다 */ + const FS_TEMP_SRC = `#version 300 es + precision highp float; + in vec2 vUv; + out vec4 outColor; + uniform sampler2D uTemp; + uniform float uDt; + uniform float uLoad; + uniform float uSupply; + ${GLSL_LAYOUT} + void main() { + float t = texture(uTemp, vUv).r; + if (inRack(vUv)) { + t += uDt * 2.4 * uLoad; + } + float band = smoothstep(FWU.y - 0.015, FWU.y + 0.03, vUv.y) + * (1.0 - smoothstep(FWU.z - 0.03, FWU.z + 0.015, vUv.y)); + float jet = band * (1.0 - smoothstep(FWU.x, FWU.x + 0.16, vUv.x)); + t -= uDt * jet * 3.0 * uSupply; + // 천장 배기 + t -= uDt * smoothstep(0.86, 1.0, vUv.y) * 0.9; + outColor = vec4(clamp(t, -0.3, 1.1), 0.0, 0.0, 1.0); + }`; + + const FS_DIVERGENCE = `#version 300 es + precision highp float; + in vec2 vUv; + out vec4 outColor; + uniform sampler2D uVel; + uniform vec2 uTexel; + void main() { + float l = texture(uVel, vUv - vec2(uTexel.x, 0.0)).x; + float r = texture(uVel, vUv + vec2(uTexel.x, 0.0)).x; + float b = texture(uVel, vUv - vec2(0.0, uTexel.y)).y; + float t = texture(uVel, vUv + vec2(0.0, uTexel.y)).y; + outColor = vec4(0.5 * (r - l + t - b), 0.0, 0.0, 1.0); + }`; + + const FS_JACOBI = `#version 300 es + precision highp float; + in vec2 vUv; + out vec4 outColor; + uniform sampler2D uPressure; + uniform sampler2D uDivergence; + uniform vec2 uTexel; + void main() { + float l = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).r; + float r = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).r; + float b = texture(uPressure, vUv - vec2(0.0, uTexel.y)).r; + float t = texture(uPressure, vUv + vec2(0.0, uTexel.y)).r; + float d = texture(uDivergence, vUv).r; + outColor = vec4((l + r + b + t - d) * 0.25, 0.0, 0.0, 1.0); + }`; + + const FS_PROJECT = `#version 300 es + precision highp float; + in vec2 vUv; + out vec4 outColor; + uniform sampler2D uPressure; + uniform sampler2D uVel; + uniform vec2 uTexel; + ${GLSL_LAYOUT} + void main() { + float l = texture(uPressure, vUv - vec2(uTexel.x, 0.0)).r; + float r = texture(uPressure, vUv + vec2(uTexel.x, 0.0)).r; + float b = texture(uPressure, vUv - vec2(0.0, uTexel.y)).r; + float t = texture(uPressure, vUv + vec2(0.0, uTexel.y)).r; + vec2 v = texture(uVel, vUv).xy - vec2(r - l, t - b); + + // 벽면 — 미끄럼 없음 + if (vUv.x < uTexel.x * 1.5 || vUv.x > 1.0 - uTexel.x * 1.5) v.x = 0.0; + if (vUv.y < uTexel.y * 1.5) v.y = max(v.y, 0.0); + if (vUv.y > 1.0 - uTexel.y * 1.5) v.y = min(v.y, 0.0); + outColor = vec4(v, 0.0, 1.0); + }`; + + /* 파티클 — 위치 텍스처를 속도장으로 갱신. 벗어나면 급기구로 되돌린다 */ + const FS_PARTICLE_STEP = `#version 300 es + precision highp float; + in vec2 vUv; + out vec4 outColor; + uniform sampler2D uPos; + uniform sampler2D uVel; + uniform vec2 uTexel; + uniform float uDt; + uniform float uTime; + ${GLSL_LAYOUT} + float hash(vec2 p) { + return fract(sin(dot(p, vec2(41.31, 289.7))) * 43758.5453); + } + void main() { + vec4 s = texture(uPos, vUv); + vec2 p = s.xy; + float age = s.z; + + // 속도는 초당 격자 셀이므로 텍셀 크기를 곱해 정규화 좌표로 옮긴다 + vec2 v = texture(uVel, p).xy; + p += v * uDt * uTexel; + age -= uDt * 0.17; // 수명 약 4~9초 + + bool gone = age <= 0.0 || p.x < 0.004 || p.x > 0.996 || p.y < 0.004 || p.y > 0.996; + if (gone) { + float a = hash(vUv + uTime * 0.37); + float b = hash(vUv.yx + uTime * 0.71); + float c = hash(vUv * 3.1 + uTime * 0.19); + // 전부 급기구에서만 다시 나오면 같은 경로를 따라가 실 한 가닥이 된다. + // 방이 아니라 소용돌이 그림이 되는 원인이 이것이었다. + // 절반만 급기구에서 내보내고 나머지는 방 안 아무 데서나 되살린다. + if (c < 0.45) { + p = vec2(FWU.x + a * 0.035, mix(FWU.y, FWU.z, b)); + } else { + p = vec2(0.03 + a * 0.94, 0.03 + b * 0.94); + } + age = 0.35 + c * 1.35; + } + outColor = vec4(p, age, 1.0); + }`; + + const VS_PARTICLE_DRAW = `#version 300 es + precision highp float; + uniform sampler2D uPos; + uniform sampler2D uTemp; + uniform vec2 uPosSize; + uniform float uPointScale; + out float vTemp; + out float vAge; + void main() { + int id = gl_VertexID; + int w = int(uPosSize.x); + vec2 uv = (vec2(float(id % w), float(id / w)) + 0.5) / uPosSize; + vec4 s = texture(uPos, uv); + vTemp = texture(uTemp, s.xy).r; + vAge = clamp(s.z, 0.0, 1.0); + gl_Position = vec4(s.xy * 2.0 - 1.0, 0.0, 1.0); + gl_PointSize = uPointScale * (0.85 + 0.5 * vTemp); + }`; + + const FS_PARTICLE_DRAW = `#version 300 es + precision highp float; + in float vTemp; + in float vAge; + out vec4 outColor; + uniform vec3 uCold; + uniform vec3 uMid; + uniform vec3 uWarm; + uniform vec3 uHot; + void main() { + vec2 d = gl_PointCoord - 0.5; + float r = dot(d, d); + if (r > 0.25) discard; + float soft = smoothstep(0.25, 0.02, r); + + float t = clamp(vTemp * 1.15 + 0.16, 0.0, 1.0); + vec3 c = t < 0.34 + ? mix(uCold, uMid, t / 0.34) + : (t < 0.67 ? mix(uMid, uWarm, (t - 0.34) / 0.33) + : mix(uWarm, uHot, (t - 0.67) / 0.33)); + + float a = soft * smoothstep(0.0, 0.3, vAge) * (0.5 + 0.45 * t); + outColor = vec4(c * a, a); + }`; + + /* ── 자원 ─────────────────────────────────────────────────────── */ + let progs, quadVao, emptyVao, fbo; + const tex = {}; + + function makeTex(w, h, internal, format, type, data = null) { + const t = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, t); + gl.texImage2D(gl.TEXTURE_2D, 0, internal, w, h, 0, format, type, data); + const filter = floatLinear ? gl.LINEAR : gl.NEAREST; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return { t, w, h }; + } + + function pair(w, h, internal, format, type, data) { + return { read: makeTex(w, h, internal, format, type, data), write: makeTex(w, h, internal, format, type, data) }; + } + + const swap = (p) => { + const tmp = p.read; + p.read = p.write; + p.write = tmp; + }; + + function bindTarget(target) { + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target.t, 0); + gl.viewport(0, 0, target.w, target.h); + } + + function useTex(unit, texture, loc) { + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, texture.t); + gl.uniform1i(loc, unit); + } + + const drawQuad = () => { + gl.bindVertexArray(quadVao); + gl.drawArrays(gl.TRIANGLES, 0, 3); + }; + + const PART_W = 256; + const PART_H = Math.ceil(PARTICLES / PART_W); + const PART_N = PART_W * PART_H; + + function init() { + progs = { + advect: program(VERT_QUAD, FS_ADVECT), + force: program(VERT_QUAD, FS_FORCE), + tempSrc: program(VERT_QUAD, FS_TEMP_SRC), + diverge: program(VERT_QUAD, FS_DIVERGENCE), + jacobi: program(VERT_QUAD, FS_JACOBI), + project: program(VERT_QUAD, FS_PROJECT), + pstep: program(VERT_QUAD, FS_PARTICLE_STEP), + pdraw: program(VS_PARTICLE_DRAW, FS_PARTICLE_DRAW), + }; + + // 화면을 덮는 삼각형 하나. 사각형 두 장보다 싸다 + quadVao = gl.createVertexArray(); + gl.bindVertexArray(quadVao); + const buf = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + + emptyVao = gl.createVertexArray(); + fbo = gl.createFramebuffer(); + + tex.vel = pair(SIM_W, SIM_H, gl.RG16F, gl.RG, gl.HALF_FLOAT); + tex.temp = pair(SIM_W, SIM_H, gl.R16F, gl.RED, gl.HALF_FLOAT); + tex.pressure = pair(SIM_W, SIM_H, gl.R16F, gl.RED, gl.HALF_FLOAT); + tex.divergence = makeTex(SIM_W, SIM_H, gl.R16F, gl.RED, gl.HALF_FLOAT); + + // 처음부터 방 전체에 흩어 둔다. 급기구에만 몰아 두면 첫 몇 초 동안 + // 화면이 비어 있고, 그 사이에 사람들은 스크롤해 버린다 + const seed = new Float32Array(PART_N * 4); + for (let i = 0; i < PART_N; i++) { + const atInlet = i % 5 === 0; + seed[i * 4] = atInlet ? FWU.x + Math.random() * 0.04 : 0.02 + Math.random() * 0.96; + seed[i * 4 + 1] = atInlet + ? FWU.y0 + Math.random() * (FWU.y1 - FWU.y0) + : 0.02 + Math.random() * 0.96; + seed[i * 4 + 2] = 0.25 + Math.random() * 1.4; + seed[i * 4 + 3] = 1; + } + tex.part = pair(PART_W, PART_H, gl.RGBA32F, gl.RGBA, gl.FLOAT, seed); + + // 파티클 위치 텍스처는 정확히 읽혀야 한다 — 보간하면 이웃 입자와 섞인다 + for (const s of [tex.part.read, tex.part.write]) { + gl.bindTexture(gl.TEXTURE_2D, s.t); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + } + } + + /* ── 조작 입력 ────────────────────────────────────────────────── */ + const controls = { + load: Number(document.getElementById("ctlLoad")?.value ?? 70) / 100, + supply: Number(document.getElementById("ctlSupply")?.value ?? 65) / 100, + }; + + function bindSlider(id, key, outId, fmt) { + const el = document.getElementById(id); + const out = document.getElementById(outId); + if (!el) return; + const sync = () => { + controls[key] = Number(el.value) / 100; + if (out) out.textContent = fmt(Number(el.value)); + }; + el.addEventListener("input", sync); + sync(); + } + + bindSlider("ctlLoad", "load", "outLoad", (v) => `${v}%`); + bindSlider("ctlSupply", "supply", "outSupply", (v) => `${v}%`); + + const pointer = { x: 0, y: 0, on: 0 }; + const onMove = (e) => { + const r = canvas.getBoundingClientRect(); + const p = e.touches ? e.touches[0] : e; + pointer.x = (p.clientX - r.left) / r.width; + pointer.y = 1 - (p.clientY - r.top) / r.height; + pointer.on = 1; + }; + canvas.addEventListener("pointermove", onMove, { passive: true }); + canvas.addEventListener("pointerleave", () => (pointer.on = 0), { passive: true }); + + /* ── 렌더 루프 ────────────────────────────────────────────────── */ + let running = false; + let raf = 0; + let last = 0; + const texel = [1 / SIM_W, 1 / SIM_H]; + + const cssVar = (name, fallback) => { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; + }; + const hexToRgb = (hex) => { + const h = hex.replace("#", ""); + const n = parseInt(h.length === 3 ? h.replace(/./g, "$&$&") : h, 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; + }; + const ramp = { + cold: hexToRgb(cssVar("--temp-cold", "#2f7d9e")), + mid: hexToRgb(cssVar("--temp-mid", "#39a894")), + warm: hexToRgb(cssVar("--temp-warm", "#8cc152")), + hot: hexToRgb(cssVar("--temp-hot", "#d7d64a")), + }; + + function resize() { + const r = canvas.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP); + const w = Math.max(1, Math.round(r.width * dpr)); + const h = Math.max(1, Math.round(r.height * dpr)); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + } + + function step(dt) { + gl.disable(gl.BLEND); + + // 감쇠는 프레임이 아니라 시간에 걸려야 한다. 60fps 기준값을 dt 로 환산한다 + const decay = (per60) => Math.pow(per60, dt * 60); + + // 1) 속도 이류 + gl.useProgram(progs.advect.p); + gl.uniform2f(progs.advect.u.uTexel, texel[0], texel[1]); + gl.uniform1f(progs.advect.u.uDt, dt); + gl.uniform1f(progs.advect.u.uDissipate, decay(0.999)); + useTex(0, tex.vel.read, progs.advect.u.uVel); + useTex(1, tex.vel.read, progs.advect.u.uSrc); + bindTarget(tex.vel.write); + drawQuad(); + swap(tex.vel); + + // 2) 온도 이류 + gl.useProgram(progs.advect.p); + gl.uniform1f(progs.advect.u.uDissipate, decay(0.9975)); + useTex(0, tex.vel.read, progs.advect.u.uVel); + useTex(1, tex.temp.read, progs.advect.u.uSrc); + bindTarget(tex.temp.write); + drawQuad(); + swap(tex.temp); + + // 3) 온도 소스 + gl.useProgram(progs.tempSrc.p); + gl.uniform1f(progs.tempSrc.u.uDt, dt); + gl.uniform1f(progs.tempSrc.u.uLoad, controls.load); + gl.uniform1f(progs.tempSrc.u.uSupply, controls.supply); + useTex(0, tex.temp.read, progs.tempSrc.u.uTemp); + bindTarget(tex.temp.write); + drawQuad(); + swap(tex.temp); + + // 4) 부력 + 설비 힘 + gl.useProgram(progs.force.p); + gl.uniform1f(progs.force.u.uDt, dt); + gl.uniform1f(progs.force.u.uTime, last * 0.001); + gl.uniform1f(progs.force.u.uLoad, controls.load); + gl.uniform1f(progs.force.u.uSupply, controls.supply); + gl.uniform2f(progs.force.u.uPointer, pointer.x, pointer.y); + gl.uniform1f(progs.force.u.uPointerOn, pointer.on); + useTex(0, tex.vel.read, progs.force.u.uVel); + useTex(1, tex.temp.read, progs.force.u.uTemp); + bindTarget(tex.vel.write); + drawQuad(); + swap(tex.vel); + + // 5) 발산 + gl.useProgram(progs.diverge.p); + gl.uniform2f(progs.diverge.u.uTexel, texel[0], texel[1]); + useTex(0, tex.vel.read, progs.diverge.u.uVel); + bindTarget(tex.divergence); + drawQuad(); + + // 6) 압력 — Jacobi + gl.useProgram(progs.jacobi.p); + gl.uniform2f(progs.jacobi.u.uTexel, texel[0], texel[1]); + useTex(1, tex.divergence, progs.jacobi.u.uDivergence); + for (let i = 0; i < JACOBI; i++) { + useTex(0, tex.pressure.read, progs.jacobi.u.uPressure); + bindTarget(tex.pressure.write); + drawQuad(); + swap(tex.pressure); + } + + // 7) 투영 + gl.useProgram(progs.project.p); + gl.uniform2f(progs.project.u.uTexel, texel[0], texel[1]); + useTex(0, tex.pressure.read, progs.project.u.uPressure); + useTex(1, tex.vel.read, progs.project.u.uVel); + bindTarget(tex.vel.write); + drawQuad(); + swap(tex.vel); + + // 8) 파티클 전진 + gl.useProgram(progs.pstep.p); + gl.uniform2f(progs.pstep.u.uTexel, texel[0], texel[1]); + gl.uniform1f(progs.pstep.u.uDt, dt); + gl.uniform1f(progs.pstep.u.uTime, last * 0.001); + useTex(0, tex.part.read, progs.pstep.u.uPos); + useTex(1, tex.vel.read, progs.pstep.u.uVel); + bindTarget(tex.part.write); + drawQuad(); + swap(tex.part); + } + + function draw() { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + + gl.useProgram(progs.pdraw.p); + useTex(0, tex.part.read, progs.pdraw.u.uPos); + useTex(1, tex.temp.read, progs.pdraw.u.uTemp); + gl.uniform2f(progs.pdraw.u.uPosSize, PART_W, PART_H); + gl.uniform1f(progs.pdraw.u.uPointScale, Math.max(1.6, canvas.width / 620)); + gl.uniform3fv(progs.pdraw.u.uCold, ramp.cold); + gl.uniform3fv(progs.pdraw.u.uMid, ramp.mid); + gl.uniform3fv(progs.pdraw.u.uWarm, ramp.warm); + gl.uniform3fv(progs.pdraw.u.uHot, ramp.hot); + + gl.bindVertexArray(emptyVao); + gl.drawArrays(gl.POINTS, 0, PARTICLES); + gl.bindVertexArray(null); + } + + function frame(now) { + if (!running) return; + // dt 는 초다. 탭이 뒤로 갔다 오면 한 프레임이 몇 초짜리가 되므로 상한을 둔다 + const dt = Math.min(0.033, Math.max(0.004, (now - last) / 1000 || 0.016)); + last = now; + resize(); + step(dt); + draw(); + raf = requestAnimationFrame(frame); + } + + function start() { + if (running) return; + running = true; + last = performance.now(); + raf = requestAnimationFrame(frame); + setState("live"); + } + + function stop() { + running = false; + cancelAnimationFrame(raf); + } + + try { + init(); + } catch (err) { + setState("static"); + return; + } + + // 화면 밖이면 그리지 않는다. 탭이 숨어도 마찬가지다 + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) (e.isIntersecting ? start : stop)(); + }, + { threshold: 0.02 }, + ); + io.observe(canvas); + + document.addEventListener("visibilitychange", () => { + if (document.hidden) stop(); + else if (canvas.getBoundingClientRect().top < window.innerHeight) start(); + }); + + // 실행 중에 접근성 설정이 바뀌면 즉시 따른다 + reduced.addEventListener("change", (e) => { + if (e.matches) { + stop(); + setState("static"); + io.disconnect(); + } + }); + + new ResizeObserver(resize).observe(canvas); +})(); diff --git a/apps/site/public/work/rfs-cfd/styles/main.css b/apps/site/public/work/rfs-cfd/styles/main.css new file mode 100644 index 0000000..a3431ce --- /dev/null +++ b/apps/site/public/work/rfs-cfd/styles/main.css @@ -0,0 +1,1029 @@ +@import "./tokens.css"; + +/* ========================================================================== + RFS — 메인 스타일시트 + + 구조 원칙 하나: 페이지가 두 체제를 오간다. + .band-dark = 암실. 유동·파티클·수치가 있는 곳 + .band-light = 사양서. 읽고 결정하는 곳 + 반전은 섹션마다 기분대로 뒤집는 것이 아니라 아래 두 클래스가 토큰을 + 재정의하는 **규칙**이다. 그래서 하드 게이트 4 를 통과한다. + ========================================================================== */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + scroll-behavior: smooth; + overflow-x: hidden; + background: var(--dark-surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-height: 100dvh; + font-family: var(--font-body); + font-size: var(--step-0); + line-height: var(--leading-normal); + background: var(--surface); + color: var(--ink); + word-break: keep-all; + overflow-wrap: break-word; + overflow-x: hidden; +} + +img, +picture, +canvas, +svg { + display: block; + max-width: 100%; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +h1, +h2, +h3 { + font-weight: 600; + line-height: var(--leading-tight); + letter-spacing: -0.015em; /* 한글은 -0.02em 이 하한이다 */ + text-wrap: balance; +} + +.mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.wrap { + width: min(100% - 2 * var(--pad-inline), var(--container)); + margin-inline: auto; +} + +/* ── 두 체제 ────────────────────────────────────────────────────── */ +.band { + position: relative; + background: var(--band-surface); + color: var(--band-ink); +} + +.band-light { + --band-surface: var(--surface); + --band-surface-2: var(--surface-2); + --band-surface-3: var(--surface-3); + --band-line: var(--line); + --band-line-strong: var(--line-strong); + --band-ink: var(--ink); + --band-ink-muted: var(--ink-muted); + --band-ink-faint: var(--ink-faint); + --band-accent: var(--accent); + --band-accent-hover: var(--accent-hover); + --band-accent-ink: var(--accent-ink); + --band-accent-wash: var(--accent-wash); +} + +.band-dark { + --band-surface: var(--dark-surface); + --band-surface-2: var(--dark-surface-2); + --band-surface-3: var(--dark-surface-3); + --band-line: var(--dark-line); + --band-line-strong: var(--dark-line-strong); + --band-ink: var(--dark-ink); + --band-ink-muted: var(--dark-ink-muted); + --band-ink-faint: var(--dark-ink-faint); + --band-accent: var(--dark-accent); + --band-accent-hover: var(--dark-accent-hover); + --band-accent-ink: var(--dark-accent-ink); + --band-accent-wash: var(--dark-accent-wash); +} + +.band a:not(.btn):hover { + color: var(--band-accent); +} + +/* ── 접근성 ─────────────────────────────────────────────────────── */ +.skip-link { + position: fixed; + top: var(--space-3); + left: var(--space-3); + z-index: 200; + padding: var(--space-2) var(--space-3); + background: var(--ink); + color: var(--surface); + transform: translateY(-250%); + transition: transform var(--dur-quick) var(--ease); +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +:where(a, button, input, [tabindex]):focus-visible { + outline: 2px solid var(--band-accent, var(--accent)); + outline-offset: 3px; +} + +/* ── 헤더 ───────────────────────────────────────────────────────── */ +.site-head { + position: sticky; + top: 0; + z-index: 100; + /* 반투명으로 두면 밝은 밴드 위를 지날 때 배경이 섞여 헤더 글자가 4.48:1 로 떨어진다. + 페이지가 라이트/다크를 오가는 구조라 유리 헤더는 이 페이지에서 성립하지 않는다 */ + background: var(--dark-surface); + border-bottom: 1px solid var(--dark-line); + color: var(--dark-ink); +} + +.head-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 56px; + min-width: 0; +} + +.wordmark { + display: inline-flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + color: var(--dark-ink); +} + +.wordmark-mark { + display: block; + width: 28px; + color: var(--dark-accent); +} + +.wordmark-text { + font-weight: 600; + letter-spacing: 0.04em; +} + +.nav { + display: flex; + gap: clamp(var(--space-3), 2.4vw, var(--space-5)); + min-width: 0; +} + +.nav a { + color: var(--dark-ink-muted); + font-size: var(--step--1); + white-space: nowrap; + padding-block: var(--space-2); + transition: color var(--dur-quick) var(--ease); +} + +.nav a:hover { + color: var(--dark-ink); +} + +.head-meta { + color: var(--dark-ink-faint); + font-size: var(--step--1); + white-space: nowrap; + flex-shrink: 0; +} + +/* ── 히어로 ─────────────────────────────────────────────────────── */ +.hero { + padding-bottom: var(--space-7); +} + +.hero-stage { + position: relative; + width: 100%; + /* R1(luminary) 은 시뮬레이션이 첫 화면을 100% 덮는다. 우리는 CTA 대신 + 조작 슬라이더가 아래에 있어야 하므로 62% 로 잡았다 */ + height: clamp(320px, 62dvh, 620px); + overflow: clip; + background: + radial-gradient(120% 80% at 12% 82%, #101a20 0%, transparent 62%), + radial-gradient(90% 70% at 62% 8%, #17181a 0%, transparent 58%), + var(--dark-surface); + border-bottom: 1px solid var(--dark-line); +} + +#thermalField { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity var(--dur-slow) var(--ease); +} + +[data-field-state="live"] #thermalField { + opacity: 1; +} + +.hero-fallback { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 46%; + opacity: 0.72; + mix-blend-mode: screen; + transition: opacity var(--dur-slow) var(--ease); +} + +/* 캔버스가 실제로 돌기 시작하면 실물 렌더는 물러난다. + 둘이 겹쳐 보이면 무엇이 실시간인지 알 수 없다 */ +[data-field-state="live"] .hero-fallback { + opacity: 0; +} + +.hero-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, var(--dark-line) 1px, transparent 1px), + linear-gradient(to bottom, var(--dark-line) 1px, transparent 1px); + background-size: 64px 64px; + opacity: 0.4; + pointer-events: none; +} + +.hero-scrim { + position: absolute; + inset: 0; + background: linear-gradient(to bottom, color-mix(in srgb, var(--dark-surface) 46%, transparent) 0%, transparent 34%, color-mix(in srgb, var(--dark-surface) 84%, transparent) 100%); + pointer-events: none; +} + +/* 장비 형상 — 셰이더 상수와 같은 좌표다. + 랙 x 44%–56% · y 12%–62% FWU x 5.5% 부터 · y 10%–34% */ +.stage-solid { + position: absolute; + pointer-events: none; + border: 1px solid var(--dark-line-strong); +} + +.stage-rack { + left: 44%; + right: 44%; + bottom: 12%; + top: 38%; + background: repeating-linear-gradient( + to bottom, + color-mix(in srgb, var(--dark-surface) 92%, transparent) 0 6px, + color-mix(in srgb, var(--dark-surface-3) 92%, transparent) 6px 8px + ); +} + +.stage-fwu { + left: 3%; + width: 2.5%; + bottom: 10%; + height: 24%; + background: color-mix(in srgb, var(--dark-surface-3) 88%, transparent); + border-right: 2px solid var(--dark-accent); +} + +.stage-tag { + position: absolute; + display: flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--dark-ink-muted); + pointer-events: none; +} + +.stage-tag span { + background: color-mix(in srgb, var(--dark-surface) 82%, transparent); + padding: 2px var(--space-2); + border-left: 2px solid var(--dark-accent); +} + +/* 캔버스 안 FWU 는 x 0.055 / y 0.10–0.34, 랙은 x 0.44–0.56 · y 0.12–0.62 다. + 표식은 그 좌표를 따라간다 — 그림과 라벨이 어긋나면 라벨이 거짓말이 된다 */ +.stage-tag-fwu { + left: 5.5%; + bottom: 34%; +} + +.stage-tag-rack { + left: 44%; + bottom: 62%; +} + +.hero-copy { + padding-top: var(--space-5); +} + +.hero h1 { + font-size: var(--display); + max-width: 18ch; +} + +.hero-lead { + margin-top: var(--space-3); + max-width: var(--measure); + font-size: var(--step-1); + color: var(--band-ink-muted); +} + +.hero-controls { + margin-top: var(--space-5); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-4); + max-width: 640px; +} + +.ctl-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--step--1); + color: var(--band-ink-muted); + margin-bottom: var(--space-2); +} + +.ctl-head output { + color: var(--band-ink); +} + +input[type="range"] { + width: 100%; + accent-color: var(--band-accent); + background: transparent; +} + +.hero-note { + margin-top: var(--space-5); + padding-top: var(--space-3); + border-top: 1px solid var(--band-line); + max-width: var(--measure); + font-size: var(--step--1); + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +.hero-note strong { + color: var(--band-ink); + font-weight: 600; +} + +/* ── 섹션 공통 ──────────────────────────────────────────────────── */ +.band > .wrap { + padding-block: var(--space-8); +} + +.hero > .wrap { + padding-block: 0; +} + +.eyebrow { + font-family: var(--font-mono); + font-size: var(--step--1); + letter-spacing: 0.02em; + color: var(--band-accent); + margin-bottom: var(--space-3); +} + +.band h2 { + font-size: var(--step-3); + max-width: 20ch; +} + +.section-lead { + margin-top: var(--space-4); + max-width: var(--measure); + color: var(--band-ink-muted); + font-size: var(--step-1); + line-height: var(--leading-loose); +} + +/* ── 레이아웃 패밀리 A · 비대칭 2열 ─────────────────────────────── */ +.split { + display: grid; + gap: var(--space-6); +} + +@media (min-width: 900px) { + .split { + grid-template-columns: 5fr 7fr; + gap: var(--space-7); + align-items: start; + } +} + +.split-lead h2 { + position: sticky; + top: calc(56px + var(--space-5)); +} + +@media (max-width: 899px) { + .split-lead h2 { + position: static; + } +} + +.split-body > * + * { + margin-top: var(--space-4); +} + +.split-body p { + max-width: var(--measure); + color: var(--band-ink-muted); +} + +.split-body strong { + color: var(--band-ink); + font-weight: 600; +} + +/* ── 레이아웃 패밀리 B · 대조 목록 ──────────────────────────────── */ +.contrast { + margin-top: var(--space-5); + border-top: 1px solid var(--band-line-strong); +} + +.contrast > div { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 640px) { + .contrast > div { + grid-template-columns: 12rem 1fr; + } +} + +.contrast dt { + font-weight: 600; + color: var(--band-ink); +} + +.contrast dd { + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); +} + +.contrast > div:last-child dt { + color: var(--band-accent); +} + +/* ── 레이아웃 패밀리 C · 사양 원장 (행 문법) ────────────────────── */ +.spec-ledger { + margin-top: var(--space-6); + list-style: none; + border-top: 1px solid var(--band-line-strong); +} + +.spec-ledger li { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 720px) { + .spec-ledger li { + grid-template-columns: 10rem 14rem 1fr; + align-items: baseline; + } +} + +.spec-name { + color: var(--band-ink-faint); + font-size: var(--step--1); +} + +.spec-value { + font-family: var(--font-mono); + color: var(--band-ink); +} + +.spec-note { + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); +} + +/* ── 레이아웃 패밀리 D · 수치 판 ────────────────────────────────── */ +.figures { + margin-top: var(--space-7); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); + border-top: 1px solid var(--band-line-strong); + border-left: 1px solid var(--band-line); +} + +.figures figure { + padding: var(--space-4); + border-right: 1px solid var(--band-line); + border-bottom: 1px solid var(--band-line); +} + +.fig-num { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.fig-title { + margin-top: var(--space-3); + font-size: var(--step--1); + color: var(--band-ink-muted); +} + +.fig-val { + margin-top: var(--space-1); + font-size: var(--step-3); + line-height: 1.05; + color: var(--band-ink); + letter-spacing: -0.02em; +} + +.fig-val span { + font-size: var(--step-1); + color: var(--band-accent); +} + +.fig-note { + margin-top: var(--space-2); + font-size: var(--step--1); + color: var(--band-ink-faint); + line-height: var(--leading-loose); +} + +/* ── 레이아웃 패밀리 E · 장비 목록 ─────────────────────────────── */ +.equip-list { + margin-top: var(--space-6); + display: grid; + gap: var(--space-5); + border-top: 1px solid var(--band-line-strong); + padding-top: var(--space-5); +} + +@media (min-width: 760px) { + .equip-list { + grid-template-columns: repeat(2, 1fr); + column-gap: var(--space-7); + } + /* 다섯 개다. 마지막 하나가 홀로 남으므로 두 칸을 쓰게 해서 + '3열이 예뻐서 3열' 이 되는 것을 피한다 */ + .equip-list article:last-child { + grid-column: 1 / -1; + border-top: 1px solid var(--band-line); + padding-top: var(--space-5); + } +} + +.equip-list h3 { + font-size: var(--step-1); + display: flex; + align-items: baseline; + gap: var(--space-2); + flex-wrap: wrap; +} + +.equip-list p { + margin-top: var(--space-2); + color: var(--band-ink-muted); + max-width: 42ch; + line-height: var(--leading-loose); +} + +.tag { + font-size: var(--step--1); + font-weight: 400; + color: var(--band-accent); + border: 1px solid var(--band-line-strong); + padding: 1px var(--space-2); +} + +/* ── 레이아웃 패밀리 F · 시각화 (목록 + 실물) ──────────────────── */ +.vis-layout { + margin-top: var(--space-6); + display: grid; + gap: var(--space-6); +} + +@media (min-width: 960px) { + .vis-layout { + grid-template-columns: 6fr 5fr; + gap: var(--space-7); + align-items: start; + } +} + +.vis-list { + list-style: none; + counter-reset: vis; + border-top: 1px solid var(--band-line-strong); +} + +.vis-list li { + counter-increment: vis; + padding-block: var(--space-4); + border-bottom: 1px solid var(--band-line); + display: grid; + grid-template-columns: 2.5rem 1fr; + gap: var(--space-2) var(--space-3); +} + +.vis-list li::before { + content: counter(vis, decimal-leading-zero); + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--band-ink-faint); + grid-row: 1 / span 2; + padding-top: 0.35em; +} + +.vis-list h3 { + font-size: var(--step-1); +} + +.vis-list p { + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); + grid-column: 2; +} + +.vis-list .mono { + color: var(--band-ink); +} + +.vis-shots { + display: grid; + gap: var(--space-4); +} + +.vis-shots figure { + border: 1px solid var(--band-line); + background: var(--band-surface-2); +} + +.vis-shots img { + width: 100%; + height: auto; +} + +.vis-shots figcaption { + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--band-line); + font-size: var(--step--1); + color: var(--band-ink-muted); + font-family: var(--font-mono); +} + +.shots-note { + font-size: var(--step--1); + color: var(--band-ink-faint); + line-height: var(--leading-loose); +} + +/* ── 기능 행 ────────────────────────────────────────────────────── */ +.feature-rows { + border-top: 1px solid var(--band-line-strong); +} + +.feature-rows > div { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 640px) { + .feature-rows > div { + grid-template-columns: 9rem 1fr; + } +} + +.feature-rows dt { + font-weight: 600; +} + +.feature-rows dd { + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); +} + +.keys { + margin-top: var(--space-5); + padding: var(--space-4); + background: var(--band-surface-2); + border: 1px solid var(--band-line); +} + +.keys-title { + font-size: var(--step--1); + color: var(--band-ink-faint); + margin-bottom: var(--space-3); +} + +.keys ul { + list-style: none; + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + font-size: var(--step--1); + color: var(--band-ink-muted); +} + +.keys li { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +kbd { + font-family: var(--font-mono); + font-size: 0.8125rem; + padding: 2px 7px; + background: var(--band-surface); + border: 1px solid var(--band-line-strong); + border-bottom-width: 2px; + color: var(--band-ink); +} + +/* ── 레이아웃 패밀리 G · 계층 ──────────────────────────────────── */ +.layers { + margin-top: var(--space-6); + list-style: none; + border: 1px solid var(--band-line-strong); +} + +.layers li { + display: grid; + gap: var(--space-1) var(--space-4); + padding: var(--space-4); + border-bottom: 1px solid var(--band-line); +} + +.layers li:last-child { + border-bottom: 0; +} + +@media (min-width: 760px) { + .layers li { + grid-template-columns: 3rem 13rem 1fr; + align-items: baseline; + } +} + +/* 아래로 갈수록 물리 계층에 가까워진다는 것을 배경 농도로 말한다 */ +.layers li:nth-child(1) { background: color-mix(in srgb, var(--band-surface-2) 22%, transparent); } +.layers li:nth-child(2) { background: color-mix(in srgb, var(--band-surface-2) 44%, transparent); } +.layers li:nth-child(3) { background: color-mix(in srgb, var(--band-surface-2) 66%, transparent); } +.layers li:nth-child(4) { background: color-mix(in srgb, var(--band-surface-2) 84%, transparent); } +.layers li:nth-child(5) { background: var(--band-surface-2); } + +.layer-id { + color: var(--band-accent); + font-size: var(--step--1); +} + +.layer-name { + font-weight: 600; +} + +.layer-body { + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); +} + +/* ── 요구사양 표 ────────────────────────────────────────────────── */ +.req { + width: 100%; + border-collapse: collapse; + font-size: var(--step--1); +} + +.req caption { + text-align: left; +} + +.req th, +.req td { + text-align: left; + padding: var(--space-3) var(--space-3) var(--space-3) 0; + border-bottom: 1px solid var(--band-line); + vertical-align: top; +} + +.req thead th { + color: var(--band-ink-faint); + font-weight: 400; + border-bottom: 1px solid var(--band-line-strong); +} + +.req tbody th { + font-weight: 600; + width: 7rem; +} + +.req td { + color: var(--band-ink-muted); + font-family: var(--font-mono); +} + +/* ── CTA ────────────────────────────────────────────────────────── */ +.adopt-cta { + margin-top: var(--space-6); + padding-top: var(--space-5); + border-top: 1px solid var(--band-line-strong); +} + +.btn { + display: inline-block; + margin-top: var(--space-4); + padding: var(--space-3) var(--space-5); + background: var(--band-accent); + color: var(--band-accent-ink); + font-weight: 600; + white-space: nowrap; + border: 1px solid var(--band-accent); + transition: background-color var(--dur-quick) var(--ease); +} + +.btn:hover { + background: var(--band-accent-hover); + border-color: var(--band-accent-hover); + color: var(--band-accent-ink); +} + +.adopt-fine { + margin-top: var(--space-3); + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +/* ── 푸터 ───────────────────────────────────────────────────────── */ +.site-foot { + border-top: 1px solid var(--dark-line); +} + +.foot-inner { + display: grid; + gap: var(--space-5); +} + +@media (min-width: 760px) { + .foot-inner { + grid-template-columns: 1fr auto; + align-items: start; + } +} + +.foot-name { + font-weight: 600; +} + +.foot-line { + margin-top: var(--space-2); + color: var(--band-ink-muted); + font-size: var(--step--1); +} + +.foot-meta { + display: grid; + gap: var(--space-2); + font-size: var(--step--1); +} + +.foot-meta > div { + display: grid; + grid-template-columns: 5rem 1fr; + gap: var(--space-3); +} + +.foot-meta dt { + color: var(--band-ink-faint); +} + +.foot-meta dd { + color: var(--band-ink-muted); +} + +.foot-fine { + padding-bottom: var(--space-6); +} + +.foot-fine p { + padding-top: var(--space-4); + border-top: 1px solid var(--band-line); + font-size: var(--step--1); + color: var(--band-ink-faint); + line-height: var(--leading-loose); + max-width: 60ch; +} + +/* ── 좁은 화면 ──────────────────────────────────────────────────── */ +@media (max-width: 640px) { + .head-inner { + flex-wrap: wrap; + padding-block: var(--space-2); + row-gap: var(--space-2); + } + + .nav { + order: 3; + width: 100%; + justify-content: space-between; + gap: var(--space-2); + border-top: 1px solid var(--dark-line); + padding-top: var(--space-2); + } + + .stage-tag { + font-size: 0.6875rem; + } + + .figures { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 380px) { + .figures { + grid-template-columns: 1fr; + } +} + +/* ── 모션을 줄이는 설정 ─────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + + .split-lead h2 { + position: static; + } +} + +/* ── 인쇄 ───────────────────────────────────────────────────────── */ +@media print { + .site-head, + .hero-stage, + .hero-controls, + .skip-link { + display: none; + } + + .band { + background: #fff; + color: #000; + } + + .band > .wrap { + padding-block: var(--space-4); + } +} diff --git a/apps/site/public/work/rfs-cfd/styles/tokens.css b/apps/site/public/work/rfs-cfd/styles/tokens.css new file mode 100644 index 0000000..eca163d --- /dev/null +++ b/apps/site/public/work/rfs-cfd/styles/tokens.css @@ -0,0 +1,122 @@ +/* ========================================================================== + RFS — Realtime Fluid Simulator · 디자인 토큰 SSOT + 실시간 GPU 유체 시뮬레이션 엔진 (데이터센터 열관리) 프로모션 사이트 + + 컨셉 : 암실과 사양서 — 유동이 있는 곳은 어둡고, 읽는 곳은 밝다 + 프리셋: darkroom-spec (신규 정의. dark-instrument 를 쓰지 않은 이유는 아래) + 강조색: 계기 청색 1색. 라이트/다크는 규칙으로 정의된 한 쌍이므로 1종으로 센다 + + ── 레퍼런스 실측 (2026-08-24, 1440px computed style) + R1 구조 · luminary.ai (Physics AI 시뮬레이션 플랫폼) + 배경 #2A2C2F · GT Standard + GT Standard Mono 2패밀리 + h1 72px/600 · 본문 16px · 최대÷본문 4.5배 + radius 0px 1898회 지배 (8px 36 / 9999px 11) + 컨테이너 1425 전폭 ↔ 1297 ↔ 974 · 섹션 14 + 히어로: 시뮬레이션 영상 1440×1440 이 첫 화면을 100% 덮는다 + → 가져온 원리: 시뮬레이션 결과가 히어로를 통째로 먹고 UI 는 무채색으로 물러난다. + 색은 시각물 안에서만 소리친다 + → 안 가져온 것: 섹션 14개, 비어 있는 h1(접근성 실패), 'Physics AI Factory' 류 조어 + + R2 톤 · teenage.engineering / EP-133 (전자악기 — 다른 업종) + 배경 #F9FAF9 · te-20 + TechnoType 2패밀리 + radius 0px 2492회 지배 · 강조색 #FF5000 이 단 10회 + 밝은 페이지 안에 다크 블록 #0F0E12 가 18회 · 컨테이너 1308 ↔ 1440 전폭 + 최대 52.9px ÷ 본문 13.2px = 4.0배 + → 가져온 원리: 밝은 바탕 안에 어두운 밴드를 리듬으로 둔다. 강조색은 극히 아껴 쓴다 + → 안 가져온 것: h1 부재, 오렌지 색상 자체 + + R1 보조 · simscale.com (CFD SaaS) + 배경 #F7F7F7 · Geist 1패밀리 · h1 49.78px/400 · 본문 15.38px (3.24배) + radius 0px 2558회 + pill 114회 · 컨테이너 1192 본문 ↔ 1425 전폭 교대 + → 라이트 배경 CFD 사이트의 반례로 기록. 별점 리뷰 배너는 가져오지 않는다 + + ── dark-instrument 프리셋을 쓰지 않은 이유 + 이 저장소에 이미 dark-instrument 가 둘(Pulsegate 코발트, Synapse BCI 골드) 있다. + 셋째를 같은 프리셋으로 만들면 그것은 이 브리프의 결정이 아니라 카테고리 평균이다. + RFS 는 파티클이 어두운 배경에서만 읽히지만(제품 실제 렌더 확인), 사양·도입 절차는 + 밝은 종이에서 읽는 것이 낫다. 그래서 한 페이지를 두 체제로 나눈다 — 그것이 감수한 리스크다. + + ── 강조색의 출처 + 제품 실물의 에디터 테마는 --theme-accent: #3b82f6 (Tailwind blue-500) 이다. + 그대로 쓰면 '색을 고르지 않았다'는 신호를 그대로 수입하게 되므로, 채도를 낮추고 + 청록 쪽으로 틀어 계기 청색으로 옮겼다. 출발점은 제품의 실제 색이 맞다. + ========================================================================== */ + +:root { + /* ── 타이포그래피 스케일 — 5단계 + 스케일 밖 디스플레이 1개 ── */ + --step--1: 0.8125rem; /* 13px — 라벨·캡션·표 머리 */ + --step-0: 1rem; /* 16px — 본문 (한글 하한) */ + --step-1: 1.1875rem; /* 19px — 리드 문장 */ + --step-2: clamp(1.375rem, 1.15rem + 1.1vw, 1.9375rem); /* 22 → 31px — 섹션 헤딩 */ + --step-3: clamp(1.875rem, 1.4rem + 2.4vw, 3rem); /* 30 → 48px — 큰 헤딩 */ + --display: clamp(2.5rem, 1.6rem + 4.4vw, 4.5rem); /* 40 → 72px — 히어로 전용 */ + /* 최대 ÷ 본문 = 72 ÷ 16 = 4.5배. R1(luminary) 과 같은 비율이다 */ + + --leading-tight: 1.18; + --leading-normal: 1.7; /* 한글 본문 하한 1.6 */ + --leading-loose: 1.85; + --measure: 34em; /* 한글 한 줄 25~40자 */ + + /* 라틴을 폴백 앞에 두면 라틴까지 한글 폰트로 그려진다 — Pretendard 는 둘 다 좋다 */ + --font-body: Pretendard, "Pretendard Variable", -apple-system, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace; + + /* ── 색 · 사양서(라이트) 체제 ── */ + --surface: #f4f5f3; + --surface-2: #eaece8; + --surface-3: #dfe2dd; + --line: #d3d7d1; + --line-strong: #b6bcb5; + --ink: #14171a; + --ink-muted: #545c63; /* 6.23:1 */ + --ink-faint: #626a70; /* 5.10:1 — 13px 캡션도 AA 를 지켜야 하므로 처음 값(#7C858C·3.43:1)에서 내렸다 */ + --accent: #2a6b8a; /* 밝은 바탕 위 5.33:1 */ + --accent-hover: #1f5069; + --accent-ink: #ffffff; + --accent-wash: #e3ecf1; + + /* ── 색 · 암실(다크) 체제 — 위 값들의 규칙적 반전 ── */ + --dark-surface: #0e1114; + --dark-surface-2: #14181c; + --dark-surface-3: #1b2126; + --dark-line: #262c31; + --dark-line-strong: #3a434a; + --dark-ink: #e8ecef; + --dark-ink-muted: #98a3ab; /* 7.35:1 */ + --dark-ink-faint: #7b858c; /* 5.03:1 — 처음 값(#6D777E)은 4.14:1 로 미달이었다 */ + --dark-accent: #3e8fb0; /* 어두운 바탕 위 5.20:1 */ + --dark-accent-hover: #5aa6c4; + --dark-accent-ink: #071016; + --dark-accent-wash: #16303c; + + /* ── 온도 컬러맵 — 캔버스 안에서만 쓰는 데이터 인코딩이다. + UI 강조색이 아니므로 강조색 카운트(하드 게이트 2)에서 제외한다. + 제품 실물의 파티클 렌더가 저온 시안 → 고온 그린이라 그 언어를 따랐다 ── */ + --temp-cold: #2f7d9e; + --temp-mid: #39a894; + --temp-warm: #8cc152; + --temp-hot: #d7d64a; + + /* ── 형태 — R1·R2 둘 다 radius 0 이 지배한다. 어휘 1종으로 못박는다 ── */ + --radius: 0; + + /* ── 간격 — 저장소 SSOT 표준 ── */ + --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); + + --container: 1120px; + --container-wide: 1360px; + --pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem); + + /* ── 모션 ── */ + --dur-quick: 160ms; + --dur-normal: 420ms; + --dur-slow: 900ms; + --ease: cubic-bezier(0.2, 0, 0, 1); +} diff --git a/apps/site/public/work/saebyeok-cheonggwa/assets/dawn-aisle.webp b/apps/site/public/work/saebyeok-cheonggwa/assets/dawn-aisle.webp new file mode 100644 index 0000000..11e3f17 Binary files /dev/null and b/apps/site/public/work/saebyeok-cheonggwa/assets/dawn-aisle.webp differ diff --git a/apps/site/public/work/saebyeok-cheonggwa/assets/grape-bloom.webp b/apps/site/public/work/saebyeok-cheonggwa/assets/grape-bloom.webp new file mode 100644 index 0000000..8489acf Binary files /dev/null and b/apps/site/public/work/saebyeok-cheonggwa/assets/grape-bloom.webp differ diff --git a/apps/site/public/work/saebyeok-cheonggwa/assets/pear-crate.webp b/apps/site/public/work/saebyeok-cheonggwa/assets/pear-crate.webp new file mode 100644 index 0000000..b70d3e9 Binary files /dev/null and b/apps/site/public/work/saebyeok-cheonggwa/assets/pear-crate.webp differ diff --git a/apps/site/public/work/saebyeok-cheonggwa/assets/refractometer.webp b/apps/site/public/work/saebyeok-cheonggwa/assets/refractometer.webp new file mode 100644 index 0000000..29c9677 Binary files /dev/null and b/apps/site/public/work/saebyeok-cheonggwa/assets/refractometer.webp differ diff --git a/apps/site/public/work/saebyeok-cheonggwa/index.html b/apps/site/public/work/saebyeok-cheonggwa/index.html new file mode 100644 index 0000000..dbd6270 --- /dev/null +++ b/apps/site/public/work/saebyeok-cheonggwa/index.html @@ -0,0 +1,573 @@ + + + + + + 새벽청과 (曉靑果) — 오늘 새벽 두 시의 값 + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ + +
+

+ 오늘 경매 + + + 하절기 기준 +

+ +

+ 오늘 새벽
+ 두 시의 값 +

+

+ 값은 매일 바뀝니다. 바뀌는 값을 감추고 파는 대신, 오늘 얼마에 받아 왔는지를 + 먼저 적습니다. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 오늘 낙찰 품목 여섯 가지 — 산지, 등급, 당도, 규격, 낙찰가, 전일 대비 +
품목산지등급당도규격낙찰가전일 대비
백도 복숭아경북 청도특12.5Bx4.5kg38,000▲ 2,000
샤인머스캣경북 상주특18.2Bx2kg26,500▼ 1,500
캠벨얼리충북 영동상15.4Bx5kg31,000보합
홍로 사과경북 영주특14.1Bx5kg44,000▲ 3,500
원황 배전남 나주상12.8Bx7.5kg52,000▼ 2,000
무화과전남 영암특—1.2kg24,000▲ 1,000
+ +
    +
  • + 사과와 배는 같은 날 08:30 경매입니다. 나머지 넷은 + 02:00 에 붙습니다. +
  • +
  • + 당도 칸의 — 는 값이 없다는 뜻이 아니라 + 표시가 허용되지 않는 품목이라는 뜻입니다. 무화과는 당도 표기 + 대상 13개 품목에 들어 있지 않습니다. +
  • +
  • + 낙찰가는 경매에서 받아 온 값이고, 여기에 선별·포장·배송이 붙어 판매가가 됩니다. + 두 값을 같은 것처럼 적지 않습니다. +
  • +
  • + 위 표의 숫자는 이 쇼케이스를 위한 예시입니다. 실제 운영에서는 + 그날 경매 결과가 그대로 들어옵니다. +
  • +
+
+
+ + +
+
+

밭에서 문 앞까지 다섯 번 손이 바뀝니다

+

+ 이 경로는 우리가 만든 것이 아니라 도매시장이 원래 그렇게 돌아갑니다. + 우리는 네 번째 자리에 서 있습니다. +

+ +
    +
  1. +

    01

    +

    출하

    +

    산지에서 따고 골라 담아 시장으로 보냅니다.

    +
  2. +
  3. +

    02

    +

    수집

    +

    도매법인이 그날 들어온 물량을 받아 경매에 올립니다.

    +
  4. +
  5. +

    03

    +

    경매

    +

    새벽 두 시. 값이 여기서 정해집니다. 낙찰된 값을 경락가격이라 부릅니다.

    +
  6. +
  7. +

    04

    +

    낙찰 — 우리가 서는 자리

    +

    + 중도매인 자격으로 직접 받습니다. 어느 산지의 어느 등급을 얼마에 받았는지가 + 위의 판에 그대로 올라갑니다. +

    +
  8. +
  9. +

    05

    +

    그날 아침 배송

    +

    다시 창고에 재우지 않습니다. 경매장에서 나온 상자가 그대로 갑니다.

    +
  10. +
+
+
+ + +
+
+
+

판에 적힌 네 글자를 읽는 법

+
+
+
+
+
등급 특 · 상 · 보통
+
+ 크기가 고른가, 흠이 없는가, 색이 제대로 들었는가를 봅니다. 농산물 표준규격이 + 품목마다 따로 정해 두고 있습니다. 당도를 표시할 수 있는 것은 + 특·상 등급뿐입니다. +
+
+
+
당도 Brix
+
+ 용액 100g 에 든 당이 몇 g 인지를 나타냅니다. 굴절당도계에 과즙 한 방울을 + 올려 잽니다. 높다고 늘 맛있는 것은 아니고 산도와의 균형이 맛을 정합니다 — + 그래서 숫자만 크게 적지 않습니다. +
+
+
+
선과 選果
+
+ 기계로 골라내는 일입니다. 드럼식은 지름으로, 중량식은 무게로 나눕니다. + 같은 상자 안의 알이 고른 이유가 여기 있습니다. +
+
+
+
규격 kg
+
+ 상자 단위입니다. 사과는 2·5·7.5·10kg, 포도는 2·3·4·5kg 로 거래됩니다. + 같은 값이라도 규격이 다르면 비교가 안 되므로 판에 함께 적습니다. +
+
+
+
+
+
+ + +
+
+

제철

+

없을 때는 없다고 적습니다

+

+ 철이 아닌 것을 굳이 구해 오지 않습니다. 아래 띠에서 칸이 찬 달에만 그 품목이 + 판에 오릅니다. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
품목별 제철 달력 — 가로축은 1월부터 12월
품목123456789101112
딸기제철제철제철제철제철
참외제철제철제철제철
복숭아제철제철제철제철
포도제철제철제철제철
무화과제철제철제철
사과제철제철제철제철제철
배제철제철제철제철
감귤제철제철제철제철제철
+
+

+ 달마다 굵은 세로선이 이번 달입니다. 산지와 그해 날씨에 따라 두 주쯤은 앞뒤로 + 움직입니다. +

+
+
+ + +
+
+

오늘 들어온 것

+

+ 같은 품목이라도 산지가 바뀌면 다른 물건입니다. 누가 어디서 길렀는지를 함께 적습니다. +

+ +
+
+ 흰 그물망에 하나씩 싼 배가 얕은 나무 상자에 담겨 있고, 한 알은 상자 옆에 꺼내 놓여 있습니다 +
+

원황 배 전남 나주

+
+
등급
상
+
당도
12.8 Bx
+
규격
7.5 kg
+
+

+ 나주 배는 물이 많고 단맛이 늦게 옵니다. 그물망은 멋이 아니라 눌림을 막는 + 포장입니다. +

+
+
+ +
+ 흰 종이 위에 놓인 진자주색 포도 송이. 껍질에 하얀 과분이 그대로 남아 있습니다 +
+

캠벨얼리 충북 영동

+
+
등급
상
+
당도
15.4 Bx
+
규격
5 kg
+
+

+ 껍질에 앉은 흰 가루는 과분입니다. 씻겨 나간 것이 아니라 남아 있는 쪽이 + 덜 만져진 것입니다. +

+
+
+ +
+ 스테인리스 작업대 위 굴절당도계의 프리즘 판에 과즙 한 방울이 올려져 있습니다 +
+

당도는 이렇게 잽니다

+

+ 굴절당도계에 과즙을 한 방울 올리고 빛이 꺾이는 각을 봅니다. + 상자마다 재는 것이 아니라 들어온 로트에서 표본을 뽑아 잽니다 — + 그래서 판의 당도는 그 로트의 값입니다. +

+
+
+
+
+
+ + +
+
+
+

가게에 넣는 경우

+
+
+

+ 카페·베이커리·식당처럼 매일 같은 품질이 필요한 곳은 소매와 다른 조건으로 + 봅니다. 값보다 편차가 문제이기 때문입니다. +

+
+
+
주문 마감
+
전날 21:00 — 그래야 새벽 경매에서 물량을 잡습니다
+
+
+
배송
+
같은 날 오전. 수도권은 직접 돌고, 그 밖은 새벽 물류로 보냅니다
+
+
+
등급 고정
+
+ 등급을 계약으로 묶습니다. 그날 시세가 올라도 등급을 낮춰 값을 맞추지 + 않습니다 +
+
+
+
시세 공유
+
낙찰가를 그대로 전달합니다. 마진은 별도 항목으로 적습니다
+
+
+
최소 물량
+
품목당 상자 3 개부터
+
+
+ + 납품 조건 문의 + +
+
+
+ + +
+
+
+

문의

+
+
+
+
+
여는 시각
+
새벽 01:30 – 낮 12:00 · 일요일 휴무
+
+
+
주문 마감
+
전날 21:00
+
+ +
+

+ 주소·전화·사업자 정보는 실제 상호가 정해진 뒤에 채웁니다. + 지어낸 값을 임시로 적어 두지 않습니다. +

+
+
+
+
+ + + + + + diff --git a/apps/site/public/work/saebyeok-cheonggwa/og.png b/apps/site/public/work/saebyeok-cheonggwa/og.png new file mode 100644 index 0000000..fe0b2fc Binary files /dev/null and b/apps/site/public/work/saebyeok-cheonggwa/og.png differ diff --git a/apps/site/public/work/saebyeok-cheonggwa/styles/main.css b/apps/site/public/work/saebyeok-cheonggwa/styles/main.css new file mode 100644 index 0000000..30e9043 --- /dev/null +++ b/apps/site/public/work/saebyeok-cheonggwa/styles/main.css @@ -0,0 +1,831 @@ +@import "./tokens.css"; + +/* ========================================================================== + 새벽청과 — 메인 스타일시트 + + 판(board)이 이 페이지의 주인공이다. 사진은 색을 담당하고, UI 는 색을 쓰지 않는다. + .band-dark 는 '새벽'(경매장·제철 띠·납품), .band-light 는 '낮'(설명·상품)이다. + 반전은 아래 두 클래스가 토큰을 재정의하는 규칙이다. + ========================================================================== */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + scroll-behavior: smooth; + overflow-x: hidden; + background: var(--dark-surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-height: 100dvh; + font-family: var(--font-body); + font-size: var(--step-0); + line-height: var(--leading-normal); + background: var(--surface); + color: var(--ink); + word-break: keep-all; + overflow-wrap: break-word; + overflow-x: hidden; +} + +img { + display: block; + max-width: 100%; +} + +a { + color: inherit; +} + +h1, +h2, +h3 { + font-weight: 600; + line-height: var(--leading-tight); + letter-spacing: -0.015em; + text-wrap: balance; +} + +.mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.wrap { + width: min(100% - 2 * var(--pad-inline), var(--container)); + margin-inline: auto; +} + +/* ── 두 체제 ────────────────────────────────────────────────────── */ +.band-light { + --band-surface: var(--surface); + --band-surface-2: var(--surface-2); + --band-surface-3: var(--surface-3); + --band-line: var(--line); + --band-line-strong: var(--line-strong); + --band-ink: var(--ink); + --band-ink-muted: var(--ink-muted); + --band-ink-faint: var(--ink-faint); + --band-accent: var(--accent); + --band-accent-hover: var(--accent-hover); + --band-accent-ink: var(--accent-ink); + --band-accent-wash: var(--accent-wash); +} + +.band-dark { + --band-surface: var(--dark-surface); + --band-surface-2: var(--dark-surface-2); + --band-surface-3: var(--dark-surface-3); + --band-line: var(--dark-line); + --band-line-strong: var(--dark-line-strong); + --band-ink: var(--dark-ink); + --band-ink-muted: var(--dark-ink-muted); + --band-ink-faint: var(--dark-ink-faint); + --band-accent: var(--dark-accent); + --band-accent-hover: var(--dark-accent-hover); + --band-accent-ink: var(--dark-accent-ink); + --band-accent-wash: var(--dark-accent-wash); +} + +.band { + position: relative; + background: var(--band-surface); + color: var(--band-ink); +} + +.band > .wrap { + padding-block: var(--space-8); +} + +/* ── 접근성 ─────────────────────────────────────────────────────── */ +.skip-link { + position: fixed; + top: var(--space-3); + left: var(--space-3); + z-index: 200; + padding: var(--space-2) var(--space-3); + background: var(--ink); + color: var(--surface); + text-decoration: none; + transform: translateY(-250%); + transition: transform var(--dur-quick) var(--ease); +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +:where(a, button, [tabindex]):focus-visible { + outline: 2px solid var(--band-accent, var(--accent)); + outline-offset: 3px; +} + +/* ── 헤더 ───────────────────────────────────────────────────────── */ +.site-head { + position: sticky; + top: 0; + z-index: 100; + background: var(--dark-surface); + border-bottom: 1px solid var(--dark-line); + color: var(--dark-ink); +} + +.head-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 56px; + min-width: 0; +} + +.wordmark { + font-weight: 600; + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} + +.wordmark-han { + color: var(--dark-ink-faint); + font-size: var(--step--1); + margin-left: 2px; +} + +.nav { + display: flex; + gap: clamp(var(--space-3), 2.4vw, var(--space-5)); + min-width: 0; +} + +.nav a { + color: var(--dark-ink-muted); + font-size: var(--step--1); + text-decoration: none; + white-space: nowrap; + padding-block: var(--space-2); + transition: color var(--dur-quick) var(--ease); +} + +.nav a:hover { + color: var(--dark-accent); +} + +.head-meta { + font-size: var(--step--1); + color: var(--dark-ink-faint); + white-space: nowrap; + flex-shrink: 0; +} + +/* ── 히어로 ─────────────────────────────────────────────────────── */ +.hero { + overflow: clip; +} + +/* 사진은 판 뒤로 물러난다. 판이 읽히지 않으면 이 페이지는 실패다 */ +.hero-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 42%; + opacity: 0.64; +} + +.hero::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--dark-surface) 40%, transparent) 0%, + color-mix(in srgb, var(--dark-surface) 82%, transparent) 40%, + var(--dark-surface) 86% + ); + pointer-events: none; +} + +.hero-inner { + position: relative; + z-index: 1; +} + +.hero-stamp { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-2) var(--space-3); + font-size: var(--step--1); + color: var(--band-ink-muted); + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--band-line-strong); +} + +.hero-stamp time { + color: var(--band-accent); +} + +.stamp-sep { + color: var(--band-ink-faint); +} + +.hero h1 { + margin-top: var(--space-5); + font-size: var(--display); + max-width: 12ch; +} + +.hero-lead { + margin-top: var(--space-4); + max-width: var(--measure); + font-size: var(--step-1); + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +/* ── 시세판 ─────────────────────────────────────────────────────── */ +.board { + margin-top: var(--space-6); + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: var(--step--1); +} + +.board th, +.board td { + padding: var(--space-3) var(--space-2); + border-bottom: 1px solid var(--band-line); + vertical-align: baseline; +} + +.board th:first-child, +.board td:first-child { + padding-left: 0; +} + +.board th:last-child, +.board td:last-child { + padding-right: 0; +} + +.board thead th { + border-bottom: 1px solid var(--band-line-strong); + color: var(--band-ink-faint); + font-weight: 400; + white-space: nowrap; +} + +.board tbody th { + font-size: var(--step-0); + font-weight: 600; + white-space: nowrap; +} + +.board td { + color: var(--band-ink-muted); +} + +.c-num { + text-align: right; + white-space: nowrap; +} + +.unit { + margin-left: 2px; + color: var(--band-ink-faint); + font-size: 0.85em; +} + +.price { + color: var(--band-ink); + font-size: var(--step-0); +} + +/* 등락은 색으로만 말하지 않는다 — 기호가 먼저다. + 색맹 사용자에게 빨강/파랑만 남는 판은 판이 아니다 */ +.delta { + color: var(--band-ink-faint); +} + +.delta.up, +.delta.down { + color: var(--band-ink-muted); +} + +.blank { + color: var(--band-ink-faint); +} + +.board-notes { + margin-top: var(--space-5); + list-style: none; + display: grid; + gap: var(--space-3); + max-width: 62ch; +} + +.board-notes li { + padding-left: var(--space-4); + position: relative; + font-size: var(--step--1); + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +.board-notes li::before { + content: "—"; + position: absolute; + left: 0; + color: var(--band-ink-faint); +} + +.board-notes strong { + color: var(--band-ink); + font-weight: 600; +} + +.note-demo { + margin-top: var(--space-2); + padding-top: var(--space-3); + border-top: 1px solid var(--band-line); + color: var(--band-ink-faint); +} + +/* ── 섹션 공통 ──────────────────────────────────────────────────── */ +.eyebrow { + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--band-accent); + margin-bottom: var(--space-3); +} + +.band h2 { + font-size: var(--step-3); + max-width: 20ch; +} + +.section-lead, +.lead { + margin-top: var(--space-4); + max-width: var(--measure); + color: var(--band-ink-muted); + font-size: var(--step-1); + line-height: var(--leading-loose); +} + +.split { + display: grid; + gap: var(--space-5); +} + +@media (min-width: 900px) { + .split { + grid-template-columns: 4fr 8fr; + gap: var(--space-7); + align-items: start; + } +} + +/* ── 경로 ───────────────────────────────────────────────────────── */ +.route { + margin-top: var(--space-6); + list-style: none; + border-top: 1px solid var(--band-line-strong); +} + +.route li { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-4); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 760px) { + .route li { + grid-template-columns: 3rem 14rem 1fr; + align-items: baseline; + } +} + +.route-num { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.route h3 { + font-size: var(--step-1); +} + +.route li > p:last-child { + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); +} + +/* 우리가 서는 자리만 표시한다 — 다섯 중 하나만 강조해야 강조가 된다 */ +.route-here { + background: var(--band-accent-wash); + box-shadow: inset 3px 0 0 0 var(--band-accent); + padding-left: var(--space-4); +} + +.route-here .route-num, +.route-here h3 { + color: var(--band-accent); +} + +/* ── 용어 ───────────────────────────────────────────────────────── */ +.terms { + border-top: 1px solid var(--band-line-strong); +} + +.terms > div { + padding-block: var(--space-4); + border-bottom: 1px solid var(--band-line); +} + +.terms dt { + font-size: var(--step-1); + font-weight: 600; + display: flex; + align-items: baseline; + gap: var(--space-3); + flex-wrap: wrap; +} + +.term-en { + font-size: var(--step--1); + font-weight: 400; + color: var(--band-ink-faint); +} + +.terms dd { + margin-top: var(--space-2); + color: var(--band-ink-muted); + max-width: 48ch; + line-height: var(--leading-loose); +} + +.terms strong { + color: var(--band-ink); + font-weight: 600; +} + +/* ── 제철 띠 ────────────────────────────────────────────────────── */ +.season-scroll { + margin-top: var(--space-6); + overflow-x: auto; +} + +.season { + width: 100%; + min-width: 34rem; + border-collapse: collapse; + font-size: var(--step--1); + text-align: center; +} + +.season th, +.season td { + border: 1px solid var(--band-line); + padding: var(--space-2) 0; +} + +.season thead th { + color: var(--band-ink-faint); + font-weight: 400; + font-family: var(--font-mono); + border-bottom: 1px solid var(--band-line-strong); +} + +.season tbody th { + text-align: left; + padding-inline: 0 var(--space-3); + border-left: 0; + white-space: nowrap; + font-weight: 500; +} + +.season-item { + text-align: left; + width: 6rem; +} + +.season td.on { + background: var(--band-ink-muted); +} + +/* 이번 달 — 배경 대신 테두리로 표시한다. 칸이 찬 것과 이번 달이 겹쳐도 + 둘 다 읽혀야 하기 때문이다 */ +.season .is-now { + border-inline: 2px solid var(--band-accent); +} + +.season thead .is-now { + color: var(--band-accent); + border-top: 2px solid var(--band-accent); +} + +.season-note { + margin-top: var(--space-4); + font-size: var(--step--1); + color: var(--band-ink-faint); + line-height: var(--leading-loose); + max-width: var(--measure); +} + +/* ── 오늘 들어온 것 ─────────────────────────────────────────────── */ +.today-grid { + margin-top: var(--space-6); + display: grid; + gap: var(--space-5); +} + +@media (min-width: 820px) { + .today-grid { + grid-template-columns: repeat(2, 1fr); + gap: var(--space-6); + /* 상자 사진과 접사는 비율이 다르다. 늘려서 높이를 맞추면 짧은 쪽 아래에 + 300px 짜리 빈 칸이 생긴다(실측). 각자 제 높이로 두고 캡션을 사진에 붙인다 */ + align-items: start; + } + .today-card-wide { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-5); + align-items: center; + } +} + +.today-card img { + width: 100%; + height: auto; + border: 1px solid var(--band-line); +} + +.today-card figcaption { + padding-top: var(--space-3); +} + +.card-name { + font-size: var(--step-1); + font-weight: 600; + display: flex; + align-items: baseline; + gap: var(--space-3); + flex-wrap: wrap; +} + +.card-origin { + font-size: var(--step--1); + font-weight: 400; + color: var(--band-ink-faint); +} + +.card-spec { + margin-top: var(--space-3); + display: flex; + flex-wrap: wrap; + gap: var(--space-2) var(--space-4); + font-size: var(--step--1); + padding-block: var(--space-2); + border-block: 1px solid var(--band-line); +} + +.card-spec > div { + display: flex; + gap: var(--space-2); +} + +.card-spec dt { + color: var(--band-ink-faint); +} + +.card-note { + margin-top: var(--space-3); + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); + max-width: 42ch; +} + +/* ── 납품 · 문의 ───────────────────────────────────────────────── */ +.ws-rows, +.contact-rows { + margin-top: var(--space-5); + border-top: 1px solid var(--band-line-strong); +} + +.ws-rows > div, +.contact-rows > div { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 640px) { + .ws-rows > div, + .contact-rows > div { + grid-template-columns: 7rem 1fr; + } +} + +.ws-rows dt, +.contact-rows dt { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.ws-rows dd, +.contact-rows dd { + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +.contact-rows a { + color: var(--band-accent); +} + +.lead strong { + color: var(--band-ink); + font-weight: 600; +} + +.contact-fine { + margin-top: var(--space-4); + font-size: var(--step--1); + color: var(--band-ink-faint); + line-height: var(--leading-loose); + max-width: var(--measure); +} + +.btn { + display: inline-block; + margin-top: var(--space-5); + padding: var(--space-3) var(--space-5); + background: var(--band-accent); + color: var(--band-accent-ink); + font-weight: 600; + text-decoration: none; + white-space: nowrap; + transition: background-color var(--dur-quick) var(--ease); +} + +.btn:hover { + background: var(--band-accent-hover); +} + +/* ── 푸터 ───────────────────────────────────────────────────────── */ +.foot-inner { + display: grid; + gap: var(--space-2); +} + +.foot-name { + font-weight: 600; +} + +.foot-line { + color: var(--band-ink-muted); + font-size: var(--step--1); +} + +.foot-fine { + margin-top: var(--space-4); + padding-top: var(--space-3); + border-top: 1px solid var(--band-line); + font-size: var(--step--1); + color: var(--band-ink-faint); + line-height: var(--leading-loose); + max-width: 62ch; +} + +/* ── 좁은 화면 ──────────────────────────────────────────────────── */ +@media (max-width: 720px) { + .head-inner { + flex-wrap: wrap; + padding-block: var(--space-2); + row-gap: var(--space-2); + } + + .nav { + order: 3; + width: 100%; + justify-content: space-between; + gap: var(--space-2); + border-top: 1px solid var(--dark-line); + padding-top: var(--space-2); + } + + .head-meta { + display: none; + } + + /* 판은 일곱 칸이라 좁은 화면에서 표로 두면 못 읽는다. + 행마다 라벨을 붙인 카드로 접는다 — 가로 스크롤로 미루지 않는다 */ + .board thead { + display: none; + } + + .board, + .board tbody, + .board tr, + .board th, + .board td { + display: block; + } + + .board tr { + padding-block: var(--space-4); + border-bottom: 1px solid var(--band-line); + } + + .board tbody th { + font-size: var(--step-1); + padding: 0; + border: 0; + } + + .board td { + padding: var(--space-1) 0; + border: 0; + text-align: left; + display: flex; + justify-content: space-between; + gap: var(--space-3); + } + + .board td::before { + content: attr(data-label); + color: var(--band-ink-faint); + font-family: var(--font-body); + } + + .today-card-wide { + display: block; + } +} + +/* ── 모션을 줄이는 설정 ─────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ── 인쇄 — 판은 종이에서도 판이어야 한다 ─────────────────────── */ +@media print { + .site-head, + .skip-link, + .btn, + .hero-bg { + display: none; + } + + .hero::after { + display: none; + } + + .band { + background: #fff; + color: #000; + } + + .board { + break-inside: avoid; + } +} diff --git a/apps/site/public/work/saebyeok-cheonggwa/styles/tokens.css b/apps/site/public/work/saebyeok-cheonggwa/styles/tokens.css new file mode 100644 index 0000000..5f62b4f --- /dev/null +++ b/apps/site/public/work/saebyeok-cheonggwa/styles/tokens.css @@ -0,0 +1,128 @@ +/* ========================================================================== + 새벽청과 (曉靑果) — 청과 도매 직거래 · 디자인 토큰 SSOT + + 컨셉 : 오늘 새벽 두 시의 값 — 값이 매일 바뀐다는 사실을 숨기지 않고 + 첫 화면의 주인공으로 삼는다 + 프리셋: market-board (신규 정의. quiet-commerce 를 뼈대로 삼되 상품이 아니라 + '오늘의 값'이 히어로다) + 강조색: 새벽 남색 1색. 라이트/다크는 규칙으로 정의된 한 쌍이므로 1종으로 센다 + + ── 왜 화면이 무채색인가 + 과일이 색을 전부 쓴다. 사진 안에 빨강·자주·황금이 이미 다 들어 있는데 + UI 까지 색을 쓰면 둘이 싸운다. 그래서 판은 종이와 잉크로만 짓고, + 유채색은 사진과 강조색 하나에만 허락한다. + + ── 강조색의 근거 + 이 시장은 새벽 두 시에 연다. 낮의 색이 아니라 해뜨기 전의 색이어야 하고, + 과일의 난색(적·황·자)과 보색 관계라 사진을 죽이지 않는다. + 저장소의 기존 16종에 남색 계열이 비어 있는 것도 확인했다. + + ── 레퍼런스 실측 (2026-08-24, 1440px computed style) + R1 구조 · natoora.com (제철 농산물 직거래 — 같은 업종) + 텍스트 #121212 · Helvetica Now Text 사실상 1패밀리 + 본문 16px(224회) · 시각 헤드라인 72px(6회) → 최대÷본문 4.5배 + radius 0px 975회 지배 · 강조색 #3860BE 가 단 1회 + 컨테이너 1313 ↔ 1425 전폭 · 칼럼 427 + 히어로 이미지 1313×900 이 첫 화면 대부분, 같은 크기가 세로로 반복 + → 가져온 원리: **품목마다 어디서 누가 길렀는지를 문장으로 붙인다.** + 제철 커머스의 설득은 사진이 아니라 출처의 구체성이다 + → 안 가져온 것: h1 이 16px 이고 시각 헤드라인(72px)과 분리돼 있다 + (스크린리더가 읽는 제목과 눈에 보이는 제목이 다르다 — 접근성 실패). + 전체 대문자 내비도 가져오지 않는다 + + R2 톤 · phillips.com (미술품 경매 하우스 — 다른 업종) + 텍스트 순흑 #000000(141회) · 보조 #757777 · 강조색 사실상 없음 + h1 28px/400 — 아주 작다 · 본문 14px(100회) · 최대÷본문 1.7배 + radius 0px 685회 지배 · 컨테이너 1425 ↔ 1329 ↔ 443 + → 가져온 원리: **경매는 날짜와 상태가 제목보다 앞에 선다.** + 무엇이 언제 열리고 지금 어느 단계인지가 먼저 나오고, 화면은 무채색으로 + 물러나 출품작이 색을 갖게 한다 + → 안 가져온 것: 본문 14px(한글 하한 16px 위반), 최대÷본문 1.7배(평평한 스케일), + 폰트 3패밀리 + + R3 디테일 · 제철 12개월 띠 + 품목이 언제 나오는지를 12칸 격자 한 줄로 보여준다. natoora 의 'IN SEASON NOW' + 를 문구가 아니라 구조로 승격한 것이다. CSS 그리드로 짓고, 이번 달 표시에만 + 인라인 스크립트를 쓴다(달이 바뀌면 저절로 따라가야 하므로) + + ── 사실 근거 (카피에 쓰는 용어는 전부 아래에서 왔다) + · 가락시장 과일 경매는 새벽 2시~아침 8시. 포도·복숭아·감귤·자두·딸기·멜론·참외· + 토마토·수박·단감은 하절기 02:00 / 동절기 02:30, 사과·배·유자·떫은감·수입과실은 08:30 + — garak.co.kr 시장경매시간안내 + · 유통 경로: 출하자 → 도매법인(경매회사) 수집 → 경매 → 중도매인 낙찰 → 소매 → 소비자 + · 경락가격 = 낙찰가격. 농산물유통정보(KAMIS)가 가락시장 경락가격을 공개한다 + · 등급은 특·상·보통. **당도 표시는 특·상 등급에만 허용**된다 + · 당도 표시 가능 품목은 사과·배·복숭아·포도·감귤·금감·단감·자두 8품목과 + 수박·조롱수박·참외·멜론·딸기 5품목 — 농산물 표준규격 + · Brix = 용액 100g 에 든 당의 g 수 + · 선과기는 드럼식(지름 기준)과 중량식(무게 기준) + · 거래단위 예: 사과 2·5·7.5·10kg, 포도 2·3·4·5kg + ========================================================================== */ + +:root { + /* ── 타이포그래피 — 5단계 + 스케일 밖 디스플레이 1개 ── */ + --step--1: 0.8125rem; /* 13px — 표 머리·단위·메타 */ + --step-0: 1rem; /* 16px — 본문 (한글 하한) */ + --step-1: 1.1875rem; /* 19px — 리드 */ + --step-2: clamp(1.375rem, 1.15rem + 1.1vw, 1.9375rem); /* 22 → 31px */ + --step-3: clamp(1.75rem, 1.3rem + 2.3vw, 2.75rem); /* 28 → 44px */ + --display: clamp(2.375rem, 1.5rem + 4.3vw, 4.5rem); /* 38 → 72px */ + /* 최대 ÷ 본문 = 72 ÷ 16 = 4.5배. R1(natoora) 과 같은 비율이다 */ + + --leading-tight: 1.18; + --leading-normal: 1.7; + --leading-loose: 1.85; + --measure: 33em; + + --font-body: Pretendard, "Pretendard Variable", -apple-system, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, monospace; + + /* ── 색 · 종이(라이트) ── */ + --surface: #fbfaf7; + --surface-2: #f2f0ea; + --surface-3: #e7e4dc; + --line: #ddd9d0; + --line-strong: #b8b3a8; + --ink: #16181a; /* 17.2:1 */ + --ink-muted: #55595d; /* 6.87:1 */ + --ink-faint: #696e73; /* 4.96:1 */ + --accent: #1f3f6b; /* 10.08:1 · 흰 글자 얹으면 10.5:1 */ + --accent-hover: #142b4b; + --accent-ink: #ffffff; + --accent-wash: #e6ebf2; + + /* ── 색 · 새벽(다크 반전 밴드) — 위 값들의 규칙적 반전 ── */ + --dark-surface: #16181a; + --dark-surface-2: #1d2023; + --dark-surface-3: #24282c; + --dark-line: #2f3438; + --dark-line-strong: #454b50; + --dark-ink: #f3f1ec; /* 15.9:1 */ + --dark-ink-muted: #a6a9ac; /* 7.63:1 */ + --dark-ink-faint: #878b8f; /* 5.22:1 */ + --dark-accent: #7fa8d9; /* 7.31:1 — 남색은 어두운 바탕에서 안 보이므로 밝은 쪽 쌍 */ + --dark-accent-hover: #9dbfe6; + --dark-accent-ink: #0b1520; + --dark-accent-wash: #1b2c3f; + + /* ── 형태 — R1·R2 둘 다 radius 0 이 지배한다(975회 / 685회) ── */ + --radius: 0; + + /* ── 간격 — 저장소 SSOT 표준 ── */ + --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); + + --container: 1120px; + --pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem); + + /* ── 모션 ── */ + --dur-quick: 150ms; + --dur-normal: 380ms; + --ease: cubic-bezier(0.2, 0, 0, 1); +} diff --git a/apps/site/public/work/twentyoz-corp/index.html b/apps/site/public/work/twentyoz-corp/index.html new file mode 100644 index 0000000..c1da00c --- /dev/null +++ b/apps/site/public/work/twentyoz-corp/index.html @@ -0,0 +1,643 @@ + + + + + + 트웬티온스 — 네 개의 현장에서 돌아가는 소프트웨어 + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+

+ 네 개의 현장에서
+ 돌아가고 있다 +

+

+ 트웬티온스는 2020년 1월에 세운 가상현실·산업용 IoT·이커머스 솔루션 회사입니다. + 어느 현장의 이야기부터 보시겠습니까. +

+ +
+
+ 현장 고르기 + + + + + + +
+ + + + +
+ +
+
+

+ 헤드셋을 쓴 학생이 실습을 하고, 강사는 그 장면을 밖에서 본다. + 캐릭터의 몸짓은 웹캠 한 대로 잡는다. +

+
+
+
제품
+
VIVEN — 몰입형 VR 교육·훈련 플랫폼
+
+
+
기술
+
Unity3D × WebXR × MediaPipe
+
+
+
특징
+
PC VMC · Mobile VMC 모듈로 캐릭터 실시간 모션캡처
+
+
+
납품 사례
+
미래기술학교 운영 (2024) · 메타버스 기반 교육 플랫폼
+
+
+
성숙도
+
TRL 9 · 2021 – 현재
+
+
+
+ +
+

+ 설비 여섯 종류가 각자의 프로토콜로 떠드는 것을 한 엔진이 받아 + 같은 시간축에 올린다. 그 위에서 운전이 자동으로 돈다. +

+
+
+
제품
+
+ XD Hub — 산업용 IoT 데이터 통합 엔진
+ XD Works — DCIM · FEMS 통합 운영 애플리케이션 +
+
+
+
기술
+
+ Java Spring Boot × Netty × TimescaleDB
+ Spring Boot × NestJS × TypeScript +
+
+
+
특징
+
6종 산업 프로토콜을 단일 엔진으로 수집·전처리·전송
+
+
+
납품 사례
+
+ 과천 데이터 센터 · LS 일렉 청주 FEMS · 제주 폐기물 EMS · + 비금도 EMS · 한유 AFIMS (모두 2024) +
+
+
+
성숙도
+
TRL 8 · 2022 – 현재
+
+
+
+ +
+

+ 해외 AI 서비스를 쓰고 싶은데 법인카드도 세금계산서도 안 되는 기관이 있다. + 그 사이에 들어가 원화 결제와 증빙을 대신한다. +

+
+
+
제품
+
EduBill — AI SaaS 구독 대행 플랫폼
+
+
+
기술
+
Next.js × TypeScript × Supabase PostgreSQL
+
+
+
쓰는 곳
+
공공기관 · 대학 · 초중고 · 기업 HRD
+
+
+
납품 사례
+
KDIS EAI (2024) · EduBill (2024)
+
+
+
성숙도
+
TRL 8 · 2024 – 현재
+
+
+
+ +
+

+ 현장에서는 사무실 화면이 안 열린다. 장갑 낀 손과 밝은 햇빛, + 끊기는 통신을 전제로 다시 만든다. +

+
+
+
성격
+
고객사 설비·공정에 맞춰 짓는 현장 애플리케이션
+
+
+
납품 사례
+
+ 브래키츠 BSLIM · 영진EL 2.0 스마트 APP · LS 전선 부스덕트 (모두 2024) +
+
+
+
기술
+
Spring Boot / PostgreSQL / WebSocket · Vue3
+
+
+
가장 무거운 것
+
+ ACS (Auto Control System) — Unity Engine · RFS 실시간 유체 시뮬레이션 · + Transformer 기반 AI (2024) +
+
+
+
+
+
+
+
+
+ + +
+
+
+

여섯 해 동안 쌓인 것

+
+
+

+ 서로 다른 산업의 프로젝트 경험과 새 기술을 붙여, 고객이 다음 성장 동력을 찾도록 + 돕는 것이 하는 일입니다. 에너지, 건물 자동화, 헬스케어처럼 틀리면 곤란한 영역이 + 주 무대입니다. +

+
+
+
수행 프로젝트
+
159건
+
+
+
고객사
+
42곳
+
+
+
구성원
+
40명
+
+
+
특허
+
4건
+
+
+

+ 위 넷은 회사가 공개한 값입니다. 아래 한 일 목록에는 + 그중 공개 가능한 49건의 일부를 이름·연도·기술 스택과 함께 적었습니다. +

+
+
+
+ + +
+
+

자체 제품

+

팔러 다니는 것이 아니라 계속 고치는 것

+

+ 네 개 모두 특정 고객의 요구에서 시작해 제품이 됐습니다. 지금도 각자의 개발 주기가 + 돌아가고 있습니다. +

+ +
    +
  1. +

    V–01

    +
    +

    VIVEN 메타버스 플랫폼

    +

    + 기업과 교육기관을 위한 몰입형 VR 교육·훈련 플랫폼. PC VMC 와 Mobile VMC + 모듈로 캐릭터 실시간 모션캡처를 지원합니다. +

    +

    Unity3D × WebXR × MediaPipe

    +
    +
    +
    규모
    Enterprise
    +
    주기
    2021 –
    +
    성숙도
    TRL 9
    +
    +
  2. +
  3. +

    V–02

    +
    +

    EduBill 교육 SaaS

    +

    + 공공기관·대학·초중고·기업 HRD 를 위한 AI SaaS 구독 대행 플랫폼. + 해외 AI 서비스를 원화 결제와 세금계산서 발행으로 들여옵니다. +

    +

    Next.js × TypeScript × Supabase PostgreSQL

    +
    +
    +
    규모
    B2G · 교육
    +
    주기
    2024 –
    +
    성숙도
    TRL 8
    +
    +
  4. +
  5. +

    V–03

    +
    +

    XD Hub 산업용 IoT

    +

    + 데이터센터·제조·에너지·빌딩 도메인을 위한 산업용 IoT 데이터 통합 엔진. + 6종 산업 프로토콜을 단일 엔진으로 수집·전처리·전송합니다. +

    +

    Java Spring Boot × Netty × TimescaleDB

    +
    +
    +
    규모
    설비
    +
    주기
    2022 –
    +
    성숙도
    TRL 8
    +
    +
  6. +
  7. +

    V–04

    +
    +

    XD Works DCIM · FEMS

    +

    + XD Hub 가 모은 데이터를 받아 데이터센터 코로케이션, 빌딩, 공장, 발전소의 + 운영을 자동화하는 통합 운영 애플리케이션입니다. +

    +

    Spring Boot × NestJS × TypeScript

    +
    +
    +
    규모
    설비 · B2B
    +
    주기
    2023 –
    +
    성숙도
    TRL 8
    +
    +
  8. +
+
+
+ + +
+
+

한 일

+

+ 공개 가능한 49건 가운데 12건입니다. 회사 소개서에 쓰는 문장 대신 이름과 연도, + 그리고 무엇으로 만들었는지를 적습니다. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
트웬티온스 수행 프로젝트 12건 — 이름, 연도, 사용 기술
번호프로젝트연도기술
001EduBill2024Next.js · TypeScript · Supabase PostgreSQL
002과천 데이터 센터2024Vue3 · Spring · PostgreSQL
003제주 폐기물 EMS2024Vue3 · Spring · PostgreSQL
004KDIS EAI2024Vue3 · Spring · PostgreSQL
005LS 일렉 청주 FEMS2024Vue3 · Spring · PostgreSQL
006LS 전선 부스덕트2024Vue3 · Spring · PostgreSQL
007브래키츠 BSLIM2024Spring Boot · PostgreSQL · WebSocket
008영진EL 2.0 스마트 APP2024Vue3
009미래기술학교 운영2024Unity Engine · Blender · Unreal Engine
010한유 AFIMS2024Vue3 · Spring · PostgreSQL
011비금도 EMS2024Vue3 · Spring · Java
012ACS (Auto Control System)2024Unity Engine · RFS 실시간 유체 시뮬레이션 · Transformer 기반 AI
+

+ 마지막 줄이 지금 가장 멀리 가 있는 일입니다. 데이터센터의 공기 흐름을 GPU 위에서 + 실시간으로 풀어 제어를 검증합니다. +

+
+
+ + +
+
+

함께 일한 곳

+

+ 대학과 대기업 설비 부문이 섞여 있습니다. 어느 쪽이든 검수 기준이 까다로운 곳입니다. +

+
    +
  • LS SAUTER
  • +
  • 경희대학교
  • +
  • SAEKI P&C
  • +
  • KDI School
  • +
  • COSS 사업단
  • +
  • 클라우드브릭
  • +
  • 한유
  • +
  • 게임랩
  • +
  • METANET PLATFORM
  • +
  • 안랩
  • +
  • 강원대학교
  • +
  • 중앙대학교
  • +
  • 건국대학교
  • +
  • 한화솔루션
  • +
  • 가비아
  • +
  • GS칼텍스
  • +
  • SK하이닉스
  • +
  • LG디스플레이
  • +
  • 대구한의대학교
  • +
+
+
+ + +
+
+

최근 기록

+

밖에서 확인된 것

+ +
    +
  1. +

    2026.02.24

    +
    +

    경기우수벤처기업 표창 — 경기지방중소벤처기업청장상

    +

    기술·경영·혁신 능력이 뛰어나고 사회공헌도가 높은 벤처기업으로 인정

    +
    +

    수상 · 인증

    +
  2. +
  3. +

    2026.01.20

    +
    +

    2025 경기도 일자리 우수기업 인증

    +

    고용증가율 10% 이상, 근무환경과 복리후생 제도 개선으로 인정

    +
    +

    수상 · 인증

    +
  4. +
  5. +

    2025.11.03

    +
    +

    건국대, 시리아·요르단 교육기관 대표단 방문

    +

    트웬티온스와 함께 개발한 메타버스 기반 교육 플랫폼 시연

    +
    +

    교육 · 협력

    +
  6. +
  7. +

    2025.10.31

    +
    +

    중동 대표단, X-Space 에서 K-에듀테크 체험

    +

    메타버스 기반 교육 플랫폼 체험과 협력 논의

    +
    +

    교육 · 협력

    +
  8. +
  9. +

    2025.08.19

    +
    +

    과기정통부 2025 가상융합대학원 성과공유회

    +

    서강대 개최. AI 시대 인재양성 비전 논의에 참여

    +
    +

    행사

    +
  10. +
  11. +

    2025.07.04

    +
    +

    벤처기업 혁신성장유형 재인증

    +

    기술 중심의 비전과 디지털 혁신 노력을 인정받음

    +
    +

    수상 · 인증

    +
  12. +
+
+
+ + +
+
+
+

지금은 정규 공고가 닫혀 있습니다

+
+
+

+ 다섯 개 직무가 모두 마감 상태입니다. 닫힌 것을 열려 있는 것처럼 적어 두면 + 지원하는 쪽의 시간을 뺏게 되므로, 상태를 그대로 적습니다. +

+
    +
  • + 시니어 소프트웨어 엔지니어 + 산업용 소프트웨어 · C++ / C# / Python · PLC · HMI · SCADA + 마감 +
  • +
  • + DevOps 엔지니어 + 컨테이너 · 오케스트레이션 · CI/CD · IaC + 마감 +
  • +
  • + 프로덕트 매니저 + IT 서비스 기획·운영 3년 이상 · 데이터 기반 의사결정 + 마감 +
  • +
  • + 서비스 기획자 + UI/UX 기획 · 와이어프레임 · 애자일 / 스크럼 + 마감 +
  • +
  • + UI/UX 디자이너 + Figma · 웹 / 모바일 포트폴리오 · 사용자 중심 설계 + 마감 +
  • +
+

+ 공고와 무관하게 이야기를 시작할 수는 있습니다. + 채용 문의 보내기 +

+
+
+
+ + +
+
+
+

연락

+
+
+
+
+
주소
+
경기도 화성시 동탄첨단산업1로 27, A909호
(영천동, 금강펜테리움 IX 타워)
+
+
+
전화
+
070-4353-1190
+
+
+
메일
+
hello@twentyoz.kr
+
+
+
설립
+
2020년 1월
+
+
+ + 프로젝트 문의 + +
+
+
+
+ + + + diff --git a/apps/site/public/work/twentyoz-corp/og.png b/apps/site/public/work/twentyoz-corp/og.png new file mode 100644 index 0000000..f351879 Binary files /dev/null and b/apps/site/public/work/twentyoz-corp/og.png differ diff --git a/apps/site/public/work/twentyoz-corp/styles/main.css b/apps/site/public/work/twentyoz-corp/styles/main.css new file mode 100644 index 0000000..a7673cc --- /dev/null +++ b/apps/site/public/work/twentyoz-corp/styles/main.css @@ -0,0 +1,842 @@ +@import "./tokens.css"; + +/* ========================================================================== + 트웬티온스 — 메인 스타일시트 + + 현행 사이트는 순흑 한 겹이라 무엇이 중요한지 알 수 없었다. 종이를 기본으로 + 뒤집고, 반전(잉크) 밴드는 '기계'를 다루는 두 섹션(제품·기록)에만 쓴다. + 반전은 아래 두 클래스가 토큰을 재정의하는 규칙이다 — 섹션마다 기분대로가 아니다. + ========================================================================== */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + scroll-behavior: smooth; + overflow-x: hidden; + background: var(--surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-height: 100dvh; + font-family: var(--font-body); + font-size: var(--step-0); + line-height: var(--leading-normal); + background: var(--surface); + color: var(--ink); + word-break: keep-all; + overflow-wrap: break-word; + overflow-x: hidden; +} + +img, +svg { + display: block; + max-width: 100%; +} + +a { + color: inherit; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +fieldset { + border: 0; +} + +h1, +h2, +h3 { + font-weight: 600; + line-height: var(--leading-tight); + letter-spacing: -0.015em; /* 현행 사이트의 −0.06em 은 한글 상한의 세 배였다 */ + text-wrap: balance; +} + +.mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.wrap { + width: min(100% - 2 * var(--pad-inline), var(--container)); + margin-inline: auto; +} + +/* ── 두 체제 ────────────────────────────────────────────────────── */ +.band-light { + --band-surface: var(--surface); + --band-surface-2: var(--surface-2); + --band-surface-3: var(--surface-3); + --band-line: var(--line); + --band-line-strong: var(--line-strong); + --band-ink: var(--ink); + --band-ink-muted: var(--ink-muted); + --band-ink-faint: var(--ink-faint); + --band-accent: var(--accent); + --band-accent-hover: var(--accent-hover); + --band-accent-ink: var(--accent-ink); + --band-accent-wash: var(--accent-wash); +} + +.band-dark { + --band-surface: var(--dark-surface); + --band-surface-2: var(--dark-surface-2); + --band-surface-3: var(--dark-surface-3); + --band-line: var(--dark-line); + --band-line-strong: var(--dark-line-strong); + --band-ink: var(--dark-ink); + --band-ink-muted: var(--dark-ink-muted); + --band-ink-faint: var(--dark-ink-faint); + --band-accent: var(--dark-accent); + --band-accent-hover: var(--dark-accent-hover); + --band-accent-ink: var(--dark-accent-ink); + --band-accent-wash: var(--dark-accent-wash); +} + +.band { + position: relative; + background: var(--band-surface); + color: var(--band-ink); +} + +.band > .wrap { + padding-block: var(--space-8); +} + +/* ── 접근성 ─────────────────────────────────────────────────────── */ +.skip-link { + position: fixed; + top: var(--space-3); + left: var(--space-3); + z-index: 200; + padding: var(--space-2) var(--space-3); + background: var(--ink); + color: var(--surface); + text-decoration: none; + transform: translateY(-250%); + transition: transform var(--dur-quick) var(--ease); +} + +.skip-link:focus-visible { + transform: translateY(0); +} + +:where(a, button, input, label, [tabindex]):focus-visible { + outline: 2px solid var(--band-accent, var(--accent)); + outline-offset: 3px; +} + +/* ── 헤더 ───────────────────────────────────────────────────────── */ +.site-head { + position: sticky; + top: 0; + z-index: 100; + background: var(--surface); + border-bottom: 1px solid var(--line-strong); +} + +.head-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + min-height: 56px; + min-width: 0; +} + +.wordmark { + display: inline-flex; + align-items: center; + gap: var(--space-2); + font-weight: 600; + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} + +.wordmark-badge { + display: inline-grid; + place-items: center; + width: 26px; + height: 26px; + background: var(--ink); + color: var(--surface); + font-size: 0.75rem; + font-weight: 700; +} + +.nav { + display: flex; + gap: clamp(var(--space-3), 2.4vw, var(--space-5)); + min-width: 0; +} + +.nav a { + color: var(--ink-muted); + font-size: var(--step--1); + text-decoration: none; + white-space: nowrap; + padding-block: var(--space-2); + transition: color var(--dur-quick) var(--ease); +} + +.nav a:hover { + color: var(--accent); +} + +.head-meta { + font-size: var(--step--1); + color: var(--ink-faint); + white-space: nowrap; + flex-shrink: 0; +} + +/* ── 히어로 ─────────────────────────────────────────────────────── */ +.hero h1 { + font-size: var(--display); + max-width: 16ch; +} + +.hero-lead { + margin-top: var(--space-4); + max-width: var(--measure); + font-size: var(--step-1); + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +/* ── 현장 선택기 — JS 0바이트. 라디오 그룹이라 좌우 화살표가 그대로 먹는다 ── */ +.fields { + margin-top: var(--space-6); +} + +.field-radio { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.field-tabs { + display: grid; + border-top: 1px solid var(--line-strong); + border-bottom: 1px solid var(--line); +} + +@media (min-width: 720px) { + .field-tabs { + grid-template-columns: repeat(4, 1fr); + } +} + +.field-tabs label { + display: block; + padding: var(--space-3) var(--space-3) var(--space-3) 0; + border-bottom: 1px solid var(--line); + cursor: pointer; + color: var(--ink-muted); + transition: color var(--dur-quick) var(--ease); +} + +@media (min-width: 720px) { + .field-tabs label { + border-bottom: 0; + border-right: 1px solid var(--line); + padding-right: var(--space-4); + padding-left: var(--space-3); + } + .field-tabs label:first-of-type { + padding-left: 0; + } + .field-tabs label:last-of-type { + border-right: 0; + } +} + +.field-tabs label:hover { + color: var(--ink); +} + +.tab-num { + display: block; + font-size: var(--step--1); + color: var(--ink-faint); +} + +.tab-name { + display: block; + margin-top: var(--space-1); + font-weight: 600; + font-size: var(--step-0); +} + +/* 선택 상태 — 라디오가 진실의 원본이다 */ +#field-edu:checked ~ .field-tabs label[for="field-edu"], +#field-dc:checked ~ .field-tabs label[for="field-dc"], +#field-gov:checked ~ .field-tabs label[for="field-gov"], +#field-plant:checked ~ .field-tabs label[for="field-plant"] { + color: var(--ink); + box-shadow: inset 0 3px 0 0 var(--accent); +} + +#field-edu:checked ~ .field-tabs label[for="field-edu"] .tab-num, +#field-dc:checked ~ .field-tabs label[for="field-dc"] .tab-num, +#field-gov:checked ~ .field-tabs label[for="field-gov"] .tab-num, +#field-plant:checked ~ .field-tabs label[for="field-plant"] .tab-num { + color: var(--accent); +} + +.field-radio:focus-visible ~ .field-tabs label[for="field-edu"] { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.field-panel { + display: none; + padding-top: var(--space-5); +} + +#field-edu:checked ~ .field-panels [data-panel="edu"], +#field-dc:checked ~ .field-panels [data-panel="dc"], +#field-gov:checked ~ .field-panels [data-panel="gov"], +#field-plant:checked ~ .field-panels [data-panel="plant"] { + display: block; +} + +.panel-what { + font-size: var(--step-1); + max-width: var(--measure); + line-height: var(--leading-loose); +} + +.panel-facts { + margin-top: var(--space-5); + border-top: 1px solid var(--line); +} + +.panel-facts > div { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--line); +} + +@media (min-width: 640px) { + .panel-facts > div { + grid-template-columns: 8rem 1fr; + } +} + +.panel-facts dt { + font-size: var(--step--1); + color: var(--ink-faint); +} + +.panel-facts dd { + color: var(--ink-muted); + line-height: var(--leading-loose); +} + +.panel-facts strong { + color: var(--ink); + font-weight: 600; +} + +/* ── 섹션 공통 ──────────────────────────────────────────────────── */ +.eyebrow { + font-family: var(--font-mono); + font-size: var(--step--1); + color: var(--band-accent); + margin-bottom: var(--space-3); +} + +.band h2 { + font-size: var(--step-3); + max-width: 20ch; +} + +.section-lead, +.lead { + margin-top: var(--space-4); + max-width: var(--measure); + color: var(--band-ink-muted); + font-size: var(--step-1); + line-height: var(--leading-loose); +} + +/* ── 비대칭 2열 ─────────────────────────────────────────────────── */ +.split { + display: grid; + gap: var(--space-5); +} + +@media (min-width: 900px) { + .split { + grid-template-columns: 4fr 8fr; + gap: var(--space-7); + align-items: start; + } +} + +/* ── 수치 ───────────────────────────────────────────────────────── */ +.counts { + margin-top: var(--space-6); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); + border-top: 1px solid var(--band-line-strong); + border-left: 1px solid var(--band-line); +} + +.counts > div { + padding: var(--space-4); + border-right: 1px solid var(--band-line); + border-bottom: 1px solid var(--band-line); +} + +.counts dt { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.counts dd { + margin-top: var(--space-2); + font-size: var(--step-3); + line-height: 1.05; + letter-spacing: -0.02em; +} + +.counts dd span { + font-size: var(--step-0); + color: var(--band-ink-muted); + margin-left: 2px; +} + +.counts-note { + margin-top: var(--space-4); + font-size: var(--step--1); + color: var(--band-ink-faint); + line-height: var(--leading-loose); + max-width: var(--measure); +} + +.counts-note a { + color: var(--band-accent); +} + +/* ── 제품 목록 ──────────────────────────────────────────────────── */ +.product-list { + margin-top: var(--space-6); + list-style: none; + border-top: 1px solid var(--band-line-strong); +} + +.product-list li { + display: grid; + gap: var(--space-3); + padding-block: var(--space-5); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 900px) { + .product-list li { + grid-template-columns: 4rem 1fr 11rem; + gap: var(--space-5); + align-items: start; + } +} + +.prod-id { + font-size: var(--step--1); + color: var(--band-accent); +} + +.prod-body h3 { + font-size: var(--step-2); + display: flex; + align-items: baseline; + gap: var(--space-3); + flex-wrap: wrap; +} + +.prod-kind { + font-size: var(--step--1); + font-weight: 400; + color: var(--band-ink-faint); +} + +.prod-body p { + margin-top: var(--space-3); + color: var(--band-ink-muted); + max-width: 46ch; + line-height: var(--leading-loose); +} + +.prod-stack { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.prod-meta { + font-size: var(--step--1); + display: grid; + gap: var(--space-2); + align-content: start; +} + +.prod-meta > div { + display: grid; + grid-template-columns: 4rem 1fr; + gap: var(--space-2); +} + +.prod-meta dt { + color: var(--band-ink-faint); +} + +.prod-meta dd { + color: var(--band-ink-muted); +} + +/* ── 원장 표 ────────────────────────────────────────────────────── */ +.ledger { + margin-top: var(--space-6); + width: 100%; + border-collapse: collapse; + font-size: var(--step--1); + text-align: left; +} + +.ledger th, +.ledger td { + padding: var(--space-3) var(--space-3) var(--space-3) 0; + border-bottom: 1px solid var(--band-line); + vertical-align: top; +} + +.ledger thead th { + border-bottom: 1px solid var(--band-line-strong); + color: var(--band-ink-faint); + font-weight: 400; +} + +.ledger tbody th { + font-weight: 600; + font-size: var(--step-0); +} + +.ledger td { + color: var(--band-ink-muted); +} + +.col-id { + width: 3.5rem; +} + +.col-year { + width: 4rem; +} + +.row-mark th, +.row-mark td { + border-bottom: 0; +} + +.row-mark th { + color: var(--band-accent); +} + +.ledger-note { + margin-top: var(--space-4); + padding-top: var(--space-3); + border-top: 2px solid var(--band-accent); + font-size: var(--step--1); + color: var(--band-ink-muted); + max-width: var(--measure); + line-height: var(--leading-loose); +} + +/* ── 파트너 ─────────────────────────────────────────────────────── */ +.section-partners { + background: var(--surface-2); +} + +.partners { + margin-top: var(--space-6); + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 190px), 1fr)); + border-top: 1px solid var(--band-line-strong); +} + +.partners li { + padding: var(--space-3) var(--space-3) var(--space-3) 0; + border-bottom: 1px solid var(--band-line); + color: var(--band-ink-muted); + font-size: var(--step--1); +} + +/* ── 기록 ───────────────────────────────────────────────────────── */ +.record-list { + margin-top: var(--space-6); + list-style: none; + border-top: 1px solid var(--band-line-strong); +} + +.record-list li { + display: grid; + gap: var(--space-2); + padding-block: var(--space-4); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 860px) { + .record-list li { + grid-template-columns: 7rem 1fr 7rem; + gap: var(--space-4); + align-items: baseline; + } +} + +.rec-date { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.record-list h3 { + font-size: var(--step-1); +} + +.record-list div p { + margin-top: var(--space-2); + color: var(--band-ink-muted); + font-size: var(--step--1); + line-height: var(--leading-loose); +} + +.rec-kind { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +@media (min-width: 860px) { + .rec-kind { + text-align: right; + } +} + +/* ── 채용 ───────────────────────────────────────────────────────── */ +.jobs { + margin-top: var(--space-5); + list-style: none; + border-top: 1px solid var(--band-line-strong); +} + +.jobs li { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 720px) { + .jobs li { + grid-template-columns: 14rem 1fr 4rem; + align-items: baseline; + } +} + +.job-name { + font-weight: 600; +} + +.job-hint { + font-size: var(--step--1); + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +.job-state { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +@media (min-width: 720px) { + .job-state { + text-align: right; + } +} + +.jobs-open { + margin-top: var(--space-4); + font-size: var(--step--1); + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +.jobs-open a { + color: var(--band-accent); +} + +/* ── 연락 ───────────────────────────────────────────────────────── */ +.contact-rows { + border-top: 1px solid var(--band-line-strong); +} + +.contact-rows > div { + display: grid; + gap: var(--space-1) var(--space-4); + padding-block: var(--space-3); + border-bottom: 1px solid var(--band-line); +} + +@media (min-width: 640px) { + .contact-rows > div { + grid-template-columns: 6rem 1fr; + } +} + +.contact-rows dt { + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +.contact-rows dd { + color: var(--band-ink-muted); + line-height: var(--leading-loose); +} + +.contact-rows a { + color: var(--band-accent); +} + +.btn { + display: inline-block; + margin-top: var(--space-5); + padding: var(--space-3) var(--space-5); + background: var(--band-accent); + color: var(--band-accent-ink); + font-weight: 600; + text-decoration: none; + white-space: nowrap; + transition: background-color var(--dur-quick) var(--ease); +} + +.btn:hover { + background: var(--band-accent-hover); +} + +/* ── 푸터 ───────────────────────────────────────────────────────── */ +.foot-inner { + display: grid; + gap: var(--space-2); +} + +.foot-name { + font-weight: 600; +} + +.foot-line { + color: var(--band-ink-muted); + font-size: var(--step--1); +} + +.foot-fine { + margin-top: var(--space-4); + padding-top: var(--space-3); + border-top: 1px solid var(--band-line); + font-size: var(--step--1); + color: var(--band-ink-faint); +} + +/* ── 좁은 화면 ──────────────────────────────────────────────────── */ +@media (max-width: 640px) { + .head-inner { + flex-wrap: wrap; + padding-block: var(--space-2); + row-gap: var(--space-2); + } + + .nav { + order: 3; + width: 100%; + justify-content: space-between; + gap: var(--space-2); + border-top: 1px solid var(--line); + padding-top: var(--space-2); + } + + .head-meta { + display: none; + } + + .counts { + grid-template-columns: repeat(2, 1fr); + } +} + +/* ── 모션을 줄이는 설정 ─────────────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +/* ── 인쇄 — 현장 선택기는 종이에서 의미가 없으므로 네 패널을 모두 편다 ── */ +@media print { + .site-head, + .skip-link, + .btn { + display: none; + } + + .band { + background: #fff; + color: #000; + } + + .field-panel { + display: block !important; + break-inside: avoid; + } + + .field-tabs { + display: none; + } +} diff --git a/apps/site/public/work/twentyoz-corp/styles/tokens.css b/apps/site/public/work/twentyoz-corp/styles/tokens.css new file mode 100644 index 0000000..0a64c9a --- /dev/null +++ b/apps/site/public/work/twentyoz-corp/styles/tokens.css @@ -0,0 +1,117 @@ +/* ========================================================================== + 트웬티온스 (TWENTYOZ) — 회사 홈페이지 리디자인 · 디자인 토큰 SSOT + + 컨셉 : 네 개의 현장 — 회사 소개가 아니라, 같은 기술이 서로 다른 현장에서 + 무엇을 하고 있는지의 목록이 이 사이트의 본체다 + 프리셋: field-record (신규 정의. swiss-minimal 을 뼈대로 삼되 본문을 키웠다) + 강조색: 신호 적색 1색. 라이트/다크는 규칙으로 정의된 한 쌍이므로 1종으로 센다 + + ── 0단계 감사 · 현행 twentyoz.kr 실측 (2026-08-24, 1249px computed style) + 유지할 자산 + · radius 0px 742회 — 형태 어휘가 이미 통일돼 있다. 그대로 가져간다 + · 무채색 기조와 모노스페이스(JetBrains Mono 356회) — 정밀함의 어휘는 맞다 + · 실제 프로젝트 49건 · 파트너 19곳 · 제품 4종의 기술 스택 표기 + 고쳐야 할 것 + · 대비 실패 **172개 요소** — rgb(102,102,102) 가 배경 rgb(2,2,2) 위에서 3.61:1, + 그것도 9~11px 로 284곳에 쓰였다. WCAG AA 미달이고 읽기 자체가 어렵다 + · font-family 가 Inter 뿐이라 **한글 폰트가 지정돼 있지 않다**. + 한국 회사의 한국어 사이트에서 한글이 시스템 기본값으로 떨어진다 + · h1 174.86px 에 letter-spacing −10.49px = **−0.06em**. 한글 상한(−0.02em)의 세 배다 + · 배경 #020202 — 순흑에 가깝다 + · 'Digital Genetics' 은유가 전면에 있다(CULTIVATING · DROPS · Sativa/Indica · + Germination · Wholesale Desk · ACQUIRE GENETICS · Open Specimens). + SK하이닉스·LG디스플레이·한화솔루션·GS칼텍스에 산업용 IoT 를 파는 회사의 + 어휘로는 사업을 가린다. 은유를 걷어내고 한 일을 앞에 둔다 + + ── 레퍼런스 실측 (2026-08-24, 1440px) + R1 구조 · samsara.com (커넥티드 오퍼레이션 / 산업 IoT — 같은 업종) + 배경 #FFFFFF · FK Grotesk + FK Grotesk Mono 2패밀리 + h1 72px/700 · 본문 16px(404회) · 최대 80px ÷ 본문 = 5.0배 + radius 0px 2399회 지배 · 강조색 #FEAE0F 이 버튼 1곳 + 컨테이너 1425 전폭 ↔ 1345 ↔ 427 칼럼 · 섹션 13 + → 가져온 원리: 섹션 제목이 '무엇을 증명하는가'를 직접 말한다 + (Proven where it counts / A track record / Proudly rated #1). + B2B 는 주장이 아니라 기록으로 설득한다 + → 안 가져온 것: 전체 대문자 h1 과 대문자 eyebrow 5종(슬롭 지문 2위), + 순백·순흑, 섹션 13개, 앰버 pill 버튼 + + R2 톤 · lars-mueller-publishers.com (출판사 — 다른 업종) + 텍스트 #414141 (순흑 아님) · NB Akademie Std 한 패밀리의 두 웨이트 + h1 34px/500 · **본문 20px(190회)** · line-height 30px = 1.5 + 최대 ÷ 본문 = 1.7배 · radius 0px 1709회 지배 · 컨테이너 1240px + → 가져온 원리: **본문을 크게 쓰고 제목을 작게 둔다.** 위계를 크기 하나로 + 만들지 않고 여백과 순서로도 만든다. 한 패밀리의 두 웨이트로 절제한다 + → 안 가져온 것: 강조색 #542BFF(보라 계열 — 슬롭 지문), + 최대÷본문 1.7배(평평한 스케일은 안티패턴) + + 두 레퍼런스의 합성: 본문은 R2 쪽으로 키우고(17px), 최대÷본문은 R1 쪽으로 + 벌린다(4.0배). 한쪽만 따르면 평평하거나 본문이 작아진다. + ========================================================================== */ + +:root { + /* ── 타이포그래피 — 5단계 + 스케일 밖 디스플레이 1개 ── */ + --step--1: 0.8125rem; /* 13px — 표 머리·메타 */ + --step-0: 1.0625rem; /* 17px — 본문. R2 의 큰 본문을 한글에 맞춰 옮겼다 */ + --step-1: 1.25rem; /* 20px — 리드 */ + --step-2: clamp(1.375rem, 1.15rem + 1.1vw, 1.9375rem); /* 22 → 31px */ + --step-3: clamp(1.75rem, 1.25rem + 2.5vw, 2.875rem); /* 28 → 46px */ + --display: clamp(2.25rem, 1.3rem + 4.6vw, 4.25rem); /* 36 → 68px */ + /* 최대 ÷ 본문 = 68 ÷ 17 = 4.0배 */ + + --leading-tight: 1.2; + --leading-normal: 1.7; + --leading-loose: 1.85; + --measure: 33em; + + --font-body: Pretendard, "Pretendard Variable", -apple-system, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; + + /* ── 색 · 종이(라이트) ── */ + --surface: #fafaf8; + --surface-2: #f0f0ec; + --surface-3: #e5e5e0; + --line: #dcdcd6; + --line-strong: #b9b9b2; + --ink: #111214; /* 17.9:1 */ + --ink-muted: #54585c; /* 6.97:1 */ + --ink-faint: #686d72; /* 5.03:1 */ + --accent: #c4341f; /* 5.21:1 · 흰 글자 얹으면 5.44:1 */ + --accent-hover: #9e2817; + --accent-ink: #ffffff; + --accent-wash: #f6e7e3; + + /* ── 색 · 잉크(다크 반전 밴드) — 위 값들의 규칙적 반전 ── */ + --dark-surface: #111214; + --dark-surface-2: #191b1e; + --dark-surface-3: #212428; + --dark-line: #2c3034; + --dark-line-strong: #40454a; + --dark-ink: #f2f2f0; /* 16.7:1 */ + --dark-ink-muted: #a2a6a9; /* 7.67:1 */ + --dark-ink-faint: #84898d; /* 5.31:1 */ + --dark-accent: #e8563c; /* 5.17:1 */ + --dark-accent-hover: #f2705a; + --dark-accent-ink: #160906; + --dark-accent-wash: #33170f; + + /* ── 형태 — 현행 사이트가 radius 0 으로 통일돼 있다. 유지할 자산이다 ── */ + --radius: 0; + + /* ── 간격 — 저장소 SSOT 표준 ── */ + --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); + + --container: 1120px; + --pad-inline: clamp(1.25rem, 1rem + 2.5vw, 4rem); + + /* ── 모션 ── */ + --dur-quick: 150ms; + --dur-normal: 380ms; + --ease: cubic-bezier(0.2, 0, 0, 1); +} diff --git a/apps/site/src/assets/shots/work-rfs-cfd-2.png b/apps/site/src/assets/shots/work-rfs-cfd-2.png new file mode 100644 index 0000000..8122a64 Binary files /dev/null and b/apps/site/src/assets/shots/work-rfs-cfd-2.png differ diff --git a/apps/site/src/assets/shots/work-rfs-cfd.png b/apps/site/src/assets/shots/work-rfs-cfd.png new file mode 100644 index 0000000..aab7c2e Binary files /dev/null and b/apps/site/src/assets/shots/work-rfs-cfd.png differ diff --git a/apps/site/src/assets/shots/work-saebyeok-cheonggwa-2.png b/apps/site/src/assets/shots/work-saebyeok-cheonggwa-2.png new file mode 100644 index 0000000..00e1d11 Binary files /dev/null and b/apps/site/src/assets/shots/work-saebyeok-cheonggwa-2.png differ diff --git a/apps/site/src/assets/shots/work-saebyeok-cheonggwa.png b/apps/site/src/assets/shots/work-saebyeok-cheonggwa.png new file mode 100644 index 0000000..fe0b2fc Binary files /dev/null and b/apps/site/src/assets/shots/work-saebyeok-cheonggwa.png differ diff --git a/apps/site/src/assets/shots/work-twentyoz-corp-2.png b/apps/site/src/assets/shots/work-twentyoz-corp-2.png new file mode 100644 index 0000000..43b96ea Binary files /dev/null and b/apps/site/src/assets/shots/work-twentyoz-corp-2.png differ diff --git a/apps/site/src/assets/shots/work-twentyoz-corp.png b/apps/site/src/assets/shots/work-twentyoz-corp.png new file mode 100644 index 0000000..f351879 Binary files /dev/null and b/apps/site/src/assets/shots/work-twentyoz-corp.png differ diff --git a/apps/site/src/data/work.ts b/apps/site/src/data/work.ts index 15b247b..e04409b 100644 --- a/apps/site/src/data/work.ts +++ b/apps/site/src/data/work.ts @@ -40,6 +40,12 @@ import caskArchive from "../assets/shots/work-cask-archive.png"; import caskArchive2 from "../assets/shots/work-cask-archive-2.png"; import materiaInterior from "../assets/shots/work-materia-interior.png"; import materiaInterior2 from "../assets/shots/work-materia-interior-2.png"; +import rfsCfd from "../assets/shots/work-rfs-cfd.png"; +import rfsCfd2 from "../assets/shots/work-rfs-cfd-2.png"; +import twentyozCorp from "../assets/shots/work-twentyoz-corp.png"; +import twentyozCorp2 from "../assets/shots/work-twentyoz-corp-2.png"; +import saebyeokCheonggwa from "../assets/shots/work-saebyeok-cheonggwa.png"; +import saebyeokCheonggwa2 from "../assets/shots/work-saebyeok-cheonggwa-2.png"; export interface WorkItem { slug: string; @@ -321,4 +327,69 @@ export const WORK: WorkItem[] = [ alt: "마테리아 스튜디오 — 트래버틴 벽체 펜트하우스 거실 전경과 천연 마감재 플랫레이", size: "sm", }, + + /* ── 8행: 신규 쇼케이스 3종 (2 + 2 + 2) ── */ + { + slug: "rfs-cfd", + name: "RFS — 실시간 유체 시뮬레이터", + kind: "데이터센터 열관리 GPU 엔진", + concept: "암실과 사양서 — 유동이 있는 곳은 어둡고, 읽는 곳은 밝다", + risk: "히어로에서 브라우저가 실제로 열유동을 풉니다. 슬라이더로 랙 부하와 급기량을 바꾸면 그 자리에서 유동이 달라집니다", + preset: "darkroom-spec", + date: "2026-08", + metrics: [ + "WebGL2 실시간 열유동 (three.js 미사용)", + "라이트/다크 반전을 토큰 규칙으로", + "수치는 전부 개발 저장소 출처", + "정확도 지표는 싣지 않음", + "하드 게이트 14/14", + ], + href: "/work/rfs-cfd/", + shot: rfsCfd, + shotAlt: rfsCfd2, + alt: "RFS — 어두운 전산실 단면에서 서버랙 위로 초록 열기둥이 오르고 주변에 청록 냉기가 순환하는 실시간 유체 화면", + size: "sm", + }, + { + slug: "twentyoz-corp", + name: "트웬티온스 (TWENTYOZ)", + kind: "ICT 기업 홈페이지 리디자인", + concept: "네 개의 현장 — 회사 소개가 아니라, 같은 기술이 어느 현장에서 무엇을 하는지의 목록", + risk: "첫 화면에 회사 이름과 슬로건을 두지 않습니다. 현장을 고르면 제품·기술·납품 사례가 통째로 바뀝니다", + preset: "field-record", + date: "2026-08", + metrics: [ + "현행 사이트 감사: 대비 실패 172개 요소", + "한글 폰트 미지정 → Pretendard 지정", + "현장 전환 JS 0바이트 (라디오 그룹)", + "신호 적색 1색", + "하드 게이트 14/14", + ], + href: "/work/twentyoz-corp/", + shot: twentyozCorp, + shotAlt: twentyozCorp2, + alt: "트웬티온스 — 종이 톤 위에 네 개의 현장 탭이 가로로 놓이고 선택된 현장의 제품과 납품 사례가 아래에 펼쳐진 화면", + size: "sm", + }, + { + slug: "saebyeok-cheonggwa", + name: "새벽청과 (曉靑果)", + kind: "청과 도매 직거래", + concept: "오늘 새벽 두 시의 값 — 값이 매일 바뀐다는 사실을 첫 화면의 주인공으로 삼는다", + risk: "상품 사진 대신 오늘의 시세판이 히어로입니다. 낙찰가와 판매가를 같은 것처럼 적지 않고, 당도를 표시할 수 없는 품목은 '—' 로 둡니다", + preset: "market-board", + date: "2026-08", + metrics: [ + "시세판이 히어로", + "등급·당도·규격 실제 표준규격 근거", + "제철 12개월 띠 · 이번 달 자동 표시", + "새벽 남색 1색 · 사진만 유채색", + "하드 게이트 14/14", + ], + href: "/work/saebyeok-cheonggwa/", + shot: saebyeokCheonggwa, + shotAlt: saebyeokCheonggwa2, + alt: "새벽청과 — 새벽 도매시장 통로 사진 위에 품목·산지·등급·당도·낙찰가가 적힌 오늘의 시세판", + size: "sm", + }, ]; diff --git a/apps/site/tools/capture-new-shots.mjs b/apps/site/tools/capture-new-shots.mjs new file mode 100644 index 0000000..6d6970a --- /dev/null +++ b/apps/site/tools/capture-new-shots.mjs @@ -0,0 +1,85 @@ +// 신규 쇼케이스 3종의 카드 컷 촬영 — 본컷(히어로)과 -2컷(그 페이지의 두 번째 주장). +// WebGL 이 있는 페이지가 있으므로 --disable-gpu 를 쓰지 않고 swiftshader 로 소프트 렌더한다. +// +// 사용: node tools/capture-new-shots.mjs + +import puppeteer from "puppeteer-core"; +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 shots = path.join(root, "src", "assets", "shots"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const CHROME = [ + "C:/Program Files/Google/Chrome/Application/chrome.exe", + "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe", + process.env.CHROME_PATH, +].filter(Boolean).find((p) => fs.existsSync(p)); + +if (!CHROME) { + console.error("크롬을 찾지 못했다."); + process.exit(2); +} + +const JOBS = [ + { + slug: "rfs-cfd", + // 유체가 정상류에 들 때까지 기다린다. 갓 켠 화면을 찍으면 빈 판이 나온다 + settle: 5200, + second: { scrollTo: "#solver", wait: 700 }, + }, + { + slug: "twentyoz-corp", + settle: 700, + // 두 번째 컷은 '한 일' 원장 — 이 사이트의 본체다 + second: { scrollTo: "#ledger", wait: 500 }, + }, + { + slug: "saebyeok-cheonggwa", + settle: 900, + second: { scrollTo: "#season", wait: 500 }, + }, +]; + +const browser = await puppeteer.launch({ + executablePath: CHROME, + headless: "new", + args: [ + "--no-sandbox", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--force-device-scale-factor=1", + ], + defaultViewport: { width: 1440, height: 900 }, +}); + +for (const job of JOBS) { + const page = await browser.newPage(); + await page.setCacheEnabled(false); + const file = path.join(root, "public/work", job.slug, "index.html"); + await page.goto(url.pathToFileURL(file).href, { waitUntil: "networkidle0" }); + await page.evaluate(() => document.fonts.ready); + await sleep(job.settle); + + const main = path.join(shots, `work-${job.slug}.png`); + await page.screenshot({ path: main }); + await page.screenshot({ path: path.join(root, "public/work", job.slug, "og.png") }); + + await page.evaluate((sel) => { + document.querySelector(sel)?.scrollIntoView({ behavior: "instant", block: "start" }); + }, job.second.scrollTo); + await sleep(job.second.wait); + + const alt = path.join(shots, `work-${job.slug}-2.png`); + await page.screenshot({ path: alt }); + await page.close(); + + const kb = (p) => Math.round(fs.statSync(p).size / 1024); + console.log(`${job.slug}: 본컷 ${kb(main)}KB · 2컷 ${kb(alt)}KB`); +} + +await browser.close(); +console.log("촬영 완료"); diff --git a/apps/site/tools/e2e-report.md b/apps/site/tools/e2e-report.md index 8311087..0effc59 100644 --- a/apps/site/tools/e2e-report.md +++ b/apps/site/tools/e2e-report.md @@ -1,4 +1,4 @@ -# E2E 시나리오 게이트 — 2026-08-23 12:48 +# E2E 시나리오 게이트 — 2026-08-24 03:41 단언 1805 · 통과 1805 · 실패 0 — **GREEN — 전 단언 통과** diff --git a/apps/site/tools/gen-fruit-images.mjs b/apps/site/tools/gen-fruit-images.mjs new file mode 100644 index 0000000..e943a4a --- /dev/null +++ b/apps/site/tools/gen-fruit-images.mjs @@ -0,0 +1,103 @@ +/** + * 새벽청과 이미지 생성 — codex 내장 image_gen(gpt-image-2). + * + * 기존 gen-images.mjs + collect-images.mjs 는 "전부 돌린 뒤 디렉터리 생성시각 순으로 + * 짝짓기" 라서 순서가 어긋나면 조용히 틀린 그림이 붙는다. 여기서는 한 장 돌릴 때마다 + * 그 직전 시각 이후에 새로 생긴 파일만 집어 온다 — 짝이 어긋날 여지를 없앤다. + * + * 사용: node tools/gen-fruit-images.mjs + */ +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 outDir = path.join(__dirname, ".gen-fruit"); +fs.mkdirSync(outDir, { recursive: true }); + +const genRoot = path.join(os.homedir(), ".codex", "generated_images"); + +const NO = + "no text, no letters, no numbers, no signage, no logos, no watermarks, no brand marks"; + +/* 팔레트를 프롬프트에 그대로 넣는다 — 페이지 토큰과 사진이 따로 놀면 + 사진이 배경이 아니라 이물이 된다. + 종이 흰 #FBFAF7 · 잉크 #16181A · 새벽 남색 #1F3F6B */ +const JOBS = [ + { + name: "dawn-aisle", + prompt: `$imagegen Documentary photograph, 16:9 landscape. Interior of a Korean wholesale fruit market hall at 2am, before dawn. A long aisle between stacked wooden and cardboard fruit crates, cold fluorescent tube lighting overhead casting pools of light on a wet concrete floor, deep blue-black shadows in the depth of the hall. Palette restrained: paper white highlights, ink black shadows, one cold navy blue cast (#1F3F6B) in the far depth. The fruit itself is the only warm color — apples and pears glimpsed in the nearest crates. Empty of people. Quiet, industrious, pre-dawn mood. ${NO}. Photographic, realistic, fine film grain, no HDR.`, + }, + { + name: "pear-crate", + prompt: `$imagegen Editorial still-life photograph, 4:3 landscape. A shallow wooden crate of Korean pears (large, round, russet-golden) nested in white protective mesh sleeves, sitting on a plain concrete surface. One pear lifted out and resting beside the crate. Cool overcast north light from the left, off-white background (#FBFAF7), deep neutral shadows. The pears' golden-bronze skin is the only saturated color in the frame. ${NO}. Photographic, realistic, shallow depth of field on the front pear, fine grain.`, + }, + { + name: "grape-bloom", + prompt: `$imagegen Macro photograph, 3:4 portrait. A close cluster of dark purple Korean Campbell grapes with the natural pale waxy bloom still intact on the skin, a few water droplets, held against a plain off-white paper background (#FBFAF7). Extremely fine detail of the bloom's chalky film and the skin beneath. Soft even diffused light, no specular hotspots. The deep purple is the only color; everything else is neutral. ${NO}. Photographic, realistic, museum-specimen clarity.`, + }, + { + name: "refractometer", + prompt: `$imagegen Close documentary photograph, 4:3 landscape. A handheld brass-and-steel optical refractometer lying open on a stainless steel worktop, its prism plate carrying a single glistening drop of clear fruit juice, a small stainless spoon and a cut wedge of melon beside it. Cool neutral light, brushed steel greys, off-white background. Precise, laboratory-adjacent but agricultural mood — this is a produce grader's tool, not a laboratory. ${NO} (the refractometer scale must not show readable numbers). Photographic, realistic, shallow depth of field on the juice drop.`, + }, +]; + +const before = () => { + const seen = new Set(); + if (!fs.existsSync(genRoot)) return seen; + for (const d of fs.readdirSync(genRoot)) { + const dir = path.join(genRoot, d); + if (!fs.statSync(dir).isDirectory()) continue; + for (const f of fs.readdirSync(dir)) seen.add(path.join(dir, f)); + } + return seen; +}; + +const after = (seen) => { + const fresh = []; + if (!fs.existsSync(genRoot)) return fresh; + for (const d of fs.readdirSync(genRoot)) { + const dir = path.join(genRoot, d); + if (!fs.statSync(dir).isDirectory()) continue; + for (const f of fs.readdirSync(dir)) { + const p = path.join(dir, f); + if (!seen.has(p) && /\.(png|jpg|jpeg|webp)$/i.test(f)) { + fresh.push({ p, t: fs.statSync(p).mtimeMs }); + } + } + } + return fresh.sort((a, b) => b.t - a.t); +}; + +for (const job of JOBS) { + const target = path.join(outDir, `${job.name}.png`); + if (fs.existsSync(target)) { + console.log(`skip ${job.name} (이미 있음)`); + continue; + } + console.log(`gen ${job.name} …`); + const seen = before(); + try { + // Windows 의 codex 는 npm .cmd 심이라 cmd.exe 를 거쳐야 해석된다. + execFileSync("cmd.exe", ["/d", "/s", "/c", "codex exec --skip-git-repo-check -"], { + input: job.prompt, + encoding: "utf8", + timeout: 8 * 60 * 1000, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (e) { + // 배너가 stderr 로 나오고 종료코드가 0 이 아닐 수 있다. 파일이 생겼는지로 판정한다 + console.log(` (codex 비정상 종료: ${String(e.message).slice(0, 90)})`); + } + const fresh = after(seen); + if (!fresh.length) { + console.log(`FAIL ${job.name} — 새 이미지가 생기지 않았다`); + continue; + } + fs.copyFileSync(fresh[0].p, target); + console.log(`ok ${job.name} ← ${path.basename(fresh[0].p)} (${Math.round(fs.statSync(target).size / 1024)}KB)`); +} + +console.log("\n생성 완료:", fs.readdirSync(outDir).join(", ")); diff --git a/apps/site/tools/verify-report.md b/apps/site/tools/verify-report.md index 6632a77..953ba60 100644 --- a/apps/site/tools/verify-report.md +++ b/apps/site/tools/verify-report.md @@ -1,15 +1,16 @@ -# 검증 리포트 — 2026-08-23 12:48 +# 검증 리포트 — 2026-08-24 03:41 -게이트: **전 계층 통과 — 배포 가능** · 총 96.5초 +게이트: **전 계층 통과 — 배포 가능** · 총 96.1초 | 계층 | 결과 | 항목 | 소요 | |---|---|---|---| -| L0-정적/stylelint | PASS | PASS — | 1.6s | -| L0-정식/html-validate | PASS | PASS — | 1.3s | -| L1-단위/계산로직 | PASS | PASS 41 | 0.1s | -| L2-기능/감사 | PASS | PASS 72 | 10.2s | -| L2-불변식/렌더 | PASS | PASS 10 | 17.5s | +| L0-정적/stylelint | PASS | PASS — | 1.5s | +| L0-정식/html-validate | PASS | PASS — | 1.5s | +| L0-무결성/변수·경로·조판 | PASS | PASS 1 | 0.1s | +| L1-단위/계산로직 | PASS | PASS 41 | 0.0s | +| L2-기능/감사 | PASS | PASS 72 | 9.9s | +| L2-불변식/렌더 | PASS | PASS 10 | 17.2s | | L3-시각회귀/diff | PASS | PASS 30 | 12.2s | -| L4-WebKit | PASS | PASS 8 | 4.7s | -| L5-탐색/키보드 | PASS | PASS 14 | 6.4s | -| L6-시나리오/E2E | PASS | PASS — | 42.6s | +| L4-WebKit | PASS | PASS 8 | 3.7s | +| L5-탐색/키보드 | PASS | PASS 14 | 6.6s | +| L6-시나리오/E2E | PASS | PASS — | 43.3s |