diff --git a/apps/site/design.md b/apps/site/design.md index f2fd47e..143f667 100644 --- a/apps/site/design.md +++ b/apps/site/design.md @@ -1158,3 +1158,57 @@ Pretendard `dynamic-subset` 은 유니코드 범위별로 파일을 쪼개고 - 공개·하위 화면 실렌더 감사: 320·390·768·1440에서 **8/8 통과**. axe, HTML, CSS, JS, 한자 검사도 통과했다. - 갤러리 카드 네 장과 공유 이미지 두 장은 확정 화면을 실제 Chrome으로 다시 캡처했다. - 구현 중 발견한 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 단언으로 고정한다. diff --git a/apps/site/package.json b/apps/site/package.json index 1c408da..49691ad 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -12,6 +12,7 @@ "test:e2e:twentyoz": "node tools/twentyoz-e2e.mjs", "test:e2e:showcase-duo": "node tools/showcase-duo-e2e.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", "build": "astro build", "postbuild": "node tools/no-hanja.mjs --root dist", diff --git a/apps/site/src/assets/og-designpaca-en.png b/apps/site/src/assets/og-designpaca-en.png new file mode 100644 index 0000000..ab162fe Binary files /dev/null and b/apps/site/src/assets/og-designpaca-en.png differ diff --git a/apps/site/src/assets/og-designpaca-ko.png b/apps/site/src/assets/og-designpaca-ko.png new file mode 100644 index 0000000..e1fa9e4 Binary files /dev/null and b/apps/site/src/assets/og-designpaca-ko.png differ diff --git a/apps/site/src/components/BgPhoto.astro b/apps/site/src/components/BgPhoto.astro deleted file mode 100644 index a1ee278..0000000 --- a/apps/site/src/components/BgPhoto.astro +++ /dev/null @@ -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 으로 딱 자른다. -*/} - - - diff --git a/apps/site/src/components/CopyCommand.astro b/apps/site/src/components/CopyCommand.astro index 07dbc32..b369b29 100644 --- a/apps/site/src/components/CopyCommand.astro +++ b/apps/site/src/components/CopyCommand.astro @@ -5,99 +5,133 @@ interface Props { copiedLabel: string; note?: string; } + const { command, copyLabel, copiedLabel, note } = Astro.props; --- -{/* 명령어를 담는 액자. 유리를 쓰는 세 자리 중 하나다 */} -
+
- {command} -
+
{note &&

{note}

} diff --git a/apps/site/src/components/FilterArt.astro b/apps/site/src/components/FilterArt.astro deleted file mode 100644 index d3ff84c..0000000 --- a/apps/site/src/components/FilterArt.astro +++ /dev/null @@ -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" && ( -
- - 산미 8.5 · 로스팅 2일차 -
- ) -} - -{ - kind === "cvd" && ( -
- 통과 - 주의 - 실패 -
- ) -} - -{ - kind === "goo" && ( -
- {/* 같은 간격으로 놓인 세 덩어리. 왼쪽은 셋으로 보이고 오른쪽은 하나로 보인다. - 움직임으로 증명하려다 실패했다 — 지나가는 프레임은 방문자도 놓치고 - 측정도 매번 다른 값을 준다. 정지된 두 장이 더 정확하게 말한다. */} -
- -
-
- ) -} - -{ - kind === "glass" && ( -
- - {/* 격자를 함께 깐다. 굴절은 직선이 휘어야 보인다 — - 사진만 놓으면 무엇이 어떻게 밀렸는지 알 수 없다 */} - - 산미 8.5 -
- ) -} - - diff --git a/apps/site/src/components/Hero.astro b/apps/site/src/components/Hero.astro deleted file mode 100644 index 4e22573..0000000 --- a/apps/site/src/components/Hero.astro +++ /dev/null @@ -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 이라 그나마 보이는 것과도 싸웠다. 둘 다 고쳤다. -*/} -
- - -
-

{c.h1}

- -
-

{c.lead}

