skillok meeting

Интерфейс по руководству «Стиль 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>

anc · 4 октября 2026 г. 955f445c6196281d40574259b51317a618aa5376
19 файлов
  • 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 сразу открывает «Запись экрана и системного звука». Отдельно объяснена главная ловушка:
  • Показать разницуСкрыть разницу
    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
  • Показать разницуСкрыть разницу
    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 }
  • Показать разницуСкрыть разницу
    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>`;
  • Показать разницуСкрыть разницу
    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>
  • Показать разницуСкрыть разницу
    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>