feat(showcase): 신규 쇼케이스 3종 — RFS · 트웬티온스 · 새벽청과
Some checks failed
ci / build (push) Failing after 1m52s
각 워크를 서로 다른 클라이언트로 보고 0~6단계를 따로 돌았다(토큰 체계 신규 정의 = 전체 경로). 셋 모두 라이트/다크 반전을 .band-light / .band-dark 가 --band-* 를 재정의하는 규칙으로 정의해 하드 게이트 4를 통과시켰다 — 반전이 섹션마다의 기분이 아니라 두 클래스가 만드는 규칙이다. RFS — 실시간 유체 시뮬레이터 (rfs-cfd) 프리셋 darkroom-spec · 컨셉 "암실과 사양서" R1 luminary.ai · R2 teenage.engineering EP-133 · 보조 simscale.com (전부 실측) 히어로에서 브라우저가 실제로 Navier–Stokes 를 푼다. 순수 WebGL2, three.js 미사용. 이류 → 온도 소스 → 부력·설비 힘 → 발산 → 압력 Jacobi → 투영 → 파티클 전진. 격자 224×126 / 파티클 26,000 / Jacobi 22회, 좁은 화면은 절반으로 줄인다. prefers-reduced-motion 이면 시작하지 않고 그 자리에 RFS 실물 파티클 렌더가 남는다. 단위계로 세 번 실패했다: dt 를 프레임 단위로 넘겨 파티클이 0.2초 만에 소멸했고, 전부 급기구에서만 재생성해 실 한 가닥이 됐으며, 랙이 위로 뿜어 핫/콜드 아일이 갈라지지 않았다. dt 를 초로 못박고 속도를 초당 격자 셀로 통일해 해결했다. 수치는 전부 RFS 저장소 README 출처. 정확도 지표는 싣지 않았다 — 사내 검증 자료이고 현재 통과 상태가 아니다. 트웬티온스 (twentyoz-corp) 프리셋 field-record · 컨셉 "네 개의 현장" R1 samsara.com · R2 lars-mueller-publishers.com · R3 = 현행 twentyoz.kr 감사 현행 사이트 실측: 대비 실패 172개 요소(9~11px 가 3.61:1), 한글 폰트 미지정으로 한글이 시스템 기본으로 떨어짐, h1 자간 −0.06em(한글 상한의 세 배), 배경 #020202. 'Digital Genetics' 은유가 산업용 IoT 사업을 가리고 있었다. 유지한 자산은 radius 0 과 무채색·모노 기조. 첫 화면에 회사 이름 대신 현장 선택기를 뒀다 — 라디오 그룹이라 JS 0바이트이고 좌우 화살표 키가 그냥 먹는다. 파트너 19곳은 원본에 도메인 표기가 없어 나누지 않았다. 새벽청과 (saebyeok-cheonggwa) 프리셋 market-board · 컨셉 "오늘 새벽 두 시의 값" R1 natoora.com · R2 phillips.com · R3 제철 12개월 띠 커머스가 감추는 시세 변동을 히어로의 주인공으로 삼았다. 상품 사진은 아래로 내렸다. 경매 시각·경락가격·등급·당도 표시 규정을 조사해 카피의 근거로 썼다. 무화과 당도를 "—" 로 두고 값이 없는 게 아니라 표시가 허용되지 않는 품목이라고 적었다. 등락은 색이 아니라 ▲▼보합 기호가 먼저다. 표의 숫자가 예시임을 판 아래와 푸터 두 곳에 명시하고, 주소·전화는 비웠다(게이트 11). 사진 4장은 codex 내장 image_gen 생성물이며 합계 341KB. 검증: Astro 빌드 통과, verify 체인 L0~L6 10계층 전부 통과, showcase-audit 신규 3종 12/12 통과(320/390/768/1440). 성능 실측(RFS): JS 24KB · 총 704KB. 폰트만 530KB 로 예산(200KB) 초과 — 한글 웹폰트 자체가 원인이고 기존 cheongon-care 는 650KB 로 더 크다. 저장소 공통 baseline 이라 이번 판은 기록만 남기고 넘어간다. 기존 16종에서 발견된 70건(대비 미달·히어로 줄 수 초과)은 이번 범위를 넘어 design.md 에 목록만 남겼다.
BIN
apps/site/public/work/rfs-cfd/assets/sph-sideview.webp
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
apps/site/public/work/rfs-cfd/assets/sph-topview.webp
Normal file
|
After Width: | Height: | Size: 160 KiB |
545
apps/site/public/work/rfs-cfd/index.html
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>RFS — 실시간 유체 시뮬레이터 · 데이터센터 열관리 GPU 엔진</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="GPU Compute Shader 위에서 도는 δ-SPH 엔진. 파티클 100,000개를 60 FPS 로 계산해 서버랙 발열과 항온항습기 냉각을 실시간으로 반영한다. 메시 재생성 없이 설비 배치를 그 자리에서 검증한다."
|
||||
/>
|
||||
<meta property="og:title" content="RFS — 실시간 유체 시뮬레이터" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="설비를 옮기면 유동이 그 자리에서 바뀐다. 데이터센터 열관리를 위한 실시간 GPU 유체 시뮬레이션 엔진."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#0e1114" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%230E1114'/%3E%3Cpath d='M4 21c4-6 8 4 12-2s8 3 12-3' stroke='%233E8FB0' stroke-width='2.4' fill='none' stroke-linecap='round'/%3E%3Cpath d='M4 27c4-6 8 4 12-2s8 3 12-3' stroke='%238CC152' stroke-width='2.4' fill='none' stroke-linecap='round' opacity='.75'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#top">
|
||||
<span class="wordmark-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 28 20" role="presentation" focusable="false">
|
||||
<path d="M2 13c3.5-5.5 7-1 10.5-5S21 5 26 3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M2 18c3.5-5.5 7-1 10.5-5S21 10 26 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" opacity=".5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="wordmark-text">RFS</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav" aria-label="주요 섹션">
|
||||
<a href="#solver">솔버</a>
|
||||
<a href="#equipment">장비</a>
|
||||
<a href="#visualization">시각화</a>
|
||||
<a href="#adopt">도입</a>
|
||||
</nav>
|
||||
|
||||
<p class="head-meta mono">v0.13.0</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
1 · 히어로 — 암실. 실제로 도는 열유동이 첫 화면을 덮는다
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark hero" id="top" aria-labelledby="heroHeading">
|
||||
<div class="hero-stage" data-field-shell data-field-state="loading">
|
||||
<canvas id="thermalField" aria-hidden="true"></canvas>
|
||||
|
||||
<!-- WebGL 이 없거나 모션을 줄이는 설정이면 이 렌더가 남는다.
|
||||
폴백이 아니라 결과물이다 — RFS 실물의 파티클 뷰다 -->
|
||||
<img
|
||||
class="hero-fallback"
|
||||
src="./assets/sph-sideview.webp"
|
||||
width="691"
|
||||
height="437"
|
||||
alt="RFS 실물 파티클 뷰 — 서버랙을 통과한 공기가 데워져 위로 오르는 측면 단면"
|
||||
/>
|
||||
|
||||
<div class="hero-grid" aria-hidden="true"></div>
|
||||
|
||||
<!-- 장비 형상. 셰이더가 쓰는 좌표와 같은 값을 CSS 변수로 넘긴다 —
|
||||
그림과 라벨이 어긋나면 라벨이 거짓말이 된다 -->
|
||||
<div class="stage-solid stage-rack" aria-hidden="true"></div>
|
||||
<div class="stage-solid stage-fwu" aria-hidden="true"></div>
|
||||
|
||||
<div class="hero-scrim" aria-hidden="true"></div>
|
||||
|
||||
<div class="stage-tag stage-tag-fwu" aria-hidden="true"><span>FWU 급기</span></div>
|
||||
<div class="stage-tag stage-tag-rack" aria-hidden="true"><span>서버랙</span></div>
|
||||
</div>
|
||||
|
||||
<div class="wrap hero-copy">
|
||||
<h1 id="heroHeading">
|
||||
메시를 다시<br />
|
||||
만들지 않는다
|
||||
</h1>
|
||||
<p class="hero-lead">
|
||||
설비를 옮기면 유동이 그 자리에서 바뀐다. 데이터센터 열관리를 위한 실시간 GPU 유체
|
||||
시뮬레이터.
|
||||
</p>
|
||||
|
||||
<div class="hero-controls">
|
||||
<div class="ctl">
|
||||
<div class="ctl-head">
|
||||
<label for="ctlLoad">서버랙 부하</label>
|
||||
<output class="mono" id="outLoad" for="ctlLoad">70%</output>
|
||||
</div>
|
||||
<input type="range" id="ctlLoad" min="0" max="100" step="5" value="70" />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<div class="ctl-head">
|
||||
<label for="ctlSupply">FWU 급기량</label>
|
||||
<output class="mono" id="outSupply" for="ctlSupply">65%</output>
|
||||
</div>
|
||||
<input type="range" id="ctlSupply" min="0" max="100" step="5" value="65" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hero-note">
|
||||
위 화면은 브라우저에서 도는 <strong>2D 축소판</strong>이다. 슬라이더를 움직이거나 화면을
|
||||
문지르면 유동이 즉시 반응한다. 실물 RFS 는 Unity 6.4 위에서 3D δ-SPH 를 돌린다 —
|
||||
파티클 100,000개, 60 FPS.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
2 · 문제 — 왜 기존 CFD 로는 안 되는가
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-problem" id="problem" aria-labelledby="problemHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="problemHeading">
|
||||
해가 나올 즈음에는<br />이미 다른 결정을 내린 뒤다
|
||||
</h2>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<p>
|
||||
범용 CFD 도구는 시뮬레이션 한 건에 수 시간에서 수 일이 걸린다. 랙 한 대를 옮길 때마다
|
||||
메시를 다시 만들고 처음부터 다시 풀어야 하므로, 운영 중에 답을 얻는 용도로는 쓰이지
|
||||
않는다. 그래서 현장의 배치 변경은 대개 계산이 아니라 경험으로 결정된다.
|
||||
</p>
|
||||
<p>
|
||||
RFS 는 정확도의 상한을 낮추는 대신 <strong>회신 시간</strong>을 바꾼 도구다.
|
||||
발열량을 올리고, 급기 밸브를 조이고, 랙을 한 칸 옮기는 조작이 같은 화면 안에서
|
||||
유동에 반영된다. 검증할 것이 "결과 파일"이 아니라 "지금 보고 있는 장면"이 된다.
|
||||
</p>
|
||||
<dl class="contrast">
|
||||
<div>
|
||||
<dt>범용 CFD 도구</dt>
|
||||
<dd>배치를 바꾸면 메시 재생성 → 재계산. 한 건에 수 시간~수 일</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>RFS</dt>
|
||||
<dd>배치·발열·제어 변경이 같은 세션 안에서 유동에 반영된다</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
3 · 솔버 — 암실. 사양을 숨기지 않는다
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark section-solver" id="solver" aria-labelledby="solverHeading">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">유체 엔진</p>
|
||||
<h2 id="solverHeading">GPU Compute Shader 위의 δ-SPH</h2>
|
||||
<p class="section-lead">
|
||||
격자를 깔지 않는다. 공기를 입자로 놓고 이웃 입자끼리 밀도·압력·열을 주고받게 한다.
|
||||
벽이 움직여도 격자를 다시 만들 일이 없다 — 실시간이 가능한 이유가 여기에 있다.
|
||||
</p>
|
||||
|
||||
<ul class="spec-ledger">
|
||||
<li>
|
||||
<p class="spec-name">보간 커널</p>
|
||||
<p class="spec-value">Wendland C2</p>
|
||||
<p class="spec-note">Poly6·Spiky 혼용을 걷어내고 단일 경로로 통일했다</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="spec-name">상태방정식</p>
|
||||
<p class="spec-value">Tait</p>
|
||||
<p class="spec-note">약압축성 가정. 압력 반복 없이 밀도에서 압력을 직접 얻는다</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="spec-name">벽면 경계</p>
|
||||
<p class="spec-value">Adami Ghost Particle</p>
|
||||
<p class="spec-note">엣지·코너까지 덮는다. 경계에서 입자가 새던 결함을 해소한 방식</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="spec-name">시간 적분</p>
|
||||
<p class="spec-value">CFL 적응 서브스텝</p>
|
||||
<p class="spec-note">유속이 빨라지면 스텝을 자동으로 잘게 쪼갠다</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="spec-name">배속</p>
|
||||
<p class="spec-value">0.1× — 10×</p>
|
||||
<p class="spec-note">배속을 바꿔도 같은 물리 결과가 나오도록 시간 불변을 보장한다</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="spec-name">열·습도</p>
|
||||
<p class="spec-value">입자 간 전도 + 부력</p>
|
||||
<p class="spec-note">습공기 밀도 보정을 포함한다</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="spec-name">주기 경계</p>
|
||||
<p class="spec-value">PBC · 최소 이미지 규약</p>
|
||||
<p class="spec-note">셀 래핑으로 무한 반복 공간을 다룬다</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="spec-name">이웃 탐색</p>
|
||||
<p class="spec-value">공간 해시 그리드</p>
|
||||
<p class="spec-note">SpatialHash 커널 1개가 전담한다</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="figures">
|
||||
<figure>
|
||||
<p class="fig-num mono">01</p>
|
||||
<p class="fig-title">GPU 커널</p>
|
||||
<p class="fig-val mono">53<span>+</span></p>
|
||||
<p class="fig-note">Compute Shader 10개 · HLSL 67개로 분할</p>
|
||||
</figure>
|
||||
<figure>
|
||||
<p class="fig-num mono">02</p>
|
||||
<p class="fig-title">파티클</p>
|
||||
<p class="fig-val mono">100,000</p>
|
||||
<p class="fig-note">최대 512,000. 60 FPS 안정 구간 기준</p>
|
||||
</figure>
|
||||
<figure>
|
||||
<p class="fig-num mono">03</p>
|
||||
<p class="fig-title">파티클 1개의 크기</p>
|
||||
<p class="fig-val mono">20<span>B</span></p>
|
||||
<p class="fig-note">AoS 를 SoA 6개 버퍼로 갈라 얻은 값</p>
|
||||
</figure>
|
||||
<figure>
|
||||
<p class="fig-num mono">04</p>
|
||||
<p class="fig-title">테스트</p>
|
||||
<p class="fig-val mono">2,000<span>+</span></p>
|
||||
<p class="fig-note">499개 파일 · EditMode · PlayMode · Performance</p>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
4 · 장비 — 라이트. 이 제품이 아는 물건들
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-equipment" id="equipment" aria-labelledby="equipHeading">
|
||||
<div class="wrap">
|
||||
<h2 id="equipHeading">전산실에 있는 물건을 그대로 안다</h2>
|
||||
<p class="section-lead">
|
||||
유동만 푸는 것으로는 답이 안 나온다. 열을 만드는 것과 식히는 것이 각자의 제어 논리를
|
||||
가지고 장 안에 들어가 있어야 배치 변경이 의미를 갖는다.
|
||||
</p>
|
||||
|
||||
<div class="equip-list">
|
||||
<article>
|
||||
<h3>항온항습기 <span class="tag mono">FWU</span></h3>
|
||||
<p>
|
||||
PID 자동 제어로 목표 온도를 좇는다. 열교환은 ε-NTU 로 계산하고,
|
||||
프리쿨링·쿨링 밸브와 댐퍼 모드를 각각 조작할 수 있다.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>서버랙</h3>
|
||||
<p>
|
||||
CFM 기반으로 통과 공기에 열을 싣는다. 동적 전력 프로파일 5종을 갖고 있고,
|
||||
장애 상황을 만들어 그때 열이 어디로 가는지 볼 수 있다.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>팬 · 댐퍼 · 냉각코일</h3>
|
||||
<p>
|
||||
듀얼 원뿔 존 모델로 GPU 파티클 물리에 직접 개입한다.
|
||||
후처리로 그린 화살표가 아니라 실제로 입자를 민다.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>온도계</h3>
|
||||
<p>
|
||||
물리 기반 센서 응답 특성을 갖는다. 값이 즉시 튀지 않고 실제 계기처럼 따라온다.
|
||||
Cool Zone 과 Hot Zone 을 자동으로 분류한다.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>급기구 · 배기구</h3>
|
||||
<p>
|
||||
CFD inlet / outlet 패턴을 따른다. 경계에 걸린 파티클을 자동으로 재배치해
|
||||
유입·유출이 끊기지 않게 한다.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
5 · 시각화 — 암실. 실제 렌더를 그대로 싣는다
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark section-vis" id="visualization" aria-labelledby="visHeading">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">보이는 방식</p>
|
||||
<h2 id="visHeading">읽는 방법을 네 가지로 나눈다</h2>
|
||||
|
||||
<div class="vis-layout">
|
||||
<ol class="vis-list">
|
||||
<li>
|
||||
<h3>파티클</h3>
|
||||
<p>온도로 색을 매핑하고 발광과 LOD 를 건다. 공기가 어디서 데워지는지 그대로 보인다.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>트레일</h3>
|
||||
<p>Ring Buffer 로 궤적을 남긴다. 한 순간이 아니라 지나온 경로를 본다.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>히트맵 슬라이스</h3>
|
||||
<p>
|
||||
온도·속도·압력·밀도·습도 다섯 물리량의 단면을 5패스 GPU 파이프라인으로 그린다.
|
||||
<span class="mono">X</span> · <span class="mono">Y</span> ·
|
||||
<span class="mono">Z</span> 키로 축을 바꾼다.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>벡터 필드</h3>
|
||||
<p>화살표 글리프와 스트림라인을 GPU 인스턴싱으로 띄운다. 방향과 세기를 함께 읽는다.</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="vis-shots">
|
||||
<figure>
|
||||
<img
|
||||
src="./assets/sph-topview.webp"
|
||||
width="689"
|
||||
height="680"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="RFS 상면 뷰 — 랙 상부에서 데워진 입자가 초록으로 퍼지고 주변 저온 입자는 청록으로 남는다"
|
||||
/>
|
||||
<figcaption>상면 · 랙 상부의 열기둥</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img
|
||||
src="./assets/sph-sideview.webp"
|
||||
width="691"
|
||||
height="437"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="RFS 측면 뷰 — 랙을 통과하며 데워진 공기가 오른쪽으로 밀려 나가는 단면"
|
||||
/>
|
||||
<figcaption>측면 · 랙을 통과한 공기</figcaption>
|
||||
</figure>
|
||||
<p class="shots-note">
|
||||
두 장 모두 RFS 실물의 파티클 뷰다. 저온이 청록, 고온이 초록이다 —
|
||||
이 페이지 첫 화면의 색도 같은 규칙을 쓴다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
6 · 에디터 — 라이트
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-editor" id="editor" aria-labelledby="editorHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="editorHeading">시뮬레이터가 아니라 작업 환경이다</h2>
|
||||
<p class="section-lead">
|
||||
배치를 바꿔 보려면 배치를 바꿀 수 있어야 한다. RFS Editor 는 Unity UI Toolkit 으로 만든
|
||||
런타임 편집 환경이고, 시뮬레이션이 도는 채로 물건을 옮긴다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<dl class="feature-rows">
|
||||
<div>
|
||||
<dt>오브젝트 편집</dt>
|
||||
<dd>이동·회전·스케일 핸들, 다중 선택, Undo / Redo, 드래그 앤 드롭 배치</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>속성 패널</dt>
|
||||
<dd>실시간 속성 편집. 관제 시스템의 XD 태그를 여기서 바인딩한다</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>리플레이</dt>
|
||||
<dd>
|
||||
CSV 또는 XD API 를 데이터소스로 삼아 시계열을 재생하고, 실측 대비 정확도 리포트를
|
||||
만든다
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>AI Advisor</dt>
|
||||
<dd>
|
||||
26개 규칙의 Rule Engine 과 LLM 프로바이더 4종(Anthropic · OpenAI · Gemini ·
|
||||
로컬 Ollama) 연동
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>모니터링</dt>
|
||||
<dd>PUE · COP · 전력 · CO₂ 대시보드, MetricsHUD 7섹션 26메트릭</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>프로젝트</dt>
|
||||
<dd>생성·저장·불러오기, 다중 씬, glTF / GLB 3D 모델 임포트</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="keys">
|
||||
<p class="keys-title">손에 익는 키</p>
|
||||
<ul>
|
||||
<li><kbd>Space</kbd> 일시정지 · 재개</li>
|
||||
<li><kbd>H</kbd> 히트맵</li>
|
||||
<li><kbd>X</kbd><kbd>Y</kbd><kbd>Z</kbd> 슬라이스 축</li>
|
||||
<li><kbd>F3</kbd> 성능 통계</li>
|
||||
<li><kbd>Tab</kbd> UI 접기</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
7 · 아키텍처 — 라이트. 5계층
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark section-arch" id="architecture" aria-labelledby="archHeading">
|
||||
<div class="wrap">
|
||||
<h2 id="archHeading">다섯 켜</h2>
|
||||
<ol class="layers">
|
||||
<li>
|
||||
<p class="layer-id mono">L1</p>
|
||||
<p class="layer-name">애플리케이션</p>
|
||||
<p class="layer-body">RFS Editor · Simulation3D · AI Advisor · 리플레이</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="layer-id mono">L2</p>
|
||||
<p class="layer-name">파이프라인 · 컨트롤러</p>
|
||||
<p class="layer-body">SimulationPipeline → 스테이지 10개 → 컨트롤러 26개</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="layer-id mono">L3</p>
|
||||
<p class="layer-name">GPU 컴퓨트</p>
|
||||
<p class="layer-body">
|
||||
SPHCore 8 · DomainExt 14 · SpatialHash 1 · TempGrid 8 · ObjectGrid 7 ·
|
||||
Integration 5 · Scatter 4 · Trail 2 · GlobalSDF 3 · Coil 1
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="layer-id mono">L4</p>
|
||||
<p class="layer-name">데이터 버퍼</p>
|
||||
<p class="layer-body">SoA 버퍼 6개 · ParticleData 20B · 충돌 메시 데이터</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="layer-id mono">L5</p>
|
||||
<p class="layer-name">외부 통신</p>
|
||||
<p class="layer-body">Socket.IO v2 (RFS-XD 관제 연동) · REST API · CSV 내보내기</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
8 · 도입 — 라이트. 요구사양과 연락
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-adopt" id="adopt" aria-labelledby="adoptHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="adoptHeading">돌려 보려면 무엇이 필요한가</h2>
|
||||
<p class="section-lead">
|
||||
DirectCompute 를 쓰므로 NVIDIA GPU 를 권장한다. 아래는 개발 저장소가 명시한 값
|
||||
그대로다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<table class="req">
|
||||
<caption class="sr-only">RFS 시스템 요구사항 — 최소 사양과 권장 사양</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">항목</th>
|
||||
<th scope="col">최소</th>
|
||||
<th scope="col">권장</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">운영체제</th>
|
||||
<td>Windows 10 64bit</td>
|
||||
<td>Windows 11</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">GPU</th>
|
||||
<td>GTX 1660 · VRAM 6GB</td>
|
||||
<td>RTX 3060 이상 · VRAM 8GB 이상</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">메모리</th>
|
||||
<td>16 GB</td>
|
||||
<td>16 GB 이상</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">엔진</th>
|
||||
<td colspan="2">Unity 6.4 (6000.4.5f1) · URP 17.2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="adopt-cta">
|
||||
<p>
|
||||
도입 검토·기술 문의는 개발사인 트웬티온스로 연락하면 된다.
|
||||
제품은 독점 소프트웨어이고, 평가판 제공 여부는 협의 사항이다.
|
||||
</p>
|
||||
<a class="btn" href="mailto:hello@twentyoz.kr?subject=RFS%20%EB%8F%84%EC%9E%85%20%EB%AC%B8%EC%9D%98">
|
||||
RFS 도입 문의
|
||||
</a>
|
||||
<p class="adopt-fine mono">hello@twentyoz.kr · 070-4353-1190</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="band band-dark site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<div>
|
||||
<p class="foot-name">RFS — Realtime Fluid Simulator</p>
|
||||
<p class="foot-line">데이터센터 열관리를 위한 실시간 GPU 유체 시뮬레이션 엔진</p>
|
||||
</div>
|
||||
<dl class="foot-meta mono">
|
||||
<div><dt>버전</dt><dd>0.13.0</dd></div>
|
||||
<div><dt>엔진</dt><dd>Unity 6.4 · URP 17.2</dd></div>
|
||||
<div><dt>개발</dt><dd>Twentyoz Co., Ltd.</dd></div>
|
||||
<div><dt>라이선스</dt><dd>Proprietary</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="wrap foot-fine">
|
||||
<p>
|
||||
이 페이지의 수치는 RFS 개발 저장소의 README 에 적힌 값을 옮긴 것이다.
|
||||
실측 조건이 붙지 않은 정확도 지표는 싣지 않았다.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="./scripts/thermal-field.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/site/public/work/rfs-cfd/og.png
Normal file
|
After Width: | Height: | Size: 503 KiB |
686
apps/site/public/work/rfs-cfd/scripts/thermal-field.js
Normal file
|
|
@ -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);
|
||||
})();
|
||||
1029
apps/site/public/work/rfs-cfd/styles/main.css
Normal file
122
apps/site/public/work/rfs-cfd/styles/tokens.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
BIN
apps/site/public/work/saebyeok-cheonggwa/assets/dawn-aisle.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/site/public/work/saebyeok-cheonggwa/assets/grape-bloom.webp
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
apps/site/public/work/saebyeok-cheonggwa/assets/pear-crate.webp
Normal file
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 52 KiB |
573
apps/site/public/work/saebyeok-cheonggwa/index.html
Normal file
|
|
@ -0,0 +1,573 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>새벽청과 (曉靑果) — 오늘 새벽 두 시의 값</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="새벽 경매에서 낙찰받아 그날 아침에 보냅니다. 품목마다 산지·등급·당도·규격과 오늘 낙찰가를 그대로 적는 청과 직거래."
|
||||
/>
|
||||
<meta property="og:title" content="새벽청과 (曉靑果)" />
|
||||
<meta property="og:description" content="값이 매일 바뀐다는 사실을 첫 화면에 적습니다." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#16181a" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%2316181A'/%3E%3Cpath d='M6 20h20' stroke='%237FA8D9' stroke-width='2'/%3E%3Ccircle cx='13' cy='14' r='4.5' fill='none' stroke='%23F3F1EC' stroke-width='2'/%3E%3Cpath d='M13 9.5V7' stroke='%23F3F1EC' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#top">
|
||||
새벽청과 <span class="wordmark-han" aria-hidden="true">曉靑果</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="주요 섹션">
|
||||
<a href="#route">오는 길</a>
|
||||
<a href="#grade">고르는 법</a>
|
||||
<a href="#season">제철</a>
|
||||
<a href="#wholesale">납품</a>
|
||||
</nav>
|
||||
<p class="head-meta mono">02:00 경매</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
1 · 히어로 = 오늘의 판. 새벽 두 시라서 어둡다
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark hero" id="top" aria-labelledby="heroHeading">
|
||||
<!-- 새벽 두 시의 시장 통로. 판 뒤에 깔리므로 어둡게, 과일만 색을 갖게 찍었다 -->
|
||||
<img
|
||||
class="hero-bg"
|
||||
src="./assets/dawn-aisle.webp"
|
||||
width="1600"
|
||||
height="900"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="wrap hero-inner">
|
||||
<p class="hero-stamp mono">
|
||||
<span>오늘 경매</span>
|
||||
<time datetime="2026-08-24T02:00+09:00">08월 24일 02:00</time>
|
||||
<span class="stamp-sep" aria-hidden="true">·</span>
|
||||
<span>하절기 기준</span>
|
||||
</p>
|
||||
|
||||
<h1 id="heroHeading">
|
||||
오늘 새벽<br />
|
||||
두 시의 값
|
||||
</h1>
|
||||
<p class="hero-lead">
|
||||
값은 매일 바뀝니다. 바뀌는 값을 감추고 파는 대신, 오늘 얼마에 받아 왔는지를
|
||||
먼저 적습니다.
|
||||
</p>
|
||||
|
||||
<table class="board">
|
||||
<caption class="sr-only">
|
||||
오늘 낙찰 품목 여섯 가지 — 산지, 등급, 당도, 규격, 낙찰가, 전일 대비
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">품목</th>
|
||||
<th scope="col">산지</th>
|
||||
<th scope="col" class="c-num">등급</th>
|
||||
<th scope="col" class="c-num">당도</th>
|
||||
<th scope="col" class="c-num">규격</th>
|
||||
<th scope="col" class="c-num">낙찰가</th>
|
||||
<th scope="col" class="c-num">전일 대비</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">백도 복숭아</th>
|
||||
<td data-label="산지">경북 청도</td>
|
||||
<td data-label="등급" class="c-num mono">특</td>
|
||||
<td data-label="당도" class="c-num mono">12.5<span class="unit">Bx</span></td>
|
||||
<td data-label="규격" class="c-num mono">4.5<span class="unit">kg</span></td>
|
||||
<td data-label="낙찰가" class="c-num mono price">38,000</td>
|
||||
<td data-label="전일 대비" class="c-num mono delta up">▲ 2,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">샤인머스캣</th>
|
||||
<td data-label="산지">경북 상주</td>
|
||||
<td data-label="등급" class="c-num mono">특</td>
|
||||
<td data-label="당도" class="c-num mono">18.2<span class="unit">Bx</span></td>
|
||||
<td data-label="규격" class="c-num mono">2<span class="unit">kg</span></td>
|
||||
<td data-label="낙찰가" class="c-num mono price">26,500</td>
|
||||
<td data-label="전일 대비" class="c-num mono delta down">▼ 1,500</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">캠벨얼리</th>
|
||||
<td data-label="산지">충북 영동</td>
|
||||
<td data-label="등급" class="c-num mono">상</td>
|
||||
<td data-label="당도" class="c-num mono">15.4<span class="unit">Bx</span></td>
|
||||
<td data-label="규격" class="c-num mono">5<span class="unit">kg</span></td>
|
||||
<td data-label="낙찰가" class="c-num mono price">31,000</td>
|
||||
<td data-label="전일 대비" class="c-num mono delta flat">보합</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">홍로 사과</th>
|
||||
<td data-label="산지">경북 영주</td>
|
||||
<td data-label="등급" class="c-num mono">특</td>
|
||||
<td data-label="당도" class="c-num mono">14.1<span class="unit">Bx</span></td>
|
||||
<td data-label="규격" class="c-num mono">5<span class="unit">kg</span></td>
|
||||
<td data-label="낙찰가" class="c-num mono price">44,000</td>
|
||||
<td data-label="전일 대비" class="c-num mono delta up">▲ 3,500</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">원황 배</th>
|
||||
<td data-label="산지">전남 나주</td>
|
||||
<td data-label="등급" class="c-num mono">상</td>
|
||||
<td data-label="당도" class="c-num mono">12.8<span class="unit">Bx</span></td>
|
||||
<td data-label="규격" class="c-num mono">7.5<span class="unit">kg</span></td>
|
||||
<td data-label="낙찰가" class="c-num mono price">52,000</td>
|
||||
<td data-label="전일 대비" class="c-num mono delta down">▼ 2,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">무화과</th>
|
||||
<td data-label="산지">전남 영암</td>
|
||||
<td data-label="등급" class="c-num mono">특</td>
|
||||
<td data-label="당도" class="c-num mono blank" title="당도 표시 대상 품목이 아닙니다">—</td>
|
||||
<td data-label="규격" class="c-num mono">1.2<span class="unit">kg</span></td>
|
||||
<td data-label="낙찰가" class="c-num mono price">24,000</td>
|
||||
<td data-label="전일 대비" class="c-num mono delta up">▲ 1,000</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ul class="board-notes">
|
||||
<li>
|
||||
사과와 배는 같은 날 <span class="mono">08:30</span> 경매입니다. 나머지 넷은
|
||||
<span class="mono">02:00</span> 에 붙습니다.
|
||||
</li>
|
||||
<li>
|
||||
당도 칸의 <span class="mono">—</span> 는 값이 없다는 뜻이 아니라
|
||||
<strong>표시가 허용되지 않는 품목</strong>이라는 뜻입니다. 무화과는 당도 표기
|
||||
대상 13개 품목에 들어 있지 않습니다.
|
||||
</li>
|
||||
<li>
|
||||
낙찰가는 경매에서 받아 온 값이고, 여기에 선별·포장·배송이 붙어 판매가가 됩니다.
|
||||
두 값을 같은 것처럼 적지 않습니다.
|
||||
</li>
|
||||
<li class="note-demo">
|
||||
위 표의 숫자는 <strong>이 쇼케이스를 위한 예시</strong>입니다. 실제 운영에서는
|
||||
그날 경매 결과가 그대로 들어옵니다.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
2 · 어떻게 오는가
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-route" id="route" aria-labelledby="routeHeading">
|
||||
<div class="wrap">
|
||||
<h2 id="routeHeading">밭에서 문 앞까지 다섯 번 손이 바뀝니다</h2>
|
||||
<p class="section-lead">
|
||||
이 경로는 우리가 만든 것이 아니라 도매시장이 원래 그렇게 돌아갑니다.
|
||||
우리는 네 번째 자리에 서 있습니다.
|
||||
</p>
|
||||
|
||||
<ol class="route">
|
||||
<li>
|
||||
<p class="route-num mono">01</p>
|
||||
<h3>출하</h3>
|
||||
<p>산지에서 따고 골라 담아 시장으로 보냅니다.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="route-num mono">02</p>
|
||||
<h3>수집</h3>
|
||||
<p>도매법인이 그날 들어온 물량을 받아 경매에 올립니다.</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="route-num mono">03</p>
|
||||
<h3>경매</h3>
|
||||
<p>새벽 두 시. 값이 여기서 정해집니다. 낙찰된 값을 경락가격이라 부릅니다.</p>
|
||||
</li>
|
||||
<li class="route-here">
|
||||
<p class="route-num mono">04</p>
|
||||
<h3>낙찰 — 우리가 서는 자리</h3>
|
||||
<p>
|
||||
중도매인 자격으로 직접 받습니다. 어느 산지의 어느 등급을 얼마에 받았는지가
|
||||
위의 판에 그대로 올라갑니다.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="route-num mono">05</p>
|
||||
<h3>그날 아침 배송</h3>
|
||||
<p>다시 창고에 재우지 않습니다. 경매장에서 나온 상자가 그대로 갑니다.</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
3 · 고르는 법
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-grade" id="grade" aria-labelledby="gradeHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="gradeHeading">판에 적힌 네 글자를 읽는 법</h2>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<dl class="terms">
|
||||
<div>
|
||||
<dt>등급 <span class="term-en mono">특 · 상 · 보통</span></dt>
|
||||
<dd>
|
||||
크기가 고른가, 흠이 없는가, 색이 제대로 들었는가를 봅니다. 농산물 표준규격이
|
||||
품목마다 따로 정해 두고 있습니다. <strong>당도를 표시할 수 있는 것은
|
||||
특·상 등급뿐</strong>입니다.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>당도 <span class="term-en mono">Brix</span></dt>
|
||||
<dd>
|
||||
용액 100g 에 든 당이 몇 g 인지를 나타냅니다. 굴절당도계에 과즙 한 방울을
|
||||
올려 잽니다. 높다고 늘 맛있는 것은 아니고 산도와의 균형이 맛을 정합니다 —
|
||||
그래서 숫자만 크게 적지 않습니다.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>선과 <span class="term-en mono">選果</span></dt>
|
||||
<dd>
|
||||
기계로 골라내는 일입니다. 드럼식은 지름으로, 중량식은 무게로 나눕니다.
|
||||
같은 상자 안의 알이 고른 이유가 여기 있습니다.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>규격 <span class="term-en mono">kg</span></dt>
|
||||
<dd>
|
||||
상자 단위입니다. 사과는 2·5·7.5·10kg, 포도는 2·3·4·5kg 로 거래됩니다.
|
||||
같은 값이라도 규격이 다르면 비교가 안 되므로 판에 함께 적습니다.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
4 · 제철 띠 — 12개월
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark section-season" id="season" aria-labelledby="seasonHeading">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">제철</p>
|
||||
<h2 id="seasonHeading">없을 때는 없다고 적습니다</h2>
|
||||
<p class="section-lead">
|
||||
철이 아닌 것을 굳이 구해 오지 않습니다. 아래 띠에서 칸이 찬 달에만 그 품목이
|
||||
판에 오릅니다.
|
||||
</p>
|
||||
|
||||
<div class="season-scroll">
|
||||
<table class="season">
|
||||
<caption class="sr-only">품목별 제철 달력 — 가로축은 1월부터 12월</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="season-item">품목</th>
|
||||
<th scope="col" data-m="1">1</th>
|
||||
<th scope="col" data-m="2">2</th>
|
||||
<th scope="col" data-m="3">3</th>
|
||||
<th scope="col" data-m="4">4</th>
|
||||
<th scope="col" data-m="5">5</th>
|
||||
<th scope="col" data-m="6">6</th>
|
||||
<th scope="col" data-m="7">7</th>
|
||||
<th scope="col" data-m="8">8</th>
|
||||
<th scope="col" data-m="9">9</th>
|
||||
<th scope="col" data-m="10">10</th>
|
||||
<th scope="col" data-m="11">11</th>
|
||||
<th scope="col" data-m="12">12</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">딸기</th>
|
||||
<td class="on" data-m="1"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="2"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="3"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="4"><span class="sr-only">제철</span></td>
|
||||
<td data-m="5"></td><td data-m="6"></td><td data-m="7"></td><td data-m="8"></td>
|
||||
<td data-m="9"></td><td data-m="10"></td><td data-m="11"></td>
|
||||
<td class="on" data-m="12"><span class="sr-only">제철</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">참외</th>
|
||||
<td data-m="1"></td><td data-m="2"></td><td data-m="3"></td>
|
||||
<td class="on" data-m="4"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="5"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="6"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="7"><span class="sr-only">제철</span></td>
|
||||
<td data-m="8"></td><td data-m="9"></td><td data-m="10"></td>
|
||||
<td data-m="11"></td><td data-m="12"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">복숭아</th>
|
||||
<td data-m="1"></td><td data-m="2"></td><td data-m="3"></td>
|
||||
<td data-m="4"></td><td data-m="5"></td>
|
||||
<td class="on" data-m="6"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="7"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="8"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="9"><span class="sr-only">제철</span></td>
|
||||
<td data-m="10"></td><td data-m="11"></td><td data-m="12"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">포도</th>
|
||||
<td data-m="1"></td><td data-m="2"></td><td data-m="3"></td>
|
||||
<td data-m="4"></td><td data-m="5"></td><td data-m="6"></td>
|
||||
<td class="on" data-m="7"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="8"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="9"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="10"><span class="sr-only">제철</span></td>
|
||||
<td data-m="11"></td><td data-m="12"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">무화과</th>
|
||||
<td data-m="1"></td><td data-m="2"></td><td data-m="3"></td>
|
||||
<td data-m="4"></td><td data-m="5"></td><td data-m="6"></td>
|
||||
<td data-m="7"></td>
|
||||
<td class="on" data-m="8"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="9"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="10"><span class="sr-only">제철</span></td>
|
||||
<td data-m="11"></td><td data-m="12"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">사과</th>
|
||||
<td data-m="1"></td><td data-m="2"></td><td data-m="3"></td>
|
||||
<td data-m="4"></td><td data-m="5"></td><td data-m="6"></td>
|
||||
<td data-m="7"></td>
|
||||
<td class="on" data-m="8"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="9"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="10"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="11"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="12"><span class="sr-only">제철</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">배</th>
|
||||
<td data-m="1"></td><td data-m="2"></td><td data-m="3"></td>
|
||||
<td data-m="4"></td><td data-m="5"></td><td data-m="6"></td>
|
||||
<td data-m="7"></td>
|
||||
<td class="on" data-m="8"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="9"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="10"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="11"><span class="sr-only">제철</span></td>
|
||||
<td data-m="12"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">감귤</th>
|
||||
<td class="on" data-m="1"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="2"><span class="sr-only">제철</span></td>
|
||||
<td data-m="3"></td><td data-m="4"></td><td data-m="5"></td>
|
||||
<td data-m="6"></td><td data-m="7"></td><td data-m="8"></td>
|
||||
<td data-m="9"></td>
|
||||
<td class="on" data-m="10"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="11"><span class="sr-only">제철</span></td>
|
||||
<td class="on" data-m="12"><span class="sr-only">제철</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="season-note">
|
||||
달마다 굵은 세로선이 이번 달입니다. 산지와 그해 날씨에 따라 두 주쯤은 앞뒤로
|
||||
움직입니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
5 · 오늘 들어온 것 — 사진이 색을 갖는 자리
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-today" id="today" aria-labelledby="todayHeading">
|
||||
<div class="wrap">
|
||||
<h2 id="todayHeading">오늘 들어온 것</h2>
|
||||
<p class="section-lead">
|
||||
같은 품목이라도 산지가 바뀌면 다른 물건입니다. 누가 어디서 길렀는지를 함께 적습니다.
|
||||
</p>
|
||||
|
||||
<div class="today-grid">
|
||||
<figure class="today-card">
|
||||
<img
|
||||
src="./assets/pear-crate.webp"
|
||||
width="1100"
|
||||
height="825"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="흰 그물망에 하나씩 싼 배가 얕은 나무 상자에 담겨 있고, 한 알은 상자 옆에 꺼내 놓여 있습니다"
|
||||
/>
|
||||
<figcaption>
|
||||
<p class="card-name">원황 배 <span class="card-origin">전남 나주</span></p>
|
||||
<dl class="card-spec mono">
|
||||
<div><dt>등급</dt><dd>상</dd></div>
|
||||
<div><dt>당도</dt><dd>12.8 Bx</dd></div>
|
||||
<div><dt>규격</dt><dd>7.5 kg</dd></div>
|
||||
</dl>
|
||||
<p class="card-note">
|
||||
나주 배는 물이 많고 단맛이 늦게 옵니다. 그물망은 멋이 아니라 눌림을 막는
|
||||
포장입니다.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="today-card">
|
||||
<img
|
||||
src="./assets/grape-bloom.webp"
|
||||
width="900"
|
||||
height="1200"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="흰 종이 위에 놓인 진자주색 포도 송이. 껍질에 하얀 과분이 그대로 남아 있습니다"
|
||||
/>
|
||||
<figcaption>
|
||||
<p class="card-name">캠벨얼리 <span class="card-origin">충북 영동</span></p>
|
||||
<dl class="card-spec mono">
|
||||
<div><dt>등급</dt><dd>상</dd></div>
|
||||
<div><dt>당도</dt><dd>15.4 Bx</dd></div>
|
||||
<div><dt>규격</dt><dd>5 kg</dd></div>
|
||||
</dl>
|
||||
<p class="card-note">
|
||||
껍질에 앉은 흰 가루는 과분입니다. 씻겨 나간 것이 아니라 남아 있는 쪽이
|
||||
덜 만져진 것입니다.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="today-card today-card-wide">
|
||||
<img
|
||||
src="./assets/refractometer.webp"
|
||||
width="1100"
|
||||
height="825"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="스테인리스 작업대 위 굴절당도계의 프리즘 판에 과즙 한 방울이 올려져 있습니다"
|
||||
/>
|
||||
<figcaption>
|
||||
<p class="card-name">당도는 이렇게 잽니다</p>
|
||||
<p class="card-note">
|
||||
굴절당도계에 과즙을 한 방울 올리고 빛이 꺾이는 각을 봅니다.
|
||||
상자마다 재는 것이 아니라 들어온 로트에서 표본을 뽑아 잽니다 —
|
||||
그래서 판의 당도는 그 로트의 값입니다.
|
||||
</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
6 · 납품
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark section-wholesale" id="wholesale" aria-labelledby="wsHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="wsHeading">가게에 넣는 경우</h2>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<p class="lead">
|
||||
카페·베이커리·식당처럼 매일 같은 품질이 필요한 곳은 소매와 다른 조건으로
|
||||
봅니다. 값보다 <strong>편차</strong>가 문제이기 때문입니다.
|
||||
</p>
|
||||
<dl class="ws-rows">
|
||||
<div>
|
||||
<dt>주문 마감</dt>
|
||||
<dd>전날 <span class="mono">21:00</span> — 그래야 새벽 경매에서 물량을 잡습니다</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>배송</dt>
|
||||
<dd>같은 날 오전. 수도권은 직접 돌고, 그 밖은 새벽 물류로 보냅니다</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>등급 고정</dt>
|
||||
<dd>
|
||||
등급을 계약으로 묶습니다. 그날 시세가 올라도 등급을 낮춰 값을 맞추지
|
||||
않습니다
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>시세 공유</dt>
|
||||
<dd>낙찰가를 그대로 전달합니다. 마진은 별도 항목으로 적습니다</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>최소 물량</dt>
|
||||
<dd>품목당 상자 <span class="mono">3</span> 개부터</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<a class="btn" href="mailto:order@saebyeok-cheonggwa.example?subject=%EB%82%A9%ED%92%88%20%EB%AC%B8%EC%9D%98">
|
||||
납품 조건 문의
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
7 · 문의
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-contact" id="contact" aria-labelledby="contactHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="contactHeading">문의</h2>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<dl class="contact-rows">
|
||||
<div>
|
||||
<dt>여는 시각</dt>
|
||||
<dd>새벽 <span class="mono">01:30</span> – 낮 <span class="mono">12:00</span> · 일요일 휴무</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>주문 마감</dt>
|
||||
<dd>전날 <span class="mono">21:00</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>메일</dt>
|
||||
<dd><a class="mono" href="mailto:order@saebyeok-cheonggwa.example">order@saebyeok-cheonggwa.example</a></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="contact-fine">
|
||||
주소·전화·사업자 정보는 실제 상호가 정해진 뒤에 채웁니다.
|
||||
지어낸 값을 임시로 적어 두지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="band band-dark site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p class="foot-name">새벽청과 <span aria-hidden="true">曉靑果</span></p>
|
||||
<p class="foot-line">새벽 경매에서 받아 그날 아침에 보냅니다</p>
|
||||
<p class="foot-fine">
|
||||
이 페이지는 designpaca 쇼케이스입니다. 실제 영업하는 상호가 아니며,
|
||||
판의 숫자는 화면 구성을 보이기 위한 예시입니다. 경매 시각·등급·당도 표시 규정 등
|
||||
제도에 관한 설명은 실제 농산물 표준규격과 도매시장 운영 방식을 따릅니다.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
/* 이번 달 칸에 표시를 남긴다. 달이 바뀌면 저절로 따라가야 하므로 이것만 스크립트다. */
|
||||
(() => {
|
||||
const m = new Date().getMonth() + 1;
|
||||
document.querySelectorAll(`.season [data-m="${m}"]`).forEach((el) => {
|
||||
el.classList.add("is-now");
|
||||
});
|
||||
const head = document.querySelector(`.season thead [data-m="${m}"]`);
|
||||
if (head) head.setAttribute("aria-current", "date");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/site/public/work/saebyeok-cheonggwa/og.png
Normal file
|
After Width: | Height: | Size: 521 KiB |
831
apps/site/public/work/saebyeok-cheonggwa/styles/main.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
128
apps/site/public/work/saebyeok-cheonggwa/styles/tokens.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
643
apps/site/public/work/twentyoz-corp/index.html
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>트웬티온스 — 네 개의 현장에서 돌아가는 소프트웨어</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="2020년 설립. 가상현실·산업용 IoT·이커머스 솔루션 전문 기업. VIVEN·EduBill·XD Hub·XD Works 네 개의 자체 제품이 강의실, 데이터센터, 공공기관, 공장 현장에서 돌아간다."
|
||||
/>
|
||||
<meta property="og:title" content="트웬티온스 — 네 개의 현장" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="회사 소개 대신 한 일을 앞에 둔다. 6년 159건, 자체 제품 4종."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="theme-color" content="#fafaf8" />
|
||||
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap"
|
||||
/>
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23111214'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='15' font-weight='700' fill='%23FAFAF8' text-anchor='middle'%3E20%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main">본문으로 건너뛰기</a>
|
||||
|
||||
<header class="site-head">
|
||||
<div class="wrap head-inner">
|
||||
<a class="wordmark" href="#top">
|
||||
<span class="wordmark-badge mono" aria-hidden="true">20</span>
|
||||
<span>트웬티온스</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="주요 섹션">
|
||||
<a href="#products">제품</a>
|
||||
<a href="#ledger">한 일</a>
|
||||
<a href="#record">기록</a>
|
||||
<a href="#contact">연락</a>
|
||||
</nav>
|
||||
<p class="head-meta mono">EST. 2020</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
1 · 히어로 = 현장 선택기.
|
||||
회사 이름과 슬로건을 첫 화면에 두지 않는다 — 감수한 리스크다
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light hero" id="top" aria-labelledby="heroHeading">
|
||||
<div class="wrap">
|
||||
<h1 id="heroHeading">
|
||||
네 개의 현장에서<br />
|
||||
돌아가고 있다
|
||||
</h1>
|
||||
<p class="hero-lead">
|
||||
트웬티온스는 2020년 1월에 세운 가상현실·산업용 IoT·이커머스 솔루션 회사입니다.
|
||||
어느 현장의 이야기부터 보시겠습니까.
|
||||
</p>
|
||||
|
||||
<form class="fields">
|
||||
<fieldset>
|
||||
<legend class="sr-only">현장 고르기</legend>
|
||||
|
||||
<input type="radio" name="field" id="field-edu" class="field-radio" checked />
|
||||
<input type="radio" name="field" id="field-dc" class="field-radio" />
|
||||
<input type="radio" name="field" id="field-gov" class="field-radio" />
|
||||
<input type="radio" name="field" id="field-plant" class="field-radio" />
|
||||
|
||||
<div class="field-tabs">
|
||||
<label for="field-edu">
|
||||
<span class="tab-num mono">01</span>
|
||||
<span class="tab-name">강의실과 훈련장</span>
|
||||
</label>
|
||||
<label for="field-dc">
|
||||
<span class="tab-num mono">02</span>
|
||||
<span class="tab-name">데이터센터와 발전소</span>
|
||||
</label>
|
||||
<label for="field-gov">
|
||||
<span class="tab-num mono">03</span>
|
||||
<span class="tab-name">공공기관과 대학 행정</span>
|
||||
</label>
|
||||
<label for="field-plant">
|
||||
<span class="tab-num mono">04</span>
|
||||
<span class="tab-name">공장과 설비 현장</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="field-panels">
|
||||
<article class="field-panel" data-panel="edu">
|
||||
<p class="panel-what">
|
||||
헤드셋을 쓴 학생이 실습을 하고, 강사는 그 장면을 밖에서 본다.
|
||||
캐릭터의 몸짓은 웹캠 한 대로 잡는다.
|
||||
</p>
|
||||
<dl class="panel-facts">
|
||||
<div>
|
||||
<dt>제품</dt>
|
||||
<dd><strong>VIVEN</strong> — 몰입형 VR 교육·훈련 플랫폼</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기술</dt>
|
||||
<dd class="mono">Unity3D × WebXR × MediaPipe</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>특징</dt>
|
||||
<dd>PC VMC · Mobile VMC 모듈로 캐릭터 실시간 모션캡처</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>납품 사례</dt>
|
||||
<dd>미래기술학교 운영 (2024) · 메타버스 기반 교육 플랫폼</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>성숙도</dt>
|
||||
<dd class="mono">TRL 9 · 2021 – 현재</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="field-panel" data-panel="dc">
|
||||
<p class="panel-what">
|
||||
설비 여섯 종류가 각자의 프로토콜로 떠드는 것을 한 엔진이 받아
|
||||
같은 시간축에 올린다. 그 위에서 운전이 자동으로 돈다.
|
||||
</p>
|
||||
<dl class="panel-facts">
|
||||
<div>
|
||||
<dt>제품</dt>
|
||||
<dd>
|
||||
<strong>XD Hub</strong> — 산업용 IoT 데이터 통합 엔진<br />
|
||||
<strong>XD Works</strong> — DCIM · FEMS 통합 운영 애플리케이션
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기술</dt>
|
||||
<dd class="mono">
|
||||
Java Spring Boot × Netty × TimescaleDB<br />
|
||||
Spring Boot × NestJS × TypeScript
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>특징</dt>
|
||||
<dd>6종 산업 프로토콜을 단일 엔진으로 수집·전처리·전송</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>납품 사례</dt>
|
||||
<dd>
|
||||
과천 데이터 센터 · LS 일렉 청주 FEMS · 제주 폐기물 EMS ·
|
||||
비금도 EMS · 한유 AFIMS (모두 2024)
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>성숙도</dt>
|
||||
<dd class="mono">TRL 8 · 2022 – 현재</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="field-panel" data-panel="gov">
|
||||
<p class="panel-what">
|
||||
해외 AI 서비스를 쓰고 싶은데 법인카드도 세금계산서도 안 되는 기관이 있다.
|
||||
그 사이에 들어가 원화 결제와 증빙을 대신한다.
|
||||
</p>
|
||||
<dl class="panel-facts">
|
||||
<div>
|
||||
<dt>제품</dt>
|
||||
<dd><strong>EduBill</strong> — AI SaaS 구독 대행 플랫폼</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기술</dt>
|
||||
<dd class="mono">Next.js × TypeScript × Supabase PostgreSQL</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>쓰는 곳</dt>
|
||||
<dd>공공기관 · 대학 · 초중고 · 기업 HRD</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>납품 사례</dt>
|
||||
<dd>KDIS EAI (2024) · EduBill (2024)</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>성숙도</dt>
|
||||
<dd class="mono">TRL 8 · 2024 – 현재</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article class="field-panel" data-panel="plant">
|
||||
<p class="panel-what">
|
||||
현장에서는 사무실 화면이 안 열린다. 장갑 낀 손과 밝은 햇빛,
|
||||
끊기는 통신을 전제로 다시 만든다.
|
||||
</p>
|
||||
<dl class="panel-facts">
|
||||
<div>
|
||||
<dt>성격</dt>
|
||||
<dd>고객사 설비·공정에 맞춰 짓는 현장 애플리케이션</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>납품 사례</dt>
|
||||
<dd>
|
||||
브래키츠 BSLIM · 영진EL 2.0 스마트 APP · LS 전선 부스덕트 (모두 2024)
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>기술</dt>
|
||||
<dd class="mono">Spring Boot / PostgreSQL / WebSocket · Vue3</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>가장 무거운 것</dt>
|
||||
<dd>
|
||||
ACS (Auto Control System) — Unity Engine · RFS 실시간 유체 시뮬레이션 ·
|
||||
Transformer 기반 AI (2024)
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
2 · 회사
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-about" id="about" aria-labelledby="aboutHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="aboutHeading">여섯 해 동안 쌓인 것</h2>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<p class="lead">
|
||||
서로 다른 산업의 프로젝트 경험과 새 기술을 붙여, 고객이 다음 성장 동력을 찾도록
|
||||
돕는 것이 하는 일입니다. 에너지, 건물 자동화, 헬스케어처럼 틀리면 곤란한 영역이
|
||||
주 무대입니다.
|
||||
</p>
|
||||
<dl class="counts">
|
||||
<div>
|
||||
<dt>수행 프로젝트</dt>
|
||||
<dd class="mono">159<span>건</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>고객사</dt>
|
||||
<dd class="mono">42<span>곳</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>구성원</dt>
|
||||
<dd class="mono">40<span>명</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>특허</dt>
|
||||
<dd class="mono">4<span>건</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="counts-note">
|
||||
위 넷은 회사가 공개한 값입니다. 아래 <a href="#ledger">한 일</a> 목록에는
|
||||
그중 공개 가능한 49건의 일부를 이름·연도·기술 스택과 함께 적었습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
3 · 제품 — 반전 밴드. 제품은 기계다
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark section-products" id="products" aria-labelledby="prodHeading">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">자체 제품</p>
|
||||
<h2 id="prodHeading">팔러 다니는 것이 아니라 계속 고치는 것</h2>
|
||||
<p class="section-lead">
|
||||
네 개 모두 특정 고객의 요구에서 시작해 제품이 됐습니다. 지금도 각자의 개발 주기가
|
||||
돌아가고 있습니다.
|
||||
</p>
|
||||
|
||||
<ol class="product-list">
|
||||
<li>
|
||||
<p class="prod-id mono">V–01</p>
|
||||
<div class="prod-body">
|
||||
<h3>VIVEN <span class="prod-kind">메타버스 플랫폼</span></h3>
|
||||
<p>
|
||||
기업과 교육기관을 위한 몰입형 VR 교육·훈련 플랫폼. PC VMC 와 Mobile VMC
|
||||
모듈로 캐릭터 실시간 모션캡처를 지원합니다.
|
||||
</p>
|
||||
<p class="prod-stack mono">Unity3D × WebXR × MediaPipe</p>
|
||||
</div>
|
||||
<dl class="prod-meta mono">
|
||||
<div><dt>규모</dt><dd>Enterprise</dd></div>
|
||||
<div><dt>주기</dt><dd>2021 –</dd></div>
|
||||
<div><dt>성숙도</dt><dd>TRL 9</dd></div>
|
||||
</dl>
|
||||
</li>
|
||||
<li>
|
||||
<p class="prod-id mono">V–02</p>
|
||||
<div class="prod-body">
|
||||
<h3>EduBill <span class="prod-kind">교육 SaaS</span></h3>
|
||||
<p>
|
||||
공공기관·대학·초중고·기업 HRD 를 위한 AI SaaS 구독 대행 플랫폼.
|
||||
해외 AI 서비스를 원화 결제와 세금계산서 발행으로 들여옵니다.
|
||||
</p>
|
||||
<p class="prod-stack mono">Next.js × TypeScript × Supabase PostgreSQL</p>
|
||||
</div>
|
||||
<dl class="prod-meta mono">
|
||||
<div><dt>규모</dt><dd>B2G · 교육</dd></div>
|
||||
<div><dt>주기</dt><dd>2024 –</dd></div>
|
||||
<div><dt>성숙도</dt><dd>TRL 8</dd></div>
|
||||
</dl>
|
||||
</li>
|
||||
<li>
|
||||
<p class="prod-id mono">V–03</p>
|
||||
<div class="prod-body">
|
||||
<h3>XD Hub <span class="prod-kind">산업용 IoT</span></h3>
|
||||
<p>
|
||||
데이터센터·제조·에너지·빌딩 도메인을 위한 산업용 IoT 데이터 통합 엔진.
|
||||
6종 산업 프로토콜을 단일 엔진으로 수집·전처리·전송합니다.
|
||||
</p>
|
||||
<p class="prod-stack mono">Java Spring Boot × Netty × TimescaleDB</p>
|
||||
</div>
|
||||
<dl class="prod-meta mono">
|
||||
<div><dt>규모</dt><dd>설비</dd></div>
|
||||
<div><dt>주기</dt><dd>2022 –</dd></div>
|
||||
<div><dt>성숙도</dt><dd>TRL 8</dd></div>
|
||||
</dl>
|
||||
</li>
|
||||
<li>
|
||||
<p class="prod-id mono">V–04</p>
|
||||
<div class="prod-body">
|
||||
<h3>XD Works <span class="prod-kind">DCIM · FEMS</span></h3>
|
||||
<p>
|
||||
XD Hub 가 모은 데이터를 받아 데이터센터 코로케이션, 빌딩, 공장, 발전소의
|
||||
운영을 자동화하는 통합 운영 애플리케이션입니다.
|
||||
</p>
|
||||
<p class="prod-stack mono">Spring Boot × NestJS × TypeScript</p>
|
||||
</div>
|
||||
<dl class="prod-meta mono">
|
||||
<div><dt>규모</dt><dd>설비 · B2B</dd></div>
|
||||
<div><dt>주기</dt><dd>2023 –</dd></div>
|
||||
<div><dt>성숙도</dt><dd>TRL 8</dd></div>
|
||||
</dl>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
4 · 한 일 — 프로젝트 원장
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-ledger" id="ledger" aria-labelledby="ledgerHeading">
|
||||
<div class="wrap">
|
||||
<h2 id="ledgerHeading">한 일</h2>
|
||||
<p class="section-lead">
|
||||
공개 가능한 49건 가운데 12건입니다. 회사 소개서에 쓰는 문장 대신 이름과 연도,
|
||||
그리고 무엇으로 만들었는지를 적습니다.
|
||||
</p>
|
||||
|
||||
<table class="ledger">
|
||||
<caption class="sr-only">트웬티온스 수행 프로젝트 12건 — 이름, 연도, 사용 기술</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="col-id">번호</th>
|
||||
<th scope="col">프로젝트</th>
|
||||
<th scope="col" class="col-year">연도</th>
|
||||
<th scope="col">기술</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="mono">001</td>
|
||||
<th scope="row">EduBill</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Next.js · TypeScript · Supabase PostgreSQL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">002</td>
|
||||
<th scope="row">과천 데이터 센터</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3 · Spring · PostgreSQL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">003</td>
|
||||
<th scope="row">제주 폐기물 EMS</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3 · Spring · PostgreSQL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">004</td>
|
||||
<th scope="row">KDIS EAI</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3 · Spring · PostgreSQL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">005</td>
|
||||
<th scope="row">LS 일렉 청주 FEMS</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3 · Spring · PostgreSQL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">006</td>
|
||||
<th scope="row">LS 전선 부스덕트</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3 · Spring · PostgreSQL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">007</td>
|
||||
<th scope="row">브래키츠 BSLIM</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Spring Boot · PostgreSQL · WebSocket</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">008</td>
|
||||
<th scope="row">영진EL 2.0 스마트 APP</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">009</td>
|
||||
<th scope="row">미래기술학교 운영</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Unity Engine · Blender · Unreal Engine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">010</td>
|
||||
<th scope="row">한유 AFIMS</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3 · Spring · PostgreSQL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="mono">011</td>
|
||||
<th scope="row">비금도 EMS</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Vue3 · Spring · Java</td>
|
||||
</tr>
|
||||
<tr class="row-mark">
|
||||
<td class="mono">012</td>
|
||||
<th scope="row">ACS (Auto Control System)</th>
|
||||
<td class="mono">2024</td>
|
||||
<td class="mono">Unity Engine · RFS 실시간 유체 시뮬레이션 · Transformer 기반 AI</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="ledger-note">
|
||||
마지막 줄이 지금 가장 멀리 가 있는 일입니다. 데이터센터의 공기 흐름을 GPU 위에서
|
||||
실시간으로 풀어 제어를 검증합니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
5 · 함께 일한 곳
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-partners" id="partners" aria-labelledby="partnersHeading">
|
||||
<div class="wrap">
|
||||
<h2 id="partnersHeading">함께 일한 곳</h2>
|
||||
<p class="section-lead">
|
||||
대학과 대기업 설비 부문이 섞여 있습니다. 어느 쪽이든 검수 기준이 까다로운 곳입니다.
|
||||
</p>
|
||||
<ul class="partners">
|
||||
<li>LS SAUTER</li>
|
||||
<li>경희대학교</li>
|
||||
<li>SAEKI P&C</li>
|
||||
<li>KDI School</li>
|
||||
<li>COSS 사업단</li>
|
||||
<li>클라우드브릭</li>
|
||||
<li>한유</li>
|
||||
<li>게임랩</li>
|
||||
<li>METANET PLATFORM</li>
|
||||
<li>안랩</li>
|
||||
<li>강원대학교</li>
|
||||
<li>중앙대학교</li>
|
||||
<li>건국대학교</li>
|
||||
<li>한화솔루션</li>
|
||||
<li>가비아</li>
|
||||
<li>GS칼텍스</li>
|
||||
<li>SK하이닉스</li>
|
||||
<li>LG디스플레이</li>
|
||||
<li>대구한의대학교</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
6 · 기록 — 반전 밴드
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-dark section-record" id="record" aria-labelledby="recordHeading">
|
||||
<div class="wrap">
|
||||
<p class="eyebrow">최근 기록</p>
|
||||
<h2 id="recordHeading">밖에서 확인된 것</h2>
|
||||
|
||||
<ol class="record-list">
|
||||
<li>
|
||||
<p class="rec-date mono">2026.02.24</p>
|
||||
<div>
|
||||
<h3>경기우수벤처기업 표창 — 경기지방중소벤처기업청장상</h3>
|
||||
<p>기술·경영·혁신 능력이 뛰어나고 사회공헌도가 높은 벤처기업으로 인정</p>
|
||||
</div>
|
||||
<p class="rec-kind">수상 · 인증</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="rec-date mono">2026.01.20</p>
|
||||
<div>
|
||||
<h3>2025 경기도 일자리 우수기업 인증</h3>
|
||||
<p>고용증가율 10% 이상, 근무환경과 복리후생 제도 개선으로 인정</p>
|
||||
</div>
|
||||
<p class="rec-kind">수상 · 인증</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="rec-date mono">2025.11.03</p>
|
||||
<div>
|
||||
<h3>건국대, 시리아·요르단 교육기관 대표단 방문</h3>
|
||||
<p>트웬티온스와 함께 개발한 메타버스 기반 교육 플랫폼 시연</p>
|
||||
</div>
|
||||
<p class="rec-kind">교육 · 협력</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="rec-date mono">2025.10.31</p>
|
||||
<div>
|
||||
<h3>중동 대표단, X-Space 에서 K-에듀테크 체험</h3>
|
||||
<p>메타버스 기반 교육 플랫폼 체험과 협력 논의</p>
|
||||
</div>
|
||||
<p class="rec-kind">교육 · 협력</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="rec-date mono">2025.08.19</p>
|
||||
<div>
|
||||
<h3>과기정통부 2025 가상융합대학원 성과공유회</h3>
|
||||
<p>서강대 개최. AI 시대 인재양성 비전 논의에 참여</p>
|
||||
</div>
|
||||
<p class="rec-kind">행사</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="rec-date mono">2025.07.04</p>
|
||||
<div>
|
||||
<h3>벤처기업 혁신성장유형 재인증</h3>
|
||||
<p>기술 중심의 비전과 디지털 혁신 노력을 인정받음</p>
|
||||
</div>
|
||||
<p class="rec-kind">수상 · 인증</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
7 · 채용
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-career" id="career" aria-labelledby="careerHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="careerHeading">지금은 정규 공고가 닫혀 있습니다</h2>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<p class="lead">
|
||||
다섯 개 직무가 모두 마감 상태입니다. 닫힌 것을 열려 있는 것처럼 적어 두면
|
||||
지원하는 쪽의 시간을 뺏게 되므로, 상태를 그대로 적습니다.
|
||||
</p>
|
||||
<ul class="jobs">
|
||||
<li>
|
||||
<span class="job-name">시니어 소프트웨어 엔지니어</span>
|
||||
<span class="job-hint">산업용 소프트웨어 · C++ / C# / Python · PLC · HMI · SCADA</span>
|
||||
<span class="job-state mono">마감</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="job-name">DevOps 엔지니어</span>
|
||||
<span class="job-hint">컨테이너 · 오케스트레이션 · CI/CD · IaC</span>
|
||||
<span class="job-state mono">마감</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="job-name">프로덕트 매니저</span>
|
||||
<span class="job-hint">IT 서비스 기획·운영 3년 이상 · 데이터 기반 의사결정</span>
|
||||
<span class="job-state mono">마감</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="job-name">서비스 기획자</span>
|
||||
<span class="job-hint">UI/UX 기획 · 와이어프레임 · 애자일 / 스크럼</span>
|
||||
<span class="job-state mono">마감</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="job-name">UI/UX 디자이너</span>
|
||||
<span class="job-hint">Figma · 웹 / 모바일 포트폴리오 · 사용자 중심 설계</span>
|
||||
<span class="job-state mono">마감</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="jobs-open">
|
||||
공고와 무관하게 이야기를 시작할 수는 있습니다.
|
||||
<a href="mailto:hello@twentyoz.kr?subject=%EC%B1%84%EC%9A%A9%20%EB%AC%B8%EC%9D%98">채용 문의 보내기</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
8 · 연락
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<section class="band band-light section-contact" id="contact" aria-labelledby="contactHeading">
|
||||
<div class="wrap split">
|
||||
<div class="split-lead">
|
||||
<h2 id="contactHeading">연락</h2>
|
||||
</div>
|
||||
<div class="split-body">
|
||||
<dl class="contact-rows">
|
||||
<div>
|
||||
<dt>주소</dt>
|
||||
<dd>경기도 화성시 동탄첨단산업1로 27, A909호<br />(영천동, 금강펜테리움 IX 타워)</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>전화</dt>
|
||||
<dd><a class="mono" href="tel:+8207043531190">070-4353-1190</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>메일</dt>
|
||||
<dd><a class="mono" href="mailto:hello@twentyoz.kr">hello@twentyoz.kr</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>설립</dt>
|
||||
<dd class="mono">2020년 1월</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<a class="btn" href="mailto:hello@twentyoz.kr?subject=%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%20%EB%AC%B8%EC%9D%98">
|
||||
프로젝트 문의
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="band band-dark site-foot">
|
||||
<div class="wrap foot-inner">
|
||||
<p class="foot-name">트웬티온스 · TWENTYOZ</p>
|
||||
<p class="foot-line">
|
||||
가상현실 · 산업용 IoT · 이커머스 솔루션 · 경기도 화성시 동탄
|
||||
</p>
|
||||
<p class="foot-fine">© 2020–2026 TWENTYOZ</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/site/public/work/twentyoz-corp/og.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
842
apps/site/public/work/twentyoz-corp/styles/main.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
117
apps/site/public/work/twentyoz-corp/styles/tokens.css
Normal file
|
|
@ -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);
|
||||
}
|
||||