diff --git a/CLAUDE.md b/CLAUDE.md
index 0f05616..c41cd92 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,6 +17,7 @@ pnpm lint # ESLint (flat config, includes react-hooks rules)
pnpm typecheck # tsc --noEmit
pnpm example # Run the CSR example (Vite, :3001)
pnpm example:next # Run the SSR example (Next.js, :3002)
+pnpm website # Run the promo website (Next.js, :3003)
# Run a specific test by name pattern
pnpm vitest run -t "test name pattern"
@@ -77,6 +78,10 @@ React is the only external (peer dependency). Bundle budgets: everything ≤ 2 k
- `examples/basic` — Vite CSR app importing the library source (`../../src`) directly.
- `examples/nextjs` — Next.js 15 App Router app consuming the **built package** via `"react-device-check": "link:../.."` — run `pnpm build` at the root before starting it.
+### Website
+
+`website/` is a standalone Next.js 15 promo/landing site (own lockfile, not a workspace member) consuming the **published npm package** — unlike both examples, it needs no root build. English at `/`, Korean at `/ko` via two route-group root layouts (each sets its own ``); hreflang/canonical/OG metadata come from `website/lib/seo.ts` (`SITE_URL` is the single deploy-URL definition). The OG image is the static `website/public/og.png`, referenced explicitly in `lib/seo.ts` (the `opengraph-image` file convention does not inject meta tags across route-group root layouts). Deployed on Vercel with Root Directory = `website`; excluded from CI, lint, size-limit, and the Playwright E2E matrix. The examples' ports and `data-testid` contracts are untouched by it.
+
### Package manager
This project uses `pnpm` and Node 20.x (see `.nvmrc`). Use `pnpm` for all install/run commands.
diff --git a/README.ko.md b/README.ko.md
index eeed94a..4a3c88d 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -5,6 +5,8 @@
[English](README.md) | [한국어](README.ko.md)
+**웹사이트 / 라이브 데모**: [react-device-check-site.vercel.app/ko](https://react-device-check-site.vercel.app/ko)
+
**경량 · 정확 · SSR-safe React 기기 판별 훅.** 사용자가 폰인지 태블릿인지 데스크톱인지, 어떤 OS인지를 의존성 0개, 전체 ~1.5 kB(min+brotli)로 판별합니다. Next.js에서 hydration 에러가 발생하지 않습니다.
2026년의 기기 판별은 보기보다 어렵습니다. iPad는 자신을 Mac이라고 위장하고, Chrome은 User-Agent 문자열을 동결했으며(모든 안드로이드 모델명이 `K`로 보고됨), Samsung DeX는 폰에서 데스크톱 리눅스 UA를 보내고, iOS 26은 OS 버전 토큰을 영구 동결했습니다. `react-device-check`는 여전히 동작하는 신호들 — User-Agent Client Hints, UA 문자열, `maxTouchPoints` 교차검증 — 을 정규식 데이터베이스가 아닌 작고 결정론적인 판별 트리로 융합합니다.
@@ -88,16 +90,16 @@ function DownloadButton() {
전체 기기 스냅샷을 반환하고 반응형 변경을 구독합니다.
-| 필드 | 타입 | 수명 | 설명 |
-| ---------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------ |
-| `type` | `'mobile' \| 'tablet' \| 'desktop'` | 정적 | 기기 클래스 |
-| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | 정적 | OS 계열 |
-| `isMobile` | `boolean` | 정적 | `type === 'mobile'` 축약 |
-| `isTablet` | `boolean` | 정적 | `type === 'tablet'` 축약 |
-| `isDesktop` | `boolean` | 정적 | `type === 'desktop'` 축약 |
-| `isTouchPrimary` | `boolean` | 반응형 | `(pointer: coarse)` — 마우스 연결 시(DeX, iPad) 실시간 전환 |
-| `orientation` | `'portrait' \| 'landscape'` | 반응형 | 뷰포트 방향, 회전 시 갱신 |
-| `isHydrated` | `boolean` | — | 서버·hydration 첫 페인트에서 `false`, 직후 `true` |
+| 필드 | 타입 | 수명 | 설명 |
+| ---------------- | -------------------------------------------------------------------- | ------ | ----------------------------------------------------------- |
+| `type` | `'mobile' \| 'tablet' \| 'desktop'` | 정적 | 기기 클래스 |
+| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | 정적 | OS 계열 |
+| `isMobile` | `boolean` | 정적 | `type === 'mobile'` 축약 |
+| `isTablet` | `boolean` | 정적 | `type === 'tablet'` 축약 |
+| `isDesktop` | `boolean` | 정적 | `type === 'desktop'` 축약 |
+| `isTouchPrimary` | `boolean` | 반응형 | `(pointer: coarse)` — 마우스 연결 시(DeX, iPad) 실시간 전환 |
+| `orientation` | `'portrait' \| 'landscape'` | 반응형 | 뷰포트 방향, 회전 시 갱신 |
+| `isHydrated` | `boolean` | — | 서버·hydration 첫 페인트에서 `false`, 직후 `true` |
> **참고:** `type`과 `os`는 의도적으로 세션당 고정입니다. UA 사실은 페이지 리로드 없이 변하지 않으며, 고정 유지가 UI 흔들림을 방지합니다. 뷰포트 의존적인 것은 반응형 필드(또는 CSS)를 사용하세요.
@@ -127,13 +129,13 @@ const { type, os } = detectDevice({ ua: req.headers['user-agent'] });
detectDevice(undefined, { fallback: { type: 'mobile' } });
```
-| `DetectionInput` 필드 | 클라이언트에서 읽는 곳 |
-| ---------------------- | ---------------------------- |
-| `ua` | `navigator.userAgent` |
-| `uaData` | `navigator.userAgentData` |
-| `maxTouchPoints` | `navigator.maxTouchPoints` |
-| `platform` | `navigator.platform` |
-| `screen` | `screen.width` / `height` |
+| `DetectionInput` 필드 | 클라이언트에서 읽는 곳 |
+| --------------------- | -------------------------- |
+| `ua` | `navigator.userAgent` |
+| `uaData` | `navigator.userAgentData` |
+| `maxTouchPoints` | `navigator.maxTouchPoints` |
+| `platform` | `navigator.platform` |
+| `screen` | `screen.width` / `height` |
### `getNavigatorInput(): DetectionInput | undefined`
diff --git a/README.md b/README.md
index a8b1673..80f4c19 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,8 @@
[English](README.md) | [한국어](README.ko.md)
+**Website / live demo**: [react-device-check-site.vercel.app](https://react-device-check-site.vercel.app)
+
**Lightweight, accurate, SSR-safe React hooks for device detection.** Know whether your user is on a phone, tablet, or desktop — and which OS — with zero dependencies, ~1.5 kB (min+brotli) for everything, and no hydration errors in Next.js.
Detecting devices in 2026 is harder than it looks: iPads masquerade as Macs, Chrome froze its User-Agent string (every Android model reports `K`), Samsung DeX sends a desktop Linux UA from a phone, and iOS 26 froze its OS version token forever. `react-device-check` fuses the signals that still work — User-Agent Client Hints, the UA string, and `maxTouchPoints` cross-checks — into a small deterministic decision tree instead of a regex database.
@@ -88,16 +90,16 @@ function DownloadButton() {
Returns the full device snapshot and subscribes to reactive changes.
-| Field | Type | Lifetime | Description |
-| ---------------- | --------------------------------------- | -------- | ------------------------------------------------------------------------ |
-| `type` | `'mobile' \| 'tablet' \| 'desktop'` | static | Device class |
-| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | static | OS family |
-| `isMobile` | `boolean` | static | Sugar for `type === 'mobile'` |
-| `isTablet` | `boolean` | static | Sugar for `type === 'tablet'` |
-| `isDesktop` | `boolean` | static | Sugar for `type === 'desktop'` |
-| `isTouchPrimary` | `boolean` | reactive | `(pointer: coarse)` — flips live when a mouse is attached (DeX, iPad) |
-| `orientation` | `'portrait' \| 'landscape'` | reactive | Viewport orientation, updates on rotation |
-| `isHydrated` | `boolean` | — | `false` on the server and hydration first paint, `true` right after |
+| Field | Type | Lifetime | Description |
+| ---------------- | -------------------------------------------------------------------- | -------- | --------------------------------------------------------------------- |
+| `type` | `'mobile' \| 'tablet' \| 'desktop'` | static | Device class |
+| `os` | `'ios' \| 'android' \| 'windows' \| 'macos' \| 'linux' \| 'unknown'` | static | OS family |
+| `isMobile` | `boolean` | static | Sugar for `type === 'mobile'` |
+| `isTablet` | `boolean` | static | Sugar for `type === 'tablet'` |
+| `isDesktop` | `boolean` | static | Sugar for `type === 'desktop'` |
+| `isTouchPrimary` | `boolean` | reactive | `(pointer: coarse)` — flips live when a mouse is attached (DeX, iPad) |
+| `orientation` | `'portrait' \| 'landscape'` | reactive | Viewport orientation, updates on rotation |
+| `isHydrated` | `boolean` | — | `false` on the server and hydration first paint, `true` right after |
> **Note:** `type` and `os` are intentionally static per session. User-agent facts cannot change without a page load, and keeping them stable prevents UI flapping. Use the reactive fields (or CSS) for anything viewport-dependent.
@@ -127,13 +129,13 @@ const { type, os } = detectDevice({ ua: req.headers['user-agent'] });
detectDevice(undefined, { fallback: { type: 'mobile' } });
```
-| `DetectionInput` field | Read from (client) |
-| ---------------------- | --------------------------- |
-| `ua` | `navigator.userAgent` |
-| `uaData` | `navigator.userAgentData` |
-| `maxTouchPoints` | `navigator.maxTouchPoints` |
-| `platform` | `navigator.platform` |
-| `screen` | `screen.width` / `height` |
+| `DetectionInput` field | Read from (client) |
+| ---------------------- | -------------------------- |
+| `ua` | `navigator.userAgent` |
+| `uaData` | `navigator.userAgentData` |
+| `maxTouchPoints` | `navigator.maxTouchPoints` |
+| `platform` | `navigator.platform` |
+| `screen` | `screen.width` / `height` |
### `getNavigatorInput(): DetectionInput | undefined`
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 68a1e72..ebd1be7 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -3,7 +3,17 @@ import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
export default tseslint.config(
- { ignores: ['dist/', 'coverage/', 'examples/', 'node_modules/', 'test-results/', 'playwright-report/'] },
+ {
+ ignores: [
+ 'dist/',
+ 'coverage/',
+ 'examples/',
+ 'website/',
+ 'node_modules/',
+ 'test-results/',
+ 'playwright-report/',
+ ],
+ },
js.configs.recommended,
...tseslint.configs.recommended,
{
diff --git a/package.json b/package.json
index bdf7e69..81d0dc0 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"typecheck": "tsc --noEmit && tsc -p tsconfig.tools.json",
"example": "cd examples/basic && pnpm install && pnpm dev",
"example:next": "cd examples/nextjs && pnpm install && pnpm dev",
+ "website": "cd website && pnpm install && pnpm dev",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
diff --git a/website/app/(en)/layout.tsx b/website/app/(en)/layout.tsx
new file mode 100644
index 0000000..5a2db28
--- /dev/null
+++ b/website/app/(en)/layout.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+import { inter, jetbrainsMono } from '@/lib/fonts';
+import { SITE_URL } from '@/lib/seo';
+import '../globals.css';
+
+// Fallback for routes without their own metadata (e.g. not-found); pages override via buildMetadata
+export const metadata: Metadata = { metadataBase: new URL(SITE_URL) };
+
+// Route groups give each locale its own root layout so is correct per URL
+export default function EnLayout({ children }: { children: ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/website/app/(en)/page.tsx b/website/app/(en)/page.tsx
new file mode 100644
index 0000000..c6fbf07
--- /dev/null
+++ b/website/app/(en)/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import Landing from '@/components/Landing';
+import { en } from '@/content/en';
+import { buildMetadata } from '@/lib/seo';
+
+export const metadata: Metadata = buildMetadata('en');
+
+export default function HomePage() {
+ return ;
+}
diff --git a/website/app/(ko)/ko/page.tsx b/website/app/(ko)/ko/page.tsx
new file mode 100644
index 0000000..87e5d75
--- /dev/null
+++ b/website/app/(ko)/ko/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import Landing from '@/components/Landing';
+import { ko } from '@/content/ko';
+import { buildMetadata } from '@/lib/seo';
+
+export const metadata: Metadata = buildMetadata('ko');
+
+export default function KoHomePage() {
+ return ;
+}
diff --git a/website/app/(ko)/layout.tsx b/website/app/(ko)/layout.tsx
new file mode 100644
index 0000000..891f887
--- /dev/null
+++ b/website/app/(ko)/layout.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+import { inter, jetbrainsMono } from '@/lib/fonts';
+import { SITE_URL } from '@/lib/seo';
+import '../globals.css';
+
+// Fallback for routes without their own metadata; pages override via buildMetadata
+export const metadata: Metadata = { metadataBase: new URL(SITE_URL) };
+
+// Second root layout for the /ko subtree — navigation across locales is a full page load by design
+export default function KoLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/website/app/globals.css b/website/app/globals.css
new file mode 100644
index 0000000..1e0cfa3
--- /dev/null
+++ b/website/app/globals.css
@@ -0,0 +1,1087 @@
+/* ---------- Design tokens: dark by default, light via prefers-color-scheme ---------- */
+:root {
+ color-scheme: dark;
+ --bg: #0a0a0f;
+ --bg-header: rgba(10, 10, 15, 0.72);
+ --surface: rgba(255, 255, 255, 0.03);
+ --surface-hover: rgba(255, 255, 255, 0.055);
+ --border: rgba(255, 255, 255, 0.09);
+ --border-strong: rgba(255, 255, 255, 0.18);
+ --text: #ededf2;
+ --text-dim: #a3a3b5;
+ --text-faint: #74748c;
+ --accent: #818cf8;
+ --accent-strong: #a5b4fc;
+ --accent-contrast: #0a0a0f;
+ --accent-soft: rgba(129, 140, 248, 0.13);
+ --glow: rgba(129, 140, 248, 0.16);
+ --ok: #34d399;
+ --ok-soft: rgba(52, 211, 153, 0.14);
+ --code-bg: #0d0d16;
+ --code-text: #e6e6f0;
+ --radius: 14px;
+ --radius-sm: 8px;
+ --font-fallback:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Apple SD Gothic Neo',
+ 'Malgun Gothic', sans-serif;
+ --mono-fallback: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color-scheme: light;
+ --bg: #fafafc;
+ --bg-header: rgba(250, 250, 252, 0.8);
+ --surface: rgba(17, 17, 34, 0.03);
+ --surface-hover: rgba(17, 17, 34, 0.055);
+ --border: rgba(17, 17, 34, 0.1);
+ --border-strong: rgba(17, 17, 34, 0.22);
+ --text: #17171f;
+ --text-dim: #4c4c60;
+ --text-faint: #7c7c92;
+ --accent: #4f46e5;
+ --accent-strong: #4338ca;
+ --accent-contrast: #ffffff;
+ --accent-soft: rgba(79, 70, 229, 0.09);
+ --glow: rgba(99, 102, 241, 0.12);
+ --ok: #059669;
+ --ok-soft: rgba(5, 150, 105, 0.11);
+ /* Code panels intentionally stay dark in light mode */
+ }
+}
+
+/* ---------- Base ---------- */
+* {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-sans), var(--font-fallback);
+ font-size: 1.0625rem;
+ line-height: 1.7;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ /* Keep Korean words intact at line breaks; no effect on Latin text */
+ word-break: keep-all;
+}
+
+h1,
+h2,
+h3 {
+ line-height: 1.25;
+ letter-spacing: -0.02em;
+ margin: 0;
+ text-wrap: balance;
+}
+
+p {
+ margin: 0;
+}
+
+a {
+ color: var(--accent-strong);
+ text-decoration: none;
+}
+
+a:hover {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+ border-radius: 3px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ }
+ html {
+ scroll-behavior: auto;
+ }
+}
+
+.container {
+ max-width: 68rem;
+ margin-inline: auto;
+ padding-inline: 1.5rem;
+}
+
+/* ---------- Header ---------- */
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ background: var(--bg-header);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--border);
+}
+
+.header-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ height: 3.75rem;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.6rem;
+ color: var(--text);
+ font-weight: 600;
+ font-size: 0.95rem;
+ letter-spacing: -0.01em;
+}
+
+.brand:hover {
+ text-decoration: none;
+}
+
+.brand-mark {
+ width: 0.85rem;
+ height: 0.85rem;
+ border-radius: 4px;
+ background: linear-gradient(135deg, #818cf8, #c084fc);
+ box-shadow: 0 0 12px var(--glow);
+}
+
+.header-nav {
+ display: flex;
+ align-items: center;
+ gap: 1.1rem;
+ font-size: 0.9rem;
+}
+
+.header-nav a {
+ color: var(--text-dim);
+}
+
+.header-nav a:hover {
+ color: var(--text);
+ text-decoration: none;
+}
+
+.lang-switch {
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.25rem 0.8rem;
+ transition: border-color 0.2s ease;
+}
+
+.lang-switch:hover {
+ border-color: var(--border-strong);
+}
+
+/* ---------- Hero ---------- */
+.hero {
+ position: relative;
+ padding-block: 6.5rem 5rem;
+ text-align: center;
+}
+
+.hero::before {
+ content: '';
+ position: absolute;
+ inset: -6rem -4rem auto;
+ height: 26rem;
+ background: radial-gradient(
+ ellipse 60% 70% at 50% 0%,
+ var(--glow),
+ transparent 70%
+ );
+ pointer-events: none;
+ z-index: -1;
+}
+
+.hero-badges {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.5rem;
+ margin-bottom: 1.75rem;
+}
+
+.chip {
+ font-size: 0.8rem;
+ color: var(--text-dim);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.2rem 0.75rem;
+ background: var(--surface);
+}
+
+.hero h1 {
+ font-size: clamp(2.4rem, 6vw, 4.25rem);
+ font-weight: 700;
+ max-width: 20ch;
+ margin-inline: auto;
+}
+
+.gradient-text {
+ background: linear-gradient(120deg, var(--accent-strong), #c084fc);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+.hero-tagline {
+ max-width: 44rem;
+ margin: 1.5rem auto 0;
+ font-size: 1.15rem;
+ color: var(--text-dim);
+ text-wrap: pretty;
+}
+
+.hero-ctas {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.75rem;
+ margin-top: 2.5rem;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-weight: 600;
+ font-size: 0.95rem;
+ border-radius: 999px;
+ padding: 0.65rem 1.5rem;
+ transition:
+ transform 0.15s ease,
+ background-color 0.2s ease,
+ border-color 0.2s ease;
+}
+
+.btn:hover {
+ text-decoration: none;
+ transform: translateY(-1px);
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: var(--accent-contrast);
+}
+
+.btn-primary:hover {
+ background: var(--accent-strong);
+}
+
+.btn-ghost {
+ border: 1px solid var(--border-strong);
+ color: var(--text);
+ background: var(--surface);
+}
+
+.btn-ghost:hover {
+ background: var(--surface-hover);
+}
+
+/* Hero install widget: package-manager tabs + click-to-copy command (dark in both themes) */
+.install-tabs {
+ display: inline-flex;
+ flex-direction: column;
+ width: min(26rem, 100%);
+ margin-top: 2.25rem;
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ text-align: left;
+}
+
+.install-tabs-bar {
+ display: flex;
+ gap: 0.25rem;
+ padding: 0.45rem 0.6rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.pm-tab {
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.78rem;
+ color: #a3a3b5;
+ background: transparent;
+ border: 0;
+ border-radius: 6px;
+ padding: 0.25rem 0.7rem;
+ cursor: pointer;
+ transition:
+ color 0.15s ease,
+ background-color 0.15s ease;
+}
+
+.pm-tab:hover {
+ color: #ededf2;
+}
+
+.pm-tab[aria-pressed='true'] {
+ background: rgba(255, 255, 255, 0.09);
+ color: #ededf2;
+}
+
+.install-cmd {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ width: 100%;
+ padding: 0.8rem 1rem;
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.88rem;
+ color: var(--code-text);
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ text-align: left;
+}
+
+.install-cmd::before {
+ content: '$';
+ color: var(--accent);
+}
+
+.copy-state {
+ margin-left: auto;
+ font-size: 0.73rem;
+ color: #74748c;
+ white-space: nowrap;
+ transition: color 0.15s ease;
+}
+
+.install-cmd:hover .copy-state {
+ color: #ededf2;
+}
+
+.copy-state-ok {
+ color: var(--ok);
+}
+
+.install-cmd:hover .copy-state-ok {
+ color: var(--ok);
+}
+
+/* ---------- Sections ---------- */
+.section {
+ padding-block: 4.5rem;
+ border-top: 1px solid var(--border);
+}
+
+.section-head {
+ margin-bottom: 2.25rem;
+}
+
+.overline {
+ font-size: 0.8rem;
+ font-weight: 600;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-bottom: 0.5rem;
+}
+
+.section-head h2 {
+ font-size: clamp(1.6rem, 3.5vw, 2.2rem);
+ font-weight: 700;
+}
+
+.section-intro {
+ max-width: 46rem;
+ color: var(--text-dim);
+ margin-top: 0.9rem;
+}
+
+.prose {
+ max-width: 46rem;
+ color: var(--text-dim);
+}
+
+.prose code,
+.section-intro code,
+.panel-note code,
+.card p code {
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.85em;
+ color: var(--accent-strong);
+ background: var(--accent-soft);
+ border-radius: 5px;
+ padding: 0.1em 0.35em;
+}
+
+/* ---------- Device showcase (vertical rows, CSS 3D mockups) ---------- */
+.showcase-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.showcase-item {
+ display: grid;
+ grid-template-columns: 21rem 1fr;
+ gap: 2rem 3.5rem;
+ align-items: center;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: calc(var(--radius) + 4px);
+ padding: 2.5rem;
+ transition: border-color 0.25s ease;
+}
+
+.showcase-item:hover {
+ border-color: var(--border-strong);
+}
+
+.showcase-visual {
+ display: flex;
+ justify-content: center;
+ perspective: 1200px;
+}
+
+.device3d {
+ transform-style: preserve-3d;
+ transition: transform 0.5s ease;
+}
+
+.tilt-phone {
+ transform: rotateY(-14deg) rotateX(4deg);
+}
+
+.tilt-tablet {
+ transform: rotateY(-12deg) rotateX(3deg);
+}
+
+.tilt-laptop {
+ transform: rotateX(9deg);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.tilt-monitor {
+ transform: rotateY(10deg) rotateX(2deg);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.tilt-tv {
+ transform: rotateY(-8deg) rotateX(2deg);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.showcase-item:hover .device3d {
+ transform: rotateY(0) rotateX(0);
+}
+
+/* Metallic rim: gradient wrapper + near-black body, glass reflection overlay */
+.frame {
+ position: relative;
+ padding: 3px;
+ border-radius: 30px;
+ background: linear-gradient(
+ 155deg,
+ #4a4a5e 0%,
+ #1a1a26 30%,
+ #101018 68%,
+ #3c3c50 100%
+ );
+ box-shadow:
+ 0 2px 4px rgba(0, 0, 0, 0.45),
+ 0 18px 32px -12px rgba(0, 0, 0, 0.55),
+ 0 48px 90px -40px var(--glow);
+}
+
+.frame-inner {
+ position: relative;
+ background: #08080e;
+ border-radius: 27px;
+ padding: 0.55rem;
+ overflow: hidden;
+}
+
+.frame-inner::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 115deg,
+ rgba(255, 255, 255, 0.09) 0%,
+ transparent 38%
+ );
+ pointer-events: none;
+}
+
+.frame-phone {
+ width: 190px;
+}
+
+.island {
+ width: 34%;
+ height: 9px;
+ border-radius: 999px;
+ background: #000;
+ border: 1px solid rgba(255, 255, 255, 0.09);
+ margin: 0.35rem auto 0.55rem;
+}
+
+.frame-tablet {
+ width: 300px;
+ border-radius: 24px;
+}
+
+.frame-tablet .frame-inner {
+ border-radius: 21px;
+ padding: 0.7rem;
+}
+
+.frame-laptop {
+ width: 330px;
+ border-radius: 16px 16px 4px 4px;
+}
+
+.frame-laptop .frame-inner {
+ border-radius: 13px 13px 2px 2px;
+}
+
+.laptop-hinge {
+ width: 336px;
+ height: 4px;
+ background: #0c0c13;
+}
+
+.laptop-deck {
+ position: relative;
+ width: 408px;
+ height: 14px;
+ border-radius: 2px 2px 18px 18px;
+ background: linear-gradient(180deg, #3e3e52, #15151f 75%);
+ box-shadow: 0 16px 30px -12px rgba(0, 0, 0, 0.6);
+}
+
+.laptop-deck::after {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 0;
+ transform: translateX(-50%);
+ width: 64px;
+ height: 5px;
+ border-radius: 0 0 8px 8px;
+ background: #0d0d15;
+}
+
+/* iMac-style monitor: aluminum chin under the screen, then neck + foot */
+.frame-monitor {
+ width: 330px;
+ border-radius: 18px;
+}
+
+.frame-monitor .frame-inner {
+ border-radius: 15px;
+ padding-bottom: 0;
+}
+
+.monitor-chin {
+ height: 24px;
+ margin: 0.55rem -0.55rem 0;
+ background: linear-gradient(180deg, #3e3e50, #23232f);
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+/* Slim-bezel TV: near-edgeless screen on a wide low pedestal */
+.frame-tv {
+ width: 400px;
+ border-radius: 12px;
+ padding: 2px;
+}
+
+.frame-tv .frame-inner {
+ border-radius: 10px;
+ padding: 0.35rem;
+}
+
+.stand-neck {
+ width: 36px;
+ height: 26px;
+ background: linear-gradient(180deg, #2e2e3e, #1a1a26);
+}
+
+.stand-neck-tv {
+ width: 90px;
+ height: 14px;
+}
+
+.stand-base {
+ width: 140px;
+ height: 9px;
+ border-radius: 999px;
+ background: linear-gradient(180deg, #3a3a4c, #17171f);
+ box-shadow: 0 14px 26px -10px rgba(0, 0, 0, 0.6);
+}
+
+.stand-base-tv {
+ width: 230px;
+ height: 7px;
+}
+
+/* Screens stay dark in both themes, like the code blocks */
+.device-screen {
+ background: var(--code-bg);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 18px;
+ padding: 0.85rem 0.95rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.74rem;
+ line-height: 1.5;
+}
+
+.frame-phone .device-screen {
+ min-height: 210px;
+}
+
+.frame-tablet .device-screen {
+ min-height: 158px;
+ border-radius: 14px;
+}
+
+.frame-laptop .device-screen {
+ min-height: 150px;
+ border-radius: 8px;
+}
+
+.frame-monitor .device-screen {
+ min-height: 168px;
+ border-radius: 10px;
+}
+
+.frame-tv .device-screen {
+ min-height: 196px;
+ border-radius: 8px;
+ padding: 1rem 1.1rem;
+}
+
+.screen-key {
+ color: #74748c;
+}
+
+.screen-val {
+ color: var(--accent-strong);
+}
+
+.showcase-copy h3 {
+ font-size: 1.25rem;
+ margin-bottom: 1.1rem;
+}
+
+.signal-row {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.4rem 0.9rem;
+ margin-bottom: 0.6rem;
+}
+
+.signal-label {
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-faint);
+ min-width: 9rem;
+}
+
+.signal-claim {
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.82rem;
+ color: var(--text-dim);
+ background: var(--surface-hover);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 0.12em 0.55em;
+}
+
+.verdict-chips {
+ display: inline-flex;
+ gap: 0.4rem;
+}
+
+.verdict-chip {
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.82rem;
+ color: var(--accent-strong);
+ background: var(--accent-soft);
+ border-radius: 6px;
+ padding: 0.12em 0.55em;
+}
+
+.showcase-caption {
+ margin-top: 1rem;
+ font-size: 0.95rem;
+ color: var(--text-dim);
+ max-width: 34rem;
+ text-wrap: pretty;
+}
+
+@media (max-width: 960px) {
+ .showcase-item {
+ grid-template-columns: 1fr;
+ padding: 1.75rem;
+ }
+
+ .laptop-deck {
+ width: 100%;
+ max-width: 408px;
+ }
+
+ .laptop-hinge {
+ width: 100%;
+ max-width: 336px;
+ }
+}
+
+/* ---------- Usage examples ---------- */
+.usage-list {
+ display: flex;
+ flex-direction: column;
+ margin-top: 1rem;
+}
+
+.usage-item {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 3fr);
+ gap: 1.5rem 3rem;
+ align-items: start;
+ padding-block: 2.5rem;
+ border-top: 1px solid var(--border);
+}
+
+.usage-item:first-child {
+ border-top: none;
+ padding-top: 1.5rem;
+}
+
+.usage-num {
+ display: block;
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.8rem;
+ color: var(--accent);
+ margin-bottom: 0.6rem;
+}
+
+.usage-item h3 {
+ font-size: 1.15rem;
+ margin-bottom: 0.7rem;
+}
+
+.usage-body {
+ font-size: 0.95rem;
+ color: var(--text-dim);
+ text-wrap: pretty;
+}
+
+@media (max-width: 960px) {
+ .usage-item {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ---------- Live demo panels ---------- */
+.demo-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.25rem;
+}
+
+.panel {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.4rem 1.5rem;
+ transition: border-color 0.25s ease;
+}
+
+.panel-live {
+ border-color: var(--border-strong);
+}
+
+.panel-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.panel-head h3 {
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.badge {
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.72rem;
+ border-radius: 999px;
+ padding: 0.18rem 0.65rem;
+ white-space: nowrap;
+ color: var(--text-faint);
+ background: var(--surface-hover);
+ border: 1px solid var(--border);
+ transition:
+ color 0.3s ease,
+ background-color 0.3s ease,
+ border-color 0.3s ease;
+}
+
+.badge-ok {
+ color: var(--ok);
+ background: var(--ok-soft);
+ border-color: transparent;
+}
+
+.panel dl {
+ margin: 0;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.panel .row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 1rem;
+ padding: 0.3rem 0;
+ border-bottom: 1px solid var(--border);
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.88rem;
+}
+
+.panel .row:last-child {
+ border-bottom: none;
+}
+
+.panel dt {
+ color: var(--text-faint);
+}
+
+.panel dd {
+ margin: 0;
+ color: var(--text);
+}
+
+.panel-live dd {
+ color: var(--accent-strong);
+}
+
+.panel-note {
+ margin-top: 1rem;
+ font-size: 0.88rem;
+ color: var(--text-faint);
+ text-wrap: pretty;
+}
+
+.demo-hint {
+ margin-top: 1.5rem;
+ max-width: 46rem;
+ font-size: 0.95rem;
+ color: var(--text-dim);
+ border-left: 3px solid var(--accent);
+ padding-left: 1rem;
+}
+
+/* ---------- Code blocks (always dark, even in light mode) ---------- */
+.codeblock {
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ max-width: 46rem;
+}
+
+.codeblock-bar {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.6rem 1rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.codeblock-dots {
+ display: inline-flex;
+ gap: 0.35rem;
+}
+
+.codeblock-dots i {
+ width: 0.65rem;
+ height: 0.65rem;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.codeblock-name {
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.78rem;
+ color: #74748c;
+}
+
+.codeblock pre {
+ margin: 0;
+ padding: 1.1rem 1.25rem;
+ overflow-x: auto;
+ font-size: 0.88rem;
+ line-height: 1.65;
+}
+
+.codeblock code {
+ font-family: var(--font-mono), var(--mono-fallback);
+ color: var(--code-text);
+}
+
+/* ---------- Features grid ---------- */
+.features-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 1.25rem;
+}
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.4rem 1.5rem;
+ transition:
+ border-color 0.25s ease,
+ background-color 0.25s ease;
+}
+
+.card:hover {
+ border-color: var(--accent);
+ background: var(--surface-hover);
+}
+
+.card h3 {
+ font-size: 1.02rem;
+ font-weight: 600;
+ margin-bottom: 0.6rem;
+}
+
+.card p {
+ font-size: 0.92rem;
+ color: var(--text-dim);
+}
+
+/* ---------- API table ---------- */
+.api-table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
+.api-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.92rem;
+}
+
+.api-table th {
+ text-align: left;
+ font-size: 0.78rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-faint);
+ padding: 0.8rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.api-table td {
+ padding: 0.85rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+ vertical-align: top;
+}
+
+.api-table tr:last-child td {
+ border-bottom: none;
+}
+
+.api-table td:first-child {
+ font-family: var(--font-mono), var(--mono-fallback);
+ font-size: 0.85rem;
+ color: var(--accent-strong);
+ white-space: nowrap;
+}
+
+.api-table td:last-child {
+ color: var(--text-dim);
+}
+
+.api-docs-link {
+ margin-top: 1.25rem;
+ font-size: 0.95rem;
+ color: var(--text-dim);
+}
+
+/* ---------- Footer ---------- */
+.site-footer {
+ border-top: 1px solid var(--border);
+ padding-block: 2.5rem;
+ margin-top: 2rem;
+}
+
+.footer-inner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ font-size: 0.9rem;
+ color: var(--text-faint);
+}
+
+.footer-links {
+ display: flex;
+ gap: 1.25rem;
+}
+
+.footer-links a {
+ color: var(--text-dim);
+}
+
+/* ---------- Responsive ---------- */
+@media (max-width: 860px) {
+ .features-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .demo-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .hero {
+ padding-block: 4.5rem 3.5rem;
+ }
+
+ .section {
+ padding-block: 3.25rem;
+ }
+}
diff --git a/website/app/icon.svg b/website/app/icon.svg
new file mode 100644
index 0000000..361bcdc
--- /dev/null
+++ b/website/app/icon.svg
@@ -0,0 +1,12 @@
+
diff --git a/website/app/robots.ts b/website/app/robots.ts
new file mode 100644
index 0000000..125b2ae
--- /dev/null
+++ b/website/app/robots.ts
@@ -0,0 +1,9 @@
+import type { MetadataRoute } from 'next';
+import { SITE_URL } from '@/lib/seo';
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: { userAgent: '*', allow: '/' },
+ sitemap: `${SITE_URL}/sitemap.xml`,
+ };
+}
diff --git a/website/app/sitemap.ts b/website/app/sitemap.ts
new file mode 100644
index 0000000..12958f4
--- /dev/null
+++ b/website/app/sitemap.ts
@@ -0,0 +1,12 @@
+import type { MetadataRoute } from 'next';
+import { SITE_URL } from '@/lib/seo';
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ // hreflang alternates in the sitemap mirror the per-page metadata alternates
+ const languages = { en: `${SITE_URL}/`, ko: `${SITE_URL}/ko` };
+
+ return [
+ { url: `${SITE_URL}/`, alternates: { languages } },
+ { url: `${SITE_URL}/ko`, alternates: { languages } },
+ ];
+}
diff --git a/website/components/CodeBlock.tsx b/website/components/CodeBlock.tsx
new file mode 100644
index 0000000..512edd0
--- /dev/null
+++ b/website/components/CodeBlock.tsx
@@ -0,0 +1,22 @@
+interface CodeBlockProps {
+ code: string;
+ filename?: string;
+}
+
+export default function CodeBlock({ code, filename }: CodeBlockProps) {
+ return (
+
+
+
+
+
+
+
+ {filename && {filename}}
+
+
+ {code}
+
+
+ );
+}
diff --git a/website/components/DeviceShowcase.tsx b/website/components/DeviceShowcase.tsx
new file mode 100644
index 0000000..2dd1303
--- /dev/null
+++ b/website/components/DeviceShowcase.tsx
@@ -0,0 +1,184 @@
+import type { ShowcaseStrings } from '@/content/types';
+
+interface DeviceShowcaseProps {
+ strings: ShowcaseStrings;
+}
+
+type ScreenValue = readonly [key: string, value: string];
+
+type DeviceKind = 'phone' | 'tablet' | 'monitor' | 'laptop' | 'tv';
+
+interface DeviceSpec {
+ kind: DeviceKind;
+ // The deceptive signal the device sends, shown next to the hook's verdict
+ claim: string;
+ values: ScreenValue[];
+}
+
+// Values/claims are locale-independent code output; names/captions come from the strings dict by index
+const DEVICES: DeviceSpec[] = [
+ {
+ kind: 'phone',
+ claim: 'Macintosh; Intel Mac OS X · screen: 393px',
+ values: [
+ ['type', "'mobile'"],
+ ['os', "'ios'"],
+ ['isTouchPrimary', 'true'],
+ ['orientation', "'portrait'"],
+ ],
+ },
+ {
+ kind: 'phone',
+ claim: 'Linux; Android 14; K',
+ values: [
+ ['type', "'mobile'"],
+ ['os', "'android'"],
+ ['isTouchPrimary', 'true'],
+ ['orientation', "'portrait'"],
+ ],
+ },
+ {
+ kind: 'tablet',
+ claim: 'Macintosh; Intel Mac OS X 10_15_7',
+ values: [
+ ['type', "'tablet'"],
+ ['os', "'ios'"],
+ ['isTouchPrimary', 'true'],
+ ['orientation', "'landscape'"],
+ ],
+ },
+ {
+ kind: 'monitor',
+ claim: 'Macintosh; Intel Mac OS X 10_15_7 · maxTouchPoints: 0',
+ values: [
+ ['type', "'desktop'"],
+ ['os', "'macos'"],
+ ['isTouchPrimary', 'false'],
+ ['orientation', "'landscape'"],
+ ],
+ },
+ {
+ kind: 'laptop',
+ claim: 'Windows NT 10.0 · maxTouchPoints: 10',
+ values: [
+ ['type', "'desktop'"],
+ ['os', "'windows'"],
+ ['isTouchPrimary', 'false'],
+ ['orientation', "'landscape'"],
+ ],
+ },
+ {
+ kind: 'tv',
+ claim: 'Linux; Android 11; SHIELD Android TV',
+ values: [
+ ['type', "'desktop'"],
+ ['os', "'android'"],
+ ['isTouchPrimary', 'false'],
+ ['orientation', "'landscape'"],
+ ],
+ },
+];
+
+function Screen({ values }: { values: ScreenValue[] }) {
+ return (
+
+ {values.map(([key, value]) => (
+
+ {key}:{' '}
+ {value}
+
+ ))}
+
+ );
+}
+
+function DeviceFrame({
+ kind,
+ values,
+}: {
+ kind: DeviceKind;
+ values: ScreenValue[];
+}) {
+ if (kind === 'laptop') {
+ return (
+
+ );
+ }
+ if (kind === 'monitor' || kind === 'tv') {
+ return (
+
+
+
+
+ {kind === 'monitor' && (
+
+ )}
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+ {kind === 'phone' &&
}
+
+
+
+
+ );
+}
+
+export default function DeviceShowcase({ strings }: DeviceShowcaseProps) {
+ return (
+
+ {DEVICES.map((device, index) => {
+ const meta = strings.devices[index];
+ const [[, type], [, os]] = device.values;
+ return (
+
+
+
+
+
+
{meta.name}
+
+ {strings.claimLabel}
+ {device.claim}
+
+
+ {strings.verdictLabel}
+
+ {type}
+ {os}
+
+
+
{meta.caption}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/website/components/InstallTabs.tsx b/website/components/InstallTabs.tsx
new file mode 100644
index 0000000..e1368fd
--- /dev/null
+++ b/website/components/InstallTabs.tsx
@@ -0,0 +1,66 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { INSTALL_COMMANDS } from '@/content/code';
+
+interface InstallTabsProps {
+ strings: { copyHint: string; copied: string };
+}
+
+export default function InstallTabs({ strings }: InstallTabsProps) {
+ const [active, setActive] = useState(0);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ if (!copied) return;
+ const timer = setTimeout(() => setCopied(false), 2000);
+ return () => clearTimeout(timer);
+ }, [copied]);
+
+ const handleCopy = async () => {
+ try {
+ // Clipboard API is absent in insecure contexts and can reject on permissions
+ await navigator.clipboard.writeText(INSTALL_COMMANDS[active].command);
+ setCopied(true);
+ } catch {
+ // Copy silently failed — keep the hint label so the UI never claims success
+ }
+ };
+
+ return (
+
+
+ {INSTALL_COMMANDS.map((item, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/website/components/Landing.tsx b/website/components/Landing.tsx
new file mode 100644
index 0000000..3248e2d
--- /dev/null
+++ b/website/components/Landing.tsx
@@ -0,0 +1,233 @@
+import type { LandingStrings, Locale } from '@/content/types';
+import { USAGE_EXAMPLES } from '@/content/code';
+import { GITHUB_URL, NPM_URL, buildJsonLd } from '@/lib/seo';
+import CodeBlock from './CodeBlock';
+import DeviceShowcase from './DeviceShowcase';
+import InstallTabs from './InstallTabs';
+import LiveDemo from './LiveDemo';
+
+interface LandingProps {
+ locale: Locale;
+ strings: LandingStrings;
+}
+
+interface SectionHeadProps {
+ id: string;
+ overline: string;
+ title: string;
+}
+
+function SectionHead({ id, overline, title }: SectionHeadProps) {
+ return (
+
+ );
+}
+
+// The entire landing markup lives here once; locale only swaps strings and link targets
+export default function Landing({ locale, strings: s }: LandingProps) {
+ const readmeUrl =
+ locale === 'ko'
+ ? `${GITHUB_URL}/blob/main/README.ko.md`
+ : `${GITHUB_URL}#readme`;
+ const homePath = locale === 'en' ? '/' : '/ko';
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {s.hero.badges.map((badge) => (
+
+ {badge}
+
+ ))}
+
+
+ {s.hero.titlePre}
+ {s.hero.titleAccent}
+ {s.hero.titlePost}
+
+ {s.hero.tagline}
+
+
+
+
+
+
+ {s.showcase.intro}
+
+
+
+
+
+
+ {s.demo.intro}
+
+
+ {s.demo.hint}
+
+
+
+
+ {s.usage.body}
+
+ {s.usage.examples.map((example, index) => (
+
+
+
+ {String(index + 1).padStart(2, '0')}
+
+
{example.title}
+
{example.body}
+
+
+
+ ))}
+
+
+
+
+
+
+ {s.features.items.map((item) => (
+
+ {item.title}
+ {item.body}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ | API |
+ Description |
+
+
+
+ {s.api.rows.map((row) => (
+
+ | {row.name} |
+ {row.desc} |
+
+ ))}
+
+
+
+
+ {s.api.docsLead}{' '}
+
+ {s.api.docsLinkText}
+
+ .
+
+
+
+
+
+ >
+ );
+}
diff --git a/website/components/LiveDemo.tsx b/website/components/LiveDemo.tsx
new file mode 100644
index 0000000..84541ae
--- /dev/null
+++ b/website/components/LiveDemo.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import { useState } from 'react';
+import { useDevice } from 'react-device-check';
+import type { DemoStrings } from '@/content/types';
+
+interface LiveDemoProps {
+ strings: DemoStrings;
+}
+
+type Row = readonly [label: string, value: string];
+
+export default function LiveDemo({ strings }: LiveDemoProps) {
+ const device = useDevice();
+ // Freeze the hydration-render snapshot — this is exactly what the server sent
+ const [firstPaint] = useState(device);
+
+ const toRows = (d: typeof device): Row[] => [
+ ['type', d.type],
+ ['os', d.os],
+ ['isTouchPrimary', String(d.isTouchPrimary)],
+ ['orientation', d.orientation],
+ ['isHydrated', String(d.isHydrated)],
+ ];
+
+ return (
+
+
+
+ {strings.serverPanel}
+ {strings.waitingBadge}
+
+
+ {toRows(firstPaint).map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+ {strings.serverNote}
+
+
+
+
+ {strings.livePanel}
+
+ {device.isHydrated ? strings.hydratedBadge : strings.waitingBadge}
+
+
+
+ {toRows(device).map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+ {strings.liveNote}
+
+
+ );
+}
diff --git a/website/content/code.ts b/website/content/code.ts
new file mode 100644
index 0000000..2c85365
--- /dev/null
+++ b/website/content/code.ts
@@ -0,0 +1,55 @@
+// Locale-independent code snippets — code and comments stay English for both locales
+
+// One entry per package manager, rendered as click-to-copy tabs in the hero
+export const INSTALL_COMMANDS = [
+ { pm: 'npm', command: 'npm install react-device-check' },
+ { pm: 'yarn', command: 'yarn add react-device-check' },
+ { pm: 'pnpm', command: 'pnpm add react-device-check' },
+];
+
+// Paired with strings.usage.examples by index
+export const USAGE_EXAMPLES = [
+ {
+ filename: 'app/page.tsx',
+ code: `import { useDevice } from 'react-device-check';
+
+export default function Page() {
+ const { type, os, isMobile, isHydrated } = useDevice();
+
+ // Server render & hydration first paint: type = 'desktop', isHydrated = false.
+ // One render later the real device shows up — no hydration mismatch, ever.
+ if (!isHydrated) return ;
+
+ if (isMobile && os === 'ios') return ;
+ return You are on a {type} running {os}.
;
+}`,
+ },
+ {
+ filename: 'components/DownloadButton.tsx',
+ code: `import { useIsMobile, useOS } from 'react-device-check';
+
+export default function DownloadButton() {
+ const isMobile = useIsMobile(); // boolean only — ~1.1 kB total
+ const os = useOS(); // 'ios' | 'android' | ...
+
+ if (isMobile && os === 'ios') return ;
+ if (isMobile && os === 'android') return ;
+ return ;
+}`,
+ },
+ {
+ filename: 'middleware.ts',
+ code: `import { detectDevice } from 'react-device-check';
+
+export function middleware(request: Request) {
+ // No React, no globals — inject any signals you have
+ const { type } = detectDevice({
+ ua: request.headers.get('user-agent') ?? '',
+ });
+
+ if (type === 'mobile') {
+ // e.g. rewrite to the lightweight variant
+ }
+}`,
+ },
+];
diff --git a/website/content/en.ts b/website/content/en.ts
new file mode 100644
index 0000000..5ae5b32
--- /dev/null
+++ b/website/content/en.ts
@@ -0,0 +1,162 @@
+import type { LandingStrings } from './types';
+
+export const en: LandingStrings = {
+ header: {
+ langLabel: '한국어',
+ langHref: '/ko',
+ },
+ hero: {
+ badges: ['~1.5 kB min+brotli', 'Zero dependencies', 'React 17–19', 'MIT'],
+ titlePre: 'Device detection that’s right ',
+ titleAccent: 'in CSR and SSR',
+ titlePost: '',
+ tagline:
+ 'In React CSR apps and Next.js SSR alike, know exactly whether your user is on a phone, tablet, or desktop, and which OS. Zero dependencies, iPads unmask themselves, frozen user agents don’t matter, and there is not a single hydration error.',
+ ctaDemo: 'See it live',
+ ctaGithub: 'GitHub',
+ },
+ showcase: {
+ overline: 'At a glance',
+ title: 'Devices lie. The answers don’t.',
+ intro:
+ 'Each screen shows what useDevice() returns on that device — including the ones that lie about themselves.',
+ claimLabel: 'What it claims',
+ verdictLabel: 'What the hook returns',
+ devices: [
+ {
+ name: 'iPhone 15',
+ caption:
+ 'With Safari’s “Request Desktop Website” even an iPhone claims to be a Mac. Multitouch plus the screen-size cross-check still says mobile.',
+ },
+ {
+ name: 'Galaxy S24',
+ caption:
+ 'Chrome froze the UA — every Android reports model “K”. Client Hints still nail it.',
+ },
+ {
+ name: 'iPad Pro',
+ caption:
+ 'Masquerades as a Mac since iPadOS 13. Multitouch unmasking says tablet anyway.',
+ },
+ {
+ name: 'iMac',
+ caption:
+ 'A real Mac sending the exact same UA as the iPad above. The multitouch cross-check (maxTouchPoints: 0) is what tells them apart.',
+ },
+ {
+ name: 'Windows touch laptop',
+ caption:
+ 'A touchscreen doesn’t fool it — touch is only consulted for the Apple masquerade. Stays desktop.',
+ },
+ {
+ name: 'Android TV',
+ caption:
+ 'An Android UA without the Mobile token would normally land in the tablet bucket, but TV markers are checked first — desktop is the best fit for a 10-foot UI.',
+ },
+ ],
+ },
+ demo: {
+ overline: 'Live demo',
+ title: 'Watch the SSR contract in action',
+ intro:
+ 'This page is server-rendered by Next.js. The left panel is frozen at the hydration first paint — exactly what the server sent. The right panel is what the hook knows right now.',
+ serverPanel: 'First paint (what the server rendered)',
+ serverNote:
+ 'Always desktop / unknown, on every device — that is why server HTML and client HTML can never disagree.',
+ livePanel: 'Live values',
+ liveNote:
+ 'Corrected in a single render right after hydration. isTouchPrimary and orientation keep updating live.',
+ waitingBadge: 'server default',
+ hydratedBadge: 'hydrated',
+ hint: 'Open this page on a phone, or reload with DevTools device emulation: the left panel stays desktop while the right one tells the truth — and the console logs zero hydration errors.',
+ },
+ install: {
+ copyHint: 'Click to copy',
+ copied: 'Copied!',
+ },
+ usage: {
+ overline: 'Usage',
+ title: 'Three ways to use it',
+ body: 'From a one-line boolean to the framework-free engine — each import ships only what it actually needs.',
+ examples: [
+ {
+ title: 'Read the full snapshot',
+ body: 'useDevice() returns type, os, boolean sugar, and the live fields. The server render and the hydration first paint always agree by construction, so you never write typeof window guards — branch on isHydrated only when you want to hide the one-render correction.',
+ },
+ {
+ title: 'Import only what you ship',
+ body: 'The static hooks are listener-free and maximally tree-shakeable: importing only useIsMobile and useOS drops the reactive store entirely and ships ~1.1 kB. Perfect for OS-specific store buttons.',
+ },
+ {
+ title: 'Use the engine anywhere',
+ body: 'detectDevice() is the pure decision tree behind the hooks — no React, no globals. Inject a UA string (or Client Hints) and get the same deterministic verdict in middleware, on servers, or in tests.',
+ },
+ ],
+ },
+ features: {
+ overline: 'Why',
+ title: 'Built for how devices lie in 2026',
+ items: [
+ {
+ title: 'Accurate where others fail',
+ body: 'iPads report as tablets even behind the macOS desktop UA (MacIntel + multitouch unmasking). Android tablets follow the official Mobile-token rule; Samsung DeX and in-app WebViews are handled.',
+ },
+ {
+ title: 'Client Hints first',
+ body: 'Trusts navigator.userAgentData on Chromium — immune to user-agent freezing — and falls back to UA parsing everywhere else.',
+ },
+ {
+ title: 'SSR-safe by construction',
+ body: 'Server render and hydration first paint always match, so React 18/19 never log a hydration mismatch. The hook corrects itself in one post-hydration render.',
+ },
+ {
+ title: 'Tiny and tree-shakeable',
+ body: 'Zero runtime dependencies, dual ESM/CJS. Importing only useIsMobile ships ~1.1 kB and drops the reactive store entirely — budgets are enforced in CI.',
+ },
+ {
+ title: 'Hybrid reactivity',
+ body: 'type and os stay stable for the session, while isTouchPrimary and orientation update live via matchMedia — covering foldables, DeX docking, and iPad Stage Manager.',
+ },
+ {
+ title: 'Proven in real browsers',
+ body: 'Beyond 76 unit tests, a Playwright matrix — iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, desktop Chrome and Safari — verifies detection and zero hydration errors.',
+ },
+ ],
+ },
+ compare: {
+ overline: 'Comparison',
+ title: 'What about react-device-detect?',
+ body: 'react-device-detect computes import-time constants from the UA, which crashes or mismatches under SSR, misreports iPads as desktops, never updates, and ships ~13 kB gzip that cannot be tree-shaken. It has been unmaintained since 2023, and its parser dependency moved to AGPL. react-device-check is a maintained MIT replacement designed around today’s platform realities.',
+ },
+ api: {
+ overline: 'API',
+ title: 'Small surface, full coverage',
+ rows: [
+ {
+ name: 'useDevice()',
+ desc: 'Full snapshot: type, os, boolean sugar, live isTouchPrimary / orientation, and isHydrated.',
+ },
+ {
+ name: 'useDeviceType()',
+ desc: "'mobile' | 'tablet' | 'desktop' — static per session, listener-free.",
+ },
+ {
+ name: 'useIsMobile() · useIsTablet() · useIsDesktop()',
+ desc: 'Boolean sugar — maximally tree-shakeable; importing only these drops the reactive store.',
+ },
+ {
+ name: 'useOS()',
+ desc: "'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'.",
+ },
+ {
+ name: 'detectDevice(input?, options?)',
+ desc: 'The pure engine — no React required, every signal injectable. Great for servers and tests.',
+ },
+ ],
+ docsLead: 'Full API reference and known limitations live in the',
+ docsLinkText: 'GitHub README',
+ },
+ footer: {
+ tagline: 'MIT licensed · built by umsungjun',
+ },
+};
diff --git a/website/content/ko.ts b/website/content/ko.ts
new file mode 100644
index 0000000..9a7cfcf
--- /dev/null
+++ b/website/content/ko.ts
@@ -0,0 +1,162 @@
+import type { LandingStrings } from './types';
+
+export const ko: LandingStrings = {
+ header: {
+ langLabel: 'English',
+ langHref: '/',
+ },
+ hero: {
+ badges: ['~1.5 kB min+brotli', '의존성 0개', 'React 17–19', 'MIT'],
+ titlePre: 'CSR에서도, SSR에서도 ',
+ titleAccent: '정확한 기기 판별',
+ titlePost: '',
+ tagline:
+ 'React CSR 앱에서도 Next.js SSR에서도, 사용자가 폰·태블릿·데스크톱 중 무엇으로, 어떤 OS에서 접근했는지 정확하게 알 수 있습니다. 의존성 0개에 iPad 위장 해제와 동결된 User-Agent까지 처리하며, hydration 에러는 단 한 건도 발생하지 않습니다.',
+ ctaDemo: '라이브로 보기',
+ ctaGithub: 'GitHub',
+ },
+ showcase: {
+ overline: 'At a glance',
+ title: '기기는 속여도, 답은 정직합니다',
+ intro:
+ '각 화면은 그 기기에서 useDevice()가 반환하는 값입니다 — 자신을 속이는 기기까지 포함해서요.',
+ claimLabel: '기기의 주장',
+ verdictLabel: '훅의 판별',
+ devices: [
+ {
+ name: 'iPhone 15',
+ caption:
+ 'Safari의 “데스크톱 웹사이트 요청”을 켜면 iPhone조차 자신을 Mac이라고 주장합니다. 멀티터치와 화면 크기 교차검증이 그래도 mobile임을 밝혀냅니다.',
+ },
+ {
+ name: 'Galaxy S24',
+ caption:
+ 'Chrome은 UA를 동결해 모든 안드로이드가 모델명 “K”로 보고됩니다. Client Hints가 정확히 판별합니다.',
+ },
+ {
+ name: 'iPad Pro',
+ caption:
+ 'iPadOS 13부터 Mac으로 위장하지만, 멀티터치 언마스킹이 태블릿임을 밝혀냅니다.',
+ },
+ {
+ name: 'iMac',
+ caption:
+ '위의 iPad와 완전히 같은 UA를 보내는 진짜 Mac입니다. 멀티터치 교차검증(maxTouchPoints: 0)이 둘을 구분합니다.',
+ },
+ {
+ name: 'Windows 터치 노트북',
+ caption:
+ '터치스크린에 속지 않습니다 — 터치 신호는 Apple 위장 분기에서만 참조됩니다. desktop을 유지합니다.',
+ },
+ {
+ name: 'Android TV',
+ caption:
+ 'Mobile 토큰이 없는 Android UA라 원래는 태블릿으로 분류될 신호지만, TV 마커를 먼저 확인합니다. 10-foot UI에는 desktop이 가장 맞는 답입니다.',
+ },
+ ],
+ },
+ demo: {
+ overline: 'Live demo',
+ title: 'SSR 계약이 동작하는 모습',
+ intro:
+ '이 페이지는 Next.js로 서버 렌더됩니다. 왼쪽 패널은 hydration 첫 페인트 시점에 동결된 값 — 서버가 보낸 그대로입니다. 오른쪽 패널은 훅이 지금 알고 있는 값입니다.',
+ serverPanel: '첫 페인트 (서버가 렌더한 값)',
+ serverNote:
+ '어떤 기기에서든 항상 desktop / unknown — 그래서 서버 HTML과 클라이언트 HTML이 어긋날 수 없습니다.',
+ livePanel: '라이브 값',
+ liveNote:
+ 'hydration 직후 단 한 번의 렌더로 교정됩니다. isTouchPrimary와 orientation은 계속 실시간 갱신됩니다.',
+ waitingBadge: 'server default',
+ hydratedBadge: 'hydrated',
+ hint: '이 페이지를 폰에서 열거나 DevTools 기기 에뮬레이션으로 새로고침해 보세요. 왼쪽 패널은 desktop에 머물고 오른쪽이 진실을 말합니다 — 콘솔의 hydration 에러는 0건입니다.',
+ },
+ install: {
+ copyHint: '클릭해서 복사',
+ copied: '복사됨!',
+ },
+ usage: {
+ overline: 'Usage',
+ title: '세 가지 사용 방법',
+ body: '불리언 한 줄부터 프레임워크 없는 순수 엔진까지 — 각 import는 실제로 필요한 만큼만 번들에 담습니다.',
+ examples: [
+ {
+ title: '전체 스냅샷 읽기',
+ body: 'useDevice()는 type, os, 불리언 슈가와 실시간 필드를 반환합니다. 서버 렌더와 hydration 첫 페인트는 구조적으로 항상 일치하므로 typeof window 가드를 쓸 일이 없고, 교정 렌더 한 번을 감추고 싶을 때만 isHydrated로 분기하면 됩니다.',
+ },
+ {
+ title: '쓰는 것만 import',
+ body: '정적 훅은 리스너가 없고 최대한으로 tree-shaking됩니다. useIsMobile과 useOS만 import하면 반응형 스토어가 통째로 빠져 ~1.1 kB만 배송됩니다. OS별 앱스토어 버튼 같은 곳에 딱 맞습니다.',
+ },
+ {
+ title: 'React 밖에서도 사용',
+ body: 'detectDevice()는 훅 뒤에서 동작하는 순수 판별 트리입니다 — React도 전역 객체도 필요 없습니다. UA 문자열(또는 Client Hints)을 주입하면 미들웨어, 서버, 테스트 어디서든 같은 결정론적 결과를 얻습니다.',
+ },
+ ],
+ },
+ features: {
+ overline: 'Why',
+ title: '2026년, 기기가 거짓말하는 방식에 맞춘 설계',
+ items: [
+ {
+ title: '다른 라이브러리가 틀리는 곳에서 정확',
+ body: 'iPadOS 13+가 macOS 데스크톱 UA를 보내도 iPad를 태블릿으로 판별합니다(MacIntel + 멀티터치 언마스킹). 안드로이드 태블릿은 공식 Mobile 토큰 규칙으로 구분하고, Samsung DeX와 인앱 웹뷰도 처리합니다.',
+ },
+ {
+ title: 'Client Hints 우선',
+ body: 'Chromium에서는 navigator.userAgentData를 신뢰해 UA 동결에 면역이고, 그 외 환경에서는 UA 파싱으로 폴백합니다.',
+ },
+ {
+ title: '구조적으로 SSR-safe',
+ body: '서버 렌더와 hydration 첫 페인트가 항상 일치해 React 18/19에서 hydration mismatch가 기록되지 않습니다. hydration 직후 렌더 한 번으로 실제 값으로 교정됩니다.',
+ },
+ {
+ title: '작고 tree-shakeable',
+ body: '런타임 의존성 0개, ESM/CJS 듀얼. useIsMobile만 import하면 ~1.1 kB이고 반응형 스토어 전체가 번들에서 제거됩니다 — 예산은 CI에서 강제됩니다.',
+ },
+ {
+ title: '하이브리드 반응성',
+ body: 'type과 os는 세션 동안 고정되고, isTouchPrimary와 orientation은 matchMedia로 실시간 갱신됩니다 — 폴더블, DeX 도킹, iPad Stage Manager까지 커버합니다.',
+ },
+ {
+ title: '실브라우저 검증',
+ body: '76개 단위 테스트에 더해 Playwright 매트릭스 — iPhone 15, iPad Pro, Galaxy S24, Galaxy Tab S9, 데스크톱 Chrome/Safari — 가 판별 결과와 hydration 에러 0건을 검증합니다.',
+ },
+ ],
+ },
+ compare: {
+ overline: 'Comparison',
+ title: 'react-device-detect는요?',
+ body: 'react-device-detect는 import 시점에 UA로 상수를 계산해 SSR에서 크래시하거나 mismatch가 나고, iPad를 데스크톱으로 오판하며, 값이 갱신되지 않고, tree-shaking이 불가능한 ~13 kB gzip을 항상 배송합니다. 2023년 이후 유지보수가 중단됐고 파서 의존성은 AGPL로 전환됐습니다. react-device-check는 오늘의 플랫폼 현실에 맞춰 설계된, 유지보수되는 MIT 대안입니다.',
+ },
+ api: {
+ overline: 'API',
+ title: '작은 표면적, 빠짐없는 커버리지',
+ rows: [
+ {
+ name: 'useDevice()',
+ desc: '전체 스냅샷: type, os, 불리언 슈가, 실시간 isTouchPrimary / orientation, isHydrated.',
+ },
+ {
+ name: 'useDeviceType()',
+ desc: "'mobile' | 'tablet' | 'desktop' — 세션 동안 정적, 리스너 없음.",
+ },
+ {
+ name: 'useIsMobile() · useIsTablet() · useIsDesktop()',
+ desc: '불리언 슈가 — 최대 tree-shaking. 이것만 import하면 반응형 스토어가 번들에서 빠집니다.',
+ },
+ {
+ name: 'useOS()',
+ desc: "'ios' | 'android' | 'windows' | 'macos' | 'linux' | 'unknown'.",
+ },
+ {
+ name: 'detectDevice(input?, options?)',
+ desc: 'React 없이 쓰는 순수 엔진 — 모든 신호를 주입할 수 있어 서버와 테스트에 적합합니다.',
+ },
+ ],
+ docsLead: '전체 API 레퍼런스와 알려진 한계는 여기에 있습니다:',
+ docsLinkText: 'GitHub README (한국어)',
+ },
+ footer: {
+ tagline: 'MIT 라이선스 · made by umsungjun',
+ },
+};
diff --git a/website/content/types.ts b/website/content/types.ts
new file mode 100644
index 0000000..ebd03d1
--- /dev/null
+++ b/website/content/types.ts
@@ -0,0 +1,66 @@
+export type Locale = 'en' | 'ko';
+
+interface SectionHeading {
+ overline: string;
+ title: string;
+}
+
+export interface ShowcaseStrings extends SectionHeading {
+ intro: string;
+ claimLabel: string;
+ verdictLabel: string;
+ devices: { name: string; caption: string }[];
+}
+
+export interface DemoStrings extends SectionHeading {
+ intro: string;
+ serverPanel: string;
+ serverNote: string;
+ livePanel: string;
+ liveNote: string;
+ waitingBadge: string;
+ hydratedBadge: string;
+ hint: string;
+}
+
+// One interface for every visible string — TypeScript keeps en/ko structurally in sync
+export interface LandingStrings {
+ header: {
+ langLabel: string;
+ langHref: string;
+ };
+ hero: {
+ badges: string[];
+ titlePre: string;
+ titleAccent: string;
+ titlePost: string;
+ tagline: string;
+ ctaDemo: string;
+ ctaGithub: string;
+ };
+ showcase: ShowcaseStrings;
+ demo: DemoStrings;
+ install: {
+ copyHint: string;
+ copied: string;
+ };
+ usage: SectionHeading & {
+ body: string;
+ // Explanations pair with USAGE_EXAMPLES in content/code.ts by index
+ examples: { title: string; body: string }[];
+ };
+ features: SectionHeading & {
+ items: { title: string; body: string }[];
+ };
+ compare: SectionHeading & {
+ body: string;
+ };
+ api: SectionHeading & {
+ rows: { name: string; desc: string }[];
+ docsLead: string;
+ docsLinkText: string;
+ };
+ footer: {
+ tagline: string;
+ };
+}
diff --git a/website/lib/fonts.ts b/website/lib/fonts.ts
new file mode 100644
index 0000000..f27ba5a
--- /dev/null
+++ b/website/lib/fonts.ts
@@ -0,0 +1,10 @@
+import { Inter, JetBrains_Mono } from 'next/font/google';
+
+// Self-hosted via next/font at build time — no external requests at runtime, zero CLS
+export const inter = Inter({ subsets: ['latin'], variable: '--font-sans', display: 'swap' });
+
+export const jetbrainsMono = JetBrains_Mono({
+ subsets: ['latin'],
+ variable: '--font-mono',
+ display: 'swap',
+});
diff --git a/website/lib/seo.ts b/website/lib/seo.ts
new file mode 100644
index 0000000..03f41f8
--- /dev/null
+++ b/website/lib/seo.ts
@@ -0,0 +1,77 @@
+import type { Metadata } from 'next';
+import type { Locale } from '@/content/types';
+
+// Single source of truth for the deployed origin — change here if a custom domain is added
+export const SITE_URL = 'https://react-device-check-site.vercel.app';
+export const GITHUB_URL = 'https://github.com/umsungjun/react-device-check';
+export const NPM_URL = 'https://www.npmjs.com/package/react-device-check';
+
+const TITLES: Record = {
+ en: 'react-device-check — SSR-safe React device detection hooks',
+ ko: 'react-device-check — SSR-safe React 기기 판별 훅',
+};
+
+const DESCRIPTIONS: Record = {
+ en: 'Detect mobile, tablet, or desktop and the OS in React with zero dependencies, ~1.5 kB, and no hydration errors in Next.js. iPad-as-Mac unmasking, Client Hints first, React 17–19.',
+ ko: '의존성 0개, ~1.5 kB, Next.js hydration 에러 없이 React에서 모바일·태블릿·데스크톱과 OS를 판별하세요. iPad 위장 해제, Client Hints 우선, React 17–19 지원.',
+};
+
+const PATHS: Record = { en: '/', ko: '/ko' };
+
+// One builder for both routes so canonical/hreflang shapes can never drift
+export const buildMetadata = (locale: Locale): Metadata => ({
+ metadataBase: new URL(SITE_URL),
+ title: TITLES[locale],
+ description: DESCRIPTIONS[locale],
+ keywords: [
+ 'react',
+ 'react-hook',
+ 'device-detection',
+ 'mobile-detect',
+ 'is-mobile',
+ 'is-tablet',
+ 'user-agent',
+ 'client-hints',
+ 'ssr',
+ 'nextjs',
+ 'hydration',
+ 'typescript',
+ ],
+ alternates: {
+ canonical: PATHS[locale],
+ languages: { en: '/', ko: '/ko', 'x-default': '/' },
+ },
+ openGraph: {
+ type: 'website',
+ url: PATHS[locale],
+ siteName: 'react-device-check',
+ title: TITLES[locale],
+ description: DESCRIPTIONS[locale],
+ locale: locale === 'en' ? 'en_US' : 'ko_KR',
+ alternateLocale: locale === 'en' ? 'ko_KR' : 'en_US',
+ // Static file in public/ — explicit reference because the opengraph-image
+ // file convention doesn't inject meta tags across route-group root layouts
+ images: [{ url: '/og.png', width: 1200, height: 630, alt: TITLES[locale] }],
+ },
+ twitter: {
+ card: 'summary_large_image',
+ title: TITLES[locale],
+ description: DESCRIPTIONS[locale],
+ images: ['/og.png'],
+ },
+});
+
+// SoftwareApplication rich-result shape: free-price offer is the standard answer for OSS
+export const buildJsonLd = (locale: Locale) => ({
+ '@context': 'https://schema.org',
+ '@type': 'SoftwareApplication',
+ name: 'react-device-check',
+ description: DESCRIPTIONS[locale],
+ url: SITE_URL,
+ applicationCategory: 'DeveloperApplication',
+ operatingSystem: 'Any',
+ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
+ license: 'https://opensource.org/licenses/MIT',
+ inLanguage: locale,
+ sameAs: [GITHUB_URL, NPM_URL],
+});
diff --git a/website/next-env.d.ts b/website/next-env.d.ts
new file mode 100644
index 0000000..830fb59
--- /dev/null
+++ b/website/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/website/next.config.ts b/website/next.config.ts
new file mode 100644
index 0000000..bd048f5
--- /dev/null
+++ b/website/next.config.ts
@@ -0,0 +1,11 @@
+import path from 'node:path';
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+ // Linting is owned by the repo root; the website has no eslint install of its own
+ eslint: { ignoreDuringBuilds: true },
+ // The repo root has its own lockfile — pin tracing here so Next doesn't infer the wrong root
+ outputFileTracingRoot: path.join(__dirname),
+};
+
+export default nextConfig;
diff --git a/website/package.json b/website/package.json
new file mode 100644
index 0000000..b94e542
--- /dev/null
+++ b/website/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "react-device-check-website",
+ "private": true,
+ "version": "0.0.0",
+ "scripts": {
+ "dev": "next dev -p 3003",
+ "build": "next build",
+ "start": "next start -p 3003"
+ },
+ "dependencies": {
+ "next": "^15.5.0",
+ "react": "^19.2.0",
+ "react-device-check": "^0.1.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.5",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "typescript": "^5.9.3"
+ },
+ "packageManager": "pnpm@10.5.2"
+}
diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml
new file mode 100644
index 0000000..401e5d9
--- /dev/null
+++ b/website/pnpm-lock.yaml
@@ -0,0 +1,592 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ next:
+ specifier: ^15.5.0
+ version: 15.5.22(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react:
+ specifier: ^19.2.0
+ version: 19.2.8
+ react-device-check:
+ specifier: ^0.1.0
+ version: 0.1.0(react@19.2.8)
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.8(react@19.2.8)
+ devDependencies:
+ '@types/node':
+ specifier: ^22.10.5
+ version: 22.20.1
+ '@types/react':
+ specifier: ^19.2.7
+ version: 19.2.18
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.4(@types/react@19.2.18)
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
+packages:
+
+ '@emnapi/runtime@1.11.3':
+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
+
+ '@img/colour@1.1.0':
+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
+ engines: {node: '>=18'}
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-darwin-x64@0.34.5':
+ resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linux-arm64@0.34.5':
+ resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linux-arm@0.34.5':
+ resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@img/sharp-linux-s390x@0.34.5':
+ resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@img/sharp-linux-x64@0.34.5':
+ resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@img/sharp-wasm32@0.34.5':
+ resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [wasm32]
+
+ '@img/sharp-win32-arm64@0.34.5':
+ resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@img/sharp-win32-ia32@0.34.5':
+ resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [ia32]
+ os: [win32]
+
+ '@img/sharp-win32-x64@0.34.5':
+ resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@next/env@15.5.22':
+ resolution: {integrity: sha512-O5BlKb3KtsHkvO0gjjV66PuJnAgCtIEIzwkt50HRAHsQkU1t77eksIXSZV84/WMtZJjWrnDUPKHVRi0D62nSAA==}
+
+ '@next/swc-darwin-arm64@15.5.22':
+ resolution: {integrity: sha512-/VISwtffSg8+fVvBbXdglsvruCsdbBC4dG25iU6xascKVqfQKsj/OtjGnOEkIS7pX5GB9e9/r5QprpicsGL3gw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@next/swc-darwin-x64@15.5.22':
+ resolution: {integrity: sha512-NiA9ve8hbiuhG/Q17a2mZDRVxMTtg3rTOgjLnDaLlE+AEPAQlkkuKrfePEbeOrgYmX0U2KGX4EVEn09hXU5GlQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@next/swc-linux-arm64-gnu@15.5.22':
+ resolution: {integrity: sha512-vAPa9vltW+UW/KWtjXeSUFgV3wb1x9d/BeyC6WFI6eBpL0D2f70oGwtOp6193mNW3qusrpgBzMQferPf+Zh8Dw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@next/swc-linux-arm64-musl@15.5.22':
+ resolution: {integrity: sha512-iknK80pWlNDnkdSr13bd8mMuG3Z2oTxODwsZHvuMY7caMk77+rBLdHVWsy8v2EVa3ZojJ/+wJX5fnq8va6Gv8A==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@next/swc-linux-x64-gnu@15.5.22':
+ resolution: {integrity: sha512-penuEdkwU2OOAiS+n4LE8T/VIoCfAI01QcLZTJ2xc3+l4Q22L/DzURocmI2LU1b+8BMQoLAP1Sze3uYAZT05Bg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@next/swc-linux-x64-musl@15.5.22':
+ resolution: {integrity: sha512-ZM0BKJm3FZ+guG6WT6PcyOLtp6paZ5tngcJC/uUKvLW4Y0TQnnVi1+UGdo8Q6Yxp5gaS82pmC1rD/oFlhkWB3g==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@next/swc-win32-arm64-msvc@15.5.22':
+ resolution: {integrity: sha512-rY/YaumrZaS0//94BnHLF5VSRp0GFUO4GvXNuoCBb0cGSci96yO+p1JaNL2aq9YZAYv9cuZRziV02x5IQH/wjg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@next/swc-win32-x64-msvc@15.5.22':
+ resolution: {integrity: sha512-s5IA4cyrbR2XK/5NWcu5dp8CfPBiKME+UhvNperia7uQybEgg5+LIhGMiY37WQE4rcI4owsDcU4IVUjLoTuDkA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/helpers@0.5.15':
+ resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
+
+ '@types/node@22.20.1':
+ resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
+
+ '@types/react-dom@19.2.4':
+ resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.18':
+ resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
+
+ caniuse-lite@1.0.30001806:
+ resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
+
+ client-only@0.0.1:
+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ nanoid@3.3.17:
+ resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ next@15.5.22:
+ resolution: {integrity: sha512-mrtal1sRxO4YrlDS98sDuIvGZivKbFix8w7oAL9ZynfOgc3cADQOQgvwtMooc18Qr8bKzvQAcHwHZ0mbJ7zcfQ==}
+ engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
+ hasBin: true
+ peerDependencies:
+ '@opentelemetry/api': ^1.1.0
+ '@playwright/test': ^1.51.1
+ babel-plugin-react-compiler: '*'
+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ sass: ^1.3.0
+ peerDependenciesMeta:
+ '@opentelemetry/api':
+ optional: true
+ '@playwright/test':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ sass:
+ optional: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ postcss@8.4.31:
+ resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ react-device-check@0.1.0:
+ resolution: {integrity: sha512-pnxygIvHj7YNv1hrhEtWuPTeKyTpOlcC9Wd+0g6CF6++ZONDbmwWnq6ognUdbeMzyAP2g4BB1wXeWgIAnMUQDw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ react: ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ react-dom@19.2.8:
+ resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
+ peerDependencies:
+ react: ^19.2.8
+
+ react@19.2.8:
+ resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
+ engines: {node: '>=0.10.0'}
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ sharp@0.34.5:
+ resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
+ engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ styled-jsx@5.1.6:
+ resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
+ engines: {node: '>= 12.0.0'}
+ peerDependencies:
+ '@babel/core': '*'
+ babel-plugin-macros: '*'
+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ babel-plugin-macros:
+ optional: true
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@6.21.0:
+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+snapshots:
+
+ '@emnapi/runtime@1.11.3':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@img/colour@1.1.0':
+ optional: true
+
+ '@img/sharp-darwin-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-darwin-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-libvips-darwin-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-darwin-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-arm@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-ppc64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-riscv64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-s390x@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linux-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-arm64@1.2.4':
+ optional: true
+
+ '@img/sharp-libvips-linuxmusl-x64@1.2.4':
+ optional: true
+
+ '@img/sharp-linux-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-arm@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-ppc64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-riscv64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-s390x@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ optional: true
+
+ '@img/sharp-linux-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-arm64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ optional: true
+
+ '@img/sharp-linuxmusl-x64@0.34.5':
+ optionalDependencies:
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ optional: true
+
+ '@img/sharp-wasm32@0.34.5':
+ dependencies:
+ '@emnapi/runtime': 1.11.3
+ optional: true
+
+ '@img/sharp-win32-arm64@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-ia32@0.34.5':
+ optional: true
+
+ '@img/sharp-win32-x64@0.34.5':
+ optional: true
+
+ '@next/env@15.5.22': {}
+
+ '@next/swc-darwin-arm64@15.5.22':
+ optional: true
+
+ '@next/swc-darwin-x64@15.5.22':
+ optional: true
+
+ '@next/swc-linux-arm64-gnu@15.5.22':
+ optional: true
+
+ '@next/swc-linux-arm64-musl@15.5.22':
+ optional: true
+
+ '@next/swc-linux-x64-gnu@15.5.22':
+ optional: true
+
+ '@next/swc-linux-x64-musl@15.5.22':
+ optional: true
+
+ '@next/swc-win32-arm64-msvc@15.5.22':
+ optional: true
+
+ '@next/swc-win32-x64-msvc@15.5.22':
+ optional: true
+
+ '@swc/helpers@0.5.15':
+ dependencies:
+ tslib: 2.8.1
+
+ '@types/node@22.20.1':
+ dependencies:
+ undici-types: 6.21.0
+
+ '@types/react-dom@19.2.4(@types/react@19.2.18)':
+ dependencies:
+ '@types/react': 19.2.18
+
+ '@types/react@19.2.18':
+ dependencies:
+ csstype: 3.2.3
+
+ caniuse-lite@1.0.30001806: {}
+
+ client-only@0.0.1: {}
+
+ csstype@3.2.3: {}
+
+ detect-libc@2.1.2:
+ optional: true
+
+ nanoid@3.3.17: {}
+
+ next@15.5.22(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ dependencies:
+ '@next/env': 15.5.22
+ '@swc/helpers': 0.5.15
+ caniuse-lite: 1.0.30001806
+ postcss: 8.4.31
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ styled-jsx: 5.1.6(react@19.2.8)
+ optionalDependencies:
+ '@next/swc-darwin-arm64': 15.5.22
+ '@next/swc-darwin-x64': 15.5.22
+ '@next/swc-linux-arm64-gnu': 15.5.22
+ '@next/swc-linux-arm64-musl': 15.5.22
+ '@next/swc-linux-x64-gnu': 15.5.22
+ '@next/swc-linux-x64-musl': 15.5.22
+ '@next/swc-win32-arm64-msvc': 15.5.22
+ '@next/swc-win32-x64-msvc': 15.5.22
+ sharp: 0.34.5
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
+
+ picocolors@1.1.1: {}
+
+ postcss@8.4.31:
+ dependencies:
+ nanoid: 3.3.17
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ react-device-check@0.1.0(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+
+ react-dom@19.2.8(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+ scheduler: 0.27.0
+
+ react@19.2.8: {}
+
+ scheduler@0.27.0: {}
+
+ semver@7.8.5:
+ optional: true
+
+ sharp@0.34.5:
+ dependencies:
+ '@img/colour': 1.1.0
+ detect-libc: 2.1.2
+ semver: 7.8.5
+ optionalDependencies:
+ '@img/sharp-darwin-arm64': 0.34.5
+ '@img/sharp-darwin-x64': 0.34.5
+ '@img/sharp-libvips-darwin-arm64': 1.2.4
+ '@img/sharp-libvips-darwin-x64': 1.2.4
+ '@img/sharp-libvips-linux-arm': 1.2.4
+ '@img/sharp-libvips-linux-arm64': 1.2.4
+ '@img/sharp-libvips-linux-ppc64': 1.2.4
+ '@img/sharp-libvips-linux-riscv64': 1.2.4
+ '@img/sharp-libvips-linux-s390x': 1.2.4
+ '@img/sharp-libvips-linux-x64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-arm64': 1.2.4
+ '@img/sharp-libvips-linuxmusl-x64': 1.2.4
+ '@img/sharp-linux-arm': 0.34.5
+ '@img/sharp-linux-arm64': 0.34.5
+ '@img/sharp-linux-ppc64': 0.34.5
+ '@img/sharp-linux-riscv64': 0.34.5
+ '@img/sharp-linux-s390x': 0.34.5
+ '@img/sharp-linux-x64': 0.34.5
+ '@img/sharp-linuxmusl-arm64': 0.34.5
+ '@img/sharp-linuxmusl-x64': 0.34.5
+ '@img/sharp-wasm32': 0.34.5
+ '@img/sharp-win32-arm64': 0.34.5
+ '@img/sharp-win32-ia32': 0.34.5
+ '@img/sharp-win32-x64': 0.34.5
+ optional: true
+
+ source-map-js@1.2.1: {}
+
+ styled-jsx@5.1.6(react@19.2.8):
+ dependencies:
+ client-only: 0.0.1
+ react: 19.2.8
+
+ tslib@2.8.1: {}
+
+ typescript@5.9.3: {}
+
+ undici-types@6.21.0: {}
diff --git a/website/public/google6b4dd67b252bbb17.html b/website/public/google6b4dd67b252bbb17.html
new file mode 100644
index 0000000..f8ee4a5
--- /dev/null
+++ b/website/public/google6b4dd67b252bbb17.html
@@ -0,0 +1 @@
+google-site-verification: google6b4dd67b252bbb17.html
diff --git a/website/public/og.png b/website/public/og.png
new file mode 100644
index 0000000..2661145
Binary files /dev/null and b/website/public/og.png differ
diff --git a/website/tsconfig.json b/website/tsconfig.json
new file mode 100644
index 0000000..fb3fc0f
--- /dev/null
+++ b/website/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "paths": {
+ "@/*": ["./*"]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}