Document Phase 0 scaffold and architecture

This commit is contained in:
2026-07-10 22:48:08 +03:00
parent 39fec46ab6
commit d646c51b90
26 changed files with 8222 additions and 1 deletions

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
.pnpm-store/
.vite/
out/
coverage/
*.log
*.tsbuildinfo
.DS_Store
.env
.env.*
resources/models/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
node-linker=hoisted

423
AGENTS.md Normal file
View 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 блоками примерно по 100300 мс.
- Не полагаться на то, что ОС или 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-компоненты не должны самостоятельно искать фразы или изменять индекс.
## Визуальный контракт
Главный элемент окна — лицо персонажа и индекс. Остальные элементы не должны с ними конкурировать.
Базовые уровни:
| Индекс | Состояние |
| ---: | --- |
| 019 | спокойный |
| 2039 | настороженный |
| 4059 | раздражённый |
| 6079 | на грани |
| 8099 | критический |
| 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
View 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 пока не заявляется; доступность проверяется как обязательная часть каждого пользовательского среза.

View File

@@ -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).

View 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.

View 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 (100300 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 100300 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />

67
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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);
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
export default defineConfig({});

3
vite.preload.config.ts Normal file
View File

@@ -0,0 +1,3 @@
import { defineConfig } from 'vite';
export default defineConfig({});

29
vite.renderer.config.ts Normal file
View 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
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
reporter: ['text', 'html'],
},
include: ['src/**/*.test.ts'],
},
});