Интерфейс по руководству «Стиль Skillok»; одни часы записи на весь экран
По skillok/skillok-develop design/SKILLOK_STYLE.md: - кнопки и поля — --radius-control 2px; поля — --field-bg, 9/11, 13px, фокус терракотой внутрь; неактивная кнопка — 0,5; .ui-danger; мишени 44px под пальцем; - подписи полей — 12px капс с разрядкой; карточки без заливки, отступы 14–16px; вкладки 32px без жирного; моноширинный — только код, числа — tabular-nums; тень — только у сообщения; - значки — icons.ts (SVG 16, контур 1,5) вместо «★ ▶ ❚❚ ✕»; - обращение на «ты» в интерфейсе и сообщениях ядра; сняты пояснения «как пользоваться»; блок проверки звука — .audio-check (не пересекается с галочкой .check). Светлая тема остаётся единственной — решение владельца для Meet (руководство просит обе темы). Шапка и меню показывают время по записанному звуку, как панель записи. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ade9583
· сравнить с родителем
-
CHANGELOG.md +9
Показать разницуСкрыть разницу
4 4 5 5 ### Улучшено 6 6 7 +- **Интерфейс — по руководству «Стиль Skillok»** (`skillok/skillok-develop`, 8 + `design/SKILLOK_STYLE.md`): кнопки и поля со скруглением 2px, поля залиты на тон темнее 9 + страницы, подписи полей мелким капсом; карточки без своей заливки; активная вкладка — 10 + только подчёркиванием; время и числа — тем же шрифтом, что текст (моноширинный — 11 + только у кода); значки — тонкие SVG вместо символов «★ ▶ ✕»; тень — только у 12 + всплывающего сообщения. Обращение — на «ты», лишние пояснения сняты. 13 +- Время в шапке и в меню идёт по тем же часам, что на панели записи: записанный звук, 14 + паузы не в счёт. 15 + 7 16 - **Звук компьютера не пишется на Mac — понятно, что делать.** В проверке звука и на 8 17 панели записи вместо системной ошибки — шаги и кнопка «Открыть настройки macOS», которая 9 18 сразу открывает «Запись экрана и системного звука». Отдельно объяснена главная ловушка: -
crates/audio/src/mac.rs +2 −2
Показать разницуСкрыть разницу
70 70 pub fn open() -> Result<Self, CaptureError> { 71 71 let err = |e: &dyn std::fmt::Display| { 72 72 CaptureError::Loopback(format!( 73 − "{e}. Разрешите запись звука: Системные настройки → Конфиденциальность и безопасность → \ 73 + "{e}. Разреши запись звука: Системные настройки → Конфиденциальность и безопасность → \ 74 74 Запись экрана и системного звука" 75 75 )) 76 76 }; 192 192 .map_err(|_| CaptureError::Microphone("поток микрофона не запустился".into()))? 193 193 .map_err(|e| { 194 194 CaptureError::Microphone(format!( 195 − "{e}. Проверьте доступ: Системные настройки → Конфиденциальность и безопасность → Микрофон" 195 + "{e}. Проверь доступ: Системные настройки → Конфиденциальность и безопасность → Микрофон" 196 196 )) 197 197 })?; 198 198 Ok(Self { -
src-tauri/src/ai.rs +2 −2
Показать разницуСкрыть разницу
22 22 23 23 #[derive(Debug, thiserror::Error, PartialEq, Eq)] 24 24 pub enum Error { 25 − #[error("Напишите, что сделать")] 25 + #[error("Напиши, что сделать")] 26 26 EmptyRequest, 27 − #[error("Сначала расшифруйте запись")] 27 + #[error("Сначала расшифруй запись")] 28 28 EmptyNote, 29 29 } 30 30 -
src-tauri/src/commands.rs +11 −11
Показать разницуСкрыть разницу
62 62 self.secrets 63 63 .get(OPENROUTER_KEY) 64 64 .map_err(|e| e.to_string())? 65 − .ok_or_else(|| "Не подключён ИИ: добавьте ключ OpenRouter в настройках".into()) 65 + .ok_or_else(|| "Не подключён ИИ: добавь ключ OpenRouter в настройках".into()) 66 66 } 67 67 68 68 /// Начинает работу над встречей; вторая работа над той же встречей — отказ. 70 70 fn start_job(&self, id: &str) -> Result<Job<'_>, String> { 71 71 let mut jobs = self.jobs.lock().unwrap_or_else(|p| p.into_inner()); 72 72 if jobs.contains_key(id) { 73 − return Err("Над этой записью уже идёт работа — дождитесь её или отмените".into()); 73 + return Err("Над этой записью уже идёт работа — дождись её или отмени".into()); 74 74 } 75 75 let token = CancellationToken::new(); 76 76 jobs.insert(id.to_string(), token.clone()); 148 148 pub async fn set_openrouter_key(state: State<'_, AppState>, key: String) -> Result<KeyInfo, String> { 149 149 let key = key.trim().to_string(); 150 150 if key.is_empty() { 151 − return Err("Вставьте ключ OpenRouter".into()); 151 + return Err("Вставь ключ OpenRouter".into()); 152 152 } 153 153 let info = Client::new(key.clone()) 154 154 .key_info() 214 214 .unwrap_or_else(|p| p.into_inner()) 215 215 .is_some() 216 216 { 217 − return Err("Сначала остановите запись".into()); 217 + return Err("Сначала останови запись".into()); 218 218 } 219 219 if !state.jobs.lock().unwrap_or_else(|p| p.into_inner()).is_empty() { 220 − return Err("Дождитесь, пока закончится расшифровка или ответ ИИ".into()); 220 + return Err("Дождись, пока закончится расшифровка или ответ ИИ".into()); 221 221 } 222 222 let target = match path.as_deref().map(str::trim) { 223 223 Some(p) if !p.is_empty() => PathBuf::from(p), 265 265 #[tauri::command] 266 266 pub fn delete_meeting(state: State<'_, AppState>, id: String) -> Result<(), String> { 267 267 if state.is_recording(&id) { 268 − return Err("Сначала остановите запись".into()); 268 + return Err("Сначала останови запись".into()); 269 269 } 270 270 let _job = state.start_job(&id)?; 271 271 state.store().delete(&id).map_err(|e| e.to_string()) 366 366 id: String, 367 367 ) -> Result<TranscribeResult, String> { 368 368 if state.is_recording(&id) { 369 − return Err("Сначала остановите запись".into()); 369 + return Err("Сначала останови запись".into()); 370 370 } 371 371 let key = state.key()?; 372 372 let job = state.start_job(&id)?; 373 373 let settings = state.settings()?; 374 374 let meeting = state.store().get(&id).map_err(|e| e.to_string())?.meeting; 375 375 if meeting.audio.is_empty() { 376 − return Err("В этой встрече нет звука — запишите встречу или добавьте аудиофайл".into()); 376 + return Err("В этой встрече нет звука — запиши встречу или добавь аудиофайл".into()); 377 377 } 378 378 let mut tracks: Vec<(Speaker, PathBuf)> = Vec::new(); 379 379 for AudioTrack { file, speaker, .. } in &meeting.audio { 509 509 let voices = state 510 510 .store() 511 511 .load_voices(&id) 512 − .map_err(|_| "Для этой записи нет данных о голосах — расшифруйте её заново".to_string())?; 512 + .map_err(|_| "Для этой записи нет данных о голосах — расшифруй её заново".to_string())?; 513 513 let count = match count { 514 514 Some(n) if n >= 1 => Count::Fixed(n), 515 515 _ => Count::Auto { 638 638 let slot = state.recording.lock().unwrap_or_else(|p| p.into_inner()); 639 639 match slot.as_ref() { 640 640 Some(r) if r.meeting_id == id => r.session.position_ms(), 641 − _ => return Err("Запись не идёт — отметьте место в проигрывателе".into()), 641 + _ => return Err("Запись не идёт — отметь место в проигрывателе".into()), 642 642 } 643 643 } 644 644 }; 823 823 // Звук компьютера в Windows разрешения не требует; микрофон — требует. 824 824 "ms-settings:privacy-microphone" 825 825 } else { 826 − return Err("На этой системе откройте настройки конфиденциальности вручную".into()); 826 + return Err("На этой системе открой настройки конфиденциальности вручную".into()); 827 827 }; 828 828 tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string()) 829 829 } -
Показать разницуСкрыть разницу
305 305 if let Some(clash) = ids.iter().find(|id| target.join(id).exists()) { 306 306 return Err(Error::Io(std::io::Error::new( 307 307 std::io::ErrorKind::AlreadyExists, 308 − format!("в новой папке уже есть запись «{clash}» — выберите пустую папку"), 308 + format!("в новой папке уже есть запись «{clash}» — выбери пустую папку"), 309 309 ))); 310 310 } 311 311 for id in &ids { -
Показать разницуСкрыть разницу
19 19 20 20 #[derive(Debug, thiserror::Error)] 21 21 pub enum Error { 22 − #[error("OpenRouter не принял ключ — проверьте его в настройках")] 22 + #[error("OpenRouter не принял ключ — проверь его в настройках")] 23 23 Unauthorized, 24 24 #[error("На счёте OpenRouter не хватает средств")] 25 25 PaymentRequired, 29 29 Http { status: u16, message: String }, 30 30 #[error("Нет связи с OpenRouter: {0}")] 31 31 Network(String), 32 − #[error("Модель не закончила ответ ({0}) — попробуйте ещё раз или другую модель")] 32 + #[error("Модель не закончила ответ ({0}) — попробуй ещё раз или другую модель")] 33 33 Incomplete(String), 34 34 } 35 35 -
Показать разницуСкрыть разницу
67 67 } 68 68 } 69 69 70 −pub const UNRECOGNIZED: &str = "*(этот кусок не распознался — нажмите «Расшифровать» ещё раз)*"; 70 +pub const UNRECOGNIZED: &str = "*(этот кусок не распознался — нажми «Расшифровать» ещё раз)*"; 71 71 72 72 #[derive(Debug, Clone, Copy)] 73 73 pub struct Config { -
src/dev-mock.ts +9 −9
Показать разницуСкрыть разницу
185 185 return undefined as T; 186 186 case "set_openrouter_key": { 187 187 const k = String(args.key).trim(); 188 − if (!k) throw "Вставьте ключ OpenRouter"; 188 + if (!k) throw "Вставь ключ OpenRouter"; 189 189 key = k.length > 12 ? `${k.slice(0, 8)}…${k.slice(-4)}` : "•".repeat(k.length); 190 190 return info as T; 191 191 } 192 192 case "check_openrouter_key": 193 − if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках"; 193 + if (!key) throw "Не подключён ИИ: добавь ключ OpenRouter в настройках"; 194 194 return info as T; 195 195 case "delete_openrouter_key": 196 196 key = null; 215 215 return d.note as T; 216 216 } 217 217 case "set_storage_dir": 218 − if (recording) throw "Сначала остановите запись"; 218 + if (recording) throw "Сначала останови запись"; 219 219 settings.storage_dir = (args.path as string | null) ?? ""; 220 220 return (settings.storage_dir || "~/Library/Application Support/org.skillok.meet/meetings") as T; 221 221 case "open_folder": 234 234 } 235 235 case "transcribe_meeting": { 236 236 const d = find(args.id); 237 − if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках"; 238 − if (!d.meeting.audio.length) throw "В этой встрече нет звука — запишите встречу или добавьте аудиофайл"; 237 + if (!key) throw "Не подключён ИИ: добавь ключ OpenRouter в настройках"; 238 + if (!d.meeting.audio.length) throw "В этой встрече нет звука — запиши встречу или добавь аудиофайл"; 239 239 // Реплики приходят по ходу, как из ядра: по куску за раз, по порядку времени. 240 240 // У файла имени говорящего по ходу нет — оно появится после разбора по голосам. 241 241 const lines = TRANSCRIPT.replace(/^## .*\n+/, "") 262 262 } 263 263 case "regroup_speakers": { 264 264 const d = find(args.id); 265 − if (!d.voices) throw "Для этой записи нет данных о голосах — расшифруйте её заново"; 265 + if (!d.voices) throw "Для этой записи нет данных о голосах — расшифруй её заново"; 266 266 await wait(300); 267 267 const v = byVoices((args.count as number | null) ?? null); 268 268 return { note: upsert(d, "Расшифровка", v.md), voices: v.voices } as T; 270 270 case "run_command": { 271 271 const d = find(args.id); 272 272 const c = args.command as Command; 273 − if (!key) throw "Не подключён ИИ: добавьте ключ OpenRouter в настройках"; 274 − if (!d.note.trim()) throw "Заметка пустая — сначала расшифруйте аудио или напишите текст"; 275 − if (c.kind === "custom" && !c.text.trim()) throw "Напишите, что сделать"; 273 + if (!key) throw "Не подключён ИИ: добавь ключ OpenRouter в настройках"; 274 + if (!d.note.trim()) throw "Заметка пустая — сначала расшифруй запись"; 275 + if (c.kind === "custom" && !c.text.trim()) throw "Напиши, что сделать"; 276 276 cancelled = false; 277 277 const heading = c.kind === "custom" ? c.text.trim().replace(/^./, (x) => x.toUpperCase()) : { summary: "Конспект", tasks: "Задачи", review: "Ревью" }[c.kind]; 278 278 const block = -
src/icons.ts +20
Показать разницуСкрыть разницу
1 +// Значки — по руководству «Стиль Skillok»: SVG 16 × 16, контур currentColor 2 +// толщиной 1,5, скруглённые концы, без заливки. Рисунки — на сетке 24 (как 3 +// Lucide), поэтому контур 2,25: при показе в 16 px это ровно 1,5 px. Эмодзи и 4 +// символы («★», «▶», «✕») вместо значков не используются. 5 + 6 +const icon = (paths: string) => 7 + `<svg class="icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`; 8 + 9 +export const ICONS = { 10 + home: icon('<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), 11 + mic: icon('<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>'), 12 + file: icon('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>'), 13 + settings: icon('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>'), 14 + search: icon('<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>'), 15 + record: icon('<rect x="5" y="5" width="14" height="14"/>'), 16 + close: icon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>'), 17 + play: icon('<path d="M7 4.5v15l12-7.5z"/>'), 18 + pause: icon('<path d="M8 5v14"/><path d="M16 5v14"/>'), 19 + star: icon('<path d="M12 3.5l2.6 5.3 5.9.9-4.25 4.1 1 5.85L12 16.9l-5.25 2.75 1-5.85L3.5 9.7l5.9-.9z"/>'), 20 +}; -
src/nav.ts +7 −19
Показать разницуСкрыть разницу
4 4 // живого skillok.org (шапка `.hdr`, ящик `.drawer.hdr-nav`, меню `.mn`): 5 5 // закрывается крестиком, кликом мимо, Esc и переходом на другой экран. 6 6 7 −import { api, elapsed, files, type Meeting } from "./api"; 7 +import { api, files, type Meeting } from "./api"; 8 +import { clock, recordedMs } from "./recording"; 8 9 import { esc } from "./dom"; 10 +import { ICONS } from "./icons"; 9 11 import * as jobs from "./jobs"; 10 12 import { addAudio, state } from "./state"; 11 13 14 16 /** Название открытой записи — для крошек. */ 15 17 let crumb: string | null = null; 16 18 17 −const icon = (paths: string) => 18 − `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`; 19 − 20 −// Иконки — Lucide, как у skillok.org. 21 −const ICONS = { 22 − home: icon('<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), 23 − mic: icon('<path d="M12 19v3"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><rect x="9" y="2" width="6" height="13" rx="3"/>'), 24 − file: icon('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 12v6"/><path d="m15 15-3-3-3 3"/>'), 25 − settings: icon('<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>'), 26 − search: icon('<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>'), 27 − plus: icon('<path d="M5 12h14"/><path d="M12 5v14"/>'), 28 − record: icon('<rect x="4" y="4" width="16" height="16"/>'), 29 −}; 30 − 31 19 function route(): { section: string; id: string | null } { 32 20 const [section, id] = location.hash.replace(/^#\/?/, "").split("/"); 33 21 return { section: section ?? "", id: section === "m" && id ? decodeURIComponent(id) : null }; 57 45 .join(""); 58 46 const record = r 59 47 ? link(`#/m/${encodeURIComponent(r.meeting_id)}`, r.paused ? "Запись на паузе" : "Идёт запись", ICONS.record, false, 60 − `<span class="mn-count" data-strong data-drawer-timer>${elapsed(r.started_at)}</span>`) 48 + `<span class="mn-count" data-strong data-drawer-timer>${clock(recordedMs())}</span>`) 61 49 : captureSupported 62 50 ? link("#/new", "Записать встречу", ICONS.mic, section === "new") 63 51 : ""; 64 52 return ` 65 53 <div class="drawer-top"> 66 54 <a class="drawer-home" href="#/">Skillok Meet</a> 67 − <button type="button" class="drawer-close" data-drawer-close aria-label="Закрыть меню">✕</button> 55 + <button type="button" class="drawer-close" data-drawer-close aria-label="Закрыть меню">${ICONS.close}</button> 68 56 </div> 69 57 <aside class="mn"> 70 58 <div class="mn-group"><nav class="mn-list" aria-label="Меню"> 96 84 if (r) { 97 85 return `<a class="hdr-live" href="#/m/${encodeURIComponent(r.meeting_id)}"> 98 86 <span class="dot dot-live${r.paused ? " is-paused" : ""}"></span>${r.paused ? "Пауза" : "Запись"} 99 − <span class="mono-num" data-hdr-timer>${elapsed(r.started_at)}</span></a>`; 87 + <span class="mono-num" data-hdr-timer>${clock(recordedMs())}</span></a>`; 100 88 } 101 89 // Кнопки «Записать» в шапке нет (решение владельца 01.10.2026): запись 102 90 // начинается с главной или из меню. 235 223 window.addEventListener("hashchange", closeAll); 236 224 window.setInterval(() => { 237 225 if (!state.recording) return; 238 − const t = elapsed(state.recording.started_at); 226 + const t = clock(recordedMs()); 239 227 document.querySelectorAll("[data-hdr-timer], [data-drawer-timer]").forEach((el) => (el.textContent = t)); 240 228 }, 1000); 241 229 } -
src/player.ts +6 −5
Показать разницуСкрыть разницу
1 1 // Проигрыватель записи — полоса внизу экрана записи, как у MacWhisper: 2 2 // играть и пауза, ±15 секунд, скорость, «волна» всей встречи (клик или 3 −// протяжка — перемотка, на ней отметки и текущее место) и «★ отметить здесь». 3 +// протяжка — перемотка, на ней отметки и текущее место) и «отметить здесь». 4 4 // 5 5 // У записи две дорожки — микрофон и звук компьютера — отдельными файлами; 6 6 // проигрыватель играет их вместе и подравнивает, если разошлись. Файлы 7 7 // читаются с диска через протокол `asset:` — копий звука не делается. 8 8 9 9 import { api, files, type Marker, type Meeting } from "./api"; 10 +import { ICONS } from "./icons"; 10 11 import { clock } from "./recording"; 11 12 import { toast } from "./state"; 12 13 45 46 const el = document.createElement("div"); 46 47 el.className = "player"; 47 48 el.innerHTML = ` 48 − <button class="player-btn player-play" data-pl-play aria-label="Играть">▶</button> 49 + <button class="player-btn player-play" data-pl-play aria-label="Играть">${ICONS.play}</button> 49 50 <button class="player-btn" data-pl-back title="Назад на 15 секунд">−15</button> 50 51 <button class="player-btn" data-pl-fwd title="Вперёд на 15 секунд">+15</button> 51 52 <span class="player-time mono-num" data-pl-time>${clock(0)} / ${clock(view.duration_ms)}</span> 52 − <canvas class="player-wave" data-pl-wave height="40" aria-label="Вся запись: нажмите или протяните, чтобы перемотать"></canvas> 53 + <canvas class="player-wave" data-pl-wave height="40" aria-label="Вся запись — перемотка"></canvas> 53 54 <select class="input player-rate" data-pl-rate aria-label="Скорость">${RATES.map((r) => `<option value="${r}">${String(r).replace(".", ",")}×</option>`).join("")}</select> 54 − <button class="player-btn player-mark" data-pl-mark title="Отметить это место">★</button>`; 55 + <button class="player-btn player-mark" data-pl-mark title="Отметить это место" aria-label="Отметить это место">${ICONS.star}</button>`; 55 56 const canvas = el.querySelector<HTMLCanvasElement>("[data-pl-wave]")!; 56 57 const playBtn = el.querySelector<HTMLButtonElement>("[data-pl-play]")!; 57 58 90 91 } 91 92 ctx.fillRect(played * canvas.width, 0, Math.max(1, dpr), h); 92 93 el.querySelector("[data-pl-time]")!.textContent = `${clock(now())} / ${clock(total)}`; 93 − playBtn.textContent = playing() ? "❚❚" : "▶"; 94 + playBtn.innerHTML = playing() ? ICONS.pause : ICONS.play; 94 95 playBtn.setAttribute("aria-label", playing() ? "Пауза" : "Играть"); 95 96 }; 96 97 -
src/recording.ts +17 −10
Показать разницуСкрыть разницу
2 2 // экране записи. Метка «идёт запись» с любого экрана — в шапке (nav.ts). 3 3 4 4 import { api, events, type Marker } from "./api"; 5 +import { ICONS } from "./icons"; 5 6 import * as jobs from "./jobs"; 6 7 import { esc } from "./dom"; 7 8 import type { Scope } from "./scope"; 14 15 const silent = new Set<Track>(); 15 16 16 17 const SILENT_TEXT: Record<Track, string> = { 17 − me: "Минуту не слышно вашего микрофона. Он выключен — в системе или в Zoom — или выбран не тот.", 18 + me: "Минуту не слышно твоего микрофона. Он выключен — в системе или в Zoom — или выбран не тот.", 18 19 others: 19 − "Минуту не слышно собеседников. Если они говорят, а звука нет — звук компьютера не пишется: на Mac разрешите Skillok Meet «Запись экрана и системного звука» в настройках конфиденциальности.", 20 + "Минуту не слышно собеседников. Если они говорят, а звука нет — звук компьютера не пишется: на Mac разреши Skillok Meet «Запись экрана и системного звука» в настройках конфиденциальности.", 20 21 }; 21 22 22 23 const isMac = () => /Mac/i.test(navigator.userAgent); 35 36 if (!isMac()) return ""; 36 37 return ` 37 38 <div class="perm-help"> 38 − <p class="small">macOS не даёт записывать звук компьютера. Откройте «Запись экрана и системного звука» и проверьте Skillok Meet:</p> 39 + <p class="small">macOS не даёт записывать звук компьютера. Открой «Запись экрана и системного звука» и проверь Skillok Meet:</p> 39 40 <ol class="small"> 40 − <li>Нет в списке или выключен — включите.</li> 41 − <li><b>Уже включён</b> — значит, разрешение осталось от прошлой версии программы, а новой macOS прав не даёт. Выделите Skillok Meet и удалите кнопкой «−».</li> 42 − <li>Закройте и снова откройте Skillok Meet, нажмите «Записать встречу» — macOS спросит разрешение, разрешите и ещё раз перезапустите программу.</li> 41 + <li>Нет в списке или выключен — включи.</li> 42 + <li><b>Уже включён</b> — значит, разрешение осталось от прошлой версии программы, а новой macOS прав не даёт. Выдели Skillok Meet и удали кнопкой «−».</li> 43 + <li>Закрой и снова открой Skillok Meet, нажми «Записать встречу» — macOS спросит разрешение; разреши и ещё раз перезапусти программу.</li> 43 44 </ol> 44 45 <button class="ui-btn ui-btn-sm" type="button" data-open-privacy="screen">Открыть настройки macOS</button> 45 46 </div>`; 46 47 } 47 48 return ` 48 49 <div class="perm-help"> 49 − <p class="small">Проверьте, что микрофон подключён и Skillok Meet разрешён доступ к нему.${ 50 − isMac() ? " После обновления программы разрешение тоже может слететь — выключите и включите его снова." : "" 50 + <p class="small">Проверь, что микрофон подключён и Skillok Meet разрешён доступ к нему.${ 51 + isMac() ? " После обновления программы разрешение тоже может слететь — выключи и включи его снова." : "" 51 52 }</p> 52 53 <button class="ui-btn ui-btn-sm" type="button" data-open-privacy="microphone">Открыть настройки ${isMac() ? "macOS" : "Windows"}</button> 53 54 </div>`; 124 125 marks = list.slice(); 125 126 } 126 127 128 +/** Сколько звука записано, мс (паузы не в счёт) — одни часы на весь экран: 129 + * панель записи, шапка и меню. */ 130 +export function recordedMs(): number { 131 + return Math.max(positionMs, state.recording?.position_ms ?? 0); 132 +} 133 + 127 134 /** Время записи «чч:мм:сс» по записанному звуку. */ 128 135 export function clock(ms: number): string { 129 136 const t = Math.floor(ms / 1000); 169 176 <button class="ui-btn" data-rec-stop-transcribe>Остановить и расшифровать</button> 170 177 <button class="link-btn" data-rec-pause>${r.paused ? "Продолжить" : "Пауза"}</button> 171 178 <span class="spacer"></span> 172 − <button class="ui-btn ui-btn-sm rec-mark-btn" data-rec-mark title="Отметить важный момент — он будет виден в расшифровке и на проигрывателе">★ Отметить момент</button> 179 + <button class="ui-btn ui-btn-sm rec-mark-btn" data-rec-mark title="Отметка будет видна в расшифровке и на проигрывателе">${ICONS.star}Отметить момент</button> 173 180 </div> 174 181 ${ 175 182 marks.length 179 186 <input class="input" data-mark-text="${i}" value="${esc(m.text)}" placeholder="что было — необязательно" maxlength="200"></li>`, 180 187 ) 181 188 .join("")}</ol>` 182 − : `<p class="xs muted">Случилось важное — «★ Отметить момент»: место будет видно в расшифровке и на проигрывателе.</p>` 189 + : "" 183 190 } 184 191 </div>`; 185 192 } -
src/styles/app.css +55 −47
Показать разницуСкрыть разницу
17 17 transition: background-color 0.12s; 18 18 } 19 19 a.hdr-crumb:hover { background: var(--color-bg-hover); text-decoration: none; } 20 −.hdr-crumb-strong { font-weight: 700; min-width: 0; flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 20 +.hdr-crumb-strong { font-weight: 400; min-width: 0; flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 21 21 .hdr-right { display: flex; align-items: center; gap: 8px; flex: none; } 22 22 .hdr-live { 23 23 display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px; 74 74 .drawer-group-row > span { flex: 1; } 75 75 .drawer-empty { margin: 0; padding: 4px 14px 6px; font-size: var(--text-xs); color: var(--color-text-secondary); } 76 76 .drawer-filter { 77 − display: block; width: 100%; height: 32px; margin: 4px 0 8px; padding: 0 8px; 78 − font-family: var(--font-ui); font-size: var(--text-md); color: var(--color-text-primary); 79 − background: var(--background); border: 1px solid var(--color-border); outline: none; 77 + display: block; width: 100%; height: 32px; margin: 4px 0 8px; padding: 0 11px; 78 + font-family: var(--font-ui); font-size: var(--text-sm); color: var(--color-text-primary); 79 + background: var(--field-bg); border: 1px solid var(--color-border-light); border-radius: var(--radius-control); 80 80 } 81 −.drawer-filter:focus { border-color: var(--color-accent); box-shadow: rgba(166, 83, 60, 0.25) 0 0 0 3px; } 81 +.drawer-filter:focus { background: var(--background); outline: 2px solid var(--color-accent); outline-offset: -2px; } 82 82 .drawer-filter[hidden] { display: none; } 83 83 84 84 /* Меню */ 106 106 .mn-dot { flex-shrink: 0; width: 6px; height: 6px; background: var(--color-accent); } 107 107 .mn-rule { height: 1px; margin: 8px 14px; background: var(--color-border); } 108 108 109 −.main { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 32px 32px 80px; background: var(--background); } 109 +.main { flex: 1; min-width: 0; min-height: 0; overflow-y: auto; padding: 24px 16px 80px; background: var(--background); } 110 110 .home, .page { max-width: 720px; margin: 0 auto; display: grid; gap: 20px; } 111 111 .meeting { max-width: 860px; margin: 0 auto; display: grid; gap: 20px; } 112 112 113 113 /* ── Общее ── */ 114 114 .actions { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; } 115 115 .spacer { flex: 1; } 116 −.mono-num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } 116 +.mono-num { font-variant-numeric: tabular-nums; } 117 117 .back { color: var(--color-text-secondary); justify-self: start; } 118 118 .back:hover { color: var(--color-text-primary); text-decoration: none; } 119 119 .page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } 133 133 134 134 .card, .hero { 135 135 display: grid; gap: 14px; justify-items: start; 136 − padding: 28px; border: 1px solid var(--color-border); background: var(--color-bg-elevated); 136 + padding: 16px; border: 1px solid var(--color-border); 137 137 } 138 138 .card h2 { font-size: var(--text-2xl); } 139 139 .card > p { max-width: 60ch; } 152 152 @media (prefers-reduced-motion: reduce) { .dot-live, .spinner { animation: none; } } 153 153 154 154 /* ── Главная ── */ 155 −.hero { padding: 40px 32px; gap: 16px; } 155 +.hero { padding: 32px; gap: 16px; } 156 156 .hero h1 { font-size: var(--text-3xl); } 157 157 .hero-text { max-width: 56ch; color: var(--kv-body); font-size: var(--text-lg); } 158 158 .hero-btn { min-height: 44px; padding: 0 28px; font-size: var(--text-md); } 171 171 border-left: 2px solid var(--color-accent); background: var(--color-bg-secondary); color: var(--kv-body); 172 172 } 173 173 174 −.check { display: grid; gap: 10px; width: 100%; } 174 +.audio-check { display: grid; gap: 10px; width: 100%; } 175 175 .check-row { 176 176 display: grid; grid-template-columns: 140px 1fr 90px; grid-template-areas: "title bar state" "hint hint hint"; 177 177 align-items: center; gap: 4px 12px; 192 192 .records { display: grid; gap: 12px; } 193 193 .records-head { display: flex; align-items: center; justify-content: space-between; } 194 194 .records h2 { font-size: var(--text-lg); } 195 −.record-list { border: 1px solid var(--color-border); background: var(--color-bg-elevated); } 195 +.record-list { border: 1px solid var(--color-border); } 196 196 .record { 197 197 display: grid; grid-template-columns: 1fr auto; grid-template-areas: "title status" "meta status"; 198 198 gap: 2px 16px; align-items: center; padding: 12px 16px; 225 225 color: var(--color-accent-text); border: 1px solid var(--color-accent); 226 226 } 227 227 .rec-badge.is-paused { color: var(--color-text-secondary); border-color: var(--color-border-light); } 228 −.rec-timer { font-size: var(--text-6xl); font-weight: 700; line-height: 1; } 228 +.rec-timer { font-size: var(--text-5xl); font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; } 229 229 .rec-wave { display: block; width: 100%; height: 72px; background: var(--color-bg-secondary); } 230 230 .rec-sources { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; } 231 231 .src { display: inline-flex; align-items: center; gap: 8px; font-size: var(--text-sm); } 247 247 248 248 /* ── Экран записи ── */ 249 249 .bar { 250 − position: sticky; top: -32px; z-index: 2; 250 + position: sticky; top: -24px; z-index: 2; 251 251 display: flex; align-items: center; gap: 16px; 252 − margin: -32px -32px 0; padding: 20px 32px 12px; 252 + margin: -24px -16px 0; padding: 16px 16px 12px; 253 253 background: var(--background); border-bottom: 1px solid var(--color-border); 254 254 } 255 255 .bar-title { flex: 1; min-width: 0; display: grid; gap: 2px; } 263 263 .title-input:hover { border-bottom-color: var(--color-border); } 264 264 .title-input:focus { outline: none; border-bottom-color: var(--color-accent); } 265 265 266 −.big-pct { font-size: var(--text-6xl); font-weight: 700; line-height: 1; } 266 +.big-pct { font-size: var(--text-5xl); font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; } 267 267 .progress { width: 100%; height: 6px; background: var(--color-bg-secondary); overflow: hidden; } 268 268 .progress > span { display: block; height: 100%; width: 0; background: var(--color-accent); transition: width 0.3s; } 269 269 270 270 .result { display: grid; } 271 271 .tabs { display: flex; flex-wrap: wrap; gap: 0 4px; border-bottom: 1px solid var(--color-border); } 272 +/* Вкладки — как .ui-underline-nav: 32px, 0 8px, 14px, вес 400; активная — 273 + только подчёркиванием 2px терракотой, без жирного. */ 272 274 .tab { 273 − height: 38px; padding: 0 12px; 274 − font: inherit; font-size: var(--text-md); color: var(--color-text-primary); 275 − background: none; border: 0; cursor: pointer; 275 + height: 32px; padding: 0 8px; 276 + font: inherit; font-size: var(--text-md); font-weight: 400; color: var(--color-text-primary); 277 + background: none; border: 0; cursor: pointer; transition: background-color 0.12s; 276 278 } 277 279 .tab:hover { background: var(--color-bg-hover); } 278 280 .tab.is-empty { color: var(--color-text-secondary); } 279 −.tab.is-active { box-shadow: inset 0 -2px 0 var(--color-accent); color: var(--color-text-primary); font-weight: 700; } 281 +.tab.is-active { box-shadow: inset 0 -2px 0 var(--color-accent); color: var(--color-text-primary); } 280 282 .tab-custom { color: var(--color-accent-text); } 281 283 282 −.pane { border: 1px solid var(--color-border); border-top: 0; background: var(--color-bg-elevated); } 284 +.pane { border: 1px solid var(--color-border); border-top: 0; } 283 285 .pane-tools { 284 286 display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; 285 − padding: 8px 20px; border-bottom: 1px solid var(--kv-line-2); 287 + padding: 8px 14px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-secondary); 286 288 } 287 −.pane-body { padding: 20px 24px 28px; } 288 −.pane > .error { padding: 12px 24px 0; } 289 −.pane-empty { display: grid; gap: 12px; justify-items: start; padding: 36px 28px; } 289 +.pane-body { padding: 14px 16px 20px; } 290 +.pane > .error { padding: 12px 16px 0; } 291 +.pane-empty { display: grid; gap: 12px; justify-items: start; padding: 32px 16px; } 290 292 .pane-empty h3 { font-size: var(--text-xl); } 291 293 .pane-empty > p { max-width: 60ch; } 292 294 .pane-empty.ask .input { width: 100%; max-width: 560px; } 293 295 .working { display: flex; align-items: center; font-size: var(--text-lg); } 294 296 .pane-tools .working { font-size: var(--text-sm); } 295 −.pane-edit { display: grid; gap: 12px; padding: 16px 20px; } 297 +.pane-edit { display: grid; gap: 12px; padding: 14px 16px; } 296 298 .section-editor { 297 − width: 100%; min-height: 50vh; padding: 12px 14px; 298 − font-family: var(--font-mono); font-size: var(--text-sm); line-height: 1.65; 299 − color: var(--color-text-primary); background: var(--background); 300 − border: 1px solid var(--color-border-light); resize: vertical; 299 + width: 100%; min-height: 50vh; padding: 9px 11px; 300 + font-family: var(--font-ui); font-size: var(--text-sm); line-height: 1.6; 301 + color: var(--color-text-primary); background: var(--field-bg); 302 + border: 1px solid var(--color-border-light); border-radius: var(--radius-control); resize: vertical; 301 303 } 302 −.section-editor:focus { outline: 2px solid var(--color-accent); outline-offset: -1px; } 304 +.section-editor:focus { background: var(--background); outline: 2px solid var(--color-accent); outline-offset: -2px; } 303 305 304 306 .meeting-foot { padding-top: 8px; display: flex; gap: 20px; flex-wrap: wrap; } 305 307 .path { display: block; padding: 8px 10px; background: var(--color-bg-secondary); word-break: break-all; } 312 314 .tr-time { font-size: var(--text-xs); color: var(--color-text-secondary); padding-top: 3px; } 313 315 button.tr-time { 314 316 justify-self: start; align-self: start; height: fit-content; padding: 3px 0 0; margin: 0; 315 − font-family: var(--font-mono); background: none; border: 0; cursor: pointer; text-align: left; 317 + font-family: var(--font-ui); font-variant-numeric: tabular-nums; background: none; border: 0; cursor: pointer; text-align: left; 316 318 } 317 319 button.tr-time:hover { color: var(--color-accent-text); text-decoration: underline; text-underline-offset: 3px; } 318 320 .tr-row.is-playing { box-shadow: inset 2px 0 0 var(--color-accent); background: var(--color-bg-hover); } 319 321 .tr-row.is-playing .tr-time { color: var(--color-accent-text); } 320 322 .tr-mark .tr-who { color: var(--kv-ink); } 323 +.tr-mark-title { display: inline-flex; align-items: center; gap: 6px; } 324 +.drawer-close svg { width: 16px; height: 16px; } 321 325 .tr-mark p { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; } 322 326 .tr-text { display: grid; gap: 2px; } 323 327 .tr-who { font-size: var(--text-sm); font-weight: 700; color: var(--color-accent-text); } 329 333 background: none; border: 0; cursor: pointer; text-align: left; 330 334 } 331 335 button.tr-who:hover { text-decoration: underline; text-underline-offset: 3px; } 332 −.tr-who-input { justify-self: start; width: min(240px, 100%); padding: 2px 6px; font-size: var(--text-sm); font-weight: 700; } 336 +.tr-who-input { justify-self: start; width: min(240px, 100%); padding: 4px 8px; font-size: var(--text-sm); font-weight: 700; } 333 337 .tr-text p { color: var(--kv-body); line-height: 1.6; } 334 338 335 339 /* Проигрыватель — полоса внизу экрана записи */ 336 340 .player { 337 341 position: sticky; bottom: -80px; z-index: 3; 338 342 display: flex; align-items: center; gap: 10px; 339 − max-width: 860px; margin: 20px auto 0; padding: 10px 14px; 340 − background: var(--color-bg-elevated); border: 1px solid var(--color-border); 341 − box-shadow: 0 -4px 16px rgba(10, 10, 10, 0.06); 343 + max-width: 860px; margin: 20px auto 0; padding: 8px 12px; 344 + background: var(--background); border: 1px solid var(--color-border); 342 345 } 346 +/* Кнопки проигрывателя — как кнопки шапки: 32 × 32, рамка 1px, значок 16px. */ 343 347 .player-btn { 344 − flex: none; min-width: 34px; height: 32px; padding: 0 8px; 345 − font: inherit; font-size: var(--text-sm); color: var(--kv-ink); 346 − background: transparent; border: 1px solid var(--color-border-light); cursor: pointer; 348 + flex: none; display: inline-flex; align-items: center; justify-content: center; gap: 4px; 349 + min-width: 32px; height: 32px; padding: 0 6px; 350 + font: inherit; font-size: var(--text-xs); font-variant-numeric: tabular-nums; color: var(--color-text-secondary); 351 + background: transparent; border: 1px solid var(--color-border-light); border-radius: var(--radius-control); 352 + cursor: pointer; transition: background-color 0.12s, color 0.12s; 347 353 } 348 −.player-btn:hover { background: var(--color-bg-hover); } 349 −.player-play { min-width: 40px; border: 2px solid var(--kv-ink); font-weight: 700; } 350 −.player-mark { color: var(--color-accent-text); border-color: var(--color-accent); } 354 +.player-btn:hover { color: var(--color-text-primary); background: var(--color-bg-hover); } 355 +.player-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } 356 +.player-btn svg { width: 16px; height: 16px; } 357 +.player-play { color: var(--kv-ink); border: 2px solid var(--kv-ink); } 358 +.player-mark { color: var(--color-accent-text); } 359 +@media (pointer: coarse) { .player-btn { min-width: 44px; height: 44px; } } 351 360 .player-time { flex: none; font-size: var(--text-xs); color: var(--color-text-secondary); } 352 361 .player-wave { flex: 1; min-width: 80px; height: 40px; cursor: pointer; touch-action: none; } 353 −.player-rate { flex: none; width: auto; padding: 4px 6px; font-size: var(--text-xs); } 362 +.player-rate { flex: none; width: auto; padding: 5px 8px; font-size: var(--text-xs); } 354 363 355 364 /* Markdown результата */ 356 365 .md { display: grid; gap: 12px; max-width: 72ch; } 380 389 position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 20; 381 390 max-width: min(640px, calc(100% - 32px)); margin: 0; padding: 10px 16px; 382 391 font-size: var(--text-sm); color: var(--background); background: var(--kv-ink); 383 − box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); 392 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); 384 393 } 385 −.toast.is-error { background: var(--color-error); color: #fff; } 394 +.toast.is-error { background: var(--color-error); color: var(--color-on-accent); } 386 395 387 396 @media (max-width: 700px) { 388 397 .player { flex-wrap: wrap; } 390 399 } 391 400 392 401 @media (max-width: 900px) { 393 − .main { padding: 24px 20px 64px; } 394 − .bar { top: -24px; margin: -24px -20px 0; padding: 16px 20px 10px; } 395 − .hero, .card { padding: 24px 20px; } 402 + .main { padding: 24px 16px 64px; } 403 + .hero { padding: 24px 16px; } 396 404 } -
src/styles/tokens.css +7 −2
Показать разницуСкрыть разницу
1 1 /* Токены стиля — копия системы skillok.org 2 2 (src/web-backend/crates/web/templates/base.html и ui.html в репозитории Skillok). 3 − Правила те же: новых цветов не заводить, кегль только из шкалы --text-*, 4 − пол 12px, углы квадратные, JetBrains Mono — только для кода и времени. */ 3 + Правила — руководство «Стиль Skillok» (skillok/skillok-develop, 4 + design/SKILLOK_STYLE.md): новых цветов не заводить, кегль только из шкалы 5 + --text-*, пол 12px, скругление 2px только у кнопок и полей, JetBrains Mono — 6 + только для кода. */ 5 7 6 8 @font-face { 7 9 font-family: 'iA Writer Quattro S'; 71 73 --color-bg-secondary: #EBEBE8; 72 74 --color-bg-elevated: #FAFAF8; 73 75 --color-bg-hover: rgba(10, 10, 10, 0.06); 76 + /* Поле залито на тон темнее страницы, как у GitHub; скругление — только кнопкам и полям. */ 77 + --field-bg: var(--color-bg-secondary); 78 + --radius-control: 2px; 74 79 --color-error: #C81E1E; 75 80 76 81 color-scheme: light; -
src/styles/ui.css +38 −16
Показать разницуСкрыть разницу
1 −/* Компоненты — по образцу ui.html skillok.org: контурная кнопка, ряд вкладок 2 − с подчёркиванием, поля и строки списка. Квадратные углы везде. */ 1 +/* Компоненты — по руководству «Стиль Skillok» (skillok/skillok-develop, 2 + design/SKILLOK_STYLE.md): одна контурная кнопка, ряд вкладок с 3 + подчёркиванием, поля, залитые на тон темнее страницы, строки списка. 4 + Скругление 2px — только у кнопок и полей; карточки, бейджи, вкладки — прямые. */ 3 5 4 6 *, *::before, *::after { box-sizing: border-box; font-variant-ligatures: no-contextual; } 5 7 22 24 h3 { font-size: var(--text-md); } 23 25 p { margin: 0; } 24 26 27 +/* Моноширинный — только код. Время и числа — тем же шрифтом с ровными цифрами. */ 25 28 code, .mono { font-family: var(--font-mono); font-size: var(--text-xs); } 29 +.mono-num { font-variant-numeric: tabular-nums; } 26 30 27 31 /* ── Кнопка: одна, контурная (как «Войти» в шапке skillok.org) ── */ 28 32 .ui-btn { 42 46 color: var(--kv-ink); 43 47 background: transparent; 44 48 border: 2px solid var(--kv-ink); 45 − border-radius: 0; 49 + border-radius: var(--radius-control); 46 50 cursor: pointer; 47 − transition: background 0.15s, color 0.15s, border-color 0.15s; 51 + transition: background 0.12s, color 0.12s, border-color 0.12s; 48 52 white-space: normal; 49 53 text-wrap: balance; 50 54 flex-shrink: 0; 52 56 .ui-btn:hover { background: var(--kv-ink); color: var(--background); } 53 57 .ui-btn:active { background: var(--color-accent); border-color: var(--color-accent); color: var(--color-on-accent); } 54 58 .ui-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } 55 −.ui-btn[disabled] { opacity: 0.45; cursor: not-allowed; } 59 +.ui-btn[disabled] { opacity: 0.5; cursor: not-allowed; } 56 60 .ui-btn[disabled]:hover { background: transparent; color: var(--kv-ink); } 57 61 .ui-btn-sm { min-height: 28px; padding: 0 10px; font-size: var(--text-xs); } 62 +.ui-btn svg { flex: none; width: 16px; height: 16px; } 63 +/* Опасное действие — тот же контур цветом ошибки. */ 64 +.ui-danger { color: var(--color-error); border-color: var(--color-error); } 65 +.ui-danger:hover { background: var(--color-error); color: var(--color-on-accent); } 66 +/* Под пальцем — мишень не меньше 44 × 44. */ 67 +@media (pointer: coarse) { 68 + .ui-btn, .ui-btn-sm { min-height: 44px; min-width: 44px; } 69 +} 58 70 59 71 /* ── Ряд вкладок ── */ 60 72 .ui-underline-nav { 91 103 92 104 /* ── Поля ── */ 93 105 .field { display: grid; gap: 6px; } 94 −.field > label, .field-label { font-size: var(--text-sm); font-weight: 700; } 106 +/* Подпись поля (.ui-head): 12px, капс, разрядка, вторичный цвет. */ 107 +.field > label, .field-label { 108 + font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; 109 + color: var(--color-text-secondary); 110 +} 95 111 .field-hint { font-size: var(--text-xs); color: var(--color-text-secondary); } 96 112 .input, .textarea { 97 113 width: 100%; 98 − padding: 6px 10px; 114 + padding: 9px 11px; 99 115 font: inherit; 100 − font-size: var(--text-md); 116 + font-size: var(--text-sm); 117 + line-height: 1.5; 101 118 color: var(--color-text-primary); 102 − background: var(--color-bg-elevated); 119 + background: var(--field-bg); 103 120 border: 1px solid var(--color-border-light); 104 − border-radius: 0; 121 + border-radius: var(--radius-control); 122 +} 123 +.input::placeholder, .textarea::placeholder { color: var(--color-text-secondary); } 124 +.input:focus, .textarea:focus { background: var(--background); outline: 2px solid var(--color-accent); outline-offset: -2px; } 125 +.textarea { min-height: 120px; resize: vertical; line-height: 1.6; } 126 +@media (pointer: coarse) { 127 + .input, .textarea { font-size: var(--text-lg); } 105 128 } 106 −.input:focus, .textarea:focus { outline: 2px solid var(--color-accent); outline-offset: -1px; border-color: transparent; } 107 −.textarea { min-height: 120px; resize: vertical; line-height: 1.5; } 108 129 .textarea.mono { font-size: var(--text-sm); } 109 130 .check { display: flex; align-items: flex-start; gap: 8px; font-size: var(--text-md); cursor: pointer; } 110 131 .check input { margin-top: 4px; accent-color: var(--color-accent); } 111 132 112 133 /* ── Рамка-блок и список ── */ 113 −.box { border: 1px solid var(--color-border); background: var(--color-bg-elevated); } 134 +/* Карточка: рамка 1px, своей заливки нет — бумага страницы; шапка на подложке. */ 135 +.box { border: 1px solid var(--color-border); } 114 136 .box-head { 115 137 display: flex; align-items: center; justify-content: space-between; gap: 12px; 116 − padding: 10px 16px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-secondary); 138 + padding: 10px 14px; border-bottom: 1px solid var(--color-border); background: var(--color-bg-secondary); 117 139 } 118 −.box-body { padding: 16px; display: grid; gap: 16px; } 140 +.box-body { padding: 14px; display: grid; gap: 16px; } 119 141 .row { 120 142 display: flex; align-items: baseline; gap: 16px; 121 143 padding: 12px 16px; border-top: 1px solid var(--color-border); 129 151 .error { color: var(--color-error); font-size: var(--text-sm); } 130 152 .ok { color: var(--color-accent-text); font-size: var(--text-sm); } 131 153 .label { 132 − display: inline-block; padding: 0 6px; line-height: 20px; 154 + display: inline-block; padding: 0 7px; line-height: 20px; letter-spacing: 0.06em; 133 155 font-size: var(--text-xs); border: 1px solid var(--color-border-light); color: var(--color-text-secondary); 134 156 } 135 157 .label-accent { border-color: var(--color-accent); color: var(--color-accent-text); } -
src/views/home.ts +7 −8
Показать разницуСкрыть разницу
11 11 if (state.recording) { 12 12 return `<section class="hero is-live">${recordingPanel()}</section>`; 13 13 } 14 − const pick = `<button class="link-btn" data-pick>выберите файл</button>`; 14 + const pick = `<button class="link-btn" data-pick>выбери файл</button>`; 15 15 if (!view.capture_supported) { 16 16 return ` 17 17 <section class="hero drop"> 18 − <h1>Добавьте запись встречи</h1> 19 − <p class="hero-text">На этой системе запись не поддерживается. Перетащите сюда аудиофайл — mp3, m4a, wav и другие.</p> 18 + <h1>Добавь запись встречи</h1> 19 + <p class="hero-text">На этой системе запись не поддерживается. Перетащи сюда аудиофайл — mp3, m4a, wav и другие.</p> 20 20 <button class="ui-btn hero-btn" data-pick>Выбрать файл</button> 21 21 </section>`; 22 22 } 23 23 return ` 24 24 <section class="hero"> 25 − <h1>Запишите встречу</h1> 26 − <p class="hero-text">Zoom, Google Meet, Телемост — что угодно. Запишутся собеседники и вы, потом — расшифровка, конспект и задачи.</p> 25 + <h1>Запиши встречу</h1> 26 + <p class="hero-text">Zoom, Google Meet, Телемост — что угодно. Запишутся собеседники и ты, потом — расшифровка, конспект и задачи.</p> 27 27 <a class="ui-btn hero-btn" href="#/new">Записать</a> 28 − <p class="small muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.</p> 29 28 </section> 30 29 <section class="hero drop"> 31 − <p>Уже есть запись? Перетащите аудиофайл сюда или ${pick}.</p> 30 + <p>Уже есть запись? Перетащи аудиофайл сюда или ${pick}.</p> 32 31 <p class="small muted">mp3, m4a, wav, aiff, flac, ogg</p> 33 32 </section>`; 34 33 } 74 73 ${ 75 74 view.openrouter_key 76 75 ? "" 77 − : `<p class="notice">Чтобы расшифровывать записи, подключите ИИ — <a href="#/settings">это минута</a>.</p>` 76 + : `<p class="notice">Чтобы расшифровывать записи, подключи ИИ — <a href="#/settings">это минута</a>.</p>` 78 77 } 79 78 ${list(meetings)} 80 79 </div>`; -
src/views/meeting.ts +7 −7
Показать разницуСкрыть разницу
13 13 import { plain, renameSpeaker, render, sectionBody, sections, speakerNameProblem, toggleTask, upsertSection } from "../markdown"; 14 14 import { bindRecording, clock, recordingPanel } from "../recording"; 15 15 import type { Scope } from "../scope"; 16 +import { ICONS } from "../icons"; 16 17 import * as jobs from "../jobs"; 17 18 import { createPlayer, type Player } from "../player"; 18 19 import { refreshNav, setCrumb } from "../nav"; 101 102 <div class="tr-row tr-mark" data-row-ms="${m.ms}"> 102 103 <button type="button" class="tr-time mono-num" data-seek-ms="${m.ms}" title="Слушать с этого места">${clock(m.ms)}</button> 103 104 <div class="tr-text"> 104 − <span class="tr-who">★ Отметка</span> 105 + <span class="tr-who tr-mark-title">${ICONS.star}Отметка</span> 105 106 <p>${m.text ? esc(m.text) : `<span class="muted">без подписи</span>`} 106 107 <button type="button" class="link-btn link-danger xs" data-mark-del="${index}" title="Убрать отметку">убрать</button></p> 107 108 </div> 206 207 if (!hasKey) { 207 208 return ` 208 209 <section class="card"> 209 − <h2>Подключите ИИ, чтобы расшифровать</h2> 210 − <p class="muted">Расшифровка идёт через ваш ключ OpenRouter. Подключается один раз.</p> 210 + <h2>Подключи ИИ, чтобы расшифровать</h2> 211 + <p class="muted">Расшифровка идёт через твой ключ OpenRouter. Подключается один раз.</p> 211 212 <a class="ui-btn" href="#/settings">Подключить</a> 212 213 </section>`; 213 214 } 214 215 return ` 215 216 <section class="card"> 216 − <h2>Расшифруйте запись</h2> 217 + <h2>Расшифруй запись</h2> 217 218 <p class="muted">${dur ? `${esc(dur)} звука. ` : ""}Речь превратится в текст с временем и говорящими${ 218 219 meetingDuration(d.meeting) >= 10 * 60_000 ? " — это займёт несколько минут" : "" 219 220 }.</p> 272 273 ? `<a class="ui-btn" href="#/settings">Подключить ИИ</a>` 273 274 : `<button class="ui-btn" data-make="${esc(tab)}" ${busyElsewhere ? "disabled" : ""}>${esc(preset?.action ?? "Сделать")}</button>` 274 275 } 275 − ${busyElsewhere ? `<p class="small muted">Сначала дождитесь, пока закончится текущая работа.</p>` : ""} 276 + ${busyElsewhere ? `<p class="small muted">Сначала дождись, пока закончится текущая работа.</p>` : ""} 276 277 </div>`; 277 278 } 278 279 294 295 return ` 295 296 <div class="pane-tools"> 296 297 <button class="link-btn" data-edit>Править</button> 297 − ${isTranscript ? `<span class="xs muted">Имя говорящего — нажмите, чтобы переименовать</span>` : ""} 298 298 ${ 299 299 isTranscript && d.voices 300 300 ? `<label class="xs muted speakers-pick">Говорящих 458 458 459 459 root.querySelector("[data-transcribe]")?.addEventListener("click", transcribe); 460 460 root.querySelector("[data-retranscribe]")?.addEventListener("click", async () => { 461 − if (!confirm("Расшифровать заново? Нынешняя расшифровка и ваши правки в ней заменятся новой.")) return; 461 + if (!confirm("Расшифровать заново? Нынешняя расшифровка и твои правки в ней заменятся новой.")) return; 462 462 await transcribe(); 463 463 }); 464 464 root.querySelector("[data-cancel]")?.addEventListener("click", () => api.cancelJob(id)); -
src/views/new.ts +5 −5
Показать разницуСкрыть разницу
26 26 <div class="page"> 27 27 <section class="card"> 28 28 <h1>Новая запись</h1> 29 − <p>На этой системе запись не поддерживается. Добавьте готовый аудиофайл — перетащите его в окно или выберите через меню.</p> 29 + <p>На этой системе запись не поддерживается. Добавь готовый аудиофайл — перетащи его в окно или выбери в меню.</p> 30 30 </section> 31 31 </div>`; 32 32 return; 38 38 <h1>Новая запись</h1> 39 39 <div class="prep-block"> 40 40 <p class="field-label">Проверка звука</p> 41 − <div class="check" data-check> 42 − ${checkRow("me", "Микрофон", "скажите что-нибудь")} 43 − ${checkRow("others", "Звук компьютера", "включите любой звук — видео, музыку; на встрече это голоса собеседников")} 41 + <div class="audio-check" data-check> 42 + ${checkRow("me", "Микрофон", "скажи что-нибудь")} 43 + ${checkRow("others", "Звук компьютера", "включи любой звук — видео или музыку")} 44 44 </div> 45 45 </div> 46 46 <p class="muted">Лучше в наушниках — так голоса собеседников не попадут в микрофон второй раз.</p> 47 47 ${ 48 48 view.openrouter_key 49 49 ? "" 50 − : `<p class="notice">Записать можно и без ИИ. Чтобы потом расшифровать, подключите его в <a href="#/settings">настройках</a>.</p>` 50 + : `<p class="notice">Записать можно и без ИИ. Чтобы потом расшифровать, подключи его в <a href="#/settings">настройках</a>.</p>` 51 51 } 52 52 <button class="ui-btn hero-btn" data-start>Начать запись</button> 53 53 </section> -
src/views/settings.ts +2 −2
Показать разницуСкрыть разницу
36 36 : `<form class="field" data-key-form> 37 37 <label for="or-key">Ключ</label> 38 38 <input id="or-key" class="input" name="key" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…" required> 39 − <p class="field-hint">Через ключ идут расшифровка и команды ИИ, платите вы OpenRouter напрямую. Ключ проверяется при сохранении и хранится в хранилище паролей системы, не в файлах. <a href="https://openrouter.ai/keys" target="_blank" rel="noreferrer">Где взять ключ</a></p> 39 + <p class="field-hint">Через ключ идут расшифровка и команды ИИ; платишь OpenRouter напрямую. Ключ проверяется при сохранении и хранится в хранилище паролей системы, не в файлах. <a href="https://openrouter.ai/keys" target="_blank" rel="noreferrer">Где взять ключ</a></p> 40 40 <div class="actions"><button class="ui-btn" type="submit">Сохранить ключ</button></div> 41 41 </form>` 42 42 } 70 70 <select id="m-stt" class="input" name="stt_model"> 71 71 ${sttOptions(s.stt_model)} 72 72 </select> 73 − <p class="field-hint">Звук пишется без сжатия, 16 кГц — ровно столько, сколько использует распознавание; больше частота точность не поднимает. Точность поднимают полная модель, наушники и заданный язык.</p> 73 + <p class="field-hint">Полная модель точнее, turbo — быстрее и дешевле.</p> 74 74 </div> 75 75 <details class="advanced"> 76 76 <summary class="small">Модель ИИ для результатов</summary>