Document Phase 0 scaffold and architecture
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
.vite/
|
||||
out/
|
||||
coverage/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
resources/models/
|
||||
423
AGENTS.md
Normal file
423
AGENTS.md
Normal file
@@ -0,0 +1,423 @@
|
||||
# Max Index — контракт для агента
|
||||
|
||||
## Что мы создаём
|
||||
|
||||
Max Index — небольшое кроссплатформенное desktop-приложение для дейликов и других разговоров.
|
||||
|
||||
Приложение:
|
||||
|
||||
1. Слушает выбранный источник звука.
|
||||
2. Локально распознаёт русскую речь.
|
||||
3. Находит заранее настроенные фразы вроде «это сделать легко».
|
||||
4. Увеличивает индекс от 0 до 100.
|
||||
5. Немедленно меняет лицо персонажа и показывает короткую реакцию.
|
||||
|
||||
Продукт должен ощущаться как смешной живой индикатор встречи, а не как тяжёлая система транскрибации.
|
||||
|
||||
## Какой результат хотим получить
|
||||
|
||||
Первая полноценная версия должна:
|
||||
|
||||
- работать на Windows, macOS и Linux;
|
||||
- запускаться как обычное desktop-приложение и уметь жить в tray;
|
||||
- по умолчанию слушать микрофон;
|
||||
- показывать, что прослушивание включено или остановлено;
|
||||
- позволять выбрать аудиоустройство;
|
||||
- распознавать речь локально, без облачного API;
|
||||
- поддерживать редактируемые правила: варианты фразы, величина увеличения и cooldown;
|
||||
- увеличивать индекс строго один раз на одно произнесение;
|
||||
- ограничивать индекс диапазоном `0..100`;
|
||||
- показывать последнюю сработавшую фразу и величину изменения;
|
||||
- позволять поставить прослушивание на паузу и сбросить индекс;
|
||||
- не сохранять сырой звук;
|
||||
- не сохранять полный транскрипт по умолчанию;
|
||||
- не блокировать интерфейс во время распознавания;
|
||||
- понятно объяснять ошибки разрешений, модели и аудиоустройства.
|
||||
|
||||
## Границы первой версии
|
||||
|
||||
В первую версию входят:
|
||||
|
||||
- режим микрофона;
|
||||
- локальное потоковое распознавание;
|
||||
- список правил фраз;
|
||||
- индекс и история срабатываний текущей сессии;
|
||||
- несколько состояний лица;
|
||||
- Start, Pause и Reset;
|
||||
- выбор микрофона;
|
||||
- сборка установщиков для основных desktop-платформ.
|
||||
|
||||
В первую версию не входят без отдельного запроса:
|
||||
|
||||
- аккаунты и серверная часть;
|
||||
- синхронизация между устройствами;
|
||||
- мобильные приложения;
|
||||
- запись встреч;
|
||||
- полная стенограмма;
|
||||
- определение, кто именно произнёс фразу;
|
||||
- обучение собственной speech-to-text модели;
|
||||
- сложная аналитика и рейтинги сотрудников;
|
||||
- обязательный захват системного звука.
|
||||
|
||||
Захват звука компьютера добавить отдельным вертикальным срезом после стабильного режима микрофона. Он нужен для встреч в наушниках, но имеет разные ограничения на Windows, macOS и Linux.
|
||||
|
||||
## Основной стек
|
||||
|
||||
- Electron.
|
||||
- TypeScript в strict-режиме.
|
||||
- React для интерфейса.
|
||||
- Vite для разработки и сборки renderer-кода.
|
||||
- Electron Forge для установщиков.
|
||||
- Web Audio API и `AudioWorklet` для потокового PCM.
|
||||
- `utilityProcess` для локального распознавания.
|
||||
- `sherpa-onnx` с русской streaming-моделью T-one как первый recognizer.
|
||||
- Адаптер recognizer, позволяющий позже проверить `whisper.cpp` без переделки приложения.
|
||||
- Vitest для unit-тестов.
|
||||
- Playwright или эквивалентный Electron smoke-test только там, где он реально окупается.
|
||||
|
||||
Если в репозитории уже принят другой package manager, formatter или test runner, следовать репозиторию. В новом репозитории использовать `pnpm`.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Разделять приложение на процессы и чистую предметную логику:
|
||||
|
||||
```text
|
||||
Renderer
|
||||
├─ React UI
|
||||
├─ выбор источника
|
||||
└─ Web Audio / AudioWorklet
|
||||
│ PCM-блоки
|
||||
▼
|
||||
Utility process
|
||||
├─ ресемплинг и буферизация
|
||||
├─ speech-to-text adapter
|
||||
└─ partial/final transcript events
|
||||
│ текст
|
||||
▼
|
||||
Domain
|
||||
├─ нормализация текста
|
||||
├─ поиск правил
|
||||
├─ защита от дублей
|
||||
└─ индекс 0..100
|
||||
│ события
|
||||
▼
|
||||
Renderer
|
||||
├─ лицо
|
||||
├─ индекс
|
||||
└─ история срабатываний
|
||||
```
|
||||
|
||||
### Ответственность процессов
|
||||
|
||||
`main`:
|
||||
|
||||
- жизненный цикл Electron;
|
||||
- окна и tray;
|
||||
- безопасные IPC-каналы;
|
||||
- разрешения операционной системы;
|
||||
- путь к модели и настройки;
|
||||
- запуск и восстановление utility-процесса.
|
||||
|
||||
`preload`:
|
||||
|
||||
- только узкий типизированный API через `contextBridge`;
|
||||
- никаких произвольных вызовов Node.js из renderer;
|
||||
- никаких универсальных `send(channel, payload)` наружу.
|
||||
|
||||
`renderer`:
|
||||
|
||||
- интерфейс;
|
||||
- получение MediaStream;
|
||||
- AudioWorklet;
|
||||
- отображение состояния;
|
||||
- отсутствие тяжёлого inference-кода.
|
||||
|
||||
`utility process`:
|
||||
|
||||
- загрузка нативного recognizer;
|
||||
- обработка PCM;
|
||||
- потоковое распознавание;
|
||||
- контроль очереди и backpressure;
|
||||
- возврат текста и диагностических состояний.
|
||||
|
||||
`domain`:
|
||||
|
||||
- чистые TypeScript-функции без Electron и React;
|
||||
- правила фраз;
|
||||
- deduplication и cooldown;
|
||||
- вычисление индекса;
|
||||
- выбор визуального состояния.
|
||||
|
||||
## Предлагаемая структура
|
||||
|
||||
```text
|
||||
src/
|
||||
main/
|
||||
preload/
|
||||
renderer/
|
||||
components/
|
||||
features/
|
||||
audio/
|
||||
capture/
|
||||
recognition/
|
||||
domain/
|
||||
triggers/
|
||||
index/
|
||||
face/
|
||||
shared/
|
||||
resources/
|
||||
models/
|
||||
faces/
|
||||
tests/
|
||||
fixtures/
|
||||
```
|
||||
|
||||
Не создавать все каталоги заранее. Добавлять структуру по мере появления реального кода.
|
||||
|
||||
## Контракт аудио
|
||||
|
||||
- Получать микрофон через `navigator.mediaDevices.getUserMedia`.
|
||||
- Получать сырой звук через `AudioWorklet`, а не строить основной realtime-путь на `MediaRecorder`.
|
||||
- Передавать mono PCM блоками примерно по 100–300 мс.
|
||||
- Не полагаться на то, что ОС или Chromium действительно выдали запрошенный sample rate.
|
||||
- Выполнять ресемплинг в одном явно определённом месте.
|
||||
- Не отправлять один IPC-вызов на каждый 128-sample worklet frame.
|
||||
- Не использовать синхронный IPC.
|
||||
- Останавливать MediaStream tracks при Pause, смене источника и завершении приложения.
|
||||
- Использовать идентификатор audio session и игнорировать запоздалые результаты старой сессии.
|
||||
- Ограничивать очередь PCM; при перегрузке сообщать состояние, а не бесконечно накапливать память.
|
||||
- Не писать сырой звук на диск без отдельного явного debug-режима.
|
||||
|
||||
## Контракт распознавания
|
||||
|
||||
Определить небольшой интерфейс recognizer:
|
||||
|
||||
```ts
|
||||
interface SpeechRecognizer {
|
||||
start(config: RecognitionConfig): Promise<void>;
|
||||
acceptPcm(chunk: Float32Array): void;
|
||||
stop(): Promise<void>;
|
||||
onEvent(handler: (event: RecognitionEvent) => void): () => void;
|
||||
}
|
||||
```
|
||||
|
||||
Не протаскивать API конкретной библиотеки в UI или domain-логику.
|
||||
|
||||
События должны различать:
|
||||
|
||||
- partial transcript;
|
||||
- final transcript;
|
||||
- ready/listening state;
|
||||
- recoverable error;
|
||||
- fatal model error.
|
||||
|
||||
Каждое transcript-событие должно содержать:
|
||||
|
||||
- `sessionId` — запуск аудиосессии;
|
||||
- `utteranceId` — одно отдельное произнесение;
|
||||
- `sequence` — номер ревизии текста внутри произнесения;
|
||||
- `kind` — `partial` или `final`;
|
||||
- `text`.
|
||||
|
||||
Все partial и final одного произнесения обязаны иметь одинаковый `utteranceId`. Если recognizer не предоставляет такой идентификатор, adapter должен синтезировать его на основе endpointing, не перекладывая эту задачу на UI.
|
||||
|
||||
Распознавание должно работать локально. Любой облачный вариант добавлять только как явную необязательную функцию после согласования.
|
||||
|
||||
## Контракт правил и индекса
|
||||
|
||||
Минимальная модель правила:
|
||||
|
||||
```ts
|
||||
interface TriggerRule {
|
||||
id: string;
|
||||
title: string;
|
||||
patterns: string[];
|
||||
delta: number;
|
||||
cooldownMs: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
Перед сопоставлением:
|
||||
|
||||
- привести текст к нижнему регистру;
|
||||
- заменить `ё` на `е`;
|
||||
- нормализовать пробелы;
|
||||
- убрать незначащую пунктуацию;
|
||||
- учитывать границы слов.
|
||||
|
||||
Сначала использовать точные варианты фраз. Нечёткое сравнение по умолчанию выключить: оно легко создаёт ложные срабатывания.
|
||||
|
||||
Streaming ASR может вернуть одну фразу в нескольких partial-результатах. Одно произнесение обязано создавать только одно `TriggerMatched` событие. Идентифицировать совпадение по `sessionId + utteranceId + ruleId + позиции фразы`. Cooldown проверять после дедупликации: это дополнительная защита, но не замена корректной идентичности события.
|
||||
|
||||
Если occurrence был подавлен cooldown, всё равно пометить его обработанным, чтобы поздний final того же utterance не сработал повторно.
|
||||
|
||||
Индекс изменять только через чистую функцию:
|
||||
|
||||
```ts
|
||||
next = Math.max(0, Math.min(100, current + delta));
|
||||
```
|
||||
|
||||
React-компоненты не должны самостоятельно искать фразы или изменять индекс.
|
||||
|
||||
## Визуальный контракт
|
||||
|
||||
Главный элемент окна — лицо персонажа и индекс. Остальные элементы не должны с ними конкурировать.
|
||||
|
||||
Базовые уровни:
|
||||
|
||||
| Индекс | Состояние |
|
||||
| ---: | --- |
|
||||
| 0–19 | спокойный |
|
||||
| 20–39 | настороженный |
|
||||
| 40–59 | раздражённый |
|
||||
| 60–79 | на грани |
|
||||
| 80–99 | критический |
|
||||
| 100 | MAXIMUM |
|
||||
|
||||
На срабатывание показывать короткую transient-реакцию, затем возвращаться к состоянию текущего уровня.
|
||||
|
||||
Использовать оригинального персонажа и оригинальные ассеты. Не копировать спрайты Doom. Допустимо повторить принцип HUD-лица: несколько базовых эмоций, idle-взгляды и отдельный кадр реакции.
|
||||
|
||||
Предпочитать PNG/WebP sprite sheet и простую state machine. Не добавлять тяжёлый animation framework без необходимости. Уважать системную настройку reduced motion.
|
||||
|
||||
## Интерфейс первой версии
|
||||
|
||||
Основное окно должно содержать:
|
||||
|
||||
- лицо;
|
||||
- крупное значение индекса;
|
||||
- состояние `Listening`, `Paused`, `No permission`, `Model error`;
|
||||
- Start/Pause;
|
||||
- Reset;
|
||||
- последнюю найденную фразу;
|
||||
- компактный переход к настройкам.
|
||||
|
||||
Настройки должны содержать:
|
||||
|
||||
- аудиоустройство;
|
||||
- список trigger rules;
|
||||
- изменение `delta` и cooldown;
|
||||
- тест микрофона;
|
||||
- диагностический transcript preview, явно помеченный как временный.
|
||||
|
||||
Не показывать технические термины вроде PCM, ONNX или endpointing обычному пользователю. Ошибки писать человеческим языком и давать одно понятное действие.
|
||||
|
||||
## Приватность
|
||||
|
||||
- Всегда явно показывать активное прослушивание.
|
||||
- Не запускать микрофон скрытно.
|
||||
- Не сохранять звук.
|
||||
- Не вести полную историю распознанной речи по умолчанию.
|
||||
- В обычных логах не писать полный transcript.
|
||||
- Хранить только настройки и короткие события сработавших правил.
|
||||
- Предусмотреть понятную очистку истории текущей сессии.
|
||||
- Перед использованием на рабочей встрече напомнить пользователю учитывать правила компании и согласие участников.
|
||||
|
||||
## Кроссплатформенные требования
|
||||
|
||||
- Не собирать пути строковой конкатенацией; использовать `path` и Electron resource paths.
|
||||
- Не считать, что бинарник, модель или библиотека одинаковы для всех OS/arch.
|
||||
- Проверять Windows x64, macOS arm64/x64 и Linux x64 как отдельные release targets.
|
||||
- На macOS предусмотреть описания разрешений микрофона и системного аудио в `Info.plist`.
|
||||
- На Windows корректно объяснять отключённый глобальный доступ desktop-приложений к микрофону.
|
||||
- На Linux не обещать одинаковую работу loopback без проверки PipeWire/PulseAudio окружения.
|
||||
- После первого получения модели приложение должно уметь работать offline.
|
||||
- Проверять целостность загружаемой модели и не начинать распознавание с неполным файлом.
|
||||
|
||||
## Правила написания кода
|
||||
|
||||
- Писать названия типов, файлов и переменных на английском; пользовательский интерфейс первой версии — на русском.
|
||||
- Использовать строгие типы; не вводить `any` ради быстрого исправления.
|
||||
- Держать domain-логику независимой от Electron, React и конкретного recognizer.
|
||||
- Не создавать абстракции до появления второго реального варианта, кроме явно нужного `SpeechRecognizer` и audio source boundary.
|
||||
- Не смешивать сбор аудио, распознавание, matching и UI в одном сервисе.
|
||||
- Не использовать глобальные mutable singleton-состояния для сессии.
|
||||
- Делать операции Start, Pause, Stop и Reset идемпотентными.
|
||||
- Обрабатывать отмену и завершение процессов.
|
||||
- Не оставлять фоновые listeners после закрытия окна или смены сессии.
|
||||
- Комментариями объяснять причину нетривиального решения, а не пересказывать код.
|
||||
- Не проводить несвязанный рефакторинг вместе с продуктовой задачей.
|
||||
|
||||
## Обязательные проверки
|
||||
|
||||
Для trigger/index-логики покрыть тестами как минимум:
|
||||
|
||||
- точное совпадение;
|
||||
- разные варианты одной фразы;
|
||||
- `ё` и `е`;
|
||||
- повтор одного partial transcript;
|
||||
- partial, который затем стал final;
|
||||
- два настоящих произнесения с паузой;
|
||||
- пересекающиеся правила;
|
||||
- слово как часть другого слова;
|
||||
- cooldown;
|
||||
- достижение и превышение 100;
|
||||
- Reset.
|
||||
|
||||
Для аудио проверить:
|
||||
|
||||
- Start → Listening;
|
||||
- Pause действительно останавливает tracks;
|
||||
- смену устройства;
|
||||
- исчезновение устройства;
|
||||
- падение recognizer;
|
||||
- перезапуск utility process;
|
||||
- игнорирование событий старой сессии;
|
||||
- ограничение очереди;
|
||||
- отсутствие сохранённых аудиофайлов.
|
||||
|
||||
Для UI проверить:
|
||||
|
||||
- все шесть уровней лица;
|
||||
- transient-реакцию;
|
||||
- состояния разрешений и ошибок;
|
||||
- работу при выключенной анимации;
|
||||
- отсутствие зависания при медленном recognizer.
|
||||
|
||||
## Рекомендуемый порядок реализации
|
||||
|
||||
1. Создать Electron scaffold и чистые domain-модули с unit-тестами.
|
||||
2. Собрать первый сквозной demo-срез на mock recognizer: Start → тестовое utterance → индекс → реакция лица.
|
||||
3. Подключить микрофон, AudioWorklet, session lifecycle и mock inference в utility process.
|
||||
4. Заменить mock на локальный `sherpa-onnx` adapter и проверить качество на реальных фразах.
|
||||
5. Добавить редактирование rules, выбор устройства, tray и нормальные error states.
|
||||
6. Собрать установщики и пройти release-проверки на каждой заявленной платформе.
|
||||
7. Только затем добавлять system loopback отдельными platform providers.
|
||||
|
||||
## Как агент должен работать
|
||||
|
||||
1. Сначала прочитать этот файл и существующий код.
|
||||
2. Кратко сформулировать, какой пользовательский результат будет получен.
|
||||
3. Назвать основные файлы, которых коснётся изменение.
|
||||
4. Выбрать самый маленький законченный вертикальный срез.
|
||||
5. Сохранить существующие решения, если нет конкретной причины менять их.
|
||||
6. Реализовать изменение.
|
||||
7. Запустить релевантные проверки.
|
||||
8. Не заявлять о проверке платформы, на которой тесты не запускались.
|
||||
9. В конце дать короткую человеческую сводку.
|
||||
|
||||
Формат итоговой сводки:
|
||||
|
||||
```text
|
||||
Готово
|
||||
- Что теперь умеет пользователь.
|
||||
|
||||
Изменено
|
||||
- Путь к файлу — зачем он изменён.
|
||||
|
||||
Проверено
|
||||
- Какие команды или сценарии прошли.
|
||||
|
||||
Осталось
|
||||
- Только реальные ограничения или следующий логичный шаг.
|
||||
```
|
||||
|
||||
Не писать полотно о каждой строке. Объяснять важные решения обычным языком, учитывая, что пользователь — опытный разработчик, которому нужна короткая и хорошо структурированная сводка.
|
||||
|
||||
## Навыки проекта
|
||||
|
||||
- `$build-max-index` — общая разработка и выбор вертикального среза.
|
||||
- `$implement-max-index-audio` — микрофон, системный звук, AudioWorklet, recognizer и разрешения.
|
||||
- `$implement-max-index-experience` — правила фраз, индекс, HUD и реакции персонажа.
|
||||
- `$verify-max-index` — тестирование, диагностика и проверка готовности релиза.
|
||||
37
PRODUCT.md
Normal file
37
PRODUCT.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Команды и ведущие встреч, которым нужен лёгкий локальный индикатор повторяющихся фраз во время дейликов и других разговоров. Основной сценарий происходит прямо во время встречи: пользователь должен одним взглядом понимать состояние индекса и управлять прослушиванием, не отвлекаясь на технические детали распознавания речи.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
Max Index локально слушает выбранный источник звука, находит настроенные русские фразы и превращает каждое подтверждённое срабатывание в понятное изменение индекса и реакцию персонажа. Успех означает, что приложение ощущается как смешной живой индикатор встречи, работает без облака и не превращается в систему записи или полной транскрибации.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Живой, ироничный, ненавязчивый. Интерфейс должен быстро сообщать состояние, а характер проявлять через оригинального персонажа и короткие реакции, не через декоративный шум.
|
||||
|
||||
## Anti-references
|
||||
|
||||
- Тяжёлые панели транскрибации, аналитики и наблюдения за сотрудниками.
|
||||
- Скрытое прослушивание или интерфейс, который маскирует активный микрофон.
|
||||
- Техническая консоль с терминами PCM, ONNX и endpointing в основном пользовательском потоке.
|
||||
- Копирование HUD, спрайтов или визуального языка Doom.
|
||||
- Универсальный Electron hello-world и безликий SaaS-dashboard.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. Лицо, индекс и состояние прослушивания считываются за один взгляд.
|
||||
2. Каждое действие пользователя имеет немедленный и однозначный результат.
|
||||
3. Юмор живёт в реакции персонажа, а не мешает управлению встречей.
|
||||
4. Приватность видима: активное прослушивание нельзя перепутать с паузой.
|
||||
5. Техническая сложность остаётся за человеческими сообщениями и одним следующим действием.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
Интерфейс должен работать с клавиатуры, иметь заметный focus state и достаточный контраст, не полагаться только на цвет для передачи состояния и уважать `prefers-reduced-motion`. Формальный уровень WCAG пока не заявляется; доступность проверяется как обязательная часть каждого пользовательского среза.
|
||||
54
README.md
54
README.md
@@ -1,2 +1,54 @@
|
||||
# max-index
|
||||
# Max Index
|
||||
|
||||
Кроссплатформенное Electron-приложение, которое локально распознаёт русскую
|
||||
речь и превращает настроенные фразы в живой индекс встречи.
|
||||
|
||||
## Текущий статус
|
||||
|
||||
Готов Phase 0: воспроизводимый Electron Forge + Vite + React + strict
|
||||
TypeScript scaffold, безопасное окно, статическое состояние `Пауза` и первые
|
||||
чистые domain-функции для диапазона индекса и уровней лица.
|
||||
|
||||
Микрофон и распознавание пока не подключены. Кнопки в окне намеренно
|
||||
недоступны, поэтому scaffold не создаёт ложного впечатления активного
|
||||
прослушивания.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуются Node.js `>=22.13 <23` и pnpm `10.34.x`. Точная версия закреплена в
|
||||
`packageManager` и выбирается через Corepack.
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Основные проверки:
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm package
|
||||
```
|
||||
|
||||
`pnpm package` доказывает сборку только для текущего хоста. Windows и Linux
|
||||
проверяются отдельными release-ланами.
|
||||
|
||||
## Архитектурное направление
|
||||
|
||||
```text
|
||||
Renderer capture
|
||||
→ bounded MessagePort
|
||||
→ utilityProcess recognizer
|
||||
→ RecognitionEvent
|
||||
→ main-owned SessionController + pure domain
|
||||
→ versioned renderer snapshot
|
||||
```
|
||||
|
||||
Первый вертикальный срез использует детерминированный mock в настоящем utility
|
||||
process. Нативный `sherpa-onnx-node` и модель T-one подключаются отдельным
|
||||
packaging spike после доказанной границы процессов.
|
||||
|
||||
Полный порядок реализации, контракты и evidence gates находятся в
|
||||
[`docs/goals/max-index-v1/PLAN.md`](docs/goals/max-index-v1/PLAN.md).
|
||||
|
||||
14
docs/goals/max-index-v1/GOAL.md
Normal file
14
docs/goals/max-index-v1/GOAL.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Goal: Max Index V1
|
||||
|
||||
Phase 0 is complete. Before executing the next phase, run a task-level PRE
|
||||
review for that phase, update `docs/goals/max-index-v1/PLAN.md`, then use
|
||||
Krypton Execution for the approved slice.
|
||||
|
||||
Core rules:
|
||||
|
||||
- Treat `PLAN.md` as the source plan.
|
||||
- Execute only the next PRE-approved phase; do not batch later phases.
|
||||
- Preserve intent, ownership, contract, cutover, evidence, and kill criteria.
|
||||
- Do not add a new dominant path without deleting, redirecting, demoting, or shimming the displaced path.
|
||||
- Capture acceptance evidence from the target perspective.
|
||||
- Say "implemented but unproven" if that evidence cannot be captured.
|
||||
232
docs/goals/max-index-v1/PLAN.md
Normal file
232
docs/goals/max-index-v1/PLAN.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Max Index V1 Implementation Plan
|
||||
|
||||
**Intent:** превратить пустой репозиторий в воспроизводимое кроссплатформенное desktop-приложение, которое локально распознаёт русскую речь и один раз увеличивает индекс при подтверждённой фразе.
|
||||
**Current Behavior:** Phase 0 уже даёт packaged Electron/React shell, закреплённый dependency graph и чистые index/face primitives; захват микрофона, recognizer, оркестрация сессии и редактируемые правила ещё не реализованы.
|
||||
**Expected Outcome:** разработчик поднимает проект через `pnpm`, а пользователь получает честно обозначенное локальное прослушивание, реакцию персонажа и индекс `0..100` без записи сырого звука и полной стенограммы.
|
||||
**Target-Perspective Output:** пользователь запускает приложение, выбирает микрофон, нажимает Start, произносит настроенную фразу и видит ровно одно изменение индекса и короткую реакцию; Pause действительно освобождает микрофон, повторный запуск после загрузки модели работает offline.
|
||||
**Truth Owner:** требования принадлежат корневому `AGENTS.md`; зависимости — `package.json` и `pnpm-lock.yaml`; желаемое состояние сессии, индекс и история — экземпляр `SessionController` в main-процессе поверх чистого domain reducer; MediaStream — renderer; готовность recognizer, очередь и границы utterance — utility process.
|
||||
**Contract Boundary:** узкий типизированный preload API для управления и versioned snapshots; `RecognitionEvent` с `sessionId`, `utteranceId`, `sequence`, `kind` и `text`; отдельный bounded `MessagePort` для будущего PCM data plane.
|
||||
**Cutover:** greenfield; mock recognizer сначала доказывает настоящую межпроцессную границу, затем `sherpa-onnx` становится единственным production recognizer.
|
||||
**Displaced Path:** удалить любой Forge demo-код; после подключения sherpa оставить mock только как test/dev dependency injection; после появления оригинальных ассетов удалить CSS-заглушку лица.
|
||||
**Value Density:** первый законченный срез проходит через renderer, preload, main, utility и domain, доказывая главную инварианту partial → final без двойного увеличения.
|
||||
**Acceptance Evidence:** на целевой платформе виден сценарий «микрофон → русская фраза → одно увеличение → Pause останавливает tracks → offline restart»; unit/integration/smoke проверки покрывают границы процессов и отказ recognizer.
|
||||
**Evidence Lane:** детерминированные Vitest-тесты, packaged smoke на текущем хосте, ручной сценарий с реальным микрофоном и отдельная release-матрица по OS/arch.
|
||||
**Kill Criteria:** packaged build не содержит доступного пользователю mock-пути; PCM не проходит через control IPC; renderer не хранит канонический индекс; сырые аудиофайлы и полный transcript не появляются на диске; system loopback не смешан с microphone provider.
|
||||
**Architecture Slice:** `Renderer capture → MessagePort → Utility recognizer → RecognitionEvent → Main SessionController/domain → versioned snapshot → Renderer projection`.
|
||||
**Plan Review Gate:** PRE passed for Phase 0 on 2026-07-10; later phases require task-level PRE review before execution.
|
||||
|
||||
## Границы результата
|
||||
|
||||
В V1 входят микрофон, локальный streaming ASR, правила, индекс, история текущей сессии, лицо, Start/Pause/Reset, выбор устройства, tray и установщики. Аккаунты, backend, полная стенограмма, запись встреч, speaker identification и system loopback остаются вне V1.
|
||||
|
||||
Текущий запрос реализует только Phase 0. Он создаёт запускаемый фундамент и не выдаёт статическую оболочку за работающий recognizer.
|
||||
|
||||
## Карта архитектуры
|
||||
|
||||
### Source of truth
|
||||
|
||||
- `AGENTS.md` — продуктовые и инженерные ограничения.
|
||||
- `package.json` + `pnpm-lock.yaml` — воспроизводимый dependency graph.
|
||||
- `SessionController` в main — desired state, `sessionId`, versioned snapshot, индекс и короткая история текущей сессии.
|
||||
- Pure domain reducer — matching, dedupe, cooldown, clamp и выбор face level.
|
||||
- Renderer — только MediaStream handle/capture health и отображение snapshot.
|
||||
- Utility process — recognizer readiness, endpointing, `utteranceId`, sequence и bounded PCM queue.
|
||||
- Main model manager — версия модели, URL, SHA-256, проверенный локальный путь.
|
||||
- Versioned `src/domain/triggers/default-rules.ts` — заводской набор правил; main-owned settings repository хранит только пользовательскую копию/изменения и мигрирует их по stable rule id.
|
||||
|
||||
`Listening` является observed state только после подтверждений renderer capture и utility readiness. Main хранит desired state, но не объявляет прослушивание активным без этих acknowledgements.
|
||||
|
||||
### Read path
|
||||
|
||||
```text
|
||||
navigator.mediaDevices.getUserMedia
|
||||
→ AudioWorklet
|
||||
→ mono PCM chunks (100–300 ms)
|
||||
→ transferable MessagePort with backpressure
|
||||
→ utilityProcess / SpeechRecognizer adapter
|
||||
→ RecognitionEvent
|
||||
→ SessionController + pure domain reducer
|
||||
→ versioned SessionSnapshot
|
||||
→ renderer
|
||||
```
|
||||
|
||||
### Write path
|
||||
|
||||
- Rules, device choice and model metadata: main-owned repository under `app.getPath('userData')` with schema validation and atomic writes.
|
||||
- Index and trigger history: memory only for the current session.
|
||||
- Model: temporary download, SHA-256 verification, atomic rename, then offline reuse.
|
||||
- Raw audio and full transcript: never persisted in normal mode.
|
||||
|
||||
### Files to avoid until their phase
|
||||
|
||||
- `resources/models/**` and any large model binary in Git.
|
||||
- `node-cpal`: microphone capture belongs to Web Audio in renderer.
|
||||
- Redux/Zustand, `electron-store` and animation frameworks without a demonstrated need.
|
||||
- Universal `send(channel, payload)` preload APIs.
|
||||
- `ipcRenderer.invoke('acceptPcm')` as an audio transport.
|
||||
- Playwright before a stable Electron flow makes its maintenance worthwhile.
|
||||
- Fake implementations for the project skills named in `AGENTS.md`.
|
||||
|
||||
## Dependency baseline
|
||||
|
||||
Phase 0 pins pnpm `10.34.5` and uses Node `>=22.13 <23`, Electron `43.1.0`, Forge `7.11.2`, React `19.2.7`, TypeScript `5.9.3`, Vite `5.4.21` and Vitest `3.2.7`. Forge packages stay on one version. `.npmrc` uses `node-linker=hoisted` because Forge packaging walks physical `node_modules`; `package.json` allows build scripts only for Electron, esbuild and the pinned macOS packaging helpers.
|
||||
|
||||
`sherpa-onnx-node` is deliberately introduced in Phase 3, not treated as a harmless scaffold package. It is a native runtime dependency with platform packages and shared libraries. The spike must prove Vite externalization, ASAR unpack rules for both `.node` and `.dylib/.so/.dll`, runtime library lookup and a packaged-app smoke test before the dependency becomes part of the default runtime.
|
||||
|
||||
The first model is `sherpa-onnx-streaming-t-one-russian-2025-09-08`. Its manifest owns the expected sample rate, files, source URL, version and SHA-256. The model is not an npm dependency.
|
||||
|
||||
## Phase 0: reproducible application foundation
|
||||
|
||||
**Outcome:** a clean checkout installs, type-checks, tests, packages and opens a secure Russian-language Max Index shell on the current macOS arm64 host.
|
||||
|
||||
**Status:** completed on 2026-07-10; POST review passed after navigation, CSP and macOS metadata hardening.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Copy `AGENTS.md` byte-for-byte from `/Users/dokril/Downloads/AGENTS.md`; preserve the already-created `PRODUCT.md` and goal documents; create `.npmrc`, `.gitignore`, `package.json`, `pnpm-lock.yaml`, `tsconfig.json`, `eslint.config.mjs`, `forge.env.d.ts`.
|
||||
- Create `forge.config.ts`, `vite.main.config.ts`, `vite.preload.config.ts`, `vite.renderer.config.ts`, `vitest.config.mts`.
|
||||
- Create `src/main/main.ts`, `src/preload/preload.ts`, `src/renderer/index.html`, `src/renderer/index.tsx`, `src/renderer/App.tsx`, `src/renderer/styles.css`.
|
||||
- Create only the first used pure domain files for index clamping and face-level selection plus tests.
|
||||
- Update `README.md` with commands, architecture status and explicit non-capabilities.
|
||||
|
||||
**Allowed scope:** secure `BrowserWindow`, build/release makers, static paused snapshot and original CSS placeholder. No microphone, fake listening, recognizer, persistence, tray or settings.
|
||||
|
||||
**Verification:**
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
cmp /Users/dokril/Downloads/AGENTS.md AGENTS.md
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm package
|
||||
pnpm start
|
||||
```
|
||||
|
||||
**Acceptance evidence:** package succeeds on macOS arm64 and a real Electron window states that the microphone is not connected yet. Windows and Linux remain unverified.
|
||||
|
||||
**Captured evidence:** frozen-lockfile install, lint, strict typecheck and 14 Vitest tests pass; `pnpm package` produces `out/Max Index-darwin-arm64/Max Index.app`; the packaged process loads `file://.../app.asar/.vite/renderer/main_window/index.html` and the Paused shell was visually inspected. A forced `https://example.com` navigation remained on the allowlisted file URL. Production CSP disables connections and inline styles; `Info.plist` has ATS arbitrary loads disabled and only the microphone usage description. Source and project `AGENTS.md` share SHA-256 `c66e1d562ee2fb1a9ffbba597081508b5318b7c0770e919d73b811cac8ff9985`.
|
||||
|
||||
**Parallel:** renderer shell and build configuration can proceed in parallel after package/config contracts are fixed.
|
||||
|
||||
## Phase 1: real mock utility vertical slice
|
||||
|
||||
**Outcome:** `Start → mock partial/final → exactly one TriggerMatched → reaction/index → Pause → Reset` crosses the intended process boundaries.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create `src/shared/app-contract.ts` and `src/shared/recognition-contract.ts` with runtime guards.
|
||||
- Create `src/domain/triggers/default-rules.ts`, `normalize-text.ts`, `match-trigger.ts`, `trigger-engine.ts`; create focused siblings under `src/domain/index/` and `src/domain/face/`, with tests next to each module.
|
||||
- Create `src/main/session-controller.ts` and `src/main/utility-recognizer.ts`.
|
||||
- Create `src/audio/recognition/speech-recognizer.ts`.
|
||||
- Create `src/utility/recognition.ts` as a separate Forge/Vite build entry.
|
||||
- Replace the static renderer snapshot with a read-only projection over typed preload commands `start`, `pause`, `reset`, `getSnapshot`, `subscribe → unsubscribe`.
|
||||
|
||||
**Contract details:** partial and final for one utterance share `sessionId + utteranceId`, `sequence` is monotonic, and match identity includes `ruleId + phrase position`. Cooldown is evaluated after dedupe. `SessionSnapshot.stateVersion` resolves snapshot/subscription races.
|
||||
|
||||
**Default-rules cutover:** Phase 1 ships the canonical defaults in code and initializes the in-memory session from them. Phase 4 introduces persisted user rules keyed by the same stable ids; there is no second editable defaults file and no renderer-owned rule source.
|
||||
|
||||
**Utility lifecycle:** fork only after `app.whenReady()`, use explicit dev/package artifact paths, handle ready/exit/crash/restart/shutdown, and ignore old-session events.
|
||||
|
||||
**Verification:** unit tests for normalization, `ё/е`, word boundaries, overlapping rules, partial/final dedupe, cooldown, clamp and Reset; integration tests for a real utility child, stale sessions and exit handling; manual mock user flow in dev and an explicitly internal, non-release packaged test artifact.
|
||||
|
||||
**Acceptance evidence:** one and only one visible increment for partial + final of the same utterance.
|
||||
|
||||
**Production cutover gate:** release packaging cannot expose or select the mock recognizer. Phase 1's packaged mock is built only under an explicit internal test flag; Phase 3 removes that flag from release configuration before sherpa becomes the default production adapter.
|
||||
|
||||
**Parallel:** domain reducer and renderer projection can proceed in parallel after shared contracts are fixed.
|
||||
|
||||
## Phase 2: microphone and bounded PCM transport
|
||||
|
||||
**Outcome:** Start acquires the selected microphone, Pause releases all tracks, and bounded PCM reaches the utility process without freezing renderer or main.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create `src/audio/capture/audio-capture.ts` and the AudioWorklet processor.
|
||||
- Extend main/preload/utility orchestration for a transferred `MessagePort` data plane.
|
||||
- Add device enumeration, capture acknowledgements and observable queue health.
|
||||
|
||||
**Contract details:** aggregate 128-sample worklet frames into 100–300 ms mono chunks; report actual input sample rate; resample in one utility-owned location; cap the queue and emit overload state instead of growing memory; stop tracks on Pause, device switch, window shutdown and app quit.
|
||||
|
||||
**Verification:** Start → Listening acknowledgement, Pause track shutdown, device switch/removal, stale-session rejection, bounded queue and proof that no audio file is created.
|
||||
|
||||
**Acceptance evidence:** a synthetic/mock inference path consumes live microphone PCM, while UI remains responsive under a deliberately slow consumer.
|
||||
|
||||
**Parallel:** capture and utility queue implementation can proceed in parallel only after the MessagePort protocol is reviewed.
|
||||
|
||||
## Phase 3: sherpa-onnx and T-one native packaging spike
|
||||
|
||||
**Outcome:** the same `SpeechRecognizer` contract runs the T-one streaming model locally in both dev and packaged macOS arm64 builds.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Add `sherpa-onnx-node` as a runtime dependency.
|
||||
- Create `src/audio/recognition/sherpa-recognizer.ts` and model manifest/types.
|
||||
- Create main-owned model download/integrity/path management.
|
||||
- Update Vite externalization and Forge ASAR unpack configuration for the addon and shared libraries.
|
||||
- Add a licensed short audio fixture, expected transcript assertions and packaged native-load smoke.
|
||||
|
||||
**Contract details:** sample rate comes from the model manifest (T-one currently expects 8 kHz); adapter synthesizes stable utterance identity from endpointing; errors distinguish recoverable runtime failure from fatal model failure; transcript text is redacted from normal logs.
|
||||
|
||||
**Verification:** checksum failure, interrupted download, offline reuse, real-time factor/latency sample, native addon load in packaged app and exact-phrase fixture quality.
|
||||
|
||||
**Acceptance evidence:** bundled app starts offline after the first verified model acquisition and recognizes the agreed Russian fixture without a cloud call.
|
||||
|
||||
**Parallel:** model manager and sherpa adapter may proceed in parallel after manifest and error contracts are fixed.
|
||||
|
||||
## Phase 4: rules, settings, tray and complete experience
|
||||
|
||||
**Outcome:** users can configure rules and devices, see all face states, recover from common errors and keep the app in tray.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Add main-owned settings repository and schema migrations.
|
||||
- Add renderer settings view, rule editor, mic test and explicitly temporary transcript preview.
|
||||
- Add tray lifecycle and idempotent Start/Pause/Reset orchestration.
|
||||
- Replace CSS face with an original PNG/WebP sprite sheet and a small state machine.
|
||||
|
||||
**Verification:** persisted rules and device fallback, all six face levels, transient reaction, reduced motion, permission/model/device errors, tray reopen, recognizer restart and no leaked listeners.
|
||||
|
||||
**Acceptance evidence:** a non-technical user can recover from denied permission, missing model and vanished device using one clear action per state.
|
||||
|
||||
**Parallel:** settings UI and original asset production can proceed in parallel after the settings and face-state contracts are fixed.
|
||||
|
||||
## Phase 5: release evidence
|
||||
|
||||
**Outcome:** signed/installable artifacts and honest support claims for Windows x64, macOS arm64/x64 and Linux x64.
|
||||
|
||||
**Files:** release workflow, maker settings, icons, signing/notarization configuration, artifact checksums and operator runbook.
|
||||
|
||||
**Verification:** clean install/upgrade/uninstall, permissions, native addon/model path, tray, microphone, offline restart and one-real-utterance flow on every claimed OS/arch.
|
||||
|
||||
**Acceptance evidence:** archived per-platform run with artifact hash, app version, model version, OS/arch and user-flow result. A host-only package is not cross-platform evidence.
|
||||
|
||||
**Parallel:** platform lanes run independently after Phase 4 is stable.
|
||||
|
||||
## Phase 6: system audio as a separate provider
|
||||
|
||||
**Outcome:** opt-in meeting audio capture for headphone scenarios without destabilizing microphone mode.
|
||||
|
||||
**Allowed scope:** separate Windows, macOS and Linux providers behind the audio-source boundary; explicit permissions and capability detection.
|
||||
|
||||
**Kill criteria:** no universal loopback promise, no hidden fallback, and microphone remains the default known-good provider.
|
||||
|
||||
## Project completion gate
|
||||
|
||||
V1 is complete only when all of the following are proven on every supported target:
|
||||
|
||||
1. Active listening is unmistakable and Pause releases capture resources.
|
||||
2. A configured Russian phrase changes the index exactly once per utterance.
|
||||
3. Index stays within `0..100`; Reset is idempotent.
|
||||
4. Renderer remains responsive during slow recognition and utility restart.
|
||||
5. No raw audio or full transcript is persisted by default.
|
||||
6. A verified model works offline after first acquisition.
|
||||
7. Installed artifacts pass the per-platform user flow; unsupported targets are not claimed.
|
||||
|
||||
## Primary references
|
||||
|
||||
- [Electron Forge Vite template](https://www.electronforge.io/templates/vite)
|
||||
- [Electron Forge pnpm packaging requirement](https://www.electronforge.io/)
|
||||
- [Electron utilityProcess API](https://www.electronjs.org/docs/latest/api/utility-process)
|
||||
- [sherpa-onnx Node addon installation](https://k2-fsa.github.io/sherpa/onnx/javascript-api/install.html)
|
||||
- [sherpa-onnx T-one model](https://k2-fsa.github.io/sherpa/onnx/pretrained_models/online-ctc/t-one-ctc-models.html)
|
||||
33
eslint.config.mjs
Normal file
33
eslint.config.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import eslint from '@eslint/js';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['.vite/**', 'coverage/**', 'node_modules/**', 'out/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['src/renderer/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: reactHooks.configs.flat.recommended.rules,
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'*.config.{ts,mts}',
|
||||
'forge.env.d.ts',
|
||||
'src/main/**/*.ts',
|
||||
'src/preload/**/*.ts',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
);
|
||||
116
forge.config.ts
Normal file
116
forge.config.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { ForgeConfig } from '@electron-forge/shared-types';
|
||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { VitePlugin } from '@electron-forge/plugin-vite';
|
||||
import { FusesPlugin } from '@electron-forge/plugin-fuses';
|
||||
import { FuseV1Options, FuseVersion } from '@electron/fuses';
|
||||
import plist from 'plist';
|
||||
|
||||
const unusedMacPermissionDescriptions = [
|
||||
'NSAudioCaptureUsageDescription',
|
||||
'NSBluetoothAlwaysUsageDescription',
|
||||
'NSBluetoothPeripheralUsageDescription',
|
||||
'NSCameraUsageDescription',
|
||||
] as const;
|
||||
|
||||
const stripUnusedMacPermissionDescriptions = (
|
||||
buildPath: string,
|
||||
_electronVersion: string,
|
||||
platform: string,
|
||||
_arch: string,
|
||||
callback: (error?: Error | null) => void,
|
||||
): void => {
|
||||
if (platform !== 'darwin') {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const infoPath = path.join(
|
||||
buildPath,
|
||||
'Electron.app',
|
||||
'Contents',
|
||||
'Info.plist',
|
||||
);
|
||||
|
||||
void (async () => {
|
||||
const info = plist.parse(await fs.readFile(infoPath, 'utf8'));
|
||||
|
||||
for (const key of unusedMacPermissionDescriptions) {
|
||||
delete info[key];
|
||||
}
|
||||
|
||||
await fs.writeFile(infoPath, plist.build(info), 'utf8');
|
||||
})().then(
|
||||
() => callback(),
|
||||
(error: unknown) =>
|
||||
callback(error instanceof Error ? error : new Error(String(error))),
|
||||
);
|
||||
};
|
||||
|
||||
const config: ForgeConfig = {
|
||||
packagerConfig: {
|
||||
appBundleId: 'dev.dokril.max-index',
|
||||
appCategoryType: 'public.app-category.productivity',
|
||||
asar: true,
|
||||
afterExtract: [stripUnusedMacPermissionDescriptions],
|
||||
extendInfo: {
|
||||
NSAppTransportSecurity: {
|
||||
NSAllowsArbitraryLoads: false,
|
||||
},
|
||||
},
|
||||
usageDescription: {
|
||||
Microphone:
|
||||
'Max Index использует микрофон для локального распознавания речи.',
|
||||
},
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
new MakerSquirrel({}, ['win32']),
|
||||
new MakerDMG({}, ['darwin']),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
new MakerDeb({}, ['linux']),
|
||||
new MakerRpm({}, ['linux']),
|
||||
],
|
||||
plugins: [
|
||||
new VitePlugin({
|
||||
build: [
|
||||
{
|
||||
entry: 'src/main/main.ts',
|
||||
config: 'vite.main.config.ts',
|
||||
target: 'main',
|
||||
},
|
||||
{
|
||||
entry: 'src/preload/preload.ts',
|
||||
config: 'vite.preload.config.ts',
|
||||
target: 'preload',
|
||||
},
|
||||
],
|
||||
renderer: [
|
||||
{
|
||||
name: 'main_window',
|
||||
config: 'vite.renderer.config.ts',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
forge.env.d.ts
vendored
Normal file
1
forge.env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />
|
||||
67
package.json
Normal file
67
package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "max-index",
|
||||
"productName": "Max Index",
|
||||
"version": "0.1.0",
|
||||
"description": "Локальный индикатор повторяющихся фраз для встреч",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13 <23",
|
||||
"pnpm": ">=10.34 <11"
|
||||
},
|
||||
"packageManager": "pnpm@10.34.5",
|
||||
"author": "Max Index contributors",
|
||||
"license": "UNLICENSED",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"fs-xattr",
|
||||
"macos-alias"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "1.0.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "7.11.2",
|
||||
"@electron-forge/maker-deb": "7.11.2",
|
||||
"@electron-forge/maker-dmg": "7.11.2",
|
||||
"@electron-forge/maker-rpm": "7.11.2",
|
||||
"@electron-forge/maker-squirrel": "7.11.2",
|
||||
"@electron-forge/maker-zip": "7.11.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "7.11.2",
|
||||
"@electron-forge/plugin-fuses": "7.11.2",
|
||||
"@electron-forge/plugin-vite": "7.11.2",
|
||||
"@electron-forge/shared-types": "7.11.2",
|
||||
"@electron/fuses": "1.8.0",
|
||||
"@eslint/js": "9.39.4",
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"@types/node": "22.20.1",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"electron": "43.1.0",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"globals": "17.7.0",
|
||||
"plist": "3.1.1",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.63.0",
|
||||
"vite": "5.4.21",
|
||||
"vitest": "3.2.7"
|
||||
}
|
||||
}
|
||||
6426
pnpm-lock.yaml
generated
Normal file
6426
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
src/domain/index/index-level.test.ts
Normal file
42
src/domain/index/index-level.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyIndexDelta,
|
||||
clampIndex,
|
||||
getFaceLevel,
|
||||
type FaceLevel,
|
||||
} from './index-level';
|
||||
|
||||
describe('clampIndex', () => {
|
||||
it('keeps the index inside 0..100', () => {
|
||||
expect(clampIndex(-1)).toBe(0);
|
||||
expect(clampIndex(42)).toBe(42);
|
||||
expect(clampIndex(101)).toBe(100);
|
||||
});
|
||||
|
||||
it('rejects non-finite values', () => {
|
||||
expect(() => clampIndex(Number.NaN)).toThrow(TypeError);
|
||||
expect(() => clampIndex(Number.POSITIVE_INFINITY)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('clamps a delta at the upper boundary', () => {
|
||||
expect(applyIndexDelta(96, 10)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFaceLevel', () => {
|
||||
it.each<[number, FaceLevel]>([
|
||||
[0, 'calm'],
|
||||
[19, 'calm'],
|
||||
[20, 'alert'],
|
||||
[39, 'alert'],
|
||||
[40, 'annoyed'],
|
||||
[59, 'annoyed'],
|
||||
[60, 'edge'],
|
||||
[79, 'edge'],
|
||||
[80, 'critical'],
|
||||
[99, 'critical'],
|
||||
[100, 'maximum'],
|
||||
])('maps %i to %s', (index, expected) => {
|
||||
expect(getFaceLevel(index)).toBe(expected);
|
||||
});
|
||||
});
|
||||
32
src/domain/index/index-level.ts
Normal file
32
src/domain/index/index-level.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const MIN_INDEX = 0;
|
||||
export const MAX_INDEX = 100;
|
||||
|
||||
export type FaceLevel =
|
||||
| 'calm'
|
||||
| 'alert'
|
||||
| 'annoyed'
|
||||
| 'edge'
|
||||
| 'critical'
|
||||
| 'maximum';
|
||||
|
||||
export const clampIndex = (value: number): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new TypeError('Index must be a finite number');
|
||||
}
|
||||
|
||||
return Math.max(MIN_INDEX, Math.min(MAX_INDEX, value));
|
||||
};
|
||||
|
||||
export const applyIndexDelta = (current: number, delta: number): number =>
|
||||
clampIndex(current + delta);
|
||||
|
||||
export const getFaceLevel = (index: number): FaceLevel => {
|
||||
const clamped = clampIndex(index);
|
||||
|
||||
if (clamped === MAX_INDEX) return 'maximum';
|
||||
if (clamped >= 80) return 'critical';
|
||||
if (clamped >= 60) return 'edge';
|
||||
if (clamped >= 40) return 'annoyed';
|
||||
if (clamped >= 20) return 'alert';
|
||||
return 'calm';
|
||||
};
|
||||
76
src/main/main.ts
Normal file
76
src/main/main.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { app, BrowserWindow, type Event as ElectronEvent } from 'electron';
|
||||
import started from 'electron-squirrel-startup';
|
||||
|
||||
if (started) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
const createWindow = (): void => {
|
||||
const rendererUrl =
|
||||
MAIN_WINDOW_VITE_DEV_SERVER_URL ??
|
||||
pathToFileURL(
|
||||
path.join(
|
||||
__dirname,
|
||||
`../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`,
|
||||
),
|
||||
).toString();
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
title: 'Max Index',
|
||||
width: 960,
|
||||
height: 700,
|
||||
minWidth: 760,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#f4f1e8',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
|
||||
const guardNavigation = (
|
||||
event: ElectronEvent,
|
||||
navigationUrl: string,
|
||||
): void => {
|
||||
const allowed = new URL(rendererUrl);
|
||||
const requested = new URL(navigationUrl);
|
||||
const isAllowed =
|
||||
allowed.protocol === 'file:'
|
||||
? requested.protocol === 'file:' && requested.pathname === allowed.pathname
|
||||
: requested.origin === allowed.origin;
|
||||
|
||||
if (!isAllowed) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
mainWindow.webContents.on('will-navigate', guardNavigation);
|
||||
mainWindow.webContents.on('will-redirect', guardNavigation);
|
||||
mainWindow.once('ready-to-show', () => mainWindow.show());
|
||||
|
||||
void mainWindow.loadURL(rendererUrl);
|
||||
};
|
||||
|
||||
void app.whenReady().then(() => {
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
3
src/preload/preload.ts
Normal file
3
src/preload/preload.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Phase 0 intentionally exposes no renderer API. The first vertical slice adds
|
||||
// narrow, typed commands instead of a generic IPC channel.
|
||||
export {};
|
||||
95
src/renderer/App.tsx
Normal file
95
src/renderer/App.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getFaceLevel } from '../domain/index/index-level';
|
||||
|
||||
const INITIAL_INDEX = 0;
|
||||
|
||||
export const App = () => {
|
||||
const faceLevel = getFaceLevel(INITIAL_INDEX);
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="app-header">
|
||||
<div className="brand-lockup">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
MI
|
||||
</span>
|
||||
<div>
|
||||
<p className="eyebrow">Индикатор встречи</p>
|
||||
<h1>Max Index</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p className="privacy-note">
|
||||
<span aria-hidden="true" /> Локально, без записи
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="meeting-stage" aria-labelledby="index-heading">
|
||||
<div className="character-zone">
|
||||
<p className="status-pill">
|
||||
<span aria-hidden="true" /> Пауза
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="face-frame"
|
||||
data-face-level={faceLevel}
|
||||
role="img"
|
||||
aria-label="Спокойное лицо персонажа"
|
||||
>
|
||||
<div className="face-ear face-ear-left" />
|
||||
<div className="face-ear face-ear-right" />
|
||||
<div className="face-character">
|
||||
<div className="face-brow face-brow-left" />
|
||||
<div className="face-brow face-brow-right" />
|
||||
<div className="face-eye face-eye-left" />
|
||||
<div className="face-eye face-eye-right" />
|
||||
<div className="face-mouth" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="capture-state">
|
||||
<strong>Микрофон не подключён</strong>
|
||||
<span>Прослушивание выключено</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="index-zone">
|
||||
<p className="eyebrow" id="index-heading">
|
||||
Индекс встречи
|
||||
</p>
|
||||
<p className="index-value" aria-label="Индекс: 0 из 100">
|
||||
<strong>{INITIAL_INDEX}</strong>
|
||||
<span>/100</span>
|
||||
</p>
|
||||
<progress value={INITIAL_INDEX} max="100">
|
||||
{INITIAL_INDEX}%
|
||||
</progress>
|
||||
|
||||
<p className="foundation-copy">
|
||||
Каркас готов. Следующий этап подключит локальное распознавание
|
||||
речи.
|
||||
</p>
|
||||
|
||||
<div className="controls" aria-label="Управление прослушиванием">
|
||||
<button type="button" className="primary-action" disabled>
|
||||
Начать
|
||||
</button>
|
||||
<button type="button" className="secondary-action" disabled>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
<p className="control-hint">
|
||||
Управление станет доступно после подключения первого сквозного
|
||||
среза.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="last-trigger">
|
||||
<div>
|
||||
<span>Последнее срабатывание</span>
|
||||
<strong>Пока ничего</strong>
|
||||
</div>
|
||||
<p>История хранится только в текущей сессии.</p>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
16
src/renderer/index.html
Normal file
16
src/renderer/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="__MAX_INDEX_CONTENT_SECURITY_POLICY__"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Max Index</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
src/renderer/index.tsx
Normal file
16
src/renderer/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error('Renderer root element is missing');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
436
src/renderer/styles.css
Normal file
436
src/renderer/styles.css
Normal file
@@ -0,0 +1,436 @@
|
||||
:root {
|
||||
color: oklch(24% 0.025 65);
|
||||
background: oklch(95.5% 0.015 82);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--canvas: oklch(95.5% 0.015 82);
|
||||
--surface: oklch(98% 0.008 82);
|
||||
--surface-muted: oklch(92% 0.02 82);
|
||||
--ink: oklch(24% 0.025 65);
|
||||
--ink-muted: oklch(52% 0.025 65);
|
||||
--line: oklch(83% 0.025 72);
|
||||
--accent: oklch(66% 0.19 36);
|
||||
--accent-deep: oklch(54% 0.19 35);
|
||||
--safe: oklch(63% 0.12 152);
|
||||
--shadow: 0 24px 70px oklch(24% 0.025 65 / 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
button,
|
||||
progress {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 3px solid oklch(69% 0.16 252);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(100%, 1120px);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 32px clamp(24px, 5vw, 64px) 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 48px;
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 15px;
|
||||
color: var(--surface);
|
||||
background: var(--ink);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.04em;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.app-header h1,
|
||||
.privacy-note,
|
||||
.status-pill,
|
||||
.index-value,
|
||||
.capture-state,
|
||||
.foundation-copy,
|
||||
.control-hint,
|
||||
.last-trigger p,
|
||||
.last-trigger span,
|
||||
.last-trigger strong {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin-top: 3px;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.privacy-note > span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--safe);
|
||||
box-shadow: 0 0 0 4px oklch(63% 0.12 152 / 0.14);
|
||||
}
|
||||
|
||||
.meeting-stage {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 1.15fr) minmax(290px, 0.85fr);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 34px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.character-zone,
|
||||
.index-zone {
|
||||
min-width: 0;
|
||||
padding: clamp(32px, 5vw, 58px);
|
||||
}
|
||||
|
||||
.character-zone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
left: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: oklch(86% 0.02 78);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-pill > span {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border: 2px solid oklch(72% 0.025 75);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.face-frame {
|
||||
position: relative;
|
||||
width: min(260px, 70vw);
|
||||
aspect-ratio: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 18px 0 26px;
|
||||
border-radius: 44% 56% 48% 52%;
|
||||
background: var(--accent);
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
.face-character {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 66%;
|
||||
height: 72%;
|
||||
border: 8px solid var(--ink);
|
||||
border-radius: 47% 53% 42% 44% / 42% 45% 55% 58%;
|
||||
background: oklch(85% 0.085 71);
|
||||
box-shadow: inset 0 -14px 0 oklch(74% 0.1 62 / 0.35);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.face-ear {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 45%;
|
||||
width: 36px;
|
||||
height: 54px;
|
||||
border: 7px solid var(--ink);
|
||||
border-radius: 48%;
|
||||
background: oklch(85% 0.085 71);
|
||||
}
|
||||
|
||||
.face-ear-left {
|
||||
left: 13%;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.face-ear-right {
|
||||
right: 13%;
|
||||
transform: rotate(12deg);
|
||||
}
|
||||
|
||||
.face-eye,
|
||||
.face-brow,
|
||||
.face-mouth {
|
||||
position: absolute;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.face-eye {
|
||||
top: 43%;
|
||||
width: 16px;
|
||||
height: 23px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.face-eye-left {
|
||||
left: 27%;
|
||||
}
|
||||
|
||||
.face-eye-right {
|
||||
right: 27%;
|
||||
}
|
||||
|
||||
.face-brow {
|
||||
top: 31%;
|
||||
width: 38px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.face-brow-left {
|
||||
left: 16%;
|
||||
}
|
||||
|
||||
.face-brow-right {
|
||||
right: 16%;
|
||||
}
|
||||
|
||||
.face-mouth {
|
||||
left: 50%;
|
||||
bottom: 19%;
|
||||
width: 50px;
|
||||
height: 20px;
|
||||
border-bottom: 8px solid var(--ink);
|
||||
border-radius: 0 0 999px 999px;
|
||||
background: transparent;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.capture-state {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.capture-state strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.capture-state span {
|
||||
color: oklch(74% 0.02 76);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.index-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.index-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin: 16px 0 14px;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.index-value strong {
|
||||
font-size: clamp(5.6rem, 12vw, 8.5rem);
|
||||
font-weight: 850;
|
||||
line-height: 0.82;
|
||||
}
|
||||
|
||||
.index-value span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
progress {
|
||||
width: 100%;
|
||||
height: 11px;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
progress::-webkit-progress-bar {
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
progress::-webkit-progress-value {
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.foundation-copy {
|
||||
max-width: 34ch;
|
||||
margin-top: 30px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
min-height: 48px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 0 20px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.primary-action {
|
||||
color: var(--surface);
|
||||
background: var(--accent-deep);
|
||||
}
|
||||
|
||||
.secondary-action {
|
||||
color: var(--ink);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.controls button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.control-hint {
|
||||
margin-top: 12px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.last-trigger {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 20px 6px 0;
|
||||
}
|
||||
|
||||
.last-trigger div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.last-trigger span,
|
||||
.last-trigger p {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.last-trigger strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.app-shell {
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.privacy-note {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.meeting-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.character-zone {
|
||||
min-height: 390px;
|
||||
}
|
||||
|
||||
.face-frame {
|
||||
width: 210px;
|
||||
}
|
||||
|
||||
.index-zone {
|
||||
padding: 34px 30px 38px;
|
||||
}
|
||||
|
||||
.last-trigger {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
14
src/types/plist.d.ts
vendored
Normal file
14
src/types/plist.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare module 'plist' {
|
||||
export interface PlistData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PlistModule {
|
||||
build(value: PlistData): string;
|
||||
parse(source: string): PlistData;
|
||||
}
|
||||
|
||||
const plist: PlistModule;
|
||||
|
||||
export default plist;
|
||||
}
|
||||
33
tsconfig.json
Normal file
33
tsconfig.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"include": [
|
||||
"forge.config.ts",
|
||||
"forge.env.d.ts",
|
||||
"vite.*.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx"
|
||||
],
|
||||
"exclude": [".vite", "coverage", "node_modules", "out"]
|
||||
}
|
||||
3
vite.main.config.ts
Normal file
3
vite.main.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({});
|
||||
3
vite.preload.config.ts
Normal file
3
vite.preload.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({});
|
||||
29
vite.renderer.config.ts
Normal file
29
vite.renderer.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import path from 'node:path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const contentSecurityPolicy =
|
||||
mode === 'development'
|
||||
? "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://localhost:*"
|
||||
: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
|
||||
|
||||
return {
|
||||
root: path.resolve(__dirname, 'src/renderer'),
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'max-index-content-security-policy',
|
||||
transformIndexHtml: (html) =>
|
||||
html.replace(
|
||||
'__MAX_INDEX_CONTENT_SECURITY_POLICY__',
|
||||
contentSecurityPolicy,
|
||||
),
|
||||
},
|
||||
],
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: path.resolve(__dirname, '.vite/renderer/main_window'),
|
||||
},
|
||||
};
|
||||
});
|
||||
10
vitest.config.mts
Normal file
10
vitest.config.mts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
},
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user