Compare commits

..

No commits in common. "f268cde348dfd6cc604e7c2146bdd7f823f04b20" and "a4cb799978ed09eaa894a4771c15213d485fe3eb" have entirely different histories.

6 changed files with 41 additions and 188 deletions

View file

@ -19,7 +19,7 @@ Open the localhost URL, enter a Gemini API key, choose a target language, and cl
- Chrome or Edge is recommended for tab/system audio sharing - Chrome or Edge is recommended for tab/system audio sharing
- Output-device selection appears only when `HTMLMediaElement.setSinkId()` is supported - Output-device selection appears only when `HTMLMediaElement.setSinkId()` is supported
Prefer sharing the individual tab that is playing the source audio. Full system-audio capture may also include Relay's translated output in the digital mix. Relay requests `selfBrowserSurface: "exclude"` and `restrictOwnAudio: true` where supported; a separate output device can also help when the operating system isolates its audio from the captured source. Use headphones or a separate output device to prevent translated speech from being captured again. Relay also requests `selfBrowserSurface: "exclude"` where the browser supports it.
## Privacy and key handling ## Privacy and key handling

View file

@ -2,16 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { import {
ArrowRight, ArrowRight,
AudioLines, AudioLines,
BookOpen,
Captions, Captions,
Check, Check,
ChevronDown, ChevronDown,
ChevronUp,
CircleStop, CircleStop,
Clock3, Clock3,
Eye, Eye,
EyeOff, EyeOff,
Gauge, Gauge,
Headphones,
KeyRound, KeyRound,
Laptop, Laptop,
Languages as LanguagesIcon, Languages as LanguagesIcon,
@ -26,7 +25,6 @@ import {
TriangleAlert, TriangleAlert,
Upload, Upload,
Volume2, Volume2,
VolumeX,
X, X,
} from 'lucide-react' } from 'lucide-react'
import { APP_LANGUAGES, APP_LANGUAGE_STORAGE, translate, type AppLanguage } from './i18n' import { APP_LANGUAGES, APP_LANGUAGE_STORAGE, translate, type AppLanguage } from './i18n'
@ -37,7 +35,7 @@ import {
supportsOutputPicker, supportsOutputPicker,
supportsOutputSelection, supportsOutputSelection,
} from './lib/audio' } from './lib/audio'
import { GeminiLiveSession, SessionClosedError, type Transcript } from './lib/geminiLive' import { GeminiLiveSession, type Transcript } from './lib/geminiLive'
const API_KEY_STORAGE = 'relay-gemini-api-key' const API_KEY_STORAGE = 'relay-gemini-api-key'
const TARGET_LANGUAGE_STORAGE = 'relay-target-language' const TARGET_LANGUAGE_STORAGE = 'relay-target-language'
@ -101,7 +99,6 @@ function friendlyError(error: unknown, t: T): string {
if (error.name === 'NotAllowedError') return t('errorShareCancelled') if (error.name === 'NotAllowedError') return t('errorShareCancelled')
if (error.name === 'NotFoundError') return t('errorNoSource') if (error.name === 'NotFoundError') return t('errorNoSource')
if (error.name === 'NotReadableError') return t('errorSourceUnreadable') if (error.name === 'NotReadableError') return t('errorSourceUnreadable')
if (error.name === 'AbortError') return t('errorStart')
} }
if (error instanceof Error) { if (error instanceof Error) {
const localizedMessages: Record<string, Parameters<typeof translate>[1]> = { const localizedMessages: Record<string, Parameters<typeof translate>[1]> = {
@ -148,7 +145,6 @@ function App() {
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([]) const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([])
const [outputDeviceId, setOutputDeviceId] = useState('') const [outputDeviceId, setOutputDeviceId] = useState('')
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [guideOpen, setGuideOpen] = useState(false)
const [diagnostics, setDiagnostics] = useState<SessionDiagnostics>(INITIAL_DIAGNOSTICS) const [diagnostics, setDiagnostics] = useState<SessionDiagnostics>(INITIAL_DIAGNOSTICS)
const [capture] = useState(() => new DisplayAudioCapture()) const [capture] = useState(() => new DisplayAudioCapture())
@ -260,12 +256,8 @@ function App() {
try { try {
setStatus('picking') setStatus('picking')
const captureRequest = capture.request(() => void stop(t('sourceEnded'))) await player.initialize()
const playerInitialization = player.initialize() const label = await capture.request(() => void stop(t('sourceEnded')))
const [captureResult, playerResult] = await Promise.allSettled([captureRequest, playerInitialization])
if (captureResult.status === 'rejected') throw captureResult.reason
if (playerResult.status === 'rejected') throw playerResult.reason
const label = captureResult.value
setSourceLabel(label) setSourceLabel(label)
sessionStartedAtRef.current = Date.now() sessionStartedAtRef.current = Date.now()
await refreshOutputDevices() await refreshOutputDevices()
@ -315,9 +307,9 @@ function App() {
})) }))
}) })
captureStartedRef.current = true captureStartedRef.current = true
setStatus(session.isReady() ? 'listening' : 'reconnecting') setStatus('listening')
} catch (caught) { } catch (caught) {
if (caught instanceof SessionClosedError) { if (caught instanceof DOMException && caught.name === 'AbortError') {
setStatus('idle') setStatus('idle')
return return
} }
@ -404,60 +396,10 @@ function App() {
</header> </header>
<main id="top"> <main id="top">
<section className="guide-section" aria-labelledby="guide-button-title"> <section className="hero">
<button <div className="eyebrow"><span className="live-dot" /> {t('liveInterpreter')}</div>
className="guide-launch" <h1>{t('heroLineOne')}<br /><span>{t('heroLineTwo')}</span></h1>
onClick={() => setGuideOpen((open) => !open)} <p className="hero-copy">{t('heroCopy')}</p>
aria-expanded={guideOpen}
aria-controls="usage-guide"
>
<span className="guide-launch-icon"><BookOpen size={25} /></span>
<span className="guide-launch-copy">
<strong id="guide-button-title">{t('guideButtonTitle')}</strong>
<small>{t('guideButtonCopy')}</small>
</span>
{guideOpen ? <ChevronUp size={22} /> : <ChevronDown size={22} />}
</button>
{guideOpen && (
<div className="usage-guide" id="usage-guide">
<div className="guide-heading">
<span>{t('guideEyebrow')}</span>
<h2>{t('guideTitle')}</h2>
<p>{t('guideIntro')}</p>
</div>
<ol className="guide-steps">
<li>
<span>1</span>
<div><strong>{t('guideKeyTitle')}</strong><p>{t('guideKeyCopy')}</p></div>
</li>
<li>
<span>2</span>
<div><strong>{t('guideLanguageTitle')}</strong><p>{t('guideLanguageCopy')}</p></div>
</li>
<li>
<span>3</span>
<div><strong>{t('guideOutputTitle')}</strong><p>{t('guideOutputCopy')}</p></div>
</li>
<li>
<span>4</span>
<div><strong>{t('guideShareTitle')}</strong><p>{t('guideShareCopy')}</p></div>
</li>
<li>
<span>5</span>
<div><strong>{t('guidePickerTitle')}</strong><p>{t('guidePickerCopy')}</p></div>
</li>
<li>
<span>6</span>
<div><strong>{t('guideListenTitle')}</strong><p>{t('guideListenCopy')}</p></div>
</li>
</ol>
<div className="guide-tip">
<VolumeX size={20} />
<div><strong>{t('guideTipTitle')}</strong><p>{t('guideTipCopy')}</p></div>
</div>
</div>
)}
</section> </section>
<section className="workspace" aria-label={t('translatorControls')}> <section className="workspace" aria-label={t('translatorControls')}>
@ -641,7 +583,7 @@ function App() {
</section> </section>
<section className="loop-card"> <section className="loop-card">
<div className="loop-icon"><VolumeX size={23} /></div> <div className="loop-icon"><Headphones size={23} /></div>
<div> <div>
<h2>{t('loopTitle')}</h2> <h2>{t('loopTitle')}</h2>
<p>{t('loopCopy')}</p> <p>{t('loopCopy')}</p>

View file

@ -12,25 +12,10 @@ const english = {
pageTitle: 'Relay — Live audio translation', pageTitle: 'Relay — Live audio translation',
relayHome: 'Relay home', relayHome: 'Relay home',
translatorControls: 'Live translator controls', translatorControls: 'Live translator controls',
guideButtonTitle: 'How do I use Relay?', liveInterpreter: 'Live audio interpreter',
guideButtonCopy: 'Open the detailed step-by-step guide', heroLineOne: 'Hear anything.',
guideEyebrow: 'Getting started', heroLineTwo: 'Understand everything.',
guideTitle: 'Translate computer audio in six steps', heroCopy: 'Translate audio playing on your computer in real time. Direct from your browser to Gemini—nothing passes through our servers.',
guideIntro: 'Relay runs entirely in your browser. Have your Gemini API key ready, then follow these steps.',
guideKeyTitle: 'Enter your Gemini API key',
guideKeyCopy: 'Paste your own API key into the key field. It is saved only in this browsers local storage and sent directly to Google when a session starts.',
guideLanguageTitle: 'Choose the translation language',
guideLanguageCopy: 'Select the language you want to hear. Turn on live transcripts only if you also want original and translated text; transcripts add billable text tokens.',
guideOutputTitle: 'Prefer sharing a single tab',
guideOutputCopy: 'Sharing only the tab playing the source audio normally keeps Relays translated output out of the capture. Full system-audio sharing can also capture Relay.',
guideShareTitle: 'Start the translation',
guideShareCopy: 'Click “Share audio & translate.” Your browser will open its standard screen-sharing picker.',
guidePickerTitle: 'Select a source and enable audio',
guidePickerCopy: 'Choose the tab, window, or screen that is playing the audio. Explicitly enable “Share audio” before confirming. Relay will reject sources that do not provide an audio track.',
guideListenTitle: 'Listen and monitor the session',
guideListenCopy: 'The translated voice begins automatically. The live panel shows elapsed time, sent or dropped packets, playback delay, and reconnect attempts. Click “Stop translation” when finished.',
guideTipTitle: 'Keep Relay out of the captured mix',
guideTipCopy: 'Relay does not use your microphone. If you share full system audio, its translated output may still be captured digitally. Prefer a single source tab, or use a separate output device where that isolates the audio.',
setupSession: 'Set up your session', setupSession: 'Set up your session',
clientOnly: 'Client-side only', clientOnly: 'Client-side only',
geminiApiKey: 'Gemini API key', geminiApiKey: 'Gemini API key',
@ -82,8 +67,8 @@ const english = {
emptyCopy: 'Set your language, share a source with audio, and Relay will begin speaking the translation.', emptyCopy: 'Set your language, share a source with audio, and Relay will begin speaking the translation.',
voiceTranslation: 'Live voice translation', voiceTranslation: 'Live voice translation',
transcripts: 'Original + translated transcripts', transcripts: 'Original + translated transcripts',
loopTitle: 'Keep Relays output out of the shared audio', loopTitle: 'Keep the translation from hearing itself',
loopCopy: 'Prefer sharing only the tab playing the source audio. Full system-audio sharing may capture Relays output digitally; supported browsers are asked to exclude or suppress this apps own audio.', loopCopy: 'Headphones are the safest option. You can also send translated speech to a separate output device, or share a different tab—Relay asks supported browsers to exclude this tab.',
chooseOutput: 'Choose output', chooseOutput: 'Choose output',
privacyFooter: 'Your key and audio stay between this browser and Googles Gemini API.', privacyFooter: 'Your key and audio stay between this browser and Googles Gemini API.',
appLanguage: 'App language', appLanguage: 'App language',
@ -107,25 +92,10 @@ const slovene: Record<TranslationKey, string> = {
pageTitle: 'Relay — Prevajanje zvoka v živo', pageTitle: 'Relay — Prevajanje zvoka v živo',
relayHome: 'Domača stran Relay', relayHome: 'Domača stran Relay',
translatorControls: 'Kontrolniki prevajalnika v živo', translatorControls: 'Kontrolniki prevajalnika v živo',
guideButtonTitle: 'Kako uporabljam Relay?', liveInterpreter: 'Tolmač zvoka v živo',
guideButtonCopy: 'Odprite podrobna navodila po korakih', heroLineOne: 'Poslušajte kar koli.',
guideEyebrow: 'Prvi koraki', heroLineTwo: 'Razumite vse.',
guideTitle: 'Prevedite zvok računalnika v šestih korakih', heroCopy: 'V živo prevajajte zvok, ki se predvaja na vašem računalniku. Zvok gre neposredno iz brskalnika v Gemini—nič ne potuje prek naših strežnikov.',
guideIntro: 'Relay deluje v celoti v vašem brskalniku. Pripravite ključ API Gemini in sledite tem korakom.',
guideKeyTitle: 'Vnesite ključ API Gemini',
guideKeyCopy: 'Prilepite svoj ključ API v polje za ključ. Shranjen je samo v lokalni shrambi tega brskalnika in se ob začetku seje pošlje neposredno Googlu.',
guideLanguageTitle: 'Izberite jezik prevoda',
guideLanguageCopy: 'Izberite jezik, v katerem želite poslušati prevod. Prepise v živo vključite le, če želite tudi izvirno in prevedeno besedilo; prepisi ustvarijo dodatne plačljive besedilne žetone.',
guideOutputTitle: 'Raje delite posamezen zavihek',
guideOutputCopy: 'Če delite samo zavihek, ki predvaja izvorni zvok, prevedeni izhod aplikacije Relay praviloma ni zajet. Skupna raba celotnega sistemskega zvoka lahko zajame tudi Relay.',
guideShareTitle: 'Začnite prevajanje',
guideShareCopy: 'Kliknite »Deli zvok in prevajaj«. Brskalnik bo odprl standardni izbirnik za skupno rabo zaslona.',
guidePickerTitle: 'Izberite vir in vključite zvok',
guidePickerCopy: 'Izberite zavihek, okno ali zaslon, ki predvaja zvok. Pred potrditvijo izrecno vključite možnost »Deli zvok«. Relay zavrne vire, ki ne zagotovijo zvočnega posnetka.',
guideListenTitle: 'Poslušajte in spremljajte sejo',
guideListenCopy: 'Prevedeni glas se začne predvajati samodejno. Plošča v živo prikazuje trajanje, poslane ali izpuščene pakete, zakasnitev predvajanja in poskuse ponovne povezave. Ko končate, kliknite »Ustavi prevajanje«.',
guideTipTitle: 'Izločite Relay iz zajetega zvoka',
guideTipCopy: 'Relay ne uporablja mikrofona. Če delite celotni sistemski zvok, se lahko prevedeni izhod vseeno zajame digitalno. Raje delite posamezen izvorni zavihek ali uporabite ločeno izhodno napravo, kadar ta loči zvok.',
setupSession: 'Nastavite sejo', setupSession: 'Nastavite sejo',
clientOnly: 'Samo v brskalniku', clientOnly: 'Samo v brskalniku',
geminiApiKey: 'Ključ API Gemini', geminiApiKey: 'Ključ API Gemini',
@ -177,8 +147,8 @@ const slovene: Record<TranslationKey, string> = {
emptyCopy: 'Izberite jezik in delite vir z zvokom. Relay bo začel predvajati prevod.', emptyCopy: 'Izberite jezik in delite vir z zvokom. Relay bo začel predvajati prevod.',
voiceTranslation: 'Glasovno prevajanje v živo', voiceTranslation: 'Glasovno prevajanje v živo',
transcripts: 'Izvirni in prevedeni prepis', transcripts: 'Izvirni in prevedeni prepis',
loopTitle: 'Izločite izhod Relay iz zvoka v skupni rabi', loopTitle: 'Preprečite, da bi prevod poslušal samega sebe',
loopCopy: 'Raje delite samo zavihek, ki predvaja izvorni zvok. Skupna raba celotnega sistemskega zvoka lahko digitalno zajame izhod Relay; podprte brskalnike zaprosimo, naj zvok te aplikacije izključijo ali zadušijo.', loopCopy: 'Najvarnejša izbira so slušalke. Prevedeni govor lahko pošljete tudi na drugo izhodno napravo ali delite drug zavihek—Relay podprte brskalnike zaprosi, naj ta zavihek izključijo.',
chooseOutput: 'Izberi izhod', chooseOutput: 'Izberi izhod',
privacyFooter: 'Vaš ključ in zvok ostaneta med tem brskalnikom in Googlovim API-jem Gemini.', privacyFooter: 'Vaš ključ in zvok ostaneta med tem brskalnikom in Googlovim API-jem Gemini.',
appLanguage: 'Jezik aplikacije', appLanguage: 'Jezik aplikacije',

View file

@ -18,13 +18,12 @@ export function supportsOutputSelection(): boolean {
} }
export function supportsOutputPicker(): boolean { export function supportsOutputPicker(): boolean {
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined return typeof (navigator.mediaDevices as SelectableMediaDevices).selectAudioOutput === 'function'
return typeof devices?.selectAudioOutput === 'function'
} }
export async function chooseOutputDevice(): Promise<MediaDeviceInfo | null> { export async function chooseOutputDevice(): Promise<MediaDeviceInfo | null> {
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined const devices = navigator.mediaDevices as SelectableMediaDevices
if (!devices?.selectAudioOutput) return null if (!devices.selectAudioOutput) return null
return devices.selectAudioOutput() return devices.selectAudioOutput()
} }
@ -137,7 +136,6 @@ export class DisplayAudioCapture {
noiseSuppression: false, noiseSuppression: false,
autoGainControl: false, autoGainControl: false,
suppressLocalAudioPlayback: false, suppressLocalAudioPlayback: false,
restrictOwnAudio: true,
}, },
selfBrowserSurface: 'exclude', selfBrowserSurface: 'exclude',
surfaceSwitching: 'include', surfaceSwitching: 'include',
@ -234,9 +232,9 @@ export class PcmPlayer {
} }
async setOutputDevice(deviceId: string): Promise<void> { async setOutputDevice(deviceId: string): Promise<void> {
this.sinkId = deviceId
if (!this.audio.setSinkId) throw new Error('This browser does not support selecting an output device.') if (!this.audio.setSinkId) throw new Error('This browser does not support selecting an output device.')
await this.audio.setSinkId(deviceId) await this.audio.setSinkId(deviceId)
this.sinkId = deviceId
} }
enqueue(base64: string): PlaybackQueueResult { enqueue(base64: string): PlaybackQueueResult {

View file

@ -44,32 +44,6 @@ async function decodeMessageData(data: unknown): Promise<string> {
throw new Error(`Unsupported WebSocket frame type: ${Object.prototype.toString.call(data)}`) throw new Error(`Unsupported WebSocket frame type: ${Object.prototype.toString.call(data)}`)
} }
function truncateWebSocketCloseReason(reason: string): string {
const encoder = new TextEncoder()
let truncated = ''
let byteLength = 0
for (const character of reason) {
const characterBytes = encoder.encode(character).byteLength
if (byteLength + characterBytes > 123) break
truncated += character
byteLength += characterBytes
}
return truncated
}
function isRetryableCloseCode(code: number): boolean {
return ![1002, 1003, 1007, 1008, 1009, 1010].includes(code)
}
export class SessionClosedError extends Error {
constructor() {
super('Session closed')
this.name = 'SessionClosedError'
}
}
export class GeminiLiveSession { export class GeminiLiveSession {
private socket: WebSocket | null = null private socket: WebSocket | null = null
private closing = false private closing = false
@ -199,10 +173,6 @@ export class GeminiLiveSession {
this.clearSetupTimer() this.clearSetupTimer()
if (this.closing) return if (this.closing) return
const reason = event.reason || `Connection closed (code ${event.code})` const reason = event.reason || `Connection closed (code ${event.code})`
if (!isRetryableCloseCode(event.code)) {
this.fail(reason)
return
}
this.scheduleReconnect(reason) this.scheduleReconnect(reason)
}) })
} }
@ -232,9 +202,7 @@ export class GeminiLiveSession {
this.clearTimers() this.clearTimers()
const socket = this.socket const socket = this.socket
this.socket = null this.socket = null
if (socket && socket.readyState < WebSocket.CLOSING) { if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1008, reason.slice(0, 120))
socket.close(1008, truncateWebSocketCloseReason(reason))
}
this.callbacks.onError(reason) this.callbacks.onError(reason)
if (!this.initialSettled) { if (!this.initialSettled) {
@ -270,10 +238,6 @@ export class GeminiLiveSession {
return true return true
} }
isReady(): boolean {
return this.ready && this.socket?.readyState === WebSocket.OPEN
}
close(): void { close(): void {
if (this.closing) return if (this.closing) return
this.closing = true this.closing = true
@ -281,7 +245,7 @@ export class GeminiLiveSession {
this.clearTimers() this.clearTimers()
if (!this.initialSettled) { if (!this.initialSettled) {
this.initialSettled = true this.initialSettled = true
this.rejectInitial?.(new SessionClosedError()) this.rejectInitial?.(new DOMException('Session closed', 'AbortError'))
this.resolveInitial = null this.resolveInitial = null
this.rejectInitial = null this.rejectInitial = null
} }

View file

@ -58,31 +58,14 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible
.icon-button:hover { background: white; } .icon-button:hover { background: white; }
main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 64px; } main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding-bottom: 64px; }
.hero { padding: 78px 0 52px; text-align: center; max-width: 820px; margin: 0 auto; }
.eyebrow { display: inline-flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: normal; font-size: 11px; font-weight: 700; color: var(--green); }
.live-dot { width: 7px; height: 7px; background: #2e9b77; border-radius: 50%; box-shadow: 0 0 0 5px rgba(46,155,119,.13); }
.hero h1 { margin: 20px 0 18px; font: 800 clamp(42px, 6vw, 70px)/1.02 Arial, sans-serif; letter-spacing: -.055em; }
.hero h1 span { color: var(--green); }
.hero-copy { max-width: 640px; margin: auto; font-size: 17px; line-height: 1.65; color: var(--muted); }
.guide-section { margin-top: 40px; } .workspace { display: grid; grid-template-columns: 1fr 1fr; min-height: 610px; border: 1px solid #d8d6cf; background: var(--paper); border-radius: 24px; overflow: hidden; box-shadow: 0 24px 70px rgba(32, 40, 36, .08); }
.guide-launch { width: 100%; min-height: 88px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16px; padding: 17px 22px; color: var(--ink); text-align: left; border: 1px solid #cfd9d4; border-radius: 17px; background: linear-gradient(135deg, #eef7f3, #faf8f3); cursor: pointer; box-shadow: 0 10px 28px rgba(32, 40, 36, .05); transition: transform .18s, border-color .18s, box-shadow .18s; }
.guide-launch:hover { transform: translateY(-1px); border-color: #9ebdaf; box-shadow: 0 14px 32px rgba(32, 40, 36, .08); }
.guide-launch-icon { width: 50px; height: 50px; display: grid; place-items: center; color: white; background: var(--green); border-radius: 14px 14px 14px 5px; }
.guide-launch-copy { min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.guide-launch-copy strong { font-size: 17px; }
.guide-launch-copy small { color: var(--muted); font-size: 12px; }
.guide-launch > svg { color: var(--green); }
.usage-guide { margin-top: 12px; padding: 30px; border: 1px solid #d8d8d1; border-radius: 18px; background: rgba(251,250,247,.96); box-shadow: 0 18px 45px rgba(32,40,36,.06); }
.guide-heading { max-width: 720px; margin-bottom: 25px; }
.guide-heading > span { display: block; margin-bottom: 7px; color: var(--green); font-size: 10px; font-weight: 700; text-transform: uppercase; }
.guide-heading h2 { margin-bottom: 7px; font-size: 21px; }
.guide-heading p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.guide-steps { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 0; padding: 0; list-style: none; }
.guide-steps li { display: grid; grid-template-columns: auto 1fr; align-items: start; gap: 12px; padding: 16px; border: 1px solid #e0dfd9; border-radius: 12px; background: white; }
.guide-steps li > span { width: 28px; height: 28px; display: grid; place-items: center; color: var(--green); background: #e3f1eb; border-radius: 8px; font-size: 11px; font-weight: 800; }
.guide-steps strong { display: block; margin: 1px 0 5px; font-size: 12px; }
.guide-steps p { margin: 0; color: #747c78; font-size: 10px; line-height: 1.55; }
.guide-tip { display: flex; align-items: flex-start; gap: 12px; margin-top: 13px; padding: 15px 17px; color: #845134; border: 1px solid #ead2c2; border-radius: 12px; background: #fff3eb; }
.guide-tip > svg { flex: 0 0 auto; margin-top: 1px; }
.guide-tip strong { display: block; margin-bottom: 3px; font-size: 11px; }
.guide-tip p { margin: 0; color: #876957; font-size: 10px; line-height: 1.5; }
.workspace { display: grid; grid-template-columns: 1fr 1fr; min-height: 610px; margin-top: 18px; border: 1px solid #d8d6cf; background: var(--paper); border-radius: 24px; overflow: hidden; box-shadow: 0 24px 70px rgba(32, 40, 36, .08); }
.config-panel, .session-panel { padding: 38px 40px; } .config-panel, .session-panel { padding: 38px 40px; }
.config-panel { border-right: 1px solid var(--line); background: rgba(251,250,247,.96); } .config-panel { border-right: 1px solid var(--line); background: rgba(251,250,247,.96); }
.session-panel { background: #f7f6f2; display: flex; flex-direction: column; } .session-panel { background: #f7f6f2; display: flex; flex-direction: column; }
@ -199,22 +182,18 @@ footer p { color: #888e8b; font-size: 10px; }
@media (max-width: 850px) { @media (max-width: 850px) {
.workspace { grid-template-columns: 1fr; } .workspace { grid-template-columns: 1fr; }
.guide-steps { grid-template-columns: 1fr; }
.config-panel { border-right: 0; border-bottom: 1px solid var(--line); } .config-panel { border-right: 0; border-bottom: 1px solid var(--line); }
.session-panel { min-height: 550px; } .session-panel { min-height: 550px; }
.hero { padding-top: 55px; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.topbar { padding: 0 16px; grid-template-columns: 1fr auto; } .topbar { padding: 0 16px; grid-template-columns: 1fr auto; }
.model-pill { display: none; } .model-pill { display: none; }
main { width: min(100% - 24px, 1180px); } main { width: min(100% - 24px, 1180px); }
.guide-section { margin-top: 24px; } .hero { padding: 44px 8px 35px; }
.guide-launch { min-height: 78px; padding: 14px 16px; } .hero-copy { font-size: 14px; }
.guide-launch-icon { width: 44px; height: 44px; } .workspace { border-radius: 17px; }
.guide-launch-copy strong { font-size: 14px; }
.guide-launch-copy small { font-size: 10px; }
.usage-guide { padding: 21px; }
.workspace { margin-top: 14px; border-radius: 17px; }
.config-panel, .session-panel { padding: 27px 21px; } .config-panel, .session-panel { padding: 27px 21px; }
.privacy-label { display: none; } .privacy-label { display: none; }
.language-flow { grid-template-columns: 1fr 25px 1fr; } .language-flow { grid-template-columns: 1fr 25px 1fr; }