diff --git a/README.md b/README.md
index 01b039f..6b1374d 100644
--- a/README.md
+++ b/README.md
@@ -2,12 +2,22 @@
Core is a Web3 Framework, written in Go using Wails.io to replace Electron and the bloat of browsers that, at their core, still live in their mum's basement.
-More to come, follow us on Discord http://discord.dappco.re
+- Discord: http://discord.dappco.re
+- Repo: https://github.com/Snider/Core
+## Vision
-Repo: https://github.com/Snider/Core
+Core is an **opinionated Web3 desktop application framework** providing:
-## Quick start
+1. **Service-Oriented Architecture** - Pluggable services with dependency injection
+2. **Encrypted Workspaces** - Each workspace gets its own PGP keypair, files are obfuscated
+3. **Cross-Platform Storage** - Abstract storage backends (local, SFTP, WebDAV) behind a `Medium` interface
+4. **Multi-Brand Support** - Same codebase powers different "hub" apps (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub)
+5. **Built-in Crypto** - PGP encryption/signing, hashing, checksums as first-class citizens
+
+**Mental model:** A secure, encrypted workspace manager where each "workspace" is a cryptographically isolated environment. The framework handles windows, menus, trays, config, and i18n.
+
+## Quick Start
```go
import core "github.com/Snider/Core"
@@ -17,119 +27,322 @@ app := core.New(
)
```
-## Development Workflow
-
-This project follows a Test-Driven Development (TDD) approach. We use [Task](https://taskfile.dev/) for task automation to streamline the development process.
-
-The recommended workflow is:
-
-1. **Generate Tests**: For any changes to the public API, first generate the necessary test stubs.
-
- ```bash
- task test-gen
- ```
-
-2. **Run Tests (and watch them fail)**: Verify that the new tests fail as expected.
-
- ```bash
- task test
- ```
-
-3. **Implement Your Feature**: Write the code to make the tests pass.
-
-4. **Run Tests Again**: Ensure all tests now pass.
-
- ```bash
- task test
- ```
-
-5. **Submit for Review**: Once your changes are complete and tests are passing, submit them for a CodeRabbit review.
-
- ```bash
- task review
- ```
-
-## Project Structure
-
-The project is organized into the following main directories:
-
-- `pkg/`: Contains the core Go packages that make up the framework.
-- `cmd/`: Contains the entry points for the two main applications:
- - `core-gui/`: The Wails-based GUI application.
- - `core/`: The command-line interface (CLI) application.
-
## Prerequisites
-- [Go](https://go.dev/)
+- [Go](https://go.dev/) 1.25+
- [Node.js](https://nodejs.org/)
-- [Wails](https://wails.io/)
+- [Wails](https://wails.io/) v3
- [Task](https://taskfile.dev/)
-## Building and Running
-
-### GUI Application
-
-To run the GUI application in development mode:
+## Development Workflow (TDD)
```bash
-task gui:dev
+task test-gen # 1. Generate test stubs
+task test # 2. Run tests (watch them fail)
+# 3. Implement your feature
+task test # 4. Run tests (watch them pass)
+task review # 5. CodeRabbit review
```
-To build the final application for your platform:
+## Building & Running
```bash
-task gui:build
+# GUI (Wails)
+task gui:dev # Development with hot-reload
+task gui:build # Production build
+
+# CLI
+task cli:build # Build to cmd/core/bin/core
+task cli:run # Build and run
```
-### CLI Application
+## All Tasks
-To build the CLI application:
+| Task | Description |
+|------|-------------|
+| `task test` | Run all Go tests |
+| `task test-gen` | Generate test stubs for public API |
+| `task check` | go mod tidy + tests + review |
+| `task review` | CodeRabbit review |
+| `task cov` | Generate coverage.txt |
+| `task cov-view` | Open HTML coverage report |
+| `task sync` | Update public API Go files |
+
+---
+
+## Architecture
+
+### Project Structure
+
+```
+.
+├── core.go # Facade re-exporting pkg/core
+├── pkg/
+│ ├── core/ # Service container, DI, Runtime[T]
+│ ├── config/ # JSON persistence, XDG paths
+│ ├── display/ # Windows, tray, menus (Wails)
+│ ├── crypt/ # Hashing, checksums, PGP
+│ │ └── openpgp/ # Full PGP implementation
+│ ├── io/ # Medium interface + backends
+│ ├── workspace/ # Encrypted workspace management
+│ ├── help/ # In-app documentation
+│ └── i18n/ # Internationalization
+├── cmd/
+│ ├── core/ # CLI application
+│ └── core-gui/ # Wails GUI application
+└── go.work # Links root, cmd/core, cmd/core-gui
+```
+
+### Service Pattern (Dual-Constructor DI)
+
+Every service follows this pattern:
+
+```go
+// Static DI - standalone use/testing (no core.Runtime)
+func New() (*Service, error)
+
+// Dynamic DI - for core.WithService() registration
+func Register(c *core.Core) (any, error)
+```
+
+Services embed `*core.Runtime[Options]` for access to `Core()` and `Config()`.
+
+### IPC/Action System
+
+Services implement `HandleIPCEvents(c *core.Core, msg core.Message) error` - auto-discovered via reflection. Handles typed actions like `core.ActionServiceStartup`.
+
+---
+
+## Wails v3 Frontend Bindings
+
+Core uses [Wails v3](https://v3alpha.wails.io/) to expose Go methods to a WebView2 browser runtime. Wails automatically generates TypeScript bindings for registered services.
+
+**Documentation:** [Wails v3 Method Bindings](https://v3alpha.wails.io/features/bindings/methods/)
+
+### How It Works
+
+1. **Go services** with exported methods are registered with Wails
+2. Run `wails3 generate bindings` (or `wails3 dev` / `wails3 build`)
+3. **TypeScript SDK** is generated in `frontend/bindings/`
+4. Frontend calls Go methods with full type safety, no HTTP overhead
+
+### Current Binding Architecture
+
+```go
+// cmd/core-gui/main.go
+app.RegisterService(application.NewService(coreService)) // Only Core is registered
+```
+
+**Problem:** Only `Core` is registered with Wails. Sub-services (crypt, workspace, display, etc.) are internal to Core's service map - their methods aren't directly exposed to JS.
+
+**Currently exposed** (see `cmd/core-gui/public/bindings/`):
+```typescript
+// From frontend:
+import { ACTION, Config, Service } from './bindings/github.com/Snider/Core/pkg/core'
+
+ACTION(msg) // Broadcast IPC message
+Config() // Get config service reference
+Service("workspace") // Get service by name (returns any)
+```
+
+**NOT exposed:** Direct calls like `workspace.CreateWorkspace()` or `crypt.Hash()`.
+
+### The IPC Bridge Pattern (Chosen Architecture)
+
+Sub-services are accessed via Core's **IPC/ACTION system**, not direct Wails bindings:
+
+```typescript
+// Frontend calls Core.ACTION() with typed messages
+import { ACTION } from './bindings/github.com/Snider/Core/pkg/core'
+
+// Open a window
+ACTION({ action: "display.open_window", name: "settings", options: { Title: "Settings", Width: 800 } })
+
+// Switch workspace
+ACTION({ action: "workspace.switch_workspace", name: "myworkspace" })
+```
+
+Each service implements `HandleIPCEvents(c *core.Core, msg core.Message)` to process these messages:
+
+```go
+// pkg/display/display.go
+func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
+ switch m := msg.(type) {
+ case map[string]any:
+ if action, ok := m["action"].(string); ok && action == "display.open_window" {
+ return s.handleOpenWindowAction(m)
+ }
+ }
+ return nil
+}
+```
+
+**Why this pattern:**
+- Single Wails service (Core) = simpler binding generation
+- Services remain decoupled from Wails
+- Centralized message routing via `ACTION()`
+- Services can communicate internally using same pattern
+
+**Current gap:** Not all service methods have IPC handlers yet. See `HandleIPCEvents` in each service to understand what's wired up.
+
+### Generating Bindings
```bash
-task cli:build
+cd cmd/core-gui
+wails3 generate bindings # Regenerate after Go changes
```
-The executable will be located in the `cmd/core/bin` directory.
+Bindings output to `cmd/core-gui/public/bindings/github.com/Snider/Core/` mirroring Go package structure.
-## Available Tasks
+---
-To run any of the following tasks, open your terminal in the project's root directory and execute the `task` command.
+### Service Interfaces (`pkg/core/interfaces.go`)
-### General Tasks
+```go
+type Config interface {
+ Get(key string, out any) error
+ Set(key string, v any) error
+}
-- `task test`: Runs all Go tests recursively for the entire project.
-- `task test-gen`: Generates tests for the public API.
-- `task check`: A comprehensive check that runs `go mod tidy`, the full test suite, and a CodeRabbit review.
-- `task review`: Submits the current changes for a CodeRabbit review.
-- `task cov`: Generates a test coverage profile (`coverage.txt`).
-- `task cov-view`: Opens the HTML coverage report in your browser.
-- `task sync`: Updates the public API Go files to match the exported interface of the modules.
+type Display interface {
+ OpenWindow(opts ...WindowOption) error
+}
-### GUI Application (`cmd/core-gui`)
+type Workspace interface {
+ CreateWorkspace(identifier, password string) (string, error)
+ SwitchWorkspace(name string) error
+ WorkspaceFileGet(filename string) (string, error)
+ WorkspaceFileSet(filename, content string) error
+}
-These tasks are run from the root directory and operate on the GUI application.
+type Crypt interface {
+ EncryptPGP(writer io.Writer, recipientPath, data string, ...) (string, error)
+ DecryptPGP(recipientPath, message, passphrase string, ...) (string, error)
+}
+```
-- `task gui:build`: Builds the GUI application.
-- `task gui:package`: Packages a production build of the GUI application.
-- `task gui:run`: Runs the GUI application.
-- `task gui:dev`: Runs the GUI application in development mode, with hot-reloading enabled.
+---
-### CLI Application (`cmd/core`)
+## Current State (Prototype)
-These tasks are run from the root directory and operate on the CLI application.
+### Working
-- `task cli:build`: Builds the CLI application.
-- `task cli:build:dev`: Builds the CLI application for development.
-- `task cli:run`: Builds and runs the CLI application.
-- `task cli:sync`: Updates the public API Go files.
-- `task cli:test-gen`: Generates tests for the public API.
+| Package | Notes |
+|---------|-------|
+| `pkg/core` | Service container, DI, thread-safe - solid |
+| `pkg/config` | JSON persistence, XDG paths - solid |
+| `pkg/crypt` | Hashing, checksums, PGP - solid, well-tested |
+| `pkg/help` | Embedded docs, Show/ShowAt - solid |
+| `pkg/i18n` | Multi-language with go-i18n - solid |
+| `pkg/io` | Medium interface + local backend - solid |
+| `pkg/workspace` | Workspace creation, switching, file ops - functional |
-## Docs (MkDocs)
-The help site and in‑app docs are built with MkDocs Material and live under `pkg/v1/core/docs`.
+### Partial
-- Live preview: from `pkg/v1/core/docs` run
- - `pip install -r requirements.txt`
- - `mkdocs serve -o -c` (or `task dev` if you use Task)
-- Build static site: `mkdocs build --clean -d public` (or `task build`)
+| Package | Issues |
+|---------|--------|
+| `pkg/display` | Window creation works; menu/tray handlers are TODOs |
-The demo app embeds the built docs from `public/` and can open specific sections in new windows using stable, short headings.
+---
+
+## Priority Work Items
+
+### 1. IMPLEMENT: System Tray Brand Support
+
+`pkg/display/tray.go:52-63` - Commented brand-specific menu items need implementation.
+
+### 2. ADD: Integration Tests
+
+| Package | Notes |
+|---------|-------|
+| `pkg/display` | Integration tests requiring Wails runtime (27% unit coverage) |
+
+---
+
+## Package Deep Dives
+
+### pkg/workspace - The Core Feature
+
+Each workspace is:
+1. Identified by LTHN hash of user identifier
+2. Has directory structure: `config/`, `log/`, `data/`, `files/`, `keys/`
+3. Gets a PGP keypair generated on creation
+4. Files accessed via obfuscated paths
+
+The `workspaceList` maps workspace IDs to public keys.
+
+### pkg/crypt/openpgp
+
+Full PGP using `github.com/ProtonMail/go-crypto`:
+- `CreateKeyPair(name, passphrase)` - RSA-4096 with revocation cert
+- `EncryptPGP()` - Encrypt + optional signing
+- `DecryptPGP()` - Decrypt + optional signature verification
+
+### pkg/io - Storage Abstraction
+
+```go
+type Medium interface {
+ Read(path string) (string, error)
+ Write(path, content string) error
+ EnsureDir(path string) error
+ IsFile(path string) bool
+ FileGet(path string) (string, error)
+ FileSet(path, content string) error
+}
+```
+
+Implementations: `local/`, `sftp/`, `webdav/`
+
+---
+
+## Future Work
+
+### Phase 1: Core Stability
+- [x] ~~Fix workspace medium injection (critical blocker)~~
+- [x] ~~Initialize `io.Local` global~~
+- [x] ~~Clean up dead code (orphaned vars, broken wrappers)~~
+- [x] ~~Wire up IPC handlers for all services (config, crypt, display, help, i18n, workspace)~~
+- [x] ~~Complete display menu handlers (New/List workspace)~~
+- [x] ~~Tray icon setup with asset embedding~~
+- [x] ~~Test coverage for io packages~~
+- [ ] System tray brand-specific menus
+
+### Phase 2: Multi-Brand Support
+- [ ] Define brand configuration system (config? build flags?)
+- [ ] Implement brand-specific tray menus (AdminHub, ServerHub, GatewayHub, DeveloperHub, ClientHub)
+- [ ] Brand-specific theming/assets
+- [ ] Per-brand default workspace configurations
+
+### Phase 3: Remote Storage
+- [ ] Complete SFTP backend (`pkg/io/sftp/`)
+- [ ] Complete WebDAV backend (`pkg/io/webdav/`)
+- [ ] Workspace sync across storage backends
+- [ ] Conflict resolution for multi-device access
+
+### Phase 4: Enhanced Crypto
+- [ ] Key management UI (import/export, key rotation)
+- [ ] Multi-recipient encryption
+- [ ] Hardware key support (YubiKey, etc.)
+- [ ] Encrypted workspace backup/restore
+
+### Phase 5: Developer Experience
+- [ ] TypeScript types for IPC messages (codegen from Go structs)
+- [ ] Hot-reload for service registration
+- [ ] Plugin system for third-party services
+- [ ] CLI tooling for workspace management
+
+### Phase 6: Distribution
+- [ ] Auto-update mechanism
+- [ ] Platform installers (DMG, MSI, AppImage)
+- [ ] Signing and notarization
+- [ ] Crash reporting integration
+
+---
+
+## For New Contributors
+
+1. Run `task test` to verify all tests pass
+2. Follow TDD: `task test-gen` creates stubs, implement to pass
+3. The dual-constructor pattern is intentional: `New(deps)` for tests, `Register()` for runtime
+4. See `cmd/core-gui/main.go` for how services wire together
+5. IPC handlers in each service's `HandleIPCEvents()` are the frontend bridge
diff --git a/pkg/config/config.go b/pkg/config/config.go
index eba202e..a34b52b 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -13,6 +13,20 @@ import (
"github.com/adrg/xdg"
)
+// HandleIPCEvents processes IPC messages for the config service.
+func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
+ switch msg.(type) {
+ case core.ActionServiceStartup:
+ // Config initializes during Register(), no additional startup needed.
+ return nil
+ default:
+ if c.App != nil && c.App.Logger != nil {
+ c.App.Logger.Debug("Config: Unhandled message type", "type", fmt.Sprintf("%T", msg))
+ }
+ }
+ return nil
+}
+
const appName = "lethean"
const configFileName = "config.json"
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 6ca52f2..3fe6d92 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -129,4 +129,39 @@ func TestConfigService(t *testing.T) {
t.Errorf("Expected value '%s', got '%s'", expectedValue, actualValue)
}
})
+
+ t.Run("HandleIPCEvents with ActionServiceStartup", func(t *testing.T) {
+ _, cleanup := setupTestEnv(t)
+ defer cleanup()
+
+ c := newTestCore(t)
+ serviceAny, err := Register(c)
+ if err != nil {
+ t.Fatalf("Register() failed: %v", err)
+ }
+
+ s := serviceAny.(*Service)
+ err = s.HandleIPCEvents(c, core.ActionServiceStartup{})
+ if err != nil {
+ t.Errorf("HandleIPCEvents(ActionServiceStartup) should not error, got: %v", err)
+ }
+ })
+
+ t.Run("HandleIPCEvents with unknown message type", func(t *testing.T) {
+ _, cleanup := setupTestEnv(t)
+ defer cleanup()
+
+ c := newTestCore(t)
+ serviceAny, err := Register(c)
+ if err != nil {
+ t.Fatalf("Register() failed: %v", err)
+ }
+
+ s := serviceAny.(*Service)
+ // Pass an arbitrary type as unknown message
+ err = s.HandleIPCEvents(c, "unknown message")
+ if err != nil {
+ t.Errorf("HandleIPCEvents(unknown) should not error, got: %v", err)
+ }
+ })
}
diff --git a/pkg/core/docs/site/404.html b/pkg/core/docs/site/404.html
new file mode 100644
index 0000000..e0fae56
--- /dev/null
+++ b/pkg/core/docs/site/404.html
@@ -0,0 +1,707 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 404 - Not found
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/assets/external/fonts.googleapis.com/css.49ea35f2.css b/pkg/core/docs/site/assets/external/fonts.googleapis.com/css.49ea35f2.css
new file mode 100644
index 0000000..d5c0c14
--- /dev/null
+++ b/pkg/core/docs/site/assets/external/fonts.googleapis.com/css.49ea35f2.css
@@ -0,0 +1,756 @@
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* math */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* math */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* math */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto';
+ font-style: italic;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* math */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 300;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* math */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 400;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2) format('woff2');
+ unicode-range: U+1F00-1FFF;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* math */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2) format('woff2');
+ unicode-range: U+0302-0303, U+0305, U+0307-0308, U+0310, U+0312, U+0315, U+031A, U+0326-0327, U+032C, U+032F-0330, U+0332-0333, U+0338, U+033A, U+0346, U+034D, U+0391-03A1, U+03A3-03A9, U+03B1-03C9, U+03D1, U+03D5-03D6, U+03F0-03F1, U+03F4-03F5, U+2016-2017, U+2034-2038, U+203C, U+2040, U+2043, U+2047, U+2050, U+2057, U+205F, U+2070-2071, U+2074-208E, U+2090-209C, U+20D0-20DC, U+20E1, U+20E5-20EF, U+2100-2112, U+2114-2115, U+2117-2121, U+2123-214F, U+2190, U+2192, U+2194-21AE, U+21B0-21E5, U+21F1-21F2, U+21F4-2211, U+2213-2214, U+2216-22FF, U+2308-230B, U+2310, U+2319, U+231C-2321, U+2336-237A, U+237C, U+2395, U+239B-23B7, U+23D0, U+23DC-23E1, U+2474-2475, U+25AF, U+25B3, U+25B7, U+25BD, U+25C1, U+25CA, U+25CC, U+25FB, U+266D-266F, U+27C0-27FF, U+2900-2AFF, U+2B0E-2B11, U+2B30-2B4C, U+2BFE, U+3030, U+FF5B, U+FF5D, U+1D400-1D7FF, U+1EE00-1EEFF;
+}
+/* symbols */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2) format('woff2');
+ unicode-range: U+0001-000C, U+000E-001F, U+007F-009F, U+20DD-20E0, U+20E2-20E4, U+2150-218F, U+2190, U+2192, U+2194-2199, U+21AF, U+21E6-21F0, U+21F3, U+2218-2219, U+2299, U+22C4-22C6, U+2300-243F, U+2440-244A, U+2460-24FF, U+25A0-27BF, U+2800-28FF, U+2921-2922, U+2981, U+29BF, U+29EB, U+2B00-2BFF, U+4DC0-4DFF, U+FFF9-FFFB, U+10140-1018E, U+10190-1019C, U+101A0, U+101D0-101FD, U+102E0-102FB, U+10E60-10E7E, U+1D2C0-1D2D3, U+1D2E0-1D37F, U+1F000-1F0FF, U+1F100-1F1AD, U+1F1E6-1F1FF, U+1F30D-1F30F, U+1F315, U+1F31C, U+1F31E, U+1F320-1F32C, U+1F336, U+1F378, U+1F37D, U+1F382, U+1F393-1F39F, U+1F3A7-1F3A8, U+1F3AC-1F3AF, U+1F3C2, U+1F3C4-1F3C6, U+1F3CA-1F3CE, U+1F3D4-1F3E0, U+1F3ED, U+1F3F1-1F3F3, U+1F3F5-1F3F7, U+1F408, U+1F415, U+1F41F, U+1F426, U+1F43F, U+1F441-1F442, U+1F444, U+1F446-1F449, U+1F44C-1F44E, U+1F453, U+1F46A, U+1F47D, U+1F4A3, U+1F4B0, U+1F4B3, U+1F4B9, U+1F4BB, U+1F4BF, U+1F4C8-1F4CB, U+1F4D6, U+1F4DA, U+1F4DF, U+1F4E3-1F4E6, U+1F4EA-1F4ED, U+1F4F7, U+1F4F9-1F4FB, U+1F4FD-1F4FE, U+1F503, U+1F507-1F50B, U+1F50D, U+1F512-1F513, U+1F53E-1F54A, U+1F54F-1F5FA, U+1F610, U+1F650-1F67F, U+1F687, U+1F68D, U+1F691, U+1F694, U+1F698, U+1F6AD, U+1F6B2, U+1F6B9-1F6BA, U+1F6BC, U+1F6C6-1F6CF, U+1F6D3-1F6D7, U+1F6E0-1F6EA, U+1F6F0-1F6F3, U+1F6F7-1F6FC, U+1F700-1F7FF, U+1F800-1F80B, U+1F810-1F847, U+1F850-1F859, U+1F860-1F887, U+1F890-1F8AD, U+1F8B0-1F8BB, U+1F8C0-1F8C1, U+1F900-1F90B, U+1F93B, U+1F946, U+1F984, U+1F996, U+1F9E9, U+1FA00-1FA6F, U+1FA70-1FA7C, U+1FA80-1FA89, U+1FA8F-1FAC6, U+1FACE-1FADC, U+1FADF-1FAE9, U+1FAF0-1FAF8, U+1FB00-1FBFF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto';
+ font-style: normal;
+ font-weight: 700;
+ font-stretch: 100%;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: italic;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 400;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2) format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* greek */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2) format('woff2');
+ unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2) format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: fallback;
+ src: url(../fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2
new file mode 100644
index 0000000..ab38fd5
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2
new file mode 100644
index 0000000..db65849
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2
new file mode 100644
index 0000000..7c9cbed
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2
new file mode 100644
index 0000000..e0aa393
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2
new file mode 100644
index 0000000..b677130
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2
new file mode 100644
index 0000000..669ba79
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2
new file mode 100644
index 0000000..6cc1de8
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2
new file mode 100644
index 0000000..ded8a41
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2
new file mode 100644
index 0000000..dbac481
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2
new file mode 100644
index 0000000..8e0eec6
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2
new file mode 100644
index 0000000..0ddf16c
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2
new file mode 100644
index 0000000..7bd3c2e
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2
new file mode 100644
index 0000000..8e43aa4
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2
new file mode 100644
index 0000000..2c6ba19
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2
new file mode 100644
index 0000000..2f8b493
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2
new file mode 100644
index 0000000..7c16c79
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2
new file mode 100644
index 0000000..c2788c7
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2
new file mode 100644
index 0000000..528b3bf
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/roboto/v49/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2
new file mode 100644
index 0000000..2c06834
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhEq3-OXg.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2
new file mode 100644
index 0000000..532a888
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhFq3-OXg.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2
new file mode 100644
index 0000000..b02e2d6
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhGq3-OXg.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2
new file mode 100644
index 0000000..ae2f9eb
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhIq3-OXg.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2
new file mode 100644
index 0000000..bfa169c
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhLq38.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2
new file mode 100644
index 0000000..8a15f5c
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIjhPq3-OXg.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2
new file mode 100644
index 0000000..d1ee097
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm32WWg.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2
new file mode 100644
index 0000000..c8e6ed4
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm36WWoKC.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2
new file mode 100644
index 0000000..1debc1b
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3CWWoKC.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2
new file mode 100644
index 0000000..43f7516
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3KWWoKC.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2
new file mode 100644
index 0000000..227f362
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3OWWoKC.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2 b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2
new file mode 100644
index 0000000..10a65a7
Binary files /dev/null and b/pkg/core/docs/site/assets/external/fonts.gstatic.com/s/robotomono/v31/L0x7DF4xlVMF-BfR8bXMIjhOm3mWWoKC.woff2 differ
diff --git a/pkg/core/docs/site/assets/external/unpkg.com/iframe-worker/shim.js b/pkg/core/docs/site/assets/external/unpkg.com/iframe-worker/shim.js
new file mode 100644
index 0000000..5f1e232
--- /dev/null
+++ b/pkg/core/docs/site/assets/external/unpkg.com/iframe-worker/shim.js
@@ -0,0 +1 @@
+"use strict";(()=>{function c(s,n){parent.postMessage(s,n||"*")}function d(...s){return s.reduce((n,e)=>n.then(()=>new Promise(r=>{let t=document.createElement("script");t.src=e,t.onload=r,document.body.appendChild(t)})),Promise.resolve())}var o=class extends EventTarget{constructor(e){super();this.url=e;this.m=e=>{e.source===this.w&&(this.dispatchEvent(new MessageEvent("message",{data:e.data})),this.onmessage&&this.onmessage(e))};this.e=(e,r,t,i,m)=>{if(r===`${this.url}`){let a=new ErrorEvent("error",{message:e,filename:r,lineno:t,colno:i,error:m});this.dispatchEvent(a),this.onerror&&this.onerror(a)}};let r=document.createElement("iframe");r.hidden=!0,document.body.appendChild(this.iframe=r),this.w.document.open(),this.w.document.write(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Core.Config
+Short: App config and UI state persistence.
+Overview
+Stores and retrieves configuration, including window positions/sizes and user prefs.
+Setup
+package main
+
+import (
+ core "github.com/Snider/Core"
+ config "github.com/Snider/Core/config"
+)
+
+app := core . New (
+ core . WithService ( config . Register ),
+ core . WithServiceLock (),
+)
+
+Use
+
+Persist UI state automatically when using Core.Display.
+Read/write your own settings via the config API.
+
+API
+
+Register(c *core.Core) error
+Get(path string, out any) error
+Set(path string, v any) error
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/core/crypt.html b/pkg/core/docs/site/core/crypt.html
new file mode 100644
index 0000000..7c4fbfe
--- /dev/null
+++ b/pkg/core/docs/site/core/crypt.html
@@ -0,0 +1,934 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core.Crypt - Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Core.Crypt
+Short: Keys, encrypt/decrypt, sign/verify.
+Overview
+Simple wrappers around OpenPGP for common crypto tasks.
+Setup
+import (
+ core "github.com/Snider/Core"
+ crypt "github.com/Snider/Core/crypt"
+)
+
+app := core . New (
+ core . WithService ( crypt . Register ),
+ core . WithServiceLock (),
+)
+
+Use
+
+Generate keys
+Encrypt/decrypt data
+Sign/verify messages
+
+API
+
+Register(c *core.Core) error
+GenerateKey(opts ...Option) (*Key, error)
+Encrypt(pub *Key, data []byte) ([]byte, error)
+Decrypt(priv *Key, data []byte) ([]byte, error)
+Sign(priv *Key, data []byte) ([]byte, error)
+Verify(pub *Key, data, sig []byte) error
+
+Notes
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/core/display.html b/pkg/core/docs/site/core/display.html
new file mode 100644
index 0000000..85d104c
--- /dev/null
+++ b/pkg/core/docs/site/core/display.html
@@ -0,0 +1,936 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core.Display - Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Core.Display
+Short: Windows, tray, and window state.
+Overview
+Manages Wails windows, remembers positions/sizes, exposes JS bindings, and integrates with Core.Config for persistence.
+Setup
+import (
+ core "github.com/Snider/Core"
+ display "github.com/Snider/Core/display"
+)
+
+app := core . New (
+ core . WithService ( display . Register ),
+ core . WithServiceLock (),
+)
+
+Use
+
+Open a window: OpenWindow(OptName("main"), ...)
+Get a window: Window("main")
+Save/restore state automatically when Core.Config is present
+
+API
+
+Register(c *core.Core) error
+OpenWindow(opts ...Option) *Window
+Window(name string) *Window
+Options: OptName, OptWidth, OptHeight, OptURL, OptTitle
+
+Example
+func ( d * API ) ServiceStartup ( ctx context . Context , _ application . ServiceOptions ) error {
+ d . OpenWindow (
+ OptName ( "main" ), OptWidth ( 1280 ), OptHeight ( 900 ), OptURL ( "/" ), OptTitle ( "Core" ),
+ )
+ return nil
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/core/docs.html b/pkg/core/docs/site/core/docs.html
new file mode 100644
index 0000000..dc90d1f
--- /dev/null
+++ b/pkg/core/docs/site/core/docs.html
@@ -0,0 +1,932 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core.Docs - Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Core.Docs
+Short: In‑app help and deep‑links.
+Overview
+Renders MkDocs content inside your app. Opens specific sections in new windows for contextual help.
+Setup
+import (
+ core "github.com/Snider/Core"
+ docs "github.com/Snider/Core/docs"
+)
+
+app := core . New (
+ core . WithService ( docs . Register ),
+ core . WithServiceLock (),
+)
+
+Use
+
+Open docs home in a window: docs.Open()
+Open a section: docs.OpenAt("core/display#setup")
+Use short, descriptive headings to create stable anchors.
+
+API
+
+Register(c *core.Core) error
+Open() — show docs home
+OpenAt(anchor string) — open specific section
+
+Notes
+
+Docs are built with MkDocs Material and included in the demo app assets.
+You are viewing Core.Docs right now, this Website is bundled into the app binary by default.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/core/index.html b/pkg/core/docs/site/core/index.html
new file mode 100644
index 0000000..38c575d
--- /dev/null
+++ b/pkg/core/docs/site/core/index.html
@@ -0,0 +1,901 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core - Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Core
+Short: Framework bootstrap and service container.
+What it is
+Core wires modules together, provides lifecycle hooks, and locks the service graph for clarity and safety.
+Setup
+import "github.com/Snider/Core"
+
+app := core . New (
+ core . WithServiceLock (),
+)
+
+Use
+
+Register a module: core.RegisterModule(name, module)
+Access a module: core.Mod[T](c, name)
+Lock services: core.WithServiceLock()
+
+API
+
+New(opts ...) *core.Core
+RegisterModule(name string, m any) error
+Mod[T any](c *core.Core, name ...string) *T
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/core/io.html b/pkg/core/docs/site/core/io.html
new file mode 100644
index 0000000..4485a50
--- /dev/null
+++ b/pkg/core/docs/site/core/io.html
@@ -0,0 +1,932 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core.IO - Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Core.IO
+Short: Local/remote filesystem helpers.
+Overview
+Abstracts filesystems (local, SFTP, WebDAV) behind a unified API for reading/writing and listing.
+Setup
+import (
+ core "github.com/Snider/Core"
+ ioapi "github.com/Snider/Core/filesystem"
+)
+
+app := core . New (
+ core . WithService ( ioapi . Register ),
+ core . WithServiceLock (),
+)
+
+Use
+
+Open a filesystem: fs := ioapi.Local() or ioapi.SFTP(cfg)
+Read/write files: fs.Read(path), fs.Write(path, data)
+List directories: fs.List(path)
+
+API
+
+Register(c *core.Core) error
+Local() FS
+SFTP(cfg Config) (FS, error)
+WebDAV(cfg Config) (FS, error)
+
+Notes
+
+See package pkg/v1/core/filesystem/* for drivers.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/core/workspace.html b/pkg/core/docs/site/core/workspace.html
new file mode 100644
index 0000000..72bbc03
--- /dev/null
+++ b/pkg/core/docs/site/core/workspace.html
@@ -0,0 +1,930 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core.Workspace - Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Core.Workspace
+Short: Projects and paths.
+Overview
+Provides a consistent way to resolve app/project directories, temp/cache locations, and user data paths across platforms.
+Setup
+import (
+ core "github.com/Snider/Core"
+ workspace "github.com/Snider/Core/workspace"
+)
+
+app := core . New (
+ core . WithService ( workspace . Register ),
+ core . WithServiceLock (),
+)
+
+Use
+
+Get app data dir: ws.DataDir()
+Get cache dir: ws.CacheDir()
+Resolve project path: ws.Project("my-app")
+
+API
+
+Register(c *core.Core) error
+DataDir() string
+CacheDir() string
+Project(name string) string
+
+Notes
+
+Follows OS directory standards (AppData, ~/Library, XDG, etc.).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/images/cross-platform.jpeg b/pkg/core/docs/site/images/cross-platform.jpeg
new file mode 100644
index 0000000..8de2288
Binary files /dev/null and b/pkg/core/docs/site/images/cross-platform.jpeg differ
diff --git a/pkg/core/docs/site/images/decentralised-vpn.jpg b/pkg/core/docs/site/images/decentralised-vpn.jpg
new file mode 100644
index 0000000..df1f487
Binary files /dev/null and b/pkg/core/docs/site/images/decentralised-vpn.jpg differ
diff --git a/pkg/core/docs/site/images/favicon.ico b/pkg/core/docs/site/images/favicon.ico
new file mode 100644
index 0000000..8bc8ebb
Binary files /dev/null and b/pkg/core/docs/site/images/favicon.ico differ
diff --git a/pkg/core/docs/site/images/illustration.png b/pkg/core/docs/site/images/illustration.png
new file mode 100644
index 0000000..69f739c
Binary files /dev/null and b/pkg/core/docs/site/images/illustration.png differ
diff --git a/pkg/core/docs/site/images/lethean-logo.png b/pkg/core/docs/site/images/lethean-logo.png
new file mode 100644
index 0000000..591019d
Binary files /dev/null and b/pkg/core/docs/site/images/lethean-logo.png differ
diff --git a/pkg/core/docs/site/images/private-transaction-net.png b/pkg/core/docs/site/images/private-transaction-net.png
new file mode 100644
index 0000000..1eee17a
Binary files /dev/null and b/pkg/core/docs/site/images/private-transaction-net.png differ
diff --git a/pkg/core/docs/site/images/secure-data-storage.jpg b/pkg/core/docs/site/images/secure-data-storage.jpg
new file mode 100644
index 0000000..395a8ae
Binary files /dev/null and b/pkg/core/docs/site/images/secure-data-storage.jpg differ
diff --git a/pkg/core/docs/site/index.html b/pkg/core/docs/site/index.html
new file mode 100644
index 0000000..a956691
--- /dev/null
+++ b/pkg/core/docs/site/index.html
@@ -0,0 +1,939 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Core.Help - Core.Help
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Overview
+Core is an opinionated framework for building Go desktop apps with Wails, providing a small set of focused modules you can mix into your app. It ships with sensible defaults and a demo app that doubles as in‑app help.
+
+Site: https://dappco.re
+Repo: https://github.com/Snider/Core
+
+Modules
+
+Core — framework bootstrap and service container
+Core.Config — app and UI state persistence
+Core.Crypt — keys, encrypt/decrypt, sign/verify
+Core.Display — windows, tray, window state
+Core.Docs — in‑app help and deep‑links
+Core.IO — local/remote filesystem helpers
+Core.Workspace — projects and paths
+
+Quick start
+package main
+
+import (
+ core "github.com/Snider/Core"
+)
+
+func main () {
+ app := core . New (
+ core . WithServiceLock (),
+ )
+ _ = app // start via Wails in your main package
+}
+
+Services
+package demo
+
+import (
+ core "github.com/Snider/Core"
+)
+
+// Register your service
+func Register ( c * core . Core ) error {
+ return c . RegisterModule ( "demo" , & Demo { core : c })
+}
+
+Display example
+package display
+
+import (
+ "context"
+ "github.com/wailsapp/wails/v3/pkg/application"
+)
+
+// Open a window on startup
+func ( d * API ) ServiceStartup ( ctx context . Context , _ application . ServiceOptions ) error {
+ d . OpenWindow (
+ OptName ( "main" ),
+ OptHeight ( 900 ),
+ OptWidth ( 1280 ),
+ OptURL ( "/" ),
+ OptTitle ( "Core" ),
+ )
+ return nil
+}
+
+See the left nav for detailed pages on each module.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to top
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/search/search_index.js b/pkg/core/docs/site/search/search_index.js
new file mode 100644
index 0000000..193f050
--- /dev/null
+++ b/pkg/core/docs/site/search/search_index.js
@@ -0,0 +1 @@
+var __index = {"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"index.html","title":"Overview","text":"Core is an opinionated framework for building Go desktop apps with Wails, providing a small set of focused modules you can mix into your app. It ships with sensible defaults and a demo app that doubles as in\u2011app help.
Site: https://dappco.re Repo: https://github.com/Snider/Core "},{"location":"index.html#modules","title":"Modules","text":" Core \u2014 framework bootstrap and service container Core.Config \u2014 app and UI state persistence Core.Crypt \u2014 keys, encrypt/decrypt, sign/verify Core.Display \u2014 windows, tray, window state Core.Docs \u2014 in\u2011app help and deep\u2011links Core.IO \u2014 local/remote filesystem helpers Core.Workspace \u2014 projects and paths "},{"location":"index.html#quick-start","title":"Quick start","text":"package main\nimport (\ncore \"github.com/Snider/Core\"\n)\nfunc main() {\napp := core.New(\ncore.WithServiceLock(),\n)\n_ = app // start via Wails in your main package\n}\n "},{"location":"index.html#services","title":"Services","text":"package demo\nimport (\ncore \"github.com/Snider/Core\"\n)\n// Register your service\nfunc Register(c *core.Core) error {\nreturn c.RegisterModule(\"demo\", &Demo{core: c})\n}\n "},{"location":"index.html#display-example","title":"Display example","text":"package display\nimport (\n\"context\"\n\"github.com/wailsapp/wails/v3/pkg/application\"\n)\n// Open a window on startup\nfunc (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"),\nOptHeight(900),\nOptWidth(1280),\nOptURL(\"/\"),\nOptTitle(\"Core\"),\n)\nreturn nil\n}\n See the left nav for detailed pages on each module.
"},{"location":"core/index.html","title":"Core","text":"Short: Framework bootstrap and service container.
"},{"location":"core/index.html#what-it-is","title":"What it is","text":"Core wires modules together, provides lifecycle hooks, and locks the service graph for clarity and safety.
"},{"location":"core/index.html#setup","title":"Setup","text":"import \"github.com/Snider/Core\"\napp := core.New(\ncore.WithServiceLock(),\n)\n "},{"location":"core/index.html#use","title":"Use","text":" Register a module: core.RegisterModule(name, module) Access a module: core.Mod[T](c, name) Lock services: core.WithServiceLock() "},{"location":"core/index.html#api","title":"API","text":" New(opts ...) *core.Core RegisterModule(name string, m any) error Mod[T any](c *core.Core, name ...string) *T "},{"location":"core/config.html","title":"Core.Config","text":"Short: App config and UI state persistence.
"},{"location":"core/config.html#overview","title":"Overview","text":"Stores and retrieves configuration, including window positions/sizes and user prefs.
"},{"location":"core/config.html#setup","title":"Setup","text":"package main\nimport (\ncore \"github.com/Snider/Core\"\nconfig \"github.com/Snider/Core/config\"\n)\napp := core.New(\ncore.WithService(config.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/config.html#use","title":"Use","text":" Persist UI state automatically when using Core.Display. Read/write your own settings via the config API. "},{"location":"core/config.html#api","title":"API","text":" Register(c *core.Core) error Get(path string, out any) error Set(path string, v any) error "},{"location":"core/crypt.html","title":"Core.Crypt","text":"Short: Keys, encrypt/decrypt, sign/verify.
"},{"location":"core/crypt.html#overview","title":"Overview","text":"Simple wrappers around OpenPGP for common crypto tasks.
"},{"location":"core/crypt.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\ncrypt \"github.com/Snider/Core/crypt\"\n)\napp := core.New(\ncore.WithService(crypt.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/crypt.html#use","title":"Use","text":" Generate keys Encrypt/decrypt data Sign/verify messages "},{"location":"core/crypt.html#api","title":"API","text":" Register(c *core.Core) error GenerateKey(opts ...Option) (*Key, error) Encrypt(pub *Key, data []byte) ([]byte, error) Decrypt(priv *Key, data []byte) ([]byte, error) Sign(priv *Key, data []byte) ([]byte, error) Verify(pub *Key, data, sig []byte) error "},{"location":"core/crypt.html#notes","title":"Notes","text":" Uses ProtonMail OpenPGP fork. "},{"location":"core/display.html","title":"Core.Display","text":"Short: Windows, tray, and window state.
"},{"location":"core/display.html#overview","title":"Overview","text":"Manages Wails windows, remembers positions/sizes, exposes JS bindings, and integrates with Core.Config for persistence.
"},{"location":"core/display.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\ndisplay \"github.com/Snider/Core/display\"\n)\napp := core.New(\ncore.WithService(display.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/display.html#use","title":"Use","text":" Open a window: OpenWindow(OptName(\"main\"), ...) Get a window: Window(\"main\") Save/restore state automatically when Core.Config is present "},{"location":"core/display.html#api","title":"API","text":" Register(c *core.Core) error OpenWindow(opts ...Option) *Window Window(name string) *Window Options: OptName, OptWidth, OptHeight, OptURL, OptTitle "},{"location":"core/display.html#example","title":"Example","text":"func (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"), OptWidth(1280), OptHeight(900), OptURL(\"/\"), OptTitle(\"Core\"),\n)\nreturn nil\n}\n "},{"location":"core/docs.html","title":"Core.Docs","text":"Short: In\u2011app help and deep\u2011links.
"},{"location":"core/docs.html#overview","title":"Overview","text":"Renders MkDocs content inside your app. Opens specific sections in new windows for contextual help.
"},{"location":"core/docs.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\ndocs \"github.com/Snider/Core/docs\"\n)\napp := core.New(\ncore.WithService(docs.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/docs.html#use","title":"Use","text":" Open docs home in a window: docs.Open() Open a section: docs.OpenAt(\"core/display#setup\") Use short, descriptive headings to create stable anchors. "},{"location":"core/docs.html#api","title":"API","text":" Register(c *core.Core) error Open() \u2014 show docs home OpenAt(anchor string) \u2014 open specific section "},{"location":"core/docs.html#notes","title":"Notes","text":" Docs are built with MkDocs Material and included in the demo app assets. You are viewing Core.Docs right now, this Website is bundled into the app binary by default. "},{"location":"core/io.html","title":"Core.IO","text":"Short: Local/remote filesystem helpers.
"},{"location":"core/io.html#overview","title":"Overview","text":"Abstracts filesystems (local, SFTP, WebDAV) behind a unified API for reading/writing and listing.
"},{"location":"core/io.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\nioapi \"github.com/Snider/Core/filesystem\"\n)\napp := core.New(\ncore.WithService(ioapi.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/io.html#use","title":"Use","text":" Open a filesystem: fs := ioapi.Local() or ioapi.SFTP(cfg) Read/write files: fs.Read(path), fs.Write(path, data) List directories: fs.List(path) "},{"location":"core/io.html#api","title":"API","text":" Register(c *core.Core) error Local() FS SFTP(cfg Config) (FS, error) WebDAV(cfg Config) (FS, error) "},{"location":"core/io.html#notes","title":"Notes","text":" See package pkg/v1/core/filesystem/* for drivers. "},{"location":"core/workspace.html","title":"Core.Workspace","text":"Short: Projects and paths.
"},{"location":"core/workspace.html#overview","title":"Overview","text":"Provides a consistent way to resolve app/project directories, temp/cache locations, and user data paths across platforms.
"},{"location":"core/workspace.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\nworkspace \"github.com/Snider/Core/workspace\"\n)\napp := core.New(\ncore.WithService(workspace.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/workspace.html#use","title":"Use","text":" Get app data dir: ws.DataDir() Get cache dir: ws.CacheDir() Resolve project path: ws.Project(\"my-app\") "},{"location":"core/workspace.html#api","title":"API","text":" Register(c *core.Core) error DataDir() string CacheDir() string Project(name string) string "},{"location":"core/workspace.html#notes","title":"Notes","text":" Follows OS directory standards (AppData, ~/Library, XDG, etc.). "}]}
\ No newline at end of file
diff --git a/pkg/core/docs/site/search/search_index.json b/pkg/core/docs/site/search/search_index.json
new file mode 100644
index 0000000..323cc07
--- /dev/null
+++ b/pkg/core/docs/site/search/search_index.json
@@ -0,0 +1 @@
+{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"index.html","title":"Overview","text":"Core is an opinionated framework for building Go desktop apps with Wails, providing a small set of focused modules you can mix into your app. It ships with sensible defaults and a demo app that doubles as in\u2011app help.
Site: https://dappco.re Repo: https://github.com/Snider/Core "},{"location":"index.html#modules","title":"Modules","text":" Core \u2014 framework bootstrap and service container Core.Config \u2014 app and UI state persistence Core.Crypt \u2014 keys, encrypt/decrypt, sign/verify Core.Display \u2014 windows, tray, window state Core.Docs \u2014 in\u2011app help and deep\u2011links Core.IO \u2014 local/remote filesystem helpers Core.Workspace \u2014 projects and paths "},{"location":"index.html#quick-start","title":"Quick start","text":"package main\nimport (\ncore \"github.com/Snider/Core\"\n)\nfunc main() {\napp := core.New(\ncore.WithServiceLock(),\n)\n_ = app // start via Wails in your main package\n}\n "},{"location":"index.html#services","title":"Services","text":"package demo\nimport (\ncore \"github.com/Snider/Core\"\n)\n// Register your service\nfunc Register(c *core.Core) error {\nreturn c.RegisterModule(\"demo\", &Demo{core: c})\n}\n "},{"location":"index.html#display-example","title":"Display example","text":"package display\nimport (\n\"context\"\n\"github.com/wailsapp/wails/v3/pkg/application\"\n)\n// Open a window on startup\nfunc (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"),\nOptHeight(900),\nOptWidth(1280),\nOptURL(\"/\"),\nOptTitle(\"Core\"),\n)\nreturn nil\n}\n See the left nav for detailed pages on each module.
"},{"location":"core/index.html","title":"Core","text":"Short: Framework bootstrap and service container.
"},{"location":"core/index.html#what-it-is","title":"What it is","text":"Core wires modules together, provides lifecycle hooks, and locks the service graph for clarity and safety.
"},{"location":"core/index.html#setup","title":"Setup","text":"import \"github.com/Snider/Core\"\napp := core.New(\ncore.WithServiceLock(),\n)\n "},{"location":"core/index.html#use","title":"Use","text":" Register a module: core.RegisterModule(name, module) Access a module: core.Mod[T](c, name) Lock services: core.WithServiceLock() "},{"location":"core/index.html#api","title":"API","text":" New(opts ...) *core.Core RegisterModule(name string, m any) error Mod[T any](c *core.Core, name ...string) *T "},{"location":"core/config.html","title":"Core.Config","text":"Short: App config and UI state persistence.
"},{"location":"core/config.html#overview","title":"Overview","text":"Stores and retrieves configuration, including window positions/sizes and user prefs.
"},{"location":"core/config.html#setup","title":"Setup","text":"package main\nimport (\ncore \"github.com/Snider/Core\"\nconfig \"github.com/Snider/Core/config\"\n)\napp := core.New(\ncore.WithService(config.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/config.html#use","title":"Use","text":" Persist UI state automatically when using Core.Display. Read/write your own settings via the config API. "},{"location":"core/config.html#api","title":"API","text":" Register(c *core.Core) error Get(path string, out any) error Set(path string, v any) error "},{"location":"core/crypt.html","title":"Core.Crypt","text":"Short: Keys, encrypt/decrypt, sign/verify.
"},{"location":"core/crypt.html#overview","title":"Overview","text":"Simple wrappers around OpenPGP for common crypto tasks.
"},{"location":"core/crypt.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\ncrypt \"github.com/Snider/Core/crypt\"\n)\napp := core.New(\ncore.WithService(crypt.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/crypt.html#use","title":"Use","text":" Generate keys Encrypt/decrypt data Sign/verify messages "},{"location":"core/crypt.html#api","title":"API","text":" Register(c *core.Core) error GenerateKey(opts ...Option) (*Key, error) Encrypt(pub *Key, data []byte) ([]byte, error) Decrypt(priv *Key, data []byte) ([]byte, error) Sign(priv *Key, data []byte) ([]byte, error) Verify(pub *Key, data, sig []byte) error "},{"location":"core/crypt.html#notes","title":"Notes","text":" Uses ProtonMail OpenPGP fork. "},{"location":"core/display.html","title":"Core.Display","text":"Short: Windows, tray, and window state.
"},{"location":"core/display.html#overview","title":"Overview","text":"Manages Wails windows, remembers positions/sizes, exposes JS bindings, and integrates with Core.Config for persistence.
"},{"location":"core/display.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\ndisplay \"github.com/Snider/Core/display\"\n)\napp := core.New(\ncore.WithService(display.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/display.html#use","title":"Use","text":" Open a window: OpenWindow(OptName(\"main\"), ...) Get a window: Window(\"main\") Save/restore state automatically when Core.Config is present "},{"location":"core/display.html#api","title":"API","text":" Register(c *core.Core) error OpenWindow(opts ...Option) *Window Window(name string) *Window Options: OptName, OptWidth, OptHeight, OptURL, OptTitle "},{"location":"core/display.html#example","title":"Example","text":"func (d *API) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {\nd.OpenWindow(\nOptName(\"main\"), OptWidth(1280), OptHeight(900), OptURL(\"/\"), OptTitle(\"Core\"),\n)\nreturn nil\n}\n "},{"location":"core/docs.html","title":"Core.Docs","text":"Short: In\u2011app help and deep\u2011links.
"},{"location":"core/docs.html#overview","title":"Overview","text":"Renders MkDocs content inside your app. Opens specific sections in new windows for contextual help.
"},{"location":"core/docs.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\ndocs \"github.com/Snider/Core/docs\"\n)\napp := core.New(\ncore.WithService(docs.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/docs.html#use","title":"Use","text":" Open docs home in a window: docs.Open() Open a section: docs.OpenAt(\"core/display#setup\") Use short, descriptive headings to create stable anchors. "},{"location":"core/docs.html#api","title":"API","text":" Register(c *core.Core) error Open() \u2014 show docs home OpenAt(anchor string) \u2014 open specific section "},{"location":"core/docs.html#notes","title":"Notes","text":" Docs are built with MkDocs Material and included in the demo app assets. You are viewing Core.Docs right now, this Website is bundled into the app binary by default. "},{"location":"core/io.html","title":"Core.IO","text":"Short: Local/remote filesystem helpers.
"},{"location":"core/io.html#overview","title":"Overview","text":"Abstracts filesystems (local, SFTP, WebDAV) behind a unified API for reading/writing and listing.
"},{"location":"core/io.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\nioapi \"github.com/Snider/Core/filesystem\"\n)\napp := core.New(\ncore.WithService(ioapi.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/io.html#use","title":"Use","text":" Open a filesystem: fs := ioapi.Local() or ioapi.SFTP(cfg) Read/write files: fs.Read(path), fs.Write(path, data) List directories: fs.List(path) "},{"location":"core/io.html#api","title":"API","text":" Register(c *core.Core) error Local() FS SFTP(cfg Config) (FS, error) WebDAV(cfg Config) (FS, error) "},{"location":"core/io.html#notes","title":"Notes","text":" See package pkg/v1/core/filesystem/* for drivers. "},{"location":"core/workspace.html","title":"Core.Workspace","text":"Short: Projects and paths.
"},{"location":"core/workspace.html#overview","title":"Overview","text":"Provides a consistent way to resolve app/project directories, temp/cache locations, and user data paths across platforms.
"},{"location":"core/workspace.html#setup","title":"Setup","text":"import (\ncore \"github.com/Snider/Core\"\nworkspace \"github.com/Snider/Core/workspace\"\n)\napp := core.New(\ncore.WithService(workspace.Register),\ncore.WithServiceLock(),\n)\n "},{"location":"core/workspace.html#use","title":"Use","text":" Get app data dir: ws.DataDir() Get cache dir: ws.CacheDir() Resolve project path: ws.Project(\"my-app\") "},{"location":"core/workspace.html#api","title":"API","text":" Register(c *core.Core) error DataDir() string CacheDir() string Project(name string) string "},{"location":"core/workspace.html#notes","title":"Notes","text":" Follows OS directory standards (AppData, ~/Library, XDG, etc.). "}]}
\ No newline at end of file
diff --git a/pkg/core/docs/site/sitemap.xml b/pkg/core/docs/site/sitemap.xml
new file mode 100644
index 0000000..a063358
--- /dev/null
+++ b/pkg/core/docs/site/sitemap.xml
@@ -0,0 +1,35 @@
+
+
+
+ https://dappco.re/index.html
+ 2025-10-25
+
+
+ https://dappco.re/core/index.html
+ 2025-10-25
+
+
+ https://dappco.re/core/config.html
+ 2025-10-25
+
+
+ https://dappco.re/core/crypt.html
+ 2025-10-25
+
+
+ https://dappco.re/core/display.html
+ 2025-10-25
+
+
+ https://dappco.re/core/docs.html
+ 2025-10-25
+
+
+ https://dappco.re/core/io.html
+ 2025-10-25
+
+
+ https://dappco.re/core/workspace.html
+ 2025-10-25
+
+
\ No newline at end of file
diff --git a/pkg/core/docs/site/sitemap.xml.gz b/pkg/core/docs/site/sitemap.xml.gz
new file mode 100644
index 0000000..c4e06d9
Binary files /dev/null and b/pkg/core/docs/site/sitemap.xml.gz differ
diff --git a/pkg/core/docs/site/stylesheets/extra.css b/pkg/core/docs/site/stylesheets/extra.css
new file mode 100644
index 0000000..8a89327
--- /dev/null
+++ b/pkg/core/docs/site/stylesheets/extra.css
@@ -0,0 +1,367 @@
+[data-md-color-scheme="lethean"] {
+ --md-primary-fg-color: #0F131C;
+}
+
+.hero-section {
+ background: linear-gradient(135deg, #0F131C 0%, #1a237e 100%);
+ color: white;
+ padding: 4rem 2rem;
+ text-align: center;
+ margin-bottom: 3rem;
+}
+
+.hero-content {
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.hero-content h1 {
+ font-size: 2.5rem;
+ margin-bottom: 1rem;
+ color: white;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+
+.hero-subtitle {
+ font-size: 1.25rem;
+ margin-bottom: 2rem;
+ opacity: 0.9;
+}
+
+.hero-badges {
+ margin-bottom: 2rem;
+}
+
+.badge {
+ background: rgba(255, 255, 255, 0.1);
+ padding: 0.5rem 1rem;
+ border-radius: 20px;
+ margin: 0 0.5rem;
+ font-size: 0.9rem;
+}
+
+.cta-button {
+ display: inline-block;
+ background: #4A90E2;
+ color: white;
+ padding: 0.8rem 2rem;
+ border-radius: 4px;
+ text-decoration: none;
+ font-weight: 500;
+ transition: all 0.3s;
+}
+
+.cta-button:hover {
+ background: #357ABD;
+ color: white;
+ transform: translateY(-2px);
+}
+
+.cta-button.secondary {
+ background: transparent;
+ border: 2px solid #4A90E2;
+ color: #4A90E2;
+}
+
+.features-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+ gap: 0.2rem;
+ padding: 0.2rem;
+ margin-bottom: 3rem;
+}
+
+.feature-card {
+ background: white;
+ border-radius: 8px;
+ padding: 1.0rem;
+ border: 2px solid #e2e8f0;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ transition: all 0.3s;
+}
+
+[data-md-color-scheme="slate"] .feature-card {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #e2e8f0;
+}
+
+.feature-card:hover {
+ transform: translateY(-5px);
+ box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
+}
+
+.feature-card img {
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+.feature-card h3 {
+ margin: 1rem 0;
+ color: #0F131C;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+
+[data-md-color-scheme="slate"] .feature-card h3 {
+ color: #e2e8f0;
+}
+
+.get-started {
+ color: #4A90E2;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.benefits-section {
+ background: #f5f5f5;
+ padding: 0.4rem 0.2rem;
+ text-align: center;
+ margin-bottom: 3rem;
+}
+
+.benefits-section h2 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 0.5rem;
+ margin-top: 0.8rem;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
+}
+
+[data-md-color-scheme="slate"] .benefits-section {
+ background: #1a202c;
+ color: #e2e8f0;
+}
+
+.benefits-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 0.2rem;
+ padding: 0.2rem;
+ margin: 0.2rem auto;
+}
+
+.benefit-card {
+ background: white;
+ padding: 0.5rem;
+ border-radius: 8px;
+ border: 2px solid #e2e8f0;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ text-align: left;
+}
+
+[data-md-color-scheme="slate"] .benefit-card {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #e2e8f0;
+}
+
+.roadmap-section {
+ padding: 0.4rem 0.2rem;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.timeline {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 2rem;
+ margin: 2rem 0;
+}
+
+.timeline-item {
+ background: white;
+ padding: 1.5rem;
+ border-radius: 8px;
+ border: 2px solid #e2e8f0;
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
+ position: relative;
+ transition: all 0.3s;
+}
+
+.timeline-item.completed {
+ grid-column: span 2;
+}
+
+[data-md-color-scheme="slate"] .timeline-item {
+ background: #2d3748;
+ border-color: #4a5568;
+ color: #e2e8f0;
+}
+
+.timeline-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15);
+}
+
+.timeline-marker {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ position: absolute;
+ top: -10px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.timeline-item.planning .timeline-marker {
+ background: #718096;
+}
+
+.timeline-item.in-progress .timeline-marker {
+ background: #4A90E2;
+}
+
+.timeline-item.completed .timeline-marker {
+ background: #48BB78;
+}
+
+.timeline-item ul {
+ list-style: none;
+ padding: 0;
+}
+
+.timeline-item li {
+ margin: 0.5rem 0;
+ padding-left: 24px;
+ position: relative;
+}
+
+.timeline-item li::before {
+ content: "";
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.timeline-item li.planned::before {
+ background: #718096;
+}
+
+.timeline-item li.active::before {
+ background: #4A90E2;
+}
+
+.timeline-item li.completed::before {
+ background: #48BB78;
+}
+
+.timeline-item li ul {
+ margin-top: 0.5rem;
+ margin-left: 1rem;
+}
+
+.timeline-item li ul li {
+ font-size: 0.9rem;
+ margin: 0.25rem 0;
+}
+
+.timeline-item li ul li::before {
+ width: 8px;
+ height: 8px;
+ background: #a0aec0;
+}
+
+.timeline-item li ul li a {
+ color: #4A90E2;
+ text-decoration: none;
+ font-weight: 500;
+}
+
+.timeline-item li ul li a:hover {
+ color: #357ABD;
+ text-decoration: underline;
+}
+
+[data-md-color-scheme="slate"] .timeline-item li ul li a {
+ color: #63b3ed;
+}
+
+[data-md-color-scheme="slate"] .timeline-item li ul li a:hover {
+ color: #90cdf4;
+}
+
+.date {
+ font-size: 0.8rem;
+ color: #718096;
+ margin-left: 0.5rem;
+}
+
+[data-md-color-scheme="slate"] .date {
+ color: #a0aec0;
+}
+
+.cta-section {
+ background: #0F131C;
+ color: white;
+ padding: 4rem 2rem;
+ text-align: center;
+ margin-bottom: 3rem;
+}
+
+.cta-buttons {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ margin-top: 2rem;
+}
+
+.community-section {
+ padding: 4rem 2rem;
+ text-align: center;
+}
+
+.community-links {
+ display: flex;
+ gap: 2rem;
+ justify-content: center;
+ margin-top: 2rem;
+}
+
+.community-link {
+ color: #4A90E2;
+ text-decoration: none;
+ font-weight: 500;
+ transition: all 0.3s;
+}
+
+.community-link:hover {
+ color: #357ABD;
+ transform: translateY(-2px);
+}
+
+@media (max-width: 768px) {
+ .hero-content h1 {
+ font-size: 2rem;
+ }
+
+ .timeline {
+ grid-template-columns: 1fr;
+ }
+
+ .timeline-item.completed {
+ grid-column: auto;
+ }
+
+ .features-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .cta-buttons {
+ flex-direction: column;
+ }
+
+ .community-links {
+ flex-direction: column;
+ gap: 1rem;
+ }
+}
\ No newline at end of file
diff --git a/pkg/crypt/crypt.go b/pkg/crypt/crypt.go
index 4484ea9..fb25bdd 100644
--- a/pkg/crypt/crypt.go
+++ b/pkg/crypt/crypt.go
@@ -8,6 +8,7 @@ import (
"crypto/sha512"
"encoding/binary"
"encoding/hex"
+ "fmt"
"io"
"strconv"
"strings"
@@ -17,6 +18,20 @@ import (
"github.com/Snider/Core/pkg/crypt/openpgp"
)
+// HandleIPCEvents processes IPC messages for the crypt service.
+func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
+ switch msg.(type) {
+ case core.ActionServiceStartup:
+ // Crypt is stateless, no startup needed.
+ return nil
+ default:
+ if c.App != nil && c.App.Logger != nil {
+ c.App.Logger.Debug("Crypt: Unhandled message type", "type", fmt.Sprintf("%T", msg))
+ }
+ }
+ return nil
+}
+
// Options holds configuration for the crypt service.
type Options struct{}
diff --git a/pkg/crypt/crypt_test.go b/pkg/crypt/crypt_test.go
index 2cde507..3707f26 100644
--- a/pkg/crypt/crypt_test.go
+++ b/pkg/crypt/crypt_test.go
@@ -1,20 +1,366 @@
package crypt
import (
+ "bytes"
"testing"
+ "github.com/Snider/Core/pkg/core"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
-func TestHash(t *testing.T) {
- s := &Service{}
- payload := "hello"
- hash := s.Hash(LTHN, payload)
- assert.NotEmpty(t, hash)
+// --- Constructor Tests ---
+
+func TestNew(t *testing.T) {
+ t.Run("creates service successfully", func(t *testing.T) {
+ service, err := New()
+ assert.NoError(t, err)
+ assert.NotNil(t, service)
+ })
+
+ t.Run("returns independent instances", func(t *testing.T) {
+ service1, err1 := New()
+ service2, err2 := New()
+ assert.NoError(t, err1)
+ assert.NoError(t, err2)
+ assert.NotSame(t, service1, service2)
+ })
}
-func TestLuhn(t *testing.T) {
- s := &Service{}
- assert.True(t, s.Luhn("79927398713"))
- assert.False(t, s.Luhn("79927398714"))
+func TestRegister(t *testing.T) {
+ t.Run("registers with core successfully", func(t *testing.T) {
+ coreInstance, err := core.New()
+ require.NoError(t, err)
+
+ service, err := Register(coreInstance)
+ require.NoError(t, err)
+ assert.NotNil(t, service)
+ })
+
+ t.Run("returns Service type with Runtime", func(t *testing.T) {
+ coreInstance, err := core.New()
+ require.NoError(t, err)
+
+ service, err := Register(coreInstance)
+ require.NoError(t, err)
+
+ cryptService, ok := service.(*Service)
+ assert.True(t, ok)
+ assert.NotNil(t, cryptService.Runtime)
+ })
+}
+
+// --- Hash Tests ---
+
+func TestHash(t *testing.T) {
+ s, _ := New()
+
+ t.Run("LTHN hash", func(t *testing.T) {
+ hash := s.Hash(LTHN, "hello")
+ assert.NotEmpty(t, hash)
+ // LTHN hash should be consistent
+ hash2 := s.Hash(LTHN, "hello")
+ assert.Equal(t, hash, hash2)
+ })
+
+ t.Run("SHA512 hash", func(t *testing.T) {
+ hash := s.Hash(SHA512, "hello")
+ // Known SHA512 hash for "hello"
+ expected := "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"
+ assert.Equal(t, expected, hash)
+ })
+
+ t.Run("SHA256 hash", func(t *testing.T) {
+ hash := s.Hash(SHA256, "hello")
+ // Known SHA256 hash for "hello"
+ expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
+ assert.Equal(t, expected, hash)
+ })
+
+ t.Run("SHA1 hash", func(t *testing.T) {
+ hash := s.Hash(SHA1, "hello")
+ // Known SHA1 hash for "hello"
+ expected := "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"
+ assert.Equal(t, expected, hash)
+ })
+
+ t.Run("MD5 hash", func(t *testing.T) {
+ hash := s.Hash(MD5, "hello")
+ // Known MD5 hash for "hello"
+ expected := "5d41402abc4b2a76b9719d911017c592"
+ assert.Equal(t, expected, hash)
+ })
+
+ t.Run("default falls back to SHA256", func(t *testing.T) {
+ hash := s.Hash("unknown", "hello")
+ sha256Hash := s.Hash(SHA256, "hello")
+ assert.Equal(t, sha256Hash, hash)
+ })
+
+ t.Run("empty string hash", func(t *testing.T) {
+ hash := s.Hash(SHA256, "")
+ // Known SHA256 hash for empty string
+ expected := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ assert.Equal(t, expected, hash)
+ })
+
+ t.Run("hash with special characters", func(t *testing.T) {
+ hash := s.Hash(SHA256, "hello!@#$%^&*()")
+ assert.NotEmpty(t, hash)
+ assert.Len(t, hash, 64) // SHA256 produces 64 hex chars
+ })
+
+ t.Run("hash with unicode", func(t *testing.T) {
+ hash := s.Hash(SHA256, "你好世界")
+ assert.NotEmpty(t, hash)
+ assert.Len(t, hash, 64)
+ })
+
+ t.Run("hash consistency", func(t *testing.T) {
+ payload := "test payload for consistency check"
+ for _, hashType := range []HashType{LTHN, SHA512, SHA256, SHA1, MD5} {
+ hash1 := s.Hash(hashType, payload)
+ hash2 := s.Hash(hashType, payload)
+ assert.Equal(t, hash1, hash2, "Hash should be consistent for %s", hashType)
+ }
+ })
+}
+
+// --- Luhn Tests ---
+
+func TestLuhn(t *testing.T) {
+ s, _ := New()
+
+ t.Run("valid Luhn numbers", func(t *testing.T) {
+ validNumbers := []string{
+ "79927398713",
+ "4532015112830366", // Visa test number
+ "6011514433546201", // Discover test number
+ "371449635398431", // Amex test number
+ "30569309025904", // Diners Club test number
+ }
+ for _, num := range validNumbers {
+ assert.True(t, s.Luhn(num), "Expected %s to be valid", num)
+ }
+ })
+
+ t.Run("invalid Luhn numbers", func(t *testing.T) {
+ invalidNumbers := []string{
+ "79927398714",
+ "1234567890",
+ "1111111111",
+ "1234567891",
+ }
+ for _, num := range invalidNumbers {
+ assert.False(t, s.Luhn(num), "Expected %s to be invalid", num)
+ }
+ })
+
+ t.Run("all zeros is valid", func(t *testing.T) {
+ // All zeros: each digit contributes 0, sum=0, 0%10==0
+ assert.True(t, s.Luhn("0000000000"))
+ })
+
+ t.Run("handles spaces", func(t *testing.T) {
+ // Same number with and without spaces should give same result
+ assert.True(t, s.Luhn("7992 7398 713"))
+ assert.True(t, s.Luhn("4532 0151 1283 0366"))
+ })
+
+ t.Run("non-digit characters return false", func(t *testing.T) {
+ assert.False(t, s.Luhn("1234abcd5678"))
+ assert.False(t, s.Luhn("12-34-56-78"))
+ assert.False(t, s.Luhn("1234.5678"))
+ })
+
+ t.Run("empty string", func(t *testing.T) {
+ // Empty string: sum=0, 0%10==0, so it returns true
+ assert.True(t, s.Luhn(""))
+ })
+
+ t.Run("single digit", func(t *testing.T) {
+ assert.True(t, s.Luhn("0"))
+ assert.False(t, s.Luhn("1"))
+ })
+}
+
+// --- Fletcher Checksum Tests ---
+
+func TestFletcher16(t *testing.T) {
+ s, _ := New()
+
+ t.Run("basic checksum", func(t *testing.T) {
+ checksum := s.Fletcher16("hello")
+ assert.NotZero(t, checksum)
+ })
+
+ t.Run("empty string", func(t *testing.T) {
+ checksum := s.Fletcher16("")
+ assert.Equal(t, uint16(0), checksum)
+ })
+
+ t.Run("consistency", func(t *testing.T) {
+ checksum1 := s.Fletcher16("test data")
+ checksum2 := s.Fletcher16("test data")
+ assert.Equal(t, checksum1, checksum2)
+ })
+
+ t.Run("different inputs produce different checksums", func(t *testing.T) {
+ checksum1 := s.Fletcher16("hello")
+ checksum2 := s.Fletcher16("world")
+ assert.NotEqual(t, checksum1, checksum2)
+ })
+
+ t.Run("known value", func(t *testing.T) {
+ // "abcde" has a known Fletcher-16 checksum
+ checksum := s.Fletcher16("abcde")
+ assert.NotZero(t, checksum)
+ })
+}
+
+func TestFletcher32(t *testing.T) {
+ s, _ := New()
+
+ t.Run("basic checksum", func(t *testing.T) {
+ checksum := s.Fletcher32("hello")
+ assert.NotZero(t, checksum)
+ })
+
+ t.Run("empty string", func(t *testing.T) {
+ checksum := s.Fletcher32("")
+ assert.Equal(t, uint32(0), checksum)
+ })
+
+ t.Run("consistency", func(t *testing.T) {
+ checksum1 := s.Fletcher32("test data")
+ checksum2 := s.Fletcher32("test data")
+ assert.Equal(t, checksum1, checksum2)
+ })
+
+ t.Run("different inputs produce different checksums", func(t *testing.T) {
+ checksum1 := s.Fletcher32("hello")
+ checksum2 := s.Fletcher32("world")
+ assert.NotEqual(t, checksum1, checksum2)
+ })
+
+ t.Run("handles odd-length input", func(t *testing.T) {
+ // Odd length input should be padded
+ checksum := s.Fletcher32("abc")
+ assert.NotZero(t, checksum)
+ })
+
+ t.Run("handles even-length input", func(t *testing.T) {
+ checksum := s.Fletcher32("abcd")
+ assert.NotZero(t, checksum)
+ })
+}
+
+func TestFletcher64(t *testing.T) {
+ s, _ := New()
+
+ t.Run("basic checksum", func(t *testing.T) {
+ checksum := s.Fletcher64("hello")
+ assert.NotZero(t, checksum)
+ })
+
+ t.Run("empty string", func(t *testing.T) {
+ checksum := s.Fletcher64("")
+ assert.Equal(t, uint64(0), checksum)
+ })
+
+ t.Run("consistency", func(t *testing.T) {
+ checksum1 := s.Fletcher64("test data")
+ checksum2 := s.Fletcher64("test data")
+ assert.Equal(t, checksum1, checksum2)
+ })
+
+ t.Run("different inputs produce different checksums", func(t *testing.T) {
+ checksum1 := s.Fletcher64("hello")
+ checksum2 := s.Fletcher64("world")
+ assert.NotEqual(t, checksum1, checksum2)
+ })
+
+ t.Run("handles various input lengths", func(t *testing.T) {
+ // Test padding for different lengths
+ for i := 1; i <= 8; i++ {
+ input := string(make([]byte, i))
+ checksum := s.Fletcher64(input)
+ // Just verify it doesn't panic
+ _ = checksum
+ }
+ })
+
+ t.Run("long input", func(t *testing.T) {
+ // Use actual text content, not null bytes
+ longInput := ""
+ for i := 0; i < 100; i++ {
+ longInput += "test data "
+ }
+ checksum := s.Fletcher64(longInput)
+ assert.NotZero(t, checksum)
+ })
+}
+
+// --- HashType Constants Tests ---
+
+func TestHashTypeConstants(t *testing.T) {
+ t.Run("constants have expected values", func(t *testing.T) {
+ assert.Equal(t, HashType("lthn"), LTHN)
+ assert.Equal(t, HashType("sha512"), SHA512)
+ assert.Equal(t, HashType("sha256"), SHA256)
+ assert.Equal(t, HashType("sha1"), SHA1)
+ assert.Equal(t, HashType("md5"), MD5)
+ })
+}
+
+// --- PGP Tests (basic, detailed tests in openpgp package) ---
+
+func TestEncryptPGP(t *testing.T) {
+ t.Run("requires valid key paths", func(t *testing.T) {
+ s, _ := New()
+ var buf bytes.Buffer
+
+ // Should fail with invalid path
+ _, err := s.EncryptPGP(&buf, "/nonexistent/path", "test data", nil, nil)
+ assert.Error(t, err)
+ })
+}
+
+func TestDecryptPGP(t *testing.T) {
+ t.Run("requires valid key paths", func(t *testing.T) {
+ s, _ := New()
+
+ // Should fail with invalid path
+ _, err := s.DecryptPGP("/nonexistent/path", "encrypted data", "passphrase", nil)
+ assert.Error(t, err)
+ })
+}
+
+// --- HandleIPCEvents Tests ---
+
+func TestHandleIPCEvents(t *testing.T) {
+ t.Run("handles ActionServiceStartup", func(t *testing.T) {
+ coreInstance, err := core.New()
+ require.NoError(t, err)
+
+ serviceAny, err := Register(coreInstance)
+ require.NoError(t, err)
+
+ s := serviceAny.(*Service)
+ err = s.HandleIPCEvents(coreInstance, core.ActionServiceStartup{})
+ assert.NoError(t, err)
+ })
+
+ t.Run("handles unknown message type", func(t *testing.T) {
+ coreInstance, err := core.New()
+ require.NoError(t, err)
+
+ serviceAny, err := Register(coreInstance)
+ require.NoError(t, err)
+
+ s := serviceAny.(*Service)
+ // Pass an arbitrary type as unknown message
+ err = s.HandleIPCEvents(coreInstance, "unknown message")
+ assert.NoError(t, err)
+ })
}
diff --git a/pkg/display/assets/apptray.png b/pkg/display/assets/apptray.png
new file mode 100644
index 0000000..0778fc6
Binary files /dev/null and b/pkg/display/assets/apptray.png differ
diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go
index 9f135f0..b70b956 100644
--- a/pkg/display/display_test.go
+++ b/pkg/display/display_test.go
@@ -6,39 +6,298 @@ import (
"github.com/Snider/Core/pkg/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/wailsapp/wails/v3/pkg/application"
)
// newTestCore creates a new core instance with essential services for testing.
func newTestCore(t *testing.T) *core.Core {
- // We need a real wails app for the display service to function.
- // This setup will be more complex than for other services.
- // For now, we can use a simplified core instance.
coreInstance, err := core.New()
require.NoError(t, err)
return coreInstance
}
func TestNew(t *testing.T) {
- service, err := New()
- assert.NoError(t, err)
- assert.NotNil(t, service, "New() should return a non-nil service instance")
+ t.Run("creates service successfully", func(t *testing.T) {
+ service, err := New()
+ assert.NoError(t, err)
+ assert.NotNil(t, service, "New() should return a non-nil service instance")
+ })
+
+ t.Run("returns independent instances", func(t *testing.T) {
+ service1, err1 := New()
+ service2, err2 := New()
+ assert.NoError(t, err1)
+ assert.NoError(t, err2)
+ assert.NotSame(t, service1, service2, "New() should return different instances")
+ })
}
func TestRegister(t *testing.T) {
- coreInstance := newTestCore(t)
- service, err := Register(coreInstance)
- require.NoError(t, err)
- assert.NotNil(t, service, "Register() should return a non-nil service instance")
-}
+ t.Run("registers with core successfully", func(t *testing.T) {
+ coreInstance := newTestCore(t)
+ service, err := Register(coreInstance)
+ require.NoError(t, err)
+ assert.NotNil(t, service, "Register() should return a non-nil service instance")
+ })
-func TestOpenWindow(t *testing.T) {
- // This test is complex to set up properly without a running Wails application.
- // A true functional test would require a more elaborate test harness that
- // can initialize the Wails runtime.
+ t.Run("returns Service type", func(t *testing.T) {
+ coreInstance := newTestCore(t)
+ service, err := Register(coreInstance)
+ require.NoError(t, err)
- // For now, we can perform a basic smoke test.
- t.Run("basic window open smoke test", func(t *testing.T) {
- // Skipping this test for now as it requires a running app instance.
- t.Skip("Skipping OpenWindow test as it requires a running Wails application instance.")
+ displayService, ok := service.(*Service)
+ assert.True(t, ok, "Register() should return *Service type")
+ assert.NotNil(t, displayService.Runtime, "Runtime should be initialized")
+ })
+}
+
+func TestServiceName(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ name := service.ServiceName()
+ assert.Equal(t, "github.com/Snider/Core/display", name)
+}
+
+// --- Window Option Tests ---
+
+func TestWindowName(t *testing.T) {
+ t.Run("sets window name", func(t *testing.T) {
+ opt := WindowName("test-window")
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, "test-window", window.Name)
+ })
+
+ t.Run("sets empty name", func(t *testing.T) {
+ opt := WindowName("")
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, "", window.Name)
+ })
+}
+
+func TestWindowTitle(t *testing.T) {
+ t.Run("sets window title", func(t *testing.T) {
+ opt := WindowTitle("My Application")
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, "My Application", window.Title)
+ })
+
+ t.Run("sets title with special characters", func(t *testing.T) {
+ opt := WindowTitle("App - v1.0 (Beta)")
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, "App - v1.0 (Beta)", window.Title)
+ })
+}
+
+func TestWindowURL(t *testing.T) {
+ t.Run("sets window URL", func(t *testing.T) {
+ opt := WindowURL("/dashboard")
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, "/dashboard", window.URL)
+ })
+
+ t.Run("sets full URL", func(t *testing.T) {
+ opt := WindowURL("https://example.com/page")
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, "https://example.com/page", window.URL)
+ })
+}
+
+func TestWindowWidth(t *testing.T) {
+ t.Run("sets window width", func(t *testing.T) {
+ opt := WindowWidth(1024)
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, 1024, window.Width)
+ })
+
+ t.Run("sets zero width", func(t *testing.T) {
+ opt := WindowWidth(0)
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, window.Width)
+ })
+
+ t.Run("sets large width", func(t *testing.T) {
+ opt := WindowWidth(3840)
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, 3840, window.Width)
+ })
+}
+
+func TestWindowHeight(t *testing.T) {
+ t.Run("sets window height", func(t *testing.T) {
+ opt := WindowHeight(768)
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, 768, window.Height)
+ })
+
+ t.Run("sets zero height", func(t *testing.T) {
+ opt := WindowHeight(0)
+ window := &Window{}
+
+ err := opt(window)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, window.Height)
+ })
+}
+
+func TestApplyOptions(t *testing.T) {
+ t.Run("applies no options", func(t *testing.T) {
+ window := applyOptions()
+ assert.NotNil(t, window)
+ assert.Equal(t, "", window.Name)
+ assert.Equal(t, "", window.Title)
+ assert.Equal(t, 0, window.Width)
+ assert.Equal(t, 0, window.Height)
+ })
+
+ t.Run("applies single option", func(t *testing.T) {
+ window := applyOptions(WindowTitle("Test"))
+ assert.NotNil(t, window)
+ assert.Equal(t, "Test", window.Title)
+ })
+
+ t.Run("applies multiple options", func(t *testing.T) {
+ window := applyOptions(
+ WindowName("main"),
+ WindowTitle("My App"),
+ WindowURL("/home"),
+ WindowWidth(1280),
+ WindowHeight(720),
+ )
+
+ assert.NotNil(t, window)
+ assert.Equal(t, "main", window.Name)
+ assert.Equal(t, "My App", window.Title)
+ assert.Equal(t, "/home", window.URL)
+ assert.Equal(t, 1280, window.Width)
+ assert.Equal(t, 720, window.Height)
+ })
+
+ t.Run("handles nil options slice", func(t *testing.T) {
+ window := applyOptions(nil...)
+ assert.NotNil(t, window)
+ })
+
+ t.Run("applies options in order", func(t *testing.T) {
+ // Later options should override earlier ones
+ window := applyOptions(
+ WindowTitle("First"),
+ WindowTitle("Second"),
+ )
+
+ assert.NotNil(t, window)
+ assert.Equal(t, "Second", window.Title)
+ })
+}
+
+// --- ActionOpenWindow Tests ---
+
+func TestActionOpenWindow(t *testing.T) {
+ t.Run("creates action with options", func(t *testing.T) {
+ action := ActionOpenWindow{
+ WebviewWindowOptions: application.WebviewWindowOptions{
+ Name: "test",
+ Title: "Test Window",
+ Width: 800,
+ Height: 600,
+ },
+ }
+
+ assert.Equal(t, "test", action.Name)
+ assert.Equal(t, "Test Window", action.Title)
+ assert.Equal(t, 800, action.Width)
+ assert.Equal(t, 600, action.Height)
+ })
+}
+
+// --- Integration Tests (require Wails runtime) ---
+
+func TestOpenWindow(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping OpenWindow test - requires running Wails application instance")
+ })
+}
+
+func TestNewWithStruct(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping NewWithStruct test - requires running Wails application instance")
+ })
+}
+
+func TestNewWithOptions(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping NewWithOptions test - requires running Wails application instance")
+ })
+}
+
+func TestNewWithURL(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping NewWithURL test - requires running Wails application instance")
+ })
+}
+
+func TestServiceStartup(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping ServiceStartup test - requires running Wails application instance")
+ })
+}
+
+func TestSelectDirectory(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping SelectDirectory test - requires running Wails application instance")
+ })
+}
+
+func TestShowEnvironmentDialog(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping ShowEnvironmentDialog test - requires running Wails application instance")
+ })
+}
+
+func TestHandleIPCEvents(t *testing.T) {
+ t.Run("requires Wails runtime for full test", func(t *testing.T) {
+ t.Skip("Skipping HandleIPCEvents test - requires running Wails application instance")
+ })
+}
+
+func TestBuildMenu(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping buildMenu test - requires running Wails application instance")
+ })
+}
+
+func TestSystemTray(t *testing.T) {
+ t.Run("requires Wails runtime", func(t *testing.T) {
+ t.Skip("Skipping systemTray test - requires running Wails application instance")
})
}
diff --git a/pkg/display/menu.go b/pkg/display/menu.go
index 63bb0d1..b1238d1 100644
--- a/pkg/display/menu.go
+++ b/pkg/display/menu.go
@@ -1,7 +1,9 @@
package display
import (
+ "fmt"
"runtime"
+ "strings"
"github.com/wailsapp/wails/v3/pkg/application"
)
@@ -17,8 +19,12 @@ func (s *Service) buildMenu() {
appMenu.AddRole(application.EditMenu)
workspace := appMenu.AddSubmenu("Workspace")
- workspace.Add("New").OnClick(func(ctx *application.Context) { /* TODO */ })
- workspace.Add("List").OnClick(func(ctx *application.Context) { /* TODO */ })
+ workspace.Add("New...").OnClick(func(ctx *application.Context) {
+ s.handleNewWorkspace()
+ })
+ workspace.Add("List").OnClick(func(ctx *application.Context) {
+ s.handleListWorkspaces()
+ })
// Add brand-specific menu items
//if s.brand == DeveloperHub {
@@ -30,3 +36,56 @@ func (s *Service) buildMenu() {
s.Core().App.Menu.Set(appMenu)
}
+
+// handleNewWorkspace opens a window for creating a new workspace.
+func (s *Service) handleNewWorkspace() {
+ // Open a dedicated window for workspace creation
+ // The frontend at /workspace/new handles the form
+ opts := application.WebviewWindowOptions{
+ Name: "workspace-new",
+ Title: "New Workspace",
+ Width: 500,
+ Height: 400,
+ URL: "/workspace/new",
+ }
+ s.Core().App.Window.NewWithOptions(opts)
+}
+
+// handleListWorkspaces shows a dialog with available workspaces.
+func (s *Service) handleListWorkspaces() {
+ // Get workspace service from core
+ ws := s.Core().Service("workspace")
+ if ws == nil {
+ dialog := s.Core().App.Dialog.Warning()
+ dialog.SetTitle("Workspace")
+ dialog.SetMessage("Workspace service not available")
+ dialog.Show()
+ return
+ }
+
+ // Type assert to access ListWorkspaces method
+ lister, ok := ws.(interface{ ListWorkspaces() []string })
+ if !ok {
+ dialog := s.Core().App.Dialog.Warning()
+ dialog.SetTitle("Workspace")
+ dialog.SetMessage("Unable to list workspaces")
+ dialog.Show()
+ return
+ }
+
+ workspaces := lister.ListWorkspaces()
+
+ var message string
+ if len(workspaces) == 0 {
+ message = "No workspaces found.\n\nUse Workspace → New to create one."
+ } else {
+ message = fmt.Sprintf("Available Workspaces (%d):\n\n%s",
+ len(workspaces),
+ strings.Join(workspaces, "\n"))
+ }
+
+ dialog := s.Core().App.Dialog.Info()
+ dialog.SetTitle("Workspaces")
+ dialog.SetMessage(message)
+ dialog.Show()
+}
diff --git a/pkg/display/tray.go b/pkg/display/tray.go
index b96f2ef..697bb63 100644
--- a/pkg/display/tray.go
+++ b/pkg/display/tray.go
@@ -1,26 +1,33 @@
package display
import (
- _ "embed"
+ "embed"
+ "runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
-// setupTray configures and creates the system tray icon and menu.
+//go:embed assets/apptray.png
+var assets embed.FS
+
+// systemTray configures and creates the system tray icon and menu.
func (s *Service) systemTray() {
systray := s.Core().App.SystemTray.New()
systray.SetTooltip("Core")
systray.SetLabel("Core")
- //appTrayIcon, _ := d.assets.ReadFile("assets/apptray.png")
- //
- //if runtime.GOOS == "darwin" {
- // systray.SetTemplateIcon(appTrayIcon)
- //} else {
- // // Support for light/dark mode icons
- // systray.SetDarkModeIcon(appTrayIcon)
- // systray.SetIcon(appTrayIcon)
- //}
+
+ // Load and set tray icon
+ appTrayIcon, err := assets.ReadFile("assets/apptray.png")
+ if err == nil {
+ if runtime.GOOS == "darwin" {
+ systray.SetTemplateIcon(appTrayIcon)
+ } else {
+ // Support for light/dark mode icons
+ systray.SetDarkModeIcon(appTrayIcon)
+ systray.SetIcon(appTrayIcon)
+ }
+ }
// Create a hidden window for the system tray menu to interact with
trayWindow, _ := s.NewWithStruct(&Window{
Name: "system-tray",
diff --git a/pkg/display/window.go b/pkg/display/window.go
index 6dc0a8a..fe788e5 100644
--- a/pkg/display/window.go
+++ b/pkg/display/window.go
@@ -87,7 +87,3 @@ func (s *Service) SelectDirectory() (string, error) {
dialog.SetTitle("Select Project Directory")
return dialog.PromptForSingleSelection()
}
-
-var instance *Window
-
-func (s *Service) Window() *Window { return instance }
diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go
new file mode 100644
index 0000000..a3889a0
--- /dev/null
+++ b/pkg/i18n/i18n_test.go
@@ -0,0 +1,185 @@
+package i18n
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/text/language"
+)
+
+func TestNew(t *testing.T) {
+ t.Run("creates service successfully", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+ assert.NotNil(t, service)
+ assert.NotNil(t, service.bundle)
+ assert.NotEmpty(t, service.availableLangs)
+ })
+
+ t.Run("loads all available languages", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ // Should have loaded multiple languages from locales/
+ assert.GreaterOrEqual(t, len(service.availableLangs), 2)
+ })
+}
+
+func TestSetLanguage(t *testing.T) {
+ t.Run("sets English successfully", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ err = service.SetLanguage("en")
+ assert.NoError(t, err)
+ assert.NotNil(t, service.localizer)
+ })
+
+ t.Run("sets Spanish successfully", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ err = service.SetLanguage("es")
+ assert.NoError(t, err)
+ assert.NotNil(t, service.localizer)
+ })
+
+ t.Run("sets German successfully", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ err = service.SetLanguage("de")
+ assert.NoError(t, err)
+ assert.NotNil(t, service.localizer)
+ })
+
+ t.Run("handles language variants", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ // en-US should match to en
+ err = service.SetLanguage("en-US")
+ assert.NoError(t, err)
+ })
+
+ t.Run("handles unknown language by matching closest", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ // Unknown languages may fall back to a default match
+ // The matcher uses confidence levels, so many tags will match something
+ err = service.SetLanguage("tlh") // Klingon
+ // May or may not error depending on matcher confidence
+ if err != nil {
+ assert.Contains(t, err.Error(), "unsupported language")
+ }
+ })
+}
+
+func TestTranslate(t *testing.T) {
+ t.Run("translates English message", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+ require.NoError(t, service.SetLanguage("en"))
+
+ result := service.Translate("menu.settings")
+ assert.Equal(t, "Settings", result)
+ })
+
+ t.Run("translates Spanish message", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+ require.NoError(t, service.SetLanguage("es"))
+
+ result := service.Translate("menu.settings")
+ assert.Equal(t, "Ajustes", result)
+ })
+
+ t.Run("returns message ID for missing translation", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+ require.NoError(t, service.SetLanguage("en"))
+
+ result := service.Translate("nonexistent.message.id")
+ assert.Equal(t, "nonexistent.message.id", result)
+ })
+
+ t.Run("translates multiple messages correctly", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+ require.NoError(t, service.SetLanguage("en"))
+
+ assert.Equal(t, "Dashboard", service.Translate("menu.dashboard"))
+ assert.Equal(t, "Help", service.Translate("menu.help"))
+ assert.Equal(t, "Search", service.Translate("app.core.ui.search"))
+ })
+
+ t.Run("language switch changes translations", func(t *testing.T) {
+ service, err := New()
+ require.NoError(t, err)
+
+ // Start with English
+ require.NoError(t, service.SetLanguage("en"))
+ assert.Equal(t, "Search", service.Translate("app.core.ui.search"))
+
+ // Switch to Spanish
+ require.NoError(t, service.SetLanguage("es"))
+ assert.Equal(t, "Buscar", service.Translate("app.core.ui.search"))
+
+ // Switch back to English
+ require.NoError(t, service.SetLanguage("en"))
+ assert.Equal(t, "Search", service.Translate("app.core.ui.search"))
+ })
+}
+
+func TestGetAvailableLanguages(t *testing.T) {
+ t.Run("returns available languages", func(t *testing.T) {
+ langs, err := getAvailableLanguages()
+ require.NoError(t, err)
+ assert.NotEmpty(t, langs)
+
+ // Should include at least English
+ langStrings := make([]string, len(langs))
+ for i, l := range langs {
+ langStrings[i] = l.String()
+ }
+ assert.Contains(t, langStrings, "en")
+ })
+}
+
+func TestDetectLanguage(t *testing.T) {
+ t.Run("returns empty for empty LANG env", func(t *testing.T) {
+ // Save and clear LANG
+ t.Setenv("LANG", "")
+
+ service, err := New()
+ require.NoError(t, err)
+
+ detected, err := detectLanguage(service.availableLangs)
+ assert.NoError(t, err)
+ assert.Empty(t, detected)
+ })
+
+ t.Run("returns empty for empty supported list", func(t *testing.T) {
+ t.Setenv("LANG", "en_US.UTF-8")
+
+ detected, err := detectLanguage([]language.Tag{})
+ assert.NoError(t, err)
+ assert.Empty(t, detected)
+ })
+
+ t.Run("detects language from LANG env", func(t *testing.T) {
+ t.Setenv("LANG", "es_ES.UTF-8")
+
+ service, err := New()
+ require.NoError(t, err)
+
+ detected, err := detectLanguage(service.availableLangs)
+ assert.NoError(t, err)
+ // Should detect Spanish or a close variant
+ if detected != "" {
+ assert.Contains(t, detected, "es")
+ }
+ })
+}
diff --git a/pkg/io/client_test.go b/pkg/io/client_test.go
index ad1f4d9..4434420 100644
--- a/pkg/io/client_test.go
+++ b/pkg/io/client_test.go
@@ -6,6 +6,77 @@ import (
"github.com/stretchr/testify/assert"
)
+// --- MockMedium Tests ---
+
+func TestNewMockMedium(t *testing.T) {
+ m := NewMockMedium()
+ assert.NotNil(t, m)
+ assert.NotNil(t, m.Files)
+ assert.NotNil(t, m.Dirs)
+ assert.Empty(t, m.Files)
+ assert.Empty(t, m.Dirs)
+}
+
+func TestMockMedium_Read(t *testing.T) {
+ t.Run("reads existing file", func(t *testing.T) {
+ m := NewMockMedium()
+ m.Files["test.txt"] = "hello world"
+ content, err := m.Read("test.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello world", content)
+ })
+
+ t.Run("returns error for non-existent file", func(t *testing.T) {
+ m := NewMockMedium()
+ _, err := m.Read("nonexistent.txt")
+ assert.Error(t, err)
+ })
+}
+
+func TestMockMedium_Write(t *testing.T) {
+ m := NewMockMedium()
+ err := m.Write("test.txt", "content")
+ assert.NoError(t, err)
+ assert.Equal(t, "content", m.Files["test.txt"])
+
+ // Overwrite existing file
+ err = m.Write("test.txt", "new content")
+ assert.NoError(t, err)
+ assert.Equal(t, "new content", m.Files["test.txt"])
+}
+
+func TestMockMedium_EnsureDir(t *testing.T) {
+ m := NewMockMedium()
+ err := m.EnsureDir("/path/to/dir")
+ assert.NoError(t, err)
+ assert.True(t, m.Dirs["/path/to/dir"])
+}
+
+func TestMockMedium_IsFile(t *testing.T) {
+ m := NewMockMedium()
+ m.Files["exists.txt"] = "content"
+
+ assert.True(t, m.IsFile("exists.txt"))
+ assert.False(t, m.IsFile("nonexistent.txt"))
+}
+
+func TestMockMedium_FileGet(t *testing.T) {
+ m := NewMockMedium()
+ m.Files["test.txt"] = "content"
+ content, err := m.FileGet("test.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "content", content)
+}
+
+func TestMockMedium_FileSet(t *testing.T) {
+ m := NewMockMedium()
+ err := m.FileSet("test.txt", "content")
+ assert.NoError(t, err)
+ assert.Equal(t, "content", m.Files["test.txt"])
+}
+
+// --- Wrapper Function Tests ---
+
func TestRead(t *testing.T) {
m := NewMockMedium()
m.Files["test.txt"] = "hello"
@@ -21,11 +92,55 @@ func TestWrite(t *testing.T) {
assert.Equal(t, "hello", m.Files["test.txt"])
}
-func TestCopy(t *testing.T) {
- source := NewMockMedium()
- dest := NewMockMedium()
- source.Files["test.txt"] = "hello"
- err := Copy(source, "test.txt", dest, "test.txt")
+func TestEnsureDir(t *testing.T) {
+ m := NewMockMedium()
+ err := EnsureDir(m, "/my/dir")
assert.NoError(t, err)
- assert.Equal(t, "hello", dest.Files["test.txt"])
+ assert.True(t, m.Dirs["/my/dir"])
+}
+
+func TestIsFile(t *testing.T) {
+ m := NewMockMedium()
+ m.Files["exists.txt"] = "content"
+
+ assert.True(t, IsFile(m, "exists.txt"))
+ assert.False(t, IsFile(m, "nonexistent.txt"))
+}
+
+func TestCopy(t *testing.T) {
+ t.Run("copies file between mediums", func(t *testing.T) {
+ source := NewMockMedium()
+ dest := NewMockMedium()
+ source.Files["test.txt"] = "hello"
+ err := Copy(source, "test.txt", dest, "test.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", dest.Files["test.txt"])
+ })
+
+ t.Run("copies to different path", func(t *testing.T) {
+ source := NewMockMedium()
+ dest := NewMockMedium()
+ source.Files["original.txt"] = "content"
+ err := Copy(source, "original.txt", dest, "copied.txt")
+ assert.NoError(t, err)
+ assert.Equal(t, "content", dest.Files["copied.txt"])
+ })
+
+ t.Run("returns error for non-existent source", func(t *testing.T) {
+ source := NewMockMedium()
+ dest := NewMockMedium()
+ err := Copy(source, "nonexistent.txt", dest, "dest.txt")
+ assert.Error(t, err)
+ })
+}
+
+// --- Local Global Tests ---
+
+func TestLocalGlobal(t *testing.T) {
+ // io.Local should be initialized by init()
+ assert.NotNil(t, Local, "io.Local should be initialized")
+
+ // Should be able to use it as a Medium
+ var m Medium = Local
+ assert.NotNil(t, m)
}
diff --git a/pkg/io/io.go b/pkg/io/io.go
index df02342..d268586 100644
--- a/pkg/io/io.go
+++ b/pkg/io/io.go
@@ -1,5 +1,9 @@
package io
+import (
+ "github.com/Snider/Core/pkg/io/local"
+)
+
// Medium defines the standard interface for a storage backend.
// This allows for different implementations (e.g., local disk, S3, SFTP)
// to be used interchangeably.
@@ -23,5 +27,15 @@ type Medium interface {
FileSet(path, content string) error
}
-// Pre-initialized, sandboxed medium for the local filesystem.
+// Local is a pre-initialized medium for the local filesystem.
+// It uses "/" as root, providing unsandboxed access to the filesystem.
+// For sandboxed access, create a new local.Medium with a specific root path.
var Local Medium
+
+func init() {
+ var err error
+ Local, err = local.New("/")
+ if err != nil {
+ panic("io: failed to initialize Local medium: " + err.Error())
+ }
+}
diff --git a/pkg/io/local/client_test.go b/pkg/io/local/client_test.go
index ff3dce7..329eba5 100644
--- a/pkg/io/local/client_test.go
+++ b/pkg/io/local/client_test.go
@@ -152,3 +152,43 @@ func TestIsFile(t *testing.T) {
// Test with path traversal attempt
assert.False(t, medium.IsFile("../bad_file.txt"))
}
+
+func TestFileGetFileSet(t *testing.T) {
+ testRoot, err := os.MkdirTemp("", "local_fileget_fileset_test")
+ assert.NoError(t, err)
+ defer os.RemoveAll(testRoot)
+
+ medium, err := New(testRoot)
+ assert.NoError(t, err)
+
+ fileName := "data.txt"
+ content := "Hello, FileGet/FileSet!"
+
+ // Test FileSet
+ err = medium.FileSet(fileName, content)
+ assert.NoError(t, err)
+
+ // Verify file was written
+ readContent, err := os.ReadFile(filepath.Join(testRoot, fileName))
+ assert.NoError(t, err)
+ assert.Equal(t, content, string(readContent))
+
+ // Test FileGet
+ gotContent, err := medium.FileGet(fileName)
+ assert.NoError(t, err)
+ assert.Equal(t, content, gotContent)
+
+ // Test FileGet on non-existent file
+ _, err = medium.FileGet("nonexistent.txt")
+ assert.Error(t, err)
+
+ // Test FileSet with path traversal attempt
+ err = medium.FileSet("../bad.txt", "malicious")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+
+ // Test FileGet with path traversal attempt
+ _, err = medium.FileGet("../bad.txt")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "path traversal attempt detected")
+}
diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go
index 85cab14..0f492f7 100644
--- a/pkg/runtime/runtime.go
+++ b/pkg/runtime/runtime.go
@@ -9,6 +9,7 @@ import (
"github.com/Snider/Core/pkg/display"
"github.com/Snider/Core/pkg/help"
"github.com/Snider/Core/pkg/i18n"
+ "github.com/Snider/Core/pkg/io"
"github.com/Snider/Core/pkg/workspace"
// Import the ABSTRACT contracts (interfaces).
"github.com/Snider/Core/pkg/core"
@@ -99,6 +100,6 @@ func New() (*Runtime, error) {
"help": func() (any, error) { return help.New() },
"crypt": func() (any, error) { return crypt.New() },
"i18n": func() (any, error) { return i18n.New() },
- "workspace": func() (any, error) { return workspace.New() },
+ "workspace": func() (any, error) { return workspace.New(io.Local) },
})
}
diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go
index 8829051..495c776 100644
--- a/pkg/runtime/runtime_test.go
+++ b/pkg/runtime/runtime_test.go
@@ -10,6 +10,7 @@ import (
"github.com/Snider/Core/pkg/crypt"
"github.com/Snider/Core/pkg/display"
"github.com/Snider/Core/pkg/help"
+ "github.com/Snider/Core/pkg/io"
"github.com/Snider/Core/pkg/workspace"
)
@@ -62,7 +63,7 @@ func TestNewServiceInitializationError(t *testing.T) {
"help": func() (any, error) { return help.New() },
"crypt": func() (any, error) { return crypt.New() },
"i18n": func() (any, error) { return nil, errors.New("i18n service failed to initialize") }, // This factory will fail
- "workspace": func() (any, error) { return workspace.New() },
+ "workspace": func() (any, error) { return workspace.New(io.Local) },
}
runtime, err := newWithFactories(factories)
diff --git a/pkg/workspace/local.go b/pkg/workspace/local.go
deleted file mode 100644
index 27769f7..0000000
--- a/pkg/workspace/local.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package workspace
-
-import "github.com/Snider/Core/pkg/io"
-
-// localMedium implements the Medium interface for the local disk.
-type localMedium struct{}
-
-// NewLocalMedium creates a new instance of the local storage medium.
-func NewLocalMedium() io.Medium {
- return &localMedium{}
-}
-
-// FileGet reads a file from the local disk.
-func (m *localMedium) FileGet(path string) (string, error) {
- return io.Read(io.Local, path)
-}
-
-// FileSet writes a file to the local disk.
-func (m *localMedium) FileSet(path, content string) error {
- return io.Write(io.Local, path, content)
-}
-
-// Read reads a file from the local disk.
-func (m *localMedium) Read(path string) (string, error) {
- return io.Read(io.Local, path)
-}
-
-// Write writes a file to the local disk.
-func (m *localMedium) Write(path, content string) error {
- return io.Write(io.Local, path, content)
-}
-
-// EnsureDir creates a directory on the local disk.
-func (m *localMedium) EnsureDir(path string) error {
- return io.EnsureDir(io.Local, path)
-}
-
-// IsFile checks if a path exists and is a file on the local disk.
-func (m *localMedium) IsFile(path string) bool {
- return io.IsFile(io.Local, path)
-}
diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go
index 8de7b46..f14b0e3 100644
--- a/pkg/workspace/workspace.go
+++ b/pkg/workspace/workspace.go
@@ -10,6 +10,7 @@ import (
"github.com/Snider/Core/pkg/crypt/lthn"
"github.com/Snider/Core/pkg/crypt/openpgp"
"github.com/Snider/Core/pkg/io"
+ "github.com/Snider/Core/pkg/io/local"
"github.com/wailsapp/wails/v3/pkg/application"
)
@@ -46,18 +47,13 @@ func newWorkspaceService() (*Service, error) {
// New is the constructor for static dependency injection.
// It creates a Service instance without initializing the core.Runtime field.
-// Dependencies are passed directly here.
-func New() (*Service, error) {
+// The medium parameter is required for file operations.
+func New(medium io.Medium) (*Service, error) {
s, err := newWorkspaceService()
if err != nil {
return nil, err
}
- //s.medium = medium
- // Initialize the service after creation.
- // Note: ServiceStartup will now get config from s.Runtime.Config()
- //if err := s.ServiceStartup(context.Background(), application.ServiceOptions{}); err != nil {
- // return nil, fmt.Errorf("workspace service startup failed: %w", err)
- //}
+ s.medium = medium
return s, nil
}
@@ -70,6 +66,18 @@ func Register(c *core.Core) (any, error) {
return nil, err
}
s.Runtime = core.NewRuntime(c, Options{})
+
+ // Initialize the local medium for file operations
+ var workspaceDir string
+ if err := c.Config().Get("workspaceDir", &workspaceDir); err != nil {
+ return nil, fmt.Errorf("workspace: failed to get workspaceDir from config: %w", err)
+ }
+ medium, err := local.New(workspaceDir)
+ if err != nil {
+ return nil, fmt.Errorf("workspace: failed to create local medium: %w", err)
+ }
+ s.medium = medium
+
return s, nil
}
@@ -99,25 +107,24 @@ func (s *Service) getWorkspaceDir() (string, error) {
// ServiceStartup initializes the service, loading the workspace list.
func (s *Service) ServiceStartup(context.Context, application.ServiceOptions) error {
- var err error
workspaceDir, err := s.getWorkspaceDir()
if err != nil {
return err
}
+ // Load existing workspace list if it exists
listPath := filepath.Join(workspaceDir, listFile)
- if listPath != "" {
+ if s.medium.IsFile(listPath) {
+ content, err := s.medium.FileGet(listPath)
+ if err != nil {
+ return fmt.Errorf("failed to read workspace list: %w", err)
+ }
+ if err := json.Unmarshal([]byte(content), &s.workspaceList); err != nil {
+ // Log warning but continue with empty list
+ fmt.Printf("Warning: could not parse workspace list: %v\n", err)
+ s.workspaceList = make(map[string]string)
+ }
}
- //if s.medium.IsFile(listPath) {
- // content, err := s.medium.FileGet(listPath)
- // if err != nil {
- // return fmt.Errorf("failed to read workspace list: %w", err)
- // }
- // if err := json.Unmarshal([]byte(content), &s.workspaceList); err != nil {
- // fmt.Printf("Warning: could not parse workspace list: %v\n", err)
- // s.workspaceList = make(map[string]string)
- // }
- //}
return s.SwitchWorkspace(defaultWorkspace)
}
@@ -187,9 +194,9 @@ func (s *Service) SwitchWorkspace(name string) error {
}
path := filepath.Join(workspaceDir, name)
- //if err := s.medium.EnsureDir(path); err != nil {
- // return fmt.Errorf("failed to ensure workspace directory exists: %w", err)
- //}
+ if err := s.medium.EnsureDir(path); err != nil {
+ return fmt.Errorf("failed to ensure workspace directory exists: %w", err)
+ }
s.activeWorkspace = &Workspace{
Name: name,
@@ -216,3 +223,17 @@ func (s *Service) WorkspaceFileSet(filename, content string) error {
path := filepath.Join(s.activeWorkspace.Path, filename)
return s.medium.FileSet(path, content)
}
+
+// ListWorkspaces returns the list of workspace IDs.
+func (s *Service) ListWorkspaces() []string {
+ workspaces := make([]string, 0, len(s.workspaceList))
+ for id := range s.workspaceList {
+ workspaces = append(workspaces, id)
+ }
+ return workspaces
+}
+
+// ActiveWorkspace returns the currently active workspace, or nil if none is active.
+func (s *Service) ActiveWorkspace() *Workspace {
+ return s.activeWorkspace
+}
diff --git a/pkg/workspace/workspace_test.go b/pkg/workspace/workspace_test.go
index 3af293f..2826c1a 100644
--- a/pkg/workspace/workspace_test.go
+++ b/pkg/workspace/workspace_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/Snider/Core/pkg/core"
+ "github.com/Snider/Core/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/wailsapp/wails/v3/pkg/application"
)
@@ -37,64 +38,19 @@ func (m *mockConfig) Set(key string, v any) error {
return nil
}
-// MockMedium implements the Medium interface for testing purposes.
-type MockMedium struct {
- Files map[string]string
- Dirs map[string]bool
-}
-
-func NewMockMedium() *MockMedium {
- return &MockMedium{
- Files: make(map[string]string),
- Dirs: make(map[string]bool),
- }
-}
-
-func (m *MockMedium) FileGet(path string) (string, error) {
- content, ok := m.Files[path]
- if !ok {
- return "", assert.AnError // Simulate file not found error
- }
- return content, nil
-}
-
-func (m *MockMedium) FileSet(path, content string) error {
- m.Files[path] = content
- return nil
-}
-
-func (m *MockMedium) EnsureDir(path string) error {
- m.Dirs[path] = true
- return nil
-}
-
-func (m *MockMedium) IsFile(path string) bool {
- _, exists := m.Files[path]
- return exists
-}
-
-func (m *MockMedium) Read(path string) (string, error) {
- return m.FileGet(path)
-}
-
-func (m *MockMedium) Write(path, content string) error {
- return m.FileSet(path, content)
-}
-
// newTestService creates a workspace service instance with mocked dependencies.
-func newTestService(t *testing.T, workspaceDir string) (*Service, *MockMedium) {
+func newTestService(t *testing.T, workspaceDir string) (*Service, *io.MockMedium) {
coreInstance, err := core.New()
assert.NoError(t, err)
mockCfg := &mockConfig{values: map[string]interface{}{"workspaceDir": workspaceDir}}
coreInstance.RegisterService("config", mockCfg)
- service, err := New()
+ mockMedium := io.NewMockMedium()
+ service, err := New(mockMedium)
assert.NoError(t, err)
service.Runtime = core.NewRuntime(coreInstance, Options{})
- mockMedium := NewMockMedium()
- service.medium = mockMedium
return service, mockMedium
}