- - -
-
-
- - - - - diff --git a/apps/site/src/components/HeroSystemGraphic.astro b/apps/site/src/components/HeroSystemGraphic.astro new file mode 100644 index 0000000..ec46a55 --- /dev/null +++ b/apps/site/src/components/HeroSystemGraphic.astro @@ -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" + : "브리프 · 레퍼런스 · 방향 · 토큰 · 구현 · 프리플라이트 · 결과"; +--- + +
+ + {title} + {description} + + + + + + + + + + + + + + + + + + + +
{caption}
+
+ + diff --git a/apps/site/src/components/Install.astro b/apps/site/src/components/Install.astro deleted file mode 100644 index 6722408..0000000 --- a/apps/site/src/components/Install.astro +++ /dev/null @@ -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; ---- - - -
- -
-
-

{c.h2}

-

{c.lead}

- -
- -
-
    - { - c.targets.map((t) => ( -
  • - {t.name} - {t.path} -
  • - )) - } -
- -
- { - c.commands.map((x) => ( -
-
{x.cmd}
-
{x.what}
-
- )) - } -
-
-
-
- - diff --git a/apps/site/src/components/Landing.astro b/apps/site/src/components/Landing.astro new file mode 100644 index 0000000..8653e96 --- /dev/null +++ b/apps/site/src/components/Landing.astro @@ -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"; +--- + +
+
+
+

+ {c.hero.h1.split("\n").map((line) => {line})} +

+

{c.hero.lead}

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

{c.outputs.h2}

+
+

{c.outputs.lead}

+ + {c.outputs.archiveCta} + + +
+
+ +
+ +
+
+ +
+
+
+

{c.process.h2}

+

{c.process.lead}

+
+ +
    + {c.process.stages.map((stage) => ( +
  1. + {stage.step} +

    {stage.name}

    +

    {stage.result}

    +
  2. + ))} +
+
+
+ +
+
+

{c.proof.h2}

+

{c.proof.lead}

+
+ +
+ {c.proof.facts.map((fact) => ( +
+
+ {fact.value} + {fact.label} +
+
{fact.description}
+
+ ))} +
+ +
+ + {c.proof.details.summary} + + +
    + {c.proof.details.items.map((item) =>
  1. {item}
  2. )} +
+
+
+ +
+
+
+

{c.install.h2}

+

{c.install.lead}

+
+
+ +
    + {c.install.support.map((item) => ( +
  • + + {item} +
  • + ))} +
+ +
+
+
+ + + + diff --git a/apps/site/src/components/Materials.astro b/apps/site/src/components/Materials.astro deleted file mode 100644 index 07d37fc..0000000 --- a/apps/site/src/components/Materials.astro +++ /dev/null @@ -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 = - `` + - `` + - `` + - `` + - `` + - `` + - `` + - ``; - 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)"); ---- - -{/* - 이 섹션은 한동안 "필터로 재질 만들기" 였다 — 추상 사각형에 그레인·굴절·듀오톤. - 화면에서 아무 말도 하지 않았다. 필터가 실제로 값을 하는 자리는 질감이 아니라 - **실무에서 반복되는 네 가지 수작업**이고, 넷 다 이 스킬의 프리플라이트 항목이다. - 왼쪽이 문제, 오른쪽이 같은 소재에 선언 하나만 얹은 것이다. -*/} -
- -

{c.eyebrow}

-

{c.h2}

-

{c.lead}

- -
- - - {/* GPU 가 만드는 배경. WebGL 이 없으면 위 그라디언트가 그대로 결과물이다 */} - - -
    - { - c.demos.map((d, i) => ( -
  1. -

    {d.name}

    - -
    - {[false, true].map((on) => ( -
    - -
    - {on ? d.afterLabel : d.beforeLabel} -
    -
    - ))} -
    - -
    -

    {d.problem}

    -

    {d.how}

    -

    {d.verdict}

    -
    -
  2. - )) - } -
