From 92978607da9fe4ebd190fc78b31a03b1c84b4fdd Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 26 Sep 2026 15:49:08 +0900 Subject: [PATCH] feat(site): loop a faithful copy of the app's recording capsule in the hero Replace the press-to-play demo and the "see how it works" button with an example that keeps playing on the right: hold Right Alt, the capsule appears with live wave bars, elapsed time and the partial transcript, releasing shows the processing bar, and the cleaned sentence is pasted at the cursor of a notes window. It cycles through two samples per language. - Same numbers as apps/desktop recording-tip: 9 bars with cos weights, 100 ms updates, 0.5 smoothing, +/-35% jitter, 2-28 px, per-bar colours, 120 px progress bar min(95, (1 - 1/(1 + 1.5t)) * 100)%. - WCAG 2.2.2: a pause button; stops when off screen or the tab is hidden. Reduced motion shows one still frame. The animation is aria-hidden with a text description for screen readers. Chinese and Japanese transcripts appear three characters at a time. - Add 404.html so unknown paths stop falling back to the landing page, delete the duplicate accept-invite.html (/accept-invite/ is canonical) and link it straight to /#download, and move the legal pages off the retired orange accent (primary button contrast 5.2:1). --- site/public/404.html | 25 +++ site/public/accept-invite.html | 76 -------- site/public/accept-invite/index.html | 2 +- site/public/legal.css | 10 +- site/src/components/AppLoopDemo.tsx | 260 ++++++++++++++++++++++++++ site/src/components/Crosshair.tsx | 14 -- site/src/components/DictationDemo.tsx | 113 ----------- site/src/components/WaveBars.tsx | 58 ------ site/src/components/icons.tsx | 9 + site/src/i18n/index.ts | 31 +-- site/src/i18n/locales/de.ts | 33 ++-- site/src/i18n/locales/en.ts | 33 ++-- site/src/i18n/locales/es.ts | 33 ++-- site/src/i18n/locales/fr.ts | 35 ++-- site/src/i18n/locales/ja.ts | 35 ++-- site/src/i18n/locales/ko.ts | 33 ++-- site/src/i18n/locales/pt.ts | 33 ++-- site/src/i18n/locales/ru.ts | 33 ++-- site/src/i18n/locales/vi.ts | 33 ++-- site/src/i18n/locales/zh.ts | 33 ++-- site/src/index.css | 224 ++++++++++++++++------ site/src/sections/Hero.tsx | 11 +- site/vite.config.ts | 7 +- 23 files changed, 707 insertions(+), 467 deletions(-) create mode 100644 site/public/404.html delete mode 100644 site/public/accept-invite.html create mode 100644 site/src/components/AppLoopDemo.tsx delete mode 100644 site/src/components/Crosshair.tsx delete mode 100644 site/src/components/DictationDemo.tsx delete mode 100644 site/src/components/WaveBars.tsx 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 ( -