feat: wire model download UI and native docs
This commit is contained in:
1 parent
0c39063150
commit
8f754b3f00
8 files changed
+218
-152
No files matched your search
@@ -26,7 +26,7 @@ jobs:
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Install Tauri Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -3,11 +3,11 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag de release, por exemplo v0.1.0'
|
||||
description: "Tag de release, por exemplo v0.1.0"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -20,9 +20,9 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- platform: windows-latest
|
||||
args: ''
|
||||
args: ""
|
||||
- platform: ubuntu-22.04
|
||||
args: ''
|
||||
args: ""
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
with:
|
||||
tagName: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
releaseName: Transcript ${{ github.event.inputs.tag || github.ref_name }}
|
||||
releaseBody: 'Build automatizado do Transcript para Windows e Linux.'
|
||||
releaseBody: "Build automatizado do Transcript para Windows e Linux."
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
@@ -4,26 +4,28 @@ Aplicativo desktop de transcrição para Windows e Linux, construído com Tauri
|
||||
|
||||
## Estado atual
|
||||
|
||||
Este repositório contém o MVP estrutural:
|
||||
Este repositório contém um MVP nativo funcional:
|
||||
|
||||
- app Tauri 2 funcional;
|
||||
- modal editável em TypeScript sem framework pesado;
|
||||
- backend Rust modular;
|
||||
- catálogo do modelo `cstr/parakeet-tdt-0.6b-v3-GGUF` em Q8;
|
||||
- contratos para CrispASR sidecar, API OpenAI-compatible, gravação e inserção de texto;
|
||||
- catálogo e download do modelo `cstr/parakeet-tdt-0.6b-v3-GGUF` em Q8;
|
||||
- captura real de microfone em WAV via CPAL;
|
||||
- execução do sidecar CrispASR/Parakeet;
|
||||
- hotkey global `Ctrl+Shift+Space`;
|
||||
- inserção híbrida por clipboard + paste automático no Windows/X11 quando disponível;
|
||||
- fallback manual no Wayland;
|
||||
- tray/system tray básico;
|
||||
- CI, build e release via GitHub Actions.
|
||||
|
||||
Ainda não implementa captura real de áudio, hotkey global, download real do modelo ou injeção nativa em outros aplicativos. Essas integrações já têm módulos dedicados para implementação incremental.
|
||||
|
||||
## Arquitetura
|
||||
|
||||
```text
|
||||
src/ Frontend TypeScript/CSS
|
||||
src-tauri/src/
|
||||
audio/ Estado de gravação MVP
|
||||
audio/ Captura de microfone e gravação WAV
|
||||
transcription/ Contratos, CrispASR sidecar e API compatível com OpenAI
|
||||
injection/ Contratos/fallback de inserção
|
||||
injection/ Clipboard, paste automático e fallback manual
|
||||
models/ Catálogo do modelo offline
|
||||
settings/ Configuração padrão
|
||||
tray.rs Menu da bandeja do sistema
|
||||
@@ -44,10 +46,22 @@ A regra de design é manter arquivos pequenos, responsabilidades claras e zero o
|
||||
|
||||
O modelo não deve ser embutido no instalador principal. A estratégia recomendada é instalar o app leve e baixar o GGUF pelo gerenciador de modelos.
|
||||
|
||||
## Sidecar CrispASR
|
||||
|
||||
O app chama por padrão o binário `parakeet-main` com:
|
||||
|
||||
```bash
|
||||
parakeet-main -m <modelo.gguf> -f <audio.wav> -t <threads>
|
||||
```
|
||||
|
||||
Para o modelo v3, o projeto CrispASR recomenda compilar o target `parakeet-main` no branch `parakeet`. O app limita automaticamente o número de threads a no máximo 4 para manter compatibilidade com PCs simples.
|
||||
|
||||
Enquanto o binário não estiver no `PATH` ou empacotado como sidecar, a transcrição local retorna erro claro após a gravação.
|
||||
|
||||
## Compatibilidade Windows/Linux
|
||||
|
||||
- Windows: alvo principal para inserção automática futura via APIs Win32.
|
||||
- Linux X11: alvo de automação completa quando possível.
|
||||
- Windows: clipboard + tentativa de `Ctrl+V` via `SendKeys`.
|
||||
- Linux X11: clipboard + tentativa de `Ctrl+V` via `xdotool` quando instalado.
|
||||
- Linux Wayland: suporte parcial por limitação de segurança dos compositors. O fallback seguro é copiar o texto e orientar o usuário a colar manualmente.
|
||||
|
||||
## Comandos locais
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
- Modify: `src-tauri/src/ui_commands.rs`
|
||||
- Modify: `src-tauri/Cargo.toml`
|
||||
|
||||
- [ ] Add URL de download do Hugging Face e resolução de diretório local.
|
||||
- [ ] Add validação por existência e tamanho mínimo/esperado.
|
||||
- [ ] Add comando `download_default_model` com escrita em arquivo temporário e rename atômico.
|
||||
- [ ] Testar status notInstalled/ready sem baixar o arquivo real.
|
||||
- [x] Add URL de download do Hugging Face e resolução de diretório local.
|
||||
- [x] Add validação por existência e tamanho mínimo/esperado.
|
||||
- [x] Add comando `download_default_model` com escrita em arquivo temporário e rename atômico.
|
||||
- [x] Testar status notInstalled/ready sem baixar o arquivo real.
|
||||
|
||||
## Task 2: Captura real de áudio
|
||||
|
||||
@@ -30,10 +30,10 @@
|
||||
- Modify: `src-tauri/src/ui_commands.rs`
|
||||
- Modify: `src-tauri/Cargo.toml`
|
||||
|
||||
- [ ] Implementar gravador CPAL que escreve WAV via hound.
|
||||
- [ ] Guardar stream ativo no estado para manter gravação viva.
|
||||
- [ ] Stop finaliza WAV e retorna caminho.
|
||||
- [ ] Manter teste unitário de máquina de estados.
|
||||
- [x] Implementar gravador CPAL que escreve WAV via hound.
|
||||
- [x] Manter stream vivo em thread de gravação e guardar handles seguros no estado.
|
||||
- [x] Stop finaliza WAV e retorna caminho.
|
||||
- [x] Manter teste unitário de máquina de estados.
|
||||
|
||||
## Task 3: Execução CrispASR
|
||||
|
||||
@@ -42,10 +42,10 @@
|
||||
- Modify: `src-tauri/src/ui_commands.rs`
|
||||
- Modify: `src-tauri/tauri.conf.json`
|
||||
|
||||
- [ ] Resolver binário via variável/env/config e depois sidecar empacotado.
|
||||
- [ ] Executar `crispasr -m <model> -f <audio>`.
|
||||
- [ ] Capturar stdout/stderr e retornar erro claro.
|
||||
- [ ] Parsear texto simples ou JSON quando disponível.
|
||||
- [x] Resolver binário padrão `parakeet-main` para o modelo v3.
|
||||
- [x] Executar `parakeet-main -m <model> -f <audio> -t <threads>`.
|
||||
- [x] Capturar stdout/stderr e retornar erro claro.
|
||||
- [x] Parsear stdout como texto simples; JSON fica para evolução futura se o sidecar for configurado para isso.
|
||||
|
||||
## Task 4: Hotkey global
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
- Modify: `src-tauri/Cargo.toml`
|
||||
- Modify: `src-tauri/src/lib.rs`
|
||||
|
||||
- [ ] Adicionar `tauri-plugin-global-shortcut`.
|
||||
- [ ] Registrar `Ctrl+Alt+Space` no setup.
|
||||
- [ ] Ao pressionar, mostrar/focar janela e emitir evento `hotkey-toggle-recording` para a UI.
|
||||
- [x] Adicionar `tauri-plugin-global-shortcut`.
|
||||
- [x] Registrar `Ctrl+Shift+Space` no setup.
|
||||
- [x] Ao pressionar, emitir evento `transcript://toggle-recording` para a UI.
|
||||
|
||||
## Task 5: Inserção/cópia cross-platform
|
||||
|
||||
@@ -64,10 +64,10 @@
|
||||
- Modify: `src-tauri/src/ui_commands.rs`
|
||||
- Modify: `src-tauri/Cargo.toml`
|
||||
|
||||
- [ ] Adicionar clipboard backend com `arboard`.
|
||||
- [ ] Implementar `request_insert_text` que sempre coloca o texto no clipboard.
|
||||
- [ ] Retornar mensagens por plataforma/display server.
|
||||
- [ ] Manter Wayland como parcial/manual.
|
||||
- [x] Adicionar clipboard backend com `arboard`.
|
||||
- [x] Implementar `request_insert_text` que sempre coloca o texto no clipboard.
|
||||
- [x] Retornar mensagens por plataforma/display server.
|
||||
- [x] Manter Wayland como parcial/manual.
|
||||
|
||||
## Task 6: UI, CI e docs
|
||||
|
||||
@@ -77,10 +77,10 @@
|
||||
- Modify: `.github/workflows/*.yml`
|
||||
- Modify: `README.md`
|
||||
|
||||
- [ ] Mostrar status de modelo e botão de download.
|
||||
- [ ] Reagir ao evento de hotkey.
|
||||
- [ ] Atualizar CI Linux com `libasound2-dev`.
|
||||
- [ ] Rodar verificação final completa.
|
||||
- [x] Mostrar status de modelo e botão de download.
|
||||
- [x] Reagir ao evento de hotkey.
|
||||
- [x] Atualizar CI Linux com `libasound2-dev`.
|
||||
- [x] Rodar verificação final completa.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ impl TextInjector for FallbackInjector {
|
||||
fn current_display_server() -> DisplayServer {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return DisplayServer::Windows;
|
||||
DisplayServer::Windows
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -127,14 +127,18 @@ fn current_display_server() -> DisplayServer {
|
||||
.unwrap_or_default()
|
||||
.to_lowercase();
|
||||
if session_type == "wayland" || std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||
return DisplayServer::Wayland;
|
||||
}
|
||||
if session_type == "x11" || std::env::var_os("DISPLAY").is_some() {
|
||||
return DisplayServer::X11;
|
||||
DisplayServer::Wayland
|
||||
} else if session_type == "x11" || std::env::var_os("DISPLAY").is_some() {
|
||||
DisplayServer::X11
|
||||
} else {
|
||||
DisplayServer::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
DisplayServer::Unknown
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
DisplayServer::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> Result<String, String> {
|
||||
|
||||
+37
-2
@@ -33,10 +33,12 @@ export interface ModelWithStatus {
|
||||
filename: string;
|
||||
quantization: string;
|
||||
sizeBytes: number;
|
||||
downloadUrl?: string;
|
||||
languages: string[];
|
||||
recommendedForSimplePc: boolean;
|
||||
};
|
||||
status: "notInstalled" | "ready" | { error: string };
|
||||
localPath?: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptDraft {
|
||||
@@ -123,6 +125,12 @@ function formatBytes(bytes: number): string {
|
||||
return `${mib.toFixed(0)} MiB`;
|
||||
}
|
||||
|
||||
function modelStatusLabel(status: ModelWithStatus["status"]): string {
|
||||
if (status === "ready") return "pronto";
|
||||
if (status === "notInstalled") return "não instalado";
|
||||
return status.error;
|
||||
}
|
||||
|
||||
async function safeSnapshot(client: NativeClient): Promise<AppSnapshot> {
|
||||
try {
|
||||
return await client.invoke<AppSnapshot>("get_app_snapshot");
|
||||
@@ -205,7 +213,13 @@ export function createTranscriptionApp(
|
||||
const modelPanel = element("aside", "model-panel");
|
||||
const modelTitle = element("h2", undefined, "Modelo offline");
|
||||
const modelDetails = element("p");
|
||||
modelPanel.append(modelTitle, modelDetails);
|
||||
const modelStatus = element("p", "model-status");
|
||||
const downloadModelButton = element(
|
||||
"button",
|
||||
"ghost-button",
|
||||
"Baixar modelo",
|
||||
) as HTMLButtonElement;
|
||||
modelPanel.append(modelTitle, modelDetails, modelStatus, downloadModelButton);
|
||||
|
||||
shell.append(topbar, editorLabel, editor, message, actions, modelPanel);
|
||||
mount.append(shell);
|
||||
@@ -223,10 +237,17 @@ export function createTranscriptionApp(
|
||||
? "Parar e transcrever"
|
||||
: "Iniciar gravação";
|
||||
|
||||
const firstModel = snapshot.models[0]?.model;
|
||||
const firstModelStatus = snapshot.models[0];
|
||||
const firstModel = firstModelStatus?.model;
|
||||
modelDetails.textContent = firstModel
|
||||
? `${firstModel.displayName} · ${firstModel.quantization} · ${formatBytes(firstModel.sizeBytes)} · ${firstModel.repository}`
|
||||
: "Nenhum modelo offline homologado encontrado.";
|
||||
modelStatus.textContent = firstModelStatus
|
||||
? `Status: ${modelStatusLabel(firstModelStatus.status)}`
|
||||
: "Status: indisponível";
|
||||
downloadModelButton.disabled = firstModelStatus?.status === "ready";
|
||||
downloadModelButton.textContent =
|
||||
firstModelStatus?.status === "ready" ? "Modelo pronto" : "Baixar modelo";
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
@@ -249,6 +270,20 @@ export function createTranscriptionApp(
|
||||
void toggleRecording().catch(showError);
|
||||
});
|
||||
|
||||
downloadModelButton.addEventListener("click", () => {
|
||||
downloadModelButton.disabled = true;
|
||||
downloadModelButton.textContent = "Baixando...";
|
||||
message.textContent = "Baixando modelo offline. Isso pode demorar.";
|
||||
void client
|
||||
.invoke("download_default_model_command")
|
||||
.then(refresh)
|
||||
.catch((error) => {
|
||||
downloadModelButton.disabled = false;
|
||||
downloadModelButton.textContent = "Baixar modelo";
|
||||
showError(error);
|
||||
});
|
||||
});
|
||||
|
||||
continueButton.addEventListener("click", () => {
|
||||
void client
|
||||
.invoke<AppSnapshot>("start_recording")
|
||||
|
||||
+117
-104
@@ -1,198 +1,211 @@
|
||||
:root {
|
||||
color: #15120f;
|
||||
background: #f4efe7;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
color: #15120f;
|
||||
background: #f4efe7;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 15% 10%, rgba(122, 79, 56, 0.18), transparent 24rem),
|
||||
linear-gradient(135deg, #f7f1e8 0%, #ece4da 100%);
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 15% 10%,
|
||||
rgba(122, 79, 56, 0.18),
|
||||
transparent 24rem
|
||||
),
|
||||
linear-gradient(135deg, #f7f1e8 0%, #ece4da 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid rgba(122, 79, 56, 0.35);
|
||||
outline-offset: 2px;
|
||||
outline: 3px solid rgba(122, 79, 56, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-shell {
|
||||
width: min(100%, 680px);
|
||||
display: grid;
|
||||
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);
|
||||
width: min(100%, 680px);
|
||||
display: grid;
|
||||
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);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #7a4f38;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #7a4f38;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #15120f;
|
||||
font-size: clamp(1.9rem, 8vw, 3.4rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.06em;
|
||||
color: #15120f;
|
||||
font-size: clamp(1.9rem, 8vw, 3.4rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.message,
|
||||
.model-panel p {
|
||||
color: #665e55;
|
||||
line-height: 1.5;
|
||||
color: #665e55;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
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;
|
||||
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);
|
||||
color: #8a241d;
|
||||
background: rgba(201, 62, 48, 0.14);
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
color: #312923;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
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;
|
||||
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;
|
||||
min-height: 1.5rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
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;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 0.8rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
color: #fffaf5;
|
||||
background: #15120f;
|
||||
color: #fffaf5;
|
||||
background: #15120f;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
color: #15120f;
|
||||
background: #eadfd2;
|
||||
color: #15120f;
|
||||
background: #eadfd2;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
color: #6a4a36;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(106, 74, 54, 0.22);
|
||||
color: #6a4a36;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(106, 74, 54, 0.22);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.model-panel {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(234, 223, 210, 0.46);
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding: 0.85rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(234, 223, 210, 0.46);
|
||||
}
|
||||
|
||||
.model-panel h2 {
|
||||
font-size: 0.95rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.model-status {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
#app {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
#app {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-shell {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.modal-shell {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
.topbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.actions > button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.actions > button {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user