diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..fc548ca
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+
+.git
+.gitignore
+.idea
+.vscode
+
+node_modules
+frontend/node_modules
+frontend/wailsjs
+
+build/bin
+dist
+
+*.log
+
diff --git a/.gitignore b/.gitignore
index b7faf40..0471666 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,207 +1,31 @@
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[codz]
-*$py.class
-# C extensions
+# Go
+*.exe
+*.dll
*.so
+*.dylib
+*.test
+*.out
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
+# Wails build artifacts
+/build/
+/frontend/wailsjs/
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
+# Frontend dependencies and build output
+/frontend/node_modules/
+/frontend/dist/
+/frontend/package.json.md5
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py.cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# UV
-# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-#uv.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-#poetry.toml
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
-# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
-#pdm.lock
-#pdm.toml
-.pdm-python
-.pdm-build/
-
-# pixi
-# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
-#pixi.lock
-# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
-# in the .venv directory. It is recommended not to include this directory in version control.
-.pixi
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
+# Environment files
.env
-.envrc
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
+.env.*
+!.env.example
-# Spyder project settings
-.spyderproject
-.spyproject
+# IDE
+.idea/
+.vscode/
-# Rope project settings
-.ropeproject
+# OS
+.DS_Store
+Thumbs.db
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
-
-# Abstra
-# Abstra is an AI-powered process automation framework.
-# Ignore directories containing user credentials, local state, and settings.
-# Learn more at https://abstra.io/docs
-.abstra/
-
-# Visual Studio Code
-# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
-# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
-# and can be added to the global gitignore or merged into this file. However, if you prefer,
-# you could uncomment the following to ignore the entire vscode folder
-# .vscode/
-
-# Ruff stuff:
-.ruff_cache/
-
-# PyPI configuration file
-.pypirc
-
-# Cursor
-# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
-# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
-# refer to https://docs.cursor.com/context/ignore-files
-.cursorignore
-.cursorindexingignore
-
-# Marimo
-marimo/_static/
-marimo/_lsp/
-__marimo__/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..cc5e863
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,46 @@
+
+# Stage 1: Frontend build
+FROM node:22-bookworm-slim AS frontend-builder
+
+WORKDIR /frontend
+
+COPY frontend/package.json ./
+
+RUN npm install
+
+COPY frontend/ ./
+
+RUN npm run build
+
+
+# Stage 2: Go build and test
+FROM golang:1.24-bookworm AS go-builder
+
+ENV CGO_ENABLED=1
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ pkg-config \
+ libgtk-3-dev \
+ libwebkit2gtk-4.1-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /src
+
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+
+COPY --from=frontend-builder /frontend/dist ./frontend/dist
+
+RUN go test -v ./...
+
+RUN go build -o /out/fast_reader .
+
+
+# Stage 3: Export build artifact
+FROM scratch AS artifact
+
+COPY --from=go-builder /out/fast_reader /fast_reader
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b122d92
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,31 @@
+
+.PHONY: install dev build test lint clean docker-build docker-test
+
+install:
+ go mod tidy
+ cd frontend && npm install
+
+dev:
+ wails dev
+
+build:
+ wails build
+
+test:
+ go test -v -race ./...
+
+lint:
+ go vet ./...
+
+clean:
+ rm -rf build/bin frontend/dist
+
+docker-build:
+ docker build --target artifact -t fast_reader:0.1.0 .
+ docker create --name fast_reader-export fast_reader:0.1.0
+ docker cp fast_reader-export:/fast_reader ./fast_reader-linux
+ docker rm fast_reader-export
+
+docker-test:
+ docker build --target go-builder -t fast_reader-ci .
+
diff --git a/README.md b/README.md
index e7fb5ef..a42ef32 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,90 @@
# fast_reader
+
Machine that aims to speed up your reading.
+A cross-platform RSVP (Rapid Serial Visual Presentation) speed-reading
+desktop application built with Go and Wails.
+
+## Features
+
+- RSVP speed reading
+- Adjustable reading speed (100–1000 WPM)
+- Optimal Recognition Point (ORP) highlighting
+- Text input and demo text
+- Playback controls
+- Reading progress indicator
+- Keyboard shortcuts
+- Cross-platform desktop support
+
+## Tech Stack
+
+- Go
+- Wails v2
+- TypeScript
+- Vite
+
+## Prerequisites
+
+- Go 1.24+
+- Node.js 22+
+- npm
+- Wails CLI v2
+
+For Linux development, install the Wails system dependencies:
+https://wails.io/docs/gettingstarted/installation
+
+## Getting Started
+
+Install Wails:
+
+ go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2
+
+Install project dependencies:
+
+ make install
+
+Start development mode:
+
+ make dev
+
+Build the desktop application:
+
+ make build
+
+Run Go tests:
+
+ make test
+
+## Docker
+
+Build and test the Linux application in Docker:
+
+ docker build --target go-builder -t fast_reader-ci .
+
+Export the Linux binary:
+
+ make docker-build
+
+Note: Docker builds a Linux binary. Windows and macOS builds
+should be performed on their respective operating systems or
+through dedicated cross-platform build runners.
+
+## Keyboard Shortcuts
+
+| Key | Action |
+| ------ | ------------- |
+| Space | Play / Pause |
+| ← | Previous word |
+| → | Next word |
+| R | Reset |
+| Escape | Pause |
+
+## Project Structure
+
+ app/ Wails application bindings
+ internal/reader/ RSVP reader engine
+ frontend/ TypeScript UI
+ build/ Wails build artifacts
+
+## License
+
+MIT
diff --git a/app/app.go b/app/app.go
new file mode 100644
index 0000000..d8335df
--- /dev/null
+++ b/app/app.go
@@ -0,0 +1,121 @@
+package app
+
+import (
+ "context"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+
+ "fast_reader/internal/reader"
+ "fast_reader/internal/settings"
+ "fast_reader/internal/storage"
+)
+
+type App struct {
+ ctx context.Context
+ mu sync.RWMutex
+ settings settings.Settings
+ settingsPath string
+}
+
+func NewApp() *App {
+ return &App{
+ settings: settings.Default(),
+ }
+}
+
+func (a *App) Startup(ctx context.Context) {
+ a.ctx = ctx
+
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ log.Printf("Could not locate user config directory: %v", err)
+ return
+ }
+
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ a.settingsPath = filepath.Join(
+ configDir,
+ "fast_reader",
+ "settings.json",
+ )
+
+ var saved settings.Settings
+
+ err = storage.Load(a.settingsPath, &saved)
+
+ if err != nil {
+ if !os.IsNotExist(err) {
+ log.Printf("Could not load settings: %v", err)
+ }
+
+ a.settings = settings.Default()
+ return
+ }
+
+ a.settings = saved.Normalize()
+
+ log.Println("Settings loaded successfully")
+}
+
+// PrepareText converts pasted text into a sequence of words
+// ready for RSVP playback.
+func (a *App) PrepareText(text string) []reader.Word {
+ return reader.Tokenize(text)
+}
+
+// GetVersion returns the application version.
+func (a *App) GetVersion() string {
+ return "0.1.0"
+}
+
+// GetSettings returns the current application settings.
+func (a *App) GetSettings() settings.Settings {
+ a.mu.RLock()
+ defer a.mu.RUnlock()
+
+ return a.settings
+}
+
+// SaveSettings validates and persists application settings.
+func (a *App) SaveSettings(input settings.Settings) error {
+ normalized := input.Normalize()
+
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ if a.settingsPath == "" {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ return err
+ }
+
+ a.settingsPath = filepath.Join(
+ configDir,
+ "fast_reader",
+ "settings.json",
+ )
+ }
+
+ if err := storage.Save(a.settingsPath, normalized); err != nil {
+ return err
+ }
+
+ a.settings = normalized
+
+ return nil
+}
+
+// ResetSettings restores and persists the default settings.
+func (a *App) ResetSettings() (settings.Settings, error) {
+ defaults := settings.Default()
+
+ if err := a.SaveSettings(defaults); err != nil {
+ return settings.Settings{}, err
+ }
+
+ return defaults, nil
+}
diff --git a/app/app_test.go b/app/app_test.go
new file mode 100644
index 0000000..5b8a683
--- /dev/null
+++ b/app/app_test.go
@@ -0,0 +1,154 @@
+package app
+
+import (
+ "context"
+ "testing"
+
+ "fast_reader/internal/settings"
+)
+
+func TestNewApp(t *testing.T) {
+ a := NewApp()
+
+ if a == nil {
+ t.Fatal("NewApp() returned nil")
+ }
+
+ if got := a.GetSettings(); got != settings.Default() {
+ t.Errorf("initial settings = %+v, want %+v",
+ got, settings.Default())
+ }
+}
+
+func TestStartup(t *testing.T) {
+ application := NewApp()
+ ctx := context.Background()
+
+ // Startup should store the Wails application context.
+ application.Startup(ctx)
+
+ if application.ctx != ctx {
+ t.Error("Startup() did not store the application context")
+ }
+}
+
+func TestPrepareText(t *testing.T) {
+ a := NewApp()
+
+ words := a.PrepareText("Hello world")
+
+ if len(words) != 2 {
+ t.Fatalf("PrepareText() returned %d words, want 2", len(words))
+ }
+
+ if words[0].Text != "Hello" || words[1].Text != "world" {
+ t.Errorf("unexpected words: %+v", words)
+ }
+}
+
+func TestPrepareEmptyText(t *testing.T) {
+ application := NewApp()
+
+ got := application.PrepareText("")
+
+ if len(got) != 0 {
+ t.Errorf("PrepareText() returned %d words for empty input, want 0", len(got))
+ }
+}
+
+func TestGetVersion(t *testing.T) {
+ a := NewApp()
+
+ if got := a.GetVersion(); got != "0.1.0" {
+ t.Errorf("GetVersion() = %q, want %q", got, "0.1.0")
+ }
+}
+
+func TestSaveAndLoadSettings(t *testing.T) {
+ configDir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", configDir)
+
+ first := NewApp()
+ first.Startup(context.Background())
+
+ expected := settings.Settings{
+ ReadingSpeed: 500,
+ FontSize: 64,
+ Theme: settings.ThemeLight,
+ }
+
+ if err := first.SaveSettings(expected); err != nil {
+ t.Fatalf("SaveSettings() error = %v", err)
+ }
+
+ if got := first.GetSettings(); got != expected {
+ t.Errorf("settings = %+v, want %+v", got, expected)
+ }
+
+ // Simulate restarting the application.
+ second := NewApp()
+ second.Startup(context.Background())
+
+ if got := second.GetSettings(); got != expected {
+ t.Errorf("reloaded settings = %+v, want %+v", got, expected)
+ }
+}
+
+func TestSaveSettingsNormalizesValues(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+
+ a := NewApp()
+ a.Startup(context.Background())
+
+ input := settings.Settings{
+ ReadingSpeed: 2000,
+ FontSize: 10,
+ Theme: "invalid",
+ }
+
+ if err := a.SaveSettings(input); err != nil {
+ t.Fatalf("SaveSettings() error = %v", err)
+ }
+
+ expected := settings.Settings{
+ ReadingSpeed: settings.MaxReadingSpeed,
+ FontSize: settings.MinFontSize,
+ Theme: settings.ThemeDark,
+ }
+
+ if got := a.GetSettings(); got != expected {
+ t.Errorf("settings = %+v, want %+v", got, expected)
+ }
+}
+
+func TestResetSettings(t *testing.T) {
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
+
+ a := NewApp()
+ a.Startup(context.Background())
+
+ custom := settings.Settings{
+ ReadingSpeed: 600,
+ FontSize: 72,
+ Theme: settings.ThemeLight,
+ }
+
+ if err := a.SaveSettings(custom); err != nil {
+ t.Fatalf("SaveSettings() error = %v", err)
+ }
+
+ got, err := a.ResetSettings()
+ if err != nil {
+ t.Fatalf("ResetSettings() error = %v", err)
+ }
+
+ if got != settings.Default() {
+ t.Errorf("ResetSettings() = %+v, want %+v",
+ got, settings.Default())
+ }
+
+ if current := a.GetSettings(); current != settings.Default() {
+ t.Errorf("current settings = %+v, want defaults %+v",
+ current, settings.Default())
+ }
+}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..c7f0fba
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Fast_Reader
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..0767996
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1173 @@
+{
+ "name": "fast_reader-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fast_reader-frontend",
+ "version": "0.1.0",
+ "devDependencies": {
+ "typescript": "^5.8.3",
+ "vite": "^6.2.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.5.tgz",
+ "integrity": "sha512-FdWPeulkH+FH+rBzyKBD59tuVN4tRKvmVilSJm0OcnoYsQTpd9UhD78OaPcsYqo2fX6hCwAG0YIjrDLoQ3pR3g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.5.tgz",
+ "integrity": "sha512-bmkDuBGnF+Ji8atWlXcNjxIC52ShCbdXPbW7Nz0lRoCFi1lrvsO0XobJY36kTj7duc1vOmGJeQ9vtVuGjnzihA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-x64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.5.tgz",
+ "integrity": "sha512-6KvVfnqPTqmbQ5YobMl9HYiSymAsCt8HlNeriDshwJQcveXwT+vIMW6wGL1RcJbXH9+r3BAJvjab5+rIgtSEqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.5.tgz",
+ "integrity": "sha512-SNamgdrY69oPdBrQV9fKGo6PSmKcVhyn0D0jnDmcqgRJvrxQGGBhtekOHTUJ7TCgkW0nE8XMH9v2CwXmjNIzVA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.5.tgz",
+ "integrity": "sha512-HNdl34thiJtHDSglogjmAlQ296L8cc8zA7ODH65nnmY7E+jPP55WEbh8J5583rWFLMjxyYywxZuwPOwRvEi5tA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.5.tgz",
+ "integrity": "sha512-WvZYtANNswfHzSNHaJAoG6Ipfc4SyZIDYuIjFdHv07xXxhHl+Xx8o+bNDWsxpOADFT2mKXcXVrfOG4OlWTLo8Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.5.tgz",
+ "integrity": "sha512-EUU+Wgf9NyHekceC+DxAdm42f6bq2ezH/7DEb/JnCb8SAUhG2xph5BiVukxplWal2m5pP8CzJZE+FLwRopywjQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.5.tgz",
+ "integrity": "sha512-dgU1CvUuE2IkaJBAeuAbxpDdMHAdZBT5oth5u22DtuoMg4gXXYr2+Sm30RigPJG1F2VEoiGJUziGLuIh9+jPxA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.5.tgz",
+ "integrity": "sha512-rdgJcJig2VngYlcTCRQw2BA8kLWM6QwDFe9uLFo81lya0jPC1r8wZu92sAvTUIv48GWlkRVi6/fpsa5CoFSKoQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.5.tgz",
+ "integrity": "sha512-+jtCiQnrEFHWAUAcUpP7wppb1p6HKOgBP3mXx75cEYpDF2Y5MEqBky2HNz+osFuVx5uX44gnMTmbe5RJauzn6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.5.tgz",
+ "integrity": "sha512-heflX7ENhL8eHmLcUxDEHCSK8CKLyPprysb2JGoSTh5Ip7nHrzIRc+2USyxul3nxi98H8pGxJIEDEpURlS1X5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.5.tgz",
+ "integrity": "sha512-rRtNU0zBfRHkJ8pscHN9qRtUYLq8Rao4EX7aVWvKcFmJUANkCCXFRU7ovNBEMhpEONyUEB/3DRVDXwDDguSfHA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.5.tgz",
+ "integrity": "sha512-/6auyi9yj8MD/Q+8P+f2ay57jQ7a0j7Yp1JK86pecH6ZnRViQZ5GOjoRSjGArFeTSHFznRbM2QPQcBgDPyNMfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.5.tgz",
+ "integrity": "sha512-Hsc0vlt+vPvqzUkHKffWKf3YE/yexrY+yeFf7IsUvDC6PV7YGCsA/gJvPa+WJdgxGJnwuoO1HmLkbahaLKabYQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.5.tgz",
+ "integrity": "sha512-Jx5TMMSPJwHY7F/JwPdS6nuBwny2ECRK+EsAJV5HZrfiddeSS6ZrohkLkFZm3rjqBUuQ4FPX5gGMtakUuQO6fQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.5.tgz",
+ "integrity": "sha512-fGWcq2lo/XCqAkBLOokg66D/z+MUouryB8i+QdU9RR6O5knG7rucc0WFPqWYL1Wk/wuiJouDfmyBxmZKRM9bag==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.5.tgz",
+ "integrity": "sha512-JwO/Ys3jybc0UW3V5XO2CMnp0RhvoczDDQTgAEE1IctT+HRY3W5parIIWLUpmAK4gHSeeYJofm2iQPFA62K6ag==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.5.tgz",
+ "integrity": "sha512-vVD4iqel6J6A1xAJlb4mxEO9i1wOJ4/SkcKoDKAuK/wqEBPOA7/P5dDyZvNx46ocHwOUwzo3WjhTdKqET9kEAg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.5.tgz",
+ "integrity": "sha512-HmEfNh5Cdd/NHxgqwMzKnjlvicQQA+D+w+vQXDJSV0x/aHiQSjVwh1Th5RoA7QsMieIaxibt/5iJq68BraTSUg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.5.tgz",
+ "integrity": "sha512-NfmALpbe009nYzZ2QqlSW6nzbGddXpqk3nwualrIr+4QflQCTGMzt3QKK20yARiAnSV9fJdM5WNwIYUf31x3uA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.5.tgz",
+ "integrity": "sha512-ngcE7Udpy/iJ3lexVhYcbpUV2OJIakRGus3snc6K52BnAAJetDBG1Ku5Eh3CECtnW3mpBYWg5VCD4tTaOXKOVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.5.tgz",
+ "integrity": "sha512-DZ01A2EdTYX8xVeGoGiWkJBRWDh+/BOflwGPVsBay43PDxcq8ez0xuovZnPnk1xjE3CdI0pdZXxsJp/Xjmf7uQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.5.tgz",
+ "integrity": "sha512-nJKiMKDO140DUCEcQZl+f4HiGPMBxrW2webjuWJ2E3qPeoruHf2zByml4TP52lA1zDi6L4I92D/5stxeA/6pig==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.5.tgz",
+ "integrity": "sha512-ykLkBCf+Kls24QmqM3IRf1T4sGMh0rXPyymtj+hXlWKPJnXbXraHqUUsKtOIxqLasjT1i7DhfQGAaZYeiHbSOw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.5.tgz",
+ "integrity": "sha512-NiFPUFw8cHhkmBlL1k1eaz+5AXXm89rztj+DQdDuQ7VGPM0+ADFhezQ2KcBMF50UEFzGUr0+DVjVND743XwKWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.5.tgz",
+ "integrity": "sha512-Khj/vrKCpdbiAWYbJAeSy4D5iep+qwW/86lNoKvdleJYGxgbRE7h87+5d5T9D7gt8NcqhNFpreRgIg8T4bLzKQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
+ "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.32",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
+ "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.67.5",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.5.tgz",
+ "integrity": "sha512-3Ve5VH1hacCVutdpbmu/htg4kz+JCOIWgy4Q3hjHPYGHeHeIElNHJ0LcJ3rCfIsd5G7qOl5k5mwpzGMj9kdHPA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.10"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.67.5",
+ "@rollup/rollup-android-arm64": "4.67.5",
+ "@rollup/rollup-android-x64": "4.67.5",
+ "@rollup/rollup-darwin-arm64": "4.67.5",
+ "@rollup/rollup-darwin-x64": "4.67.5",
+ "@rollup/rollup-freebsd-arm64": "4.67.5",
+ "@rollup/rollup-freebsd-x64": "4.67.5",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.67.5",
+ "@rollup/rollup-linux-arm-musleabihf": "4.67.5",
+ "@rollup/rollup-linux-arm64-gnu": "4.67.5",
+ "@rollup/rollup-linux-arm64-musl": "4.67.5",
+ "@rollup/rollup-linux-loong64-gnu": "4.67.5",
+ "@rollup/rollup-linux-loong64-musl": "4.67.5",
+ "@rollup/rollup-linux-ppc64-gnu": "4.67.5",
+ "@rollup/rollup-linux-ppc64-musl": "4.67.5",
+ "@rollup/rollup-linux-riscv64-gnu": "4.67.5",
+ "@rollup/rollup-linux-riscv64-musl": "4.67.5",
+ "@rollup/rollup-linux-s390x-gnu": "4.67.5",
+ "@rollup/rollup-linux-x64-gnu": "4.67.5",
+ "@rollup/rollup-linux-x64-musl": "4.67.5",
+ "@rollup/rollup-openbsd-x64": "4.67.5",
+ "@rollup/rollup-openharmony-arm64": "4.67.5",
+ "@rollup/rollup-win32-arm64-msvc": "4.67.5",
+ "@rollup/rollup-win32-ia32-msvc": "4.67.5",
+ "@rollup/rollup-win32-x64-gnu": "4.67.5",
+ "@rollup/rollup-win32-x64-msvc": "4.67.5",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
+ "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..c98f95f
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "fast_reader-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "build": "tsc && vite build",
+ "dev": "vite"
+ },
+ "devDependencies": {
+ "typescript": "^5.8.3",
+ "vite": "^6.2.0"
+ },
+ "allowScripts": {
+ "esbuild@0.25.12": true
+ }
+}
diff --git a/frontend/src/app.ts b/frontend/src/app.ts
new file mode 100644
index 0000000..426486d
--- /dev/null
+++ b/frontend/src/app.ts
@@ -0,0 +1,431 @@
+import {
+ PrepareText,
+ GetSettings,
+ SaveSettings,
+ ResetSettings,
+} from "../wailsjs/go/app/App";
+
+interface Word {
+ text: string;
+ prefix: string;
+ focus: string;
+ suffix: string;
+}
+
+interface AppSettings {
+ readingSpeed: number;
+ fontSize: number;
+ theme: string;
+}
+
+const root = document.querySelector("#app")!;
+
+root.innerHTML = `
+
+
+
+
+
+
+
+ Ready to read
+ 0 / 0
+
+
+
+ FlashRead
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 300 WPM
+
+
+
+
+
+
+
+ 48 px
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+const textInput = document.querySelector("#text-input")!;
+const wordCount = document.querySelector("#word-count")!;
+const display = document.querySelector("#word-display")!;
+const prefixEl = display.querySelector(".word-prefix")!;
+const focusEl = display.querySelector(".word-focus")!;
+const suffixEl = display.querySelector(".word-suffix")!;
+const playButton = document.querySelector("#play")!;
+const resetButton = document.querySelector("#reset")!;
+const skipButton = document.querySelector("#skip")!;
+const speedInput = document.querySelector("#speed")!;
+const speedValue = document.querySelector("#speed-value")!;
+const progressBar = document.querySelector("#progress-bar")!;
+const positionLabel =
+ document.querySelector("#current-position")!;
+const statusLabel = document.querySelector("#reading-status")!;
+const demoButton = document.querySelector("#load-demo")!;
+const fontSizeInput = document.querySelector("#font-size")!;
+
+const fontSizeValue = document.querySelector("#font-size-value")!;
+
+const themeInput = document.querySelector("#theme")!;
+
+const resetSettingsButton =
+ document.querySelector("#reset-settings")!;
+
+const settingsStatus =
+ document.querySelector("#settings-status")!;
+
+let words: Word[] = [];
+let currentIndex = 0;
+let isPlaying = false;
+let timer: number | undefined;
+let wpm = 300;
+let fontSize = 48;
+let theme = "dark";
+
+let currentSettings: AppSettings = {
+ readingSpeed: 300,
+ fontSize: 48,
+ theme: "dark",
+};
+
+function applySettings(settings: AppSettings) {
+ currentSettings = settings;
+
+ wpm = settings.readingSpeed;
+ fontSize = settings.fontSize;
+ theme = settings.theme;
+
+ speedInput.value = String(wpm);
+ speedValue.textContent = `${wpm} WPM`;
+
+ fontSizeInput.value = String(fontSize);
+ fontSizeValue.textContent = `${fontSize} px`;
+
+ themeInput.value = theme;
+
+ document.documentElement.dataset.theme = theme;
+ display.style.fontSize = `${fontSize}px`;
+}
+
+async function persistSettings() {
+ currentSettings = {
+ readingSpeed: wpm,
+ fontSize,
+ theme,
+ };
+
+ settingsStatus.textContent = "Saving...";
+
+ try {
+ await SaveSettings(currentSettings);
+ settingsStatus.textContent = "Settings saved";
+ } catch (error) {
+ console.error("Could not save settings:", error);
+ settingsStatus.textContent = "Could not save settings";
+ }
+}
+
+async function loadSettings() {
+ try {
+ const saved = await GetSettings();
+ applySettings(saved);
+ } catch (error) {
+ console.error("Could not load settings:", error);
+ settingsStatus.textContent = "Using default settings";
+ }
+}
+
+function updateWordCount() {
+ const count = textInput.value.trim()
+ ? textInput.value.trim().split(/\s+/).length
+ : 0;
+
+ wordCount.textContent = `${count} words`;
+}
+
+function renderWord() {
+ if (words.length === 0) {
+ prefixEl.textContent = "";
+ focusEl.textContent = "F";
+ suffixEl.textContent = "lashRead";
+ positionLabel.textContent = "0 / 0";
+ progressBar.style.width = "0%";
+ return;
+ }
+
+ const word = words[currentIndex];
+
+ prefixEl.textContent = word.prefix;
+ focusEl.textContent = word.focus;
+ suffixEl.textContent = word.suffix;
+
+ positionLabel.textContent = `${currentIndex + 1} / ${words.length}`;
+
+ progressBar.style.width = `${((currentIndex + 1) / words.length) * 100}%`;
+}
+
+function stopPlayback() {
+ isPlaying = false;
+
+ if (timer !== undefined) {
+ window.clearTimeout(timer);
+ timer = undefined;
+ }
+
+ playButton.textContent = "▶ Start reading";
+ statusLabel.textContent = "Paused";
+}
+
+function advance() {
+ if (!isPlaying) return;
+
+ if (currentIndex >= words.length - 1) {
+ stopPlayback();
+ statusLabel.textContent = "Finished reading";
+ return;
+ }
+
+ const currentWord = words[currentIndex].text;
+
+ currentIndex++;
+ renderWord();
+
+ const delay = getDuration(currentWord);
+ timer = window.setTimeout(advance, delay);
+}
+
+function getDuration(word: string): number {
+ let duration = 60000 / wpm;
+
+ if (/[.!?]$/.test(word)) {
+ duration += 300;
+ } else if (/[,;:]$/.test(word)) {
+ duration += 150;
+ }
+
+ if ([...word].length >= 10) {
+ duration += 50;
+ }
+
+ return duration;
+}
+
+async function startPlayback() {
+ if (words.length === 0) {
+ const text = textInput.value.trim();
+
+ if (!text) {
+ textInput.focus();
+ return;
+ }
+
+ words = await PrepareText(text);
+ currentIndex = 0;
+ renderWord();
+ }
+
+ if (currentIndex >= words.length - 1) {
+ currentIndex = 0;
+ renderWord();
+ }
+
+ isPlaying = true;
+ playButton.textContent = "⏸ Pause";
+ statusLabel.textContent = "Reading";
+
+ timer = window.setTimeout(advance, getDuration(words[currentIndex].text));
+}
+
+playButton.addEventListener("click", () => {
+ if (isPlaying) {
+ stopPlayback();
+ } else {
+ void startPlayback();
+ }
+});
+
+resetButton.addEventListener("click", () => {
+ stopPlayback();
+ currentIndex = 0;
+ renderWord();
+ statusLabel.textContent = "Ready to read";
+});
+
+skipButton.addEventListener("click", () => {
+ if (currentIndex < words.length - 1) {
+ currentIndex++;
+ renderWord();
+ }
+});
+
+speedInput.addEventListener("input", () => {
+ wpm = Number(speedInput.value);
+ speedValue.textContent = `${wpm} WPM`;
+});
+
+speedInput.addEventListener("change", () => {
+ void persistSettings();
+});
+fontSizeInput.addEventListener("input", () => {
+ fontSize = Number(fontSizeInput.value);
+
+ fontSizeValue.textContent = `${fontSize} px`;
+ display.style.fontSize = `${fontSize}px`;
+});
+
+fontSizeInput.addEventListener("change", () => {
+ void persistSettings();
+});
+
+themeInput.addEventListener("change", () => {
+ theme = themeInput.value;
+ document.documentElement.dataset.theme = theme;
+
+ void persistSettings();
+});
+
+textInput.addEventListener("input", () => {
+ stopPlayback();
+ words = [];
+ currentIndex = 0;
+ updateWordCount();
+ renderWord();
+ statusLabel.textContent = "Ready to read";
+});
+
+demoButton.addEventListener("click", () => {
+ textInput.value =
+ "The quick brown fox jumps over the lazy dog. " +
+ "Speed reading helps you focus on one word at a time, " +
+ "reducing the need for eye movement across a page. " +
+ "Experiment with different speeds and discover what works best for you.";
+
+ updateWordCount();
+ words = [];
+ currentIndex = 0;
+ stopPlayback();
+ renderWord();
+});
+resetSettingsButton.addEventListener("click", async () => {
+ try {
+ settingsStatus.textContent = "Resetting...";
+
+ const defaults = await ResetSettings();
+ applySettings(defaults);
+
+ settingsStatus.textContent = "Settings reset";
+ } catch (error) {
+ console.error("Could not reset settings:", error);
+ settingsStatus.textContent = "Could not reset settings";
+ }
+});
+
+document.addEventListener("keydown", (event) => {
+ const target = event.target as HTMLElement;
+
+ if (target.tagName === "TEXTAREA" || target.tagName === "INPUT") {
+ return;
+ }
+
+ switch (event.key) {
+ case " ":
+ event.preventDefault();
+ playButton.click();
+ break;
+
+ case "ArrowRight":
+ skipButton.click();
+ break;
+
+ case "ArrowLeft":
+ if (currentIndex > 0) {
+ currentIndex--;
+ renderWord();
+ }
+ break;
+
+ case "r":
+ case "R":
+ resetButton.click();
+ break;
+
+ case "Escape":
+ stopPlayback();
+ break;
+ }
+});
+
+updateWordCount();
+renderWord();
+
+void loadSettings();
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..cb6b82d
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,2 @@
+import "./styles/style.css";
+import "./app";
diff --git a/frontend/src/styles/style.css b/frontend/src/styles/style.css
new file mode 100644
index 0000000..275ffa7
--- /dev/null
+++ b/frontend/src/styles/style.css
@@ -0,0 +1,306 @@
+:root {
+ font-family:
+ Inter,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+
+ --background: #121218;
+ --text: #e8e8ed;
+ --surface: #1c1c24;
+ --border: #30303a;
+ --muted: #92929e;
+ --accent: #8b7cf6;
+ --red: #ff4c55;
+ --secondary-background: #292932;
+ --secondary-border: #3a3a45;
+ --secondary-text: #d0d0da;
+ --progress-track: #32323c;
+
+ color: var(--text);
+ background: var(--background);
+}
+
+:root[data-theme="light"] {
+ --background: #f4f4f8;
+ --text: #24242d;
+ --surface: #ffffff;
+ --border: #dedee7;
+ --muted: #707080;
+ --accent: #6958df;
+ --red: #d93645;
+ --secondary-background: #ededf3;
+ --secondary-border: #d5d5df;
+ --secondary-text: #33333e;
+ --progress-track: #dedee7;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 600px;
+ min-height: 100vh;
+ background: var(--background);
+ color: var(--text);
+}
+
+button,
+textarea,
+input {
+ font: inherit;
+}
+
+.layout {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: 36px 48px;
+}
+
+header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 32px;
+}
+
+h1 {
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: -0.8px;
+ margin: 0;
+}
+
+.subtitle {
+ margin: 6px 0 0;
+ color: var(--muted);
+ font-size: 14px;
+}
+
+.version {
+ color: var(--muted);
+ font-size: 12px;
+ border: 1px solid var(--border);
+ padding: 5px 10px;
+ border-radius: 20px;
+}
+
+.input-section {
+ margin-bottom: 24px;
+}
+
+.input-section label,
+.settings label {
+ display: block;
+ font-size: 13px;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+textarea {
+ display: block;
+ width: 100%;
+ height: 130px;
+ resize: vertical;
+ padding: 14px;
+ color: var(--text);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ outline: none;
+ line-height: 1.6;
+ font-size: 14px;
+}
+
+textarea:focus {
+ border-color: var(--accent);
+}
+
+.input-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 8px;
+}
+
+#word-count {
+ font-size: 12px;
+ color: var(--muted);
+}
+
+button {
+ border: 0;
+ border-radius: 8px;
+ cursor: pointer;
+ transition: opacity 0.15s;
+}
+
+button:hover {
+ opacity: 0.85;
+}
+
+.primary {
+ background: var(--accent);
+ color: white;
+ padding: 12px 28px;
+ font-weight: 600;
+}
+
+.secondary {
+ color: var(--secondary-text);
+ background: var(--secondary-background);
+ border: 1px solid var(--secondary-border);
+ padding: 10px 16px;
+}
+
+.reader {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ padding: 24px;
+}
+
+.reader-top {
+ display: flex;
+ justify-content: space-between;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.word-display {
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ min-height: 180px;
+ padding: 48px 0;
+ font-size: clamp(36px, 6vw, 64px);
+ font-weight: 500;
+ letter-spacing: 0.5px;
+ font-variant-ligatures: none;
+}
+
+.word-prefix,
+.word-focus,
+.word-suffix {
+ white-space: pre;
+}
+
+.word-focus {
+ color: var(--red);
+ font-weight: 700;
+}
+
+.progress-track {
+ height: 4px;
+ border-radius: 4px;
+ overflow: hidden;
+ background: var(--progress-track);
+}
+
+#progress-bar {
+ height: 100%;
+ width: 0;
+ background: var(--accent);
+ transition: width 0.08s linear;
+}
+
+.controls {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 12px;
+ margin: 24px 0 32px;
+}
+
+.settings {
+ max-width: 500px;
+ margin: 0 auto;
+}
+
+.speed-control {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+}
+
+input[type="range"] {
+ flex: 1;
+ accent-color: var(--accent);
+ cursor: pointer;
+}
+
+#speed-value {
+ min-width: 80px;
+ font-size: 13px;
+ text-align: right;
+}
+
+footer {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 24px;
+ margin-top: 24px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+@media (max-width: 700px) {
+ .layout {
+ padding: 24px 18px;
+ }
+
+ .reader {
+ padding: 16px;
+ }
+
+ .word-display {
+ min-height: 140px;
+ padding: 36px 0;
+ }
+}
+
+.setting-group {
+ margin-bottom: 20px;
+}
+
+.settings select {
+ width: 100%;
+ padding: 10px 12px;
+ color: var(--text);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ outline: none;
+ color-scheme: light dark;
+}
+
+.settings select option {
+ color: var(--text);
+ background-color: var(--surface);
+}
+
+.settings select:focus {
+ border-color: var(--accent);
+}
+
+.settings-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ margin-top: 24px;
+}
+
+#settings-status {
+ color: var(--muted);
+ font-size: 12px;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..9e7fe01
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": [
+ "ES2022",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": false,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..197e80a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,35 @@
+module fast_reader
+
+go 1.24.0
+
+require github.com/wailsapp/wails/v2 v2.10.2
+
+require (
+ github.com/bep/debounce v1.2.1 // indirect
+ github.com/go-ole/go-ole v1.3.0 // indirect
+ github.com/godbus/dbus/v5 v5.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
+ github.com/labstack/echo/v4 v4.13.3 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
+ github.com/leaanthony/gosod v1.0.4 // indirect
+ github.com/leaanthony/slicer v1.6.0 // indirect
+ github.com/leaanthony/u v1.1.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/samber/lo v1.49.1 // indirect
+ github.com/tkrajina/go-reflector v0.5.8 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
+ github.com/wailsapp/go-webview2 v1.0.19 // indirect
+ github.com/wailsapp/mimetype v1.4.1 // indirect
+ golang.org/x/crypto v0.33.0 // indirect
+ golang.org/x/net v0.35.0 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b1e0229
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,81 @@
+github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
+github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
+github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
+github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
+github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
+github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
+github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
+github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
+github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
+github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
+github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
+github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
+github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
+github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
+github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
+github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
+github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
+github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
+github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
+github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
+github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
+github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk=
+github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
+golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
+golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/reader/engine.go b/internal/reader/engine.go
new file mode 100644
index 0000000..1801f01
--- /dev/null
+++ b/internal/reader/engine.go
@@ -0,0 +1,39 @@
+package reader
+
+import "time"
+
+const (
+ MinWPM = 100
+ MaxWPM = 1000
+)
+
+// WordDuration calculates how long a word should be displayed
+// at the specified reading speed.
+func WordDuration(word string, wpm int) time.Duration {
+ // Enforce supported reading speed limits.
+ if wpm < MinWPM {
+ wpm = MinWPM
+ }
+
+ if wpm > MaxWPM {
+ wpm = MaxWPM
+ }
+
+ // Base duration for one word.
+ duration := time.Minute / time.Duration(wpm)
+
+ // Add pauses for punctuation.
+ switch {
+ case IsSentenceEnding(word):
+ duration += 300 * time.Millisecond
+ case IsPausePunctuation(word):
+ duration += 150 * time.Millisecond
+ }
+
+ // Allow slightly more time for longer words.
+ if RuneLength(word) >= 10 {
+ duration += 50 * time.Millisecond
+ }
+
+ return duration
+}
diff --git a/internal/reader/engine_test.go b/internal/reader/engine_test.go
new file mode 100644
index 0000000..4eedffd
--- /dev/null
+++ b/internal/reader/engine_test.go
@@ -0,0 +1,273 @@
+package reader
+
+import (
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestORPIndex(t *testing.T) {
+ tests := []struct {
+ name string
+ word string
+ expected int
+ }{
+ {"empty", "", 0},
+ {"one character", "a", 0},
+ {"two characters", "go", 1},
+ {"three characters", "cat", 1},
+ {"five characters", "hello", 1},
+ {"six characters", "reader", 2},
+ {"nine characters", "wonderful", 2},
+ {"ten characters", "technology", 3},
+ {"long word", "extraordinary", 3},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := ORPIndex([]rune(tt.word))
+
+ if got != tt.expected {
+ t.Errorf("ORPIndex(%q) = %d, want %d",
+ tt.word, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSplitWord(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected Word
+ }{
+ {
+ name: "normal word",
+ input: "hello",
+ expected: Word{
+ Text: "hello",
+ Prefix: "h",
+ Focus: "e",
+ Suffix: "llo",
+ },
+ },
+ {
+ name: "unicode word",
+ input: "café",
+ expected: Word{
+ Text: "café",
+ Prefix: "c",
+ Focus: "a",
+ Suffix: "fé",
+ },
+ },
+ {
+ name: "emoji",
+ input: "🙂hello",
+ expected: Word{
+ Text: "🙂hello",
+ Prefix: "🙂h",
+ Focus: "e",
+ Suffix: "llo",
+ },
+ },
+ {
+ name: "empty word",
+ input: "",
+ expected: Word{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := SplitWord(tt.input)
+
+ if !reflect.DeepEqual(got, tt.expected) {
+ t.Errorf("SplitWord(%q) = %+v, want %+v",
+ tt.input, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestTokenize(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expectedCount int
+ }{
+ {"normal text", "Hello world", 2},
+ {"multiple spaces", "Hello world", 2},
+ {"newlines", "Hello\nworld", 2},
+ {"tabs", "Hello\tworld", 2},
+ {"mixed whitespace", " Hello\n world\tagain ", 3},
+ {"empty input", "", 0},
+ {"whitespace only", " \n\t ", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := Tokenize(tt.input)
+
+ if len(got) != tt.expectedCount {
+ t.Errorf("Tokenize(%q) returned %d words, want %d",
+ tt.input, len(got), tt.expectedCount)
+ }
+ })
+ }
+}
+
+func TestTokenizePreservesWords(t *testing.T) {
+ input := "Hello, world!"
+ words := Tokenize(input)
+
+ expected := []string{"Hello,", "world!"}
+
+ for i, word := range words {
+ if word.Text != expected[i] {
+ t.Errorf("word %d = %q, want %q",
+ i, word.Text, expected[i])
+ }
+ }
+}
+
+func TestIsSentenceEnding(t *testing.T) {
+ tests := []struct {
+ word string
+ expected bool
+ }{
+ {"hello.", true},
+ {"hello!", true},
+ {"hello?", true},
+ {"hello…", true},
+ {`hello."`, true},
+ {"hello!)", true},
+ {"hello", false},
+ {"hello,", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.word, func(t *testing.T) {
+ got := IsSentenceEnding(tt.word)
+
+ if got != tt.expected {
+ t.Errorf("IsSentenceEnding(%q) = %t, want %t",
+ tt.word, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestIsPausePunctuation(t *testing.T) {
+ tests := []struct {
+ word string
+ expected bool
+ }{
+ {"hello,", true},
+ {"hello;", true},
+ {"hello:", true},
+ {`hello,"`, true},
+ {"hello", false},
+ {"hello.", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.word, func(t *testing.T) {
+ got := IsPausePunctuation(tt.word)
+
+ if got != tt.expected {
+ t.Errorf("IsPausePunctuation(%q) = %t, want %t",
+ tt.word, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestWordDuration(t *testing.T) {
+ tests := []struct {
+ name string
+ word string
+ wpm int
+ expected time.Duration
+ }{
+ {
+ name: "normal word",
+ word: "hello",
+ wpm: 300,
+ expected: 200 * time.Millisecond,
+ },
+ {
+ name: "sentence ending",
+ word: "hello.",
+ wpm: 300,
+ expected: 500 * time.Millisecond,
+ },
+ {
+ name: "comma pause",
+ word: "hello,",
+ wpm: 300,
+ expected: 350 * time.Millisecond,
+ },
+ {
+ name: "long word",
+ word: "extraordinary",
+ wpm: 300,
+ expected: 250 * time.Millisecond,
+ },
+ {
+ name: "sentence ending and long word",
+ word: "extraordinary.",
+ wpm: 300,
+ expected: 550 * time.Millisecond,
+ },
+ {
+ name: "minimum WPM clamp",
+ word: "hello",
+ wpm: 50,
+ expected: 600 * time.Millisecond,
+ },
+ {
+ name: "maximum WPM clamp",
+ word: "hello",
+ wpm: 1500,
+ expected: 60 * time.Millisecond,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := WordDuration(tt.word, tt.wpm)
+
+ if got != tt.expected {
+ t.Errorf("WordDuration(%q, %d) = %v, want %v",
+ tt.word, tt.wpm, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestRuneLength(t *testing.T) {
+ tests := []struct {
+ word string
+ expected int
+ }{
+ {"hello", 5},
+ {"café", 4},
+ {"🙂", 1},
+ {"🙂hello", 6},
+ {"", 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.word, func(t *testing.T) {
+ got := RuneLength(tt.word)
+
+ if got != tt.expected {
+ t.Errorf("RuneLength(%q) = %d, want %d",
+ tt.word, got, tt.expected)
+ }
+ })
+ }
+}
diff --git a/internal/reader/orp.go b/internal/reader/orp.go
new file mode 100644
index 0000000..ad3f353
--- /dev/null
+++ b/internal/reader/orp.go
@@ -0,0 +1,20 @@
+package reader
+
+// ORPIndex calculates the approximate optimal recognition
+// point of a word.
+//
+// The returned value is a zero-based rune index.
+func ORPIndex(runes []rune) int {
+ length := len(runes)
+
+ switch {
+ case length <= 1:
+ return 0
+ case length <= 5:
+ return 1
+ case length <= 9:
+ return 2
+ default:
+ return 3
+ }
+}
diff --git a/internal/reader/tokenizer.go b/internal/reader/tokenizer.go
new file mode 100644
index 0000000..5a55961
--- /dev/null
+++ b/internal/reader/tokenizer.go
@@ -0,0 +1,98 @@
+package reader
+
+import (
+ "strings"
+ "unicode"
+)
+
+type Word struct {
+ Text string `json:"text"`
+ Prefix string `json:"prefix"`
+ Focus string `json:"focus"`
+ Suffix string `json:"suffix"`
+}
+
+// Tokenize splits text into words while ignoring repeated whitespace.
+func Tokenize(text string) []Word {
+ fields := strings.Fields(text)
+ words := make([]Word, 0, len(fields))
+
+ for _, field := range fields {
+ words = append(words, SplitWord(field))
+ }
+
+ return words
+}
+
+// SplitWord separates a word into its prefix, focus character and suffix.
+func SplitWord(word string) Word {
+ if word == "" {
+ return Word{}
+ }
+
+ runes := []rune(word)
+ position := ORPIndex(runes)
+
+ // Keep the focus index inside the word.
+ if position >= len(runes) {
+ position = len(runes) - 1
+ }
+
+ return Word{
+ Text: word,
+ Prefix: string(runes[:position]),
+ Focus: string(runes[position : position+1]),
+ Suffix: string(runes[position+1:]),
+ }
+}
+
+// lastMeaningfulRune returns the last rune after ignoring
+// closing quotes and brackets.
+func lastMeaningfulRune(word string) rune {
+ word = strings.TrimRightFunc(word, func(r rune) bool {
+ switch r {
+ case '"', '\'', '”', '’', ')', ']', '}', '»':
+ return true
+ default:
+ return false
+ }
+ })
+
+ runes := []rune(word)
+ if len(runes) == 0 {
+ return 0
+ }
+
+ return runes[len(runes)-1]
+}
+
+// IsSentenceEnding checks whether a word ends a sentence.
+func IsSentenceEnding(word string) bool {
+ switch lastMeaningfulRune(word) {
+ case '.', '!', '?', '…':
+ return true
+ default:
+ return false
+ }
+}
+
+// IsPausePunctuation checks for punctuation that should
+// introduce a short pause during playback.
+func IsPausePunctuation(word string) bool {
+ switch lastMeaningfulRune(word) {
+ case ',', ';', ':':
+ return true
+ default:
+ return false
+ }
+}
+
+// RuneLength returns the number of Unicode characters in a word.
+func RuneLength(word string) int {
+ return len([]rune(word))
+}
+
+// IsPunctuation reports whether the final meaningful character is punctuation.
+func IsPunctuation(word string) bool {
+ return unicode.IsPunct(lastMeaningfulRune(word))
+}
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
new file mode 100644
index 0000000..5a0a009
--- /dev/null
+++ b/internal/settings/settings.go
@@ -0,0 +1,64 @@
+package settings
+
+import "strings"
+
+const (
+ MinReadingSpeed = 100
+ MaxReadingSpeed = 1000
+
+ MinFontSize = 24
+ MaxFontSize = 96
+
+ ThemeDark = "dark"
+ ThemeLight = "light"
+)
+
+// Settings contains user preferences.
+type Settings struct {
+ ReadingSpeed int `json:"readingSpeed"`
+ FontSize int `json:"fontSize"`
+ Theme string `json:"theme"`
+}
+
+// Default returns the default application settings.
+func Default() Settings {
+ return Settings{
+ ReadingSpeed: 300,
+ FontSize: 48,
+ Theme: ThemeDark,
+ }
+}
+
+// Validate checks whether all settings contain supported values.
+func (s Settings) Validate() bool {
+ return s.ReadingSpeed >= MinReadingSpeed &&
+ s.ReadingSpeed <= MaxReadingSpeed &&
+ s.FontSize >= MinFontSize &&
+ s.FontSize <= MaxFontSize &&
+ (s.Theme == ThemeDark || s.Theme == ThemeLight)
+}
+
+// Normalize returns settings with values restricted to supported options.
+func (s Settings) Normalize() Settings {
+ defaults := Default()
+
+ if s.ReadingSpeed < MinReadingSpeed {
+ s.ReadingSpeed = MinReadingSpeed
+ } else if s.ReadingSpeed > MaxReadingSpeed {
+ s.ReadingSpeed = MaxReadingSpeed
+ }
+
+ if s.FontSize < MinFontSize {
+ s.FontSize = MinFontSize
+ } else if s.FontSize > MaxFontSize {
+ s.FontSize = MaxFontSize
+ }
+
+ s.Theme = strings.ToLower(strings.TrimSpace(s.Theme))
+
+ if s.Theme != ThemeDark && s.Theme != ThemeLight {
+ s.Theme = defaults.Theme
+ }
+
+ return s
+}
diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go
new file mode 100644
index 0000000..0cb3163
--- /dev/null
+++ b/internal/settings/settings_test.go
@@ -0,0 +1,214 @@
+package settings
+
+import "testing"
+
+func TestDefault(t *testing.T) {
+ got := Default()
+
+ if got.ReadingSpeed != 300 {
+ t.Errorf("ReadingSpeed = %d, want 300", got.ReadingSpeed)
+ }
+
+ if got.FontSize != 48 {
+ t.Errorf("FontSize = %d, want 48", got.FontSize)
+ }
+
+ if got.Theme != ThemeDark {
+ t.Errorf("Theme = %q, want %q", got.Theme, ThemeDark)
+ }
+}
+
+func TestValidate(t *testing.T) {
+ tests := []struct {
+ name string
+ settings Settings
+ want bool
+ }{
+ {
+ name: "valid defaults",
+ settings: Default(),
+ want: true,
+ },
+ {
+ name: "minimum values",
+ settings: Settings{
+ ReadingSpeed: MinReadingSpeed,
+ FontSize: MinFontSize,
+ Theme: ThemeDark,
+ },
+ want: true,
+ },
+ {
+ name: "maximum values",
+ settings: Settings{
+ ReadingSpeed: MaxReadingSpeed,
+ FontSize: MaxFontSize,
+ Theme: ThemeLight,
+ },
+ want: true,
+ },
+ {
+ name: "invalid reading speed",
+ settings: Settings{
+ ReadingSpeed: 50,
+ FontSize: 48,
+ Theme: ThemeDark,
+ },
+ want: false,
+ },
+ {
+ name: "reading speed too high",
+ settings: Settings{
+ ReadingSpeed: 1500,
+ FontSize: 48,
+ Theme: ThemeDark,
+ },
+ want: false,
+ },
+ {
+ name: "invalid font size",
+ settings: Settings{
+ ReadingSpeed: 300,
+ FontSize: 10,
+ Theme: ThemeDark,
+ },
+ want: false,
+ },
+ {
+ name: "invalid theme",
+ settings: Settings{
+ ReadingSpeed: 300,
+ FontSize: 48,
+ Theme: "purple",
+ },
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.settings.Validate(); got != tt.want {
+ t.Errorf("Validate() = %t, want %t", got, tt.want)
+ }
+ })
+ }
+}
+func TestNormalize(t *testing.T) {
+ tests := []struct {
+ name string
+ input Settings
+ want Settings
+ }{
+ {
+ name: "valid settings unchanged",
+ input: Default(),
+ want: Default(),
+ },
+ {
+ name: "speed below minimum clamped",
+ input: Settings{
+ ReadingSpeed: 50,
+ FontSize: 48,
+ Theme: ThemeDark,
+ },
+ want: Settings{
+ ReadingSpeed: MinReadingSpeed,
+ FontSize: 48,
+ Theme: ThemeDark,
+ },
+ },
+ {
+ name: "speed above maximum clamped",
+ input: Settings{
+ ReadingSpeed: 1200,
+ FontSize: 48,
+ Theme: ThemeDark,
+ },
+ want: Settings{
+ ReadingSpeed: MaxReadingSpeed,
+ FontSize: 48,
+ Theme: ThemeDark,
+ },
+ },
+ {
+ name: "font below minimum clamped",
+ input: Settings{
+ ReadingSpeed: 400,
+ FontSize: 10,
+ Theme: ThemeLight,
+ },
+ want: Settings{
+ ReadingSpeed: 400,
+ FontSize: MinFontSize,
+ Theme: ThemeLight,
+ },
+ },
+ {
+ name: "font above maximum clamped",
+ input: Settings{
+ ReadingSpeed: 400,
+ FontSize: 120,
+ Theme: ThemeLight,
+ },
+ want: Settings{
+ ReadingSpeed: 400,
+ FontSize: MaxFontSize,
+ Theme: ThemeLight,
+ },
+ },
+ {
+ name: "invalid theme replaced",
+ input: Settings{
+ ReadingSpeed: 400,
+ FontSize: 48,
+ Theme: "purple",
+ },
+ want: Settings{
+ ReadingSpeed: 400,
+ FontSize: 48,
+ Theme: ThemeDark,
+ },
+ },
+ {
+ name: "theme whitespace and case normalized",
+ input: Settings{
+ ReadingSpeed: 400,
+ FontSize: 48,
+ Theme: " LIGHT ",
+ },
+ want: Settings{
+ ReadingSpeed: 400,
+ FontSize: 48,
+ Theme: ThemeLight,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tt.input.Normalize()
+
+ if got != tt.want {
+ t.Errorf("Normalize() = %+v, want %+v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestNormalizeAlwaysProducesValidSettings(t *testing.T) {
+ tests := []Settings{
+ {},
+ {ReadingSpeed: -1, FontSize: -1, Theme: "invalid"},
+ {ReadingSpeed: 2000, FontSize: 200, Theme: " BLUE "},
+ {ReadingSpeed: 300, FontSize: 48, Theme: " LIGHT "},
+ }
+
+ for _, input := range tests {
+ got := input.Normalize()
+
+ if !got.Validate() {
+ t.Errorf("Normalize(%+v) produced invalid settings: %+v",
+ input, got)
+ }
+ }
+}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
new file mode 100644
index 0000000..31c9ac5
--- /dev/null
+++ b/internal/storage/storage.go
@@ -0,0 +1,66 @@
+package storage
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+)
+
+// Encode serializes a value into JSON.
+func Encode(value any) ([]byte, error) {
+ return json.MarshalIndent(value, "", " ")
+}
+
+// Decode deserializes JSON into the supplied value.
+func Decode(data []byte, target any) error {
+ return json.Unmarshal(data, target)
+}
+
+// Save writes a value to a JSON file.
+func Save(path string, value any) error {
+ data, err := Encode(value)
+ if err != nil {
+ return err
+ }
+
+ dir := filepath.Dir(path)
+
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return err
+ }
+
+ file, err := os.CreateTemp(dir, ".settings-*.tmp")
+ if err != nil {
+ return err
+ }
+
+ tempPath := file.Name()
+
+ defer os.Remove(tempPath)
+
+ if _, err := file.Write(data); err != nil {
+ file.Close()
+ return err
+ }
+
+ if err := file.Sync(); err != nil {
+ file.Close()
+ return err
+ }
+
+ if err := file.Close(); err != nil {
+ return err
+ }
+
+ return os.Rename(tempPath, path)
+}
+
+// Load reads a JSON file into the supplied target.
+func Load(path string, target any) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ return Decode(data, target)
+}
diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go
new file mode 100644
index 0000000..cf5c987
--- /dev/null
+++ b/internal/storage/storage_test.go
@@ -0,0 +1,176 @@
+package storage
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+type testSettings struct {
+ ReadingSpeed int `json:"readingSpeed"`
+ Theme string `json:"theme"`
+}
+
+func TestEncode(t *testing.T) {
+ input := testSettings{
+ ReadingSpeed: 300,
+ Theme: "dark",
+ }
+
+ got, err := Encode(input)
+ if err != nil {
+ t.Fatalf("Encode() returned error: %v", err)
+ }
+
+ var decoded testSettings
+
+ if err := json.Unmarshal(got, &decoded); err != nil {
+ t.Fatalf("encoded JSON is invalid: %v", err)
+ }
+
+ if decoded != input {
+ t.Errorf("decoded value = %+v, want %+v", decoded, input)
+ }
+
+ if !strings.Contains(string(got), "\n") {
+ t.Error("Encode() should produce indented JSON")
+ }
+}
+
+func TestEncodeNil(t *testing.T) {
+ got, err := Encode(nil)
+ if err != nil {
+ t.Fatalf("Encode(nil) returned error: %v", err)
+ }
+
+ if string(got) != "null" {
+ t.Errorf("Encode(nil) = %q, want %q", got, "null")
+ }
+}
+
+func TestEncodeUnsupportedValue(t *testing.T) {
+ _, err := Encode(make(chan int))
+
+ if err == nil {
+ t.Error("Encode() expected an error for unsupported value")
+ }
+}
+
+func TestDecode(t *testing.T) {
+ input := []byte(`{
+ "readingSpeed": 400,
+ "theme": "light"
+ }`)
+
+ var got testSettings
+
+ if err := Decode(input, &got); err != nil {
+ t.Fatalf("Decode() returned error: %v", err)
+ }
+
+ expected := testSettings{
+ ReadingSpeed: 400,
+ Theme: "light",
+ }
+
+ if got != expected {
+ t.Errorf("Decode() = %+v, want %+v", got, expected)
+ }
+}
+
+func TestDecodeInvalidJSON(t *testing.T) {
+ input := []byte(`{"readingSpeed": }`)
+
+ var got testSettings
+
+ if err := Decode(input, &got); err == nil {
+ t.Error("Decode() expected an error for invalid JSON")
+ }
+}
+
+func TestDecodeNilTarget(t *testing.T) {
+ err := Decode([]byte(`{"readingSpeed":300}`), nil)
+
+ if err == nil {
+ t.Error("Decode() expected an error for nil target")
+ }
+}
+
+func TestDecodeEmptyJSON(t *testing.T) {
+ var got testSettings
+
+ if err := Decode([]byte(``), &got); err == nil {
+ t.Error("Decode() expected an error for empty input")
+ }
+}
+
+// NEW
+func TestEncodeDecode(t *testing.T) {
+ original := testSettings{
+ ReadingSpeed: 400,
+ Theme: "dark",
+ }
+
+ data, err := Encode(original)
+ if err != nil {
+ t.Fatalf("Encode() error = %v", err)
+ }
+
+ var decoded testSettings
+
+ if err := Decode(data, &decoded); err != nil {
+ t.Fatalf("Decode() error = %v", err)
+ }
+
+ if !reflect.DeepEqual(decoded, original) {
+ t.Errorf("decoded = %+v, want %+v", decoded, original)
+ }
+}
+
+func TestSaveAndLoad(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings", "settings.json")
+
+ original := testSettings{
+ ReadingSpeed: 500,
+ Theme: "light",
+ }
+
+ if err := Save(path, original); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+
+ var loaded testSettings
+
+ if err := Load(path, &loaded); err != nil {
+ t.Fatalf("Load() error = %v", err)
+ }
+
+ if !reflect.DeepEqual(loaded, original) {
+ t.Errorf("loaded = %+v, want %+v", loaded, original)
+ }
+}
+
+func TestLoadMissingFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "missing.json")
+
+ var target testSettings
+
+ err := Load(path, &target)
+
+ if !os.IsNotExist(err) {
+ t.Errorf("Load() error = %v, want file-not-found error", err)
+ }
+}
+
+func TestSaveInvalidValue(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "settings.json")
+
+ err := Save(path, make(chan int))
+
+ if err == nil {
+ t.Fatal("Save() expected an error for an unsupported JSON value")
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..213d69a
--- /dev/null
+++ b/main.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "embed"
+
+ "github.com/wailsapp/wails/v2"
+ "github.com/wailsapp/wails/v2/pkg/options"
+ "github.com/wailsapp/wails/v2/pkg/options/assetserver"
+
+ "fast_reader/app"
+)
+
+//go:embed all:frontend/dist
+var assets embed.FS
+
+func main() {
+ application := app.NewApp()
+
+ err := wails.Run(&options.App{
+ Title: "Fast Reader Training",
+ Width: 1000,
+ Height: 700,
+ MinWidth: 700,
+ MinHeight: 500,
+
+ AssetServer: &assetserver.Options{
+ Assets: assets,
+ },
+
+ BackgroundColour: &options.RGBA{
+ R: 18,
+ G: 18,
+ B: 24,
+ A: 1,
+ },
+
+ OnStartup: application.Startup,
+ Bind: []interface{}{
+ application,
+ },
+ })
+
+ if err != nil {
+ println("Error:", err.Error())
+ }
+}
diff --git a/wails.json b/wails.json
new file mode 100644
index 0000000..c480da1
--- /dev/null
+++ b/wails.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://wails.io/schemas/config.v2.json",
+ "name": "Fast Reader",
+ "outputfilename": "fast_reader",
+ "frontend:install": "npm install",
+ "frontend:build": "npm run build",
+ "frontend:dev:watcher": "npm run dev",
+ "frontend:dev:serverUrl": "auto"
+}