From e689683b72bd30bacc3ca091e65883c6387d3eaa Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 26 Sep 2026 15:04:43 +0900 Subject: [PATCH] feat(site): rewrite the landing page around verified facts and accessible controls - Replace claims that contradicted the app: the default hotkey is Right Alt (hold to talk), local features are free with no daily cap, there is no 14-day trial, Team/SSO/SCIM/ZDR are not offered, and the repository is not public. Remove invented metrics, status badges, the competitor table, the ad-mediation changelog, the hash calculator and the duplicate demo. - Seven sections: hero with one labelled example, features, how it works, privacy, pricing (Free / Pro 2,900 / Pro+ 8,900 KRW a month), download, FAQ. Footer links the privacy policy, terms and account deletion pages. - All copy moves into i18n and every one of the 10 locales is translated. Pricing and cloud quotas come from site/src/pricing.ts. - Accessibility: skip link, labelled sections, aria-expanded with Esc and focus return for the menu, language list and FAQ, live status for the example, reduced-motion support, 44px targets, AA contrast on the primary button. Korean keep-all line breaking is scoped to :lang(ko) because Tailwind's break-keep blocked wrapping in Japanese and Chinese. - JS 111 -> 98 KB gzip, CSS 7.8 -> 5.2 KB gzip. --- design.md | 36 ++ docs/map/10-feature-catalog.md | 2 +- docs/map/11-gap-backlog.md | 1 + site/index.html | 17 +- site/src/App.tsx | 38 +- site/src/components/Badge.tsx | 26 -- site/src/components/DataPoint.tsx | 22 -- site/src/components/DictationDemo.tsx | 113 ++++++ site/src/components/GlowButton.tsx | 50 --- site/src/components/InstrumentCard.tsx | 25 -- site/src/components/LanguageSwitcher.tsx | 114 +++--- site/src/components/SectionHeader.tsx | 34 -- site/src/components/SectionHeading.tsx | 20 ++ site/src/components/WaveBars.tsx | 55 +-- site/src/components/Wordmark.tsx | 14 + site/src/components/icons.tsx | 49 +++ site/src/components/useReveal.ts | 31 -- site/src/hooks/useClientOS.ts | 118 +----- site/src/hooks/usePrefersReducedMotion.ts | 17 + site/src/i18n/index.ts | 220 ++++++------ site/src/i18n/locales/de.ts | 241 +++++++------ site/src/i18n/locales/en.ts | 241 +++++++------ site/src/i18n/locales/es.ts | 243 ++++++------- site/src/i18n/locales/fr.ts | 243 ++++++------- site/src/i18n/locales/ja.ts | 237 ++++++------ site/src/i18n/locales/ko.ts | 241 +++++++------ site/src/i18n/locales/pt.ts | 247 ++++++------- site/src/i18n/locales/ru.ts | 245 ++++++------- site/src/i18n/locales/vi.ts | 247 ++++++------- site/src/i18n/locales/zh.ts | 235 ++++++------ site/src/index.css | 282 +++++---------- site/src/pricing.ts | 28 ++ site/src/sections/CTA.tsx | 77 ---- site/src/sections/Download.tsx | 420 +++------------------- site/src/sections/FAQ.tsx | 83 ++--- site/src/sections/Features.tsx | 246 +------------ site/src/sections/Footer.tsx | 101 ++---- site/src/sections/Header.tsx | 155 ++++---- site/src/sections/Hero.tsx | 359 ++---------------- site/src/sections/HowItWorks.tsx | 189 +--------- site/src/sections/Pricing.tsx | 355 ++++-------------- site/src/sections/Privacy.tsx | 126 +------ site/src/tokens.ts | 115 +----- site/tailwind.config.js | 22 +- 44 files changed, 2198 insertions(+), 3782 deletions(-) delete mode 100644 site/src/components/Badge.tsx delete mode 100644 site/src/components/DataPoint.tsx create mode 100644 site/src/components/DictationDemo.tsx delete mode 100644 site/src/components/GlowButton.tsx delete mode 100644 site/src/components/InstrumentCard.tsx delete mode 100644 site/src/components/SectionHeader.tsx create mode 100644 site/src/components/SectionHeading.tsx create mode 100644 site/src/components/Wordmark.tsx create mode 100644 site/src/components/icons.tsx delete mode 100644 site/src/components/useReveal.ts create mode 100644 site/src/hooks/usePrefersReducedMotion.ts create mode 100644 site/src/pricing.ts delete mode 100644 site/src/sections/CTA.tsx diff --git a/design.md b/design.md index 78810e1..cef9884 100644 --- a/design.md +++ b/design.md @@ -156,3 +156,39 @@ - AdMediationEngine.setConfig: 음수/0 이하 설정값 검증 추가 - 광고 스위트 652/652 GREEN, 데스크톱 전체 1,242/1,242 GREEN, tsc 0 - 수익화 로드맵: docs/monetization-plan.md (AdMob 프로덕션 ID 발급이 다음 관문) + +## 2026-09-26 랜딩 사이트(site/) 정리 — 진실성 · 접근성 · 단순화 + +연장 경로(`design.md` 방향 유지: swiss-minimal × dark-instrument, 탈볼드, Pretendard). +레퍼런스 1′: superwhisper.com 실측 — 히어로 CTA 2개, 히어로 수치 없음, 가격 3단 "이전 티어 전부 +". + +- 브리프: 무엇을 = Windows 음성 받아쓰기 홍보 사이트 / 누구에게 = 한국어 기본 지식노동자·개발자, + 목표 행동 = **다운로드 1순위 + 구독(Pro/Pro+)** / 제약 = 10개 로케일 전부 번역, 기존 토큰 유지. +- 확정한 사실(사용자): 가격 Free ₩0 · Pro ₩2,900/월 · Pro+ ₩8,900/월(원화, 월간만), 오픈 소스 주장 삭제(저장소 비공개). +- 섹션: 히어로(예시 1개) → 기능 → 작동 방식 → 개인정보 → 요금 → 다운로드 → FAQ → 푸터. + 레이아웃 패밀리: 분할(히어로) / 제목 고정+목록(기능·FAQ) / 4열 번호 행(작동 방식) / 사양서 `dl`(개인정보) / 카드 3장(요금) / 8:4 패널(다운로드). +- 사실 SSOT: 단축키 = `packages/core/src/keybinding.ts`(오른쪽 Alt 누른 채), 사용량 = `packages/core/src/constants.ts` + `PREMIUM_MODEL_LIMITS`, 가격·결제 URL = `site/src/pricing.ts`, 버전·게시일 = `site/src/release.ts`(version:sync 대상). +- 토큰 변경: `brand.blue-deep #1d4ed8` 추가(주 버튼 hover). 주 버튼 채움은 `blue-dark`(흰 글자 5.2:1) — + `blue`(3.7:1)는 본문 크기 버튼 글자에 AA 미달. 그림자는 `chassis`만, 애니메이션은 `glow-pulse`만 남김. +- 컴포넌트 클래스: `.btn/.btn-primary/.btn-secondary`(최소 44px, 줄바꿈 허용), `.kbd`(sans — 한글 키 이름), `.link`, `.skip-link`. +- 한글 줄바꿈 클래스는 `keep-ko`(`:lang(ko)`에서만 keep-all). **Tailwind 내장 `break-keep`을 쓰지 말 것** — + 중국어·일본어에서 줄바꿈 기회를 없애 320px에서 가로 넘침이 났다(실측). +- 접근성: 본문 건너뛰기 링크, 섹션 `aria-labelledby`, 모바일 메뉴·언어 선택 `aria-expanded`+Esc+포커스 복귀, + FAQ 디스클로저(`aria-controls`/`region`), 예시 상태 `aria-live`, 감소 모션(전역 CSS + 예시는 결과로 바로), + 앵커 `scroll-margin-top`, 새 탭 링크 표기, ``·title·description 로케일 추종. +- 검증: 10개 로케일 × 320/390/1440 가로 넘침 0, axe(wcag2a/aa/21aa/22aa/best-practice, ko·ja) 위반 0, + 390 터치 타깃 24px 미만 0, 페이지 길이 1440 10,536→5,062px / 390 14,753→7,826px, + 빌드 JS 111.0→98.0KB gzip, CSS 7.8→5.2KB gzip. + +### 의도적으로 하지 않은 것 +- **히어로 수치·상태 뱃지 없음.** "3배 빠름", "10/10", "<1.2s", "시스템 정상 가동", "프로토콜" 등은 근거가 없어 삭제. 되살리지 말 것. +- **경쟁사 비교표·광고/리워드 문구·SHA-256 계산기·릴리스 히스토리 없음.** 계산기는 비교 기준이 없어 무의미했고, 무결성은 `latest.yml` 링크로 대신한다. +- **설치 파일 크기 표기 없음.** `version:sync`가 갱신하지 않는 값이라 다음 릴리스에 틀린 숫자가 된다. 게시일(동기화 대상)만 쓴다. +- **"인기" 뱃지·연간 요금·통화 토글·Team·14일 체험 없음.** 결제 카탈로그·앱에 근거 없음. +- **기능 카드에 FREE/PRO 태그 없음.** 로컬 기능은 전부 free다(`LicenseService.FEATURE_MIN_TIER`). +- **화자 분리·RAG를 대표 기능으로 내세우지 않음.** 화자 구분은 추정 단계라 약속하지 않는다. +- **CRT 움직이는 스캔 효과 제거**(정지 주사선만 히어로 예시 패널에 유지). 이전 기록의 "CRT CSS 유지"는 예시 패널 한 곳으로 좁혔다. +- **미확정:** 서버 결제 카탈로그(Payple)는 아직 Pro ₩9,900 / Pro+ ₩29,900이다. 사이트 가격과 맞추려면 서버 쪽 변경이 필요하다. + 결제 URL `https://d3ro.chanpaca.net/billing?tier=`는 데스크톱 `payment-handlers.ts`와 같지만, + `license-handlers.ts`는 `https://d3ro.dev`를 쓴다 — 어느 쪽이 정본인지 확인 필요. diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index bfc2921..c38c2a4 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -199,7 +199,7 @@ end-to-end behaviour has **not been verified by typing in a real app** (`11` GAP | SHELL-08 | Auto-update | [x] | [-] | [!] | [-] | Desktop electron-updater; Forgejo canonical feed; channels + mandatory/major-vs-delta policy (`release/update-policy.json`); mobile store updates | | SHELL-09 | Support / diagnostics | [x] | [ ] | [ ] | [~] | Desktop `SupportModal`; admin `/support` stub | | SHELL-10 | Download center / releases | [-] | [x] | [-] | [x] | Web `/download`, admin `/releases`, Forgejo | -| SHELL-11 | Landing site / legal pages | [-] | [-] | [-] | [-] | `site/` — privacy/terms/delete-account live | +| SHELL-11 | Landing site / legal pages | [-] | [-] | [-] | [-] | `site/` — privacy/terms/delete-account live; 2026-09-26 truthfulness/a11y cleanup (facts from code SSOT, pricing `site/src/pricing.ts`, 10 locales fully translated, footer links legal pages) — see `design.md` | | SHELL-12 | Notifications (in-app / desktop) | [x] | [ ] | [x] | [x] | Desktop events; mobile push | --- diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index 06a9ebe..6c93cf0 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -26,6 +26,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res | GAP-REL-08 | Release | 다운로드 센터가 **존재하지 않는 설치 파일**을 가리켰다. `apps/web/src/lib/desktop-release.ts`와 `site/src/release.ts`의 `DESKTOP_VERSION`이 `1.2.0`에 멈춰 있어 설치 URL이 `D3RO-Voice-Setup-1.2.0-x64.exe`였고, 그 경로는 피드에서 404다(실측: 1.2.0=404, 1.3.7=206). `version:sync`가 이 두 표면을 덮지 않아 계속 어긋났다. | `scripts/ci/sync-version.mjs`, `apps/web/src/lib/desktop-release.ts`, `site/src/release.ts` | `[x]` 2026-09-19: 두 다운로드 계약 파일을 `sync-version.mjs` 대상에 추가해 버전·릴리스일이 SSOT에서 자동 반영되도록 하고, 현재 값(1.3.7 / 2026-09-19)으로 정정했다. `version:check`·typecheck·site 빌드 GREEN. | | GAP-REL-09 | Release | 랜딩 사이트가 **재배포되지 않는다**. `deploy` 워크플로가 main push마다 실패한다. 실측 원인(run#66 로그): `site/src/sections/Hero.tsx`가 타이머 ref를 `NodeJS.Timeout`으로 타이핑해 `@types/node` 네임스페이스가 필요했고, 배포 잡은 `npm ci --prefix site`만 하므로 조상 `node_modules`의 hoisted 타입이 없어 `tsc -b`가 `TS2503: Cannot find namespace 'NodeJS'`로 실패한다. 그래서 `https://d3ro.chanpaca.net/release-identity.json`이 404다(공개 버전 검증 불가). | `.forgejo/workflows/deploy-site.yml`, `site/src/sections/Hero.tsx` | `[x]` 2026-09-19: ref를 `ReturnType`으로 바꿔 hoisted 타입 의존을 제거했다(격리 `--typeRoots`로 CI 조건 재현 → 수정 전 TS2503, 수정 후 clean). 같은 수정을 push하자 `deploy` run#67이 사이트 빌드를 통과해 `dist/`를 만들었고, 실패는 다음 단계(Cloudflare)로 이동했다. | | GAP-REL-09b | Release | `d3ro.chanpaca.net`이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 `d3ro`/`d3ro-voice`가 **존재하지 않아** 커스텀 도메인 바인딩이 없었다(빈 본문 404, `cf-ray`만 반환). Pages 커스텀 도메인은 존 DNS CNAME(`d3ro → d3ro.pages.dev`)을 요구하는데 기존 `d3ro` 레코드가 남아 있어 `CNAME record not set`으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). `deploy-site.yml`은 `CF_API_TOKEN` 시크릿이 없어 마지막 게시 단계에서도 `exit 1`이다. | `server/cloudflare-site-bridge/`, `.forgejo/workflows/deploy-site.yml`, `docs/map/02-infrastructure.md` | `[x]` 2026-09-19: Pages 프로젝트 `d3ro` 생성 + `site/dist` production 배포(`d3ro.pages.dev` 200, `release-identity.json` = commit `2407f5a` / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(`server/cloudflare-site-bridge`, `d3ro.chanpaca.net/*` → Pages 프록시, `npx wrangler deploy`)를 배포 → 라이브 확인: `/`·`/privacy/`·`/terms/`·`/delete-account/` 200, 라이브 번들이 설치 파일명을 `1.3.7`로 계산, `/download.html` → `/#download`. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 `CF_API_TOKEN`(Pages/Workers Edit) + `CF_ACCOUNT_ID`=`8e83cc130e7329c160cf2b88d6b4c20a`를 Forgejo 시크릿에 등록. **2026-09-23:** CI `deploy`(deploy-site.yml)는 `CF_API_TOKEN` 부재로 run#71까지 여전히 실패한다. 로컬 인증 wrangler(pages:write)로 `npm run build --prefix site` → release-identity 작성 → `npx wrangler pages deploy site/dist --project-name d3ro --branch main`을 수동 배포했고, 라이브 `https://d3ro.chanpaca.net/release-identity.json`이 commit `5c11ee2` / version `1.5.0`을, 라이브 번들이 설치 파일명 `D3RO-Voice-Setup-1.5.0-x64.exe`를 보고한다. 자동 게시에는 여전히 시크릿 등록이 필요하다. | +| GAP-BILL-01 | Billing | 랜딩 사이트 가격(Pro ₩2,900 / Pro+ ₩8,900, 2026-09-26 사용자 확정)과 서버 결제 카탈로그(Payple Pro ₩9,900 / Pro+ ₩29,900)가 다르다. 결제 진입 URL도 `payment-handlers.ts`(`d3ro.chanpaca.net/billing`)와 `license-handlers.ts`(`d3ro.dev/billing`)로 갈린다. | `site/src/pricing.ts`, `server/supabase/functions/_shared/payple.ts`, `server/supabase/functions/_shared/billing-catalog.ts`, `apps/desktop/src/main/ipc/license-handlers.ts` | `[ ]` 2026-09-26: 사이트는 새 가격을 표시하고 `d3ro.chanpaca.net/billing?tier=`로 연결한다. 서버 카탈로그 금액 변경(Payple·Stripe 가격 ID)과 결제 URL 정본 확정이 남았다. | | GAP-REL-10 | Release | `release-windows`(태그 파이프라인)는 서명 가드에 도달하기 **전에** sidecar 단계에서 죽는다. 이 러너 컨텍스트에서는 `sidecar:setup`이 Python 3.11+를 찾지 못한다(`Python 3.11+ 를 찾을 수 없습니다`) → `sidecar:build` → `verify-sidecar-bundle.mjs` 연쇄 실패(실측: run#65 `v1.3.7`, run#61 `v1.3.6`). 같은 러너의 portable 잡은 `py -3.11 → Python 3.11.9`를 찾아 사이드카 빌드에 성공하므로, 워크플로/컨테이너 간 PATH 차이다. | `.forgejo/workflows/release.yml`, `apps/desktop/scripts/setup-sidecar.mjs` | `[!]` 2026-09-19: 러너에 Python 3.11+(`py` 런처 포함)를 보장하거나 워크플로에 `actions/setup-python` 단계를 추가한다. 그 전까지 서명 게시는 불가능하다(GAP-REL-02와 별개 선행 차단). | | GAP-REL-11 | Release | portable 워크플로의 마지막 `actions/upload-artifact@v4` 단계가 Forgejo 러너에서 `GHESNotSupportedError`로 실패한다(증거 보존만 실패, 게시는 성공). | `.forgejo/workflows/portable.yml` | `[x]` 2026-09-19: `v1.3.7` portable 게시는 run#64에서 성공(7z 단일 볼륨 83.7MB + zip 2부, `portable-latest/portable.json`이 1.3.7 보고). 남은 조치: upload-artifact 단계를 제거하거나 v3/다른 보존 방식으로 바꿔 워크플로를 GREEN으로 만든다. | | GAP-REL-12 | Release | Forgejo generic registry는 파일 단위 덮어쓰기를 거부(HTTP 409)하는데, portable 게시 스크립트의 "동일 파일 건너뛰기"가 1MiB 초과 파일을 **크기만** 비교했다. 그래서 새 runtime의 sidecar 볼륨 `.001`이 이전 볼륨과 정확히 같은 94,371,840 bytes였고, 내용이 다른데도 "동일"로 오판해 `runtime-latest`가 낡은 `.001` + 새 `.002`로 갈라졌다(다운로드 시 손상). | `scripts/ci/publish-portable-release.mjs` | `[x]` 2026-09-23: 파일 목록 API(`GET /api/v1/packages/{owner}/generic/{name}/{version}/files`)의 sha256으로 정확히 비교하고, 내용이 다른 파일이 하나라도 있으면 해당 별칭 버전 전체를 삭제 후 재업로드하도록 수정했다. 이미 발생한 `runtime-latest`는 버전을 지우고 재게시해 4개 파일 모두 로컬 해시와 일치함을 확인했다. | diff --git a/site/index.html b/site/index.html index 565a70d..d23d23e 100644 --- a/site/index.html +++ b/site/index.html @@ -1,19 +1,24 @@ - + - - - D3RO Voice — Local AI Voice Assistant + + + + + + + D3RO Voice — 내 PC에서 돌아가는 음성 받아쓰기 + - + - +
diff --git a/site/src/App.tsx b/site/src/App.tsx index e0a40cc..7a77235 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -1,4 +1,4 @@ -import { I18nProvider } from './i18n' +import { I18nProvider, useI18n } from './i18n' import { Header } from './sections/Header' import { Hero } from './sections/Hero' import { Features } from './sections/Features' @@ -7,26 +7,32 @@ import { Privacy } from './sections/Privacy' import { Pricing } from './sections/Pricing' import { Download } from './sections/Download' import { FAQ } from './sections/FAQ' -import { CTA } from './sections/CTA' import { Footer } from './sections/Footer' +function Page() { + const { t } = useI18n() + return ( +
+ {t.a11y.skipToContent} +
+
+ + + + + + + +
+
+
+ ) +} + export function App() { return ( -
-
-
- - - - - - - - -
-
-
+
) } diff --git a/site/src/components/Badge.tsx b/site/src/components/Badge.tsx deleted file mode 100644 index 00da79a..0000000 --- a/site/src/components/Badge.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { ReactNode } from 'react' -import { Led } from './Led' - -interface BadgeProps { - children: ReactNode - led?: boolean - ledColor?: 'blue' | 'green' | 'red' - className?: string -} - -export function Badge({ children, led = false, ledColor = 'blue', className = '' }: BadgeProps) { - return ( - - {led && } - {children} - - ) -} diff --git a/site/src/components/DataPoint.tsx b/site/src/components/DataPoint.tsx deleted file mode 100644 index 5b7b744..0000000 --- a/site/src/components/DataPoint.tsx +++ /dev/null @@ -1,22 +0,0 @@ -interface DataPointProps { - label: string - value: string - unit?: string - className?: string -} - -export function DataPoint({ label, value, unit, className = '' }: DataPointProps) { - return ( -
- - {label} - - - {value} - {unit && ( - {unit} - )} - -
- ) -} diff --git a/site/src/components/DictationDemo.tsx b/site/src/components/DictationDemo.tsx new file mode 100644 index 0000000..30e8ff4 --- /dev/null +++ b/site/src/components/DictationDemo.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef, useState } from 'react' +import { Crosshair } from './Crosshair' +import { Led } from './Led' +import { WaveBars } from './WaveBars' +import { PlayIcon } from './icons' +import { useI18n } from '../i18n' +import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion' + +type Phase = 'idle' | 'listening' | 'polishing' | 'done' + +const TYPE_INTERVAL_MS = 40 +const POLISH_DELAY_MS = 700 + +/** + * 받아쓰기 과정을 보여 주는 스크립트 예시. 실제 녹음은 하지 않으며 그 사실을 화면에 적는다. + * 감소 모션이면 타이핑 연출 없이 결과로 바로 간다. + */ +export function DictationDemo() { + const { t } = useI18n() + const reducedMotion = usePrefersReducedMotion() + const [phase, setPhase] = useState('idle') + const [typed, setTyped] = useState('') + const timers = useRef([]) + + const clearTimers = () => { + timers.current.forEach((id) => window.clearTimeout(id)) + timers.current = [] + } + + useEffect(() => clearTimers, []) + + // 언어가 바뀌면 예시 문장이 달라지므로 처음 상태로 돌린다. + useEffect(() => { + clearTimers() + setPhase('idle') + setTyped('') + }, [t]) + + const play = () => { + clearTimers() + const raw = t.demo.sampleRaw + if (reducedMotion) { + setTyped(raw) + setPhase('done') + return + } + setTyped('') + setPhase('listening') + const chars = Array.from(raw) + chars.forEach((_, i) => { + timers.current.push(window.setTimeout(() => setTyped(chars.slice(0, i + 1).join('')), i * TYPE_INTERVAL_MS)) + }) + const heard = chars.length * TYPE_INTERVAL_MS + timers.current.push(window.setTimeout(() => setPhase('polishing'), heard)) + timers.current.push(window.setTimeout(() => setPhase('done'), heard + POLISH_DELAY_MS)) + } + + const running = phase === 'listening' || phase === 'polishing' + const status = phase === 'idle' ? t.demo.title : t.demo[phase] + + return ( + +
+
+
+