-
-
- -{/* - 필터 정의. 화면에 그려지지 않고 참조만 된다. - - 전부 color-interpolation-filters 를 명시한다. 기본값은 linearRGB 인데, - 블렌드와 합성이 씻겨 보인다 — 대부분 sRGB 가 맞다. - 단 하나, 색각 시뮬레이션은 반대다. 색각 변환은 물리적인 빛의 혼합이라 - 선형 공간에서 계산해야 실제로 그 사람이 보는 색이 나온다. -*/} - - - diff --git a/apps/site/src/components/Metrics.astro b/apps/site/src/components/Metrics.astro deleted file mode 100644 index 8dc2b17..0000000 --- a/apps/site/src/components/Metrics.astro +++ /dev/null @@ -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 이 진짜 위계를 나를 때만 쓴다는 규칙을 여기서 지킨다. -*/} -{/* - 이 섹션에는 패럴랙스를 넣지 않는다. 빠뜨린 것이 아니라 결정이다. - - 여기가 이 페이지의 수치를 공개하는 자리다. 숫자가 스크롤에 따라 - 흔들리면 그 값도 흔들리는 것처럼 읽힌다. 계기판의 바늘은 떨지 않는다. - 움직임이 의미를 만드는 자리가 있으면, 움직이지 않는 것이 의미인 자리도 있다. -*/} -
-
-

{c.eyebrow}

-

{c.h2}

-
-

{c.lead}

- -
- { - feature.map((m, i) => ( -
- {m.value} - {m.label} - {m.note} -
- )) - } - -
- { - rest.map((m, i) => ( -
-
{m.label}
-
{m.value}
-
{m.note}
-
- )) - } -
-
- -

{c.caveat}

-
- - diff --git a/apps/site/src/components/OutputSpecimens.astro b/apps/site/src/components/OutputSpecimens.astro new file mode 100644 index 0000000..478a8a3 --- /dev/null +++ b/apps/site/src/components/OutputSpecimens.astro @@ -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: "완료 전에 같은 기준을 좁은 화면, 중간 화면, 넓은 화면에서 실제 수치로 검사합니다.", + }, + }; +--- + +
+

{copy.title}

+ +
+
+ +

{copy.direction.name}

+
+ +
+ + +
    + {copy.direction.axes.map((axis, index) => ( +
  1. + + {axis} +
  2. + ))} +
+ +

{copy.direction.statement}

+
+

{copy.direction.note}

+
+ +
+
+ +

{copy.tokens.name}

+
+ +
+ + + + +
+ {copy.tokens.roles.map((role, index) => ( +
+
{role}
+
+ + {copy.tokens.values[index]} +
+
+ ))} +
+
+

{copy.tokens.note}

+
+ +
+
+ +

{copy.proof.name}

+
+ +
+ + +
    + {copy.proof.checks.map((check, index) => ( +
  1. + + {check} + +
  2. + ))} +
+
+

{copy.proof.note}

