diff --git a/site/public/404.html b/site/public/404.html new file mode 100644 index 0000000..0a9909d --- /dev/null +++ b/site/public/404.html @@ -0,0 +1,25 @@ + + + + + + + 페이지를 찾을 수 없습니다 — D3RO Voice + + + + +
+ D3RO VOICE +
+

페이지를 찾을 수 없습니다

+

주소가 바뀌었거나 없는 페이지입니다.

+

This page does not exist or has moved.

+ +
+
+ + diff --git a/site/public/accept-invite.html b/site/public/accept-invite.html deleted file mode 100644 index d694474..0000000 --- a/site/public/accept-invite.html +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - D3RO Voice — 팀 초대 열기 - - - - -
-
-
- D3RO VOICE - SECURE INVITE -
- - - -
-

TEAM ACCESS / MOBILE HANDOFF

-

D3RO Voice 앱에서
팀 초대를 확인해.

-

- 초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을 - 저장하거나 수락 결과를 만들지 않아. -

-

- Acceptance is completed only after the app verifies your signed-in account and server permissions. -

-
- -
-
-
LINK STATUS
-
검증 중
-
-
-
TOKEN FINGERPRINT
-
—
-
-
- - - -
- - D3RO Voice 앱 열기 - - -
- - -
-
- - - diff --git a/site/public/accept-invite/index.html b/site/public/accept-invite/index.html index d694474..2f4e6b1 100644 --- a/site/public/accept-invite/index.html +++ b/site/public/accept-invite/index.html @@ -67,7 +67,7 @@ diff --git a/site/public/legal.css b/site/public/legal.css index 76e4fa0..3beac54 100644 --- a/site/public/legal.css +++ b/site/public/legal.css @@ -15,8 +15,8 @@ body { #090b0f; } -a { color: #ff8a62; } -a:hover { color: #ffad91; } +a { color: #60a5fa; } +a:hover { color: #93c5fd; } .shell { width: min(920px, calc(100% - 32px)); @@ -54,7 +54,7 @@ main { .eyebrow { margin: 0 0 12px; - color: #ff8a62; + color: #60a5fa; font: 700 11px/1.4 "JetBrains Mono", monospace; letter-spacing: .14em; text-transform: uppercase; @@ -85,12 +85,12 @@ ul, ol { padding-left: 22px; } justify-content: center; padding: 0 18px; border-radius: 12px; - background: #3b82f6; + background: #2563eb; color: #fff; font-weight: 500; text-decoration: none; } -.button:hover { background: #ff6b38; color: #fff; } +.button:hover { background: #1d4ed8; color: #fff; } .button.secondary { background: #282d37; color: #f3f4f6; } footer { diff --git a/site/src/components/AppLoopDemo.tsx b/site/src/components/AppLoopDemo.tsx new file mode 100644 index 0000000..3756baf --- /dev/null +++ b/site/src/components/AppLoopDemo.tsx @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useI18n } from '../i18n' +import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion' +import { PauseIcon, PlayIcon } from './icons' + +/** + * 히어로 오른쪽의 반복 예시. 데스크톱 앱의 녹음 캡슐 + * (apps/desktop/src/renderer/popups/recording-tip)을 같은 수치로 재현한다. + * + * 대기 → 오른쪽 Alt 누른 채 말하기(캡슐·파형·경과 시간·부분 전사) + * → 키를 떼면 처리 중(점근 진행 막대) → 커서 자리에 다듬어진 문장 입력 → 다음 예시 + * + * WCAG 2.2.2: 5초 넘게 움직이므로 일시정지 버튼을 둔다. 화면 밖이거나 탭이 숨겨지면 멈춘다. + * 감소 모션이면 반복하지 않고 한 장면(말한 내용과 입력 결과)을 정지 화면으로 보여 준다. + */ + +type Phase = 'idle' | 'listening' | 'thinking' | 'typed' + +// 앱 recording-tip/script.js 와 같은 값 +const BAR_COUNT = 9 +const BAR_INTERVAL_MS = 100 +const BAR_MIN = 2 +const BAR_MAX = 28 +const SMOOTHING = 0.5 +const RANDOM_FACTOR = 0.35 +const BAR_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) => { + const center = (BAR_COUNT - 1) / 2 + return Math.cos(((i - center) / center) * (Math.PI / 2)) +}) + +// 장면 길이(ms) +const IDLE_MS = 1400 +const WORD_MS = 260 +const LISTEN_TAIL_MS = 700 +const THINK_MS = 1100 +const TYPED_MS = 3200 + +/** + * 부분 전사가 나타나는 단위. 띄어 쓰는 언어는 낱말(뒤 공백 포함), 띄어 쓰지 않는 + * 중국어·일본어는 세 글자씩 끊는다. 이어 붙이면 원문과 같다. + */ +function speechChunks(text: string): string[] { + if (/\s/.test(text)) return text.match(/\S+\s*/g) ?? [text] + const chars = Array.from(text) + const chunks: string[] = [] + for (let i = 0; i < chars.length; i += 3) chunks.push(chars.slice(i, i + 3).join('')) + return chunks +} + +function formatDuration(ms: number): string { + const s = Math.floor(ms / 1000) + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` +} + +export function AppLoopDemo() { + const { t } = useI18n() + const reducedMotion = usePrefersReducedMotion() + + const [phase, setPhase] = useState('idle') + const [sampleIndex, setSampleIndex] = useState(0) + const [wordCount, setWordCount] = useState(0) + const [elapsed, setElapsed] = useState(0) + const [userPaused, setUserPaused] = useState(false) + const [offscreen, setOffscreen] = useState(false) + + const rootRef = useRef(null) + const barRefs = useRef<(HTMLSpanElement | null)[]>([]) + const progressRef = useRef(null) + const timers = useRef([]) + + const running = !reducedMotion && !userPaused && !offscreen + const sample = t.demo.samples[sampleIndex % t.demo.samples.length] + const words = speechChunks(sample.raw) + + const clearTimers = useCallback(() => { + timers.current.forEach((id) => window.clearTimeout(id)) + timers.current = [] + }, []) + + // 화면 밖·숨은 탭에서는 멈춘다. + useEffect(() => { + const el = rootRef.current + if (!el) return + const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting), { threshold: 0.2 }) + observer.observe(el) + const onVisibility = () => setOffscreen(document.hidden) + document.addEventListener('visibilitychange', onVisibility) + return () => { + observer.disconnect() + document.removeEventListener('visibilitychange', onVisibility) + } + }, []) + + // 언어가 바뀌면 처음부터. + useEffect(() => { + setSampleIndex(0) + setPhase('idle') + }, [t]) + + // 장면 진행. 멈추면 현재 예시의 처음으로 돌아가 다시 시작한다. + useEffect(() => { + clearTimers() + if (!running) return + + const schedule = (fn: () => void, at: number) => { + timers.current.push(window.setTimeout(fn, at)) + } + + setPhase('idle') + setWordCount(0) + setElapsed(0) + + const listenStart = IDLE_MS + const listenEnd = listenStart + words.length * WORD_MS + LISTEN_TAIL_MS + const thinkEnd = listenEnd + THINK_MS + const loopEnd = thinkEnd + TYPED_MS + + schedule(() => setPhase('listening'), listenStart) + words.forEach((_, i) => schedule(() => setWordCount(i + 1), listenStart + (i + 1) * WORD_MS)) + for (let s = 1000; s < listenEnd - listenStart; s += 1000) { + schedule(() => setElapsed(s), listenStart + s) + } + schedule(() => setPhase('thinking'), listenEnd) + schedule(() => setPhase('typed'), thinkEnd) + schedule(() => setSampleIndex((i) => i + 1), loopEnd) + + return clearTimers + // sampleIndex가 바뀌면 다음 예시로 다시 짠다. + }, [running, sampleIndex, t, clearTimers]) // eslint-disable-line react-hooks/exhaustive-deps + + // 파형: 앱과 같은 알고리즘(cos 가중치 × 레벨 × 무작위, 스무딩)으로 100ms마다 갱신. + useEffect(() => { + const bars = barRefs.current + const heights = new Array(BAR_COUNT).fill(BAR_MIN) + const paint = (level: number, jitter: boolean) => { + bars.forEach((bar, i) => { + if (!bar) return + const base = Math.max(0.08, level) * BAR_MAX * BAR_WEIGHTS[i] + const randomized = jitter ? base * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR) : base + const target = Math.max(BAR_MIN, Math.min(BAR_MAX, randomized)) + heights[i] = jitter ? heights[i] + (target - heights[i]) * SMOOTHING : target + bar.style.height = `${Math.round(heights[i])}px` + }) + } + + if (!running || phase !== 'listening') { + paint(reducedMotion ? 0.7 : 0.08, false) + return + } + let tick = 0 + const id = window.setInterval(() => { + tick += 1 + // 말소리처럼 음절마다 오르내리는 합성 레벨 + const syllable = 0.5 + 0.5 * Math.sin(tick * 1.9) + paint(0.35 + 0.55 * syllable * (0.6 + 0.4 * Math.random()), true) + }, BAR_INTERVAL_MS) + return () => window.clearInterval(id) + }, [phase, running, reducedMotion]) + + // 처리 중 진행 막대: min(95, (1 - 1/(1 + 1.5t)) * 100)% + useEffect(() => { + const bar = progressRef.current + if (!bar || phase !== 'thinking') return + const start = performance.now() + let raf = 0 + const step = () => { + const sec = (performance.now() - start) / 1000 + bar.style.width = `${Math.min(95, (1 - 1 / (1 + 1.5 * sec)) * 100)}%` + if (running) raf = requestAnimationFrame(step) + } + raf = requestAnimationFrame(step) + return () => cancelAnimationFrame(raf) + }, [phase, running]) + + // 감소 모션: 한 장면을 정지 화면으로. + const shownPhase: Phase = reducedMotion ? 'typed' : phase + const capsulePhase: Phase = reducedMotion ? 'listening' : phase + const partial = reducedMotion ? sample.raw : words.slice(0, wordCount).join('') + const keyDown = !reducedMotion && capsulePhase === 'listening' + + const statusText = { + idle: t.demo.stateIdle, + listening: t.demo.stateListening, + thinking: t.demo.statePolishing, + typed: t.demo.stateDone, + }[reducedMotion ? 'typed' : phase] + + return ( +
+

{t.demo.description}

+ + + +
+ {t.demo.note} + {!reducedMotion && ( + + )} +
+
+ ) +} diff --git a/site/src/components/Crosshair.tsx b/site/src/components/Crosshair.tsx deleted file mode 100644 index 887656a..0000000 --- a/site/src/components/Crosshair.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { ReactNode } from 'react' - -interface CrosshairProps { - className?: string - children: ReactNode -} - -export function Crosshair({ className = '', children }: CrosshairProps) { - return ( -
- {children} -
- ) -} diff --git a/site/src/components/DictationDemo.tsx b/site/src/components/DictationDemo.tsx deleted file mode 100644 index 30e8ff4..0000000 --- a/site/src/components/DictationDemo.tsx +++ /dev/null @@ -1,113 +0,0 @@ -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/WaveBars.tsx b/site/src/components/WaveBars.tsx deleted file mode 100644 index f623b0d..0000000 --- a/site/src/components/WaveBars.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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 { - /** 소리를 듣는 중일 때만 움직인다. 멈추면 낮은 정지 막대로 돌아간다. */ - active: boolean -} - -export function WaveBars({ active }: WaveBarsProps) { - const barsRef = useRef<(HTMLSpanElement | null)[]>([]) - const reducedMotion = usePrefersReducedMotion() - const animate = active && !reducedMotion - - useEffect(() => { - 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` - }) - return - } - - 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 ( -