+ + {status} +

+ +
+ +
+ +
+ +
+ {phase === 'idle' ? ( +

{t.demo.idle}

+ ) : ( + <> +
+

{t.demo.rawLabel}

+

+ {typed} +

+
+ {phase === 'done' && ( +
+

{t.demo.cleanLabel}

+

{t.demo.sampleClean}

+
+ )} + + )} +
+ +
+ {t.demo.note} +
+
+
+
+ ) +} diff --git a/site/src/components/GlowButton.tsx b/site/src/components/GlowButton.tsx deleted file mode 100644 index 591c053..0000000 --- a/site/src/components/GlowButton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { ReactNode } from 'react' - -interface GlowButtonProps { - children: ReactNode - href?: string - download?: string - variant?: 'primary' | 'secondary' - size?: 'sm' | 'md' | 'lg' - className?: string - onClick?: () => void -} - -export function GlowButton({ - children, - href, - download, - variant = 'primary', - size = 'md', - className = '', - onClick, -}: GlowButtonProps) { - const base = 'glow-btn inline-flex items-center justify-center font-medium tracking-normal select-none rounded-xl transition-all duration-200 leading-normal' - - const variants = { - primary: 'bg-brand-blue text-white hover:bg-brand-blue-light shadow-glow-sm hover:shadow-glow-md active:scale-[0.98]', - secondary: 'bg-surface-700/80 text-neutral-100 border border-white/10 hover:border-brand-blue/40 hover:bg-surface-600 hover:text-white active:scale-[0.98]', - } - - const sizes = { - sm: 'px-4.5 py-2 text-xs', - md: 'px-6 py-2.5 sm:py-3 text-xs sm:text-sm font-medium', - lg: 'px-8 sm:px-10 py-3.5 sm:py-4 text-sm sm:text-base font-medium', - } - - const cls = `${base} ${variants[variant]} ${sizes[size]} ${className}` - - if (href) { - return ( - - {children} - - ) - } - - return ( - - ) -} diff --git a/site/src/components/InstrumentCard.tsx b/site/src/components/InstrumentCard.tsx deleted file mode 100644 index 1b15c82..0000000 --- a/site/src/components/InstrumentCard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { ReactNode } from 'react' - -interface InstrumentCardProps { - children: ReactNode - className?: string - hover?: boolean -} -export function InstrumentCard({ children, className = '', hover = true }: InstrumentCardProps) { - return ( -
-
- {children} -
-
- ) -} diff --git a/site/src/components/LanguageSwitcher.tsx b/site/src/components/LanguageSwitcher.tsx index cfa2b2a..bbb4337 100644 --- a/site/src/components/LanguageSwitcher.tsx +++ b/site/src/components/LanguageSwitcher.tsx @@ -1,65 +1,97 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useId } from 'react' import { useI18n, LOCALES } from '../i18n' +/** + * 언어 선택. 디스클로저 패턴: 버튼이 목록을 열고 닫으며, + * Esc·바깥 클릭·포커스 이탈로 닫힌다. 현재 언어는 aria-current로 알린다. + */ export function LanguageSwitcher() { - const { locale, setLocale } = useI18n() + const { t, locale, setLocale } = useI18n() const [open, setOpen] = useState(false) - const ref = useRef(null) + const rootRef = useRef(null) + const buttonRef = useRef(null) + const listId = useId() const current = LOCALES.find((l) => l.code === locale) useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { + if (!open) return + const onPointer = (e: PointerEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() setOpen(false) + buttonRef.current?.focus() } } - document.addEventListener('mousedown', handleClick) - return () => document.removeEventListener('mousedown', handleClick) - }, []) + document.addEventListener('pointerdown', onPointer) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('pointerdown', onPointer) + document.removeEventListener('keydown', onKey, true) + } + }, [open]) return ( -
+
{ + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false) + }} + > - {open && ( -
- {LOCALES.map((l) => ( - - ))} -
- )} +
) } function GlobeIcon() { return ( - +
-
- - - {index} - -
-
-

- {title} -

- {subtitle && ( -

- {subtitle} -

- )} -
- ) -} diff --git a/site/src/components/SectionHeading.tsx b/site/src/components/SectionHeading.tsx new file mode 100644 index 0000000..587052d --- /dev/null +++ b/site/src/components/SectionHeading.tsx @@ -0,0 +1,20 @@ +interface SectionHeadingProps { + id: string + title: string + subtitle?: string + className?: string +} + +/** 섹션 제목. `id`는 섹션의 aria-labelledby 대상이다. */ +export function SectionHeading({ id, title, subtitle, className = '' }: SectionHeadingProps) { + return ( +
+

+ {title} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ ) +} diff --git a/site/src/components/WaveBars.tsx b/site/src/components/WaveBars.tsx index d1c6627..f623b0d 100644 --- a/site/src/components/WaveBars.tsx +++ b/site/src/components/WaveBars.tsx @@ -1,47 +1,58 @@ import { useEffect, useRef } from 'react' +import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion' const BAR_COUNT = 9 +// 녹음 UI와 같은 cos 분포: 가운데가 가장 높다. const COS_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) => Math.cos((i - 4) * (Math.PI / 9)), ) +const REST_HEIGHT = 4 +const MAX_EXTRA = 28 interface WaveBarsProps { - className?: string + /** 소리를 듣는 중일 때만 움직인다. 멈추면 낮은 정지 막대로 돌아간다. */ + active: boolean } -export function WaveBars({ className = '' }: WaveBarsProps) { - const barsRef = useRef<(HTMLDivElement | null)[]>([]) +export function WaveBars({ active }: WaveBarsProps) { + const barsRef = useRef<(HTMLSpanElement | null)[]>([]) + const reducedMotion = usePrefersReducedMotion() + const animate = active && !reducedMotion useEffect(() => { - let frame: number - let t = 0 - - const animate = () => { - t += 0.05 - barsRef.current.forEach((bar, i) => { - if (!bar) return - const base = COS_WEIGHTS[i] - const wave = Math.sin(t + i * 0.4) * 0.5 + 0.5 - const h = 4 + base * wave * 28 - bar.style.height = `${h}px` + const bars = barsRef.current + if (!animate) { + bars.forEach((bar, i) => { + if (bar) bar.style.height = `${active ? REST_HEIGHT + COS_WEIGHTS[i] * MAX_EXTRA * 0.6 : REST_HEIGHT}px` }) - frame = requestAnimationFrame(animate) + return } - frame = requestAnimationFrame(animate) + let frame = 0 + let t = 0 + const tick = () => { + t += 0.12 + bars.forEach((bar, i) => { + if (!bar) return + const wave = Math.sin(t + i * 0.7) * 0.5 + 0.5 + bar.style.height = `${REST_HEIGHT + COS_WEIGHTS[i] * wave * MAX_EXTRA}px` + }) + frame = requestAnimationFrame(tick) + } + frame = requestAnimationFrame(tick) return () => cancelAnimationFrame(frame) - }, []) + }, [animate, active]) return ( -