feat(site): redesign promotion experience
This commit is contained in:
parent
6930906583
commit
13dd160c16
39 changed files with 5764 additions and 5738 deletions
|
|
@ -1158,3 +1158,57 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고
|
||||||
- 공개·하위 화면 실렌더 감사: 320·390·768·1440에서 **8/8 통과**. axe, HTML, CSS, JS, 한자 검사도 통과했다.
|
- 공개·하위 화면 실렌더 감사: 320·390·768·1440에서 **8/8 통과**. axe, HTML, CSS, JS, 한자 검사도 통과했다.
|
||||||
- 갤러리 카드 네 장과 공유 이미지 두 장은 확정 화면을 실제 Chrome으로 다시 캡처했다.
|
- 갤러리 카드 네 장과 공유 이미지 두 장은 확정 화면을 실제 Chrome으로 다시 캡처했다.
|
||||||
- 구현 중 발견한 21개 교훈은 `research/showcase-duo-learning-ledger-2026-08-29.md`에 근거와 함께 남겼고, 재사용 가능한 규칙은 `packages/skill/references/trustworthy-showcases.md`와 관련 게이트 문서에 반영했다.
|
- 구현 중 발견한 21개 교훈은 `research/showcase-duo-learning-ledger-2026-08-29.md`에 근거와 함께 남겼고, 재사용 가능한 규칙은 `packages/skill/references/trustworthy-showcases.md`와 관련 게이트 문서에 반영했다.
|
||||||
|
|
||||||
|
## 홍보 사이트 전면 재설계 — Quiet Editorial Proof Surface (2026-08-30)
|
||||||
|
|
||||||
|
### 문제와 방향
|
||||||
|
|
||||||
|
기존 홍보 화면의 떠 있는 유리 GNB, 의미가 약한 수치 메뉴, 화면을 덮는 로그, 서로 경쟁하는 장식은 제거했다. 새 방향은 **조용한 편집 지면 위에 실제 결과 화면이 색과 밀도를 소유하는 증거 중심 사이트**다. 차가운 종이색, 헤어라인 그리드, 흑연색 본문, 신호용 적색 한 색으로 구조를 만들고, 어두운 면은 7단계 과정 구간에만 사용한다.
|
||||||
|
|
||||||
|
기준 레퍼런스는 세 역할로 나눠 측정했다.
|
||||||
|
|
||||||
|
- [Framer](https://www.framer.com/): 1440px에서 약 1200px 콘텐츠 레일, 64px GNB, 54/54px·500 헤드라인처럼 첫 화면의 밀도와 직접 탐색 구조를 참고했다.
|
||||||
|
- [Stripe Press](https://press.stripe.com/): 제품의 실제 표지가 색을 소유하고 주변 인터페이스는 편집 지면으로 물러나는 방식을 이미지 우선 증거 표면에 적용했다.
|
||||||
|
- [Instrument](https://www.instrument.com/): 48px 외곽 여백과 넓은 그리드, 제한된 모션처럼 큰 화면의 여백이 빈 공간이 아니라 위계를 만드는 방식을 참고했다.
|
||||||
|
|
||||||
|
스위스 미니멀을 구조의 기준으로 삼고, 실제 프로젝트 화면을 비대칭 편집 레이아웃에 배치했다. 다른 디자인 스킬의 장식 문법을 한 화면에 합치지 않았으며, 프로젝트 `design.md`의 사실성·성능·검증 계약이 항상 우선한다.
|
||||||
|
|
||||||
|
### 시스템 계약
|
||||||
|
|
||||||
|
- 배경 `#f2f1eb`, raised `#faf9f4`, 본문 `#171b19`, muted `#5e655f`, accent `#c43f20`, focus `#1267d6`를 단일 토큰으로 고정한다.
|
||||||
|
- 본문과 라틴 모두 [Wanted Sans Variable](https://github.com/wanteddev/wanted-sans)을 주 서체로 사용하고 400·470·520만 쓴다. 굵은 600/700과 브라우저 기본 서체를 디자인 상태로 사용하지 않는다. JetBrains Mono는 설치 명령에만 쓴다.
|
||||||
|
- 웹폰트 실패 시의 시스템 폰트는 디자인 선택이 아니라 복원 경로다. 실제 페이지 카피를 100px/400으로 측정해 Windows 한글 85.2%, macOS 한글 93.7%, 라틴 102.3% `size-adjust`를 적용한다.
|
||||||
|
- 본문 control은 최소 44px, 버튼 텍스트 좌우 inset은 최소 8px, 작은 화면의 콘텐츠 gutter는 최소 20px다. 제목은 한국어 `keep-all`, 필요한 식별자만 `anywhere`를 쓴다.
|
||||||
|
- GNB는 전체 폭 불투명 sticky bar다. 4개 핵심 경로를 모바일에서도 직접 보여 주며, 적은 항목을 햄버거 안에 숨기거나 floating glass로 만들지 않는다.
|
||||||
|
|
||||||
|
### 정보 구조·카피·다국어
|
||||||
|
|
||||||
|
- 홈은 `가치 제안 → 실제 결과 → 7단계 과정 → 검증 근거 → 설치` 순서다. 공개 수치 `7단계`, `15개 하드 게이트`, `KO / EN`은 실제 구현과 단일 기준 데이터에 연결한다.
|
||||||
|
- 쇼케이스는 대표 화면 한 개와 나머지 20개 결과를 분리해 같은 작품이 연속 반복되지 않게 한다. 전체 21개 작업의 이름·분류·설계 방향·리스크·alt는 slug 기반 fail-closed 영어 사전과 한국어 원본을 공유한다.
|
||||||
|
- 영어판의 실제 캡처 안 한국어는 원본 프로젝트 인터페이스다. 이를 번역된 화면처럼 꾸미지 않고 결과 섹션의 설명과 대표 캡션에 `Original Korean-language interfaces`라고 명시한다.
|
||||||
|
- 의미가 불분명한 구호, `full log`, 가짜 성과 수치, 제작자 관점의 변명, 과장된 저작권 문구를 제거한다. 라이선스 표기는 저장소의 실제 MIT 상태만 말한다.
|
||||||
|
- canonical, 한국어/영어 hreflang, x-default, OG/Twitter 이미지와 설명, JSON-LD 언어를 각 경로의 실제 콘텐츠와 일치시킨다.
|
||||||
|
|
||||||
|
### 이미지·효과·모션
|
||||||
|
|
||||||
|
- hero는 캡처 콜라주가 아니라 designpaca의 `0~6` 검증 게이트를 일곱 장의 공간 판으로 보여 주는 전용 inline SVG다. 실제 결과 캡처는 바로 다음 섹션으로 내려 원본 정보 구조가 잘리지 않게 하고, 카드 목록은 `cover / top`, 펼친 상세 화면은 전체 판독이 목적이므로 `contain`을 쓴다.
|
||||||
|
- 새 이미지 생성은 하지 않았다. 실제 화면이 이미 더 강한 증거이고, 생성물로 결과를 꾸미면 프로젝트 사실 경계가 흐려지기 때문이다.
|
||||||
|
- Three.js 버전도 별도 프로토타입으로 렌더링해 비교했다. 약 `132KB gzip`의 런타임을 더하면서도 이 장면에서는 흔한 게이트 터널로 읽혀 채택하지 않았다. 같은 의미를 DOM에 가까운 SVG로 더 선명하게 전달하고 WebGL 실패 분기를 없애는 판단이다.
|
||||||
|
- SVG 필터는 전체 화면 효과가 아니라 종이결의 `feTurbulence`와 적색 신호·등고선의 작은 `feDisplacementMap`에만 쓴다. 배경에는 opacity `0.025`의 정적 SVG grain을 유지한다.
|
||||||
|
- hero의 일곱 판은 8px 이내로 한 번 조립되고 적색 스캔도 한 번만 끝난다. `prefers-reduced-motion`에서는 둘 다 제거하며 지속 rAF나 무한 주변 모션은 만들지 않는다.
|
||||||
|
|
||||||
|
### 빌드·검증 계약
|
||||||
|
|
||||||
|
- 4개 공개 경로는 `/`, `/en/`, `/showcase/`, `/en/showcase/`다.
|
||||||
|
- 반응형 기하는 320·390·768·1024·1440·1920·2560px에서 검사하고, 한국어/영어 홈·쇼케이스의 390·1440px 전체 화면을 정확히 8장 캡처한다.
|
||||||
|
- 브라우저 게이트는 실제 text node 기준 h1 줄 수, 문서/요소 overflow, 44px target, 버튼 inset, opaque sticky GNB, 키보드 순서와 focus occlusion, copy 성공·권한 실패, axe A/AA, hreflang/SEO, 감소 모션, 효과 제거, 외부 폰트 실패 폴백, 이미지 decode와 placeholder를 hard fail로 검사한다.
|
||||||
|
- Chromium의 단일 장문 `fullPage` 캡처는 디코드가 끝난 화면 밖 이미지를 회색 surface로 기록할 수 있다. 따라서 증거 캡처는 실제 viewport PNG를 타일로 합성하고, 최종 PNG 안의 각 이미지 rect 픽셀 분산을 다시 검사한다. DOM의 `complete`만으로 시각 통과를 판정하지 않는다.
|
||||||
|
- 최종 로컬 증거 `2026-08-30T08-43-03-335Z`에서 80/80 시나리오, 계약 단언 995/995와 hard lock 5개를 합친 1000/1000 단언, 8/8 캡처가 통과했다. 390px lab LCP/CLS는 홈 한국어 `236ms/0`, 홈 영어 `224ms/0`, 쇼케이스 한국어 `104ms/0`, 쇼케이스 영어 `104ms/0`이다.
|
||||||
|
- 빌드 산출물은 외부 JS 청크가 없고, 홈 상호작용은 작은 inline script만 사용한다. CSS는 경로별 분할하며 폰트 family는 Wanted Sans와 JetBrains Mono 두 개로 제한한다.
|
||||||
|
- 배포는 이 작업의 범위가 아니다. 프로덕션 빌드와 로컬 실제 브라우저 증거가 통과해도 공개 배포 상태를 대신하지 않는다.
|
||||||
|
|
||||||
|
### 사용자 시각 검수 정정 — 홍보 증거 표면 (2026-08-30)
|
||||||
|
|
||||||
|
- 앞선 `실제 결과 화면` 우선 판단은 폐기한다. 홈은 프로젝트 캡처를 모두 제거하고 방향 결정·실제 토큰 이름·검사 계약을 하나의 비대칭 산출물 표면으로 보여 주며, 기존 화면은 고객 성과처럼 말하지 않는 `설계 예시` 아카이브에만 남긴다. 한국어·영어 홈의 산출물 구간에 이미지가 없고 접근 가능한 표본 세 개가 있는지를 E2E 하드 게이트로 고정한다. OG/Twitter도 프로젝트 캡처를 쓰지 않고 `capture-site-og.mjs`가 한국어·영어 실제 히어로를 각각 1200×630으로 다시 만든다.
|
||||||
|
- `2026-08-30` · 국소 — 기존 히어로의 8px 일회성 조립 계약을 대체한다. [Chrome 공식 stacking-cards 예시](https://scroll-driven-animations.style/demos/stacking-cards/css/)의 transform-only scrub 원칙을 참고하되 sticky 구간이나 빈 스크롤 길이는 만들지 않고, 7개 판·등록 핀·신호선을 `scroll(root block)`에 묶는다. 데스크톱은 문서 스크롤 `0→18vh`, 68rem 이하는 `0→58vh`에서 분산 상태와 조립 완료 상태 사이를 양방향으로 연속 보간하며, 미지원 브라우저·`prefers-reduced-motion`은 정적인 조립 완료 상태를 사용한다. KO/EN 변화·역스크롤 원복·감소 모션 불변·이펙트 제거 상태를 E2E 하드 게이트로 고정한다.
|
||||||
|
- `2026-08-30` · 국소 — 히어로 아래 방향 결정·토큰 조판·검증 프레임과 7단계 진행 레일도 각각의 named CSS view timeline에 연결한다. 본문과 수치는 움직이지 않고 방향선·글립·토큰 표본·검사 화살표·짧은 진행선만 transform/opacity로 보간하며, 검증 프레임의 가장 넓은 판은 두 작은 판을 맞추는 고정 기준면으로 남긴다. 모바일 검증 완료 지점은 sticky GNB 아래 제목 여유 24px 이상에서 끝나도록 앞당기고, 미지원 브라우저·감소 모션·이펙트 제거 상태는 정적 완료 화면을 사용한다. KO/EN 전진·역스크롤 geometry 8개, 320~2560px 반응형, effects-off와 감소 모션을 80/80 시나리오·1000/1000 단언으로 고정한다.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs",
|
"test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs",
|
||||||
"test:e2e:showcase-duo": "node tools/showcase-duo-e2e.mjs",
|
"test:e2e:showcase-duo": "node tools/showcase-duo-e2e.mjs",
|
||||||
"capture:showcase-duo": "node tools/capture-showcase-duo.mjs",
|
"capture:showcase-duo": "node tools/capture-showcase-duo.mjs",
|
||||||
|
"capture:site-og": "node tools/capture-site-og.mjs",
|
||||||
"prebuild": "npm run build:showcase-assets",
|
"prebuild": "npm run build:showcase-assets",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"postbuild": "node tools/no-hanja.mjs --root dist",
|
"postbuild": "node tools/no-hanja.mjs --root dist",
|
||||||
|
|
|
||||||
BIN
apps/site/src/assets/og-designpaca-en.png
Normal file
BIN
apps/site/src/assets/og-designpaca-en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 357 KiB |
BIN
apps/site/src/assets/og-designpaca-ko.png
Normal file
BIN
apps/site/src/assets/og-designpaca-ko.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 346 KiB |
|
|
@ -1,138 +0,0 @@
|
||||||
---
|
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import refraction from "../assets/bg/bg-refraction.webp";
|
|
||||||
import paper from "../assets/bg/bg-paper.webp";
|
|
||||||
import split from "../assets/bg/bg-split.webp";
|
|
||||||
import align from "../assets/bg/bg-align.webp";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 배경은 섹션마다 다른 말을 해야 한다. 같은 그림을 전부에 깔면 그건 벽지다.
|
|
||||||
* 넷 다 gpt-image-2 로 만들었고, 프롬프트에 이 페이지의 토큰 값을 그대로 넣었다.
|
|
||||||
*/
|
|
||||||
const SOURCES = { refraction, paper, split, align } as const;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
variant?: keyof typeof SOURCES;
|
|
||||||
/**
|
|
||||||
* `fixed` — 뷰포트에 고정. 콘텐츠만 그 위를 지나간다. 무한히 먼 원경.
|
|
||||||
* `drift` — 스크롤보다 느리게 따라온다. 중경. 종이처럼 가까운 재질에 쓴다.
|
|
||||||
*/
|
|
||||||
mode?: "fixed" | "drift";
|
|
||||||
/** drift 전용. 이미지를 컨테이너보다 이 비율만큼 키우고 그 초과분 전체를 움직인다 */
|
|
||||||
depth?: number;
|
|
||||||
opacity?: number;
|
|
||||||
flip?: boolean;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
variant = "refraction",
|
|
||||||
mode = "fixed",
|
|
||||||
depth = 0.35,
|
|
||||||
opacity = 0.9,
|
|
||||||
flip = false,
|
|
||||||
} = Astro.props;
|
|
||||||
const src = SOURCES[variant];
|
|
||||||
|
|
||||||
// 밝은 종이만 그대로 얹고, 어두운 광원 사진은 더하기 합성한다.
|
|
||||||
const additive = variant !== "paper";
|
|
||||||
|
|
||||||
const scale = 1 + depth;
|
|
||||||
const shift = -(depth / scale) * 100;
|
|
||||||
---
|
|
||||||
|
|
||||||
{/*
|
|
||||||
패럴랙스의 원형은 "배경이 고정되어 원경처럼 보이는 것"이다.
|
|
||||||
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 같은 방향이다.
|
|
||||||
|
|
||||||
`background-attachment: fixed` 는 쓰지 않는다. iOS Safari 가 GPU 메모리 때문에
|
|
||||||
그것을 throttle 해서 스크롤 중 배경이 튀거나 리셋된다.
|
|
||||||
대신 부모에 `clip-path: inset(0)` 을 걸어 **fixed 의 컨테이닝 블록**으로 만든다.
|
|
||||||
그러면 배경은 뷰포트에 고정되면서 그 섹션 영역에서만 보인다.
|
|
||||||
|
|
||||||
주의: 조상에 `transform`·`filter`·`mask` 가 있으면 그게 컨테이닝 블록이 되어
|
|
||||||
이 기법이 통째로 깨진다. 그래서 위아래 페이드 마스크를 포기했다 —
|
|
||||||
마스크를 걸면 고정이 사라진다. 경계는 clip 으로 딱 자른다.
|
|
||||||
*/}
|
|
||||||
<div
|
|
||||||
class="bg-photo"
|
|
||||||
class:list={[mode, { flip, additive }]}
|
|
||||||
aria-hidden="true"
|
|
||||||
style={`--bg-opacity:${opacity}; --bg-h:${scale * 100}%; --bg-shift:${shift.toFixed(2)}%`}
|
|
||||||
>
|
|
||||||
<Image src={src} alt="" widths={[860, 1400, 1920]} sizes="100vw" loading="lazy" decoding="async" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bg-photo {
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
grid-column: full;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 원경: 뷰포트에 고정 ---------------------------------- */
|
|
||||||
.fixed {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
.fixed :global(img) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
opacity: var(--bg-opacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 중경: 스크롤보다 느리게 ------------------------------ */
|
|
||||||
.drift {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
.drift :global(img) {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
/* 컨테이너보다 크다. 이 초과분이 곧 움직일 수 있는 거리다 */
|
|
||||||
height: var(--bg-h);
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
opacity: var(--bg-opacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
어두운 배경 위의 광원 사진은 opacity 로 누르면 안 된다 — 빛까지 같이 죽는다.
|
|
||||||
screen 은 검정을 투명하게 만들고 밝은 곳만 더한다.
|
|
||||||
*/
|
|
||||||
.additive :global(img) { mix-blend-mode: screen; }
|
|
||||||
.flip :global(img) { transform: scaleX(-1); }
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
@supports (animation-timeline: view()) {
|
|
||||||
/* drift 만 움직인다. fixed 는 애초에 안 움직이는 것이 효과다 */
|
|
||||||
.drift :global(img) {
|
|
||||||
animation: bg-parallax linear both;
|
|
||||||
animation-timeline: view();
|
|
||||||
animation-range: cover 0% cover 100%;
|
|
||||||
}
|
|
||||||
.drift.flip :global(img) { animation-name: bg-parallax-flip; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-parallax {
|
|
||||||
from { transform: translateY(0); }
|
|
||||||
to { transform: translateY(var(--bg-shift)); }
|
|
||||||
}
|
|
||||||
@keyframes bg-parallax-flip {
|
|
||||||
from { transform: translateY(0) scaleX(-1); }
|
|
||||||
to { transform: translateY(var(--bg-shift)) scaleX(-1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모션을 줄이는 설정에서는 고정 배경도 부담이 될 수 있다. 흐름에 되돌린다 */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.fixed { position: absolute; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
|
||||||
.additive :global(img) { opacity: calc(var(--bg-opacity) * 0.9); }
|
|
||||||
.bg-photo:not(.additive) :global(img) { opacity: calc(var(--bg-opacity) * 0.7); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -5,99 +5,133 @@ interface Props {
|
||||||
copiedLabel: string;
|
copiedLabel: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { command, copyLabel, copiedLabel, note } = Astro.props;
|
const { command, copyLabel, copiedLabel, note } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
{/* 명령어를 담는 액자. 유리를 쓰는 세 자리 중 하나다 */}
|
<div class="cmd-shell">
|
||||||
<div class="cmd-shell frame">
|
|
||||||
<div class="cmd">
|
<div class="cmd">
|
||||||
<code class="cmd-text">{command}</code>
|
<code class="cmd-text" translate="no"><span aria-hidden="true">$ </span>{command}</code>
|
||||||
<button type="button" class="btn btn-solid cmd-copy" data-copy={command} data-copied={copiedLabel}>
|
<button type="button" class="btn btn-primary cmd-copy" data-copy={command} data-copied={copiedLabel}>
|
||||||
<span class="cmd-copy-label">{copyLabel}</span>
|
<span class="cmd-copy-label">{copyLabel}</span>
|
||||||
<span class="cmd-copy-icon" aria-hidden="true">
|
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="none">
|
<path d="M5.5 10.5 10.5 5.5M6.5 5.5h4v4" />
|
||||||
<path d="M5.5 10.5L10.5 5.5M10.5 5.5H6.5M10.5 5.5V9.5"
|
|
||||||
stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="copy-status sr-only" aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
{note && <p class="cmd-note muted">{note}</p>}
|
{note && <p class="cmd-note muted">{note}</p>}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.cmd-shell { max-width: 30em; min-width: 0; }
|
.cmd-shell {
|
||||||
.cmd {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 32rem;
|
||||||
|
border: var(--line-width) solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
|
padding: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-text {
|
.cmd-text {
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: nowrap;
|
padding-inline: var(--space-2);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
line-height: var(--control-h);
|
||||||
.cmd-text::before {
|
white-space: nowrap;
|
||||||
content: "$ ";
|
scrollbar-width: thin;
|
||||||
color: var(--ink-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 높이·패딩·웨이트·전환은 .btn 이 정한다. 여기서는 이 자리에만
|
.cmd-text span { color: var(--ink-muted); }
|
||||||
필요한 것만 더한다 — 아이콘이 오른쪽 안쪽에 붙어야 해서 그쪽만 좁다 */
|
.cmd-copy { white-space: nowrap; }
|
||||||
.cmd-copy {
|
.cmd-copy svg {
|
||||||
flex: 0 0 auto;
|
fill: none;
|
||||||
padding-right: var(--space-2);
|
stroke: currentColor;
|
||||||
font-family: inherit;
|
stroke-width: 1.5;
|
||||||
}
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
.cmd-copy-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: color-mix(in oklab, var(--accent-ink) 18%, transparent);
|
|
||||||
transition: transform var(--dur-quick) var(--ease-out);
|
transition: transform var(--dur-quick) var(--ease-out);
|
||||||
}
|
}
|
||||||
.cmd-copy:hover .cmd-copy-icon { transform: translate(2px, -2px); }
|
.cmd-copy:hover svg,
|
||||||
|
.cmd-copy:focus-visible svg { transform: translate(2px, -2px); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.cmd-copy svg {
|
||||||
|
transition: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-copy:hover svg,
|
||||||
|
.cmd-copy:focus-visible svg { transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.cmd-note {
|
.cmd-note {
|
||||||
|
max-width: 32rem;
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
font-size: var(--step-0);
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 26rem) {
|
||||||
|
.cmd { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.cmd-copy { width: 100%; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 이 페이지의 유일한 상호작용이다. 라이브러리를 쓰지 않는다.
|
document.querySelectorAll<HTMLButtonElement>(".cmd-copy").forEach((button) => {
|
||||||
document.querySelectorAll<HTMLButtonElement>(".cmd-copy").forEach((btn) => {
|
if (button.dataset["copyReady"] === "true") return;
|
||||||
const label = btn.querySelector<HTMLSpanElement>(".cmd-copy-label");
|
button.dataset["copyReady"] = "true";
|
||||||
if (!label) return;
|
const label = button.querySelector<HTMLSpanElement>(".cmd-copy-label");
|
||||||
btn.addEventListener("click", async () => {
|
const shell = button.closest<HTMLElement>(".cmd-shell");
|
||||||
const text = btn.dataset["copy"] ?? "";
|
const status = shell?.querySelector<HTMLElement>(".copy-status");
|
||||||
const done = btn.dataset["copied"] ?? "OK";
|
const code = shell?.querySelector<HTMLElement>(".cmd-text");
|
||||||
const original = label.textContent;
|
if (!label || !status || !code) return;
|
||||||
|
const original = label.textContent ?? "";
|
||||||
|
let resetTimer: number | undefined;
|
||||||
|
let announceTimer: number | undefined;
|
||||||
|
|
||||||
|
const announce = (message: string) => {
|
||||||
|
window.clearTimeout(announceTimer);
|
||||||
|
status.textContent = "";
|
||||||
|
announceTimer = window.setTimeout(() => {
|
||||||
|
status.textContent = message;
|
||||||
|
}, 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
const command = button.dataset["copy"] ?? "";
|
||||||
|
const copied = button.dataset["copied"] ?? "Copied";
|
||||||
|
window.clearTimeout(resetTimer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(command);
|
||||||
|
label.textContent = copied;
|
||||||
|
announce(document.documentElement.lang === "ko"
|
||||||
|
? "설치 명령을 클립보드에 복사했어요."
|
||||||
|
: "Install command copied to the clipboard.");
|
||||||
} catch {
|
} catch {
|
||||||
// 클립보드 권한이 없으면 선택 상태로 만들어 사용자가 직접 복사하게 둔다
|
|
||||||
const code = btn.parentElement?.querySelector(".cmd-text");
|
|
||||||
if (code) {
|
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(code);
|
range.selectNodeContents(code);
|
||||||
const sel = getSelection();
|
const selection = getSelection();
|
||||||
sel?.removeAllRanges();
|
selection?.removeAllRanges();
|
||||||
sel?.addRange(range);
|
selection?.addRange(range);
|
||||||
|
announce(document.documentElement.lang === "ko"
|
||||||
|
? "설치 명령을 선택했어요. 복사 단축키를 눌러주세요."
|
||||||
|
: "The install command is selected. Use your copy shortcut.");
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
resetTimer = window.setTimeout(() => {
|
||||||
label.textContent = done;
|
label.textContent = original;
|
||||||
label.setAttribute("aria-live", "polite");
|
}, 1600);
|
||||||
setTimeout(() => { label.textContent = original; }, 1600);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
---
|
|
||||||
import { Image } from "astro:assets";
|
|
||||||
import paper from "../assets/bg/bg-paper.webp";
|
|
||||||
import split from "../assets/bg/bg-split.webp";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 데모의 소재. 같은 소재를 두 번 그리고 한쪽에만 필터를 건다.
|
|
||||||
* 변수를 하나만 바꿔야 데모가 무언가를 증명한다 — 소재가 다르면
|
|
||||||
* 보이는 차이가 필터 때문인지 소재 때문인지 알 수 없다.
|
|
||||||
*/
|
|
||||||
interface Props {
|
|
||||||
kind: "scrim" | "cvd" | "goo" | "glass";
|
|
||||||
on: boolean;
|
|
||||||
}
|
|
||||||
const { kind, on } = Astro.props;
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
{
|
|
||||||
kind === "scrim" && (
|
|
||||||
<div class="art art-photo">
|
|
||||||
<Image src={paper} alt="" widths={[300, 600]} sizes="300px" loading="lazy" />
|
|
||||||
<span class:list={["cap", { on }]}>산미 8.5 · 로스팅 2일차</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
kind === "cvd" && (
|
|
||||||
<div class:list={["art art-badges", { on }]}>
|
|
||||||
<span class="badge b-ok">통과</span>
|
|
||||||
<span class="badge b-warn">주의</span>
|
|
||||||
<span class="badge b-bad">실패</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
kind === "goo" && (
|
|
||||||
<div class="art art-goo">
|
|
||||||
{/* 같은 간격으로 놓인 세 덩어리. 왼쪽은 셋으로 보이고 오른쪽은 하나로 보인다.
|
|
||||||
움직임으로 증명하려다 실패했다 — 지나가는 프레임은 방문자도 놓치고
|
|
||||||
측정도 매번 다른 값을 준다. 정지된 두 장이 더 정확하게 말한다. */}
|
|
||||||
<div class:list={["goo-track", { on }]}>
|
|
||||||
<span class="goo-blob" /><span class="goo-blob" /><span class="goo-blob" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
kind === "glass" && (
|
|
||||||
<div class="art art-glass">
|
|
||||||
<Image src={split} alt="" widths={[300, 600]} sizes="300px" loading="lazy" />
|
|
||||||
{/* 격자를 함께 깐다. 굴절은 직선이 휘어야 보인다 —
|
|
||||||
사진만 놓으면 무엇이 어떻게 밀렸는지 알 수 없다 */}
|
|
||||||
<span class="glass-grid" />
|
|
||||||
<span class:list={["glass-panel", { on }]}>산미 8.5</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.art {
|
|
||||||
height: var(--art-h, 118px);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
background: var(--surface-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 스크림 ------------------------------------------------
|
|
||||||
밝은 종이 사진 위의 흰 캡션. 필터가 없으면 대비가 무너진다 */
|
|
||||||
.art-photo :global(img) {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.cap {
|
|
||||||
position: absolute;
|
|
||||||
left: var(--space-3);
|
|
||||||
bottom: var(--space-3);
|
|
||||||
right: var(--space-3);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
font-size: var(--step-0);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.cap.on { filter: url(#f-scrim); }
|
|
||||||
|
|
||||||
/* ---- 색각 --------------------------------------------------
|
|
||||||
상태를 색으로만 구분한 흔한 배지 묶음. 사이트 팔레트가 아니라
|
|
||||||
"현장에서 그렇게 오는" 색이다 — 그게 이 데모의 소재다 */
|
|
||||||
.art-badges {
|
|
||||||
--art-h: 84px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
.art-badges.on { filter: url(#f-deuter); }
|
|
||||||
.badge {
|
|
||||||
padding: 0.4em 0.95em;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--step--1);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
color: #08110f;
|
|
||||||
}
|
|
||||||
/* 적록색약에서 서로 무너지는 조합을 일부러 골랐다.
|
|
||||||
현장에서 상태 배지가 실제로 이렇게 온다 */
|
|
||||||
.b-ok { background: #3fb950; }
|
|
||||||
.b-warn { background: #d29922; }
|
|
||||||
.b-bad { background: #e5534b; }
|
|
||||||
|
|
||||||
/* ---- 구이 ---------------------------------------------------
|
|
||||||
필터는 트랙에만 건다. 라벨까지 걸면 글자가 녹는다 */
|
|
||||||
.art-goo {
|
|
||||||
--art-h: 96px;
|
|
||||||
display: grid;
|
|
||||||
align-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
/* 간격이 이 데모의 유일한 변수다.
|
|
||||||
붙는 조건은 눈대중이 아니라 산수다 — 두 원 사이 중간점의 블러 알파가
|
|
||||||
alpha 램프의 문턱(offset/slope = 7/18 ≒ 0.39)을 넘어야 한다.
|
|
||||||
간격 18px 에 stdDeviation 7 로는 중간점 알파가 0.2 근처라 못 넘었다.
|
|
||||||
실측에서 필터를 켜고도 덩어리가 3개 그대로였다. 간격을 좁힌다. */
|
|
||||||
.goo-track {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.goo-track.on { filter: url(#f-goo); }
|
|
||||||
.goo-blob {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 23px;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ---- 유리 ----------------------------------------------------
|
|
||||||
feImage 는 요소 크기에 맞춰 자동 조정되지 않는다.
|
|
||||||
그래서 패널 크기를 필터의 지도 크기(216×72)와 똑같이 고정한다 */
|
|
||||||
.art-glass :global(img) {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.glass-grid {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background:
|
|
||||||
repeating-linear-gradient(90deg, rgb(255 255 255 / 0.5) 0 1px, transparent 1px 20px),
|
|
||||||
repeating-linear-gradient(0deg, rgb(255 255 255 / 0.5) 0 1px, transparent 1px 20px);
|
|
||||||
}
|
|
||||||
.glass-panel {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
width: 216px;
|
|
||||||
height: 72px;
|
|
||||||
margin: -36px 0 0 -108px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.28);
|
|
||||||
background: rgb(255 255 255 / 0.05);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
/* 굴절을 못 읽는 엔진은 여기서 멈춘다 */
|
|
||||||
backdrop-filter: blur(1px);
|
|
||||||
}
|
|
||||||
.glass-panel.on { backdrop-filter: blur(1px) url(#f-lens); }
|
|
||||||
|
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
|
||||||
.cap { text-shadow: 0 1px 3px rgb(0 0 0 / 0.9); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
---
|
|
||||||
import CopyCommand from "./CopyCommand.astro";
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
c: Content["hero"];
|
|
||||||
ui: Content["ui"];
|
|
||||||
}
|
|
||||||
const { c, ui } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
{/*
|
|
||||||
3D 는 배경이다. 액자에 가두지 않는다.
|
|
||||||
|
|
||||||
한 번 액자 안에 넣었다가 되돌렸다. lusion.co 에서 가져온 "3D 는 액자 안,
|
|
||||||
텍스트는 액자 밖"은 3D 자체가 상품일 때의 규칙이다. 그 스튜디오는 3D 를
|
|
||||||
팔기 때문에 액자가 쇼케이스 프레임으로 정당하다.
|
|
||||||
배경으로 쓸 때는 규칙이 다르다 — 주인공이 아니라 공간을 만드는 재질이고,
|
|
||||||
그래서 대비를 낮추고 글자에 자리를 내준다.
|
|
||||||
|
|
||||||
처음 배경으로 깔았을 때 실패한 이유는 배치가 아니라 둘이었다.
|
|
||||||
판이 정면이라 프레넬이 0 이어서 아무것도 안 보였고(three.md §2),
|
|
||||||
헤드라인이 112px 이라 그나마 보이는 것과도 싸웠다. 둘 다 고쳤다.
|
|
||||||
*/}
|
|
||||||
<section class="full hero">
|
|
||||||
<div class="hero-bg" aria-hidden="true">
|
|
||||||
{/* WebGL 이 없으면 이 그라디언트가 폴백이 아니라 결과물이다 */}
|
|
||||||
<div class="hero-fallback"></div>
|
|
||||||
<canvas id="hero-canvas" class="hero-canvas"></canvas>
|
|
||||||
{/* 글자가 앉는 자리만 눌러준다. 배경이 균일하게 밝으면 본문 대비가 무너진다 */}
|
|
||||||
<div class="hero-scrim"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hero-grid">
|
|
||||||
<h1 class="hero-title">{c.h1}</h1>
|
|
||||||
|
|
||||||
<div class="hero-aside">
|
|
||||||
<p class="lead hero-lead">{c.lead}</p>
|
|
||||||
<CopyCommand
|
|
||||||
command={c.install}
|
|
||||||
copyLabel={ui.copy}
|
|
||||||
copiedLabel={ui.copied}
|
|
||||||
note={c.installNote}
|
|
||||||
/>
|
|
||||||
<p class="hero-links">
|
|
||||||
{c.links.map((l) => <a class="link-grow" href={l.href}>{l.label}</a>)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hero {
|
|
||||||
grid-column: full;
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
overflow: clip;
|
|
||||||
/* 72dvh 는 0.7 화면이었다. 페이지에서 가장 짧은 섹션이 첫인상이라
|
|
||||||
분량 배분이 중요도와 반대였다. 100dvh 로 채우지는 않는다 —
|
|
||||||
다음 섹션의 머리가 조금 보여야 스크롤할 것이 있다는 단서가 된다. */
|
|
||||||
min-height: min(88dvh, 52rem);
|
|
||||||
display: grid;
|
|
||||||
align-content: center;
|
|
||||||
padding-block: calc(var(--header-h) + var(--space-6)) var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 배경 ------------------------------------------------ */
|
|
||||||
.hero-bg {
|
|
||||||
position: absolute;
|
|
||||||
/* 패럴랙스로 내려가는 만큼 위아래에 여유를 둔다.
|
|
||||||
inset: 0 이면 배경이 내려간 자리에 검은 띠가 생긴다 */
|
|
||||||
inset: -4rem 0;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
/* 아래로 갈수록 지운다. black 은 색이 아니라 마스크 알파다.
|
|
||||||
없으면 히어로 하단에서 배경이 가로선으로
|
|
||||||
뚝 끊겨 다음 섹션과 두 장의 종이처럼 보인다. */
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 97%, transparent 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, black 0%, black 97%, transparent 100%);
|
|
||||||
}
|
|
||||||
.hero-fallback,
|
|
||||||
.hero-canvas,
|
|
||||||
.hero-scrim {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.hero-fallback {
|
|
||||||
background:
|
|
||||||
radial-gradient(52% 62% at 24% 26%,
|
|
||||||
color-mix(in oklab, var(--accent) 30%, transparent), transparent 68%),
|
|
||||||
radial-gradient(46% 58% at 82% 62%,
|
|
||||||
color-mix(in oklab, var(--glow-2) 26%, transparent), transparent 66%);
|
|
||||||
}
|
|
||||||
.hero-canvas { opacity: 0; transition: opacity 1.2s var(--ease-soft); }
|
|
||||||
.hero-canvas.is-live { opacity: 1; }
|
|
||||||
|
|
||||||
/* 왼쪽 위(글자가 앉는 쪽)를 눌러 대비를 만든다. 오른쪽 아래는 열어둔다 */
|
|
||||||
.hero-scrim {
|
|
||||||
background:
|
|
||||||
linear-gradient(100deg,
|
|
||||||
color-mix(in oklab, var(--surface) 88%, transparent) 0%,
|
|
||||||
color-mix(in oklab, var(--surface) 62%, transparent) 42%,
|
|
||||||
transparent 78%),
|
|
||||||
linear-gradient(to bottom,
|
|
||||||
color-mix(in oklab, var(--surface) 72%, transparent) 0%,
|
|
||||||
transparent 28%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 글자 ------------------------------------------------ */
|
|
||||||
.hero-grid {
|
|
||||||
max-width: 1180px;
|
|
||||||
width: 100%;
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: var(--gutter);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
|
|
||||||
gap: var(--space-5) var(--space-6);
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title {
|
|
||||||
/* step-4(41.5px)는 h2(33px)보다 8px 클 뿐이라 위계가 서지 않았다.
|
|
||||||
한때 --display(112px)를 썼다가 배경과 싸워서 줄였는데, 줄이다가
|
|
||||||
h2 와 구별이 안 되는 데까지 왔다. 그 사이 값이 필요하다.
|
|
||||||
|
|
||||||
하한이 중요하다. h1 만 vw 에 비례해 줄고 h2 는 고정이라,
|
|
||||||
하한을 2.2rem 으로 두면 390px 화면에서 35.2px 대 33.2px —
|
|
||||||
1.06 배로 사실상 같은 크기가 된다. 하한은 h2 기준으로 정한다.
|
|
||||||
3rem 하한에서는 320px 에서 한 줄에 글자 5개가 안 들어가 4줄로
|
|
||||||
꺾였다. 2.6rem(41.6px)은 h2 의 1.25 배를 유지하면서 3줄에 끊긴다. */
|
|
||||||
font-size: clamp(2.6rem, 1.9rem + 3.1vw, 4.4rem);
|
|
||||||
line-height: 1.08;
|
|
||||||
letter-spacing: var(--tracking-display);
|
|
||||||
max-width: 15ch;
|
|
||||||
/* 좁은 화면에서 왼쪽으로 몰려 4줄로 꺾이던 것을 어절 단위로 균형 있게.
|
|
||||||
2줄(넓은 화면)·3줄(320px) 모두 의도한 끊김으로 바뀐다 */
|
|
||||||
text-wrap: balance;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
[lang="en"] .hero-title { max-width: 18ch; line-height: 1.04; }
|
|
||||||
|
|
||||||
.hero-aside { display: grid; gap: var(--space-3); align-content: center; }
|
|
||||||
.hero-lead {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 30em;
|
|
||||||
color: var(--ink);
|
|
||||||
opacity: 0.86;
|
|
||||||
}
|
|
||||||
.hero-links {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--step-0);
|
|
||||||
color: var(--ink-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
/* min-height 를 걸지 않는다. 콘텐츠가 390px 인데 736px 을 씌우면
|
|
||||||
여백이 화면의 41% 를 먹고, 다음 섹션이 화면 밖으로 밀려나
|
|
||||||
스크롤할 것이 있다는 단서까지 사라진다. */
|
|
||||||
.hero { min-height: 0; }
|
|
||||||
.hero-grid { grid-template-columns: 1fr; gap: var(--space-5); }
|
|
||||||
.hero-title { max-width: 100%; }
|
|
||||||
/* 세로 화면에서는 글자가 위쪽 절반을 다 쓴다. 가로 방향 스크림으로는
|
|
||||||
덮이지 않으므로 위에서 아래로 누른다 */
|
|
||||||
.hero-scrim {
|
|
||||||
background: linear-gradient(to bottom,
|
|
||||||
color-mix(in oklab, var(--surface) 86%, transparent) 0%,
|
|
||||||
color-mix(in oklab, var(--surface) 58%, transparent) 52%,
|
|
||||||
transparent 86%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
<script>
|
|
||||||
/*
|
|
||||||
히어로 배경은 셰이더 플레인 하나다. 무거운 씬을 임포트하지 않는다 —
|
|
||||||
references/three.md §0 이 권하는 그 구조다.
|
|
||||||
|
|
||||||
한때 여기에도 게이트 씬(유리판 여섯 장)을 깔았는데, 그러면 같은 그림이
|
|
||||||
파이프라인 섹션과 페이지에 두 번 나온다. 게이트 은유는 옆에 여섯 단계가
|
|
||||||
적힌 그 섹션의 것이고, 여기는 공간을 만드는 재질이면 된다.
|
|
||||||
*/
|
|
||||||
import { mountShaderPlane } from "../lib/shader-plane";
|
|
||||||
|
|
||||||
const canvas = document.getElementById("hero-canvas") as HTMLCanvasElement | null;
|
|
||||||
|
|
||||||
if (canvas) {
|
|
||||||
mountShaderPlane({
|
|
||||||
canvas,
|
|
||||||
pointer: true,
|
|
||||||
speed: 0.5,
|
|
||||||
colors: { uA: "--accent", uB: "--glow-2" },
|
|
||||||
fragment: `
|
|
||||||
precision mediump float;
|
|
||||||
varying vec2 vUv;
|
|
||||||
uniform float uTime; uniform float uAspect;
|
|
||||||
uniform vec2 uPointer;
|
|
||||||
uniform vec3 uA; uniform vec3 uB;
|
|
||||||
|
|
||||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
||||||
float noise(vec2 p) {
|
|
||||||
vec2 i = floor(p), f = fract(p);
|
|
||||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
||||||
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
|
|
||||||
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
|
|
||||||
}
|
|
||||||
float fbm(vec2 p) {
|
|
||||||
float v = 0.0, a = 0.5;
|
|
||||||
for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec2 p = vUv; p.x *= uAspect;
|
|
||||||
float t = uTime * 0.06;
|
|
||||||
vec2 m = uPointer; m.x *= uAspect;
|
|
||||||
|
|
||||||
// 흐름 필드. 두 번 접어서 빛이 유리를 지난 것처럼 늘어지게 만든다.
|
|
||||||
vec2 q = vec2(fbm(p * 1.5 + t), fbm(p * 1.5 - t * 0.8 + 4.2));
|
|
||||||
vec2 r = vec2(fbm(p * 2.1 + q * 1.6 + t * 0.5),
|
|
||||||
fbm(p * 2.1 + q * 1.6 - t * 0.4 + 1.7));
|
|
||||||
float f = fbm(p * 1.2 + r * 1.9);
|
|
||||||
|
|
||||||
// 광원 둘. 포인터를 아주 조금만 따라간다.
|
|
||||||
vec2 c1 = mix(vec2(0.30 * uAspect, 0.58), m, 0.14);
|
|
||||||
vec2 c2 = vec2(0.80 * uAspect, 0.34);
|
|
||||||
|
|
||||||
float a = smoothstep(1.00, 0.06, distance(p, c1) + f * 0.42);
|
|
||||||
float b = smoothstep(0.92, 0.05, distance(p, c2) + r.y * 0.38);
|
|
||||||
|
|
||||||
// 결이 지나간 자리에 얇은 선이 선다. 유리 가장자리의 굴절 대신이다.
|
|
||||||
float streak = smoothstep(0.48, 0.52, f) - smoothstep(0.52, 0.58, f);
|
|
||||||
|
|
||||||
vec3 col = uA * a + uB * b * 0.9 + (uA + uB) * 0.5 * streak * 0.16;
|
|
||||||
float alpha = clamp(a * 1.05 + b * 0.95 + streak * 0.2, 0.0, 1.0) * 0.8;
|
|
||||||
gl_FragColor = vec4(col, alpha);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
454
apps/site/src/components/HeroSystemGraphic.astro
Normal file
454
apps/site/src/components/HeroSystemGraphic.astro
Normal file
|
|
@ -0,0 +1,454 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
lang?: "ko" | "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lang = "ko" } = Astro.props;
|
||||||
|
const suffix = lang === "en" ? "en" : "ko";
|
||||||
|
const title = lang === "en"
|
||||||
|
? "The designpaca process, assembled as a seven-stage design compiler"
|
||||||
|
: "디자인파카의 7단계 과정을 공간형 디자인 컴파일러로 표현한 그림";
|
||||||
|
const description = lang === "en"
|
||||||
|
? "Seven aligned plates turn a brief into references, direction, tokens, implementation, preflight checks, and a verified result."
|
||||||
|
: "브리프가 레퍼런스, 방향, 토큰, 구현, 프리플라이트를 거쳐 검증된 결과로 조립되는 일곱 개의 층을 보여 줍니다.";
|
||||||
|
const caption = lang === "en"
|
||||||
|
? "Brief · References · Direction · Tokens · Build · Preflight · Proof"
|
||||||
|
: "브리프 · 레퍼런스 · 방향 · 토큰 · 구현 · 프리플라이트 · 결과";
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure class="system-graphic">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 650 560"
|
||||||
|
role="img"
|
||||||
|
aria-labelledby={`system-title-${suffix} system-desc-${suffix}`}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
>
|
||||||
|
<title id={`system-title-${suffix}`}>{title}</title>
|
||||||
|
<desc id={`system-desc-${suffix}`}>{description}</desc>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<pattern id={`system-grid-${suffix}`} width="24" height="24" patternUnits="userSpaceOnUse">
|
||||||
|
<path d="M24 0H0V24" class="field-grid" />
|
||||||
|
</pattern>
|
||||||
|
|
||||||
|
<filter id={`system-field-${suffix}`} x="-8%" y="-8%" width="116%" height="116%" color-interpolation-filters="sRGB">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.026" numOctaves="2" seed="7" result="field" />
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="field" scale="3.5" xChannelSelector="R" yChannelSelector="G" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<filter id={`system-grain-${suffix}`} x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.72" numOctaves="2" seed="19" />
|
||||||
|
<feColorMatrix type="matrix" values="
|
||||||
|
0 0 0 0 0.09
|
||||||
|
0 0 0 0 0.11
|
||||||
|
0 0 0 0 0.10
|
||||||
|
0 0 0 0.16 0
|
||||||
|
" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g aria-hidden="true">
|
||||||
|
<rect x="29" y="29" width="592" height="502" class="field" />
|
||||||
|
<rect x="29" y="29" width="592" height="502" fill={`url(#system-grid-${suffix})`} />
|
||||||
|
<rect x="29" y="29" width="592" height="502" class="field-grain" filter={`url(#system-grain-${suffix})`} />
|
||||||
|
|
||||||
|
<g class="crop-marks">
|
||||||
|
<path d="M29 61V29h32M589 29h32v32M621 499v32h-32M61 531H29v-32" />
|
||||||
|
<path d="M313 29h24M313 531h24M29 268v24M621 268v24" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="contours" filter={`url(#system-field-${suffix})`}>
|
||||||
|
<path d="M69 442c59-47 90-32 131-73 41-42 28-91 79-119 47-25 91-4 136-45 38-34 60-66 130-84" />
|
||||||
|
<path d="M82 470c64-37 102-24 147-67 44-42 36-89 82-113 51-27 97 1 147-38 34-27 54-60 111-75" />
|
||||||
|
<path d="M95 496c72-31 112-16 163-60 47-41 44-83 88-105 49-24 94 10 148-27 27-19 47-43 74-53" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="datum">
|
||||||
|
<path d="M93 448 485 103" />
|
||||||
|
<path d="m93 448 21-5-10-20" />
|
||||||
|
<circle cx="93" cy="448" r="4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Seven plates share one datum. The process is one system, not seven unrelated cards. -->
|
||||||
|
<g class="compiler-stack">
|
||||||
|
<g class="stage-layer stage-01">
|
||||||
|
<polygon class="plate-edge" points="103,423 348,349 526,421 280,496 103,480" />
|
||||||
|
<polygon class="plate plate-dark" points="103,408 348,334 526,405 280,480" />
|
||||||
|
<path class="plate-rule inverse" d="m127 414 221-66 153 61-221 67Z" />
|
||||||
|
<circle class="target inverse" cx="205" cy="417" r="19" />
|
||||||
|
<path class="target inverse" d="M205 390v54M178 417h54" />
|
||||||
|
<path class="micro inverse" d="m323 381 93-28M338 398l120-36M355 415l81-24" />
|
||||||
|
<text class="index inverse" x="121" y="457">01</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="stage-layer stage-02">
|
||||||
|
<polygon class="plate-edge" points="123,359 368,285 546,357 300,432 123,416" />
|
||||||
|
<polygon class="plate" points="123,344 368,270 546,341 300,416" />
|
||||||
|
<path class="plate-rule" d="m147 350 221-66 153 61-221 67Z" />
|
||||||
|
<path class="sample" d="m183 351 75-22 48 19-75 23Zm91-27 75-22 48 19-75 23Z" />
|
||||||
|
<path class="micro" d="m365 351 91-27M379 367l57-17" />
|
||||||
|
<text class="index" x="141" y="393">02</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="stage-layer stage-03">
|
||||||
|
<polygon class="plate-edge" points="143,295 388,221 566,293 320,368 143,352" />
|
||||||
|
<polygon class="plate plate-soft" points="143,280 388,206 566,277 320,352" />
|
||||||
|
<path class="plate-rule" d="m167 286 221-66 153 61-221 67Z" />
|
||||||
|
<circle class="target" cx="258" cy="290" r="24" />
|
||||||
|
<path class="decision" d="m258 290 93-28m-15-8 15 8-8 15" />
|
||||||
|
<path class="micro" d="m390 278 87-26M405 295l52-16" />
|
||||||
|
<text class="index" x="161" y="329">03</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="stage-layer stage-04">
|
||||||
|
<polygon class="plate-edge" points="163,231 408,157 586,229 340,304 163,288" />
|
||||||
|
<polygon class="plate" points="163,216 408,142 586,213 340,288" />
|
||||||
|
<path class="plate-rule" d="m187 222 221-66 153 61-221 67Z" />
|
||||||
|
<g class="token-set">
|
||||||
|
<path d="m222 224 35-11 23 9-36 11Zm44-13 35-10 23 9-35 10Zm44-13 36-10 22 9-35 10Zm44-13 36-11 22 9-35 11Z" />
|
||||||
|
<path d="m247 245 121-36M265 256l83-24" />
|
||||||
|
</g>
|
||||||
|
<text class="index" x="181" y="265">04</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="stage-layer stage-05">
|
||||||
|
<polygon class="plate-edge" points="183,167 428,93 606,165 360,240 183,224" />
|
||||||
|
<polygon class="plate plate-dark" points="183,152 428,78 606,149 360,224" />
|
||||||
|
<path class="plate-rule inverse" d="m207 158 221-66 153 61-221 67Z" />
|
||||||
|
<path class="wireframe inverse" d="m245 164 78-24 48 19-78 24Zm78-24 74-22 48 19-74 22Zm-30 43 78-24v35l-78 24Zm78-24 74-22v35l-74 22" />
|
||||||
|
<path class="micro inverse" d="m466 139 69-21M481 156l35-11" />
|
||||||
|
<text class="index inverse" x="201" y="201">05</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="stage-layer stage-06">
|
||||||
|
<polygon class="plate-edge" points="203,103 448,29 626,101 380,176 203,160" />
|
||||||
|
<polygon class="plate plate-soft" points="203,88 448,14 626,85 380,160" />
|
||||||
|
<path class="plate-rule" d="m227 94 221-66 153 61-221 67Z" />
|
||||||
|
<path class="scan" d="m278 102 160-48 102 41-160 48Z" />
|
||||||
|
<path class="scan-line" d="m307 93 160-48" />
|
||||||
|
<path class="check" d="m479 103 11 10 27-36" />
|
||||||
|
<text class="index" x="221" y="137">06</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- The verified result exits the stack: the only red surface in the scene. -->
|
||||||
|
<g class="stage-layer stage-07">
|
||||||
|
<polygon class="output-shadow" points="362,66 495,26 607,70 473,111" />
|
||||||
|
<polygon class="output" points="350,50 483,10 595,54 461,95" />
|
||||||
|
<path class="output-rule" d="m374 54 109-32 87 35-109 33Z" />
|
||||||
|
<circle class="proof-mark" cx="476" cy="54" r="19" />
|
||||||
|
<path class="proof-mark" d="m466 54 7 7 14-19" />
|
||||||
|
<text class="index output-index" x="371" y="73">07</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="registration-pins">
|
||||||
|
<g class="registration-pin pin-01">
|
||||||
|
<path d="M280 496V480" />
|
||||||
|
<circle cx="280" cy="496" r="3" />
|
||||||
|
</g>
|
||||||
|
<g class="registration-pin pin-02">
|
||||||
|
<path d="M300 432v-16" />
|
||||||
|
<circle cx="300" cy="432" r="3" />
|
||||||
|
</g>
|
||||||
|
<g class="registration-pin pin-03">
|
||||||
|
<path d="M320 368v-16" />
|
||||||
|
<circle cx="320" cy="368" r="3" />
|
||||||
|
</g>
|
||||||
|
<g class="registration-pin pin-04">
|
||||||
|
<path d="M340 304v-16" />
|
||||||
|
<circle cx="340" cy="304" r="3" />
|
||||||
|
</g>
|
||||||
|
<g class="registration-pin pin-05">
|
||||||
|
<path d="M360 240v-16" />
|
||||||
|
<circle cx="360" cy="240" r="3" />
|
||||||
|
</g>
|
||||||
|
<g class="registration-pin pin-06">
|
||||||
|
<path d="M380 176v-16" />
|
||||||
|
<circle cx="380" cy="176" r="3" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="signal-path signal-path-open">
|
||||||
|
<path fill="none" d="M91 451 116 443 136 379 156 315 176 251 196 187 216 123 365 78" />
|
||||||
|
<circle class="signal-node" cx="116" cy="443" r="4" />
|
||||||
|
<circle class="signal-node" cx="136" cy="379" r="4" />
|
||||||
|
<circle class="signal-node" cx="156" cy="315" r="4" />
|
||||||
|
<circle class="signal-node" cx="176" cy="251" r="4" />
|
||||||
|
<circle class="signal-node" cx="196" cy="187" r="4" />
|
||||||
|
<circle class="signal-node" cx="216" cy="123" r="4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- The compact signal is a second exact path. Crossfading avoids an SVG d morph and keeps every node registered. -->
|
||||||
|
<g class="signal-path signal-path-compact">
|
||||||
|
<path fill="none" d="M126 310 151 302 157 288 163 274 169 260 175 246 181 232 278 207.5" />
|
||||||
|
<circle class="signal-node" cx="151" cy="302" r="4" />
|
||||||
|
<circle class="signal-node" cx="157" cy="288" r="4" />
|
||||||
|
<circle class="signal-node" cx="163" cy="274" r="4" />
|
||||||
|
<circle class="signal-node" cx="169" cy="260" r="4" />
|
||||||
|
<circle class="signal-node" cx="175" cy="246" r="4" />
|
||||||
|
<circle class="signal-node" cx="181" cy="232" r="4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="scale-marks">
|
||||||
|
<path d="M558 478h38M558 466v24M575 471v14M593 466v24" />
|
||||||
|
<path d="M67 94V66h28M67 66l10 10M67 66l-10 10" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<figcaption>{caption}</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.system-graphic {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 40.625rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-graphic svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 65 / 56;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-graphic figcaption {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-top: var(--line-width) solid var(--line-strong);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
fill: var(--surface-raised);
|
||||||
|
stroke: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--line);
|
||||||
|
stroke-width: 0.65;
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grain { opacity: 0.19; }
|
||||||
|
|
||||||
|
.crop-marks,
|
||||||
|
.scale-marks {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 1.1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contours {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--line-strong);
|
||||||
|
stroke-width: 0.8;
|
||||||
|
opacity: 0.42;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datum {
|
||||||
|
fill: var(--surface-raised);
|
||||||
|
stroke: var(--ink-muted);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 3 6;
|
||||||
|
opacity: 0.72;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-edge {
|
||||||
|
fill: var(--line-strong);
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate {
|
||||||
|
fill: var(--surface-raised);
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-soft { fill: var(--surface-soft); }
|
||||||
|
.plate-dark { fill: var(--surface-inverse); }
|
||||||
|
|
||||||
|
.plate-rule {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--ink-muted);
|
||||||
|
stroke-width: 0.9;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inverse { stroke: var(--ink-muted-inverse); }
|
||||||
|
|
||||||
|
.target,
|
||||||
|
.decision,
|
||||||
|
.sample,
|
||||||
|
.token-set,
|
||||||
|
.wireframe,
|
||||||
|
.scan,
|
||||||
|
.scan-line,
|
||||||
|
.check {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 1.35;
|
||||||
|
stroke-linecap: square;
|
||||||
|
stroke-linejoin: miter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample,
|
||||||
|
.token-set { fill: var(--surface-soft); }
|
||||||
|
.wireframe.inverse { stroke: var(--ink-inverse); }
|
||||||
|
.scan { stroke-dasharray: 3 4; }
|
||||||
|
.scan-line { stroke: var(--accent); stroke-width: 2; }
|
||||||
|
.check { stroke: var(--accent); stroke-width: 2.8; }
|
||||||
|
|
||||||
|
.micro {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--ink-muted);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.micro.inverse { stroke: var(--ink-muted-inverse); }
|
||||||
|
|
||||||
|
.index {
|
||||||
|
fill: var(--ink-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.index.inverse { fill: var(--ink-muted-inverse); }
|
||||||
|
|
||||||
|
.output-shadow { fill: var(--ink); opacity: 0.16; }
|
||||||
|
.output {
|
||||||
|
fill: var(--accent);
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-rule {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-ink);
|
||||||
|
stroke-width: 0.85;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-mark {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent-ink);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: square;
|
||||||
|
stroke-linejoin: miter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-index { fill: var(--accent-ink); }
|
||||||
|
|
||||||
|
.registration-pins {
|
||||||
|
fill: var(--surface-raised);
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-path {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: square;
|
||||||
|
stroke-linejoin: miter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-node { fill: var(--accent); }
|
||||||
|
|
||||||
|
.stage-layer,
|
||||||
|
.registration-pin {
|
||||||
|
--stack-x: 0px;
|
||||||
|
--stack-y: 0px;
|
||||||
|
transform: translate(var(--stack-x), var(--stack-y));
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-01,
|
||||||
|
.pin-01 { --stack-x: 35px; --stack-y: -141px; }
|
||||||
|
.stage-02,
|
||||||
|
.pin-02 { --stack-x: 21px; --stack-y: -91px; }
|
||||||
|
.stage-03,
|
||||||
|
.pin-03 { --stack-x: 7px; --stack-y: -41px; }
|
||||||
|
.stage-04,
|
||||||
|
.pin-04 { --stack-x: -7px; --stack-y: 9px; }
|
||||||
|
.stage-05,
|
||||||
|
.pin-05 { --stack-x: -21px; --stack-y: 59px; }
|
||||||
|
.stage-06,
|
||||||
|
.pin-06 { --stack-x: -35px; --stack-y: 109px; }
|
||||||
|
.stage-07 { --stack-x: -87px; --stack-y: 129.5px; }
|
||||||
|
|
||||||
|
.signal-path-open { opacity: 0; }
|
||||||
|
.signal-path-compact { opacity: 1; }
|
||||||
|
|
||||||
|
@supports (animation-timeline: scroll()) {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.stage-layer,
|
||||||
|
.registration-pin {
|
||||||
|
animation: system-stack-scroll linear both;
|
||||||
|
animation-timeline: scroll(root block);
|
||||||
|
animation-range: 0 18vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-path-open { animation: system-signal-open linear both; }
|
||||||
|
.signal-path-compact { animation: system-signal-compact linear both; }
|
||||||
|
|
||||||
|
.signal-path-open,
|
||||||
|
.signal-path-compact {
|
||||||
|
animation-timeline: scroll(root block);
|
||||||
|
animation-range: 0 18vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 68rem) {
|
||||||
|
.stage-layer,
|
||||||
|
.registration-pin,
|
||||||
|
.signal-path-open,
|
||||||
|
.signal-path-compact { animation-range: 0 58vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes system-stack-scroll {
|
||||||
|
from { transform: translate(0, 0); }
|
||||||
|
to { transform: translate(var(--stack-x), var(--stack-y)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes system-signal-open {
|
||||||
|
0%, 10% { opacity: 1; }
|
||||||
|
14%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes system-signal-compact {
|
||||||
|
0%, 86% { opacity: 0; }
|
||||||
|
90%, 100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 34rem) {
|
||||||
|
.system-graphic {
|
||||||
|
width: calc(100% + var(--space-2));
|
||||||
|
margin-inline: calc(var(--space-1) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contours,
|
||||||
|
.scale-marks { opacity: 0.62; }
|
||||||
|
|
||||||
|
.index { font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stage-layer,
|
||||||
|
.registration-pin,
|
||||||
|
.signal-path { animation: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
---
|
|
||||||
import CopyCommand from "./CopyCommand.astro";
|
|
||||||
import BgPhoto from "./BgPhoto.astro";
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props { c: Content["install"]; ui: Content["ui"]; }
|
|
||||||
const { c, ui } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<section id="install" class="full install has-bg" aria-labelledby="install-h">
|
|
||||||
<BgPhoto variant="refraction" mode="fixed" opacity={0.85} flip />
|
|
||||||
<div class="install-grid">
|
|
||||||
<div class="install-head">
|
|
||||||
<h2 id="install-h">{c.h2}</h2>
|
|
||||||
<p class="lead install-lead">{c.lead}</p>
|
|
||||||
<CopyCommand command={c.command} copyLabel={ui.copy} copiedLabel={ui.copied} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="install-body">
|
|
||||||
<ul class="targets">
|
|
||||||
{
|
|
||||||
c.targets.map((t) => (
|
|
||||||
<li>
|
|
||||||
<span class="t-name">{t.name}</span>
|
|
||||||
<span class="t-path mono muted">{t.path}</span>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<dl class="cmds">
|
|
||||||
{
|
|
||||||
c.commands.map((x) => (
|
|
||||||
<div class="cmd-row">
|
|
||||||
<dt class="mono">{x.cmd}</dt>
|
|
||||||
<dd class="muted">{x.what}</dd>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.install { padding-block: var(--space-8); }
|
|
||||||
.install-grid {
|
|
||||||
max-width: 1180px;
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: var(--gutter);
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-6);
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.install-lead { margin-block: var(--space-3) var(--space-5); max-width: 26em; }
|
|
||||||
|
|
||||||
.targets { list-style: none; margin: 0 0 var(--space-5); padding: 0; }
|
|
||||||
.targets li {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2) var(--space-3);
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-block: var(--space-2);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.t-name { font-weight: var(--weight-medium); }
|
|
||||||
.t-path {
|
|
||||||
font-size: var(--mono-size);
|
|
||||||
/* 경로는 공백이 없어 min-content 가 363px 다 — anywhere 로 접게 한다 */
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cmds { margin: 0; display: grid; gap: var(--space-3); }
|
|
||||||
.cmd-row {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.cmd-row:last-child { border-bottom: 1px solid var(--line); }
|
|
||||||
dt { color: var(--accent); }
|
|
||||||
dd { margin: 0; font-size: var(--step-0); }
|
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
/* 순수 1fr 은 minmax(auto,1fr) 다 — nowrap 명령어의 min-content 가
|
|
||||||
트랙을 뷰포트 밖까지 밀어낸다(390px 실측 +5px). min 0 이여야 접는다 */
|
|
||||||
.install-grid { grid-template-columns: minmax(0, 1fr); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
501
apps/site/src/components/Landing.astro
Normal file
501
apps/site/src/components/Landing.astro
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
---
|
||||||
|
import CopyCommand from "./CopyCommand.astro";
|
||||||
|
import HeroSystemGraphic from "./HeroSystemGraphic.astro";
|
||||||
|
import OutputSpecimens from "./OutputSpecimens.astro";
|
||||||
|
import type { LandingContent } from "../i18n/landing";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
c: LandingContent;
|
||||||
|
lang: "ko" | "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { c, lang } = Astro.props;
|
||||||
|
const repoUrl = "https://git.chanpaca.net/yunchan/designpaca";
|
||||||
|
const docsUrl = `${repoUrl}#readme`;
|
||||||
|
const showcasePath = lang === "ko" ? "/showcase/" : "/en/showcase/";
|
||||||
|
const copyLabel = c.hero.primaryCta;
|
||||||
|
const copiedLabel = lang === "ko" ? "복사했어요" : "Copied";
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="landing-hero grain" aria-labelledby="hero-title">
|
||||||
|
<div class="hero-grid shell">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<h1 id="hero-title">
|
||||||
|
{c.hero.h1.split("\n").map((line) => <span>{line}</span>)}
|
||||||
|
</h1>
|
||||||
|
<p class="hero-lead">{c.hero.lead}</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<CopyCommand
|
||||||
|
command={c.hero.command}
|
||||||
|
copyLabel={copyLabel}
|
||||||
|
copiedLabel={copiedLabel}
|
||||||
|
/>
|
||||||
|
<a class="btn btn-text" href="#outputs">
|
||||||
|
{c.hero.secondaryCta}
|
||||||
|
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||||
|
<path d="M3 5.5 8 10l5-4.5" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-visual">
|
||||||
|
<HeroSystemGraphic lang={lang} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="outputs" class="output-section">
|
||||||
|
<div class="section-head shell">
|
||||||
|
<h2>{c.outputs.h2}</h2>
|
||||||
|
<div>
|
||||||
|
<p>{c.outputs.lead}</p>
|
||||||
|
<a class="section-link" href={showcasePath}>
|
||||||
|
{c.outputs.archiveCta}
|
||||||
|
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true">
|
||||||
|
<path d="M3 8h10M9 4l4 4-4 4" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-surface shell">
|
||||||
|
<OutputSpecimens lang={lang} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="process" class="process-section grain">
|
||||||
|
<div class="process-grid shell">
|
||||||
|
<header class="process-head">
|
||||||
|
<h2>{c.process.h2}</h2>
|
||||||
|
<p>{c.process.lead}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ol class="stage-list">
|
||||||
|
{c.process.stages.map((stage) => (
|
||||||
|
<li>
|
||||||
|
<span class="stage-index nums">{stage.step}</span>
|
||||||
|
<h3>{stage.name}</h3>
|
||||||
|
<p>{stage.result}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="proof" class="proof-section">
|
||||||
|
<div class="section-head shell">
|
||||||
|
<h2>{c.proof.h2}</h2>
|
||||||
|
<p>{c.proof.lead}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="proof-facts shell">
|
||||||
|
{c.proof.facts.map((fact) => (
|
||||||
|
<div>
|
||||||
|
<dt>
|
||||||
|
<span class="proof-value nums">{fact.value}</span>
|
||||||
|
<span>{fact.label}</span>
|
||||||
|
</dt>
|
||||||
|
<dd>{fact.description}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<details class="audit-details shell">
|
||||||
|
<summary>
|
||||||
|
<span>{c.proof.details.summary}</span>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="M3 5.5 8 10l5-4.5" />
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<ol>
|
||||||
|
{c.proof.details.items.map((item) => <li>{item}</li>)}
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="install" class="install-section">
|
||||||
|
<div class="install-grid shell">
|
||||||
|
<div>
|
||||||
|
<h2>{c.install.h2}</h2>
|
||||||
|
<p class="install-lead">{c.install.lead}</p>
|
||||||
|
</div>
|
||||||
|
<div class="install-action">
|
||||||
|
<CopyCommand
|
||||||
|
command={c.install.command}
|
||||||
|
copyLabel={copyLabel}
|
||||||
|
copiedLabel={copiedLabel}
|
||||||
|
/>
|
||||||
|
<ul>
|
||||||
|
{c.install.support.map((item) => (
|
||||||
|
<li>
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
|
<path d="m3 8 3 3 7-7" />
|
||||||
|
</svg>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div class="install-links">
|
||||||
|
<a href={docsUrl}>{c.install.docsLabel}</a>
|
||||||
|
<a href={repoUrl}>{c.install.repoLabel}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="shell">
|
||||||
|
<p>{c.footer.tagline}</p>
|
||||||
|
<p>{c.footer.license}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.landing-hero {
|
||||||
|
padding-block: var(--space-7) var(--space-9);
|
||||||
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.82fr) minmax(34rem, 1.18fr);
|
||||||
|
gap: var(--space-8);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h1 span { display: block; }
|
||||||
|
|
||||||
|
.hero-lead {
|
||||||
|
max-width: var(--measure-narrow);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--step-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
margin-top: var(--space-7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions :global(.cmd-shell) { width: min(100%, 31rem); }
|
||||||
|
.hero-actions .btn-text svg,
|
||||||
|
.section-link svg,
|
||||||
|
.audit-details summary svg,
|
||||||
|
.install-action li svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-section,
|
||||||
|
.proof-section {
|
||||||
|
padding-block: var(--space-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(20rem, 0.82fr);
|
||||||
|
gap: var(--space-8);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head > div,
|
||||||
|
.section-head > p {
|
||||||
|
max-width: var(--measure-narrow);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-link {
|
||||||
|
min-height: var(--control-h);
|
||||||
|
width: fit-content;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-surface {
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-section {
|
||||||
|
padding-block: var(--space-9);
|
||||||
|
background: var(--surface-inverse);
|
||||||
|
color: var(--ink-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-section.grain::after {
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
opacity: 0.035;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(18rem, 0.72fr) minmax(0, 1.28fr);
|
||||||
|
gap: var(--space-8);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-head {
|
||||||
|
position: sticky;
|
||||||
|
top: calc(var(--header-h) + var(--space-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-head p {
|
||||||
|
max-width: var(--measure-narrow);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
color: var(--ink-muted-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: var(--line-width) solid var(--line-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list li {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.5rem minmax(10rem, 0.62fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-4) var(--space-6);
|
||||||
|
align-items: baseline;
|
||||||
|
padding-block: var(--space-5);
|
||||||
|
border-bottom: var(--line-width) solid var(--line-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-index {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-list h3 { font-size: var(--step-1); }
|
||||||
|
.stage-list p {
|
||||||
|
color: var(--ink-muted-inverse);
|
||||||
|
font-size: var(--step-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: view()) {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.stage-list li::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-start: 0;
|
||||||
|
bottom: calc(var(--line-width) * -1);
|
||||||
|
width: 2.5rem;
|
||||||
|
height: var(--line-width);
|
||||||
|
background: var(--accent);
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: stage-rail-register linear both;
|
||||||
|
animation-timeline: view(block);
|
||||||
|
animation-range: entry 12% cover 32%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stage-rail-register {
|
||||||
|
from { opacity: 0.25; transform: scaleX(0); }
|
||||||
|
to { opacity: 1; transform: scaleX(1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-facts {
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
border-block: var(--line-width) solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-facts > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-facts > div + div { border-left: var(--line-width) solid var(--line); }
|
||||||
|
|
||||||
|
.proof-facts dt { display: grid; gap: var(--space-2); }
|
||||||
|
.proof-value {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: clamp(2.5rem, 2rem + 2vw, 4rem);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-facts dd {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-details {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
border-bottom: var(--line-width) solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-details summary {
|
||||||
|
min-height: var(--control-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-block: var(--space-4);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-details summary::-webkit-details-marker { display: none; }
|
||||||
|
.audit-details summary::marker { content: ""; }
|
||||||
|
.audit-details summary svg { transition: transform var(--dur-quick) var(--ease-out); }
|
||||||
|
.audit-details[open] summary svg { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.audit-details ol {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 var(--space-6);
|
||||||
|
list-style: none;
|
||||||
|
counter-reset: audit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-details li {
|
||||||
|
counter-increment: audit;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
border-top: var(--line-width) solid var(--line);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.audit-details li::before {
|
||||||
|
content: counter(audit, decimal-leading-zero);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--mono-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-section {
|
||||||
|
padding-block: var(--space-9);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border-block: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.95fr) minmax(28rem, 1.05fr);
|
||||||
|
gap: var(--space-8);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-lead {
|
||||||
|
max-width: var(--measure-narrow);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-action :global(.cmd-shell) { max-width: none; }
|
||||||
|
|
||||||
|
.install-action ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--space-6) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
border-top: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-action li {
|
||||||
|
min-height: var(--control-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-block: var(--space-3);
|
||||||
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-action li svg { color: var(--accent); }
|
||||||
|
|
||||||
|
.install-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-4) var(--space-6);
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.install-links a {
|
||||||
|
min-height: var(--control-h);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer { padding-block: var(--space-7); }
|
||||||
|
.site-footer .shell {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-6);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
.site-footer p:first-child { max-width: 32rem; color: var(--ink); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.audit-details summary svg { transition: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 68rem) {
|
||||||
|
.hero-grid { grid-template-columns: minmax(0, 1fr); gap: var(--space-8); }
|
||||||
|
.hero-copy { max-width: 44rem; }
|
||||||
|
.process-grid { grid-template-columns: minmax(15rem, 0.62fr) minmax(0, 1.38fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 56rem) {
|
||||||
|
.hero-grid,
|
||||||
|
.section-head,
|
||||||
|
.process-grid,
|
||||||
|
.install-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.hero-grid { gap: var(--space-8); }
|
||||||
|
.hero-copy { max-width: 44rem; }
|
||||||
|
.process-head { position: static; }
|
||||||
|
.section-head { gap: var(--space-5); }
|
||||||
|
.proof-facts { grid-template-columns: 1fr; }
|
||||||
|
.proof-facts > div + div { border-left: 0; border-top: var(--line-width) solid var(--line); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 47.5rem) {
|
||||||
|
.landing-hero { padding-top: var(--space-8); }
|
||||||
|
.hero-copy h1 { font-size: clamp(2.2rem, 11.2vw, 2.8125rem); }
|
||||||
|
:global(html[lang="en"]) .hero-copy h1 { font-size: clamp(2.25rem, 10.2vw, 2.65rem); }
|
||||||
|
.stage-list li {
|
||||||
|
grid-template-columns: 2rem minmax(0, 1fr);
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
}
|
||||||
|
.stage-list p { grid-column: 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 34rem) {
|
||||||
|
.hero-actions { align-items: stretch; }
|
||||||
|
.hero-actions :global(.cmd-shell),
|
||||||
|
.hero-actions .btn-text { width: 100%; }
|
||||||
|
.site-footer .shell { display: grid; align-items: start; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,248 +0,0 @@
|
||||||
---
|
|
||||||
import BgPhoto from "./BgPhoto.astro";
|
|
||||||
import FilterArt from "./FilterArt.astro";
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props { c: Content["materials"]; }
|
|
||||||
const { c } = Astro.props;
|
|
||||||
|
|
||||||
/* 변위 지도 두 장. 가장자리에서 안쪽으로 미는 값만 담고 가운데는 중립(128)이다.
|
|
||||||
`#` 이 data URI 를 끊으므로 색은 rgb() 로 적는다. */
|
|
||||||
const W = 216;
|
|
||||||
const H = 72;
|
|
||||||
const map = (vertical: boolean, from: string, to: string) => {
|
|
||||||
const dir = vertical ? 'x1="0" y1="0" x2="0" y2="1"' : 'x1="0" y1="0" x2="1" y2="0"';
|
|
||||||
const svg =
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">` +
|
|
||||||
`<defs><linearGradient id="g" ${dir}>` +
|
|
||||||
`<stop offset="0" stop-color="${from}"/>` +
|
|
||||||
`<stop offset="0.30" stop-color="rgb(128,128,128)"/>` +
|
|
||||||
`<stop offset="0.70" stop-color="rgb(128,128,128)"/>` +
|
|
||||||
`<stop offset="1" stop-color="${to}"/>` +
|
|
||||||
`</linearGradient></defs>` +
|
|
||||||
`<rect width="${W}" height="${H}" fill="url(#g)"/></svg>`;
|
|
||||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
|
||||||
};
|
|
||||||
const MAP_X = map(false, "rgb(255,128,128)", "rgb(0,128,128)");
|
|
||||||
const MAP_Y = map(true, "rgb(128,255,128)", "rgb(128,0,128)");
|
|
||||||
---
|
|
||||||
|
|
||||||
{/*
|
|
||||||
이 섹션은 한동안 "필터로 재질 만들기" 였다 — 추상 사각형에 그레인·굴절·듀오톤.
|
|
||||||
화면에서 아무 말도 하지 않았다. 필터가 실제로 값을 하는 자리는 질감이 아니라
|
|
||||||
**실무에서 반복되는 네 가지 수작업**이고, 넷 다 이 스킬의 프리플라이트 항목이다.
|
|
||||||
왼쪽이 문제, 오른쪽이 같은 소재에 선언 하나만 얹은 것이다.
|
|
||||||
*/}
|
|
||||||
<section class="section materials has-bg" aria-labelledby="mat-h">
|
|
||||||
<BgPhoto variant="refraction" mode="fixed" opacity={0.9} />
|
|
||||||
<p class="eyebrow reveal">{c.eyebrow}</p>
|
|
||||||
<h2 id="mat-h" class="reveal">{c.h2}</h2>
|
|
||||||
<p class="lead mat-lead reveal">{c.lead}</p>
|
|
||||||
|
|
||||||
<div class="full stage glass">
|
|
||||||
<span class="rim" aria-hidden="true"></span>
|
|
||||||
<div class="stage-fallback" aria-hidden="true"></div>
|
|
||||||
{/* GPU 가 만드는 배경. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */}
|
|
||||||
<canvas id="mat-canvas" class="stage-canvas" aria-hidden="true"></canvas>
|
|
||||||
|
|
||||||
<ol role="list" class="fdemos">
|
|
||||||
{
|
|
||||||
c.demos.map((d, i) => (
|
|
||||||
<li class="fdemo reveal" style={`--reveal-delay:${i * 70}ms`}>
|
|
||||||
<h3 class="fd-name">{d.name}</h3>
|
|
||||||
|
|
||||||
<div class="fd-pair">
|
|
||||||
{[false, true].map((on) => (
|
|
||||||
<figure class="fd-side">
|
|
||||||
<FilterArt kind={d.id} on={on} />
|
|
||||||
<figcaption class:list={["fd-tag", "mono", on ? "is-good" : "is-bad"]}>
|
|
||||||
{on ? d.afterLabel : d.beforeLabel}
|
|
||||||
</figcaption>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fd-text">
|
|
||||||
<p class="fd-problem">{d.problem}</p>
|
|
||||||
<p class="fd-how muted">{d.how}</p>
|
|
||||||
<p class="fd-verdict mono">{d.verdict}</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
필터 정의. 화면에 그려지지 않고 참조만 된다.
|
|
||||||
|
|
||||||
전부 color-interpolation-filters 를 명시한다. 기본값은 linearRGB 인데,
|
|
||||||
블렌드와 합성이 씻겨 보인다 — 대부분 sRGB 가 맞다.
|
|
||||||
단 하나, 색각 시뮬레이션은 반대다. 색각 변환은 물리적인 빛의 혼합이라
|
|
||||||
선형 공간에서 계산해야 실제로 그 사람이 보는 색이 나온다.
|
|
||||||
*/}
|
|
||||||
<svg class="visually-hidden" aria-hidden="true" focusable="false">
|
|
||||||
<defs>
|
|
||||||
<!--
|
|
||||||
글자 모양대로 생기는 스크림.
|
|
||||||
박스를 그리는 대신 알파를 부풀려 그 모양만 채운다.
|
|
||||||
영역을 넉넉히 잡지 않으면 부풀린 만큼 가장자리가 잘린다.
|
|
||||||
-->
|
|
||||||
<filter id="f-scrim" color-interpolation-filters="sRGB"
|
|
||||||
x="-25%" y="-60%" width="150%" height="220%">
|
|
||||||
<feMorphology in="SourceAlpha" operator="dilate" radius="4" result="fat" />
|
|
||||||
<feGaussianBlur in="fat" stdDeviation="3.5" result="soft" />
|
|
||||||
<!-- 블러가 알파를 옅게 만든다. 다시 세우지 않으면 스크림이 비쳐서
|
|
||||||
대비가 3.1:1 에서 멈춘다 — 실측으로 확인하고 올린 값이다 -->
|
|
||||||
<feComponentTransfer in="soft" result="dense">
|
|
||||||
<feFuncA type="linear" slope="4.4" />
|
|
||||||
</feComponentTransfer>
|
|
||||||
<feFlood flood-color="#04100f" flood-opacity="1" result="ink" />
|
|
||||||
<feComposite in="ink" in2="dense" operator="in" result="scrim" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="scrim" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
적록색약(2형) 시뮬레이션. Brettel–Viénot–Mollon 계열 근사 행렬.
|
|
||||||
여기만 linearRGB 다 — sRGB 로 돌리면 감마가 섞여 실제보다 덜 심하게 보인다.
|
|
||||||
-->
|
|
||||||
<filter id="f-deuter" color-interpolation-filters="linearRGB">
|
|
||||||
<feColorMatrix type="matrix"
|
|
||||||
values="0.625 0.375 0 0 0
|
|
||||||
0.700 0.300 0 0 0
|
|
||||||
0 0.300 0.700 0 0
|
|
||||||
0 0 0 1 0" />
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
구이. 커뮤니티에서 가장 많이 만들어지는 필터이고, 값 두 개가 전부다.
|
|
||||||
stdDeviation 이 얼마나 멀리서부터 붙을지를, 알파 기울기(18)가
|
|
||||||
얼마나 단단하게 잘릴지를 정한다. 기울기를 낮추면 흐릿한 젤리가 된다.
|
|
||||||
마지막 atop 이 없으면 블러된 헤일로가 남는다.
|
|
||||||
-->
|
|
||||||
<filter id="f-goo" color-interpolation-filters="sRGB"
|
|
||||||
x="-20%" y="-40%" width="140%" height="180%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
|
|
||||||
<feColorMatrix in="blur" type="matrix" result="goo"
|
|
||||||
values="1 0 0 0 0
|
|
||||||
0 1 0 0 0
|
|
||||||
0 0 1 0 0
|
|
||||||
0 0 0 18 -7" />
|
|
||||||
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
|
|
||||||
<!--
|
|
||||||
리퀴드 글래스. 변위 지도는 **노이즈가 아니다** — 유리는 무작위로 휘지 않고
|
|
||||||
가장자리에서 안쪽으로 휜다. feTurbulence 로 만들면 지도가 중립값(128)으로
|
|
||||||
수렴해 scale 을 아무리 키워도 아무 일도 안 일어난다(실측: 채널차 1.66).
|
|
||||||
|
|
||||||
R 채널이 X, G 채널이 Y 이동량이고 128 이 "움직이지 마라" 다.
|
|
||||||
한 장의 그라디언트로 R 과 G 를 따로 만들 수 없으니, 지도 두 장을 놓고
|
|
||||||
displacement 를 두 번 건다. 안 쓰는 축은 B(=128 고정)를 물려 0 으로 만든다.
|
|
||||||
|
|
||||||
feImage 의 width/height 는 요소 크기와 같아야 한다 — 자동으로 안 맞춰준다.
|
|
||||||
그래서 이 패널만 크기를 고정해 뒀다.
|
|
||||||
-->
|
|
||||||
<filter id="f-lens" color-interpolation-filters="sRGB"
|
|
||||||
x="0" y="0" width="100%" height="100%">
|
|
||||||
<feImage href={MAP_X} x="0" y="0" width="216" height="72" result="mx" />
|
|
||||||
<feDisplacementMap in="SourceGraphic" in2="mx" scale="30"
|
|
||||||
xChannelSelector="R" yChannelSelector="B" result="dx" />
|
|
||||||
<feImage href={MAP_Y} x="0" y="0" width="216" height="72" result="my" />
|
|
||||||
<feDisplacementMap in="dx" in2="my" scale="30"
|
|
||||||
xChannelSelector="B" yChannelSelector="G" />
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.mat-lead { margin-bottom: var(--space-6); max-width: var(--measure); }
|
|
||||||
|
|
||||||
.stage {
|
|
||||||
position: relative;
|
|
||||||
max-width: 1180px;
|
|
||||||
margin-inline: auto;
|
|
||||||
/* --wide-pad 는 페이지 그리드의 바깥 칼럼을 정의하는 토큰이다(최대 10rem).
|
|
||||||
여기에 그대로 쓰면 1440px 화면에서 좌우 160px 씩, 폭 320px 이 사라진다.
|
|
||||||
그만큼 오른쪽 설명 칸이 170px 로 줄어 여덟 자마다 줄이 바뀌었다. */
|
|
||||||
padding: var(--space-6);
|
|
||||||
border-radius: var(--radius-frame);
|
|
||||||
overflow: hidden;
|
|
||||||
isolation: isolate;
|
|
||||||
}
|
|
||||||
.stage-fallback,
|
|
||||||
.stage-canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
.stage-fallback {
|
|
||||||
background:
|
|
||||||
radial-gradient(60% 70% at 22% 20%,
|
|
||||||
color-mix(in oklab, var(--accent) 16%, transparent), transparent 70%),
|
|
||||||
radial-gradient(56% 64% at 80% 76%,
|
|
||||||
color-mix(in oklab, var(--glow-2) 14%, transparent), transparent 68%);
|
|
||||||
}
|
|
||||||
.stage-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); }
|
|
||||||
.stage-canvas.is-live { opacity: 0.55; }
|
|
||||||
|
|
||||||
.fdemos {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
.fdemo + .fdemo { border-top: 1px solid var(--line); padding-top: var(--space-6); }
|
|
||||||
|
|
||||||
/* 데모와 설명을 세로로 쌓으면 한 항목이 320px 을 먹는다.
|
|
||||||
넷이면 섹션이 3.1 화면 — 파이프라인 설명보다 길어졌다.
|
|
||||||
그림은 왼쪽, 말은 오른쪽에 두면 같은 내용이 절반 높이에 들어간다. */
|
|
||||||
.fdemo {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 584px) minmax(0, 1fr);
|
|
||||||
column-gap: var(--space-6);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.fd-name {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-size: var(--step-1);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
.fd-problem { color: var(--ink-muted); }
|
|
||||||
|
|
||||||
.fd-pair {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 280px));
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.fd-side { margin: 0; }
|
|
||||||
.fd-tag {
|
|
||||||
margin: var(--space-2) 0 0;
|
|
||||||
font-size: var(--mono-size);
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
.is-bad { color: var(--ink-muted); }
|
|
||||||
.is-good { color: var(--accent); }
|
|
||||||
|
|
||||||
.fd-text > * + * { margin-top: var(--space-3); }
|
|
||||||
.fd-verdict {
|
|
||||||
margin: var(--space-2) 0 0;
|
|
||||||
font-size: var(--mono-size);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
|
||||||
.fdemo { grid-template-columns: 1fr; row-gap: var(--space-4); }
|
|
||||||
}
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.fd-pair { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
---
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props { c: Content["metrics"]; }
|
|
||||||
const { c } = Astro.props;
|
|
||||||
const feature = c.items.filter((m) => m.feature);
|
|
||||||
const rest = c.items.filter((m) => !m.feature);
|
|
||||||
---
|
|
||||||
|
|
||||||
{/*
|
|
||||||
레이아웃 패밀리: 수치 조판.
|
|
||||||
카드에 담지 않는다. 숫자가 크기와 정렬선으로 스스로 위계를 만든다.
|
|
||||||
카드는 elevation 이 진짜 위계를 나를 때만 쓴다는 규칙을 여기서 지킨다.
|
|
||||||
*/}
|
|
||||||
{/*
|
|
||||||
이 섹션에는 패럴랙스를 넣지 않는다. 빠뜨린 것이 아니라 결정이다.
|
|
||||||
|
|
||||||
여기가 이 페이지의 수치를 공개하는 자리다. 숫자가 스크롤에 따라
|
|
||||||
흔들리면 그 값도 흔들리는 것처럼 읽힌다. 계기판의 바늘은 떨지 않는다.
|
|
||||||
움직임이 의미를 만드는 자리가 있으면, 움직이지 않는 것이 의미인 자리도 있다.
|
|
||||||
*/}
|
|
||||||
<section id="metrics" class="section metrics" aria-labelledby="metrics-h">
|
|
||||||
<div class="head">
|
|
||||||
<p class="eyebrow reveal">{c.eyebrow}</p>
|
|
||||||
<h2 id="metrics-h" class="reveal">{c.h2}</h2>
|
|
||||||
</div>
|
|
||||||
<p class="lead metrics-lead reveal">{c.lead}</p>
|
|
||||||
|
|
||||||
<div class="full figures">
|
|
||||||
{
|
|
||||||
feature.map((m, i) => (
|
|
||||||
<div class="fig fig-lead reveal" style={`--reveal-delay:${i * 80}ms`}>
|
|
||||||
<span class="fig-value nums">{m.value}</span>
|
|
||||||
<span class="fig-label">{m.label}</span>
|
|
||||||
<span class="fig-note muted">{m.note}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
<dl class="fig-rest">
|
|
||||||
{
|
|
||||||
rest.map((m, i) => (
|
|
||||||
<div class="row reveal" style={`--reveal-delay:${(i + 2) * 50}ms`}>
|
|
||||||
<dt>{m.label}</dt>
|
|
||||||
<dd class="nums">{m.value}</dd>
|
|
||||||
<dd class="row-note muted">{m.note}</dd>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="caveat muted reveal">{c.caveat}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.metrics-lead { margin-bottom: var(--space-7); max-width: 30em; }
|
|
||||||
|
|
||||||
.figures {
|
|
||||||
max-width: 1180px;
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: var(--gutter);
|
|
||||||
display: grid;
|
|
||||||
/* 비대칭. 큰 수치 둘이 왼쪽을 차지하고 나머지는 오른쪽 여백에 흐른다 */
|
|
||||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
|
||||||
column-gap: var(--space-7);
|
|
||||||
row-gap: var(--space-6);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fig-lead { display: grid; gap: var(--space-1); }
|
|
||||||
.fig-lead:nth-child(2) {
|
|
||||||
/* 두 번째 큰 수치는 한 칸 내려앉는다 */
|
|
||||||
margin-top: var(--space-6);
|
|
||||||
}
|
|
||||||
.fig-value {
|
|
||||||
font-size: clamp(3.4rem, 2rem + 5vw, 6rem);
|
|
||||||
line-height: 0.92;
|
|
||||||
font-weight: var(--weight-strong);
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.fig-label { font-size: var(--step-1); font-weight: var(--weight-medium); margin-top: var(--space-2); }
|
|
||||||
.fig-note { font-size: var(--step-0); max-width: 22em; }
|
|
||||||
|
|
||||||
/* 나머지 수치는 오른쪽 열에서 두 칸을 세로로 이어 쓴다 */
|
|
||||||
.fig-rest {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 1 / span 2;
|
|
||||||
margin: 0;
|
|
||||||
display: grid;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
column-gap: var(--space-4);
|
|
||||||
align-items: baseline;
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.row:last-child { border-bottom: 1px solid var(--line); }
|
|
||||||
dt { font-weight: var(--weight-medium); }
|
|
||||||
dd { margin: 0; }
|
|
||||||
dd.nums { font-size: var(--step-1); color: var(--accent); justify-self: end; }
|
|
||||||
.row-note {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-size: var(--step-0);
|
|
||||||
margin-top: var(--space-1);
|
|
||||||
max-width: 32em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.caveat { margin-top: var(--space-6); font-size: var(--step-0); max-width: 34em; }
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.figures { grid-template-columns: 1fr; }
|
|
||||||
.fig-rest { grid-column: 1; grid-row: auto; }
|
|
||||||
.fig-lead:nth-child(2) { margin-top: 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
650
apps/site/src/components/OutputSpecimens.astro
Normal file
650
apps/site/src/components/OutputSpecimens.astro
Normal file
|
|
@ -0,0 +1,650 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
lang?: "ko" | "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lang = "ko" } = Astro.props;
|
||||||
|
const suffix = lang === "en" ? "en" : "ko";
|
||||||
|
const copy = lang === "en"
|
||||||
|
? {
|
||||||
|
title: "Three designpaca output specimens",
|
||||||
|
direction: {
|
||||||
|
name: "Direction",
|
||||||
|
axes: ["Structure", "Tone", "Detail"],
|
||||||
|
statement: "Evidence converges into one visual position.",
|
||||||
|
note: "References are separated by role before their decisions are combined.",
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
name: "Tokens & type",
|
||||||
|
specimen: "Aa",
|
||||||
|
koreanSpecimen: "가",
|
||||||
|
roles: ["Surface", "Ink", "Signal"],
|
||||||
|
values: ["--surface", "--ink", "--accent"],
|
||||||
|
note: "Type, color roles, and spacing become reusable constraints.",
|
||||||
|
},
|
||||||
|
proof: {
|
||||||
|
name: "Verification contract",
|
||||||
|
checks: ["Line count", "Content inset", "Contrast", "Keyboard path"],
|
||||||
|
note: "The same rules are measured at narrow, medium, and wide widths before completion.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: "디자인파카 결과 표본 세 가지",
|
||||||
|
direction: {
|
||||||
|
name: "방향 결정",
|
||||||
|
axes: ["구조", "분위기", "디테일"],
|
||||||
|
statement: "서로 다른 근거가 하나의 시각 방향으로 모입니다.",
|
||||||
|
note: "레퍼런스를 역할별로 분리한 뒤, 필요한 결정만 한 방향으로 결합합니다.",
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
name: "토큰과 조판",
|
||||||
|
specimen: "Aa",
|
||||||
|
koreanSpecimen: "가",
|
||||||
|
roles: ["바탕", "본문", "신호"],
|
||||||
|
values: ["--surface", "--ink", "--accent"],
|
||||||
|
note: "글자, 색의 역할, 간격을 다시 쓸 수 있는 제약으로 정리합니다.",
|
||||||
|
},
|
||||||
|
proof: {
|
||||||
|
name: "검사 계약",
|
||||||
|
checks: ["제목 줄 수", "콘텐츠 여백", "색 대비", "키보드 경로"],
|
||||||
|
note: "완료 전에 같은 기준을 좁은 화면, 중간 화면, 넓은 화면에서 실제 수치로 검사합니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="output-specimens" role="group" aria-labelledby={`specimens-title-${suffix}`}>
|
||||||
|
<h3 id={`specimens-title-${suffix}`} class="visually-hidden">{copy.title}</h3>
|
||||||
|
|
||||||
|
<article class="specimen specimen-direction" aria-labelledby={`direction-title-${suffix}`}>
|
||||||
|
<header class="specimen-head">
|
||||||
|
<span class="specimen-index nums" aria-hidden="true">01</span>
|
||||||
|
<h3 id={`direction-title-${suffix}`}>{copy.direction.name}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="direction-stage">
|
||||||
|
<svg viewBox="0 0 760 380" aria-hidden="true" preserveAspectRatio="none">
|
||||||
|
<path class="direction-field" d="M42 320 242 56l241 86 234 162-337 44Z" />
|
||||||
|
<path class="direction-field direction-field-inner" d="m130 295 134-177 190 68 157 109-231 30Z" />
|
||||||
|
<path class="direction-axis" d="M42 320 380 348 242 56m138 292 337-44M380 348 483 142" />
|
||||||
|
<path class="direction-vector" d="m194 275 186 73 175-99" />
|
||||||
|
<circle class="direction-node" cx="194" cy="275" r="7" />
|
||||||
|
<circle class="direction-node" cx="380" cy="348" r="7" />
|
||||||
|
<circle class="direction-node" cx="555" cy="249" r="7" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<ol class="direction-axes">
|
||||||
|
{copy.direction.axes.map((axis, index) => (
|
||||||
|
<li class={`direction-axis-label direction-axis-${index + 1}`}>
|
||||||
|
<span class="nums" aria-hidden="true">0{index + 1}</span>
|
||||||
|
<span>{axis}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p class="direction-statement">{copy.direction.statement}</p>
|
||||||
|
</div>
|
||||||
|
<p class="specimen-note">{copy.direction.note}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="specimen specimen-tokens" aria-labelledby={`tokens-title-${suffix}`}>
|
||||||
|
<header class="specimen-head">
|
||||||
|
<span class="specimen-index nums" aria-hidden="true">02</span>
|
||||||
|
<h3 id={`tokens-title-${suffix}`}>{copy.tokens.name}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="type-stage">
|
||||||
|
<div class="type-glyphs" role="img" aria-label={`${copy.tokens.specimen} ${copy.tokens.koreanSpecimen}`}>
|
||||||
|
<span aria-hidden="true">{copy.tokens.specimen}</span>
|
||||||
|
<span aria-hidden="true">{copy.tokens.koreanSpecimen}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="type-baselines" aria-hidden="true">
|
||||||
|
<span></span><span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="token-register">
|
||||||
|
{copy.tokens.roles.map((role, index) => (
|
||||||
|
<div>
|
||||||
|
<dt>{role}</dt>
|
||||||
|
<dd>
|
||||||
|
<span class={`token-sample token-sample-${index + 1}`} aria-hidden="true"></span>
|
||||||
|
<span class="token-value nums">{copy.tokens.values[index]}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<p class="specimen-note">{copy.tokens.note}</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="specimen specimen-proof" aria-labelledby={`proof-title-${suffix}`}>
|
||||||
|
<header class="specimen-head">
|
||||||
|
<span class="specimen-index nums" aria-hidden="true">03</span>
|
||||||
|
<h3 id={`proof-title-${suffix}`}>{copy.proof.name}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="proof-stage">
|
||||||
|
<div class="viewport-family" aria-hidden="true">
|
||||||
|
<div class="viewport viewport-narrow">
|
||||||
|
<span class="viewport-size nums">320</span>
|
||||||
|
<i></i><i></i><i></i>
|
||||||
|
</div>
|
||||||
|
<div class="viewport viewport-medium">
|
||||||
|
<span class="viewport-size nums">768</span>
|
||||||
|
<i></i><i></i><i></i>
|
||||||
|
</div>
|
||||||
|
<div class="viewport viewport-wide">
|
||||||
|
<span class="viewport-size nums">1440</span>
|
||||||
|
<i></i><i></i><i></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="check-register">
|
||||||
|
{copy.proof.checks.map((check, index) => (
|
||||||
|
<li>
|
||||||
|
<span class="check-index nums" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||||||
|
<span>{check}</span>
|
||||||
|
<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true">
|
||||||
|
<path d="M3 9h12M12 6l3 3-3 3" />
|
||||||
|
</svg>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<p class="specimen-note">{copy.proof.note}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-specimens {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||||
|
gap: var(--space-8) var(--space-5);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specimen {
|
||||||
|
min-width: 0;
|
||||||
|
border-top: var(--line-width) solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specimen-direction { grid-column: 1 / span 8; }
|
||||||
|
.specimen-tokens { grid-column: 9 / -1; margin-top: var(--space-8); }
|
||||||
|
.specimen-proof { grid-column: 3 / -1; }
|
||||||
|
|
||||||
|
.specimen-head {
|
||||||
|
min-height: var(--control-h);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2.5rem minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specimen-head h3 {
|
||||||
|
font-size: var(--step-0);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specimen-index {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: var(--mono-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.specimen-note {
|
||||||
|
max-width: 38rem;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 24rem;
|
||||||
|
overflow: clip;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-stage > svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-field {
|
||||||
|
fill: var(--surface-raised);
|
||||||
|
stroke: var(--line-strong);
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-field-inner {
|
||||||
|
fill: var(--surface);
|
||||||
|
stroke: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-axis {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--line-strong);
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 2 5;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-vector {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2.2;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-node {
|
||||||
|
fill: var(--accent);
|
||||||
|
stroke: var(--surface-raised);
|
||||||
|
stroke-width: 3;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-axes {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-axis-label {
|
||||||
|
position: absolute;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-axis-label .nums { color: var(--ink-muted); font-size: var(--mono-size); }
|
||||||
|
.direction-axis-1 { left: 5%; bottom: 7%; }
|
||||||
|
.direction-axis-2 { left: 30%; top: 7%; }
|
||||||
|
.direction-axis-3 { right: 5%; bottom: 12%; text-align: right; }
|
||||||
|
|
||||||
|
.direction-statement {
|
||||||
|
position: absolute;
|
||||||
|
left: 48%;
|
||||||
|
top: 48%;
|
||||||
|
width: min(43%, 18rem);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--step-1);
|
||||||
|
line-height: 1.38;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 29rem;
|
||||||
|
padding: var(--space-5);
|
||||||
|
overflow: clip;
|
||||||
|
background: var(--surface-inverse);
|
||||||
|
color: var(--ink-inverse);
|
||||||
|
border-bottom: var(--line-width) solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-glyphs {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-bottom: var(--space-4);
|
||||||
|
border-bottom: var(--line-width) solid var(--line-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-glyphs span:first-child {
|
||||||
|
font-size: clamp(4rem, 8vw, 7rem);
|
||||||
|
font-weight: var(--weight-body);
|
||||||
|
line-height: 0.9;
|
||||||
|
letter-spacing: -0.055em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-glyphs span:last-child {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: clamp(2.25rem, 5vw, 4rem);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-baselines {
|
||||||
|
position: absolute;
|
||||||
|
inset: var(--space-5);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(4, 1fr);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-baselines span { border-bottom: var(--line-width) solid var(--line-inverse); opacity: 0.38; }
|
||||||
|
|
||||||
|
.token-register {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-register > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--control-h);
|
||||||
|
border-top: var(--line-width) solid var(--line-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-register dt { color: var(--ink-muted-inverse); font-size: var(--step--1); }
|
||||||
|
.token-register dd { display: flex; align-items: center; gap: var(--space-2); margin: 0; }
|
||||||
|
|
||||||
|
.token-sample {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border: var(--line-width) solid var(--line-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-sample-1 { background: var(--surface-raised); }
|
||||||
|
.token-sample-2 { background: var(--ink-inverse); }
|
||||||
|
.token-sample-3 { background: var(--accent); }
|
||||||
|
.token-value { color: var(--ink-muted-inverse); font-size: var(--mono-size); }
|
||||||
|
|
||||||
|
.proof-stage {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(15rem, 0.75fr);
|
||||||
|
min-height: 22rem;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-family {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-7) var(--space-5);
|
||||||
|
border-right: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-7);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto repeat(3, 1fr);
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: var(--line-width) solid var(--ink);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport i {
|
||||||
|
display: block;
|
||||||
|
border-top: var(--line-width) solid var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport i:nth-of-type(2) { width: 72%; }
|
||||||
|
.viewport i:nth-of-type(3) { width: 46%; }
|
||||||
|
.viewport-size { color: var(--ink-muted); font-size: var(--mono-size); }
|
||||||
|
|
||||||
|
.viewport-narrow { left: 8%; width: 18%; height: 48%; z-index: 3; }
|
||||||
|
.viewport-medium { left: 25%; width: 34%; height: 64%; z-index: 2; }
|
||||||
|
.viewport-wide { right: 7%; width: 55%; height: 80%; z-index: 1; }
|
||||||
|
|
||||||
|
.check-register {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-5);
|
||||||
|
align-self: stretch;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li {
|
||||||
|
min-height: var(--control-h);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2rem minmax(0, 1fr) auto;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-index { color: var(--ink-muted); font-size: var(--mono-size); }
|
||||||
|
|
||||||
|
.check-register svg {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: square;
|
||||||
|
stroke-linejoin: miter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (animation-timeline: view()) {
|
||||||
|
.specimen-direction {
|
||||||
|
view-timeline-name: --direction-specimen;
|
||||||
|
view-timeline-axis: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specimen-tokens {
|
||||||
|
view-timeline-name: --tokens-specimen;
|
||||||
|
view-timeline-axis: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specimen-proof {
|
||||||
|
view-timeline-name: --proof-specimen;
|
||||||
|
view-timeline-axis: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.direction-field-inner {
|
||||||
|
animation: direction-plane-in linear both;
|
||||||
|
animation-timeline: --direction-specimen;
|
||||||
|
animation-range: entry 8% cover 30%;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-vector,
|
||||||
|
.direction-node {
|
||||||
|
--direction-shift-x: calc(var(--space-3) * -1);
|
||||||
|
animation: direction-lock linear both;
|
||||||
|
animation-timeline: --direction-specimen;
|
||||||
|
animation-range: entry 18% cover 38%;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-glyphs {
|
||||||
|
animation: type-register linear both;
|
||||||
|
animation-timeline: --tokens-specimen;
|
||||||
|
animation-range: entry 8% cover 27%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-sample {
|
||||||
|
animation: token-resolve linear both;
|
||||||
|
animation-timeline: --tokens-specimen;
|
||||||
|
transform-origin: left center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-register > div:nth-child(1) .token-sample {
|
||||||
|
animation-range: entry 22% cover 34%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-register > div:nth-child(2) .token-sample {
|
||||||
|
animation-range: entry 26% cover 38%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-register > div:nth-child(3) .token-sample {
|
||||||
|
animation-range: entry 30% cover 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
animation: frame-resolve linear both;
|
||||||
|
animation-timeline: --proof-specimen;
|
||||||
|
animation-range: entry 8% cover 32%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-narrow {
|
||||||
|
--frame-x: var(--space-6);
|
||||||
|
--frame-y: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-medium {
|
||||||
|
--frame-x: var(--space-4);
|
||||||
|
--frame-y: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-wide {
|
||||||
|
--frame-x: 0;
|
||||||
|
--frame-y: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register svg {
|
||||||
|
animation: check-pass linear both;
|
||||||
|
animation-timeline: --proof-specimen;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(1) svg {
|
||||||
|
animation-range: entry 30% cover 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(2) svg {
|
||||||
|
animation-range: entry 34% cover 46%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(3) svg {
|
||||||
|
animation-range: entry 38% cover 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(4) svg {
|
||||||
|
animation-range: entry 42% cover 54%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes direction-plane-in {
|
||||||
|
from { opacity: 0.42; translate: 0 var(--space-3); }
|
||||||
|
to { opacity: 1; translate: 0 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes direction-lock {
|
||||||
|
from { opacity: 0; translate: var(--direction-shift-x) var(--space-1); }
|
||||||
|
to { opacity: 1; translate: 0 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes type-register {
|
||||||
|
from { opacity: 0.35; translate: 0 var(--space-3); }
|
||||||
|
to { opacity: 1; translate: 0 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes token-resolve {
|
||||||
|
from { opacity: 0.35; scale: 0.18 1; }
|
||||||
|
to { opacity: 1; scale: 1 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes frame-resolve {
|
||||||
|
from { translate: var(--frame-x) var(--frame-y); }
|
||||||
|
to { translate: 0 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes check-pass {
|
||||||
|
from { opacity: 0.2; translate: calc(var(--space-2) * -1) 0; }
|
||||||
|
to { opacity: 1; translate: 0 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 62rem) {
|
||||||
|
.specimen-direction { grid-column: 1 / span 7; }
|
||||||
|
.specimen-tokens { grid-column: 8 / -1; }
|
||||||
|
.specimen-proof { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 47.5rem) {
|
||||||
|
.output-specimens { grid-template-columns: minmax(0, 1fr); gap: var(--space-8); }
|
||||||
|
.specimen-direction,
|
||||||
|
.specimen-tokens,
|
||||||
|
.specimen-proof { grid-column: 1; margin-top: 0; }
|
||||||
|
.direction-stage { min-height: 21rem; }
|
||||||
|
.type-stage { min-height: 24rem; }
|
||||||
|
.proof-stage { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.viewport-family { min-height: 18rem; border-right: 0; border-bottom: var(--line-width) solid var(--line); }
|
||||||
|
|
||||||
|
@supports (animation-timeline: view()) {
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.direction-vector,
|
||||||
|
.direction-node {
|
||||||
|
--direction-shift-x: calc(var(--space-2) * -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-narrow {
|
||||||
|
--frame-x: var(--space-3);
|
||||||
|
--frame-y: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-medium {
|
||||||
|
--frame-x: var(--space-2);
|
||||||
|
--frame-y: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
animation-range: entry 4% cover 28%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(1) svg {
|
||||||
|
animation-range: entry 26% cover 38%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(2) svg {
|
||||||
|
animation-range: entry 30% cover 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(3) svg {
|
||||||
|
animation-range: entry 34% cover 46%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-register li:nth-child(4) svg {
|
||||||
|
animation-range: entry 38% cover 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 28rem) {
|
||||||
|
.direction-statement { left: 39%; width: 55%; font-size: var(--step-0); }
|
||||||
|
.direction-axis-2 { left: 25%; }
|
||||||
|
.viewport-family { padding-inline: var(--space-3); }
|
||||||
|
.viewport-narrow { left: 4%; }
|
||||||
|
.viewport-medium { left: 20%; }
|
||||||
|
.viewport-wide { right: 3%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.direction-field-inner,
|
||||||
|
.direction-vector,
|
||||||
|
.direction-node,
|
||||||
|
.type-glyphs,
|
||||||
|
.token-sample,
|
||||||
|
.viewport,
|
||||||
|
.check-register svg {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
translate: 0 0;
|
||||||
|
scale: 1 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,274 +0,0 @@
|
||||||
---
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
c: Content["pipeline"];
|
|
||||||
}
|
|
||||||
const { c } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
{/*
|
|
||||||
순서를 번호 없이 표현하는 세 층:
|
|
||||||
① 시각 — 스파인 위의 위치가 순서를 만든다 (Beck 튜브맵)
|
|
||||||
② 내용 — 같은 브리프가 단계마다 변해간다 (IKEA 조립 설명서)
|
|
||||||
③ 의미 — <ol role="list"> — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다
|
|
||||||
role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다
|
|
||||||
*/}
|
|
||||||
<section id="pipeline" class="section has-bg" aria-labelledby="pipeline-h">
|
|
||||||
{/* 히어로에 있던 게이트 씬을 여기로 옮겼다.
|
|
||||||
히어로에서는 그 은유를 판독기가 자막으로 설명해야 했다 — 그림이 스스로
|
|
||||||
말하지 못한다는 뜻이다. 여기서는 옆에 실제 여섯 단계가 적혀 있고,
|
|
||||||
판을 통과할 때 그 단계가 켜진다. 자막이 아니라 콘텐츠가 반응한다. */}
|
|
||||||
<div class="gate-bg" aria-hidden="true">
|
|
||||||
<div class="gate-fallback"></div>
|
|
||||||
<canvas id="pipeline-canvas" class="gate-canvas"></canvas>
|
|
||||||
</div>
|
|
||||||
{/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다.
|
|
||||||
섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371,
|
|
||||||
리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */}
|
|
||||||
<div class="full spine-wrap">
|
|
||||||
<p class="eyebrow reveal">{c.eyebrow}</p>
|
|
||||||
<h2 id="pipeline-h" class="reveal">{c.h2}</h2>
|
|
||||||
<p class="lead pipeline-lead reveal">{c.lead}</p>
|
|
||||||
|
|
||||||
<div class="brief reveal"><div class="brief-core">
|
|
||||||
<span class="brief-label">{c.briefLabel}</span>
|
|
||||||
<p class="brief-text">{c.brief}</p>
|
|
||||||
</div></div>
|
|
||||||
|
|
||||||
<ol role="list" class="spine">
|
|
||||||
{
|
|
||||||
c.stages.map((s, i) => (
|
|
||||||
<li class="stop reveal" data-stage={i}>
|
|
||||||
<div class="stop-head">
|
|
||||||
<h3 class="stop-name">{s.name}</h3>
|
|
||||||
<p class="stop-does">{s.does}</p>
|
|
||||||
</div>
|
|
||||||
<p class:list={["stop-becomes", "par-lag", { mono: s.mono }]}>{s.becomes}</p>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* 컨테이너는 넓어도 읽는 것은 measure 안에 둔다 */
|
|
||||||
.pipeline-lead {
|
|
||||||
margin-bottom: var(--space-5);
|
|
||||||
max-width: var(--measure);
|
|
||||||
}
|
|
||||||
.brief { max-width: var(--measure); }
|
|
||||||
#pipeline-h { max-width: 24ch; }
|
|
||||||
|
|
||||||
/* ---- 게이트 배경 ------------------------------------------
|
|
||||||
sticky 로 화면에 머물고 음수 마진으로 레이아웃 자리는 차지하지 않는다.
|
|
||||||
섹션이 지나가는 동안 카메라가 판 여섯 장을 통과한다. */
|
|
||||||
.gate-bg {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
height: 100dvh;
|
|
||||||
/* 섹션 패딩(112px) 아래에서 시작하면 히어로 배경이 끝난 자리와
|
|
||||||
이 캔버스 사이에 200px 짜리 검은 띠가 생긴다. 패딩만큼 끌어올린다. */
|
|
||||||
margin-top: calc(var(--space-7) * -1);
|
|
||||||
margin-bottom: -100dvh;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
grid-column: full;
|
|
||||||
}
|
|
||||||
.gate-fallback,
|
|
||||||
.gate-canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.gate-fallback {
|
|
||||||
background:
|
|
||||||
radial-gradient(46% 54% at 28% 34%,
|
|
||||||
color-mix(in oklab, var(--accent) 20%, transparent), transparent 70%),
|
|
||||||
radial-gradient(42% 50% at 76% 68%,
|
|
||||||
color-mix(in oklab, var(--glow-2) 18%, transparent), transparent 68%);
|
|
||||||
}
|
|
||||||
/* 캔버스가 섹션 패딩 위까지 덮으면 이웃 섹션과 딱 붙어 보인다.
|
|
||||||
위아래를 지워 여백을 되돌린다. */
|
|
||||||
.gate-bg {
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 6%, black 88%, transparent 100%);
|
|
||||||
mask-image: linear-gradient(to bottom, transparent 0%, black 6%, black 88%, transparent 100%);
|
|
||||||
}
|
|
||||||
.gate-canvas { opacity: 0; transition: opacity 1s var(--ease-soft); }
|
|
||||||
.gate-canvas.is-live { opacity: 0.7; }
|
|
||||||
/* 세로 화면에서는 캔버스가 화면 전체를 덮는다. 같은 밝기면 본문이 묻힌다 */
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.gate-canvas.is-live { opacity: 0.45; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 통과한 단계가 켜진다. 3D 와 텍스트를 잇는 유일한 연결이다 */
|
|
||||||
.stop { transition: opacity var(--dur-quick) var(--ease-out); }
|
|
||||||
.stop.is-active .stop-name { color: var(--accent); }
|
|
||||||
.stop.is-active::before {
|
|
||||||
background: var(--accent);
|
|
||||||
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 22%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 스파인은 페이지의 본체다. main 칼럼(561px)에 가두면
|
|
||||||
페이지에서 가장 긴 섹션이 가장 좁아진다.
|
|
||||||
R1(zed.dev) 실측: 컨테이너 1120px 과 전체폭이 교대한다. */
|
|
||||||
.spine-wrap {
|
|
||||||
max-width: 1180px;
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: var(--gutter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brief { margin-bottom: var(--space-2); }
|
|
||||||
.brief-core { padding: var(--space-4); }
|
|
||||||
.brief-label {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--step-0);
|
|
||||||
letter-spacing: var(--tracking-label);
|
|
||||||
color: var(--ink-muted);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
.brief-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--step-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 스파인 ---------------------------------------------
|
|
||||||
선은 장식이 아니라 순서 그 자체다. 정거장의 위치가 번호를 대신한다.
|
|
||||||
---------------------------------------------------------- */
|
|
||||||
.spine {
|
|
||||||
/* 정거장의 세로 리듬은 여기 한 곳에서 나온다.
|
|
||||||
전에는 패딩이 space-6, 마커 위치가 space-4 로 각자 적혀 있어서
|
|
||||||
원이 제목보다 24px 위에 떠 있었다. 값을 각자 쓰면 이렇게 어긋난다. */
|
|
||||||
--stop-pad: var(--space-5);
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0 0 var(--space-5);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.spine::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 5px;
|
|
||||||
top: var(--stop-pad);
|
|
||||||
bottom: var(--stop-pad);
|
|
||||||
width: 1px;
|
|
||||||
background: var(--line-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop {
|
|
||||||
position: relative;
|
|
||||||
/* 정거장 간격이 곧 3D 게이트의 스크롤 구간이다.
|
|
||||||
space-4(24px)로는 섹션이 1493px 밖에 안 돼 sticky 이동 거리가 593px 뿐이었다.
|
|
||||||
섹션이 화면에 보이는 구간(2393px)의 4분의 1이라, 조금만 스크롤해도
|
|
||||||
여섯 게이트를 다 지나쳐 버렸다.
|
|
||||||
|
|
||||||
space-7 까지 올렸더니 이번엔 정거장 사이가 224px 로 벌어져
|
|
||||||
목록이 흩어져 보였다 — 여섯 개가 한 목록으로 안 읽힌다.
|
|
||||||
읽기 간격과 스크롤 구간은 같은 손잡이가 아니다.
|
|
||||||
간격은 읽기에 맞추고, 스크롤 구간은 스파인 뒤 여백에서 번다. */
|
|
||||||
padding-block: var(--stop-pad);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
|
||||||
gap: var(--space-5);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.stop-head { min-width: 0; }
|
|
||||||
.stop + .stop {
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 정거장 표시 — 채워진 원. 숫자가 아니다 */
|
|
||||||
.stop::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: calc(var(--space-5) * -1);
|
|
||||||
top: calc(var(--stop-pad) + 0.55em);
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--surface);
|
|
||||||
border: 3px solid var(--accent);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-name {
|
|
||||||
font-size: var(--step-1);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
.stop-does {
|
|
||||||
color: var(--ink-muted);
|
|
||||||
margin: 0;
|
|
||||||
max-width: 34ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 브리프가 이 단계에서 무엇이 됐는가 — 변화가 순서를 만든다 */
|
|
||||||
.stop-becomes {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: var(--space-3);
|
|
||||||
border-left: 2px solid var(--accent);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.stop::before {
|
|
||||||
transition: transform var(--dur-quick) var(--ease-out);
|
|
||||||
}
|
|
||||||
.stop:hover::before { transform: scale(1.5); }
|
|
||||||
.stop-becomes { transition: border-color var(--dur-quick) var(--ease-out); }
|
|
||||||
.stop:hover .stop-becomes { border-color: var(--ink); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.stop-becomes.mono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--mono-size);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 한때 여기에 padding-bottom: 62dvh 가 있었다. 게이트 진행도를 섹션 높이로
|
|
||||||
재고 있어서, 여섯 단계를 다 돌리려면 목록 뒤에 화면 절반이 넘는 빈 공간이
|
|
||||||
필요했다. 콘텐츠가 아니라 스크롤 연료였다.
|
|
||||||
진행도를 목록이 화면을 지나는 구간으로 바꾸면서 그 여백이 필요 없어졌다. */
|
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
|
||||||
.stop {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 스크롤 진입 페이드를 넣었다가 뺐다.
|
|
||||||
`animation-range: entry 10% cover 22%` 로는 페이지 하단 요소가 cover 22% 에
|
|
||||||
영영 도달하지 못해, 6개 정거장 중 뒤 3개가 opacity 0 으로 잠겼다.
|
|
||||||
이펙트를 끄면 보이지만 켜면 사라지는 것은 폴백이 있는 것과 다르다.
|
|
||||||
이 섹션은 페이지의 본체다. 콘텐츠를 걸고 하는 연출은 하지 않는다. */
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 게이트를 통과할 때 해당 단계를 켠다. 값이 바뀔 때만 DOM 을 쓴다.
|
|
||||||
import { mountGateScene } from "../lib/gate-scene";
|
|
||||||
|
|
||||||
const canvas = document.getElementById("pipeline-canvas") as HTMLCanvasElement | null;
|
|
||||||
const section = document.getElementById("pipeline");
|
|
||||||
const spine = document.querySelector<HTMLElement>("#pipeline .spine");
|
|
||||||
const stops = [...document.querySelectorAll<HTMLElement>("#pipeline .stop")];
|
|
||||||
|
|
||||||
if (canvas && section && spine && stops.length) {
|
|
||||||
let last = -1;
|
|
||||||
mountGateScene({
|
|
||||||
canvas,
|
|
||||||
accentVar: "--accent",
|
|
||||||
glowVar: "--glow-2",
|
|
||||||
// 캔버스는 sticky 라 rect 가 변하지 않는다.
|
|
||||||
// 진행도는 섹션이 아니라 **정거장 목록**에서 읽는다 — 게이트가 도는 구간과
|
|
||||||
// 읽는 구간이 같아야 목록 뒤에 스크롤용 빈 공간을 두지 않아도 된다.
|
|
||||||
progressFrom: spine,
|
|
||||||
onGate(index) {
|
|
||||||
if (index === last) return;
|
|
||||||
last = index;
|
|
||||||
stops.forEach((el, i) => el.classList.toggle("is-active", i === index));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
---
|
|
||||||
import BgPhoto from "./BgPhoto.astro";
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props { c: Content["preflight"]; }
|
|
||||||
const { c } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
{/*
|
|
||||||
레이아웃 패밀리: 로그.
|
|
||||||
걸린 것과 고친 것이 한 줄에서 이어진다. 상자에 담지 않는다.
|
|
||||||
왼쪽 세로선이 로그라는 형식을 만든다.
|
|
||||||
*/}
|
|
||||||
<section class="section preflight has-bg" aria-labelledby="pf-h">
|
|
||||||
<BgPhoto variant="align" mode="fixed" opacity={1} />
|
|
||||||
<h2 id="pf-h" class="reveal">{c.h2}</h2>
|
|
||||||
<p class="lead pf-lead reveal">{c.lead}</p>
|
|
||||||
|
|
||||||
<div class="full log">
|
|
||||||
{
|
|
||||||
c.items.map((i, idx) => (
|
|
||||||
<article class="entry reveal par-lag" style={`--reveal-delay:${idx * 45}ms; --par-lag:${6 + idx * 2}px`}>
|
|
||||||
<p class="caught">{i.caught}</p>
|
|
||||||
<p class="fixed muted">{i.fixed}</p>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="pf-note muted reveal">{c.note}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.pf-lead { margin-block: var(--space-4) var(--space-7); max-width: 30em; }
|
|
||||||
|
|
||||||
.log {
|
|
||||||
max-width: 1180px;
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: var(--gutter);
|
|
||||||
display: grid;
|
|
||||||
}
|
|
||||||
.entry {
|
|
||||||
position: relative;
|
|
||||||
padding: var(--space-4) 0 var(--space-4) var(--space-5);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
|
|
||||||
gap: var(--space-5);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
/* 로그를 관통하는 세로선. 항목마다 상자를 두르지 않는다 */
|
|
||||||
.entry::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 1px;
|
|
||||||
background: var(--line);
|
|
||||||
}
|
|
||||||
.entry:first-child::before { top: var(--space-4); }
|
|
||||||
.entry:last-child::before { bottom: var(--space-4); }
|
|
||||||
/* 걸린 지점을 표시하는 짧은 획 */
|
|
||||||
.entry::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: -2px;
|
|
||||||
top: calc(var(--space-4) + 0.55em);
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.caught { margin: 0; font-weight: var(--weight-medium); }
|
|
||||||
.fixed { margin: 0; font-size: var(--step-0); }
|
|
||||||
.pf-note { margin-top: var(--space-6); font-size: var(--step-0); max-width: 34em; }
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
|
||||||
.entry { grid-template-columns: 1fr; gap: var(--space-2); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
---
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props { c: Content["rules"]; }
|
|
||||||
const { c } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
{/*
|
|
||||||
레이아웃 패밀리: 어긋난 목록.
|
|
||||||
규칙은 왼쪽 정렬선에, 근거는 오른쪽으로 밀어 흘린다.
|
|
||||||
카드도 상자도 없다. 어긋남 자체가 두 층위를 구분한다.
|
|
||||||
*/}
|
|
||||||
<section class="section rules" aria-labelledby="rules-h">
|
|
||||||
<h2 id="rules-h" class="reveal">{c.h2}</h2>
|
|
||||||
<p class="lead rules-lead reveal">{c.lead}</p>
|
|
||||||
|
|
||||||
<dl class="full rule-list">
|
|
||||||
{
|
|
||||||
c.items.map((r, i) => (
|
|
||||||
<div class="rule reveal" style={`--reveal-delay:${i * 45}ms`}>
|
|
||||||
<dt>{r.rule}</dt>
|
|
||||||
<dd class="muted">{r.why}</dd>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.rules-lead { margin-block: var(--space-4) var(--space-7); max-width: 30em; }
|
|
||||||
|
|
||||||
.rule-list {
|
|
||||||
max-width: 1180px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding-inline: var(--gutter);
|
|
||||||
}
|
|
||||||
.rule {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
|
|
||||||
gap: var(--space-4) var(--space-6);
|
|
||||||
padding-block: var(--space-5);
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
.rule:last-child { border-bottom: 1px solid var(--line); }
|
|
||||||
|
|
||||||
/* 홀수 규칙은 근거가 한 칸 더 밀려난다. 목록이 일정하게 흐르지 않는다 */
|
|
||||||
.rule:nth-child(odd) dd { padding-left: var(--space-6); }
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-size: var(--step-2);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
letter-spacing: var(--tracking-display);
|
|
||||||
line-height: var(--leading-tight);
|
|
||||||
}
|
|
||||||
dd { margin: 0; font-size: var(--step-0); max-width: 34em; }
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
|
||||||
.rule { grid-template-columns: 1fr; gap: var(--space-2); }
|
|
||||||
.rule:nth-child(odd) dd { padding-left: 0; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
---
|
|
||||||
interface Props {
|
|
||||||
class?: string;
|
|
||||||
}
|
|
||||||
const { class: className } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class:list={["hero-gl-wrap", className]} aria-hidden="true">
|
|
||||||
<div class="hero-gl-fallback"></div>
|
|
||||||
<canvas id="showcase-gl" class="hero-gl-canvas" style="opacity: 0;"></canvas>
|
|
||||||
<div class="hero-gl-scrim"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { boot } from "../gl/boot.ts";
|
|
||||||
|
|
||||||
const canvas = document.getElementById("showcase-gl") as HTMLCanvasElement | null;
|
|
||||||
if (canvas) {
|
|
||||||
boot(canvas, () => import("../gl/scenes/showcase-hero.ts"));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hero-gl-wrap {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
overflow: hidden;
|
|
||||||
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
|
||||||
-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-gl-fallback {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background:
|
|
||||||
radial-gradient(85% 60% at 50% -10%, color-mix(in oklab, var(--accent) 18%, transparent) 0%, transparent 70%),
|
|
||||||
radial-gradient(60% 50% at 85% 20%, rgba(217, 155, 56, 0.12) 0%, transparent 60%),
|
|
||||||
linear-gradient(180deg, var(--surface-sunken) 0%, var(--surface) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-gl-canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
max-width: none !important;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-gl-scrim {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: radial-gradient(circle at 50% 30%, rgba(12, 16, 23, 0.35) 0%, rgba(12, 16, 23, 0.8) 100%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.hero-gl-canvas {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
---
|
|
||||||
import BgPhoto from "./BgPhoto.astro";
|
|
||||||
import type { Content } from "../i18n/content";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
c: Content["typography"];
|
|
||||||
}
|
|
||||||
const { c } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<section class="section band-inverse has-bg" aria-labelledby="type-h">
|
|
||||||
<BgPhoto variant="paper" mode="drift" depth={0.25} opacity={1} />
|
|
||||||
<p class="eyebrow reveal">{c.eyebrow}</p>
|
|
||||||
<h2 id="type-h" class="reveal">{c.h2}</h2>
|
|
||||||
<p class="lead type-lead reveal">{c.lead}</p>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
주장을 글로 하지 않고 실물로 보여준다.
|
|
||||||
|
|
||||||
한때 word-break 하나만 두 칼럼으로 비교했는데, 어절이 짧아 두 쪽이
|
|
||||||
같은 자리에서 끊겼다 — 줄 수도 높이도 똑같았다. 차이가 0 인 비교는
|
|
||||||
증명이 아니라 주장의 반증이다. 셋으로 늘리고, 잘리는 긴 어절을 넣고,
|
|
||||||
폭을 좁혀 실제로 갈리게 만들었다.
|
|
||||||
*/}
|
|
||||||
<div class="full demos reveal">
|
|
||||||
{
|
|
||||||
c.demos.map((d) => (
|
|
||||||
<section class="demo" aria-label={d.title}>
|
|
||||||
<h3 class="demo-title">{d.title}</h3>
|
|
||||||
<div class="demo-pair">
|
|
||||||
<div class="demo-side">
|
|
||||||
<p class="demo-tag mono is-bad">{d.badLabel}</p>
|
|
||||||
<p lang="ko" class:list={["demo-text", `bad-${d.id}`]}>{c.sample}</p>
|
|
||||||
</div>
|
|
||||||
<div class="demo-side">
|
|
||||||
<p class="demo-tag mono is-good">{d.goodLabel}</p>
|
|
||||||
<p lang="ko" class:list={["demo-text", `good-${d.id}`]}>{c.sample}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="demo-note muted">{d.note}</p>
|
|
||||||
</section>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="type-rules reveal">
|
|
||||||
{c.rules.map((r) => (<li>{r}</li>))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.type-lead { margin-bottom: var(--space-6); max-width: var(--measure); }
|
|
||||||
|
|
||||||
.demos {
|
|
||||||
max-width: 1180px;
|
|
||||||
margin-inline: auto;
|
|
||||||
padding-inline: var(--gutter);
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-title {
|
|
||||||
font-size: var(--step-1);
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-pair {
|
|
||||||
display: grid;
|
|
||||||
/* 폭을 좁게 고정한다. 넓으면 두 쪽이 같은 자리에서 끊겨 차이가 사라진다 */
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 15rem));
|
|
||||||
gap: var(--space-5);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-side { min-width: 0; }
|
|
||||||
|
|
||||||
.demo-tag {
|
|
||||||
font-size: var(--step--1);
|
|
||||||
letter-spacing: var(--tracking-label);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
padding-bottom: var(--space-1);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
.is-bad { color: var(--ink-muted); }
|
|
||||||
.is-good { color: var(--accent); border-bottom-color: var(--accent); }
|
|
||||||
|
|
||||||
.demo-text {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--step-0);
|
|
||||||
line-height: var(--leading-normal);
|
|
||||||
word-break: keep-all;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 셋의 실제 차이 ------------------------------------- */
|
|
||||||
/* 1) 줄바꿈 — 기본값은 어절 한가운데를 끊는다 */
|
|
||||||
.bad-break { word-break: break-all; }
|
|
||||||
.good-break { word-break: keep-all; }
|
|
||||||
|
|
||||||
/* 2) 행간 — 라틴 표준을 그대로 쓰면 받침이 붙어 보인다 */
|
|
||||||
.bad-leading { line-height: 1.45; }
|
|
||||||
.good-leading { line-height: 1.75; }
|
|
||||||
|
|
||||||
/* 3) 크기 — 라틴의 라벨 크기는 한글에서 획이 뭉갠다 */
|
|
||||||
.bad-size { font-size: 11px; }
|
|
||||||
.good-size { font-size: 15px; }
|
|
||||||
|
|
||||||
.demo-note {
|
|
||||||
margin: var(--space-3) 0 0;
|
|
||||||
font-size: var(--step-0);
|
|
||||||
max-width: var(--measure);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-rules {
|
|
||||||
list-style: none;
|
|
||||||
margin: var(--space-7) 0 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
.type-rules li {
|
|
||||||
padding-block: var(--space-3);
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
.demo-pair { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
/**
|
|
||||||
* Three.js 지연 로드 부트스트랩
|
|
||||||
* designpaca/references/three.md 코드 A 준수
|
|
||||||
*/
|
|
||||||
export async function boot(
|
|
||||||
canvas: HTMLCanvasElement | null,
|
|
||||||
loader: () => Promise<{ default: (canvas: HTMLCanvasElement, opts: any) => () => void }>
|
|
||||||
) {
|
|
||||||
if (!canvas || !gate()) {
|
|
||||||
canvas?.remove();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await inViewport(canvas);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { default: mount } = await loader();
|
|
||||||
const css = getComputedStyle(document.documentElement);
|
|
||||||
const dispose = mount(canvas, { css });
|
|
||||||
|
|
||||||
const dur = css.getPropertyValue("--dur-normal").trim() || "350ms";
|
|
||||||
const ease = css.getPropertyValue("--ease-out").trim() || "ease";
|
|
||||||
canvas.style.transition = `opacity ${dur} ${ease}`;
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
canvas.style.opacity = "1";
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("pagehide", dispose, { once: true });
|
|
||||||
return dispose;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[designpaca:three] WebGL init fallback:", err);
|
|
||||||
canvas.remove();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function gate(): boolean {
|
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
|
|
||||||
if ((navigator as any).connection?.saveData) return false;
|
|
||||||
if (((navigator as any).deviceMemory ?? 8) < 4 || (navigator.hardwareConcurrency ?? 8) < 4) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const probe = document.createElement("canvas");
|
|
||||||
const gl = probe.getContext("webgl2") || probe.getContext("webgl");
|
|
||||||
if (!gl) return false;
|
|
||||||
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function inViewport(el: HTMLElement): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const io = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
io.disconnect();
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "200px" }
|
|
||||||
);
|
|
||||||
io.observe(el);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import * as THREE from "three";
|
|
||||||
import { createStage } from "../stage.ts";
|
|
||||||
|
|
||||||
const VERT = /* glsl */ `
|
|
||||||
void main() {
|
|
||||||
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FRAG = /* glsl */ `
|
|
||||||
precision highp float;
|
|
||||||
uniform float uTime;
|
|
||||||
uniform float uScroll;
|
|
||||||
uniform vec2 uResolution;
|
|
||||||
uniform vec2 uPointer;
|
|
||||||
uniform vec3 uSurface;
|
|
||||||
uniform vec3 uRaised;
|
|
||||||
uniform vec3 uAccent;
|
|
||||||
uniform vec3 uAccent2;
|
|
||||||
|
|
||||||
// Simplex 2D noise
|
|
||||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
|
||||||
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
|
||||||
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }
|
|
||||||
|
|
||||||
float snoise(vec2 v) {
|
|
||||||
const vec4 C = vec4(0.211324865405187,
|
|
||||||
0.366025403784439,
|
|
||||||
-0.577350269189626,
|
|
||||||
0.024390243902439);
|
|
||||||
vec2 i = floor(v + dot(v, C.yy));
|
|
||||||
vec2 x0 = v - i + dot(i, C.xx);
|
|
||||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
|
||||||
vec4 x12 = x0.xyxy + C.xxzz;
|
|
||||||
x12.xy -= i1;
|
|
||||||
i = mod289(i);
|
|
||||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
|
||||||
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
|
|
||||||
m = m*m;
|
|
||||||
m = m*m;
|
|
||||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
|
||||||
vec3 h = abs(x) - 0.5;
|
|
||||||
vec3 ox = floor(x + 0.5);
|
|
||||||
vec3 a0 = x - ox;
|
|
||||||
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
|
|
||||||
vec3 g;
|
|
||||||
g.x = a0.x * x0.x + h.x * x0.y;
|
|
||||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
|
||||||
return 130.0 * dot(m, g);
|
|
||||||
}
|
|
||||||
|
|
||||||
float fbm(vec2 p) {
|
|
||||||
float v = 0.0;
|
|
||||||
float a = 0.5;
|
|
||||||
mat2 rot = mat2(cos(0.35), sin(0.35), -sin(0.35), cos(0.35));
|
|
||||||
for (int i = 0; i < 3; ++i) {
|
|
||||||
v += a * snoise(p);
|
|
||||||
p = rot * p * 1.75 + vec2(10.0);
|
|
||||||
a *= 0.5;
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// Normalise coordinates to center, maintaining aspect ratio
|
|
||||||
vec2 st = (gl_FragCoord.xy * 2.0 - uResolution) / max(uResolution.x, uResolution.y);
|
|
||||||
|
|
||||||
// Very gentle, slow breathing tempo (ambient silk light wave, no storm vortex)
|
|
||||||
float t = uTime * 0.035;
|
|
||||||
|
|
||||||
// Gentle pointer glow displacement
|
|
||||||
vec2 pc = (uPointer - 0.5) * 1.5;
|
|
||||||
float pDist = length(st - pc);
|
|
||||||
vec2 pDrift = normalize(st - pc + 1e-4) * exp(-pDist * 2.8) * 0.06;
|
|
||||||
|
|
||||||
// Broad, calm rolling ambient wave
|
|
||||||
vec2 waveCoords = st * 1.1 + pDrift;
|
|
||||||
float n1 = fbm(waveCoords + vec2(t * 0.1, t * 0.07));
|
|
||||||
float n2 = fbm(waveCoords * 1.3 - vec2(t * 0.05, n1 * 0.35));
|
|
||||||
|
|
||||||
float lightField = fbm(waveCoords + vec2(n2 * 0.5, t * 0.04));
|
|
||||||
|
|
||||||
// Base background colors
|
|
||||||
vec3 col = mix(uSurface, uRaised, clamp(lightField * 0.65 + 0.35, 0.0, 1.0));
|
|
||||||
|
|
||||||
// Subtle, elegant emerald & amber mineral glows in soft ambient pockets
|
|
||||||
float accentGlow = smoothstep(-0.25, 0.65, n1) * 0.28;
|
|
||||||
float goldGlow = smoothstep(-0.15, 0.7, n2) * 0.14;
|
|
||||||
|
|
||||||
col = mix(col, uAccent, accentGlow);
|
|
||||||
col = mix(col, uAccent2, goldGlow);
|
|
||||||
|
|
||||||
// Soft vignette
|
|
||||||
float vignette = smoothstep(1.35, 0.35, length(st));
|
|
||||||
col *= (0.75 + 0.25 * vignette);
|
|
||||||
|
|
||||||
// Grain dither to eliminate banding
|
|
||||||
float grain = (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * (1.2 / 255.0);
|
|
||||||
col += grain;
|
|
||||||
|
|
||||||
gl_FragColor = vec4(col, 0.92);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function mount(canvas: HTMLCanvasElement, { css }: { css: CSSStyleDeclaration }) {
|
|
||||||
const stage = createStage(canvas, { dprMax: Math.min(window.devicePixelRatio || 1, 1.5), alpha: true });
|
|
||||||
|
|
||||||
const getColor = (token: string, fallback: string) => {
|
|
||||||
const val = css.getPropertyValue(token).trim();
|
|
||||||
return new THREE.Color(val || fallback);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fullscreen Triangle
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
|
||||||
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
|
|
||||||
|
|
||||||
const uniforms = {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uScroll: { value: 0 },
|
|
||||||
uResolution: { value: new THREE.Vector2(1, 1) },
|
|
||||||
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
|
|
||||||
uSurface: { value: getColor("--surface-raised", "#121820") },
|
|
||||||
uRaised: { value: getColor("--surface-sunken", "#080c11") },
|
|
||||||
uAccent: { value: getColor("--accent", "#3ee5b3") },
|
|
||||||
uAccent2: { value: new THREE.Color("#d99b38") }, // Warm gold mineral glow
|
|
||||||
};
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(
|
|
||||||
geometry,
|
|
||||||
new THREE.ShaderMaterial({
|
|
||||||
vertexShader: VERT,
|
|
||||||
fragmentShader: FRAG,
|
|
||||||
uniforms,
|
|
||||||
depthTest: false,
|
|
||||||
depthWrite: false,
|
|
||||||
transparent: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
mesh.frustumCulled = false;
|
|
||||||
stage.scene.add(mesh);
|
|
||||||
|
|
||||||
stage.onResize = (w: number, h: number, dpr: number) => {
|
|
||||||
uniforms.uResolution.value.set(w * dpr, h * dpr);
|
|
||||||
};
|
|
||||||
stage.onResize(stage.size.w, stage.size.h, stage.size.dpr);
|
|
||||||
|
|
||||||
const targetPointer = new THREE.Vector2(0.5, 0.5);
|
|
||||||
const onPointerMove = (e: MouseEvent) => {
|
|
||||||
targetPointer.set(e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight);
|
|
||||||
};
|
|
||||||
window.addEventListener("pointermove", onPointerMove, { passive: true });
|
|
||||||
|
|
||||||
stage.add((t: number, dt: number) => {
|
|
||||||
uniforms.uTime.value = t;
|
|
||||||
uniforms.uScroll.value = window.scrollY || 0;
|
|
||||||
|
|
||||||
// Smooth pointer lerp
|
|
||||||
uniforms.uPointer.value.lerp(targetPointer, Math.min(1, dt * 2.5));
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("pointermove", onPointerMove);
|
|
||||||
geometry.dispose();
|
|
||||||
(mesh.material as THREE.Material).dispose();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import * as THREE from "three";
|
|
||||||
|
|
||||||
export interface StageOptions {
|
|
||||||
dprMax?: number;
|
|
||||||
alpha?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStage(canvas: HTMLCanvasElement, { dprMax = 1.5, alpha = true }: StageOptions = {}) {
|
|
||||||
const renderer = new THREE.WebGLRenderer({
|
|
||||||
canvas,
|
|
||||||
alpha,
|
|
||||||
antialias: false,
|
|
||||||
stencil: false,
|
|
||||||
powerPreference: "high-performance",
|
|
||||||
});
|
|
||||||
|
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
||||||
renderer.toneMapping = THREE.NoToneMapping;
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
|
|
||||||
camera.position.z = 5;
|
|
||||||
|
|
||||||
const size = { w: 0, h: 0, dpr: 1 };
|
|
||||||
const updaters = new Set<(t: number, dt: number) => void>();
|
|
||||||
let running = false;
|
|
||||||
let last = 0;
|
|
||||||
const t0 = performance.now();
|
|
||||||
|
|
||||||
const draw = (t: number, dt: number) => {
|
|
||||||
for (const f of updaters) f(t, dt);
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const dt = Math.min((now - last) / 1000, 1 / 20);
|
|
||||||
last = now;
|
|
||||||
draw((now - t0) / 1000, dt);
|
|
||||||
};
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
if (!running) {
|
|
||||||
running = true;
|
|
||||||
last = performance.now();
|
|
||||||
renderer.setAnimationLoop(tick);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const stop = () => {
|
|
||||||
running = false;
|
|
||||||
renderer.setAnimationLoop(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
const w = Math.max(1, Math.round(r.width));
|
|
||||||
const h = Math.max(1, Math.round(r.height));
|
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, dprMax);
|
|
||||||
|
|
||||||
if (w === size.w && h === size.h && dpr === size.dpr) return;
|
|
||||||
Object.assign(size, { w, h, dpr });
|
|
||||||
|
|
||||||
renderer.setPixelRatio(dpr);
|
|
||||||
renderer.setSize(w, h, false);
|
|
||||||
camera.aspect = w / h;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
|
|
||||||
stage.onResize?.(w, h, dpr);
|
|
||||||
if (!running) draw(0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(resize) : null;
|
|
||||||
ro?.observe(canvas);
|
|
||||||
|
|
||||||
const io =
|
|
||||||
typeof IntersectionObserver !== "undefined"
|
|
||||||
? new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()), { rootMargin: "10%" })
|
|
||||||
: null;
|
|
||||||
io?.observe(canvas);
|
|
||||||
|
|
||||||
const onVis = () => (document.hidden ? stop() : start());
|
|
||||||
document.addEventListener("visibilitychange", onVis);
|
|
||||||
|
|
||||||
const onLost = (e: Event) => {
|
|
||||||
e.preventDefault();
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
canvas.addEventListener("webglcontextlost", onLost);
|
|
||||||
|
|
||||||
const stage = {
|
|
||||||
renderer,
|
|
||||||
scene,
|
|
||||||
camera,
|
|
||||||
size,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
onResize: null as ((w: number, h: number, dpr: number) => void) | null,
|
|
||||||
renderOnce: () => draw(0, 0),
|
|
||||||
add(f: (t: number, dt: number) => void) {
|
|
||||||
updaters.add(f);
|
|
||||||
return () => updaters.delete(f);
|
|
||||||
},
|
|
||||||
setDprMax(v: number) {
|
|
||||||
dprMax = v;
|
|
||||||
size.dpr = -1;
|
|
||||||
resize();
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
stop();
|
|
||||||
ro?.disconnect();
|
|
||||||
io?.disconnect();
|
|
||||||
document.removeEventListener("visibilitychange", onVis);
|
|
||||||
canvas.removeEventListener("webglcontextlost", onLost);
|
|
||||||
scene.traverse((o: any) => {
|
|
||||||
o.geometry?.dispose();
|
|
||||||
const mats = [o.material].flat().filter(Boolean);
|
|
||||||
for (const m of mats) {
|
|
||||||
for (const u of Object.values(m.uniforms ?? {}) as any[]) {
|
|
||||||
if (u?.value?.isTexture) u.value.dispose();
|
|
||||||
}
|
|
||||||
m.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
scene.clear();
|
|
||||||
renderer.dispose();
|
|
||||||
renderer.forceContextLoss();
|
|
||||||
updaters.clear();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
return stage;
|
|
||||||
}
|
|
||||||
|
|
@ -1,693 +0,0 @@
|
||||||
/*
|
|
||||||
사이트 전체 문안. 레이아웃이 아니라 이 파일만 고치면 두 언어판이 함께 갱신된다.
|
|
||||||
|
|
||||||
문장 규칙(2026-08-21 카피 전면 재작성 때 정한 것):
|
|
||||||
- 본문은 존댓말로 통일한다. 제목·헤드라인은 명사구 또는 단형 종결.
|
|
||||||
- 근거 없는 수치를 넣지 않는다. 재지 않은 값은 "미측정"으로 남긴다.
|
|
||||||
- "—" 를 문장 부호로 쓰지 않는다. 쉼표·마침표로 끊는다.
|
|
||||||
- 자기 검증 이야기(프리플라이트)는 사실 그대로. 포장하지 않고, 고친 것만 적는다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Stage {
|
|
||||||
name: string;
|
|
||||||
does: string;
|
|
||||||
becomes: string;
|
|
||||||
mono?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Metric {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
note: string;
|
|
||||||
feature?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Rule {
|
|
||||||
rule: string;
|
|
||||||
why: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Content {
|
|
||||||
meta: { title: string; description: string };
|
|
||||||
hero: {
|
|
||||||
h1: string;
|
|
||||||
lead: string;
|
|
||||||
install: string;
|
|
||||||
installNote: string;
|
|
||||||
links: { label: string; href: string }[];
|
|
||||||
stage: {
|
|
||||||
alt: string;
|
|
||||||
caption: string;
|
|
||||||
gate: string;
|
|
||||||
pass: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
pipeline: {
|
|
||||||
eyebrow: string;
|
|
||||||
h2: string;
|
|
||||||
lead: string;
|
|
||||||
briefLabel: string;
|
|
||||||
brief: string;
|
|
||||||
stages: Stage[];
|
|
||||||
};
|
|
||||||
materials: {
|
|
||||||
eyebrow: string;
|
|
||||||
h2: string;
|
|
||||||
lead: string;
|
|
||||||
demos: {
|
|
||||||
id: "scrim" | "cvd" | "goo" | "glass";
|
|
||||||
name: string;
|
|
||||||
problem: string;
|
|
||||||
beforeLabel: string;
|
|
||||||
afterLabel: string;
|
|
||||||
how: string;
|
|
||||||
verdict: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
metrics: { eyebrow: string; h2: string; lead: string; items: Metric[]; caveat: string };
|
|
||||||
preflight: {
|
|
||||||
h2: string;
|
|
||||||
lead: string;
|
|
||||||
items: { caught: string; fixed: string }[];
|
|
||||||
note: string;
|
|
||||||
};
|
|
||||||
typography: {
|
|
||||||
eyebrow: string;
|
|
||||||
h2: string;
|
|
||||||
lead: string;
|
|
||||||
sample: string;
|
|
||||||
demos: {
|
|
||||||
id: "break" | "leading" | "size";
|
|
||||||
title: string;
|
|
||||||
badLabel: string;
|
|
||||||
goodLabel: string;
|
|
||||||
note: string;
|
|
||||||
}[];
|
|
||||||
rules: string[];
|
|
||||||
};
|
|
||||||
rules: { h2: string; lead: string; items: Rule[] };
|
|
||||||
install: {
|
|
||||||
h2: string;
|
|
||||||
lead: string;
|
|
||||||
command: string;
|
|
||||||
targets: { name: string; path: string }[];
|
|
||||||
commands: { cmd: string; what: string }[];
|
|
||||||
};
|
|
||||||
/* 저장소 링크는 푸터가 아니라 헤더에 둔다.
|
|
||||||
푸터를 없애면 소스로 가는 길이 사라지고, 개발 도구 소개 페이지에서
|
|
||||||
그 링크는 장식이 아니라 목적지다. */
|
|
||||||
nav: { items: { label: string; href: string }[]; menu: string; repo: string; repoLabel: string };
|
|
||||||
ui: { copy: string; copied: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
const STAGES_KO: Stage[] = [
|
|
||||||
{
|
|
||||||
name: "브리프 게이트",
|
|
||||||
does: "무엇을, 누구에게, 어떤 제약인지 세 줄로 압축합니다. 규모를 판정해 작업 경로를 고릅니다.",
|
|
||||||
becomes: "누구에게: 홈카페 3년차 이상. 산미와 로스팅 프로파일을 구분합니다",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "레퍼런스 조사",
|
|
||||||
does: "실제로 존재하는 사이트 세 곳을 서로 다른 층위에서 고릅니다. 구조, 톤, 디테일.",
|
|
||||||
becomes: "R2 = 독립 문학 출판사. 커피 업계 밖에서 톤을 가져옵니다",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "방향 결정",
|
|
||||||
does: "레퍼런스를 하나의 방향으로 합성하고, 감수할 리스크를 하나 고릅니다.",
|
|
||||||
becomes: "감상은 이름에만, 화면에는 수치만",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "디자인 토큰",
|
|
||||||
does: "코드를 쓰기 전에 숫자를 정합니다. 성능 예산도 이 단계에서 확정합니다.",
|
|
||||||
becomes: "--measure: 33em / --leading-normal: 1.7 / JS 예산 0KB",
|
|
||||||
mono: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "구현",
|
|
||||||
does: "레이아웃, 재질, 입체, 모션 순서로 만듭니다. 이펙트를 모두 꺼도 완성돼 있어야 합니다.",
|
|
||||||
becomes: "<h1>산미 8.5 · 바디 6.0 · 로스팅 라이트</h1>",
|
|
||||||
mono: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "프리플라이트",
|
|
||||||
does: "자기 결과물을 남의 것처럼 검사합니다. 하드 게이트 12개는 모두 '아니오'여야 합니다.",
|
|
||||||
becomes: "게이트 12/12 통과 · 슬롭 지문 0건 · 320px 오버플로 31px 수정",
|
|
||||||
mono: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const STAGES_EN: Stage[] = [
|
|
||||||
{
|
|
||||||
name: "Brief gate",
|
|
||||||
does: "Compress the brief into three lines: what, for whom, constraints. Pick a path by scope.",
|
|
||||||
becomes: "For whom: home baristas of 3+ years who can taste the roast profile",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Reference research",
|
|
||||||
does: "Pick three real sites at three different layers: structure, tone, detail.",
|
|
||||||
becomes: "R2 = an independent literary publisher. Tone comes from outside the category",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Direction",
|
|
||||||
does: "Synthesize the references into one axis. Choose exactly one risk worth taking.",
|
|
||||||
becomes: "Sentiment stays in the name. The screen carries numbers.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Design tokens",
|
|
||||||
does: "Decide the numbers before writing code. The performance budget is fixed here too.",
|
|
||||||
becomes: "--measure: 65ch / --leading-normal: 1.55 / JS budget 0KB",
|
|
||||||
mono: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Build",
|
|
||||||
does: "Layout, surface, depth, motion, in that order. It must be finished with every effect switched off.",
|
|
||||||
becomes: "<h1>Acidity 8.5 · Body 6.0 · Light roast</h1>",
|
|
||||||
mono: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Preflight",
|
|
||||||
does: "Read your own work as a stranger's. All twelve gates must answer no.",
|
|
||||||
becomes: "12/12 gates passed · 0 slop fingerprints · 31px overflow at 320px fixed",
|
|
||||||
mono: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ko: Content = {
|
|
||||||
meta: {
|
|
||||||
title: "designpaca · 웹 디자인 파이프라인 스킬",
|
|
||||||
description:
|
|
||||||
"AI 코딩 에이전트가 웹 디자인을 순서대로 하게 만드는 스킬입니다. 레퍼런스 조사부터 셀프 감사까지 여섯 단계를 통과시켜, 템플릿 같은 페이지가 나올 자리에 근거 있는 디자인이 나오게 합니다. Claude Code, Codex, Cursor 등에 한 줄로 설치합니다.",
|
|
||||||
},
|
|
||||||
hero: {
|
|
||||||
h1: "결정하지 않으면 기본값이 나온다",
|
|
||||||
lead:
|
|
||||||
"AI가 뽑은 페이지가 템플릿처럼 보이는 건 능력이 부족해서가 아니라 결정이 없어서입니다. designpaca는 레퍼런스 조사부터 셀프 감사까지 여섯 단계를 순서대로 통과시키는 웹 디자인 파이프라인 스킬입니다. 단계마다 통과 조건이 있어서, 기본값이 들어갈 자리에 근거가 들어갑니다.",
|
|
||||||
install: "npx designpaca",
|
|
||||||
installNote: "Node 20.11 이상 · 설치 대상은 자동으로 찾아 선택합니다",
|
|
||||||
links: [
|
|
||||||
{ label: "사례 보기", href: "/showcase" },
|
|
||||||
{ label: "npm", href: "https://www.npmjs.com/package/designpaca" },
|
|
||||||
],
|
|
||||||
stage: {
|
|
||||||
alt: "유리판 여섯 장이 깊이 방향으로 나란히 서 있습니다. 스크롤하면 카메라가 판을 하나씩 통과하고, 통과한 판은 뒤로 물러납니다.",
|
|
||||||
caption: "판 하나가 단계 하나입니다. 통과 조건을 채우지 못하면 다음 판으로 넘어가지 않습니다.",
|
|
||||||
gate: "게이트",
|
|
||||||
pass: "통과",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pipeline: {
|
|
||||||
eyebrow: "여섯 단계",
|
|
||||||
h2: "브리프 한 줄이 검증된 페이지가 될 때까지",
|
|
||||||
lead:
|
|
||||||
"모든 단계에는 통과 조건이 있습니다. 통과하지 못하면 다음 단계로 넘어가지 않습니다. 아래는 같은 브리프가 여섯 단계를 지나며 무엇으로 바뀌는지입니다.",
|
|
||||||
briefLabel: "들어온 브리프",
|
|
||||||
brief: "수제 커피 로스터리의 원두 정기구독 랜딩 만들어줘",
|
|
||||||
stages: STAGES_KO,
|
|
||||||
},
|
|
||||||
materials: {
|
|
||||||
eyebrow: "SVG 필터",
|
|
||||||
h2: "이미지 편집기를 열지 않고 고치는 네 가지",
|
|
||||||
lead:
|
|
||||||
"질감 필터 데모는 흔합니다. 중요한 건 손이 실제로 줄어드는 자리입니다. 아래 네 가지에서 왼쪽은 매번 손으로 맞추던 방식, 오른쪽은 같은 소재에 선언 하나를 얹은 결과입니다. 수치는 이 화면을 그대로 캡처해 잰 값입니다.",
|
|
||||||
demos: [
|
|
||||||
{
|
|
||||||
id: "scrim",
|
|
||||||
name: "글자 모양대로 생기는 스크림",
|
|
||||||
problem:
|
|
||||||
"사진 위 캡션이 대비 기준을 못 넘깁니다. 사진마다 밝기가 달라서 반투명 박스를 매번 손으로 맞추게 됩니다.",
|
|
||||||
beforeLabel: "그냥 얹음",
|
|
||||||
afterLabel: "filter: url(#scrim)",
|
|
||||||
how: "글자 알파를 부풀려 그 모양대로만 색을 채웁니다. 박스가 아니라 글자를 따라가므로 사진을 덜 가립니다.",
|
|
||||||
verdict: "흰 글자 대비 1.70:1 → 4.79:1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cvd",
|
|
||||||
name: "색각 이상 시뮬레이션",
|
|
||||||
problem:
|
|
||||||
"상태를 색으로만 구분하면 남성 12명 중 1명에게는 같은 배지로 보입니다. 내 화면만 보고는 잡을 수 없습니다.",
|
|
||||||
beforeLabel: "내가 보는 화면",
|
|
||||||
afterLabel: "적록색약(2형)",
|
|
||||||
how: "feColorMatrix 한 줄입니다. 이것만은 linearRGB 로 둡니다. 색각 변환은 물리적인 빛의 혼합이기 때문입니다.",
|
|
||||||
verdict: "배지 셋의 최소 색거리 82 → 2 · 주의와 실패가 같은 색",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "goo",
|
|
||||||
name: "떨어진 것이 하나로 뭉치기",
|
|
||||||
problem:
|
|
||||||
"조각이 녹아 붙는 표현에 캔버스나 물리 라이브러리를 켜게 됩니다. 로더와 메뉴 전환에서 반복됩니다.",
|
|
||||||
beforeLabel: "필터 없음 · 원 3개",
|
|
||||||
afterLabel: "filter: url(#goo)",
|
|
||||||
how: "블러한 뒤 알파만 세워 잘라냅니다. 붙을지 말지는 취향이 아니라 간격과 블러 반경의 비율로 정해집니다.",
|
|
||||||
verdict: "덩어리 3개 → 1개 · JS 0줄",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "glass",
|
|
||||||
name: "배경을 휘게 하는 오버레이",
|
|
||||||
problem: "반투명 패널은 뒤를 흐리게만 만듭니다. 실제 유리는 빛을 휩니다.",
|
|
||||||
beforeLabel: "backdrop-filter: blur",
|
|
||||||
afterLabel: "blur + url(#lens)",
|
|
||||||
how: "변위 지도를 backdrop-filter 에 넘깁니다. 노이즈로 만들면 값이 중립으로 수렴해서 아무 일도 일어나지 않습니다.",
|
|
||||||
verdict: "가장자리에서 격자가 눌립니다 · Chromium 전용",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
metrics: {
|
|
||||||
eyebrow: "이 페이지의 수치",
|
|
||||||
h2: "카피만 고쳐서는 슬롭이 없어지지 않는다",
|
|
||||||
lead:
|
|
||||||
"문장을 다듬으면 좋아지는 건 문장뿐입니다. radius 어휘 수, 반투명 패널 개수, 타입 단계 수는 그대로 남습니다. 그래서 이 페이지는 자기 자신의 형태 수치를 공개합니다. 나쁘게 나와도 그대로 싣습니다. 감사를 남기는 것 자체가 이 스킬의 핵심 기능이기 때문입니다.",
|
|
||||||
items: [
|
|
||||||
{ label: "히어로까지 JS", value: "0.6%", note: "예산 50KB 중 실제로 쓴 비율", feature: true },
|
|
||||||
{ label: "하드 게이트", value: "12/12", note: "정적 검사와 320px · 1440px 실렌더 양쪽", feature: true },
|
|
||||||
{ label: "three.js 추가분", value: "184KB", note: "검사대가 화면에 들어올 때만 내려받습니다. 초기 번들에는 없습니다" },
|
|
||||||
{ label: "슬롭 지문", value: "0건", note: "9개 패턴 grep" },
|
|
||||||
{ label: "radius 어휘", value: "2종", note: "3px 과 14px. 알약 모양을 없앴습니다" },
|
|
||||||
{ label: "반투명 패널", value: "2개", note: "한때 29개까지 늘었고 감사에서 걷어냈습니다. 유리는 무언가를 담는 액자에만 남겼습니다" },
|
|
||||||
{ label: "타입 단계", value: "6단계", note: "토큰에 정의된 수입니다. 디스플레이는 스케일 밖이고, 최대 글자는 본문의 5.6배" },
|
|
||||||
{ label: "폰트 패밀리", value: "2개", note: "언어판마다 자기 것만 받습니다" },
|
|
||||||
{ label: "강조색", value: "1개", note: "라이트와 다크는 같은 색의 대응값" },
|
|
||||||
{ label: "첫 인터랙션", value: "미측정", note: "배포 후 Lighthouse 로 잽니다" },
|
|
||||||
],
|
|
||||||
caveat:
|
|
||||||
"값은 프로덕션 빌드에서 측정합니다. 아직 재지 않은 항목은 비워둡니다. 지어낸 숫자보다 빈칸이 정직합니다. 이 페이지는 제품 데모라서 three.js 를 얹으며 JS 예산을 올렸고, 그 사실을 여기에 적는 것 자체가 규칙입니다.",
|
|
||||||
},
|
|
||||||
preflight: {
|
|
||||||
h2: "이 페이지를 만들며 실제로 걸린 것",
|
|
||||||
lead: "남을 검사한 결과가 아니라 자신을 검사한 결과입니다.",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
caught: "폰트가 교체되는 순간 글자 폭이 13% 튀고 있었습니다",
|
|
||||||
fixed:
|
|
||||||
"웹폰트가 오기 전에는 시스템 폰트로 그려집니다. 맑은 고딕은 Pretendard 보다 13% 넓어서 교체되는 순간 줄바꿈이 다시 잡힙니다. 폴백 폰트에 size-adjust 로 치수를 맞췄고 실측 차이는 0.0%가 되었습니다. OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의했습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "유리 카드를 29개 뿌렸습니다. 재질만 바뀌었을 뿐 구조는 카드 나열 그대로였습니다",
|
|
||||||
fixed:
|
|
||||||
"우리 규칙이 '카드는 elevation 이 진짜 위계를 나를 때만'이라고 정해두고 있었습니다. 수치는 조판으로, 규칙은 어긋난 목록으로, 감사 기록은 로그로 바꿨습니다. 유리는 5개만 남았습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "'backdrop-filter 에 SVG 를 얹으면 Chrome 이 선언을 버린다'고 적었는데 사실이 아니었습니다",
|
|
||||||
fixed:
|
|
||||||
"한 번 실패하자 브라우저 탓으로 적어뒀습니다. 다시 재보니 Chrome 151 에서 computed 값은 none 이 아니라 url(#id) 로 살아 있고 배경도 실제로 굴절됩니다. 진짜 원인은 feImage 의 width/height 를 요소 크기와 맞추지 않은 것이었습니다. backdrop-filter 는 필터 이미지를 자동으로 맞춰주지 않습니다. 한 번 실패한 것을 '안 되는 것'으로 적으면 그 문장이 다음 사람의 상한선이 됩니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "가로로 444px 넘쳤습니다. 레이아웃 전체가 어긋나 있었습니다",
|
|
||||||
fixed:
|
|
||||||
"main 에서 그리드 클래스가 빠져 있었습니다. subgrid 는 부모가 그리드일 때만 작동하므로 모든 섹션이 정렬선을 잃은 상태였습니다. 눈으로는 어색한 정도였고 폭을 재고서야 드러났습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "스크롤을 끝까지 내려도 파이프라인 6단계 중 뒤 3개가 안 보였습니다",
|
|
||||||
fixed:
|
|
||||||
"scroll-driven animation 의 범위 끝에 페이지 하단 요소는 도달하지 못합니다. 이펙트를 끄면 보이고 켜면 사라지는 것은 폴백이 있는 것과 다릅니다. 한 번 보이면 유지되는 방식으로 바꿨습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "다크 밴드 안 버튼의 대비가 2.88:1. 기준은 4.5:1 이었습니다",
|
|
||||||
fixed:
|
|
||||||
"밴드가 강조색만 반전시키고 그 위 글자색은 흰색 그대로였습니다. 반전 대응 토큰을 하나 더 두고 함께 바꿨습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "320px 에서 가로로 13px 넘쳤습니다. 표는 이미 스크롤 컨테이너 안에 있었는데도요",
|
|
||||||
fixed:
|
|
||||||
"그리드 항목의 기본 min-width 는 auto 라서 내용이 부모를 밀어냅니다. min-width: 0 을 줘야만 overflow 가 제 역할을 합니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "eyebrow 라벨이 6개였습니다. 규칙 상한은 3개였습니다",
|
|
||||||
fixed: "셋을 남기고 나머지를 걷었습니다. 라벨이 없어도 섹션은 충분히 읽힙니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "본문 컬럼이 960px 였습니다. 방금 토큰으로 정한 measure 는 561px 였습니다",
|
|
||||||
fixed:
|
|
||||||
"그리드의 main 컬럼을 토큰에 묶었습니다. 스킬의 예시 코드도 같은 문제를 갖고 있어서 함께 고쳤습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "방향을 바꿀 때 이 스킬의 1단계를 건너뛰었습니다",
|
|
||||||
fixed:
|
|
||||||
"재질을 유리로 바꾸는 것은 2단계 결정이고, 그러려면 레퍼런스로 돌아가야 합니다. 우리는 구현으로 바로 들어갔습니다. 결과는 이전 방향의 뼈대에 유리를 바른 페이지였고 design.md 는 옛 결정을 설명하고 있었습니다. 0단계부터 전체 경로를 다시 돌았습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "유리판이 카메라를 정면으로 보고 있어 프레넬이 구조적으로 0 이었습니다",
|
|
||||||
fixed:
|
|
||||||
"평면이 정면을 보면 법선과 시선의 내적이 1 이라 가장자리 반사가 계산되지 않습니다. 유리로 읽히게 하는 유일한 신호가 죽어 있어 여섯 판이 흐릿한 사각형으로 보였습니다. 법선을 살짝 굽히고 UV 가장자리에서 테두리 광을 따로 만들었습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "이 페이지의 뼈대인 파이프라인 섹션이 뷰포트의 43% 만 쓰고 있었습니다",
|
|
||||||
fixed:
|
|
||||||
"1440px 중 625px. 아홉 섹션 중 가장 좁으면서 높이는 1637px 로 가장 길었습니다. 각 단계를 2열로 열어 1113px 로 넓혔습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "대비 검사 스크립트가 반투명 배경을 불투명으로 계산하고 있었습니다",
|
|
||||||
fixed:
|
|
||||||
"'English 1.15:1' 이라는 틀린 값이 나왔습니다. 알파를 밑바닥부터 합성하도록 고치니 실패가 0건이 되었습니다. 검사 도구가 틀리면 통과도 실패도 의미가 없습니다.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
note:
|
|
||||||
"열세 건 모두 이 페이지를 만들며 걸렸고, 걸린 뒤에 고쳤습니다. 그중 세 건은 검사기가 아니라 사람이 먼저 잡았습니다. 카드가 전형적이라는 지적, 폰트를 대충 다룬다는 지적, 그리고 이 스킬을 정말로 썼느냐는 질문이었습니다.",
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
eyebrow: "한글 조판",
|
|
||||||
h2: "라틴 기본값은 한글에서 세 군데가 깨진다",
|
|
||||||
lead:
|
|
||||||
"영어권 디자인 도구에는 한글 조판 규칙이 없습니다. 기본값을 그대로 두면 아래 세 가지가 동시에 무너집니다. 왼쪽이 기본값, 오른쪽이 이 스킬이 정한 값입니다. 글자도 폭도 폰트도 같습니다.",
|
|
||||||
sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 합니다.",
|
|
||||||
demos: [
|
|
||||||
{
|
|
||||||
id: "break",
|
|
||||||
title: "줄바꿈",
|
|
||||||
badLabel: "기본값",
|
|
||||||
goodLabel: "word-break: keep-all",
|
|
||||||
note: "기본값은 어절 한가운데를 끊습니다. 왼쪽 마지막 줄을 보세요. 한 단어가 두 줄에 걸쳐 있습니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "leading",
|
|
||||||
title: "행간",
|
|
||||||
badLabel: "line-height 1.45",
|
|
||||||
goodLabel: "line-height 1.75",
|
|
||||||
note: "라틴 표준인 1.4~1.5 는 받침이 있는 글자에 좁습니다. 줄이 서로 붙어 보입니다.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "size",
|
|
||||||
title: "크기",
|
|
||||||
badLabel: "11px",
|
|
||||||
goodLabel: "15px",
|
|
||||||
note: "라틴에서 11px 은 라벨에 쓰는 크기입니다. 한글은 획이 많아서 같은 크기에서 뭉개집니다.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rules: [
|
|
||||||
"word-break: keep-all 이 없으면 단어가 아무 데서나 잘립니다",
|
|
||||||
"행간은 1.6에서 1.8 사이. 라틴보다 넉넉하게",
|
|
||||||
"음수 자간은 쓰지 않습니다. 한글은 이미 고정폭에 가까워서 바로 뭉개집니다",
|
|
||||||
"한 줄은 25자에서 40자. 라틴 기준 65자를 그대로 쓰면 너무 깁니다",
|
|
||||||
"한글 폰트를 지정하지 않은 것 자체가 미완성의 신호입니다",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
h2: "규칙마다 왜 그런지 적혀 있다",
|
|
||||||
lead:
|
|
||||||
"근거 없는 금지는 지켜지지 않습니다. 그래서 모든 규칙에 이유가 적혀 있습니다. 사용자가 명시적으로 요구하면 규칙보다 사용자가 이깁니다.",
|
|
||||||
items: [
|
|
||||||
{ rule: "레퍼런스 없이 시작하지 않는다", why: "머릿속 기본값에서 출발하면 결과는 그 카테고리의 평균이 됩니다." },
|
|
||||||
{ rule: "강조색은 하나다", why: "두 개가 필요하다고 느끼면 위계 설계가 실패한 것입니다." },
|
|
||||||
{ rule: "대담함은 한 곳에만", why: "두 곳에서 소리치면 둘 다 죽습니다." },
|
|
||||||
{ rule: "화려함은 4순위다", why: "심사 배점이 Design 40 / Usability 30 / Creativity 20 / Content 10 입니다." },
|
|
||||||
{ rule: "지어낸 수치를 쓰지 않는다", why: "숫자 모양의 구멍은 정직하고, 지어낸 숫자는 신뢰를 깎습니다." },
|
|
||||||
{ rule: "이펙트를 다 꺼도 완성돼 있어야 한다", why: "그 상태가 모든 폴백의 기반입니다. 무너지면 이펙트가 구조를 대신하고 있는 것입니다." },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
install: {
|
|
||||||
h2: "한 줄이면 된다",
|
|
||||||
lead:
|
|
||||||
"설치할 하네스를 자동으로 찾아 선택합니다. 스킬 파일을 직접 고쳤다면 업데이트가 그 파일을 건드리지 않습니다.",
|
|
||||||
command: "npx designpaca",
|
|
||||||
targets: [
|
|
||||||
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
|
|
||||||
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
|
|
||||||
{ name: "Cursor", path: "~/.cursor/skills/designpaca/" },
|
|
||||||
{ name: "ZCode", path: "~/.zcode/skills/designpaca/" },
|
|
||||||
{ name: "중립 경로", path: "~/.agents/skills/designpaca/ · 어떤 하네스든" },
|
|
||||||
{ name: "Antigravity", path: "~/.gemini/config/skills/designpaca/" },
|
|
||||||
{ name: "Gemini CLI", path: "~/.gemini/skills/designpaca/" },
|
|
||||||
{ name: "Windsurf", path: "~/.codeium/windsurf/skills/designpaca/" },
|
|
||||||
{ name: "Copilot", path: "~/.copilot/skills/designpaca/" },
|
|
||||||
{ name: "AGENTS.md", path: "블록 주입. 에이전트 무관" },
|
|
||||||
],
|
|
||||||
commands: [
|
|
||||||
{ cmd: "npx designpaca update", what: "최신 스킬로 갱신합니다. 직접 고친 파일은 건너뜁니다" },
|
|
||||||
{ cmd: "npx designpaca doctor", what: "설치 상태와 드리프트를 진단합니다" },
|
|
||||||
{ cmd: "npx designpaca uninstall", what: "매니페스트 기반으로 정확하게 제거합니다" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
nav: {
|
|
||||||
items: [
|
|
||||||
{ label: "파이프라인", href: "#pipeline" },
|
|
||||||
{ label: "사례", href: "/showcase" },
|
|
||||||
{ label: "수치", href: "#metrics" },
|
|
||||||
{ label: "설치", href: "#install" },
|
|
||||||
],
|
|
||||||
menu: "메뉴",
|
|
||||||
repo: "https://git.chanpaca.net/yunchan/designpaca",
|
|
||||||
repoLabel: "저장소",
|
|
||||||
},
|
|
||||||
ui: { copy: "복사", copied: "복사됨" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const en: Content = {
|
|
||||||
meta: {
|
|
||||||
title: "designpaca · a web design pipeline skill",
|
|
||||||
description:
|
|
||||||
"A skill that makes AI coding agents do web design in order. Six stages from reference research to self-audit, each with a pass condition, so defaults get replaced with reasons. Installs into Claude Code, Codex, Cursor and more in one line.",
|
|
||||||
},
|
|
||||||
hero: {
|
|
||||||
h1: "Undecided design ships as defaults",
|
|
||||||
lead:
|
|
||||||
"Pages built by AI look templated not because the model lacks taste, but because nobody made the decisions. designpaca is a web design pipeline skill for AI coding agents. Six stages from reference research to self-audit, each with a pass condition, so the slot where a default would land gets a reason instead.",
|
|
||||||
install: "npx designpaca",
|
|
||||||
installNote: "Node 20.11+ · install targets are detected and preselected",
|
|
||||||
links: [
|
|
||||||
{ label: "See the work", href: "/showcase" },
|
|
||||||
{ label: "npm", href: "https://www.npmjs.com/package/designpaca" },
|
|
||||||
{ label: "Repository", href: "https://git.chanpaca.net/yunchan/designpaca" },
|
|
||||||
],
|
|
||||||
stage: {
|
|
||||||
alt: "Six glass panes stand in a row along the depth axis. Scrolling moves the camera through them one at a time, and each pane recedes once passed.",
|
|
||||||
caption: "One pane is one stage. Fail its condition and you do not reach the next.",
|
|
||||||
gate: "GATE",
|
|
||||||
pass: "PASS",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pipeline: {
|
|
||||||
eyebrow: "Six stages",
|
|
||||||
h2: "From one line of brief to a page that passed its own audit",
|
|
||||||
lead:
|
|
||||||
"Every stage has a pass condition. Fail it and you do not move on. Below is what one brief became at each stage.",
|
|
||||||
briefLabel: "The brief that came in",
|
|
||||||
brief: "Build a subscription landing page for a small-batch coffee roastery",
|
|
||||||
stages: STAGES_EN,
|
|
||||||
},
|
|
||||||
materials: {
|
|
||||||
eyebrow: "SVG filters",
|
|
||||||
h2: "Four things you fix without opening an image editor",
|
|
||||||
lead:
|
|
||||||
"Filter demos that make texture are everywhere. What matters is where filters actually save you work. In all four below, the left is what you used to hand-tune every time and the right is the same material with one declaration added. The numbers come from capturing this screen and measuring it.",
|
|
||||||
demos: [
|
|
||||||
{
|
|
||||||
id: "scrim",
|
|
||||||
name: "A scrim shaped like the letters",
|
|
||||||
problem:
|
|
||||||
"A caption over a photo fails the contrast threshold. Every photo has a different brightness, so the translucent box gets hand-tuned each time.",
|
|
||||||
beforeLabel: "just placed on top",
|
|
||||||
afterLabel: "filter: url(#scrim)",
|
|
||||||
how: "feMorphology fattens the glyph alpha, a blur softens it, and feFlood fills only that shape. It follows the letters instead of covering the photo with a box.",
|
|
||||||
verdict: "white-on-photo contrast 1.70:1 → 4.79:1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cvd",
|
|
||||||
name: "Colour-vision simulation",
|
|
||||||
problem:
|
|
||||||
"State encoded in colour alone becomes one badge for 1 in 12 men. No amount of looking at your own screen catches it.",
|
|
||||||
beforeLabel: "what you see",
|
|
||||||
afterLabel: "deuteranopia",
|
|
||||||
how: "A single feColorMatrix. This is the one filter that must not run in sRGB, because colour-vision transforms are physical mixing. linearRGB is correct here.",
|
|
||||||
verdict: "minimum colour distance across the three badges 82 → 2 · warning and failure become one colour",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "goo",
|
|
||||||
name: "Separate shapes fusing into one",
|
|
||||||
problem:
|
|
||||||
"Getting pieces to melt together usually means reaching for canvas or a physics library. It comes up in loaders, merging menus, indicator transitions.",
|
|
||||||
beforeLabel: "no filter · 3 circles",
|
|
||||||
afterLabel: "filter: url(#goo)",
|
|
||||||
how: "Blur hard, then stand the alpha up with feColorMatrix. Whether they fuse is arithmetic, not taste: the ratio of gap to blur radius decides it.",
|
|
||||||
verdict: "counted across the middle row: 3 blobs → 1 · 0 lines of JS",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "glass",
|
|
||||||
name: "An overlay that bends the backdrop",
|
|
||||||
problem: "A translucent panel only blurs what is behind it. Real glass bends light.",
|
|
||||||
beforeLabel: "backdrop-filter: blur",
|
|
||||||
afterLabel: "blur + url(#lens)",
|
|
||||||
how: "Hand a displacement map to backdrop-filter. Build the map with feTurbulence and every value collapses toward neutral, so nothing moves. The map has to be a gradient you draw, pushing inward from the edges.",
|
|
||||||
verdict: "the grid compresses at the edges · Chromium only",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
metrics: {
|
|
||||||
eyebrow: "This page, measured",
|
|
||||||
h2: "Fixing the copy does not remove the slop",
|
|
||||||
lead:
|
|
||||||
"Polishing sentences fixes sentences. The radius vocabulary, the translucent panels, the number of type steps all stay. So this page publishes its own form metrics, and a bad number ships as it is. Leaving the audit in view is the core feature being sold here.",
|
|
||||||
items: [
|
|
||||||
{ label: "JS to first paint", value: "0.6%", note: "of the 50KB budget actually spent", feature: true },
|
|
||||||
{ label: "hard gates", value: "12/12", note: "static checks plus real renders at 320px and 1440px", feature: true },
|
|
||||||
{ label: "three.js payload", value: "184KB", note: "fetched only when the inspection frame enters view. Never in the initial bundle" },
|
|
||||||
{ label: "slop fingerprints", value: "0", note: "9 patterns grepped" },
|
|
||||||
{ label: "radius vocabulary", value: "2", note: "3px and 14px. The pill shape is gone" },
|
|
||||||
{ label: "translucent panels", value: "2", note: "grew to 29 before being pulled back. Glass stayed only on frames that hold something" },
|
|
||||||
{ label: "type scale steps", value: "6", note: "as defined in tokens. Display sits outside the scale, and the largest type is 5.6x the body" },
|
|
||||||
{ label: "font families", value: "2", note: "each language loads only its own" },
|
|
||||||
{ label: "accent colours", value: "1", note: "light and dark are the same colour" },
|
|
||||||
{ label: "time to interactive", value: "not yet", note: "measured with Lighthouse after deploy" },
|
|
||||||
],
|
|
||||||
caveat:
|
|
||||||
"Measured on the production build. Unmeasured entries stay empty. A hole is more honest than an invented number. This page is a product demo, so the JS budget was raised to carry three.js. Saying so here is the rule.",
|
|
||||||
},
|
|
||||||
preflight: {
|
|
||||||
h2: "What this page actually got caught on",
|
|
||||||
lead: "Not the result of auditing someone else. The result of auditing itself.",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
caught: "Text width jumped 13% at the moment the font swapped",
|
|
||||||
fixed:
|
|
||||||
"Before the web font arrives the system font draws instead, and Malgun Gothic runs 13% wider than Pretendard, so line breaks are recalculated on swap. Matched the fallback with size-adjust. Measured difference is now 0.0%. System fonts differ per OS, so each gets its own fallback.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "29 glass cards. Only the material had changed. The structure was still a row of cards",
|
|
||||||
fixed:
|
|
||||||
"Our own rule says cards are for when elevation carries real hierarchy. Figures became typography, rules became an offset list, the audit became a log. Five panels of glass remain.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "We wrote that Chrome drops backdrop-filter with SVG. It does not",
|
|
||||||
fixed:
|
|
||||||
"We blamed the browser after one failure. Re-measuring showed Chrome 151 keeps the computed value at url(#id) and genuinely refracts. The real cause was feImage width and height not matching the element, because backdrop-filter does not auto-size filter images. Write a failure down as impossible and that sentence becomes the next person's ceiling.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "444px of horizontal overflow. The entire layout was off its rails",
|
|
||||||
fixed:
|
|
||||||
"The grid class had gone missing from main. Subgrid only works when the parent is a grid, so every section had lost its alignment. It merely looked slightly off until the width was actually measured.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "Scrolling to the very bottom still left the last 3 of 6 pipeline stages invisible",
|
|
||||||
fixed:
|
|
||||||
"Elements near the page bottom never reach the end of a scroll-driven animation range. Visible with effects off but missing with them on is not the same as having a fallback. Rebuilt so that once revealed, it stays revealed.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "The button inside the dark band sat at 2.88:1. The threshold is 4.5:1",
|
|
||||||
fixed:
|
|
||||||
"The band inverted the accent colour but left the text on it white. Added an inverted counterpart token and switched both together.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "13px of horizontal overflow at 320px, even with the table inside a scroll container",
|
|
||||||
fixed:
|
|
||||||
"A grid item's default min-width is auto, so content pushes the parent wider. Overflow only works once min-width: 0 is set.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "6 eyebrow labels. The count rule caps them at 3",
|
|
||||||
fixed: "Kept three, dropped the rest. Sections read fine without a label above the heading.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "The text column was 960px. The measure we had just set was 561px",
|
|
||||||
fixed:
|
|
||||||
"Bound the grid's main column to the token. The skill's own example code was wrong too, so that got fixed as well.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "We skipped this skill's own stage 1 when the direction changed",
|
|
||||||
fixed:
|
|
||||||
"Switching the material to glass is a stage-2 decision, and that means going back to references first. We jumped straight to implementation instead. The result was glass painted onto the previous direction's skeleton, while design.md still described the old decisions. We re-ran the whole path from stage 0.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "The glass panes faced the camera head-on, so Fresnel was structurally zero",
|
|
||||||
fixed:
|
|
||||||
"When a flat plane faces the camera, the dot product of normal and view is 1, so no edge reflection is computed. The one signal that makes a surface read as glass was dead, which is why six panes looked like blurry rectangles. We bend the normal slightly and derive the rim light from the UV edge instead.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "The pipeline, the spine of this page, used only 43% of the viewport",
|
|
||||||
fixed:
|
|
||||||
"625px out of 1440. Narrowest of the nine sections while being the tallest at 1637px. Opening each stage into two columns brought it to 1113px.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
caught: "The contrast script treated translucent backgrounds as opaque",
|
|
||||||
fixed:
|
|
||||||
"It reported \"English 1.15:1\", which was wrong. Compositing alpha bottom-up gave zero failures. A checker that is wrong makes both its passes and its failures meaningless.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
note:
|
|
||||||
"All thirteen were caught while building this page, and fixed after being caught. Three came from a person rather than a checker: the cards look generic, the typography is not handled well, and did we actually use our own skill.",
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
eyebrow: "Your language is probably not Latin",
|
|
||||||
h2: "Latin defaults break Korean in three places",
|
|
||||||
lead:
|
|
||||||
"Western design tools carry no Korean typesetting rules. Leave the defaults in place and all three of these fail at once. Left is the default, right is what this skill sets. Same text, same width, same font.",
|
|
||||||
sample: "스페셜티커피의 로스팅프로파일을 구분하려면 산미와 바디를 수치로 봐야 한다.",
|
|
||||||
demos: [
|
|
||||||
{
|
|
||||||
id: "break",
|
|
||||||
title: "Line breaking",
|
|
||||||
badLabel: "default",
|
|
||||||
goodLabel: "word-break: keep-all",
|
|
||||||
note: "The default breaks mid-word. Look at the last line on the left: one word straddles two lines.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "leading",
|
|
||||||
title: "Leading",
|
|
||||||
badLabel: "line-height 1.45",
|
|
||||||
goodLabel: "line-height 1.75",
|
|
||||||
note: "The Latin standard of 1.4 to 1.5 is tight for syllable blocks with a final consonant.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "size",
|
|
||||||
title: "Size",
|
|
||||||
badLabel: "11px",
|
|
||||||
goodLabel: "15px",
|
|
||||||
note: "11px is a label size in Latin. Korean glyphs carry more strokes and smear at it.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
rules: [
|
|
||||||
"Without word-break: keep-all, words break at arbitrary points",
|
|
||||||
"line-height between 1.6 and 1.8, looser than Latin",
|
|
||||||
"Never use negative tracking. Korean is already near-monospaced",
|
|
||||||
"25 to 40 characters per line. The Latin 65 is far too long",
|
|
||||||
"Shipping without a Korean font stack is itself a tell",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
h2: "Every rule states why",
|
|
||||||
lead:
|
|
||||||
"A prohibition without a reason does not survive contact with a brief. So every rule carries its reason. If the user asks for something explicitly, the user wins.",
|
|
||||||
items: [
|
|
||||||
{ rule: "Never start without references", why: "Starting from memory produces the category average." },
|
|
||||||
{ rule: "One accent colour", why: "Wanting a second one means the hierarchy failed." },
|
|
||||||
{ rule: "Be bold in exactly one place", why: "Shout in two places and both go unheard." },
|
|
||||||
{ rule: "Spectacle ranks fourth", why: "Judging weights are Design 40 / Usability 30 / Creativity 20 / Content 10." },
|
|
||||||
{ rule: "Never invent a number", why: "A number-shaped hole is honest. An invented number costs trust." },
|
|
||||||
{
|
|
||||||
rule: "It must be finished with every effect off",
|
|
||||||
why: "That state is the basis of every fallback. If it collapses, the effects were doing the structure's job.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
install: {
|
|
||||||
h2: "One line",
|
|
||||||
lead:
|
|
||||||
"Install targets are detected and preselected. If you edit a skill file yourself, updates leave it alone.",
|
|
||||||
command: "npx designpaca",
|
|
||||||
targets: [
|
|
||||||
{ name: "Claude Code", path: "~/.claude/skills/designpaca/" },
|
|
||||||
{ name: "Codex CLI", path: "~/.codex/skills/designpaca/" },
|
|
||||||
{ name: "Cursor", path: "~/.cursor/skills/designpaca/" },
|
|
||||||
{ name: "ZCode", path: "~/.zcode/skills/designpaca/" },
|
|
||||||
{ name: "Neutral path", path: "~/.agents/skills/designpaca/ · any harness" },
|
|
||||||
{ name: "Antigravity", path: "~/.gemini/config/skills/designpaca/" },
|
|
||||||
{ name: "Gemini CLI", path: "~/.gemini/skills/designpaca/" },
|
|
||||||
{ name: "Windsurf", path: "~/.codeium/windsurf/skills/designpaca/" },
|
|
||||||
{ name: "Copilot", path: "~/.copilot/skills/designpaca/" },
|
|
||||||
{ name: "AGENTS.md", path: "block injection, agent agnostic" },
|
|
||||||
],
|
|
||||||
commands: [
|
|
||||||
{ cmd: "npx designpaca update", what: "Pull the latest skill. Files you edited are skipped" },
|
|
||||||
{ cmd: "npx designpaca doctor", what: "Diagnose install state and drift" },
|
|
||||||
{ cmd: "npx designpaca uninstall", what: "Exact removal, driven by the manifest" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
nav: {
|
|
||||||
items: [
|
|
||||||
{ label: "Pipeline", href: "#pipeline" },
|
|
||||||
{ label: "Work", href: "/showcase" },
|
|
||||||
{ label: "Numbers", href: "#metrics" },
|
|
||||||
{ label: "Install", href: "#install" },
|
|
||||||
],
|
|
||||||
menu: "Menu",
|
|
||||||
repo: "https://git.chanpaca.net/yunchan/designpaca",
|
|
||||||
repoLabel: "Source",
|
|
||||||
},
|
|
||||||
ui: { copy: "Copy", copied: "Copied" },
|
|
||||||
};
|
|
||||||
346
apps/site/src/i18n/landing.ts
Normal file
346
apps/site/src/i18n/landing.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
type NavItem<Href extends string> = Readonly<{
|
||||||
|
label: string;
|
||||||
|
href: Href;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type LandingNavItems = readonly [
|
||||||
|
NavItem<"#outputs">,
|
||||||
|
NavItem<"#process">,
|
||||||
|
NavItem<"#proof">,
|
||||||
|
NavItem<"#install">,
|
||||||
|
];
|
||||||
|
|
||||||
|
type ProcessStage<Step extends number> = Readonly<{
|
||||||
|
step: Step;
|
||||||
|
name: string;
|
||||||
|
result: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type LandingStages = readonly [
|
||||||
|
ProcessStage<0>,
|
||||||
|
ProcessStage<1>,
|
||||||
|
ProcessStage<2>,
|
||||||
|
ProcessStage<3>,
|
||||||
|
ProcessStage<4>,
|
||||||
|
ProcessStage<5>,
|
||||||
|
ProcessStage<6>,
|
||||||
|
];
|
||||||
|
|
||||||
|
type ProofFact = Readonly<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type LandingProofFacts = readonly [ProofFact, ProofFact, ProofFact];
|
||||||
|
|
||||||
|
type LandingAuditItems = readonly [
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
];
|
||||||
|
|
||||||
|
type LandingSupportItems = readonly [string, string, string];
|
||||||
|
|
||||||
|
export interface LandingContent {
|
||||||
|
readonly meta: Readonly<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
readonly nav: Readonly<{
|
||||||
|
items: LandingNavItems;
|
||||||
|
showcaseLabel: string;
|
||||||
|
repoLabel: string;
|
||||||
|
menuLabel: string;
|
||||||
|
langLabel: string;
|
||||||
|
}>;
|
||||||
|
readonly hero: Readonly<{
|
||||||
|
kicker?: string;
|
||||||
|
h1: string;
|
||||||
|
lead: string;
|
||||||
|
command: string;
|
||||||
|
primaryCta: string;
|
||||||
|
secondaryCta: string;
|
||||||
|
}>;
|
||||||
|
readonly outputs: Readonly<{
|
||||||
|
h2: string;
|
||||||
|
lead: string;
|
||||||
|
archiveCta: string;
|
||||||
|
}>;
|
||||||
|
readonly process: Readonly<{
|
||||||
|
h2: string;
|
||||||
|
lead: string;
|
||||||
|
stages: LandingStages;
|
||||||
|
}>;
|
||||||
|
readonly proof: Readonly<{
|
||||||
|
h2: string;
|
||||||
|
lead: string;
|
||||||
|
facts: LandingProofFacts;
|
||||||
|
details: Readonly<{
|
||||||
|
summary: string;
|
||||||
|
items: LandingAuditItems;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
readonly install: Readonly<{
|
||||||
|
h2: string;
|
||||||
|
lead: string;
|
||||||
|
command: string;
|
||||||
|
support: LandingSupportItems;
|
||||||
|
docsLabel: string;
|
||||||
|
repoLabel: string;
|
||||||
|
}>;
|
||||||
|
readonly footer: Readonly<{
|
||||||
|
tagline: string;
|
||||||
|
license: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const landingKo = {
|
||||||
|
meta: {
|
||||||
|
title: "designpaca | AI 코딩 도구를 위한 웹 디자인 파이프라인",
|
||||||
|
description:
|
||||||
|
"designpaca는 AI 코딩 도구에 디자인 판단과 검증 절차를 더해, 방향 결정부터 반응형 구현과 프리플라이트 감사까지 연결합니다.",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
items: [
|
||||||
|
{ label: "산출물", href: "#outputs" },
|
||||||
|
{ label: "진행 절차", href: "#process" },
|
||||||
|
{ label: "품질 검사", href: "#proof" },
|
||||||
|
{ label: "설치", href: "#install" },
|
||||||
|
],
|
||||||
|
showcaseLabel: "설계 예시",
|
||||||
|
repoLabel: "소스 저장소",
|
||||||
|
menuLabel: "주요 메뉴",
|
||||||
|
langLabel: "English로 보기",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
h1: "AI 코딩에\n디자인을 더합니다",
|
||||||
|
lead:
|
||||||
|
"레퍼런스를 조사해 방향을 정하고, 디자인 토큰부터 반응형 구현과 프리플라이트 검증까지 일관된 절차로 연결합니다.",
|
||||||
|
command: "npx designpaca",
|
||||||
|
primaryCta: "설치 명령 복사",
|
||||||
|
secondaryCta: "산출물 살펴보기",
|
||||||
|
},
|
||||||
|
outputs: {
|
||||||
|
h2: "정한 기준이 코드와 검사까지 이어집니다",
|
||||||
|
lead: "레퍼런스에서 고른 원칙은 결정 기록에 남고, 디자인 토큰과 구현 규칙이 되며, 마지막에는 반복 가능한 검사로 확인됩니다.",
|
||||||
|
archiveCta: "설계 예시 보기",
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
h2: "일곱 단계로 판단과 구현을 연결합니다",
|
||||||
|
lead: "각 단계는 다음 작업에 필요한 결정을 남기고, 구현이 임의의 기본값으로 돌아가는 것을 막습니다.",
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
step: 0,
|
||||||
|
name: "브리프 게이트",
|
||||||
|
result: "목표, 사용자, 필수 기능, 언어와 제약을 확정해 흔들리지 않는 출발점을 만듭니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
name: "레퍼런스 조사",
|
||||||
|
result: "같은 문제를 푼 실제 사례를 조사해 적용할 원칙과 피해야 할 관습을 구분합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
name: "방향 결정",
|
||||||
|
result: "시각 방향, 정보 위계와 감수할 위험을 정해 한 가지 설계 언어로 좁힙니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
name: "디자인 토큰과 예산",
|
||||||
|
result: "색, 서체, 간격, 모션과 성능 한계를 실제 값으로 정의합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
name: "구현",
|
||||||
|
result: "정한 원칙을 반응형 레이아웃, 재질, 상호작용과 접근 가능한 상태로 구현합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 5,
|
||||||
|
name: "프리플라이트 감사",
|
||||||
|
result: "화면 폭, 대비, 키보드, 메타데이터, 토큰과 성능을 반복 가능한 검사로 확인합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 6,
|
||||||
|
name: "결정 기록",
|
||||||
|
result: "채택한 결정과 제외한 선택의 근거를 기록해 다음 작업에서도 품질 기준을 이어갑니다.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
proof: {
|
||||||
|
h2: "결과를 검증하고 기록합니다",
|
||||||
|
lead: "반응형, 접근성, 토큰 위생과 메타데이터를 같은 기준으로 반복 검사합니다.",
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
value: "7",
|
||||||
|
label: "설계 단계",
|
||||||
|
description: "브리프부터 결정 기록까지 0부터 6까지의 단계를 순서대로 수행합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "15",
|
||||||
|
label: "하드 게이트",
|
||||||
|
description: "오버플로, 대비, 토큰, SEO와 죽은 코드를 완료 전에 확인합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "KO / EN",
|
||||||
|
label: "동등한 콘텐츠 구조",
|
||||||
|
description: "한국어와 영어가 같은 정보 구조와 주요 행동을 공유합니다.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
summary: "검증 항목 자세히 보기",
|
||||||
|
items: [
|
||||||
|
"핵심 콘텐츠가 320px 화면에서도 잘리거나 겹치지 않는지 확인합니다.",
|
||||||
|
"제목, 본문과 버튼의 간격 및 정렬이 토큰 체계를 따르는지 확인합니다.",
|
||||||
|
"텍스트와 인터페이스 색의 대비를 측정합니다.",
|
||||||
|
"키보드만으로 내비게이션과 주요 행동을 완료할 수 있는지 확인합니다.",
|
||||||
|
"이미지가 목적에 맞게 contain 또는 cover로 표시되는지 확인합니다.",
|
||||||
|
"메타데이터, 언어, 대체 텍스트와 ARIA 라벨이 실제 화면과 일치하는지 확인합니다.",
|
||||||
|
"죽은 선택자, 정의되지 않은 토큰과 불필요한 스크립트를 찾습니다.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
h2: "지금 사용하는 AI 코딩 도구에 연결하세요",
|
||||||
|
lead: "대화형 설치가 작업 환경을 감지하고 지원되는 도구에 필요한 스킬 파일을 배치합니다.",
|
||||||
|
command: "npx designpaca",
|
||||||
|
support: [
|
||||||
|
"Codex, Claude Code, Cursor를 포함한 주요 AI 코딩 도구",
|
||||||
|
"사용자 전역 또는 프로젝트 단위 설치",
|
||||||
|
"업데이트, 설치 진단과 매니페스트 기반 제거",
|
||||||
|
],
|
||||||
|
docsLabel: "설치 문서 보기",
|
||||||
|
repoLabel: "소스 저장소 보기",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
tagline: "AI 코딩 도구에 디자인 판단과 검증 절차를 더합니다.",
|
||||||
|
license: "오픈소스 · MIT 라이선스",
|
||||||
|
},
|
||||||
|
} as const satisfies LandingContent;
|
||||||
|
|
||||||
|
export const landingEn = {
|
||||||
|
meta: {
|
||||||
|
title: "designpaca | A design pipeline for AI coding tools",
|
||||||
|
description:
|
||||||
|
"designpaca adds design judgment and a repeatable verification process to AI coding tools, from direction and tokens to responsive implementation.",
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
items: [
|
||||||
|
{ label: "Deliverables", href: "#outputs" },
|
||||||
|
{ label: "Workflow", href: "#process" },
|
||||||
|
{ label: "Checks", href: "#proof" },
|
||||||
|
{ label: "Install", href: "#install" },
|
||||||
|
],
|
||||||
|
showcaseLabel: "Design examples",
|
||||||
|
repoLabel: "Source repository",
|
||||||
|
menuLabel: "Primary navigation",
|
||||||
|
langLabel: "한국어로 보기",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
h1: "Design rigor\nfor AI coding",
|
||||||
|
lead:
|
||||||
|
"Research references, choose a direction, define tokens, build responsively, and verify the result through one repeatable process.",
|
||||||
|
command: "npx designpaca",
|
||||||
|
primaryCta: "Copy install command",
|
||||||
|
secondaryCta: "See the deliverables",
|
||||||
|
},
|
||||||
|
outputs: {
|
||||||
|
h2: "Decisions carry through to code and checks",
|
||||||
|
lead: "Principles selected from research are recorded, translated into design tokens and implementation rules, then verified through repeatable checks.",
|
||||||
|
archiveCta: "Browse design examples",
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
h2: "Connect judgment to implementation in seven stages",
|
||||||
|
lead: "Each stage records the decisions the next one needs, keeping the build anchored to an intentional direction.",
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
step: 0,
|
||||||
|
name: "Brief gate",
|
||||||
|
result: "Set the goal, audience, required features, language, and constraints before design work begins.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
name: "Reference research",
|
||||||
|
result: "Study real examples that solved a similar problem, then separate useful principles from habits to avoid.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
name: "Direction",
|
||||||
|
result: "Choose the visual direction, information hierarchy, and accepted risk to establish one coherent design language.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
name: "Design tokens and budget",
|
||||||
|
result: "Define color, type, spacing, motion, and performance limits as concrete values.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
name: "Implementation",
|
||||||
|
result: "Turn the chosen principles into responsive layouts, materials, interactions, and accessible states.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 5,
|
||||||
|
name: "Preflight audit",
|
||||||
|
result: "Check viewport behavior, contrast, keyboard access, metadata, tokens, and performance with repeatable tests.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 6,
|
||||||
|
name: "Decision record",
|
||||||
|
result: "Document accepted and rejected choices so the same quality standard carries into the next build.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
proof: {
|
||||||
|
h2: "Verify the result and record the evidence",
|
||||||
|
lead: "Run responsive behavior, accessibility, token hygiene, and metadata through the same repeatable checks.",
|
||||||
|
facts: [
|
||||||
|
{
|
||||||
|
value: "7",
|
||||||
|
label: "Design stages",
|
||||||
|
description: "Move in order from stage 0, the brief, through stage 6, the decision record.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "15",
|
||||||
|
label: "Hard gates",
|
||||||
|
description: "Check overflow, contrast, tokens, SEO, and dead code before declaring the work complete.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "KO / EN",
|
||||||
|
label: "Equivalent content structure",
|
||||||
|
description: "Korean and English share the same information architecture and primary actions.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
summary: "View the audit checks",
|
||||||
|
items: [
|
||||||
|
"Make sure core content stays intact without clipping or overlap at 320px.",
|
||||||
|
"Check that heading, body, and button spacing follows the token system.",
|
||||||
|
"Measure contrast for text and interface controls.",
|
||||||
|
"Complete navigation and primary actions with a keyboard.",
|
||||||
|
"Confirm each image uses contain or cover for its purpose.",
|
||||||
|
"Match metadata, language, alt text, and ARIA labels to the rendered page.",
|
||||||
|
"Flag dead selectors, undefined tokens, and unnecessary scripts.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
install: {
|
||||||
|
h2: "Connect it to the AI coding tool you already use",
|
||||||
|
lead: "The interactive installer detects your workspace and places the skill files where supported tools expect them.",
|
||||||
|
command: "npx designpaca",
|
||||||
|
support: [
|
||||||
|
"Codex, Claude Code, Cursor, and other AI coding tools",
|
||||||
|
"User-wide or project-scoped installation",
|
||||||
|
"Updates, install diagnostics, and manifest-based removal",
|
||||||
|
],
|
||||||
|
docsLabel: "Read the install guide",
|
||||||
|
repoLabel: "View the source repository",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
tagline: "Design judgment and verification for AI coding tools.",
|
||||||
|
license: "Open source · MIT License",
|
||||||
|
},
|
||||||
|
} as const satisfies LandingContent;
|
||||||
96
apps/site/src/i18n/showcase.ts
Normal file
96
apps/site/src/i18n/showcase.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
export interface ShowcaseContent {
|
||||||
|
readonly meta: Readonly<{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
readonly hero: Readonly<{
|
||||||
|
label: string;
|
||||||
|
countLabel: (count: number) => string;
|
||||||
|
h1: readonly [string, string];
|
||||||
|
lead: string;
|
||||||
|
ctaLabel: (count: number) => string;
|
||||||
|
featuredLabel: string;
|
||||||
|
}>;
|
||||||
|
readonly archive: Readonly<{
|
||||||
|
h2: string;
|
||||||
|
lead: string;
|
||||||
|
visitLabel: string;
|
||||||
|
visitAriaLabel: (name: string) => string;
|
||||||
|
notesLabel: string;
|
||||||
|
directionLabel: string;
|
||||||
|
riskLabel: string;
|
||||||
|
detailAlt: (name: string) => string;
|
||||||
|
}>;
|
||||||
|
readonly footer: Readonly<{
|
||||||
|
licenseLabel: string;
|
||||||
|
navLabel: string;
|
||||||
|
homeLabel: string;
|
||||||
|
repositoryLabel: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showcaseKo = {
|
||||||
|
meta: {
|
||||||
|
title: "프로젝트별 인터페이스 설계 예시 · designpaca",
|
||||||
|
description:
|
||||||
|
"서로 다른 목적과 사용 환경에 맞춰 구성한 인터페이스 설계 예시와 판단 기록을 살펴보세요.",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
label: "설계 예시",
|
||||||
|
countLabel: (count) => `공개 예시 ${count}개`,
|
||||||
|
h1: ["서로 다른 조건의", "인터페이스 설계"],
|
||||||
|
lead:
|
||||||
|
"꽃집과 교육 서비스, 연구 도구와 운영 콘솔은 필요한 정보도 사용자가 움직이는 방식도 다릅니다. 각 프로젝트의 목적과 환경에 맞춰 만든 화면을 모았습니다.",
|
||||||
|
ctaLabel: (count) => `설계 예시 ${count}개 보기`,
|
||||||
|
featuredLabel: "대표 설계 예시",
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
h2: "설계 예시 모음",
|
||||||
|
lead: "각 화면과 함께 설계 방향 및 감수한 리스크를 펼쳐 확인할 수 있습니다.",
|
||||||
|
visitLabel: "예시 사이트 열기",
|
||||||
|
visitAriaLabel: (name) => `${name} 예시 사이트 열기`,
|
||||||
|
notesLabel: "설계 기록 보기",
|
||||||
|
directionLabel: "설계 방향",
|
||||||
|
riskLabel: "감수한 리스크",
|
||||||
|
detailAlt: (name) => `${name}의 추가 설계 화면`,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
licenseLabel: "오픈소스 · MIT 라이선스",
|
||||||
|
navLabel: "쇼케이스 하단 메뉴",
|
||||||
|
homeLabel: "홈",
|
||||||
|
repositoryLabel: "저장소",
|
||||||
|
},
|
||||||
|
} as const satisfies ShowcaseContent;
|
||||||
|
|
||||||
|
export const showcaseEn = {
|
||||||
|
meta: {
|
||||||
|
title: "Interface design examples for distinct needs · designpaca",
|
||||||
|
description:
|
||||||
|
"Explore interfaces designed around the goals, content, and operating context of retail, education, research, and service projects.",
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
label: "Design archive",
|
||||||
|
countLabel: (count) => `${count} examples`,
|
||||||
|
h1: ["Designing for", "different needs"],
|
||||||
|
lead:
|
||||||
|
"A flower studio, enrollment service, research tool, and operations console do not ask users to do the same thing. Each interface here was designed around its project's content, environment, and primary task.",
|
||||||
|
ctaLabel: (count) => `Browse all ${count} examples`,
|
||||||
|
featuredLabel: "Featured design example · Korean UI",
|
||||||
|
},
|
||||||
|
archive: {
|
||||||
|
h2: "Design examples",
|
||||||
|
lead: "Open the design notes to see the direction and tradeoffs behind each interface.",
|
||||||
|
visitLabel: "Open example",
|
||||||
|
visitAriaLabel: (name) => `${name}: open the design example`,
|
||||||
|
notesLabel: "View design notes",
|
||||||
|
directionLabel: "Direction",
|
||||||
|
riskLabel: "Tradeoff",
|
||||||
|
detailAlt: (name) => `${name}, additional interface view`,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
licenseLabel: "Open source · MIT License",
|
||||||
|
navLabel: "Showcase footer",
|
||||||
|
homeLabel: "Home",
|
||||||
|
repositoryLabel: "Repository",
|
||||||
|
},
|
||||||
|
} as const satisfies ShowcaseContent;
|
||||||
257
apps/site/src/i18n/work.ts
Normal file
257
apps/site/src/i18n/work.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
import { WORK, type WorkItem } from "../data/work";
|
||||||
|
|
||||||
|
export type WorkLocale = "ko" | "en";
|
||||||
|
|
||||||
|
type WorkCopyKey = "name" | "kind" | "concept" | "risk" | "alt";
|
||||||
|
type WorkCopy = Pick<WorkItem, WorkCopyKey>;
|
||||||
|
|
||||||
|
export type LocalizedWorkItem = Omit<WorkItem, WorkCopyKey> & WorkCopy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep this tuple explicit: WorkItem currently exposes slug as `string`, so
|
||||||
|
* deriving a union from WORK would lose the per-slug exhaustiveness check.
|
||||||
|
* Runtime validation below catches drift between this list and the data SSOT.
|
||||||
|
*/
|
||||||
|
export const WORK_SLUGS = [
|
||||||
|
"thursday-flowers",
|
||||||
|
"lumina-optics",
|
||||||
|
"form-architecture",
|
||||||
|
"muwol-coffee",
|
||||||
|
"pulsegate",
|
||||||
|
"hyang-incense",
|
||||||
|
"orbit-aerospace",
|
||||||
|
"synapse-bci",
|
||||||
|
"baekje-celadon",
|
||||||
|
"overtone-sound",
|
||||||
|
"font-foundry",
|
||||||
|
"gaon-lms",
|
||||||
|
"dure-enrollment",
|
||||||
|
"cheongon-care",
|
||||||
|
"cask-archive",
|
||||||
|
"materia-interior",
|
||||||
|
"rfs-cfd",
|
||||||
|
"twentyoz-corp",
|
||||||
|
"saebyeok-cheonggwa",
|
||||||
|
"hannam-jari",
|
||||||
|
"griddle-club",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WorkSlug = (typeof WORK_SLUGS)[number];
|
||||||
|
|
||||||
|
export const WORK_EN_COPY = {
|
||||||
|
"thursday-flowers": {
|
||||||
|
name: "Thursday's Garden",
|
||||||
|
kind: "Floral studio · Single-page site",
|
||||||
|
concept: "An editorial working journal for a florist in a Seochon alley.",
|
||||||
|
risk:
|
||||||
|
"Three photographs at different scales float freely across the opening screen, each following its own alignment.",
|
||||||
|
alt: "Thursday's Garden landing page, with three flower photographs placed at different positions on a cream background.",
|
||||||
|
},
|
||||||
|
"lumina-optics": {
|
||||||
|
name: "Lumina Optics",
|
||||||
|
kind: "Optical lens test lab",
|
||||||
|
concept:
|
||||||
|
"A single beam and one prism form a test bench that calculates refraction and dispersion on the GPU in every frame.",
|
||||||
|
risk:
|
||||||
|
"Live shader-based refraction with IOR and dispersion controls leads the opening screen. The editorial content occupies a separate surface beyond the canvas.",
|
||||||
|
alt: "Lumina Optics: a WebGL scene of light spreading into a spectrum through a prism on a dark test bench, paired with an MTF curve.",
|
||||||
|
},
|
||||||
|
"form-architecture": {
|
||||||
|
name: "Studio FORM",
|
||||||
|
kind: "Architecture and spatial research archive",
|
||||||
|
concept: "A tightly composed set of drawing sheets where lines and dimensions carry the story.",
|
||||||
|
risk:
|
||||||
|
"An image-free, reverse-chronological text index is paired with a sticky floor plan that draws itself as the reader moves through the sections.",
|
||||||
|
alt: "Studio FORM: a hairline-divided text index, a line-drawn floor plan, and three black-and-white photographs.",
|
||||||
|
},
|
||||||
|
"muwol-coffee": {
|
||||||
|
name: "Muwol",
|
||||||
|
kind: "Single-origin coffee subscription",
|
||||||
|
concept:
|
||||||
|
"A paper-ledger approach to coffee subscriptions, with prices up front and transaction history open for inspection.",
|
||||||
|
risk:
|
||||||
|
"Ledger rows organize the coffee selection, and the subscription action appears as the darkest inverted block in the grayscale system.",
|
||||||
|
alt: "Muwol Roastery: a hero pairing coffee beans with their price, followed by a flavor coordinate plot and green-coffee purchasing ledger.",
|
||||||
|
},
|
||||||
|
pulsegate: {
|
||||||
|
name: "Pulsegate",
|
||||||
|
kind: "Edge latency monitoring & synthetic probes",
|
||||||
|
concept: "A restrained instrument panel where cobalt is reserved for type and chart lines.",
|
||||||
|
risk:
|
||||||
|
"One static 24-hour frame and a wiring diagram drawn progressively on scroll establish the information rhythm.",
|
||||||
|
alt: "Pulsegate: a static instrument frame containing a 24-hour p99 chart and a wiring diagram drawn in cobalt lines.",
|
||||||
|
},
|
||||||
|
"hyang-incense": {
|
||||||
|
name: "Hyangyeon",
|
||||||
|
kind: "Natural agarwood archive",
|
||||||
|
concept:
|
||||||
|
"An archival record on a bright editorial surface, led by open space and a single trail of smoke.",
|
||||||
|
risk:
|
||||||
|
"An image-free index and horizontal information labels establish the density of the archive.",
|
||||||
|
alt: "Hyangyeon: a plume of smoke and quiet aging records set within a wide, bright field of open space.",
|
||||||
|
},
|
||||||
|
"orbit-aerospace": {
|
||||||
|
name: "Orbit Aerospace",
|
||||||
|
kind: "75 kN methane engine specification",
|
||||||
|
concept:
|
||||||
|
"Measured evidence leads through a section view of a regeneratively cooled engine and records from ground-fire tests.",
|
||||||
|
risk:
|
||||||
|
"A hand-drawn engine section leads the opening screen, and the accompanying test log keeps tests placed on hold visible.",
|
||||||
|
alt: "Orbit Aerospace: an engine section on a drafting grid, a specification table with copper highlights, and a nighttime firing-test photograph.",
|
||||||
|
},
|
||||||
|
"synapse-bci": {
|
||||||
|
name: "Synapse BCI Lab",
|
||||||
|
kind: "64-channel neural interface",
|
||||||
|
concept:
|
||||||
|
"Every reading carries its conditions. Simulations are labeled, and unmeasured values remain blank.",
|
||||||
|
risk:
|
||||||
|
"A 64-channel spike-raster canvas that pauses offscreen leads the page, while ethics is promoted to a primary content section.",
|
||||||
|
alt: "Synapse BCI: a 64-channel raster canvas streaming gold spike marks beside a close-up photograph of a neural chip.",
|
||||||
|
},
|
||||||
|
"baekje-celadon": {
|
||||||
|
name: "The Silence of Celadon",
|
||||||
|
kind: "Curated Goryeo celadon exhibition",
|
||||||
|
concept: "Three darkened rooms draw the viewer progressively closer to the glaze.",
|
||||||
|
risk:
|
||||||
|
"A full-bleed macro of glaze crackle, close views of inlay, and a room-number system built around Room I and Room II define the exhibition structure.",
|
||||||
|
alt: "The Silence of Celadon: a celadon ewer under a dark-room spotlight and a full-bleed macro view of its glaze crackle.",
|
||||||
|
},
|
||||||
|
"overtone-sound": {
|
||||||
|
name: "Overtone Sound Lab",
|
||||||
|
kind: "Spatial audio · DSP lab",
|
||||||
|
concept: "A catalog spreads across a dark studio floor on a deliberately offset grid.",
|
||||||
|
risk:
|
||||||
|
"A procedural pseudo-FFT canvas with no audio decoding, a staggered two-column archive, and a condensed type scale keep the layout from falling into repetition.",
|
||||||
|
alt: "Overtone Sound Lab: a dark hero with an amber waveform and a staggered two-column project index.",
|
||||||
|
},
|
||||||
|
"font-foundry": {
|
||||||
|
name: "Hansung Type Foundry",
|
||||||
|
kind: "Korean text type foundry",
|
||||||
|
concept:
|
||||||
|
"Monumental glyphs fill the opening screen, followed by material studies and typographic measurements.",
|
||||||
|
risk:
|
||||||
|
"Purpose-written specimen copy and a typesetting bench with live tracking, leading, and size controls take center stage.",
|
||||||
|
alt: "Hansung Type Foundry: monumental Korean glyphs on a warm neutral field and a typesetting bench with three sliders.",
|
||||||
|
},
|
||||||
|
"gaon-lms": {
|
||||||
|
name: "Gaon Student Records",
|
||||||
|
kind: "Learning management console · 8 screens",
|
||||||
|
concept:
|
||||||
|
"A record-first administration console where highlighter underlines and status labels separate the information.",
|
||||||
|
risk:
|
||||||
|
"The row structure of a student register connects all eight screens into one administration system.",
|
||||||
|
alt: "Gaon Student Records: a paper-toned dashboard with weekly attendance bars, an assignment deadline table, and a graphite sidebar.",
|
||||||
|
},
|
||||||
|
"dure-enrollment": {
|
||||||
|
name: "Dure University Course Registration",
|
||||||
|
kind: "Course registration · 6 screens",
|
||||||
|
concept:
|
||||||
|
"Clear headings, IBM Plex body text, and brick-red status labels bring order to each layer of course-registration information.",
|
||||||
|
risk:
|
||||||
|
"All six screens share enrollment and saved-course state. Time conflicts, credit overloads, and full classes are blocked before submission.",
|
||||||
|
alt: "Dure University Course Registration: a weekly timetable grid with a course list and brick-red status labels.",
|
||||||
|
},
|
||||||
|
"cheongon-care": {
|
||||||
|
name: "Cheongon Care",
|
||||||
|
kind: "Hygiene services & dispatch console · 2 modes",
|
||||||
|
concept:
|
||||||
|
"A six-step, clinical-grade hygiene protocol connects fixed-price quotes with a live dispatch console.",
|
||||||
|
risk:
|
||||||
|
"A customer quote form and an administrator dispatch dashboard share live state inside one web app, creating a closed-loop interaction.",
|
||||||
|
alt: "Cheongon Care: a living room after detailed move-in cleaning and German Kärcher high-temperature steam hygiene equipment.",
|
||||||
|
},
|
||||||
|
"cask-archive": {
|
||||||
|
name: "Cask Archive",
|
||||||
|
kind: "Independent-bottler whisky bar & guided tastings",
|
||||||
|
concept:
|
||||||
|
"An official independent-bottler certification ledger pairs each spirit's time and barrel history with one-to-one guided tastings.",
|
||||||
|
risk:
|
||||||
|
"The opening screen presents a flavor filter and a cask ledger recording single-cask numbers and customs certificates.",
|
||||||
|
alt: "Cask Archive: a single-cask whisky bottle on an oak barrel top, paired with a view of a Scottish maturation warehouse.",
|
||||||
|
},
|
||||||
|
"materia-interior": {
|
||||||
|
name: "Materia Studio",
|
||||||
|
kind: "High-end spatial interiors & architecture",
|
||||||
|
concept:
|
||||||
|
"An atelier designing material presence and the path of natural light through travertine interiors.",
|
||||||
|
risk:
|
||||||
|
"Measured specifications for travertine, oak, and linen sit alongside a live switch between daylight and nighttime ambience.",
|
||||||
|
alt: "Materia Studio: a penthouse living room with travertine walls and a flat lay of natural finish materials.",
|
||||||
|
},
|
||||||
|
"rfs-cfd": {
|
||||||
|
name: "RFS: Real-Time Flow Simulator",
|
||||||
|
kind: "GPU engine for data-center thermal management",
|
||||||
|
concept: "A dark flow field sits beside a bright specification surface.",
|
||||||
|
risk:
|
||||||
|
"The browser solves thermal flow live in the hero. Changing rack load and supply airflow with the sliders updates the flow in place.",
|
||||||
|
alt: "RFS: a real-time flow view where green heat plumes rise above server racks in a dark data-center section as teal cool air circulates around them.",
|
||||||
|
},
|
||||||
|
"twentyoz-corp": {
|
||||||
|
name: "TWENTYOZ",
|
||||||
|
kind: "ICT corporate website redesign",
|
||||||
|
concept: "An index tracks how the same technology works across four operating environments.",
|
||||||
|
risk:
|
||||||
|
"The opening screen starts with environment selection. Each choice updates the products, technology, and delivery case studies as one complete set.",
|
||||||
|
alt: "TWENTYOZ: four operating-environment tabs arranged across a paper-toned surface, with the selected environment's products and delivery cases expanded below.",
|
||||||
|
},
|
||||||
|
"saebyeok-cheonggwa": {
|
||||||
|
name: "Saebyeok Produce",
|
||||||
|
kind: "Direct wholesale produce marketplace",
|
||||||
|
concept:
|
||||||
|
"Daily price movement at two in the morning leads the opening screen.",
|
||||||
|
risk:
|
||||||
|
"Today's market board leads the page. Winning bids and sale prices occupy separate fields, and unavailable sugar readings are marked with a dash.",
|
||||||
|
alt: "Saebyeok Produce: today's market board listing item, origin, grade, sugar content, and winning bid over a photograph of an early-morning wholesale market aisle.",
|
||||||
|
},
|
||||||
|
"hannam-jari": {
|
||||||
|
name: "Hannam Jari",
|
||||||
|
kind: "Bespoke housing brief & operations console",
|
||||||
|
concept:
|
||||||
|
"AI compares five housing criteria, then a licensed real-estate agent verifies the result.",
|
||||||
|
risk:
|
||||||
|
"Every fictional property is clearly labeled. The user's criteria, the AI's reasoning, and the facts an agent must verify are separated on one screen.",
|
||||||
|
alt: "Hannam Jari: a bright, stone-toned briefing desk with a Seoul residential exterior and an input panel for five housing criteria.",
|
||||||
|
},
|
||||||
|
"griddle-club": {
|
||||||
|
name: "Griddle Club",
|
||||||
|
kind: "Urban burger shop & franchise guide",
|
||||||
|
concept:
|
||||||
|
"A two-track site covers Seoul-style griddle burgers assembled to order and the terms behind the operation.",
|
||||||
|
risk:
|
||||||
|
"Franchise review begins with the trade area, premises, kitchen flow, and costs still to be determined, all laid out before consultation.",
|
||||||
|
alt: "Griddle Club: a bright paper-toned interface with cobalt accents and a Seoul-style shop scene where a griddle-cooked burger is being wrapped.",
|
||||||
|
},
|
||||||
|
} as const satisfies Record<WorkSlug, WorkCopy>;
|
||||||
|
|
||||||
|
const EXPECTED_SLUGS: ReadonlySet<string> = new Set(WORK_SLUGS);
|
||||||
|
|
||||||
|
function assertWorkSlugParity(): void {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const item of WORK) {
|
||||||
|
if (!EXPECTED_SLUGS.has(item.slug)) {
|
||||||
|
throw new Error(`Missing English work copy for slug: ${item.slug}`);
|
||||||
|
}
|
||||||
|
if (seen.has(item.slug)) {
|
||||||
|
throw new Error(`Duplicate work slug: ${item.slug}`);
|
||||||
|
}
|
||||||
|
seen.add(item.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of WORK_SLUGS) {
|
||||||
|
if (!seen.has(slug)) {
|
||||||
|
throw new Error(`English work copy has no matching WORK item: ${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertWorkSlugParity();
|
||||||
|
|
||||||
|
export function getLocalizedWork(lang: WorkLocale): LocalizedWorkItem[] {
|
||||||
|
if (lang === "ko") return WORK;
|
||||||
|
|
||||||
|
return WORK.map((item) => ({
|
||||||
|
...item,
|
||||||
|
...WORK_EN_COPY[item.slug as WorkSlug],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
---
|
---
|
||||||
import { ko, en } from "../i18n/content";
|
import { landingKo, landingEn } from "../i18n/landing";
|
||||||
|
import socialPreviewKo from "../assets/og-designpaca-ko.png";
|
||||||
|
import socialPreviewEn from "../assets/og-designpaca-en.png";
|
||||||
import "../styles/fonts.css";
|
import "../styles/fonts.css";
|
||||||
import "../styles/tokens.css";
|
import "../styles/tokens.css";
|
||||||
import "../styles/base.css";
|
import "../styles/base.css";
|
||||||
|
|
@ -8,72 +10,82 @@ interface Props {
|
||||||
lang: "ko" | "en";
|
lang: "ko" | "en";
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
translationPath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lang, title, description } = Astro.props;
|
const { lang, title, description } = Astro.props;
|
||||||
|
const copy = lang === "ko" ? landingKo : landingEn;
|
||||||
// 언어별로 자기 폰트만 받는다. 두 벌을 한 페이지에 로드하면 예산을 넘긴다.
|
const socialPreview = lang === "ko" ? socialPreviewKo : socialPreviewEn;
|
||||||
const FONT_CSS = {
|
const socialPreviewAlt = lang === "ko"
|
||||||
ko: "https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css",
|
? "designpaca의 일곱 단계 디자인 파이프라인 그래픽"
|
||||||
en: "https://api.fontshare.com/v2/css?f%5B%5D=switzer@400,500,600&display=swap",
|
: "The seven-stage designpaca pipeline graphic";
|
||||||
} as const;
|
const path = Astro.url.pathname;
|
||||||
|
const normalizedPath = path.replace(/\/+$/, "") || "/";
|
||||||
const alt = lang === "ko" ? "/en/" : "/";
|
const isKoHome = normalizedPath === "/";
|
||||||
|
const isEnHome = normalizedPath === "/en";
|
||||||
// 헤더 내비게이션. 본문과 같은 콘텐츠 파일에서 온다 — 라벨을 레이아웃에
|
const inferredTranslationPath = isKoHome ? "/en/" : isEnHome ? "/" : null;
|
||||||
// 하드코딩하면 두 언어판이 어긋난다.
|
const translationPath = Object.prototype.hasOwnProperty.call(Astro.props, "translationPath")
|
||||||
const nav = (lang === "ko" ? ko : en).nav;
|
? Astro.props.translationPath
|
||||||
|
: inferredTranslationPath;
|
||||||
// 내비 앵커는 홈의 섹션을 가리킨다. 홈이 아닌 페이지(쇼케이스 등)에서는
|
|
||||||
// "#pipeline" 이 그 페이지 안의 없는 앵커가 되어 아무 일도 하지 않는다 —
|
|
||||||
// 홈 경로를 붙여 홈 섹션으로 건너가게 한다.
|
|
||||||
const homePath = lang === "ko" ? "/" : "/en/";
|
const homePath = lang === "ko" ? "/" : "/en/";
|
||||||
const normalizedPath = Astro.url.pathname.replace(/\/+$/, "") || "/";
|
const showcasePath = lang === "ko" ? "/showcase/" : "/en/showcase/";
|
||||||
const isHome = normalizedPath === (lang === "ko" ? "/" : "/en");
|
const isHome = isKoHome || isEnHome;
|
||||||
const navItems = nav.items.map((i) => ({
|
const navItems = copy.nav.items.map((item) => ({
|
||||||
...i,
|
...item,
|
||||||
href: !isHome && i.href.startsWith("#") ? `${homePath}${i.href}` : i.href,
|
href: isHome ? item.href : `${homePath}${item.href}`,
|
||||||
}));
|
}));
|
||||||
|
const repositoryUrl = "https://git.chanpaca.net/yunchan/designpaca";
|
||||||
// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크.
|
const canonical = new URL(path, Astro.site);
|
||||||
// data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다.
|
const translated = translationPath ? new URL(translationPath, Astro.site) : null;
|
||||||
// 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다.
|
const socialPreviewUrl = new URL(socialPreview.src, Astro.site);
|
||||||
// MARK_BG = --accent-inverse · MARK_FG = --surface-inverse
|
const markBg = "%23c43f20";
|
||||||
const MARK_BG = "#0E6B63";
|
const markFg = "%23faf9f4";
|
||||||
const MARK_FG = "#F4F2EC";
|
const favicon = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='3' fill='${markBg}'/%3E%3Cpath d='M8 6v20M8 7h12M8 11h8M8 15h13M8 19h9M8 23h12' stroke='${markFg}' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E`;
|
||||||
const FAVICON =
|
const schema = isHome
|
||||||
"data:image/svg+xml," +
|
? {
|
||||||
encodeURIComponent(
|
"@context": "https://schema.org",
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
"@type": "WebSite",
|
||||||
<rect width="32" height="32" rx="3" fill="${MARK_BG}"/>
|
name: "designpaca",
|
||||||
<path d="M11 7v18" stroke="${MARK_FG}" stroke-width="2" stroke-linecap="round"/>
|
url: new URL("/", Astro.site).href,
|
||||||
<circle cx="11" cy="10" r="3" fill="${MARK_FG}"/>
|
description,
|
||||||
<circle cx="11" cy="16" r="3" fill="${MARK_FG}"/>
|
inLanguage: ["ko-KR", "en-US"],
|
||||||
<circle cx="11" cy="22" r="3" fill="${MARK_FG}"/>
|
}
|
||||||
<path d="M18 10h7M18 16h5M18 22h7" stroke="${MARK_FG}" stroke-width="2" stroke-linecap="round" opacity=".55"/>
|
: {
|
||||||
</svg>`,
|
"@context": "https://schema.org",
|
||||||
);
|
"@type": "WebPage",
|
||||||
|
name: title,
|
||||||
|
url: canonical.href,
|
||||||
|
description,
|
||||||
|
inLanguage: lang === "ko" ? "ko-KR" : "en-US",
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang={lang} class="no-js">
|
<html lang={lang}>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<link rel="icon" href={FAVICON} />
|
<meta name="theme-color" content="#f2f1eb" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
|
||||||
<link rel="canonical" href={new URL(Astro.url.pathname, Astro.site)} />
|
<link rel="canonical" href={canonical} />
|
||||||
<link rel="alternate" hreflang="ko" href={new URL("/", Astro.site)} />
|
{translated && !isHome && <link rel="alternate" hreflang={lang} href={canonical} />}
|
||||||
<link rel="alternate" hreflang="en" href={new URL("/en/", Astro.site)} />
|
{translated && !isHome && <link rel="alternate" hreflang={lang === "ko" ? "en" : "ko"} href={translated} />}
|
||||||
<link rel="alternate" hreflang="x-default" href={new URL("/", Astro.site)} />
|
{translated && !isHome && <link rel="alternate" hreflang="x-default" href={lang === "ko" ? canonical : translated} />}
|
||||||
|
{isHome && <link rel="alternate" hreflang="ko" href={new URL("/", Astro.site)} />}
|
||||||
|
{isHome && <link rel="alternate" hreflang="en" href={new URL("/en/", Astro.site)} />}
|
||||||
|
{isHome && <link rel="alternate" hreflang="x-default" href={new URL("/", Astro.site)} />}
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
|
||||||
<link
|
<link
|
||||||
rel="preconnect"
|
rel="stylesheet"
|
||||||
href={lang === "ko" ? "https://cdn.jsdelivr.net" : "https://api.fontshare.com"}
|
href="https://cdn.jsdelivr.net/gh/wanteddev/wanted-sans@v1.0.3/packages/wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.min.css"
|
||||||
crossorigin
|
|
||||||
/>
|
/>
|
||||||
<link rel="stylesheet" href={FONT_CSS[lang]} />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap"
|
||||||
|
|
@ -84,309 +96,210 @@ const FAVICON =
|
||||||
<meta property="og:title" content={title} />
|
<meta property="og:title" content={title} />
|
||||||
<meta property="og:description" content={description} />
|
<meta property="og:description" content={description} />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
<meta property="og:locale" content={lang === "ko" ? "ko_KR" : "en_US"} />
|
<meta property="og:locale" content={lang === "ko" ? "ko_KR" : "en_US"} />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta property="og:image" content={socialPreviewUrl} />
|
||||||
|
<meta property="og:image:width" content={socialPreview.width} />
|
||||||
{/* JS 가 살아 있으면 no-js 를 떼고 진입 연출을 켠다 */}
|
<meta property="og:image:height" content={socialPreview.height} />
|
||||||
<script is:inline>
|
<meta property="og:image:alt" content={socialPreviewAlt} />
|
||||||
document.documentElement.classList.remove("no-js");
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
</script>
|
<meta name="twitter:image" content={socialPreviewUrl} />
|
||||||
|
<meta name="twitter:image:alt" content={socialPreviewAlt} />
|
||||||
|
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<a class="skip" href="#main">{lang === "ko" ? "본문으로 건너뛰기" : "Skip to content"}</a>
|
<a class="skip" href="#main">{lang === "ko" ? "본문으로 건너뛰기" : "Skip to content"}</a>
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner glass">
|
<div class="header-inner shell">
|
||||||
<span class="rim" aria-hidden="true"></span>
|
<a class="wordmark" href={homePath} translate="no" aria-label={lang === "ko" ? "designpaca 홈" : "designpaca home"}>
|
||||||
<a class="wordmark" href={lang === "ko" ? "/" : "/en/"} aria-label="designpaca">
|
<svg class="wordmark-mark" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||||
<svg class="mark" viewBox="0 0 32 32" width="20" height="20" aria-hidden="true">
|
<path d="M4 2.5v19M4 3h13M4 6h9M4 9h15M4 12h10M4 15h14M4 18h8M4 21h12" />
|
||||||
<path d="M11 7v18" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" />
|
|
||||||
<circle cx="11" cy="10" r="3" fill="currentColor" />
|
|
||||||
<circle cx="11" cy="16" r="3" fill="currentColor" />
|
|
||||||
<circle cx="11" cy="22" r="3" fill="currentColor" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>designpaca</span>
|
<span>designpaca</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<nav class="nav-main" aria-label={lang === "ko" ? "섹션" : "Sections"}>
|
<nav class="nav-primary" aria-label={copy.nav.menuLabel}>
|
||||||
{navItems.map((i) => <a href={i.href}>{i.label}</a>)}
|
{navItems.map((item) => <a href={item.href}>{item.label}</a>)}
|
||||||
{/* 페이지를 벗어나는 유일한 링크다. 화살표로 그렇다고 알린다 */}
|
|
||||||
<a class="nav-repo" href={nav.repo} rel="noopener">
|
|
||||||
{nav.repoLabel}
|
|
||||||
<svg viewBox="0 0 12 12" width="9" height="9" aria-hidden="true">
|
|
||||||
<path d="M3.5 8.5 8.5 3.5M4.5 3.5h4v4" stroke="currentColor"
|
|
||||||
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="header-end">
|
<div class="header-actions">
|
||||||
{/* 언어는 국가가 아니다. 국기 아이콘을 쓰지 않는다.
|
<a class="header-archive" href={showcasePath}>{copy.nav.showcaseLabel}</a>
|
||||||
좁은 화면(<480px)에서는 이 버튼이 사라지고 메덹 안 행으로 들어간다 */}
|
{translationPath && (
|
||||||
<a
|
<a
|
||||||
class="btn btn-quiet btn-sm lang-btn"
|
class="header-lang"
|
||||||
href={alt}
|
href={translationPath}
|
||||||
hreflang={lang === "ko" ? "en" : "ko"}
|
hreflang={lang === "ko" ? "en" : "ko"}
|
||||||
lang={lang === "ko" ? "en" : "ko"}
|
lang={lang === "ko" ? "en" : "ko"}
|
||||||
|
aria-label={copy.nav.langLabel}
|
||||||
>
|
>
|
||||||
{lang === "ko" ? "English" : "한국어"}
|
{lang === "ko" ? "EN" : "KO"}
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
{/* 모바일 메뉴. details 라 JS 가 0바이트에 가깝고, 열림 상태,
|
<a class="header-repo" href={repositoryUrl} rel="noopener" aria-label={copy.nav.repoLabel}>
|
||||||
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
||||||
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
|
<path d="M4 12 12 4M6 4h6v6" />
|
||||||
시트는 알약 전체 폭으로 열린다 — 11rem 짜리 조각 상자가 아니라 */}
|
|
||||||
<details class="nav-mobile">
|
|
||||||
<summary class="btn btn-quiet btn-sm" aria-label={nav.menu} aria-haspopup="menu">
|
|
||||||
<svg class="i-burger" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
|
||||||
<path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<svg class="i-close" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
|
|
||||||
<path d="M3.5 3.5l9 9M12.5 3.5l-9 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</summary>
|
|
||||||
<nav class="nav-sheet glass" aria-label={nav.menu}>
|
|
||||||
{navItems.map((i, idx) => (
|
|
||||||
<a href={i.href}>
|
|
||||||
<span class="sheet-num" aria-hidden="true">{String(idx + 1).padStart(2, "0")}</span>
|
|
||||||
{i.label}
|
|
||||||
</a>
|
</a>
|
||||||
))}
|
|
||||||
<a href={nav.repo} rel="noopener">
|
|
||||||
<span class="sheet-num" aria-hidden="true">↗</span>
|
|
||||||
{nav.repoLabel}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="sheet-lang"
|
|
||||||
href={alt}
|
|
||||||
hreflang={lang === "ko" ? "en" : "ko"}
|
|
||||||
lang={lang === "ko" ? "en" : "ko"}
|
|
||||||
>
|
|
||||||
<span class="sheet-num" aria-hidden="true">A</span>
|
|
||||||
{lang === "ko" ? "English" : "한국어"}
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main" class="page">
|
<main id="main" tabindex="-1">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<script is:inline>
|
|
||||||
// 메덥 닫기 두 가지. details 의 나머지(Esc·포커스·aria)는 브라우저 몫.
|
|
||||||
// 1) 행을 누르면 앵커로 스크롤하고 메덥은 스스로 닫는다 —
|
|
||||||
// 열린 채로 남아 본문을 가리는 것이 이 메뉴의 유일한 결함이었다.
|
|
||||||
// 2) 바깥을 누르면 닫는다. scroll 리스너는 쓰지 않는다(게이트 10).
|
|
||||||
(function () {
|
|
||||||
var menu = document.querySelector(".nav-mobile");
|
|
||||||
if (!menu) return;
|
|
||||||
menu.addEventListener("click", function (e) {
|
|
||||||
if (e.target && e.target.closest && e.target.closest("a")) {
|
|
||||||
menu.removeAttribute("open");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener("click", function (e) {
|
|
||||||
if (menu.open && !(e.target && e.target.closest && e.target.closest(".nav-mobile"))) {
|
|
||||||
menu.removeAttribute("open");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// 진입 연출. 한 번 보이면 계속 보인다.
|
|
||||||
// scroll 리스너를 쓰지 않는다(하드 게이트 10).
|
|
||||||
(function () {
|
|
||||||
var els = document.querySelectorAll(".reveal");
|
|
||||||
if (!("IntersectionObserver" in window) || !els.length) {
|
|
||||||
els.forEach(function (el) { el.classList.add("is-in"); });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var io = new IntersectionObserver(
|
|
||||||
function (entries) {
|
|
||||||
entries.forEach(function (e) {
|
|
||||||
if (!e.isIntersecting) return;
|
|
||||||
e.target.classList.add("is-in");
|
|
||||||
io.unobserve(e.target);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ rootMargin: "200px 0px 200px 0px", threshold: 0 },
|
|
||||||
);
|
|
||||||
els.forEach(function (el) { io.observe(el); });
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.skip {
|
|
||||||
position: absolute;
|
|
||||||
left: var(--space-3);
|
|
||||||
top: calc(var(--space-3) * -4);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
background: var(--accent);
|
|
||||||
color: var(--accent-ink);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
z-index: 10;
|
|
||||||
transition: transform var(--dur-quick) var(--ease-out);
|
|
||||||
}
|
|
||||||
.skip:focus { transform: translateY(calc(var(--space-3) * 5)); }
|
|
||||||
|
|
||||||
/* 상단에 붙은 풀블리드 바 대신 떠 있는 헤더 */
|
|
||||||
/* 상단에 붙은 바가 아니라 떠 있는 유리 알약 */
|
|
||||||
/* 언어 전환 링크에는 lang 속성이 붙는다(스크린리더가 "English" 를
|
|
||||||
영어로 읽어야 하므로 옳다). 그런데 [lang] 셀렉터가 타입 스케일을
|
|
||||||
통째로 재정의해서 이 링크만 16px 이 됐다 — 스케일 밖 단계가 하나 생긴다.
|
|
||||||
크기는 헤더에서 확정하고 링크는 상속만 받는다. */
|
|
||||||
.site-header {
|
.site-header {
|
||||||
font-size: var(--step-0);
|
position: sticky;
|
||||||
position: fixed;
|
top: 0;
|
||||||
top: var(--space-3);
|
z-index: 10;
|
||||||
left: 0;
|
min-height: var(--header-h);
|
||||||
right: 0;
|
background: var(--surface);
|
||||||
z-index: 3;
|
border-bottom: var(--line-width) solid var(--line);
|
||||||
padding-inline: var(--gutter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-inner {
|
.header-inner {
|
||||||
max-width: 1180px;
|
min-height: var(--header-h);
|
||||||
margin-inline: auto;
|
display: grid;
|
||||||
display: flex;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: var(--space-6);
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
|
|
||||||
border-radius: var(--radius-frame);
|
|
||||||
position: relative; /* 모바일 시트의 기준 — 알약 전체 폭으로 펼친다 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-3);
|
||||||
|
min-height: var(--control-h-sm);
|
||||||
|
font-size: 1rem;
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.mark { color: var(--accent); flex: none; }
|
|
||||||
/* ---- 섹션 내비 (데스크톱) --------------------------------- */
|
|
||||||
.nav-main {
|
|
||||||
display: none;
|
|
||||||
gap: var(--space-1);
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
.nav-main a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: var(--control-h-sm);
|
|
||||||
padding-inline: var(--space-3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: var(--ink-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color var(--dur-instant) var(--ease-out),
|
|
||||||
background-color var(--dur-instant) var(--ease-out);
|
|
||||||
}
|
|
||||||
.nav-main a:hover { color: var(--ink); background: var(--glass-tint); }
|
|
||||||
/* 바깥으로 나가는 링크는 섹션 앵커와 같은 무게로 두지 않는다 */
|
|
||||||
.nav-repo { gap: var(--space-1); opacity: 0.72; }
|
|
||||||
.nav-repo:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.header-end {
|
.wordmark-mark {
|
||||||
|
flex: none;
|
||||||
|
color: var(--accent);
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-primary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
justify-content: center;
|
||||||
flex: none;
|
gap: clamp(var(--space-3), 1.6vw, var(--space-6));
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 모바일 메뉴 ------------------------------------------
|
.nav-primary a,
|
||||||
details 를 쓴 이유는 JS 를 줄이기 위해서만이 아니다. 열림 상태,
|
.header-actions a {
|
||||||
Esc, 포커스 순서, aria-expanded 를 브라우저가 이미 맞게 한다.
|
min-height: var(--control-h-sm);
|
||||||
직접 만들면 그 넷을 전부 다시 구현해야 하고 보통 틀린다.
|
|
||||||
1′ 국소 레퍼런스(navbar.gallery → browserbase.com): 항목이 많은 사이트는
|
|
||||||
내비 마크업을 아예 복제해 분리한다. 우리는 5행이라 복제하지 않고
|
|
||||||
시트를 알약 전체 폭으로 펼친다 — 조각 상자보다 계기판답다. */
|
|
||||||
.nav-mobile { position: static; }
|
|
||||||
.nav-mobile > summary {
|
|
||||||
list-style: none;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: var(--control-h); /* 터치 대상은 44px — 36px 는 마우스용 */
|
color: var(--ink-muted);
|
||||||
min-height: var(--control-h);
|
font-size: var(--step--1);
|
||||||
padding: 0;
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.nav-mobile > summary::-webkit-details-marker { display: none; }
|
|
||||||
.nav-mobile > summary::marker { content: ""; }
|
.nav-primary a {
|
||||||
.nav-mobile[open] > summary {
|
position: relative;
|
||||||
background: var(--glass-tint);
|
padding-inline: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-primary a::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: var(--space-2);
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--space-2);
|
||||||
|
height: 1px;
|
||||||
|
background: var(--ink);
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
transition: transform var(--dur-quick) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-primary a:hover,
|
||||||
|
.nav-primary a:focus-visible,
|
||||||
|
.header-actions a:hover,
|
||||||
|
.header-actions a:focus-visible {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.i-close { display: none; }
|
|
||||||
.nav-mobile[open] .i-burger { display: none; }
|
|
||||||
.nav-mobile[open] .i-close { display: block; }
|
|
||||||
|
|
||||||
.nav-sheet {
|
.nav-primary a:hover::after,
|
||||||
/* 닫힌 details 의 자식에 display 를 주면 UA 숨김을 덮어써
|
.nav-primary a:focus-visible::after {
|
||||||
'보이지 않지만 레이아웃에 사는' 시트가 된다(320px 오버플로의 원인).
|
transform: scaleX(1);
|
||||||
닫힘을 명시적으로 다시 선언한다 — details 패턴의 필수 안전장치 */
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + var(--space-2));
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: var(--space-2);
|
|
||||||
z-index: 4;
|
|
||||||
border-radius: var(--radius-frame);
|
|
||||||
/* 메뉴는 콘텐츠가 아니라 크롬이다. .glass 의 4.5% 잉크는 알약에는
|
|
||||||
맞는 재질이지만 시트에는 가독성 버그다 — 뒤 글자가 비쳐 겹친다.
|
|
||||||
배경만 채우고 테두리 광(::after rim)·그림자는 유리 재질 그대로 */
|
|
||||||
background: color-mix(in oklab, var(--surface) 94%, white 6%);
|
|
||||||
backdrop-filter: none;
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
}
|
}
|
||||||
.nav-mobile[open] .nav-sheet { display: grid; }
|
|
||||||
.nav-sheet a {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
min-height: calc(var(--control-h) + 4px);
|
}
|
||||||
|
|
||||||
|
.header-archive {
|
||||||
padding-inline: var(--space-3);
|
padding-inline: var(--space-3);
|
||||||
border-radius: var(--radius-sm);
|
border-left: var(--line-width) solid var(--line);
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.nav-sheet a:hover { background: var(--glass-tint-2); }
|
|
||||||
.sheet-num {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--mono-size);
|
|
||||||
color: var(--accent);
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
.sheet-lang { color: var(--ink-muted); }
|
|
||||||
.sheet-lang:hover { color: var(--ink); }
|
|
||||||
|
|
||||||
/* 열림 연출 — transform/opacity 만(게이트 8) */
|
|
||||||
.nav-mobile[open] .nav-sheet {
|
|
||||||
animation: sheet-in 180ms var(--ease-out) both;
|
|
||||||
}
|
|
||||||
@keyframes sheet-in {
|
|
||||||
from { opacity: 0; transform: translateY(-6px); }
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.nav-mobile[open] .nav-sheet { animation: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
.header-lang,
|
||||||
.nav-main { display: flex; }
|
.header-repo {
|
||||||
.nav-mobile { display: none; }
|
min-width: var(--control-h-sm);
|
||||||
|
padding-inline: var(--space-2);
|
||||||
|
border: var(--line-width) solid var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 480px 미만: 언어 버튼은 헤더에서 물러나고 메덥 행으로 간다.
|
.header-repo svg {
|
||||||
320px 실측에서 워드마크+English+버거가 알약을 7px 밀어냈다 */
|
fill: none;
|
||||||
@media (max-width: 480px) {
|
stroke: currentColor;
|
||||||
.lang-btn { display: none; }
|
stroke-width: 1.5;
|
||||||
.header-inner { gap: var(--space-2); }
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 64rem) {
|
||||||
|
.header-inner { gap: var(--space-3); }
|
||||||
|
.header-archive { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.header-inner {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
grid-template-rows: 3.5rem 3.25rem;
|
||||||
|
gap: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wordmark { grid-column: 1; grid-row: 1; }
|
||||||
|
.header-actions { grid-column: 2; grid-row: 1; }
|
||||||
|
.nav-primary {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-row: 2;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-1);
|
||||||
|
border-top: var(--line-width) solid var(--line);
|
||||||
|
}
|
||||||
|
.nav-primary a {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: var(--space-1);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header-repo { display: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 23rem) {
|
||||||
|
.wordmark { gap: var(--space-2); }
|
||||||
|
.nav-primary a { font-size: 0.7rem; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,372 +0,0 @@
|
||||||
/**
|
|
||||||
* 게이트 씬 — 유리판 여섯 장이 깊이 축에 서고, 스크롤하면 카메라가 통과한다.
|
|
||||||
*
|
|
||||||
* 파이프라인 섹션 전용이다. 한때 히어로 배경으로도 썼는데, 그러면 같은 그림이
|
|
||||||
* 페이지에 두 번 나온다. 그리고 히어로에는 옆에서 반응할 콘텐츠가 없어서
|
|
||||||
* 의미를 판독기가 자막으로 설명해야 했다 — 그림이 스스로 말하지 못한다는 뜻이다.
|
|
||||||
* 히어로 배경은 `shader-plane.ts` 의 흐름 필드가 맡는다.
|
|
||||||
*
|
|
||||||
* 성능 게이트는 references/three.md 그대로다.
|
|
||||||
* - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
|
|
||||||
* - DPR 클램프 1.5. 저사양이면 판을 여섯에서 셋으로 줄인다
|
|
||||||
* - 화면 밖이거나 탭이 숨으면 그리지 않는다
|
|
||||||
* - WebGL 이 없거나 모션을 줄이는 설정이면 아예 시작하지 않는다.
|
|
||||||
* 그때 보이는 CSS 그라디언트가 폴백이 아니라 결과물이다
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface GateSceneOptions {
|
|
||||||
canvas: HTMLCanvasElement;
|
|
||||||
/** CSS 커스텀 프로퍼티 이름 */
|
|
||||||
accentVar: string;
|
|
||||||
glowVar: string;
|
|
||||||
/**
|
|
||||||
* 통과한 게이트가 바뀔 때만 부른다. 프레임마다 부르지 않는다 —
|
|
||||||
* R3(Codrops HAOQI) 에서 가져온 규칙이다. DOM 은 구조를 소유하고
|
|
||||||
* WebGL 은 셰이더만 만진다. 매 프레임 DOM 을 쓰면 그 경계가 무너진다.
|
|
||||||
*/
|
|
||||||
onGate?: (index: number, total: number) => void;
|
|
||||||
/**
|
|
||||||
* 스크롤 진행도를 이 요소로 잰다. 기본은 캔버스 자신.
|
|
||||||
* 캔버스를 sticky/fixed 로 화면에 붙여두면 캔버스의 rect 는 변하지 않으므로
|
|
||||||
* 진행도가 고정된다 — 그때는 실제로 지나가는 섹션을 넘겨야 한다.
|
|
||||||
*/
|
|
||||||
progressFrom?: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NOISE = `
|
|
||||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
||||||
float noise(vec2 p) {
|
|
||||||
vec2 i = floor(p), f = fract(p);
|
|
||||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
||||||
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
|
|
||||||
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
|
|
||||||
}
|
|
||||||
float fbm(vec2 p) {
|
|
||||||
float v = 0.0, a = 0.5;
|
|
||||||
for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export function mountGateScene(opts: GateSceneOptions): void {
|
|
||||||
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
||||||
const { canvas } = opts;
|
|
||||||
|
|
||||||
const io = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (!entries.some((e) => e.isIntersecting)) return;
|
|
||||||
io.disconnect();
|
|
||||||
void boot(opts);
|
|
||||||
},
|
|
||||||
{ rootMargin: "0px" },
|
|
||||||
);
|
|
||||||
io.observe(canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot({ canvas, accentVar, glowVar, onGate, progressFrom }: GateSceneOptions): Promise<void> {
|
|
||||||
let THREE: typeof import("three");
|
|
||||||
try {
|
|
||||||
THREE = await import("three");
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let renderer: import("three").WebGLRenderer;
|
|
||||||
try {
|
|
||||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true });
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const css = getComputedStyle(document.documentElement);
|
|
||||||
const color = (v: string, fb: string) =>
|
|
||||||
new THREE.Color((css.getPropertyValue(v).trim() || fb) as string);
|
|
||||||
const accent = color(accentVar, "#4FD6C4");
|
|
||||||
const glow = color(glowVar, "#6E7BFF");
|
|
||||||
|
|
||||||
// 저사양 판정.
|
|
||||||
// 코어 수만 보면 요즘 폰(8코어)이 전부 고사양으로 잡힌다. 화면 폭을 같이 본다 —
|
|
||||||
// 좁은 화면은 대개 배터리로 돌아가고, 같은 씬이라도 열이 더 난다.
|
|
||||||
const narrow = innerWidth < 760;
|
|
||||||
const lean = narrow || (navigator.hardwareConcurrency ?? 4) <= 4;
|
|
||||||
const PANE_COUNT = lean ? 3 : 6;
|
|
||||||
|
|
||||||
// 좁은 화면에서는 DPR 도 한 단계 더 내린다. 폰의 DPR 은 3 까지 간다.
|
|
||||||
const dpr = Math.min(devicePixelRatio, narrow ? 1.25 : 1.5);
|
|
||||||
renderer.setPixelRatio(dpr);
|
|
||||||
|
|
||||||
/* ---- 1) 배경을 렌더 타겟에 그린다 ------------------------- */
|
|
||||||
const rtScene = new THREE.Scene();
|
|
||||||
const rtCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
||||||
const rt = new THREE.WebGLRenderTarget(1, 1, {
|
|
||||||
magFilter: THREE.LinearFilter,
|
|
||||||
minFilter: THREE.LinearFilter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bgUniforms = {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uAspect: { value: 1 },
|
|
||||||
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
|
|
||||||
uProgress: { value: 0 },
|
|
||||||
uA: { value: accent },
|
|
||||||
uB: { value: glow },
|
|
||||||
};
|
|
||||||
|
|
||||||
rtScene.add(
|
|
||||||
new THREE.Mesh(
|
|
||||||
new THREE.PlaneGeometry(2, 2),
|
|
||||||
new THREE.ShaderMaterial({
|
|
||||||
uniforms: bgUniforms,
|
|
||||||
vertexShader: `
|
|
||||||
varying vec2 vUv;
|
|
||||||
void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
|
|
||||||
`,
|
|
||||||
fragmentShader: `
|
|
||||||
precision mediump float;
|
|
||||||
varying vec2 vUv;
|
|
||||||
uniform float uTime; uniform float uAspect;
|
|
||||||
uniform vec2 uPointer; uniform float uProgress;
|
|
||||||
uniform vec3 uA; uniform vec3 uB;
|
|
||||||
${NOISE}
|
|
||||||
void main() {
|
|
||||||
vec2 p = vUv; p.x *= uAspect;
|
|
||||||
float t = uTime * 0.045;
|
|
||||||
vec2 m = uPointer; m.x *= uAspect;
|
|
||||||
|
|
||||||
float f1 = fbm(p * 1.8 + vec2(t, -t * 0.6));
|
|
||||||
float f2 = fbm(p * 2.3 - vec2(t * 0.8, t * 1.1) + f1 * 0.55);
|
|
||||||
|
|
||||||
// 통과할수록 빛이 뒤로 물러난다.
|
|
||||||
// 중심을 화면 세로 중앙에 두고 반경을 넓게 잡는다 — 좁으면
|
|
||||||
// 히어로 위아래가 검게 남아 배경이 '떠 있는 덩어리'로 보인다.
|
|
||||||
vec2 c1 = mix(vec2(0.34 * uAspect, 0.52), m, 0.20) + vec2(0.0, uProgress * 0.14);
|
|
||||||
vec2 c2 = vec2(0.78 * uAspect, 0.44) - vec2(uProgress * 0.10, 0.0);
|
|
||||||
|
|
||||||
float a = smoothstep(1.05, 0.05, distance(p, c1) + f1 * 0.34);
|
|
||||||
float b = smoothstep(0.95, 0.04, distance(p, c2) + f2 * 0.30);
|
|
||||||
|
|
||||||
vec3 col = uA * a + uB * b * 0.9;
|
|
||||||
float alpha = clamp(a * 1.15 + b, 0.0, 1.0) * 0.78;
|
|
||||||
gl_FragColor = vec4(col, alpha);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
transparent: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ---- 2) 유리 판. 뒤 텍스처를 밀어서 샘플링한다 ------------- */
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 60);
|
|
||||||
camera.position.set(0, 0, 9);
|
|
||||||
|
|
||||||
const paneMat = new THREE.ShaderMaterial({
|
|
||||||
transparent: true,
|
|
||||||
depthWrite: false,
|
|
||||||
uniforms: {
|
|
||||||
uScene: { value: rt.texture },
|
|
||||||
uTime: { value: 0 },
|
|
||||||
// 테두리 광. 흰색을 박지 않고 강조색을 밝게 올려 쓴다 —
|
|
||||||
// 토큰을 바꾸면 유리 테두리도 같이 따라와야 한다.
|
|
||||||
uEdge: { value: accent.clone().lerp(new THREE.Color(1, 1, 1), 0.55) },
|
|
||||||
uResolution: { value: new THREE.Vector2(1, 1) },
|
|
||||||
},
|
|
||||||
vertexShader: `
|
|
||||||
varying vec2 vUv;
|
|
||||||
varying vec3 vNormalW;
|
|
||||||
varying vec3 vViewDir;
|
|
||||||
void main() {
|
|
||||||
vUv = uv;
|
|
||||||
vec4 world = modelMatrix * vec4(position, 1.0);
|
|
||||||
vNormalW = normalize(mat3(modelMatrix) * normal);
|
|
||||||
vViewDir = normalize(cameraPosition - world.xyz);
|
|
||||||
gl_Position = projectionMatrix * viewMatrix * world;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
fragmentShader: `
|
|
||||||
precision mediump float;
|
|
||||||
varying vec2 vUv;
|
|
||||||
varying vec3 vNormalW;
|
|
||||||
varying vec3 vViewDir;
|
|
||||||
uniform sampler2D uScene;
|
|
||||||
uniform float uTime;
|
|
||||||
uniform vec3 uEdge;
|
|
||||||
uniform vec2 uResolution;
|
|
||||||
${NOISE}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
// 판을 미세하게 굽힌다.
|
|
||||||
// 완전히 평평한 판이 카메라를 정면으로 보면 dot(n, view) 가 1 이라
|
|
||||||
// 프레넬이 구조적으로 0 이 된다. 그러면 유리로 읽히게 하는 신호가
|
|
||||||
// 하나도 남지 않는다 — 이전 판이 흐린 얼룩으로 보였던 이유다.
|
|
||||||
vec3 n = normalize(vNormalW + vec3((vUv - 0.5) * 0.9, 0.0));
|
|
||||||
float fres = pow(1.0 - abs(dot(n, normalize(vViewDir))), 2.2);
|
|
||||||
|
|
||||||
// 판의 테두리. 평면에서 두께가 보이는 곳은 법선이 아니라 UV 경계다.
|
|
||||||
// 배경이므로 경계를 넓게 풀어 선이 아니라 번짐으로 만든다 —
|
|
||||||
// 좁게 두면 사각형 프레임이 또렷하게 서서 추상 패턴이 아니라 '상자'가 된다.
|
|
||||||
vec2 e = min(vUv, 1.0 - vUv);
|
|
||||||
float edge = 1.0 - smoothstep(0.0, 0.17, min(e.x, e.y));
|
|
||||||
edge *= edge;
|
|
||||||
|
|
||||||
// 화면 좌표를 굴절시켜 뒤 텍스처를 샘플링한다
|
|
||||||
vec2 screen = gl_FragCoord.xy / uResolution;
|
|
||||||
vec2 flow = vec2(
|
|
||||||
fbm(vUv * 2.4 + uTime * 0.05),
|
|
||||||
fbm(vUv * 2.4 - uTime * 0.04 + 3.1)
|
|
||||||
) - 0.5;
|
|
||||||
vec3 back = texture2D(uScene, screen + flow * 0.06 + n.xy * 0.05).rgb;
|
|
||||||
|
|
||||||
// 배경이다. 테두리 광을 절반으로 낮춘다 — 액자 안에서 주인공일 때의
|
|
||||||
// 값을 그대로 두면 글자 뒤에서 형광 막대처럼 튄다.
|
|
||||||
vec3 col = back * 1.03 + uEdge * (fres * 0.20 + edge * 0.30);
|
|
||||||
float alpha = 0.12 + fres * 0.18 + edge * 0.32;
|
|
||||||
gl_FragColor = vec4(col, alpha);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 판은 깊이 축에 정면으로 선다. 떠다니는 사각형이 아니라 통과하는 문이다.
|
|
||||||
//
|
|
||||||
// 폭이 중요하다. 액자는 가로로 길어(약 2.9:1) 카메라의 수평 시야가
|
|
||||||
// 거리 d 에서 2.23*d 인데, 판이 그보다 좁으면 통과하는 내내 좌우 테두리가
|
|
||||||
// 화면 안에 남는다. 그러면 "문을 지난다"가 아니라 세로 기둥 두 개가
|
|
||||||
// 지나가는 것으로 보인다 — 실제로 그렇게 보였다.
|
|
||||||
// 판을 액자 비율에 맞춰 첫 판이 화면을 덮게 하면, 가까워질수록 좌우가
|
|
||||||
// 화면 밖으로 빠지고 뒤 판들이 원근으로 남아 터널이 된다.
|
|
||||||
const GATE_GAP = 4.6;
|
|
||||||
const panes: import("three").Mesh[] = [];
|
|
||||||
const geo = new THREE.PlaneGeometry(15, 6, 1, 1);
|
|
||||||
for (let i = 0; i < PANE_COUNT; i++) {
|
|
||||||
const m = new THREE.Mesh(geo, paneMat);
|
|
||||||
// 좌우로 조금씩 어긋나게 세워 통과할 때 옆면이 스친다
|
|
||||||
// 어긋남은 아주 작게. 판이 좌우로 흩어지면 '통과하는 문'이 아니라
|
|
||||||
// '쌓아둔 사각형'이 된다. 중심축이 살아야 원근이 터널로 읽힌다.
|
|
||||||
const sway = (i % 2 === 0 ? 1 : -1) * (0.16 + i * 0.05);
|
|
||||||
m.position.set(sway, Math.sin(i * 1.3) * 0.32, -i * GATE_GAP);
|
|
||||||
m.rotation.set(sway * 0.03, sway * 0.11, sway * 0.05);
|
|
||||||
m.userData["index"] = i;
|
|
||||||
m.userData["sway"] = sway;
|
|
||||||
scene.add(m);
|
|
||||||
panes.push(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 3) 크기·포인터·루프 --------------------------------- */
|
|
||||||
const pointer = new THREE.Vector2(0.5, 0.5);
|
|
||||||
const pointerTarget = new THREE.Vector2(0.5, 0.5);
|
|
||||||
|
|
||||||
// 판이 화면을 덮는 데 필요한 최소 폭.
|
|
||||||
// 수직 시야는 화면 비율과 무관하지만 수평 시야는 aspect 에 비례한다.
|
|
||||||
// 데스크톱(2.5:1)에 맞춘 폭 15 를 세로 화면(0.45:1)에 그대로 쓰면
|
|
||||||
// 수평 시야의 여섯 배가 되어 첫 판이 화면을 완전히 덮는다 —
|
|
||||||
// 게이트가 보이지 않고 그냥 밝은 화면이 된다.
|
|
||||||
const GEO_W = 15;
|
|
||||||
const fitPanes = (aspect: number) => {
|
|
||||||
const halfH = Math.tan((camera.fov * Math.PI) / 360);
|
|
||||||
const viewW = 2 * 7.2 * halfH * aspect;
|
|
||||||
const want = Math.max(6.5, viewW * 1.12);
|
|
||||||
const sx = Math.min(1, want / GEO_W);
|
|
||||||
for (const m of panes) m.scale.x = sx;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fit = () => {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
if (!r.width || !r.height) return;
|
|
||||||
renderer.setSize(r.width, r.height, false);
|
|
||||||
rt.setSize(Math.round(r.width * dpr * 0.6), Math.round(r.height * dpr * 0.6));
|
|
||||||
// gl_FragCoord 는 캔버스의 드로잉버퍼 좌표다. 렌더 타겟은 그 0.6배라
|
|
||||||
// 타겟 크기로 나누면 UV 가 1 을 넘어 텍스처가 잘린다.
|
|
||||||
renderer.getDrawingBufferSize(paneMat.uniforms["uResolution"]!.value);
|
|
||||||
camera.aspect = r.width / r.height;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
bgUniforms.uAspect.value = r.width / r.height;
|
|
||||||
fitPanes(camera.aspect);
|
|
||||||
};
|
|
||||||
fit();
|
|
||||||
// window resize 만 듣던 것을 ResizeObserver 로 바꾼다.
|
|
||||||
// 히어로 높이는 폰트가 늦게 오거나 콘텐츠가 접힐 때도 바뀌는데
|
|
||||||
// 그건 resize 이벤트가 아니다 — 그때 캔버스가 옛 크기로 남아
|
|
||||||
// 배경이 화면의 3분의 2만 덮고 아래가 검게 비었다.
|
|
||||||
new ResizeObserver(fit).observe(canvas);
|
|
||||||
|
|
||||||
addEventListener(
|
|
||||||
"pointermove",
|
|
||||||
(e) => {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
pointerTarget.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
let tabVisible = true;
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
tabVisible = !document.hidden;
|
|
||||||
});
|
|
||||||
let onScreen = true;
|
|
||||||
new IntersectionObserver((es) => {
|
|
||||||
onScreen = es.some((e) => e.isIntersecting);
|
|
||||||
}).observe(canvas);
|
|
||||||
|
|
||||||
canvas.classList.add("is-live");
|
|
||||||
|
|
||||||
renderer.setAnimationLoop((ms) => {
|
|
||||||
if (!tabVisible || !onScreen) return;
|
|
||||||
const t = ms * 0.001;
|
|
||||||
|
|
||||||
pointer.lerp(pointerTarget, 0.04);
|
|
||||||
|
|
||||||
// 스크롤 진행도. addEventListener("scroll") 을 쓰지 않는다(하드 게이트 10).
|
|
||||||
// rect 는 프레임당 한 번만 읽는다. 두 번 읽으면 레이아웃을 두 번 강제한다.
|
|
||||||
const rect = (progressFrom ?? canvas).getBoundingClientRect();
|
|
||||||
// 진행 거리.
|
|
||||||
// 한때 (섹션 높이 - 뷰포트)로 쟀다. 그러면 진행 구간이 섹션 길이에 묶여서,
|
|
||||||
// 게이트를 다 돌리려면 목록 뒤에 62dvh 짜리 빈 공간을 둬야 했다.
|
|
||||||
// 화면 절반이 넘는 여백이 생기고, 그건 콘텐츠가 아니라 스크롤 연료였다.
|
|
||||||
//
|
|
||||||
// 대신 **읽는 대상이 화면을 지나는 동안**으로 맞춘다.
|
|
||||||
// 위 70% 지점에 닿으면 0, 아래 30% 지점을 빠져나가면 1.
|
|
||||||
// 목록이 눈앞에 있는 동안만 게이트가 돌아서 빈 공간이 필요 없다.
|
|
||||||
const start = innerHeight * 0.7;
|
|
||||||
const end = innerHeight * 0.3;
|
|
||||||
const travel = progressFrom
|
|
||||||
? rect.height + (start - end)
|
|
||||||
: rect.height * 0.92;
|
|
||||||
const offset = progressFrom ? start - rect.top : -rect.top;
|
|
||||||
const progress = Math.min(Math.max(offset / travel, 0), 1);
|
|
||||||
|
|
||||||
bgUniforms.uProgress.value = progress;
|
|
||||||
bgUniforms.uTime.value = t;
|
|
||||||
bgUniforms.uPointer.value.copy(pointer);
|
|
||||||
paneMat.uniforms["uTime"]!.value = t;
|
|
||||||
|
|
||||||
onGate?.(Math.min(PANE_COUNT - 1, Math.floor(progress * PANE_COUNT)), PANE_COUNT);
|
|
||||||
|
|
||||||
// 카메라가 여섯 개의 문을 차례로 통과한다
|
|
||||||
const depth = (PANE_COUNT - 1) * GATE_GAP;
|
|
||||||
// 배경은 천천히 흐른다. 전 구간을 다 통과시키면 시선을 끌어 글자와 싸운다.
|
|
||||||
camera.position.z = 7.2 - progress * depth * 0.6;
|
|
||||||
camera.position.x = (pointer.x - 0.5) * 1.1;
|
|
||||||
camera.position.y = (pointer.y - 0.5) * -0.5;
|
|
||||||
camera.lookAt(camera.position.x * 0.3, 0, camera.position.z - 10);
|
|
||||||
|
|
||||||
for (const m of panes) {
|
|
||||||
const i = m.userData["index"] as number;
|
|
||||||
const sway = m.userData["sway"] as number;
|
|
||||||
// 통과 직전의 판만 미세하게 숨쉰다. 전부 흔들면 다시 부유물이 된다
|
|
||||||
const dist = Math.abs(m.position.z - camera.position.z);
|
|
||||||
const near = Math.max(0, 1 - dist / (GATE_GAP * 1.6));
|
|
||||||
m.rotation.z = sway * 0.018 + Math.sin(t * 0.3 + i) * 0.012 * near;
|
|
||||||
m.position.y = Math.sin(i * 1.3) * 0.32 + Math.sin(t * 0.24 + i) * 0.06;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배경을 먼저 타겟에 굽고, 그 텍스처를 유리가 읽는다
|
|
||||||
renderer.setRenderTarget(rt);
|
|
||||||
renderer.clear();
|
|
||||||
renderer.render(rtScene, rtCam);
|
|
||||||
renderer.setRenderTarget(null);
|
|
||||||
|
|
||||||
renderer.clear();
|
|
||||||
renderer.render(rtScene, rtCam);
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
/**
|
|
||||||
* 셰이더 플레인 부트스트랩.
|
|
||||||
*
|
|
||||||
* 스킬의 references/three.md 가 권하는 그 구조다.
|
|
||||||
* - three 는 초기 번들에 없다. 화면에 가까워질 때만 받는다
|
|
||||||
* - WebGL 이 없거나 모션을 줄이는 설정이면 아예 받지 않는다
|
|
||||||
* - DPR 를 클램프한다. 3 을 그대로 쓰면 픽셀이 네 배가 된다
|
|
||||||
* - 화면 밖이거나 탭이 숨으면 그리지 않는다
|
|
||||||
* - 실패하면 조용히 물러난다. CSS 폴백이 그대로 결과물이 된다
|
|
||||||
*
|
|
||||||
* 무거운 씬을 임포트하지 않고 평면 하나에 프래그먼트 셰이더만 얹는다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ShaderPlaneOptions {
|
|
||||||
canvas: HTMLCanvasElement;
|
|
||||||
/** GLSL 프래그먼트 셰이더. vUv, uTime, uAspect, uPointer 를 받는다 */
|
|
||||||
fragment: string;
|
|
||||||
/** CSS 커스텀 프로퍼티에서 읽어 vec3 로 넘길 색 */
|
|
||||||
colors?: Record<string, string>;
|
|
||||||
/** 포인터를 따라가게 할지. 히어로처럼 큰 면적에만 쓴다 */
|
|
||||||
pointer?: boolean;
|
|
||||||
/** 시간 진행 속도 배수 */
|
|
||||||
speed?: number;
|
|
||||||
/** 미리 받아둘 거리 */
|
|
||||||
rootMargin?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERTEX = `
|
|
||||||
varying vec2 vUv;
|
|
||||||
void main() {
|
|
||||||
vUv = uv;
|
|
||||||
gl_Position = vec4(position, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/** 두 셰이더가 공유하는 값 노이즈. 라이브러리를 끌어오지 않는다 */
|
|
||||||
export const GLSL_NOISE = `
|
|
||||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
|
||||||
float noise(vec2 p) {
|
|
||||||
vec2 i = floor(p), f = fract(p);
|
|
||||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
||||||
return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
|
|
||||||
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
|
|
||||||
}
|
|
||||||
float fbm(vec2 p) {
|
|
||||||
float v = 0.0, a = 0.5;
|
|
||||||
for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.03; a *= 0.5; }
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export function mountShaderPlane(opts: ShaderPlaneOptions): void {
|
|
||||||
const { canvas } = opts;
|
|
||||||
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
||||||
|
|
||||||
const io = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (!entries.some((e) => e.isIntersecting)) return;
|
|
||||||
io.disconnect();
|
|
||||||
void boot(opts);
|
|
||||||
},
|
|
||||||
{ rootMargin: opts.rootMargin ?? "300px 0px" },
|
|
||||||
);
|
|
||||||
io.observe(canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boot(opts: ShaderPlaneOptions): Promise<void> {
|
|
||||||
const { canvas, fragment, colors = {}, pointer = false, speed = 1 } = opts;
|
|
||||||
|
|
||||||
let THREE: typeof import("three");
|
|
||||||
try {
|
|
||||||
THREE = await import("three");
|
|
||||||
} catch {
|
|
||||||
return; // CSS 폴백이 그대로 결과물이 된다
|
|
||||||
}
|
|
||||||
|
|
||||||
let renderer: import("three").WebGLRenderer;
|
|
||||||
try {
|
|
||||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true });
|
|
||||||
} catch {
|
|
||||||
return; // WebGL 이 없는 환경
|
|
||||||
}
|
|
||||||
|
|
||||||
const css = getComputedStyle(document.documentElement);
|
|
||||||
const uniforms: Record<string, { value: unknown }> = {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uAspect: { value: 1 },
|
|
||||||
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
|
|
||||||
};
|
|
||||||
for (const [name, cssVar] of Object.entries(colors)) {
|
|
||||||
const raw = css.getPropertyValue(cssVar).trim();
|
|
||||||
uniforms[name] = { value: new THREE.Color(raw || "#4FD6C4") };
|
|
||||||
}
|
|
||||||
|
|
||||||
const material = new THREE.ShaderMaterial({
|
|
||||||
transparent: true,
|
|
||||||
uniforms,
|
|
||||||
vertexShader: VERTEX,
|
|
||||||
fragmentShader: fragment,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
|
|
||||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
||||||
|
|
||||||
const fit = () => {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
if (!r.width || !r.height) return;
|
|
||||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
|
|
||||||
renderer.setSize(r.width, r.height, false);
|
|
||||||
(uniforms["uAspect"] as { value: number }).value = r.width / r.height;
|
|
||||||
};
|
|
||||||
fit();
|
|
||||||
addEventListener("resize", fit, { passive: true });
|
|
||||||
|
|
||||||
if (pointer) {
|
|
||||||
const target = new THREE.Vector2(0.5, 0.5);
|
|
||||||
addEventListener(
|
|
||||||
"pointermove",
|
|
||||||
(e) => {
|
|
||||||
const r = canvas.getBoundingClientRect();
|
|
||||||
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
|
|
||||||
},
|
|
||||||
{ passive: true },
|
|
||||||
);
|
|
||||||
// 매 프레임 목표값으로 조금씩 따라간다. 즉시 반응하면 값싸 보인다
|
|
||||||
material.userData["follow"] = () => {
|
|
||||||
const p = uniforms["uPointer"] as { value: import("three").Vector2 };
|
|
||||||
p.value.lerp(target, 0.045);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let tabVisible = true;
|
|
||||||
document.addEventListener("visibilitychange", () => {
|
|
||||||
tabVisible = !document.hidden;
|
|
||||||
});
|
|
||||||
|
|
||||||
let onScreen = true;
|
|
||||||
new IntersectionObserver((es) => {
|
|
||||||
onScreen = es.some((e) => e.isIntersecting);
|
|
||||||
}).observe(canvas);
|
|
||||||
|
|
||||||
canvas.classList.add("is-live");
|
|
||||||
renderer.setAnimationLoop((ms) => {
|
|
||||||
if (!tabVisible || !onScreen) return;
|
|
||||||
(material.userData["follow"] as (() => void) | undefined)?.();
|
|
||||||
(uniforms["uTime"] as { value: number }).value = (ms * 0.001) * speed;
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +1,9 @@
|
||||||
---
|
---
|
||||||
import Base from "../../layouts/Base.astro";
|
import Base from "../../layouts/Base.astro";
|
||||||
import Hero from "../../components/Hero.astro";
|
import Landing from "../../components/Landing.astro";
|
||||||
import Pipeline from "../../components/Pipeline.astro";
|
import { landingEn as c } from "../../i18n/landing";
|
||||||
import Materials from "../../components/Materials.astro";
|
|
||||||
import Metrics from "../../components/Metrics.astro";
|
|
||||||
import Preflight from "../../components/Preflight.astro";
|
|
||||||
import Typography from "../../components/Typography.astro";
|
|
||||||
import Rules from "../../components/Rules.astro";
|
|
||||||
import Install from "../../components/Install.astro";
|
|
||||||
import { en as c } from "../../i18n/content";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base lang="en" title={c.meta.title} description={c.meta.description}>
|
<Base lang="en" title={c.meta.title} description={c.meta.description} translationPath="/">
|
||||||
{/*
|
<Landing c={c} lang="en" />
|
||||||
순서는 세 묶음이다. 한동안 기능과 증거가 번갈아 나와서
|
|
||||||
(사례 → 필터 → 수치 → 감사 → 조판 → 규칙) 읽는 사람이 지금 무슨 이야기를
|
|
||||||
듣고 있는지 놓쳤다. 같은 성격끼리 붙인다.
|
|
||||||
|
|
||||||
① 무엇인가 히어로 · 파이프라인
|
|
||||||
② 무엇을 아는가 SVG 필터 · 한글 조판
|
|
||||||
③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다)
|
|
||||||
④ 행동 설치
|
|
||||||
*/}
|
|
||||||
<Hero c={c.hero} ui={c.ui} />
|
|
||||||
<Pipeline c={c.pipeline} />
|
|
||||||
|
|
||||||
<Materials c={c.materials} />
|
|
||||||
<Typography c={c.typography} />
|
|
||||||
|
|
||||||
<Metrics c={c.metrics} />
|
|
||||||
<Preflight c={c.preflight} />
|
|
||||||
<Rules c={c.rules} />
|
|
||||||
|
|
||||||
<Install c={c.install} ui={c.ui} />
|
|
||||||
|
|
||||||
</Base>
|
</Base>
|
||||||
|
|
|
||||||
5
apps/site/src/pages/en/showcase.astro
Normal file
5
apps/site/src/pages/en/showcase.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
import Showcase from "../showcase.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Showcase lang="en" />
|
||||||
|
|
@ -1,37 +1,9 @@
|
||||||
---
|
---
|
||||||
import Base from "../layouts/Base.astro";
|
import Base from "../layouts/Base.astro";
|
||||||
import Hero from "../components/Hero.astro";
|
import Landing from "../components/Landing.astro";
|
||||||
import Pipeline from "../components/Pipeline.astro";
|
import { landingKo as c } from "../i18n/landing";
|
||||||
import Materials from "../components/Materials.astro";
|
|
||||||
import Metrics from "../components/Metrics.astro";
|
|
||||||
import Preflight from "../components/Preflight.astro";
|
|
||||||
import Typography from "../components/Typography.astro";
|
|
||||||
import Rules from "../components/Rules.astro";
|
|
||||||
import Install from "../components/Install.astro";
|
|
||||||
import { ko as c } from "../i18n/content";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Base lang="ko" title={c.meta.title} description={c.meta.description}>
|
<Base lang="ko" title={c.meta.title} description={c.meta.description} translationPath="/en/">
|
||||||
{/*
|
<Landing c={c} lang="ko" />
|
||||||
순서는 세 묶음이다. 한동안 기능과 증거가 번갈아 나와서
|
|
||||||
(사례 → 필터 → 수치 → 감사 → 조판 → 규칙) 읽는 사람이 지금 무슨 이야기를
|
|
||||||
듣고 있는지 놓쳤다. 같은 성격끼리 붙인다.
|
|
||||||
|
|
||||||
① 무엇인가 히어로 · 파이프라인
|
|
||||||
② 무엇을 아는가 SVG 필터 · 한글 조판
|
|
||||||
③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다)
|
|
||||||
④ 행동 설치
|
|
||||||
*/}
|
|
||||||
<Hero c={c.hero} ui={c.ui} />
|
|
||||||
<Pipeline c={c.pipeline} />
|
|
||||||
|
|
||||||
<Materials c={c.materials} />
|
|
||||||
<Typography c={c.typography} />
|
|
||||||
|
|
||||||
<Metrics c={c.metrics} />
|
|
||||||
<Preflight c={c.preflight} />
|
|
||||||
<Rules c={c.rules} />
|
|
||||||
|
|
||||||
<Install c={c.install} ui={c.ui} />
|
|
||||||
|
|
||||||
</Base>
|
</Base>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,534 +1,274 @@
|
||||||
/* ============================================================
|
|
||||||
designpaca 소개 페이지 — 기반 스타일
|
|
||||||
토큰은 tokens.css 에만 있다. 이 파일에 새 값을 만들지 않는다.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after { box-sizing: border-box; }
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
img,
|
|
||||||
picture,
|
|
||||||
video,
|
|
||||||
canvas,
|
|
||||||
svg {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
overflow-x: hidden;
|
color: var(--ink);
|
||||||
}
|
font-family: var(--font-body);
|
||||||
@media (prefers-reduced-motion: reduce) {
|
font-synthesis: none;
|
||||||
html { scroll-behavior: auto; }
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: var(--font-sans);
|
|
||||||
font-size: var(--step-0);
|
font-size: var(--step-0);
|
||||||
|
font-weight: var(--weight-body);
|
||||||
line-height: var(--leading-normal);
|
line-height: var(--leading-normal);
|
||||||
letter-spacing: var(--tracking-body);
|
|
||||||
font-synthesis-weight: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
[lang="ko"] body,
|
|
||||||
[lang="ko"] p,
|
|
||||||
[lang="ko"] li,
|
|
||||||
[lang="ko"] dt,
|
|
||||||
[lang="ko"] dd,
|
|
||||||
[lang="ko"] h1,
|
|
||||||
[lang="ko"] h2,
|
|
||||||
[lang="ko"] h3 {
|
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 대기광 -------------------------------------------------
|
::selection {
|
||||||
유리는 뒤에 무언가 있어야 유리로 보인다. 페이지 전체에 깔리는
|
|
||||||
고정 광원 두 개가 그 역할을 한다. fixed 라 스크롤 리페인트가 없다. */
|
|
||||||
body::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -2;
|
|
||||||
pointer-events: none;
|
|
||||||
background:
|
|
||||||
radial-gradient(58vw 46vw at 14% -6%,
|
|
||||||
color-mix(in oklab, var(--accent) 26%, transparent), transparent 68%),
|
|
||||||
radial-gradient(52vw 44vw at 92% 8%,
|
|
||||||
color-mix(in oklab, var(--glow-2) 20%, transparent), transparent 66%),
|
|
||||||
radial-gradient(70vw 60vw at 62% 108%,
|
|
||||||
color-mix(in oklab, var(--accent) 14%, transparent), transparent 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 필름 그레인 ------------------------------------------- */
|
|
||||||
body::after {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 4;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.05;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
|
|
||||||
mix-blend-mode: overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 스크롤 ------------------------------------------------
|
|
||||||
기본 스크롤바는 OS 색이라 어두운 페이지 옆에 밝은 막대가 선다.
|
|
||||||
표준 속성으로 먼저 칠하고, 아직 지원이 없는 곳은 -webkit- 으로 받는다. */
|
|
||||||
html {
|
|
||||||
scrollbar-color: var(--line-strong) transparent;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--line-strong);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 2px solid var(--surface);
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); }
|
|
||||||
|
|
||||||
/* 고정 헤더 높이만큼 앵커 목적지를 내린다. 없으면 점프한 섹션의
|
|
||||||
제목이 헤더 뒤로 들어간다. */
|
|
||||||
:target,
|
|
||||||
section[id] { scroll-margin-top: calc(var(--header-h) + var(--space-4)); }
|
|
||||||
|
|
||||||
/* ---- 컨트롤 ------------------------------------------------
|
|
||||||
버튼처럼 생긴 것은 전부 .btn 을 쓴다. 높이·패딩·웨이트를
|
|
||||||
컴포넌트마다 다시 정하면 그때부터 SSOT 가 아니다. */
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
min-height: var(--control-h);
|
|
||||||
padding-inline: var(--control-pad-x);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: var(--step-0);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
line-height: 1;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition:
|
|
||||||
background-color var(--dur-instant) var(--ease-out),
|
|
||||||
color var(--dur-instant) var(--ease-out),
|
|
||||||
transform var(--dur-instant) var(--ease-out);
|
|
||||||
}
|
|
||||||
.btn:active { transform: translateY(1px); }
|
|
||||||
.btn-sm { min-height: var(--control-h-sm); }
|
|
||||||
|
|
||||||
/* 채운 버튼 — 페이지에 하나뿐인 주 행동에만 */
|
|
||||||
.btn-solid {
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-ink);
|
color: var(--accent-ink);
|
||||||
}
|
}
|
||||||
.btn-solid:hover { background: color-mix(in oklab, var(--accent) 86%, var(--ink)); color: var(--accent-ink); }
|
|
||||||
|
|
||||||
/* 테두리 버튼 — 나머지 전부 */
|
h1,
|
||||||
.btn-quiet {
|
h2,
|
||||||
background: var(--glass-tint-2);
|
h3,
|
||||||
border-color: var(--glass-edge);
|
p,
|
||||||
color: var(--ink);
|
figure,
|
||||||
}
|
blockquote,
|
||||||
.btn-quiet:hover { background: var(--glass-tint); color: var(--ink); }
|
dl,
|
||||||
|
dd {
|
||||||
/* ---- 그리드 ------------------------------------------------ */
|
|
||||||
.page {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns:
|
|
||||||
[full-start] minmax(var(--gutter), 1fr)
|
|
||||||
[wide-start] minmax(0, var(--wide-pad))
|
|
||||||
[main-start] minmax(0, var(--measure)) [main-end]
|
|
||||||
minmax(0, var(--wide-pad)) [wide-end]
|
|
||||||
minmax(var(--gutter), 1fr) [full-end];
|
|
||||||
}
|
|
||||||
.page > * { grid-column: main; }
|
|
||||||
.page > .wide { grid-column: wide; }
|
|
||||||
.page > .full { grid-column: full; }
|
|
||||||
|
|
||||||
.section {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: subgrid;
|
|
||||||
grid-column: full;
|
|
||||||
padding-block: var(--space-7);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.section > * { grid-column: main; }
|
|
||||||
.section > .wide { grid-column: wide; }
|
|
||||||
.section > .full { grid-column: full; }
|
|
||||||
|
|
||||||
.page > *,
|
|
||||||
.section > * { min-width: 0; }
|
|
||||||
|
|
||||||
/* ---- 타이포 ------------------------------------------------ */
|
|
||||||
h1, h2, h3 {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: var(--leading-tight);
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: var(--leading-heading);
|
||||||
letter-spacing: var(--tracking-display);
|
letter-spacing: var(--tracking-display);
|
||||||
font-weight: var(--weight-strong);
|
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 { font-size: var(--display); }
|
h1 { font-size: var(--display); }
|
||||||
h2 { font-size: var(--step-3); }
|
h2 { font-size: var(--step-3); }
|
||||||
|
|
||||||
/* 섹션 제목만 본문 정렬선 왼쪽으로 나간다. 같은 양을 페이지 전체에서 반복한다 */
|
|
||||||
@media (min-width: 1100px) {
|
|
||||||
.section > h2,
|
|
||||||
.section > .eyebrow,
|
|
||||||
.section > .head { margin-left: calc(var(--space-6) * -1); }
|
|
||||||
}
|
|
||||||
h3 { font-size: var(--step-1); }
|
h3 { font-size: var(--step-1); }
|
||||||
|
|
||||||
|
strong,
|
||||||
p { margin: 0 0 var(--space-3); text-wrap: pretty; }
|
b {
|
||||||
p:last-child { margin-bottom: 0; }
|
font-weight: var(--weight-strong);
|
||||||
|
|
||||||
/* 제목 다음에 오는 것은 반드시 떨어진다.
|
|
||||||
h2 는 margin: 0 이고 p 도 margin-top: 0 이라 제목과 본문이 그대로 붙었다.
|
|
||||||
섹션마다 0 이거나 24px 로 갈려 있었다 — 값을 각자 붙이면 이렇게 된다.
|
|
||||||
|
|
||||||
`:where()` 로 감싸면 특이도가 0 이라 위의 `p` 규칙에 진다. 실제로 한 번 그렇게
|
|
||||||
써서 아무 효과가 없었다. 특이도를 갖추고, `p` 뒤에 둔다.
|
|
||||||
|
|
||||||
제목을 `.head` 로 감싼 섹션에서는 인접 형제가 끊긴다 — 수치 섹션이 그래서
|
|
||||||
혼자 0px 이었다. 제목 묶음도 같은 규칙에 넣는다. */
|
|
||||||
h1 + *,
|
|
||||||
h2 + *,
|
|
||||||
h3 + *,
|
|
||||||
.head + * { margin-top: var(--space-4); }
|
|
||||||
|
|
||||||
.lead { font-size: var(--step-1); color: var(--ink-muted); }
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
font-size: var(--step-0);
|
|
||||||
letter-spacing: var(--tracking-label);
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: var(--weight-medium);
|
|
||||||
margin: 0 0 var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
code, kbd, samp, pre {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--mono-size);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-underline-offset: 0.2em;
|
text-decoration-thickness: 0.07em;
|
||||||
text-decoration-thickness: from-font;
|
text-underline-offset: 0.18em;
|
||||||
transition: color var(--dur-instant) var(--ease-out);
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
summary {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg,
|
||||||
|
video,
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--mono-size);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
top: var(--space-3);
|
||||||
|
left: var(--gutter);
|
||||||
|
min-height: var(--control-h);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-inline: var(--control-pad-x);
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--ink-inverse);
|
||||||
|
transform: translateY(calc(-100% - var(--space-5)));
|
||||||
|
transition: transform var(--dur-quick) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip:focus {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
a:hover { color: var(--accent); }
|
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--focus);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
[id] {
|
||||||
유리
|
scroll-margin-top: calc(var(--header-h) + var(--space-5));
|
||||||
backdrop-filter 만 쓰면 흐린 유리에서 끝난다. 두께와 굴절이
|
}
|
||||||
있어야 유리로 읽힌다. 그래서 세 겹을 쌓는다.
|
|
||||||
1) 배경 흐림 + 채도 (backdrop-filter)
|
.shell {
|
||||||
2) 가장자리 빛 (border + inset shadow)
|
width: min(calc(100% - (2 * var(--gutter))), var(--container));
|
||||||
3) 위쪽 반사 (::before 그라디언트)
|
margin-inline: auto;
|
||||||
굴절은 지원되는 브라우저에서만 backdrop-filter 에 SVG 를 얹는다.
|
}
|
||||||
============================================================ */
|
|
||||||
.glass {
|
.reading-shell {
|
||||||
|
width: min(calc(100% - (2 * var(--gutter))), var(--container-reading));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
background: var(--glass-tint);
|
min-height: var(--control-h);
|
||||||
border: 1px solid var(--glass-edge);
|
display: inline-flex;
|
||||||
border-radius: var(--radius-frame);
|
align-items: center;
|
||||||
box-shadow: var(--glass-hi), var(--glass-bloom), var(--shadow-2);
|
justify-content: center;
|
||||||
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
|
gap: var(--space-2);
|
||||||
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-sat));
|
padding: var(--space-2) var(--control-pad-x);
|
||||||
|
border: var(--line-width) solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
line-height: 1.2;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--dur-instant) var(--ease-out);
|
||||||
}
|
}
|
||||||
/* 위쪽 모서리에 닿는 빛. 유리판의 두께가 여기서 보인다 */
|
|
||||||
.glass::before {
|
.btn::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -1px;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: inherit;
|
||||||
|
box-shadow: var(--shadow-small);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--dur-instant) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover,
|
||||||
|
.btn:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover::after,
|
||||||
|
.btn:focus-visible::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0) scale(0.985);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-solid {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-quiet {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
border-color: transparent;
|
||||||
|
padding-inline: var(--space-2);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
min-height: var(--control-h-sm);
|
||||||
|
padding-inline: var(--space-4);
|
||||||
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
border: var(--line-width) solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grain {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grain::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
border-radius: inherit;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: var(--glass-sheen);
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.72' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.45'/%3E%3C/svg%3E");
|
||||||
}
|
mix-blend-mode: multiply;
|
||||||
/* backdrop-filter 에 SVG 굴절을 얹는 것은 Chrome 에서 실제로 동작한다.
|
opacity: 0.025;
|
||||||
한때 "Chrome 이 선언을 버린다" 고 적어뒀지만 재측정에서 틀린 것으로 드러났다 —
|
|
||||||
computed 값은 url(#id) 로 유지되고 배경도 굴절된다.
|
|
||||||
여기에 아직 얹지 않은 이유는 다른 것이다: Chrome 전용이고, feImage 크기를
|
|
||||||
요소 크기에 맞춰 리사이즈마다 다시 만들어야 한다. 헤더처럼 크기가 변하는
|
|
||||||
요소에는 비용이 맞지 않는다. 자세한 기법은 references/svg-filters.md. */
|
|
||||||
|
|
||||||
/* ---- 테두리를 도는 빛 --------------------------------------
|
|
||||||
`conic-gradient` 의 각도를 `@property` 로 애니메이션하면 매 프레임 배경을
|
|
||||||
다시 칠한다 — 컴포지터에서 못 돌고 메인 스레드가 페인트한다(하드 게이트 #8).
|
|
||||||
|
|
||||||
대신 **정사각형 원뿔 레이어를 마스크 안에서 회전**시킨다. 움직이는 것은
|
|
||||||
`rotate` 뿐이라 GPU 에서 돈다. 정사각형이라 회전해도 밝기가 균일하고,
|
|
||||||
요소보다 크게(200%) 잡아 납작한 헤더에서도 모서리까지 닿는다. */
|
|
||||||
.rim {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: inherit;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
padding: 1px;
|
|
||||||
-webkit-mask:
|
|
||||||
linear-gradient(black 0 0) content-box,
|
|
||||||
linear-gradient(black 0 0);
|
|
||||||
-webkit-mask-composite: xor;
|
|
||||||
mask:
|
|
||||||
linear-gradient(black 0 0) content-box,
|
|
||||||
linear-gradient(black 0 0);
|
|
||||||
mask-composite: exclude;
|
|
||||||
}
|
|
||||||
.rim::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 200%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
translate: -50% -50%;
|
|
||||||
background: conic-gradient(
|
|
||||||
from 0deg,
|
|
||||||
transparent 0 58%,
|
|
||||||
color-mix(in oklab, var(--accent) 70%, white) 74%,
|
|
||||||
transparent 86%
|
|
||||||
);
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.rim::before {
|
|
||||||
animation: rim-spin 11s linear infinite;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes rim-spin {
|
|
||||||
to { rotate: 360deg; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 가장자리를 도는 빛. 유리판 테두리의 굴절을 흉내낸다 */
|
details > summary {
|
||||||
.glass::after {
|
cursor: pointer;
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: inherit;
|
|
||||||
pointer-events: none;
|
|
||||||
padding: 1px;
|
|
||||||
background: var(--glass-rim);
|
|
||||||
/* 아래 black 은 색이 아니라 마스크의 알파다. 팔레트와 무관하다 */
|
|
||||||
-webkit-mask:
|
|
||||||
linear-gradient(black 0 0) content-box,
|
|
||||||
linear-gradient(black 0 0);
|
|
||||||
-webkit-mask-composite: xor;
|
|
||||||
mask:
|
|
||||||
linear-gradient(black 0 0) content-box,
|
|
||||||
linear-gradient(black 0 0);
|
|
||||||
mask-composite: exclude;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 투명도를 줄여달라는 요청에 불투명한 판을 깔면 그냥 상자가 된다.
|
@media (max-width: 40rem) {
|
||||||
배경에 가까운 톤으로 낮추고 형태는 테두리와 그림자가 유지한다. */
|
.btn {
|
||||||
@media (prefers-reduced-transparency: reduce) {
|
max-width: 100%;
|
||||||
.glass {
|
|
||||||
background: color-mix(in oklab, var(--surface) 84%, var(--ink));
|
|
||||||
backdrop-filter: none;
|
|
||||||
-webkit-backdrop-filter: none;
|
|
||||||
}
|
|
||||||
/* 떠 있는 헤더는 배경과 더 가깝게. 여기 판이 깔리면 즉시 눈에 띈다 */
|
|
||||||
.site-header .glass {
|
|
||||||
background: color-mix(in oklab, var(--surface) 93%, var(--ink));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@supports not (backdrop-filter: blur(1px)) {
|
|
||||||
.glass { background: var(--surface-raised); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 유리 위에 얹는 두 번째 판. 안쪽으로 갈수록 밝아진다 */
|
|
||||||
.glass-2 { background: var(--glass-tint-2); }
|
|
||||||
|
|
||||||
/* 액자. 이중 베젤을 버렸다 — 껍데기와 속의 radius 가 서로 달라
|
|
||||||
그것만으로 형태 어휘가 두 종 늘어난다(하드 게이트 #3).
|
|
||||||
그리고 안이 불투명한 이미지면 backdrop-filter 는 아무것도 하지 않는다.
|
|
||||||
유리로 읽히게 하는 것은 테두리 광과 위쪽 반사지 흐림이 아니다. */
|
|
||||||
.frame {
|
|
||||||
position: relative;
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--glass-edge);
|
|
||||||
border-radius: var(--radius-frame);
|
|
||||||
box-shadow: var(--glass-hi), var(--core-hi), var(--shadow-2);
|
|
||||||
overflow: clip;
|
|
||||||
}
|
|
||||||
/* 이미지를 담는 액자만. 인라인 여백을 지운다 */
|
|
||||||
.frame > :is(img, picture, canvas) { display: block; width: 100%; height: auto; }
|
|
||||||
|
|
||||||
/* ---- 진입 연출 ----------------------------------------------
|
|
||||||
opacity 로 콘텐츠를 숨기지 않는다.
|
|
||||||
|
|
||||||
이 페이지에서 두 번 데였다. 처음엔 scroll-driven animation 의
|
|
||||||
범위 끝에 하단 요소가 도달하지 못해 6단계 중 3개가 영영 숨었고,
|
|
||||||
두 번째는 IntersectionObserver 가 부드러운 스크롤 중간 지점을
|
|
||||||
건너뛰어 50개 중 49개가 숨은 채로 남았다.
|
|
||||||
|
|
||||||
그래서 규칙을 바꿨다. 움직이는 것은 위치뿐이다. 최악의 경우에도
|
|
||||||
요소가 14px 아래에 있을 뿐이고 글자는 항상 읽힌다. */
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
.reveal {
|
|
||||||
transform: translateY(14px);
|
|
||||||
transition: transform var(--dur-slow) var(--ease-soft);
|
|
||||||
transition-delay: var(--reveal-delay, 0ms);
|
|
||||||
}
|
|
||||||
.reveal.is-in { transform: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 상호작용 모션 ------------------------------------------
|
|
||||||
모션은 이유가 있어야 붙인다. 여기 있는 것은 전부 피드백이다.
|
|
||||||
"지금 이 카드를 보고 있다", "이 버튼은 눌린다"를 말한다.
|
|
||||||
transform 과 opacity 만 움직인다(하드 게이트 8). */
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
/* 유리 카드는 커서 아래에서 아주 조금 떠오른다 */
|
|
||||||
.lift {
|
|
||||||
transition:
|
|
||||||
transform var(--dur-quick) var(--ease-out),
|
|
||||||
box-shadow var(--dur-quick) var(--ease-out);
|
|
||||||
}
|
|
||||||
.lift:hover {
|
|
||||||
transform: translate3d(0, -3px, 0);
|
|
||||||
box-shadow: var(--glass-hi), var(--shadow-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 링크 밑줄이 커서 쪽에서 자란다 */
|
|
||||||
.link-grow {
|
|
||||||
position: relative;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.link-grow::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: -3px;
|
|
||||||
height: 1px;
|
|
||||||
background: currentColor;
|
|
||||||
transform: scaleX(0);
|
|
||||||
transform-origin: left;
|
|
||||||
transition: transform var(--dur-quick) var(--ease-out);
|
|
||||||
}
|
|
||||||
.link-grow:hover::after { transform: scaleX(1); }
|
|
||||||
|
|
||||||
/* 쇼케이스 판은 들여다보면 조금 다가온다 */
|
|
||||||
.zoom-in { transition: transform var(--dur-normal) var(--ease-out); }
|
|
||||||
.zoom-in:hover { transform: scale(1.012); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 패럴랙스 -----------------------------------------------
|
|
||||||
scroll-driven animation 으로만 만든다. scroll 리스너는 쓰지 않는다.
|
|
||||||
움직이는 것은 transform 뿐이라 지원되지 않는 브라우저에서는
|
|
||||||
요소가 제자리에 그대로 있다. 콘텐츠가 사라질 수 없다. */
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
@supports (animation-timeline: view()) {
|
|
||||||
.par-slow,
|
|
||||||
.par-fast,
|
|
||||||
.par-deep,
|
|
||||||
.par-lag {
|
|
||||||
animation: par linear both;
|
|
||||||
animation-timeline: view();
|
|
||||||
animation-range: cover 0% cover 100%;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
.par-slow { --par-from: 26px; --par-to: -26px; }
|
|
||||||
.par-fast { --par-from: 58px; --par-to: -58px; }
|
|
||||||
.par-deep { --par-from: 92px; --par-to: -92px; }
|
|
||||||
/* 짝이 되는 요소보다 조금 늦게 따라온다.
|
|
||||||
옆의 것은 고정이고 이것만 움직여야 '저것 때문에 이것'으로 읽힌다. */
|
|
||||||
.par-lag { --par-from: var(--par-lag, 14px); --par-to: calc(var(--par-lag, 14px) * -1); }
|
|
||||||
@keyframes par {
|
|
||||||
from { transform: translate3d(0, var(--par-from), 0); }
|
|
||||||
to { transform: translate3d(0, var(--par-to), 0); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 히어로는 페이지 맨 위라 view() 의 entry 구간이 이미 지나 있다.
|
@media (prefers-reduced-motion: reduce) {
|
||||||
그래서 문서 스크롤을 직접 타임라인으로 쓴다 — 시작점이 확정된다.
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
글자는 올라가고 배경은 내려간다. 서로 반대로 움직여야 시차가 보인다.
|
|
||||||
같은 방향으로 속도만 다르게 하면 그냥 느린 스크롤로 읽힌다. */
|
|
||||||
@supports (animation-timeline: scroll()) {
|
|
||||||
.hero-grid,
|
|
||||||
.hero-bg {
|
|
||||||
animation: linear both;
|
|
||||||
animation-timeline: scroll(root block);
|
|
||||||
animation-range: 0 92vh;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
.hero-grid { animation-name: hero-rise; }
|
|
||||||
.hero-bg { animation-name: hero-sink; }
|
|
||||||
|
|
||||||
@keyframes hero-rise {
|
|
||||||
from { transform: translate3d(0, 0, 0); }
|
|
||||||
to { transform: translate3d(0, -5.5rem, 0); }
|
|
||||||
}
|
|
||||||
@keyframes hero-sink {
|
|
||||||
from { transform: translate3d(0, 0, 0); }
|
|
||||||
to { transform: translate3d(0, 3.5rem, 0); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 배경 레이어 --------------------------------------------
|
*,
|
||||||
패럴랙스는 깊이 차가 보여야 성립한다. 배경이 단색이면 앞의 것이
|
*::before,
|
||||||
몇 px 어긋나 봐야 아무도 못 느낀다 — 기준면이 없기 때문이다.
|
*::after {
|
||||||
|
animation-duration: 1ms !important;
|
||||||
기준면은 `BgPhoto` 컴포넌트가 만든다.
|
animation-iteration-count: 1 !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
CSS 로 그린 격자를 한동안 같이 깔았는데 걷어냈다. 사진이 들어온 뒤로는
|
|
||||||
배경이 두 겹이 되어 서로 경쟁했고, 격자는 배경이라기보다 표로 읽혔다.
|
|
||||||
배경도 한 섹션에 하나여야 한다. */
|
|
||||||
.has-bg {
|
|
||||||
position: relative;
|
|
||||||
isolation: isolate;
|
|
||||||
/* `clip-path: inset(0)` 는 두 가지를 동시에 한다.
|
|
||||||
1) 배경이 섹션 밖으로 넘치지 않게 자른다
|
|
||||||
2) `position: fixed` 자식의 **컨테이닝 블록**이 된다
|
|
||||||
|
|
||||||
2번이 핵심이다. 그래서 배경이 뷰포트에 고정되면서도 이 섹션 안에서만 보인다 —
|
|
||||||
콘텐츠만 그 위를 지나가고 배경은 원경처럼 멈춰 있다.
|
|
||||||
|
|
||||||
`background-attachment: fixed` 를 쓰지 않은 이유는 iOS Safari 가 GPU 메모리
|
|
||||||
때문에 그것을 throttle 해서 스크롤 중 배경이 튀기 때문이다.
|
|
||||||
|
|
||||||
주의: 여기에 `transform`·`filter`·`mask` 를 추가하면 안 된다. 그것들도
|
|
||||||
컨테이닝 블록을 만들지만, 그 순간 fixed 가 섹션 기준이 되어 고정이 사라진다. */
|
|
||||||
clip-path: inset(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 유틸 -------------------------------------------------- */
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px; height: 1px;
|
|
||||||
padding: 0; margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip-path: inset(50%);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted { color: var(--ink-muted); }
|
|
||||||
.mono { font-family: var(--font-mono); font-size: var(--mono-size); }
|
|
||||||
.nums { font-variant-numeric: tabular-nums; }
|
|
||||||
.table-scroll { overflow-x: auto; min-width: 0; }
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@
|
||||||
font-display: swap 을 쓰는 한 반드시 일어난다.
|
font-display: swap 을 쓰는 한 반드시 일어난다.
|
||||||
|
|
||||||
그래서 폴백 폰트를 실제 폰트의 치수에 맞춘다.
|
그래서 폴백 폰트를 실제 폰트의 치수에 맞춘다.
|
||||||
아래 숫자는 이 페이지에서 직접 잰 값이다. 추정치가 아니다.
|
아래 폭은 이 페이지의 실제 카피를 100px/400으로 렌더해 잰 값이다.
|
||||||
|
|
||||||
한글 Pretendard 1434px vs 맑은 고딕 1620px -> 88.5%
|
한글 Wanted Sans 3305.28px vs 맑은 고딕 3879.55px -> 85.2%
|
||||||
Apple SD Gothic Neo 1510px -> 94.9%
|
Apple SD Gothic Neo 3526.11px -> 93.7%
|
||||||
영문 Switzer 1580px vs Arial 1501px -> 105.3%
|
영문 Wanted Sans 3572.08px vs Arial 3490.73px -> 102.3%
|
||||||
|
|
||||||
재는 법은 references/typography.md §4 에 있다.
|
재는 법은 references/typography.md §4 에 있다.
|
||||||
OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의한다. 하나로 뭉치면
|
OS 마다 시스템 폰트가 다르므로 폴백도 따로 정의한다. 하나로 뭉치면
|
||||||
|
|
@ -21,9 +21,9 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "KO Fallback Win";
|
font-family: "KO Fallback Win";
|
||||||
src: local("Malgun Gothic"), local("맑은 고딕");
|
src: local("Malgun Gothic"), local("맑은 고딕");
|
||||||
size-adjust: 88.5%;
|
size-adjust: 85.2%;
|
||||||
ascent-override: 108%;
|
ascent-override: 111%;
|
||||||
descent-override: 27%;
|
descent-override: 29%;
|
||||||
line-gap-override: 0%;
|
line-gap-override: 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,8 +31,8 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "KO Fallback Mac";
|
font-family: "KO Fallback Mac";
|
||||||
src: local("Apple SD Gothic Neo"), local("AppleSDGothicNeo-Regular");
|
src: local("Apple SD Gothic Neo"), local("AppleSDGothicNeo-Regular");
|
||||||
size-adjust: 94.9%;
|
size-adjust: 93.7%;
|
||||||
ascent-override: 100%;
|
ascent-override: 102%;
|
||||||
descent-override: 25%;
|
descent-override: 25%;
|
||||||
line-gap-override: 0%;
|
line-gap-override: 0%;
|
||||||
}
|
}
|
||||||
|
|
@ -41,8 +41,8 @@
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "EN Fallback";
|
font-family: "EN Fallback";
|
||||||
src: local("Arial"), local("Helvetica Neue"), local("Helvetica");
|
src: local("Arial"), local("Helvetica Neue"), local("Helvetica");
|
||||||
size-adjust: 105.3%;
|
size-adjust: 102.3%;
|
||||||
ascent-override: 110%;
|
ascent-override: 92%;
|
||||||
descent-override: 28%;
|
descent-override: 24%;
|
||||||
line-gap-override: 0%;
|
line-gap-override: 0%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,255 +1,90 @@
|
||||||
/* ============================================================
|
|
||||||
designpaca 소개 페이지 — 디자인 토큰
|
|
||||||
프리셋: dark-instrument · 컨셉: 검사대 위의 물체
|
|
||||||
|
|
||||||
다크를 "멋있어서" 고르지 않았다. 제품이 CLI 이고 사용자가 이
|
|
||||||
화면을 터미널 옆에 두고 본다. 프리셋이 요구하는 근거다.
|
|
||||||
|
|
||||||
유리는 액자에만 쓴다. 액자 안은 3D, 액자 밖은 글자 —
|
|
||||||
R2(lusion.co) 실측에서 가져온 원리다. 둘을 겹치면 3D 는 얼룩이
|
|
||||||
되고 글자는 대비를 잃는다. 실제로 한 번 그렇게 만들었다.
|
|
||||||
|
|
||||||
전체 결정 근거는 design.md.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Wanted Sans Variable is the brand face for Korean and Latin.
|
||||||
|
The system stack remains only as a failure-safe fallback. */
|
||||||
|
--font-body: "Wanted Sans Variable", "Wanted Sans", "KO Fallback Win", "KO Fallback Mac", "EN Fallback", sans-serif;
|
||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
--mono-size: 0.9375em;
|
|
||||||
|
|
||||||
/* ---- 바탕: 잉크. 순흑이 아니다 --------------------------- */
|
/* Major-third scale with one display size outside the scale. */
|
||||||
--surface: #0B0E12;
|
--step--1: clamp(0.75rem, 0.73rem + 0.08vw, 0.8125rem);
|
||||||
--surface-raised: #11151B;
|
--step-0: clamp(1rem, 0.96rem + 0.18vw, 1.125rem);
|
||||||
--surface-sunken: #070A0D;
|
--step-1: clamp(1.2rem, 1.13rem + 0.3vw, 1.375rem);
|
||||||
/* 밝은 밴드 하나. 규칙으로 정의된 반전이다 */
|
--step-2: clamp(1.5rem, 1.34rem + 0.68vw, 1.875rem);
|
||||||
--surface-inverse: #F4F2EC;
|
--step-3: clamp(2rem, 1.71rem + 1.2vw, 2.75rem);
|
||||||
|
--display: clamp(3rem, 2.35rem + 2.1vw, 3.875rem);
|
||||||
--ink: #ECEFF2; /* 15.9:1 */
|
--leading-heading: 1.16;
|
||||||
--ink-muted: #98A2AE; /* 7.1:1 */
|
--leading-normal: 1.72;
|
||||||
--ink-inverse: #14181E;
|
--measure-narrow: 31rem;
|
||||||
--ink-muted-inverse: #565E68;
|
--tracking-display: -0.012em;
|
||||||
|
|
||||||
--line: #222932;
|
|
||||||
--line-strong: #39424E;
|
|
||||||
--line-inverse: #D8D4CA;
|
|
||||||
|
|
||||||
/* 강조: 시안 틸. 유리 너머에서도 살아남는 채도 */
|
|
||||||
--accent: #4FD6C4; /* 잉크 위 10.4:1 */
|
|
||||||
--accent-ink: #04211D;
|
|
||||||
--accent-inverse: #0E7A6E;
|
|
||||||
--accent-ink-inverse: #FFFFFF;
|
|
||||||
/* 셰이더가 쓰는 두 번째 광색. 강조색이 아니라 광원이다.
|
|
||||||
보라기를 뺐다 — #6E7BFF 는 R 이 높아 보라-파랑으로 읽히고,
|
|
||||||
그것이 antipatterns.md 의 슬롭 지문이자 dark-instrument 가
|
|
||||||
"어두운 배경일수록 빠지기 쉬운 함정"으로 지목한 색이다. */
|
|
||||||
--glow-2: #3D7BD6;
|
|
||||||
|
|
||||||
/* ---- 유리 ------------------------------------------------
|
|
||||||
유리는 액자에만 쓴다. 헤더 · 검사대 프레임 · 게이트 배지.
|
|
||||||
콘텐츠 카드에는 쓰지 않는다 — 유리는 무언가를 담을 때만
|
|
||||||
의미가 있고, 아무 데나 바르면 그냥 반투명한 상자다.
|
|
||||||
(R2 lusion.co 실측: 3D 를 액자에 가두고 텍스트는 액자 밖에 둔다) */
|
|
||||||
--glass-tint: rgb(255 255 255 / 0.045);
|
|
||||||
--glass-tint-2: rgb(255 255 255 / 0.085);
|
|
||||||
--glass-edge: rgb(255 255 255 / 0.14);
|
|
||||||
--glass-blur: 22px;
|
|
||||||
--glass-sat: 150%;
|
|
||||||
/* 안쪽 위 모서리에 닿는 빛. 유리 두께를 만든다 */
|
|
||||||
--glass-hi:
|
|
||||||
inset 0 1px 0 rgb(255 255 255 / 0.22),
|
|
||||||
inset 0 -1px 0 rgb(255 255 255 / 0.05);
|
|
||||||
/* 표면 반사와 테두리 광. 값이 아니라 토큰으로 둬야 밝은 밴드에서
|
|
||||||
같은 자리를 뒤집을 수 있다 (하드 게이트 #1) */
|
|
||||||
--glass-sheen: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
rgb(255 255 255 / 0.10) 0%,
|
|
||||||
rgb(255 255 255 / 0.02) 22%,
|
|
||||||
transparent 55%
|
|
||||||
);
|
|
||||||
/* 테두리 광.
|
|
||||||
conic-gradient 를 쓰면 납작한 형태(예: 1180x54 헤더)에서 각도가 급변해
|
|
||||||
변마다 광이 뚝 끊긴다 — 실제로 헤더 아래쪽만 절반 밝았다.
|
|
||||||
선형으로 바꾸면 네 변이 같은 그라디언트를 공유해 테두리를 따라 이어진다. */
|
|
||||||
--glass-rim: linear-gradient(
|
|
||||||
100deg,
|
|
||||||
rgb(255 255 255 / 0.34) 0%,
|
|
||||||
rgb(255 255 255 / 0.10) 26%,
|
|
||||||
rgb(255 255 255 / 0.06) 58%,
|
|
||||||
rgb(255 255 255 / 0.28) 100%
|
|
||||||
);
|
|
||||||
/* 테두리에서 아주 얕게 번지는 빛. 강조색을 실어 유리가 차갑게만 보이지 않게 한다 */
|
|
||||||
--glass-bloom: 0 0 22px -8px color-mix(in oklab, var(--accent) 42%, transparent);
|
|
||||||
--core-hi: inset 0 1px 0 rgb(255 255 255 / 0.06);
|
|
||||||
|
|
||||||
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.5);
|
|
||||||
--shadow-2: 0 2px 6px rgb(0 0 0 / 0.4), 0 12px 32px rgb(0 0 0 / 0.5);
|
|
||||||
--shadow-3: 0 6px 16px rgb(0 0 0 / 0.45), 0 32px 80px rgb(0 0 0 / 0.6);
|
|
||||||
--inset-hi: var(--glass-hi);
|
|
||||||
|
|
||||||
/* ---- 형태 ------------------------------------------------
|
|
||||||
두 종류뿐이다. 하드 게이트 #3 은 3종부터 실패로 본다.
|
|
||||||
|
|
||||||
3px — 요소. R1 zed.dev 실측에서 border-radius 2px 가 621회로
|
|
||||||
압도적이었다. 개발자 도구는 각져야 정밀해 보인다
|
|
||||||
14px — 액자. R2 lusion.co 가 3D 캔버스를 감싼 값이 15px 이다
|
|
||||||
|
|
||||||
pill(999px) 을 없앴다. 계기판에 알약은 없다. */
|
|
||||||
--radius-sm: 3px;
|
|
||||||
--radius-frame: 14px;
|
|
||||||
|
|
||||||
/* ---- 웨이트 ----------------------------------------------
|
|
||||||
세 개뿐이다. 코드 곳곳에 500·560·600·620·660 이 흩어져 있었고
|
|
||||||
그것이 이 페이지의 SSOT 가 깨진 가장 큰 자리였다.
|
|
||||||
|
|
||||||
R1(zed.dev) 모바일 실측: font-weight 400 이 832회, 나머지 전부 합쳐 12회다.
|
|
||||||
위계를 웨이트로 만들지 않는다 — 크기와 색으로 만든다.
|
|
||||||
가변폰트는 660 같은 중간값도 그려주지만, 표준 축(400/500/600)을 벗어나면
|
|
||||||
힌팅이 흐려지고 폴백 폰트에서는 아예 다른 굵기로 떨어진다. */
|
|
||||||
--weight-body: 400;
|
--weight-body: 400;
|
||||||
--weight-medium: 500;
|
--weight-medium: 470;
|
||||||
--weight-strong: 600;
|
--weight-strong: 520;
|
||||||
|
--mono-size: 0.8125rem;
|
||||||
|
|
||||||
/* ---- 컨트롤 ----------------------------------------------
|
/* Cold paper, graphite, one signal red. */
|
||||||
버튼처럼 생긴 것은 전부 이 값을 쓴다. 예외를 만들지 마라.
|
--surface: #f2f1eb;
|
||||||
R1 실측: Download / Clone 두 버튼이 h36·pad-x10·r4·w400·14px 로
|
--surface-raised: #faf9f4;
|
||||||
픽셀 단위까지 같았다. 우리는 높이도 패딩도 웨이트도 제각각이었다. */
|
--surface-soft: #e7e8e1;
|
||||||
--control-h: 2.75rem; /* 44px — 터치 타깃 */
|
--surface-inverse: #17231f;
|
||||||
--control-h-sm: 2.25rem; /* 36px — 헤더 등 조밀한 자리 */
|
--ink: #171b19;
|
||||||
--control-pad-x: var(--space-3);
|
--ink-muted: #5e655f;
|
||||||
|
--ink-inverse: #f6f5ef;
|
||||||
|
--ink-muted-inverse: #bfc8c1;
|
||||||
|
--line: #cdd0c7;
|
||||||
|
--line-strong: #9ba39c;
|
||||||
|
--accent: #c43f20;
|
||||||
|
--accent-ink: #faf9f4;
|
||||||
|
--focus: #1267d6;
|
||||||
|
|
||||||
/* ---- 간격 ------------------------------------------------
|
/* One rhythm. No ad-hoc intermediate padding values. */
|
||||||
작은 값은 고정, 큰 값만 화면에 반응한다.
|
|
||||||
고정으로 두면 모바일에서 섹션 상하 112px 이 그대로 들어가
|
|
||||||
화면 높이의 26% 를 여백이 먹는다. 실제로 그랬다. */
|
|
||||||
--space-1: 0.25rem;
|
--space-1: 0.25rem;
|
||||||
--space-2: 0.5rem;
|
--space-2: 0.5rem;
|
||||||
--space-3: 1rem;
|
--space-3: 0.75rem;
|
||||||
--space-4: 1.5rem;
|
--space-4: 1rem;
|
||||||
--space-5: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
|
--space-5: 1.5rem;
|
||||||
--space-6: clamp(2.5rem, 1.6rem + 2.6vw, 4rem);
|
--space-6: 2rem;
|
||||||
--space-7: clamp(3.5rem, 1.9rem + 4.6vw, 7rem);
|
--space-7: clamp(2.5rem, 2rem + 2vw, 4rem);
|
||||||
--space-8: clamp(5rem, 2.6rem + 6.8vw, 10rem);
|
--space-8: clamp(4rem, 2.8rem + 4vw, 7rem);
|
||||||
|
--space-9: clamp(6rem, 4rem + 6vw, 10rem);
|
||||||
|
--gutter: clamp(1.25rem, 0.72rem + 2.2vw, 3rem);
|
||||||
|
--container: 80rem;
|
||||||
|
--container-reading: 68rem;
|
||||||
|
|
||||||
/* 좌우 여백. R1(zed.dev) 모바일 실측이 32px 이었다 — 우리는 24px 였다.
|
--radius-sm: 0.1875rem;
|
||||||
모바일 전 구간 32px 을 깔고 넓은 화면에서만 40px 까지 연다. */
|
--line-width: 1px;
|
||||||
--gutter: clamp(2rem, 1.4rem + 1.8vw, 2.5rem);
|
|
||||||
|
|
||||||
/* 배경 격자의 한 칸. 간격 스케일에서 가져온다 —
|
--control-h: 2.75rem;
|
||||||
장식용 격자가 아니라 이 페이지가 실제로 쓰는 눈금이어야
|
--control-h-sm: 2.75rem;
|
||||||
"모든 값이 토큰에서 나온다"는 주장이 배경에서도 성립한다. */
|
--control-pad-x: 1.125rem;
|
||||||
--grid-cell: var(--space-6);
|
--header-h: 4.5rem;
|
||||||
|
|
||||||
/* main 칼럼 바깥의 여유 폭(wide 트랙). 넓은 화면에서 제목과 넓은 블록이
|
--dur-instant: 100ms;
|
||||||
본문보다 밖으로 나갈 자리를 만든다.
|
--dur-entry: 60ms;
|
||||||
이 값을 고정으로 두면 모바일에서도 좌우 80px 씩 살아남아, 390px 화면에서
|
--dur-quick: 180ms;
|
||||||
본문이 x=112 부터 시작하고 폭이 278px 로 줄어든다 — 제목이 네 줄로 접힌다.
|
--dur-normal: 320ms;
|
||||||
좁은 화면에서는 0 으로 닫는다. */
|
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--wide-pad: clamp(0rem, -3.7rem + 15.2vw, 10rem);
|
--ease-soft: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
/* 헤더가 차지하는 높이. 떠 있는 헤더라 문서 흐름에서 빠져 있고,
|
--shadow-small: 0 1px 1px rgb(23 27 25 / 0.05), 0 8px 24px rgb(23 27 25 / 0.06);
|
||||||
그래서 히어로 상단 여백과 앵커 목적지가 이 값을 알아야 한다.
|
|
||||||
간격 토큰을 clamp 로 바꾸는 순간 이 관계가 끊겨 h1 이 헤더 뒤로
|
|
||||||
들어갔다 — 값이 아니라 관계를 토큰으로 둬야 하는 이유다. */
|
|
||||||
--header-h: calc(var(--space-3) + var(--space-2) * 2 + var(--control-h-sm));
|
|
||||||
|
|
||||||
/* ---- 모션 ------------------------------------------------ */
|
--line-inverse: rgb(246 245 239 / 0.28);
|
||||||
--dur-instant: 120ms;
|
|
||||||
--dur-quick: 280ms;
|
|
||||||
--dur-normal: 560ms;
|
|
||||||
--dur-slow: 900ms;
|
|
||||||
|
|
||||||
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
|
||||||
--ease-soft: cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 라이트 선호: 유리를 얇게 하고 바탕을 올린다 --------------
|
html[lang="en"] {
|
||||||
테마를 뒤집지 않는다. 이 페이지는 어두운 유리가 주제다.
|
--measure-narrow: 56ch;
|
||||||
다만 라이트를 쓰는 사람에게 대비를 조금 더 준다. */
|
--leading-normal: 1.62;
|
||||||
@media (prefers-color-scheme: light) {
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
:root {
|
:root {
|
||||||
--surface: #12161C;
|
--header-h: 6.75rem;
|
||||||
--surface-raised: #181D25;
|
|
||||||
--glass-tint: rgb(255 255 255 / 0.07);
|
|
||||||
--glass-tint-2: rgb(255 255 255 / 0.11);
|
|
||||||
--glass-edge: rgb(255 255 255 / 0.18);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 타입: 한국어판 --------------------------------------- */
|
@media (prefers-reduced-motion: reduce) {
|
||||||
[lang="ko"] {
|
:root {
|
||||||
--font-sans: "Pretendard Variable", Pretendard,
|
--dur-instant: 1ms;
|
||||||
"KO Fallback Win", "KO Fallback Mac", system-ui, sans-serif;
|
--dur-entry: 1ms;
|
||||||
|
--dur-quick: 1ms;
|
||||||
--step--1: 0.8125rem;
|
--dur-normal: 1ms;
|
||||||
--step-0: 1.0625rem;
|
|
||||||
--step-1: 1.328rem;
|
|
||||||
--step-2: 1.660rem;
|
|
||||||
--step-3: 2.076rem;
|
|
||||||
--step-4: 2.595rem;
|
|
||||||
/* 페이지에서 가장 큰 글자. 히어로가 아니라 수치 섹션이 쓴다 —
|
|
||||||
히어로의 힘은 타이포가 아니라 검사대 액자에서 나온다 */
|
|
||||||
--display: clamp(3rem, 1.2rem + 7.2vw, 7rem);
|
|
||||||
|
|
||||||
--leading-tight: 1.06;
|
|
||||||
--leading-normal: 1.7;
|
|
||||||
--measure: 33em;
|
|
||||||
|
|
||||||
--tracking-display: -0.02em;
|
|
||||||
--tracking-body: 0;
|
|
||||||
--tracking-label: 0.08em;
|
|
||||||
|
|
||||||
word-break: keep-all;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 타입: 영어판 ------------------------------------------ */
|
|
||||||
[lang="en"] {
|
|
||||||
--font-sans: "Switzer", "EN Fallback", system-ui, -apple-system, sans-serif;
|
|
||||||
|
|
||||||
--step--1: 0.8125rem;
|
|
||||||
--step-0: 1rem;
|
|
||||||
--step-1: 1.25rem;
|
|
||||||
--step-2: 1.5625rem;
|
|
||||||
--step-3: 1.953rem;
|
|
||||||
--step-4: 2.441rem;
|
|
||||||
--display: clamp(2.8rem, 1rem + 7vw, 6.6rem);
|
|
||||||
|
|
||||||
--leading-tight: 0.98;
|
|
||||||
--leading-normal: 1.55;
|
|
||||||
--measure: 65ch;
|
|
||||||
|
|
||||||
--tracking-display: -0.04em;
|
|
||||||
--tracking-body: 0;
|
|
||||||
--tracking-label: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 밝은 밴드: 규칙으로 정의된 단 한 번의 반전 --------------
|
|
||||||
사용처는 조판 섹션 하나뿐이다. 한글이 종이 위에 있어야 하는
|
|
||||||
주장을 하는 자리라 거기서만 바탕이 밝아진다. */
|
|
||||||
.band-inverse {
|
|
||||||
--surface: var(--surface-inverse);
|
|
||||||
--surface-raised: #ECE8DE;
|
|
||||||
--surface-sunken: #E2DED3;
|
|
||||||
--ink: var(--ink-inverse);
|
|
||||||
--ink-muted: var(--ink-muted-inverse);
|
|
||||||
--line: var(--line-inverse);
|
|
||||||
--line-strong: var(--ink-muted-inverse);
|
|
||||||
--accent: var(--accent-inverse);
|
|
||||||
--accent-ink: var(--accent-ink-inverse);
|
|
||||||
--glass-tint: rgb(20 24 30 / 0.04);
|
|
||||||
--glass-tint-2: rgb(20 24 30 / 0.07);
|
|
||||||
--glass-edge: rgb(20 24 30 / 0.12);
|
|
||||||
--glass-hi: inset 0 1px 0 rgb(255 255 255 / 0.8);
|
|
||||||
--inset-hi: var(--glass-hi);
|
|
||||||
/* 밝은 밴드에서는 테두리 광의 방향이 뒤집힌다. 흰빛 대신 그림자로 이어붙인다 */
|
|
||||||
--glass-rim: linear-gradient(
|
|
||||||
100deg,
|
|
||||||
rgb(20 24 30 / 0.16) 0%,
|
|
||||||
rgb(20 24 30 / 0.05) 26%,
|
|
||||||
rgb(20 24 30 / 0.04) 58%,
|
|
||||||
rgb(20 24 30 / 0.14) 100%
|
|
||||||
);
|
|
||||||
--glass-bloom: 0 0 18px -10px color-mix(in oklab, var(--ink) 30%, transparent);
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
apps/site/tools/capture-site-og.mjs
Normal file
53
apps/site/tools/capture-site-og.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import url from "node:url";
|
||||||
|
|
||||||
|
import puppeteer from "puppeteer-core";
|
||||||
|
|
||||||
|
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||||
|
const siteRoot = path.resolve(here, "..");
|
||||||
|
const origin = process.env.DESIGNPACA_PREVIEW_ORIGIN || "http://localhost:4321";
|
||||||
|
const chromeCandidates = [
|
||||||
|
process.env.CHROME_PATH,
|
||||||
|
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||||
|
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
||||||
|
"/usr/bin/google-chrome",
|
||||||
|
"/usr/bin/chromium",
|
||||||
|
];
|
||||||
|
const executablePath = chromeCandidates.find((candidate) => candidate && fs.existsSync(candidate));
|
||||||
|
|
||||||
|
if (!executablePath) {
|
||||||
|
throw new Error("Chrome/Chromium executable not found. Set CHROME_PATH before capturing site OG images.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const captures = [
|
||||||
|
{ path: "/", output: "src/assets/og-designpaca-ko.png" },
|
||||||
|
{ path: "/en/", output: "src/assets/og-designpaca-en.png" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath,
|
||||||
|
headless: true,
|
||||||
|
args: ["--force-color-profile=srgb", "--no-first-run", "--no-default-browser-check"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 1 });
|
||||||
|
await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]);
|
||||||
|
|
||||||
|
for (const capture of captures) {
|
||||||
|
const target = new URL(capture.path, origin).href;
|
||||||
|
const response = await page.goto(target, { waitUntil: "networkidle0", timeout: 30_000 });
|
||||||
|
if (!response?.ok()) throw new Error(`Failed to load ${target}: ${response?.status() ?? "no response"}`);
|
||||||
|
await page.evaluate(() => document.fonts.ready);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(siteRoot, capture.output),
|
||||||
|
type: "png",
|
||||||
|
captureBeyondViewport: false,
|
||||||
|
});
|
||||||
|
console.log(`${capture.output} · 1200x630`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
/**
|
|
||||||
* 방문자에게 보이는 한국어 본문을 존댓말로 바꾼다.
|
|
||||||
*
|
|
||||||
* 제목(h1·h2·name·rule·caught)은 건드리지 않는다 — 단정문이 카피로서 힘을 갖는 자리다.
|
|
||||||
* 바꾸는 것은 **설명하는 문장**과 **명령형**이다.
|
|
||||||
* 방문자에게 "~해라" 라고 말하는 화면은 무례하다.
|
|
||||||
*/
|
|
||||||
import fs from "node:fs";
|
|
||||||
|
|
||||||
const FILE = "src/i18n/content.ts";
|
|
||||||
|
|
||||||
/** 이 키의 값만 바꾼다. 나머지는 그대로 둔다 */
|
|
||||||
const TARGET_KEYS = new Set([
|
|
||||||
"lead", "note", "how", "problem", "use", "what", "does", "becomes", "made", "alt", "sample",
|
|
||||||
"why", "fixed", "caught", "caveat", "installNote", "description", "verdict", "caption",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** 순서가 중요하다. 긴 패턴을 먼저 잡지 않으면 짧은 것이 먼저 먹는다 */
|
|
||||||
const RULES = [
|
|
||||||
// ── 명령형 — 방문자에게 명령하지 않는다 ──
|
|
||||||
[/해라\./g, "하세요."], [/해라$/g, "하세요"],
|
|
||||||
[/봐라\./g, "보세요."], [/봐라$/g, "보세요"],
|
|
||||||
[/마라\./g, "마세요."], [/마라$/g, "마세요"],
|
|
||||||
[/써라\./g, "쓰세요."], [/써라$/g, "쓰세요"],
|
|
||||||
[/가라\./g, "가세요."], [/골라라/g, "고르세요"],
|
|
||||||
|
|
||||||
// ── 서술 ──
|
|
||||||
[/하지 않는다/g, "하지 않습니다"],
|
|
||||||
[/되지 않는다/g, "되지 않습니다"],
|
|
||||||
[/지 않는다/g, "지 않습니다"],
|
|
||||||
[/않는다/g, "않습니다"],
|
|
||||||
[/것이 아니다/g, "것이 아닙니다"],
|
|
||||||
[/아니다/g, "아닙니다"],
|
|
||||||
[/것이다/g, "것입니다"],
|
|
||||||
[/뿐이다/g, "뿐입니다"],
|
|
||||||
[/때문이다/g, "때문입니다"],
|
|
||||||
[/없어진다/g, "없어집니다"],
|
|
||||||
[/사라진다/g, "사라집니다"],
|
|
||||||
[/달라진다/g, "달라집니다"],
|
|
||||||
[/남는다/g, "남습니다"],
|
|
||||||
[/걸린다/g, "걸립니다"],
|
|
||||||
[/나온다/g, "나옵니다"],
|
|
||||||
[/들어간다/g, "들어갑니다"],
|
|
||||||
[/올라간다/g, "올라갑니다"],
|
|
||||||
[/떨어진다/g, "떨어집니다"],
|
|
||||||
[/무너진다/g, "무너집니다"],
|
|
||||||
[/바뀐다/g, "바뀝니다"],
|
|
||||||
[/이어진다/g, "이어집니다"],
|
|
||||||
[/생긴다/g, "생깁니다"],
|
|
||||||
[/보인다/g, "보입니다"],
|
|
||||||
[/읽힌다/g, "읽힙니다"],
|
|
||||||
[/잡힌다/g, "잡힙니다"],
|
|
||||||
[/쓰인다/g, "쓰입니다"],
|
|
||||||
[/맡긴다/g, "맡깁니다"],
|
|
||||||
[/만든다/g, "만듭니다"],
|
|
||||||
[/막는다/g, "막습니다"],
|
|
||||||
[/줄인다/g, "줄입니다"],
|
|
||||||
[/센다/g, "셉니다"],
|
|
||||||
[/짠다/g, "짭니다"],
|
|
||||||
[/뺀다/g, "뺍니다"],
|
|
||||||
[/둔다/g, "둡니다"],
|
|
||||||
[/한다/g, "합니다"],
|
|
||||||
[/된다/g, "됩니다"],
|
|
||||||
[/있다/g, "있습니다"],
|
|
||||||
[/없다/g, "없습니다"],
|
|
||||||
[/같다/g, "같습니다"],
|
|
||||||
[/좁다/g, "좁습니다"],
|
|
||||||
[/넓다/g, "넓습니다"],
|
|
||||||
[/맞다/g, "맞습니다"],
|
|
||||||
[/낫다/g, "낫습니다"],
|
|
||||||
[/멈춘다/g, "멈춥니다"],
|
|
||||||
[/이긴다/g, "이깁니다"],
|
|
||||||
[/모른다/g, "모릅니다"],
|
|
||||||
[/받는다/g, "받습니다"],
|
|
||||||
[/걸었다/g, "걸었습니다"],
|
|
||||||
[/바꿨다/g, "바꿨습니다"],
|
|
||||||
[/했다/g, "했습니다"],
|
|
||||||
[/였다/g, "였습니다"],
|
|
||||||
[/었다/g, "었습니다"],
|
|
||||||
[/았다/g, "았습니다"],
|
|
||||||
[/린다/g, "립니다"],
|
|
||||||
[/난다/g, "납니다"],
|
|
||||||
[/쓴다/g, "씁니다"],
|
|
||||||
[/본다/g, "봅니다"],
|
|
||||||
[/간다/g, "갑니다"],
|
|
||||||
[/온다/g, "옵니다"],
|
|
||||||
[/골라준다/g, "골라줍니다"],
|
|
||||||
[/고른다/g, "고릅니다"],
|
|
||||||
[/넘긴다/g, "넘깁니다"],
|
|
||||||
[/채운다/g, "채웁니다"],
|
|
||||||
[/압축한다/g, "압축합니다"],
|
|
||||||
[/판정한다/g, "판정합니다"],
|
|
||||||
[/정한다/g, "정합니다"],
|
|
||||||
[/확정한다/g, "확정합니다"],
|
|
||||||
[/구분한다/g, "구분합니다"],
|
|
||||||
[/합성한다/g, "합성합니다"],
|
|
||||||
[/내린다/g, "내립니다"],
|
|
||||||
[/열린다/g, "열립니다"],
|
|
||||||
[/붙는다/g, "붙습니다"],
|
|
||||||
[/넣는다/g, "넣습니다"],
|
|
||||||
[/놓는다/g, "놓습니다"],
|
|
||||||
[/찾는다/g, "찾습니다"],
|
|
||||||
[/맞춘다/g, "맞춥니다"],
|
|
||||||
[/갖춘다/g, "갖춥니다"],
|
|
||||||
[/낮춘다/g, "낮춥니다"],
|
|
||||||
[/높인다/g, "높입니다"],
|
|
||||||
[/모은다/g, "모읍니다"],
|
|
||||||
[/버린다/g, "버립니다"],
|
|
||||||
[/살린다/g, "살립니다"],
|
|
||||||
[/시킨다/g, "시킵니다"],
|
|
||||||
[/지킨다/g, "지킵니다"],
|
|
||||||
[/적는다/g, "적습니다"],
|
|
||||||
[/삼는다/g, "삼습니다"],
|
|
||||||
[/남긴다/g, "남깁니다"],
|
|
||||||
[/옮긴다/g, "옮깁니다"],
|
|
||||||
[/뒤집는다/g, "뒤집습니다"],
|
|
||||||
[/따른다/g, "따릅니다"],
|
|
||||||
[/부른다/g, "부릅니다"],
|
|
||||||
[/누른다/g, "누릅니다"],
|
|
||||||
[/자른다/g, "자릅니다"],
|
|
||||||
[/오른다/g, "오릅니다"],
|
|
||||||
[/미룬다/g, "미룹니다"],
|
|
||||||
[/켠다/g, "켭니다"],
|
|
||||||
[/편다/g, "폅니다"],
|
|
||||||
[/센다/g, "셉니다"],
|
|
||||||
[/건다/g, "겁니다"],
|
|
||||||
[/푼다/g, "풉니다"],
|
|
||||||
[/꾼다/g, "꿉니다"],
|
|
||||||
[/띈다/g, "띕니다"],
|
|
||||||
[/진다/g, "집니다"],
|
|
||||||
[/킨다/g, "킵니다"],
|
|
||||||
[/힌다/g, "힙니다"],
|
|
||||||
[/빈다/g, "빕니다"],
|
|
||||||
[/친다/g, "칩니다"],
|
|
||||||
[/잘라낸다/g, "잘라냅니다"],
|
|
||||||
[/잘린다/g, "잘립니다"],
|
|
||||||
[/뭉개진다/g, "뭉개집니다"],
|
|
||||||
[/건너뛴다/g, "건너뜁니다"],
|
|
||||||
[/깎는다/g, "깎습니다"],
|
|
||||||
[/죽는다/g, "죽습니다"],
|
|
||||||
[/잰다/g, "잽니다"],
|
|
||||||
[/낸다/g, "냅니다"],
|
|
||||||
[/긴다/g, "깁니다"],
|
|
||||||
[/없앴다/g, "없앴습니다"],
|
|
||||||
[/걷어냈다/g, "걷어냈습니다"],
|
|
||||||
[/남겼다/g, "남겼습니다"],
|
|
||||||
[/뿌렸다/g, "뿌렸습니다"],
|
|
||||||
[/넘쳤다/g, "넘쳤습니다"],
|
|
||||||
[/늘렸다/g, "늘렸습니다"],
|
|
||||||
[/신호다/g, "신호입니다"],
|
|
||||||
[/결과다/g, "결과입니다"],
|
|
||||||
[/길다/g, "깁니다"],
|
|
||||||
[/그대로였다/g, "그대로였습니다"],
|
|
||||||
[/보였다/g, "보였습니다"],
|
|
||||||
[/튀고 있었다/g, "튀고 있었습니다"],
|
|
||||||
[/어긋나 있었다/g, "어긋나 있었습니다"],
|
|
||||||
[/적었는데/g, "적었는데"],
|
|
||||||
];
|
|
||||||
|
|
||||||
function polite(text) {
|
|
||||||
let out = text;
|
|
||||||
for (const [re, to] of RULES) out = out.replace(re, to);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const src = fs.readFileSync(FILE, "utf8");
|
|
||||||
// STAGES_KO 는 `export const ko` 밖에 따로 선언돼 있다. 범위를 거기서 시작하지 않으면
|
|
||||||
// 파이프라인 여섯 단계의 설명이 통째로 빠진다 — 실측에서 37개가 반말로 남아 있었다.
|
|
||||||
const koStart = src.indexOf("const STAGES_KO");
|
|
||||||
const koEnd = src.indexOf("const STAGES_EN");
|
|
||||||
const koStart2 = src.indexOf("export const ko");
|
|
||||||
const koEnd2 = src.indexOf("export const en");
|
|
||||||
let ko = src.slice(koStart, koEnd);
|
|
||||||
let ko2 = src.slice(koStart2, koEnd2);
|
|
||||||
|
|
||||||
let changed = 0;
|
|
||||||
// `key: "..."` 와 `key:\n "..."` 를 모두 잡는다
|
|
||||||
ko = ko.replace(/(\b[a-zA-Z]+):(\s*)"((?:[^"\\]|\\.)*)"/g, (m, key, gap, val) => {
|
|
||||||
if (!TARGET_KEYS.has(key)) return m;
|
|
||||||
const next = polite(val);
|
|
||||||
if (next !== val) changed++;
|
|
||||||
return `${key}:${gap}"${next}"`;
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(FILE, src.slice(0, koStart) + ko + src.slice(koEnd), "utf8");
|
|
||||||
console.log(`존댓말 변환: ${changed}개 문자열`);
|
|
||||||
2206
apps/site/tools/site-e2e.mjs
Normal file
2206
apps/site/tools/site-e2e.mjs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue