feat: build editable transcription modal
This commit is contained in:
1 parent
bfa96248aa
commit
5494b633ea
4 files changed
+501
-95
No files matched your search
@@ -0,0 +1,98 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTranscriptionApp, type AppSnapshot, type NativeClient } from './app';
|
||||||
|
|
||||||
|
const baseSnapshot: AppSnapshot = {
|
||||||
|
recordingStatus: 'idle',
|
||||||
|
transcript: {
|
||||||
|
text: 'Texto inicial',
|
||||||
|
lastMessage: 'Revise o texto antes de inserir.',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
transcription: {
|
||||||
|
mode: 'localFirst',
|
||||||
|
modelId: 'parakeet-tdt-0.6b-v3-q8_0',
|
||||||
|
language: null,
|
||||||
|
openaiCompatible: {
|
||||||
|
endpoint: 'https://api.openai.com/v1/audio/transcriptions',
|
||||||
|
apiKeyConfigured: false,
|
||||||
|
model: 'whisper-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insertion: {
|
||||||
|
method: 'clipboardPaste',
|
||||||
|
restoreClipboard: true,
|
||||||
|
waylandManualFallback: true,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
keepAudio: false,
|
||||||
|
audioFormat: 'wav',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
id: 'parakeet-tdt-0.6b-v3-q8_0',
|
||||||
|
displayName: 'Parakeet TDT 0.6B v3 Q8',
|
||||||
|
repository: 'cstr/parakeet-tdt-0.6b-v3-GGUF',
|
||||||
|
filename: 'parakeet-tdt-0.6b-v3-q8_0.gguf',
|
||||||
|
quantization: 'Q8_0',
|
||||||
|
sizeBytes: 745_121_600,
|
||||||
|
languages: ['pt'],
|
||||||
|
recommendedForSimplePc: true,
|
||||||
|
},
|
||||||
|
status: 'notInstalled',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFakeClient(): NativeClient & { calls: string[] } {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async invoke<T>(command: string): Promise<T> {
|
||||||
|
calls.push(command);
|
||||||
|
return baseSnapshot as T;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises(): Promise<void> {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createTranscriptionApp', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.textContent = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders editable transcript modal with offline model details', async () => {
|
||||||
|
const mount = document.createElement('main');
|
||||||
|
const client = createFakeClient();
|
||||||
|
|
||||||
|
createTranscriptionApp(mount, client);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const editor = mount.querySelector<HTMLTextAreaElement>('#transcript-editor');
|
||||||
|
|
||||||
|
expect(editor?.value).toBe('Texto inicial');
|
||||||
|
expect(mount.textContent).toContain('Parakeet TDT 0.6B v3 Q8');
|
||||||
|
expect(mount.textContent).toContain('Offline primeiro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls native start command when record button is pressed', async () => {
|
||||||
|
const mount = document.createElement('main');
|
||||||
|
const client = createFakeClient();
|
||||||
|
|
||||||
|
createTranscriptionApp(mount, client);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const button = mount.querySelector<HTMLButtonElement>('.primary-button');
|
||||||
|
button?.click();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(client.calls).toContain('get_app_snapshot');
|
||||||
|
expect(client.calls).toContain('start_recording');
|
||||||
|
});
|
||||||
|
});
|
||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export type RecordingStatus = 'idle' | 'recording';
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
transcription: {
|
||||||
|
mode: 'localFirst' | 'apiOnly';
|
||||||
|
modelId: string;
|
||||||
|
language: string | null;
|
||||||
|
openaiCompatible: {
|
||||||
|
endpoint: string;
|
||||||
|
apiKeyConfigured: boolean;
|
||||||
|
model: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
insertion: {
|
||||||
|
method: 'clipboardPaste' | 'keyTyping' | 'manualCopy';
|
||||||
|
restoreClipboard: boolean;
|
||||||
|
waylandManualFallback: boolean;
|
||||||
|
};
|
||||||
|
storage: {
|
||||||
|
keepAudio: boolean;
|
||||||
|
audioFormat: 'wav' | 'ogg' | 'mp3';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelWithStatus {
|
||||||
|
model: {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
repository: string;
|
||||||
|
filename: string;
|
||||||
|
quantization: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
languages: string[];
|
||||||
|
recommendedForSimplePc: boolean;
|
||||||
|
};
|
||||||
|
status: 'notInstalled' | 'ready' | { error: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptDraft {
|
||||||
|
text: string;
|
||||||
|
lastMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSnapshot {
|
||||||
|
settings: AppSettings;
|
||||||
|
recordingStatus: RecordingStatus;
|
||||||
|
transcript: TranscriptDraft;
|
||||||
|
models: ModelWithStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeClient {
|
||||||
|
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackSnapshot: AppSnapshot = {
|
||||||
|
recordingStatus: 'idle',
|
||||||
|
transcript: {
|
||||||
|
text: '',
|
||||||
|
lastMessage: 'Modo pré-visualização: comandos nativos disponíveis dentro do Tauri.',
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
transcription: {
|
||||||
|
mode: 'localFirst',
|
||||||
|
modelId: 'parakeet-tdt-0.6b-v3-q8_0',
|
||||||
|
language: null,
|
||||||
|
openaiCompatible: {
|
||||||
|
endpoint: 'https://api.openai.com/v1/audio/transcriptions',
|
||||||
|
apiKeyConfigured: false,
|
||||||
|
model: 'whisper-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
insertion: {
|
||||||
|
method: 'clipboardPaste',
|
||||||
|
restoreClipboard: true,
|
||||||
|
waylandManualFallback: true,
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
keepAudio: false,
|
||||||
|
audioFormat: 'wav',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
model: {
|
||||||
|
id: 'parakeet-tdt-0.6b-v3-q8_0',
|
||||||
|
displayName: 'Parakeet TDT 0.6B v3 Q8',
|
||||||
|
repository: 'cstr/parakeet-tdt-0.6b-v3-GGUF',
|
||||||
|
filename: 'parakeet-tdt-0.6b-v3-q8_0.gguf',
|
||||||
|
quantization: 'Q8_0',
|
||||||
|
sizeBytes: 745_121_600,
|
||||||
|
languages: ['pt', 'en', 'es', 'fr', 'de'],
|
||||||
|
recommendedForSimplePc: true,
|
||||||
|
},
|
||||||
|
status: 'notInstalled',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createNativeClient(): NativeClient {
|
||||||
|
return {
|
||||||
|
invoke: <T>(command: string, args?: Record<string, unknown>) => tauriInvoke<T>(command, args),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function element<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
className?: string,
|
||||||
|
text?: string,
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
const mib = bytes / 1024 / 1024;
|
||||||
|
return `${mib.toFixed(0)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> {
|
||||||
|
try {
|
||||||
|
return await client.invoke<AppSnapshot>('get_app_snapshot');
|
||||||
|
} catch {
|
||||||
|
return fallbackSnapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTranscriptionApp(
|
||||||
|
mount: HTMLElement,
|
||||||
|
client: NativeClient = createNativeClient(),
|
||||||
|
): void {
|
||||||
|
mount.textContent = '';
|
||||||
|
|
||||||
|
let snapshot = fallbackSnapshot;
|
||||||
|
|
||||||
|
const shell = element('section', 'modal-shell');
|
||||||
|
shell.setAttribute('aria-labelledby', 'app-title');
|
||||||
|
|
||||||
|
const topbar = element('header', 'topbar');
|
||||||
|
const titleGroup = element('div', 'title-group');
|
||||||
|
const eyebrow = element('p', 'eyebrow', 'Transcript Desktop');
|
||||||
|
const title = element('h1', undefined, 'Transcrição');
|
||||||
|
title.id = 'app-title';
|
||||||
|
const subtitle = element('p', 'subtitle');
|
||||||
|
titleGroup.append(eyebrow, title, subtitle);
|
||||||
|
|
||||||
|
const status = element('span', 'status-pill');
|
||||||
|
topbar.append(titleGroup, status);
|
||||||
|
|
||||||
|
const editorLabel = element('label', 'editor-label', 'Texto transcrito');
|
||||||
|
editorLabel.setAttribute('for', 'transcript-editor');
|
||||||
|
|
||||||
|
const editor = element('textarea', 'transcript-editor') as HTMLTextAreaElement;
|
||||||
|
editor.id = 'transcript-editor';
|
||||||
|
editor.placeholder = 'A transcrição aparecerá aqui para revisão antes da inserção.';
|
||||||
|
editor.rows = 10;
|
||||||
|
|
||||||
|
const message = element('p', 'message');
|
||||||
|
message.setAttribute('role', 'status');
|
||||||
|
|
||||||
|
const actions = element('div', 'actions');
|
||||||
|
const recordButton = element('button', 'primary-button', 'Iniciar gravação') as HTMLButtonElement;
|
||||||
|
const continueButton = element('button', 'secondary-button', '+ Continuar') as HTMLButtonElement;
|
||||||
|
const copyButton = element('button', 'secondary-button', 'Copiar') as HTMLButtonElement;
|
||||||
|
const insertButton = element('button', 'secondary-button', 'Inserir no app') as HTMLButtonElement;
|
||||||
|
const saveButton = element('button', 'ghost-button', 'Salvar texto') as HTMLButtonElement;
|
||||||
|
actions.append(recordButton, continueButton, copyButton, insertButton, saveButton);
|
||||||
|
|
||||||
|
const modelPanel = element('aside', 'model-panel');
|
||||||
|
const modelTitle = element('h2', undefined, 'Modelo offline');
|
||||||
|
const modelDetails = element('p');
|
||||||
|
modelPanel.append(modelTitle, modelDetails);
|
||||||
|
|
||||||
|
shell.append(topbar, editorLabel, editor, message, actions, modelPanel);
|
||||||
|
mount.append(shell);
|
||||||
|
|
||||||
|
const render = (next: AppSnapshot): void => {
|
||||||
|
snapshot = next;
|
||||||
|
const isRecording = snapshot.recordingStatus === 'recording';
|
||||||
|
status.textContent = isRecording ? 'Gravando' : 'Pronto';
|
||||||
|
status.dataset.state = snapshot.recordingStatus;
|
||||||
|
subtitle.textContent = `${snapshot.settings.transcription.mode === 'localFirst' ? 'Offline primeiro' : 'API'} · idioma ${snapshot.settings.transcription.language ?? 'automático'}`;
|
||||||
|
editor.value = snapshot.transcript.text;
|
||||||
|
message.textContent = snapshot.transcript.lastMessage ?? 'Revise o texto antes de inserir.';
|
||||||
|
recordButton.textContent = isRecording ? 'Parar e transcrever' : 'Iniciar gravação';
|
||||||
|
|
||||||
|
const firstModel = snapshot.models[0]?.model;
|
||||||
|
modelDetails.textContent = firstModel
|
||||||
|
? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}`
|
||||||
|
: 'Nenhum modelo offline homologado encontrado.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const refresh = async (): Promise<void> => {
|
||||||
|
render(await safeSnapshot(client));
|
||||||
|
};
|
||||||
|
|
||||||
|
recordButton.addEventListener('click', () => {
|
||||||
|
void (async () => {
|
||||||
|
const command = snapshot.recordingStatus === 'recording'
|
||||||
|
? 'stop_recording_and_transcribe'
|
||||||
|
: 'start_recording';
|
||||||
|
render(await client.invoke<AppSnapshot>(command));
|
||||||
|
})().catch((error: unknown) => {
|
||||||
|
message.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
continueButton.addEventListener('click', () => {
|
||||||
|
void client.invoke<AppSnapshot>('start_recording').then(render).catch((error: unknown) => {
|
||||||
|
message.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
copyButton.addEventListener('click', () => {
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(editor.value)
|
||||||
|
.then(() => {
|
||||||
|
message.textContent = 'Texto copiado para a área de transferência.';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
message.textContent = 'Não foi possível copiar automaticamente. Selecione o texto manualmente.';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
insertButton.addEventListener('click', () => {
|
||||||
|
void client
|
||||||
|
.invoke<{ message: string }>('request_insert_text', { text: editor.value })
|
||||||
|
.then((result) => {
|
||||||
|
message.textContent = result.message;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
message.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
saveButton.addEventListener('click', () => {
|
||||||
|
const blob = new Blob([editor.value], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = 'transcricao.txt';
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
message.textContent = 'Arquivo de texto preparado para download.';
|
||||||
|
});
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
+3
-41
@@ -1,48 +1,10 @@
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
import { createTranscriptionApp } from './app';
|
||||||
|
|
||||||
const app = document.querySelector<HTMLDivElement>('#app');
|
const app = document.querySelector<HTMLElement>('#app');
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
throw new Error('Elemento #app não encontrado.');
|
throw new Error('Elemento #app não encontrado.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const shell = document.createElement('section');
|
createTranscriptionApp(app);
|
||||||
shell.className = 'shell';
|
|
||||||
shell.setAttribute('aria-labelledby', 'app-title');
|
|
||||||
|
|
||||||
const hero = document.createElement('header');
|
|
||||||
hero.className = 'hero';
|
|
||||||
|
|
||||||
const eyebrow = document.createElement('p');
|
|
||||||
eyebrow.className = 'eyebrow';
|
|
||||||
eyebrow.textContent = 'Transcript Desktop';
|
|
||||||
|
|
||||||
const title = document.createElement('h1');
|
|
||||||
title.id = 'app-title';
|
|
||||||
title.textContent = 'Transcrição offline para Windows e Linux';
|
|
||||||
|
|
||||||
const subtitle = document.createElement('p');
|
|
||||||
subtitle.className = 'subtitle';
|
|
||||||
subtitle.textContent =
|
|
||||||
'Estrutura inicial Tauri + Rust preparada para modal editável, CrispASR sidecar e modelo Parakeet GGUF Q8.';
|
|
||||||
|
|
||||||
hero.append(eyebrow, title, subtitle);
|
|
||||||
|
|
||||||
const card = document.createElement('article');
|
|
||||||
card.className = 'card';
|
|
||||||
card.setAttribute('aria-label', 'Estado inicial');
|
|
||||||
|
|
||||||
const statusDot = document.createElement('span');
|
|
||||||
statusDot.className = 'status-dot';
|
|
||||||
statusDot.setAttribute('aria-hidden', 'true');
|
|
||||||
|
|
||||||
const cardText = document.createElement('div');
|
|
||||||
const cardTitle = document.createElement('h2');
|
|
||||||
cardTitle.textContent = 'Pronto para configurar o fluxo';
|
|
||||||
const cardDescription = document.createElement('p');
|
|
||||||
cardDescription.textContent = 'Os módulos nativos serão conectados nas próximas fatias.';
|
|
||||||
cardText.append(cardTitle, cardDescription);
|
|
||||||
|
|
||||||
card.append(statusDot, cardText);
|
|
||||||
shell.append(hero, card);
|
|
||||||
app.append(shell);
|
|
||||||
+149
-54
@@ -1,6 +1,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color: #101418;
|
color: #15120f;
|
||||||
background: #f5f1ea;
|
background: #f4efe7;
|
||||||
font-family:
|
font-family:
|
||||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
@@ -17,8 +17,8 @@ body {
|
|||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(119, 93, 72, 0.16), transparent 32rem),
|
radial-gradient(circle at 15% 10%, rgba(122, 79, 56, 0.18), transparent 24rem),
|
||||||
#f5f1ea;
|
linear-gradient(135deg, #f7f1e8 0%, #ece4da 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
@@ -28,76 +28,171 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
button {
|
||||||
width: min(100%, 760px);
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 3px solid rgba(122, 79, 56, 0.35);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 3rem 1.25rem;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: center;
|
place-items: center;
|
||||||
gap: 1.5rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.modal-shell {
|
||||||
|
width: min(100%, 680px);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
border: 1px solid rgba(21, 18, 15, 0.12);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: rgba(255, 252, 247, 0.9);
|
||||||
|
box-shadow: 0 24px 80px rgba(54, 38, 26, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.topbar {
|
||||||
margin: 0;
|
display: flex;
|
||||||
color: #7a4f38;
|
align-items: flex-start;
|
||||||
font-size: 0.78rem;
|
justify-content: space-between;
|
||||||
font-weight: 700;
|
gap: 1rem;
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #7a4f38;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
max-width: 12ch;
|
color: #15120f;
|
||||||
font-size: clamp(2.5rem, 8vw, 5rem);
|
font-size: clamp(1.9rem, 8vw, 3.4rem);
|
||||||
line-height: 0.9;
|
line-height: 0.95;
|
||||||
letter-spacing: -0.07em;
|
letter-spacing: -0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle,
|
||||||
max-width: 42rem;
|
.message,
|
||||||
color: #586069;
|
.model-panel p {
|
||||||
font-size: 1.05rem;
|
color: #665e55;
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 1rem;
|
|
||||||
border: 1px solid rgba(16, 20, 24, 0.12);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: rgba(255, 255, 255, 0.72);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card p {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
color: #66707a;
|
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-pill {
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #1f8f5f;
|
|
||||||
box-shadow: 0 0 0 0.25rem rgba(31, 143, 95, 0.16);
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #145a3c;
|
||||||
|
background: rgba(31, 143, 95, 0.13);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill[data-state="recording"] {
|
||||||
|
color: #8a241d;
|
||||||
|
background: rgba(201, 62, 48, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-label {
|
||||||
|
color: #312923;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-editor {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 14rem;
|
||||||
|
padding: 0.95rem;
|
||||||
|
border: 1px solid rgba(21, 18, 15, 0.14);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
color: #15120f;
|
||||||
|
background: #fffdfa;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.secondary-button,
|
||||||
|
.ghost-button {
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0.65rem 0.9rem;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
color: #fffaf5;
|
||||||
|
background: #15120f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-button {
|
||||||
|
color: #15120f;
|
||||||
|
background: #eadfd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
color: #6a4a36;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(106, 74, 54, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: rgba(234, 223, 210, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-panel h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
#app {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-shell {
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions > button {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user