+
+
+ + diff --git a/apps/site/src/components/Pipeline.astro b/apps/site/src/components/Pipeline.astro deleted file mode 100644 index c2b0d72..0000000 --- a/apps/site/src/components/Pipeline.astro +++ /dev/null @@ -1,274 +0,0 @@ ---- -import type { Content } from "../i18n/content"; - -interface Props { - c: Content["pipeline"]; -} -const { c } = Astro.props; ---- - -{/* - 순서를 번호 없이 표현하는 세 층: - ① 시각 — 스파인 위의 위치가 순서를 만든다 (Beck 튜브맵) - ② 내용 — 같은 브리프가 단계마다 변해간다 (IKEA 조립 설명서) - ③ 의미 —
    — 화면에 번호는 없지만 보조기술은 "6개 중 n번째"로 읽는다 - role="list" 는 list-style:none 일 때 Safari/VoiceOver 가 목록 의미를 버리는 것을 되돌린다 -*/} -
    - {/* 히어로에 있던 게이트 씬을 여기로 옮겼다. - 히어로에서는 그 은유를 판독기가 자막으로 설명해야 했다 — 그림이 스스로 - 말하지 못한다는 뜻이다. 여기서는 옆에 실제 여섯 단계가 적혀 있고, - 판을 통과할 때 그 단계가 켜진다. 자막이 아니라 콘텐츠가 반응한다. */} - - {/* 제목·리드·브리프·스파인을 한 컨테이너에 담는다. - 섹션 기본 그리드에 두면 제목은 main 칼럼의 음수 마진으로 x=371, - 리드는 x=435, 스파인은 x=143 에 서서 정렬선이 셋이 된다. */} -
    -

    {c.eyebrow}

    -

    {c.h2}

    -

    {c.lead}

    - -
    - {c.briefLabel} -

    {c.brief}

    -
    - -
      - { - c.stages.map((s, i) => ( -
    1. -
      -

      {s.name}

      -

      {s.does}

      -
      -

      {s.becomes}

      -
    2. - )) - } -
    -
    -
    - - - - diff --git a/apps/site/src/components/Preflight.astro b/apps/site/src/components/Preflight.astro deleted file mode 100644 index 0da6a2c..0000000 --- a/apps/site/src/components/Preflight.astro +++ /dev/null @@ -1,81 +0,0 @@ ---- -import BgPhoto from "./BgPhoto.astro"; -import type { Content } from "../i18n/content"; - -interface Props { c: Content["preflight"]; } -const { c } = Astro.props; ---- - -{/* - 레이아웃 패밀리: 로그. - 걸린 것과 고친 것이 한 줄에서 이어진다. 상자에 담지 않는다. - 왼쪽 세로선이 로그라는 형식을 만든다. -*/} -
    - -

    {c.h2}

    -

    {c.lead}

    - -
    - { - c.items.map((i, idx) => ( -
    -

    {i.caught}

    -

    {i.fixed}

    -
    - )) - } -
    - -

    {c.note}

    -
    - - diff --git a/apps/site/src/components/Rules.astro b/apps/site/src/components/Rules.astro deleted file mode 100644 index 471f3c4..0000000 --- a/apps/site/src/components/Rules.astro +++ /dev/null @@ -1,62 +0,0 @@ ---- -import type { Content } from "../i18n/content"; - -interface Props { c: Content["rules"]; } -const { c } = Astro.props; ---- - -{/* - 레이아웃 패밀리: 어긋난 목록. - 규칙은 왼쪽 정렬선에, 근거는 오른쪽으로 밀어 흘린다. - 카드도 상자도 없다. 어긋남 자체가 두 층위를 구분한다. -*/} -
    -

    {c.h2}

    -

    {c.lead}

    - -
    - { - c.items.map((r, i) => ( -
    -
    {r.rule}
    -
    {r.why}
    -
    - )) - } -
    -
    - - diff --git a/apps/site/src/components/ShowcaseHeroCanvas.astro b/apps/site/src/components/ShowcaseHeroCanvas.astro deleted file mode 100644 index 6e0bce1..0000000 --- a/apps/site/src/components/ShowcaseHeroCanvas.astro +++ /dev/null @@ -1,64 +0,0 @@ ---- -interface Props { - class?: string; -} -const { class: className } = Astro.props; ---- - - - - - - diff --git a/apps/site/src/components/Typography.astro b/apps/site/src/components/Typography.astro deleted file mode 100644 index 3a0063d..0000000 --- a/apps/site/src/components/Typography.astro +++ /dev/null @@ -1,129 +0,0 @@ ---- -import BgPhoto from "./BgPhoto.astro"; -import type { Content } from "../i18n/content"; - -interface Props { - c: Content["typography"]; -} -const { c } = Astro.props; ---- - -
    - -

    {c.eyebrow}

    -

    {c.h2}

    -

    {c.lead}

    - - {/* - 주장을 글로 하지 않고 실물로 보여준다. - - 한때 word-break 하나만 두 칼럼으로 비교했는데, 어절이 짧아 두 쪽이 - 같은 자리에서 끊겼다 — 줄 수도 높이도 똑같았다. 차이가 0 인 비교는 - 증명이 아니라 주장의 반증이다. 셋으로 늘리고, 잘리는 긴 어절을 넣고, - 폭을 좁혀 실제로 갈리게 만들었다. - */} -
    - { - c.demos.map((d) => ( -
    -

    {d.title}

    -
    -
    -

    {d.badLabel}

    -

    {c.sample}

    -
    -
    -

    {d.goodLabel}

    -

    {c.sample}

    -
    -
    -

    {d.note}

    -
    - )) - } -
    - -
      - {c.rules.map((r) => (
    • {r}
    • ))} -
    -
    - - diff --git a/apps/site/src/gl/boot.ts b/apps/site/src/gl/boot.ts deleted file mode 100644 index 528993b..0000000 --- a/apps/site/src/gl/boot.ts +++ /dev/null @@ -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 { - 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); - }); -} diff --git a/apps/site/src/gl/scenes/showcase-hero.ts b/apps/site/src/gl/scenes/showcase-hero.ts deleted file mode 100644 index fb3e739..0000000 --- a/apps/site/src/gl/scenes/showcase-hero.ts +++ /dev/null @@ -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(); - }; -} diff --git a/apps/site/src/gl/stage.ts b/apps/site/src/gl/stage.ts deleted file mode 100644 index 03dc018..0000000 --- a/apps/site/src/gl/stage.ts +++ /dev/null @@ -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; -} diff --git a/apps/site/src/i18n/content.ts b/apps/site/src/i18n/content.ts deleted file mode 100644 index 8b0563e..0000000 --- a/apps/site/src/i18n/content.ts +++ /dev/null @@ -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: "

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

    ", - 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: "

    Acidity 8.5 · Body 6.0 · Light roast

    ", - 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" }, -}; diff --git a/apps/site/src/i18n/landing.ts b/apps/site/src/i18n/landing.ts new file mode 100644 index 0000000..f5a9270 --- /dev/null +++ b/apps/site/src/i18n/landing.ts @@ -0,0 +1,346 @@ +type NavItem = Readonly<{ + label: string; + href: Href; +}>; + +type LandingNavItems = readonly [ + NavItem<"#outputs">, + NavItem<"#process">, + NavItem<"#proof">, + NavItem<"#install">, +]; + +type ProcessStage = 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; diff --git a/apps/site/src/i18n/showcase.ts b/apps/site/src/i18n/showcase.ts new file mode 100644 index 0000000..8dd2c81 --- /dev/null +++ b/apps/site/src/i18n/showcase.ts @@ -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; diff --git a/apps/site/src/i18n/work.ts b/apps/site/src/i18n/work.ts new file mode 100644 index 0000000..70026fa --- /dev/null +++ b/apps/site/src/i18n/work.ts @@ -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; + +export type LocalizedWorkItem = Omit & 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; + +const EXPECTED_SLUGS: ReadonlySet = new Set(WORK_SLUGS); + +function assertWorkSlugParity(): void { + const seen = new Set(); + + 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], + })); +} diff --git a/apps/site/src/layouts/Base.astro b/apps/site/src/layouts/Base.astro index dc32c20..ae285b4 100644 --- a/apps/site/src/layouts/Base.astro +++ b/apps/site/src/layouts/Base.astro @@ -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/tokens.css"; import "../styles/base.css"; @@ -8,72 +10,82 @@ interface Props { lang: "ko" | "en"; title: string; description: string; + translationPath?: string | null; } + const { lang, title, description } = Astro.props; - -// 언어별로 자기 폰트만 받는다. 두 벌을 한 페이지에 로드하면 예산을 넘긴다. -const FONT_CSS = { - ko: "https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css", - en: "https://api.fontshare.com/v2/css?f%5B%5D=switzer@400,500,600&display=swap", -} as const; - -const alt = lang === "ko" ? "/en/" : "/"; - -// 헤더 내비게이션. 본문과 같은 콘텐츠 파일에서 온다 — 라벨을 레이아웃에 -// 하드코딩하면 두 언어판이 어긋난다. -const nav = (lang === "ko" ? ko : en).nav; - -// 내비 앵커는 홈의 섹션을 가리킨다. 홈이 아닌 페이지(쇼케이스 등)에서는 -// "#pipeline" 이 그 페이지 안의 없는 앵커가 되어 아무 일도 하지 않는다 — -// 홈 경로를 붙여 홈 섹션으로 건너가게 한다. +const copy = lang === "ko" ? landingKo : landingEn; +const socialPreview = lang === "ko" ? socialPreviewKo : socialPreviewEn; +const socialPreviewAlt = lang === "ko" + ? "designpaca의 일곱 단계 디자인 파이프라인 그래픽" + : "The seven-stage designpaca pipeline graphic"; +const path = Astro.url.pathname; +const normalizedPath = path.replace(/\/+$/, "") || "/"; +const isKoHome = normalizedPath === "/"; +const isEnHome = normalizedPath === "/en"; +const inferredTranslationPath = isKoHome ? "/en/" : isEnHome ? "/" : null; +const translationPath = Object.prototype.hasOwnProperty.call(Astro.props, "translationPath") + ? Astro.props.translationPath + : inferredTranslationPath; const homePath = lang === "ko" ? "/" : "/en/"; -const normalizedPath = Astro.url.pathname.replace(/\/+$/, "") || "/"; -const isHome = normalizedPath === (lang === "ko" ? "/" : "/en"); -const navItems = nav.items.map((i) => ({ - ...i, - href: !isHome && i.href.startsWith("#") ? `${homePath}${i.href}` : i.href, +const showcasePath = lang === "ko" ? "/showcase/" : "/en/showcase/"; +const isHome = isKoHome || isEnHome; +const navItems = copy.nav.items.map((item) => ({ + ...item, + href: isHome ? item.href : `${homePath}${item.href}`, })); - -// 파비콘: 페이지의 스파인 모티프를 그대로 쓴 단순 기하 마크. -// data: URI 안의 SVG 는 상위 문서의 커스텀 프로퍼티를 읽지 못한다. -// 하드 게이트 #1 의 유일한 예외이고, 값은 tokens.css 와 손으로 맞춘다. -// MARK_BG = --accent-inverse · MARK_FG = --surface-inverse -const MARK_BG = "#0E6B63"; -const MARK_FG = "#F4F2EC"; -const FAVICON = - "data:image/svg+xml," + - encodeURIComponent( - ` - - - - - - - `, - ); +const repositoryUrl = "https://git.chanpaca.net/yunchan/designpaca"; +const canonical = new URL(path, Astro.site); +const translated = translationPath ? new URL(translationPath, Astro.site) : null; +const socialPreviewUrl = new URL(socialPreview.src, Astro.site); +const markBg = "%23c43f20"; +const markFg = "%23faf9f4"; +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 schema = isHome + ? { + "@context": "https://schema.org", + "@type": "WebSite", + name: "designpaca", + url: new URL("/", Astro.site).href, + description, + inLanguage: ["ko-KR", "en-US"], + } + : { + "@context": "https://schema.org", + "@type": "WebPage", + name: title, + url: canonical.href, + description, + inLanguage: lang === "ko" ? "ko-KR" : "en-US", + }; --- - + {title} - + + + - - - - + + {translated && !isHome && } + {translated && !isHome && } + {translated && !isHome && } + {isHome && } + {isHome && } + {isHome && } + - + + + - - - {/* JS 가 살아 있으면 no-js 를 떼고 진입 연출을 켠다 */} - + + + + + + + + diff --git a/apps/site/src/lib/gate-scene.ts b/apps/site/src/lib/gate-scene.ts deleted file mode 100644 index 9b53040..0000000 --- a/apps/site/src/lib/gate-scene.ts +++ /dev/null @@ -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 { - 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); - }); -} diff --git a/apps/site/src/lib/shader-plane.ts b/apps/site/src/lib/shader-plane.ts deleted file mode 100644 index 4be7545..0000000 --- a/apps/site/src/lib/shader-plane.ts +++ /dev/null @@ -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; - /** 포인터를 따라가게 할지. 히어로처럼 큰 면적에만 쓴다 */ - 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 { - 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 = { - 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); - }); -} diff --git a/apps/site/src/pages/en/index.astro b/apps/site/src/pages/en/index.astro index 215c9a0..d87eda0 100644 --- a/apps/site/src/pages/en/index.astro +++ b/apps/site/src/pages/en/index.astro @@ -1,37 +1,9 @@ --- import Base from "../../layouts/Base.astro"; -import Hero from "../../components/Hero.astro"; -import Pipeline from "../../components/Pipeline.astro"; -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"; +import Landing from "../../components/Landing.astro"; +import { landingEn as c } from "../../i18n/landing"; --- - - {/* - 순서는 세 묶음이다. 한동안 기능과 증거가 번갈아 나와서 - (사례 → 필터 → 수치 → 감사 → 조판 → 규칙) 읽는 사람이 지금 무슨 이야기를 - 듣고 있는지 놓쳤다. 같은 성격끼리 붙인다. - - ① 무엇인가 히어로 · 파이프라인 - ② 무엇을 아는가 SVG 필터 · 한글 조판 - ③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다) - ④ 행동 설치 - */} - - - - - - - - - - - - + + diff --git a/apps/site/src/pages/en/showcase.astro b/apps/site/src/pages/en/showcase.astro new file mode 100644 index 0000000..ad9eae6 --- /dev/null +++ b/apps/site/src/pages/en/showcase.astro @@ -0,0 +1,5 @@ +--- +import Showcase from "../showcase.astro"; +--- + + diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 11e762f..dcb3cda 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -1,37 +1,9 @@ --- import Base from "../layouts/Base.astro"; -import Hero from "../components/Hero.astro"; -import Pipeline from "../components/Pipeline.astro"; -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"; +import Landing from "../components/Landing.astro"; +import { landingKo as c } from "../i18n/landing"; --- - - {/* - 순서는 세 묶음이다. 한동안 기능과 증거가 번갈아 나와서 - (사례 → 필터 → 수치 → 감사 → 조판 → 규칙) 읽는 사람이 지금 무슨 이야기를 - 듣고 있는지 놓쳤다. 같은 성격끼리 붙인다. - - ① 무엇인가 히어로 · 파이프라인 - ② 무엇을 아는가 SVG 필터 · 한글 조판 - ③ 정말 그런가 수치 · 자기 감사 · 규칙 (사례는 /showcase 로 뺐다) - ④ 행동 설치 - */} - - - - - - - - - - - - + + diff --git a/apps/site/src/pages/showcase.astro b/apps/site/src/pages/showcase.astro index 6e2f60f..e22ceef 100644 --- a/apps/site/src/pages/showcase.astro +++ b/apps/site/src/pages/showcase.astro @@ -1,1271 +1,617 @@ --- import { Image } from "astro:assets"; import Base from "../layouts/Base.astro"; -import ShowcaseHeroCanvas from "../components/ShowcaseHeroCanvas.astro"; -import CopyCommand from "../components/CopyCommand.astro"; -import { WORK } from "../data/work.ts"; +import { getLocalizedWork } from "../i18n/work"; +import { showcaseKo, showcaseEn } from "../i18n/showcase"; -/* - 쇼케이스 아카이브. - 카드는 필수 정보만 싣고, 상세 설계와 지표는 네이티브 모달로 열린다. -*/ -const title = "사례 아카이브 · designpaca"; -const showcaseCount = WORK.length; -const showcaseCountKo = showcaseCount === 21 ? "스물한" : `${showcaseCount}`; -const description = - `템플릿을 거부한 자리에 남는 ${showcaseCountKo} 개의 고유한 인터페이스. 물성과 타이포그래피, 실제 상호작용으로 완성된 라이브 쇼케이스를 탐색하세요.`; -const ariaClose = "닫기"; -const ariaDetail = "자세히 보기"; -const siteLabel = "사이트 보기"; +interface Props { + lang?: "ko" | "en"; +} + +const { lang = "ko" } = Astro.props; +const copy = lang === "ko" ? showcaseKo : showcaseEn; +const works = getLocalizedWork(lang); +const showcaseCount = works.length; +const featuredWork = works[0]; +if (!featuredWork) throw new Error("Showcase requires at least one work item."); +const archiveWorks = works.slice(1); +const repositoryUrl = "https://git.chanpaca.net/yunchan/designpaca"; +const homePath = lang === "ko" ? "/" : "/en/"; +const translationPath = lang === "ko" ? "/en/showcase/" : "/showcase/"; --- - -
    - - -
    -
    -
    - - PROVEN ARCHIVE · {showcaseCount} LIVE SHOWCASES -
    - -

    - 타협 없는 태도가 빚어낸
    - 살아 숨 쉬는 {showcaseCountKo} 개의 결 -

    - -

    - AI가 양산하는 흔한 템플릿과 얄팍한 클리셰를 걷어내고, - 고유한 물성과 타이포그래피, 60fps 인터랙티브 셰이더로 완성한 실사례 아카이브입니다. - 브라우저 위에서 실제로 작동하는 {showcaseCount}가지 웹 공간을 직접 경험해 보세요. -

    + +
    +
    +
    + {copy.hero.label} + {copy.hero.countLabel(showcaseCount)}
    -
    - {/* 실측 지표 & 특징 뱃지 스트립 */} -
    -
    -
    - {showcaseCount} - 라이브 쇼케이스 -
    -
    - 0% - AI 템플릿 슬롭 -
    -
    - 60fps - WebGL2 셰이더 반응 -
    -
    - A11y - 웹 표준 & 접근성 보증 -
    -
    -
    +

    {copy.hero.h1[0]}{copy.hero.h1[1]}

    - {/* 탐색 가이드 바 */} -
    - EXPLORATION GUIDE -

    - 카드를 누르면 설계 철학 & 실측 지표 모달이 열리며, [사이트 보기 ↗]를 통해 새 탭에서 실제 라이브 웹앱을 조작할 수 있습니다. -

    -
    -
    +

    {copy.hero.lead}

    + + + {copy.hero.ctaLabel(showcaseCount)} + +
    -
    -
    -
      +
      + {featuredWork.alt} +
      + {copy.hero.featuredLabel} + {featuredWork.name} · {featuredWork.kind} +
      +
      + + +
      +
      +

      {copy.archive.h2}

      +

      {copy.archive.lead}

      +
      + +
        { - WORK.map((w, i) => ( -
      • -
        -
        + archiveWorks.map((work) => ( +
      • +
        +
        {w.alt} - {w.shotAlt && ( - - )} - {w.preset} - - {/* 카드 이미지 호버 시 나타나는 사이트 바로가기 퀵 액션 */} - - {siteLabel} - -
        -
        -
        -

        {w.name}

        - - - -
        -

        {w.kind}

        -

        {w.concept}

        - +
        +

        {work.kind}

        +

        {work.name}

        - {/* 카드 전체 클릭: 모달 상세 열기 (직접 링크는 z-index가 더 높아 독립 동작) */} -
        - - {/* 상세 모달: 네이티브 (2열 스플릿 + 첫 화면 상단 CTA + 모바일 스티키 독) */} - - -
        - {/* 좌측: 스크린샷 뷰어 */} -
        -
        +
        + + {copy.archive.notesLabel} + + + +
        +
        +
        +
        {copy.archive.directionLabel}
        +
        {work.concept}
        +
        +
        +
        {copy.archive.riskLabel}
        +
        {work.risk}
        +
        +
        + + {work.shotAlt && ( +
        {w.alt}
        -
        - - {/* 우측: 정보 & 상단 주 CTA (스크롤 없이 즉시 노출) */} -
        -
        -
        {w.preset}
        -

        {w.kind}

        -

        {w.name}

        -
        - - {/* 🚀 상단 주 CTA — 모달 열자마자 스크롤 0px로 즉각 클릭 가능 */} -
        - - {siteLabel} - - - {w.subLinks && w.subLinks.length > 0 && ( - - )} - 새 탭에서 라이브 데모 열기 -
        - -
        -

        {w.concept}

        - -
        - 감수한 리스크 -

        {w.risk}

        -
        - -
        - 검증 지표 -
          - {w.metrics.map((m) => ( -
        • {m}
        • - ))} -
        -
        -
        -
        + )}
        - - {/* 모바일 뷰포트용 스티키 바텀 독: 스크롤 중에도 언제든 엄지 영역에 고정 */} - -
        -
        + +
      • )) }
      - -