Compare commits
No commits in common. "f268cde348dfd6cc604e7c2146bdd7f823f04b20" and "a4cb799978ed09eaa894a4771c15213d485fe3eb" have entirely different histories.
f268cde348
...
a4cb799978
|
|
@ -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
|
||||
- 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
|
||||
|
||||
|
|
|
|||
80
src/App.tsx
80
src/App.tsx
|
|
@ -2,16 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import {
|
||||
ArrowRight,
|
||||
AudioLines,
|
||||
BookOpen,
|
||||
Captions,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleStop,
|
||||
Clock3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Gauge,
|
||||
Headphones,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
Languages as LanguagesIcon,
|
||||
|
|
@ -26,7 +25,6 @@ import {
|
|||
TriangleAlert,
|
||||
Upload,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { APP_LANGUAGES, APP_LANGUAGE_STORAGE, translate, type AppLanguage } from './i18n'
|
||||
|
|
@ -37,7 +35,7 @@ import {
|
|||
supportsOutputPicker,
|
||||
supportsOutputSelection,
|
||||
} 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 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 === 'NotFoundError') return t('errorNoSource')
|
||||
if (error.name === 'NotReadableError') return t('errorSourceUnreadable')
|
||||
if (error.name === 'AbortError') return t('errorStart')
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const localizedMessages: Record<string, Parameters<typeof translate>[1]> = {
|
||||
|
|
@ -148,7 +145,6 @@ function App() {
|
|||
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([])
|
||||
const [outputDeviceId, setOutputDeviceId] = useState('')
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [guideOpen, setGuideOpen] = useState(false)
|
||||
const [diagnostics, setDiagnostics] = useState<SessionDiagnostics>(INITIAL_DIAGNOSTICS)
|
||||
|
||||
const [capture] = useState(() => new DisplayAudioCapture())
|
||||
|
|
@ -260,12 +256,8 @@ function App() {
|
|||
|
||||
try {
|
||||
setStatus('picking')
|
||||
const captureRequest = capture.request(() => void stop(t('sourceEnded')))
|
||||
const playerInitialization = player.initialize()
|
||||
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
|
||||
await player.initialize()
|
||||
const label = await capture.request(() => void stop(t('sourceEnded')))
|
||||
setSourceLabel(label)
|
||||
sessionStartedAtRef.current = Date.now()
|
||||
await refreshOutputDevices()
|
||||
|
|
@ -315,9 +307,9 @@ function App() {
|
|||
}))
|
||||
})
|
||||
captureStartedRef.current = true
|
||||
setStatus(session.isReady() ? 'listening' : 'reconnecting')
|
||||
setStatus('listening')
|
||||
} catch (caught) {
|
||||
if (caught instanceof SessionClosedError) {
|
||||
if (caught instanceof DOMException && caught.name === 'AbortError') {
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
|
|
@ -404,60 +396,10 @@ function App() {
|
|||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section className="guide-section" aria-labelledby="guide-button-title">
|
||||
<button
|
||||
className="guide-launch"
|
||||
onClick={() => setGuideOpen((open) => !open)}
|
||||
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 className="hero">
|
||||
<div className="eyebrow"><span className="live-dot" /> {t('liveInterpreter')}</div>
|
||||
<h1>{t('heroLineOne')}<br /><span>{t('heroLineTwo')}</span></h1>
|
||||
<p className="hero-copy">{t('heroCopy')}</p>
|
||||
</section>
|
||||
|
||||
<section className="workspace" aria-label={t('translatorControls')}>
|
||||
|
|
@ -641,7 +583,7 @@ function App() {
|
|||
</section>
|
||||
|
||||
<section className="loop-card">
|
||||
<div className="loop-icon"><VolumeX size={23} /></div>
|
||||
<div className="loop-icon"><Headphones size={23} /></div>
|
||||
<div>
|
||||
<h2>{t('loopTitle')}</h2>
|
||||
<p>{t('loopCopy')}</p>
|
||||
|
|
|
|||
54
src/i18n.ts
54
src/i18n.ts
|
|
@ -12,25 +12,10 @@ const english = {
|
|||
pageTitle: 'Relay — Live audio translation',
|
||||
relayHome: 'Relay home',
|
||||
translatorControls: 'Live translator controls',
|
||||
guideButtonTitle: 'How do I use Relay?',
|
||||
guideButtonCopy: 'Open the detailed step-by-step guide',
|
||||
guideEyebrow: 'Getting started',
|
||||
guideTitle: 'Translate computer audio in six steps',
|
||||
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 browser’s 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 Relay’s 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.',
|
||||
liveInterpreter: 'Live audio interpreter',
|
||||
heroLineOne: 'Hear anything.',
|
||||
heroLineTwo: 'Understand everything.',
|
||||
heroCopy: 'Translate audio playing on your computer in real time. Direct from your browser to Gemini—nothing passes through our servers.',
|
||||
setupSession: 'Set up your session',
|
||||
clientOnly: 'Client-side only',
|
||||
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.',
|
||||
voiceTranslation: 'Live voice translation',
|
||||
transcripts: 'Original + translated transcripts',
|
||||
loopTitle: 'Keep Relay’s output out of the shared audio',
|
||||
loopCopy: 'Prefer sharing only the tab playing the source audio. Full system-audio sharing may capture Relay’s output digitally; supported browsers are asked to exclude or suppress this app’s own audio.',
|
||||
loopTitle: 'Keep the translation from hearing itself',
|
||||
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',
|
||||
privacyFooter: 'Your key and audio stay between this browser and Google’s Gemini API.',
|
||||
appLanguage: 'App language',
|
||||
|
|
@ -107,25 +92,10 @@ const slovene: Record<TranslationKey, string> = {
|
|||
pageTitle: 'Relay — Prevajanje zvoka v živo',
|
||||
relayHome: 'Domača stran Relay',
|
||||
translatorControls: 'Kontrolniki prevajalnika v živo',
|
||||
guideButtonTitle: 'Kako uporabljam Relay?',
|
||||
guideButtonCopy: 'Odprite podrobna navodila po korakih',
|
||||
guideEyebrow: 'Prvi koraki',
|
||||
guideTitle: 'Prevedite zvok računalnika v šestih korakih',
|
||||
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.',
|
||||
liveInterpreter: 'Tolmač zvoka v živo',
|
||||
heroLineOne: 'Poslušajte kar koli.',
|
||||
heroLineTwo: 'Razumite vse.',
|
||||
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.',
|
||||
setupSession: 'Nastavite sejo',
|
||||
clientOnly: 'Samo v brskalniku',
|
||||
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.',
|
||||
voiceTranslation: 'Glasovno prevajanje v živo',
|
||||
transcripts: 'Izvirni in prevedeni prepis',
|
||||
loopTitle: 'Izločite izhod Relay iz zvoka v skupni rabi',
|
||||
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.',
|
||||
loopTitle: 'Preprečite, da bi prevod poslušal samega sebe',
|
||||
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',
|
||||
privacyFooter: 'Vaš ključ in zvok ostaneta med tem brskalnikom in Googlovim API-jem Gemini.',
|
||||
appLanguage: 'Jezik aplikacije',
|
||||
|
|
|
|||
|
|
@ -18,13 +18,12 @@ export function supportsOutputSelection(): boolean {
|
|||
}
|
||||
|
||||
export function supportsOutputPicker(): boolean {
|
||||
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
|
||||
return typeof devices?.selectAudioOutput === 'function'
|
||||
return typeof (navigator.mediaDevices as SelectableMediaDevices).selectAudioOutput === 'function'
|
||||
}
|
||||
|
||||
export async function chooseOutputDevice(): Promise<MediaDeviceInfo | null> {
|
||||
const devices = navigator.mediaDevices as SelectableMediaDevices | undefined
|
||||
if (!devices?.selectAudioOutput) return null
|
||||
const devices = navigator.mediaDevices as SelectableMediaDevices
|
||||
if (!devices.selectAudioOutput) return null
|
||||
return devices.selectAudioOutput()
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +136,6 @@ export class DisplayAudioCapture {
|
|||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
suppressLocalAudioPlayback: false,
|
||||
restrictOwnAudio: true,
|
||||
},
|
||||
selfBrowserSurface: 'exclude',
|
||||
surfaceSwitching: 'include',
|
||||
|
|
@ -234,9 +232,9 @@ export class PcmPlayer {
|
|||
}
|
||||
|
||||
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.')
|
||||
await this.audio.setSinkId(deviceId)
|
||||
this.sinkId = deviceId
|
||||
}
|
||||
|
||||
enqueue(base64: string): PlaybackQueueResult {
|
||||
|
|
|
|||
|
|
@ -44,32 +44,6 @@ async function decodeMessageData(data: unknown): Promise<string> {
|
|||
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 {
|
||||
private socket: WebSocket | null = null
|
||||
private closing = false
|
||||
|
|
@ -199,10 +173,6 @@ export class GeminiLiveSession {
|
|||
this.clearSetupTimer()
|
||||
if (this.closing) return
|
||||
const reason = event.reason || `Connection closed (code ${event.code})`
|
||||
if (!isRetryableCloseCode(event.code)) {
|
||||
this.fail(reason)
|
||||
return
|
||||
}
|
||||
this.scheduleReconnect(reason)
|
||||
})
|
||||
}
|
||||
|
|
@ -232,9 +202,7 @@ export class GeminiLiveSession {
|
|||
this.clearTimers()
|
||||
const socket = this.socket
|
||||
this.socket = null
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close(1008, truncateWebSocketCloseReason(reason))
|
||||
}
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1008, reason.slice(0, 120))
|
||||
this.callbacks.onError(reason)
|
||||
|
||||
if (!this.initialSettled) {
|
||||
|
|
@ -270,10 +238,6 @@ export class GeminiLiveSession {
|
|||
return true
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.ready && this.socket?.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closing) return
|
||||
this.closing = true
|
||||
|
|
@ -281,7 +245,7 @@ export class GeminiLiveSession {
|
|||
this.clearTimers()
|
||||
if (!this.initialSettled) {
|
||||
this.initialSettled = true
|
||||
this.rejectInitial?.(new SessionClosedError())
|
||||
this.rejectInitial?.(new DOMException('Session closed', 'AbortError'))
|
||||
this.resolveInitial = null
|
||||
this.rejectInitial = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,31 +58,14 @@ button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible
|
|||
.icon-button:hover { background: white; }
|
||||
|
||||
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; }
|
||||
.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); }
|
||||
.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); }
|
||||
.config-panel, .session-panel { padding: 38px 40px; }
|
||||
.config-panel { border-right: 1px solid var(--line); background: rgba(251,250,247,.96); }
|
||||
.session-panel { background: #f7f6f2; display: flex; flex-direction: column; }
|
||||
|
|
@ -199,22 +182,18 @@ footer p { color: #888e8b; font-size: 10px; }
|
|||
|
||||
@media (max-width: 850px) {
|
||||
.workspace { grid-template-columns: 1fr; }
|
||||
.guide-steps { grid-template-columns: 1fr; }
|
||||
.config-panel { border-right: 0; border-bottom: 1px solid var(--line); }
|
||||
.session-panel { min-height: 550px; }
|
||||
.hero { padding-top: 55px; }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.topbar { padding: 0 16px; grid-template-columns: 1fr auto; }
|
||||
.model-pill { display: none; }
|
||||
main { width: min(100% - 24px, 1180px); }
|
||||
.guide-section { margin-top: 24px; }
|
||||
.guide-launch { min-height: 78px; padding: 14px 16px; }
|
||||
.guide-launch-icon { width: 44px; height: 44px; }
|
||||
.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; }
|
||||
.hero { padding: 44px 8px 35px; }
|
||||
.hero-copy { font-size: 14px; }
|
||||
.workspace { border-radius: 17px; }
|
||||
.config-panel, .session-panel { padding: 27px 21px; }
|
||||
.privacy-label { display: none; }
|
||||
.language-flow { grid-template-columns: 1fr 25px 1fr; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue